diff --git a/.gitignore b/.gitignore
index 87405231..0cfe5283 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,6 +47,8 @@ unit/test-rilmodem-cs
 unit/test-rilmodem-gprs
 unit/test-rilmodem-sms
 unit/test-mbim
+unit/test-provision
+unit/test-provision.db
 unit/test-*.log
 unit/test-*.trs
 test-driver
diff --git a/Makefile.am b/Makefile.am
index 280653f3..79645e76 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -8,10 +8,12 @@ noinst_LTLIBRARIES =
 if EXTERNAL_ELL
 ell_cflags = @ELL_CFLAGS@
 ell_ldadd = @ELL_LIBS@
+ell_dependencies =
 ell_built_sources = ell/shared
 else
 ell_cflags =
 ell_ldadd = ell/libell-internal.la
+ell_dependencies = $(ell_ldadd)
 ell_built_sources = ell/shared ell/internal ell/ell.h
 
 noinst_LTLIBRARIES += ell/libell-internal.la
@@ -736,7 +738,8 @@ endif
 
 AM_CFLAGS = @DBUS_CFLAGS@ @GLIB_CFLAGS@ $(ell_cflags) $(builtin_cflags) \
 					-DOFONO_PLUGIN_BUILTIN \
-					-DPLUGINDIR=\""$(build_plugindir)"\"
+					-DPLUGINDIR=\""$(build_plugindir)"\" \
+					-DUNITDIR=\""$(top_builddir)/unit/"\"
 
 AM_CPPFLAGS = -I$(builddir)/include -I$(builddir)/src -I$(srcdir)/src \
 			-I$(srcdir)/gdbus -I$(srcdir)/gisi -I$(srcdir)/gatchat \
@@ -876,7 +879,9 @@ test_SCRIPTS = $(test_scripts)
 endif
 
 EXTRA_DIST = src/genbuiltin plugins/ofono.rules plugins/ofono-speedup.rules \
-				$(doc_files) $(test_scripts)
+		tools/provisiontool \
+		unit/test-provision.json \
+		$(doc_files) $(test_scripts)
 
 dist_man_MANS = doc/ofonod.8
 
@@ -890,7 +895,8 @@ unit_tests = unit/test-common unit/test-util \
 				unit/test-rilmodem-cs \
 				unit/test-rilmodem-sms \
 				unit/test-rilmodem-cb \
-				unit/test-rilmodem-gprs
+				unit/test-rilmodem-gprs \
+				unit/test-provision
 
 noinst_PROGRAMS = $(unit_tests) \
 			unit/test-sms-root unit/test-mux unit/test-caif
@@ -981,6 +987,17 @@ unit_test_mbim_SOURCES = unit/test-mbim.c \
 unit_test_mbim_LDADD = $(ell_ldadd)
 unit_objects += $(unit_test_mbim_OBJECTS)
 
+unit/test-provision.db: unit/test-provision.json
+	$(AM_V_GEN)$(srcdir)/tools/provisiontool generate \
+		--infile $< --outfile $@
+
+unit_test_provision_SOURCES = unit/test-provision.c \
+				src/provisiondb.h src/provisiondb.c
+unit_test_provision_LDADD = $(ell_ldadd)
+unit_test_provision_DEPENDENCIES = $(ell_dependencies) \
+					unit/test-provision.db
+unit_objects += $(unit_test_provision_OBJECTS)
+
 TESTS = $(unit_tests)
 
 if TOOLS
@@ -1103,4 +1120,4 @@ maintainer-clean-local:
 	-rm -rf build-aux ell
 
 clean-local:
-	@$(RM) -rf include/ofono
+	@$(RM) -rf include/ofono unit/test-provision.db
diff --git a/unit/test-provision.c b/unit/test-provision.c
new file mode 100644
index 00000000..9c3dfad4
--- /dev/null
+++ b/unit/test-provision.c
@@ -0,0 +1,280 @@
+/*
+ *  oFono - Open Source Telephony
+ *  Copyright (C) 2023  Cruise, LLC
+ *
+ *  SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#ifdef HAVE_CONFIG_H
+#include <config.h>
+#endif
+
+#include <stddef.h>
+#include <assert.h>
+#include <errno.h>
+#include <ell/ell.h>
+
+#include <ofono/types.h>
+#include <ofono/gprs-context.h>
+
+#include "provisiondb.h"
+
+static struct provision_db *pdb;
+
+static void null_provision_db(const void *data)
+{
+	struct provision_db_entry *items;
+	size_t n_items;
+	int r;
+
+	r = provision_db_lookup(NULL, "123", "345", NULL, &items, &n_items);
+	assert(r == -EBADF);
+}
+
+static void invalid_mcc_mnc(const void *data)
+{
+	struct provision_db_entry *items;
+	size_t n_items;
+	int r;
+
+	r = provision_db_lookup(pdb, "3444", "33", NULL, &items, &n_items);
+	assert(r == -EINVAL);
+
+	r = provision_db_lookup(pdb, "3ab", "33", NULL, &items, &n_items);
+	assert(r == -EINVAL);
+
+	r = provision_db_lookup(pdb, "333", "3", NULL, &items, &n_items);
+	assert(r == -EINVAL);
+
+	r = provision_db_lookup(pdb, "333", "3334", NULL, &items, &n_items);
+	assert(r == -EINVAL);
+}
+
+struct provision_test {
+	const char *mcc;
+	const char *mnc;
+	const char *spn;
+	int result;
+	size_t n_items;
+	const struct provision_db_entry *items;
+};
+
+static const struct provision_db_entry alpha_contexts[] = {
+	{
+		.name = "Internet",
+		.type = OFONO_GPRS_CONTEXT_TYPE_INTERNET,
+		.proto = OFONO_GPRS_PROTO_IP,
+		.apn = "internet",
+		.auth_method = OFONO_GPRS_AUTH_METHOD_NONE,
+	},
+	{
+		.name = "IMS+MMS",
+		.type = OFONO_GPRS_CONTEXT_TYPE_IMS |
+			OFONO_GPRS_CONTEXT_TYPE_MMS |
+			OFONO_GPRS_CONTEXT_TYPE_IA,
+		.apn = "imsmms",
+		.proto = OFONO_GPRS_PROTO_IPV6,
+		.auth_method = OFONO_GPRS_AUTH_METHOD_PAP,
+		.message_center = "foobar.mmsc:80",
+		.message_proxy = "mms.proxy.net",
+	},
+};
+
+static const struct provision_db_entry zyx_contexts[] = {
+	{
+		.name = "ZYX",
+		.apn = "zyx",
+		.type = OFONO_GPRS_CONTEXT_TYPE_INTERNET |
+			OFONO_GPRS_CONTEXT_TYPE_IA,
+		.auth_method = OFONO_GPRS_AUTH_METHOD_NONE,
+		.proto = OFONO_GPRS_PROTO_IP,
+	},
+};
+
+static const struct provision_db_entry beta_contexts[] = {
+	{
+		.type = OFONO_GPRS_CONTEXT_TYPE_INTERNET |
+			OFONO_GPRS_CONTEXT_TYPE_IA,
+		.proto = OFONO_GPRS_PROTO_IPV4V6,
+		.apn = "beta.internet",
+		.auth_method = OFONO_GPRS_AUTH_METHOD_CHAP,
+	},
+};
+
+static const struct provision_db_entry charlie_contexts[] = {
+	{
+		.type = OFONO_GPRS_CONTEXT_TYPE_INTERNET |
+			OFONO_GPRS_CONTEXT_TYPE_IA,
+		.proto = OFONO_GPRS_PROTO_IPV4V6,
+		.apn = "charlie.internet",
+		.auth_method = OFONO_GPRS_AUTH_METHOD_CHAP,
+	},
+};
+
+static const struct provision_db_entry xyz_contexts[] = {
+	{
+		.type = OFONO_GPRS_CONTEXT_TYPE_INTERNET |
+			OFONO_GPRS_CONTEXT_TYPE_IA,
+		.proto = OFONO_GPRS_PROTO_IPV4V6,
+		.apn = "xyz",
+		.auth_method = OFONO_GPRS_AUTH_METHOD_CHAP,
+	}
+};
+
+/* Make sure mccmnc not in the database isn't found */
+static const struct provision_test unknown_mcc_mnc = {
+	.mcc = "994",
+	.mnc = "42",
+	.result = -ENOENT,
+};
+
+/* Successful lookup of 'Operator Beta' settings */
+static const struct provision_test lookup_beta = {
+	.mcc = "999",
+	.mnc = "006",
+	.result = 0,
+	.n_items = L_ARRAY_SIZE(beta_contexts),
+	.items = beta_contexts,
+};
+
+/* Make sure two digit mnc is treated as != to 3 digit mnc */
+static const struct provision_test two_digit_mnc = {
+	.mcc = "999",
+	.mnc = "06",
+	.result = -ENOENT,
+};
+
+/*
+ * Fallback to non-MVNO settings in case SPN doesn't match and an operator with
+ * no SPN is found.  This follows legacy oFono behavior and allows provisioning
+ * to work on modem drivers that do not support EFspn reading.
+ */
+static const struct provision_test fallback_no_spn = {
+	.mcc = "999",
+	.mnc = "005",
+	.spn = "Bogus",
+	.result = 0,
+	.n_items = L_ARRAY_SIZE(beta_contexts),
+	.items = beta_contexts,
+};
+
+/* Same as above, but with an MVNO entry for the same mcc/mnc */
+static const struct provision_test fallback_no_spn_2 = {
+	.mcc = "999",
+	.mnc = "002",
+	.spn = "Bogus",
+	.result = 0,
+	.n_items = L_ARRAY_SIZE(alpha_contexts),
+	.items = alpha_contexts,
+};
+
+/* Successful lookup of Operator Alpha */
+static const struct provision_test lookup_alpha = {
+	.mcc = "999",
+	.mnc = "001",
+	.result = 0,
+	.n_items = L_ARRAY_SIZE(alpha_contexts),
+	.items = alpha_contexts,
+};
+
+/* Successful lookup of ZYX (MVNO on Alpha) */
+static const struct provision_test lookup_zyx = {
+	.mcc = "999",
+	.mnc = "01",
+	.spn = "ZYX",
+	.result = 0,
+	.n_items = L_ARRAY_SIZE(zyx_contexts),
+	.items = zyx_contexts,
+};
+
+/*
+ * Successful lookup of Charlie.  This has to be an exact SPN match since
+ * no wildcard value is available
+ */
+static const struct provision_test lookup_charlie = {
+	.mcc = "999",
+	.mnc = "10",
+	.spn = "Charlie",
+	.result = 0,
+	.n_items = L_ARRAY_SIZE(charlie_contexts),
+	.items = charlie_contexts,
+};
+
+/* Successful lookup of XYZ (MVNO on Charlie) */
+static const struct provision_test lookup_xyz = {
+	.mcc = "999",
+	.mnc = "11",
+	.spn = "XYZ",
+	.result = 0,
+	.n_items = L_ARRAY_SIZE(xyz_contexts),
+	.items = xyz_contexts,
+};
+
+/* No match with for an MCC/MNC present in the DB, but no wildcard entry */
+static const struct provision_test lookup_no_match = {
+	.mcc = "999",
+	.mnc = "11",
+	.result = -ENOENT,
+};
+
+static void provision_lookup(const void *data)
+{
+	const struct provision_test *test = data;
+	struct provision_db_entry *items;
+	size_t n_items;
+	size_t i;
+	int r;
+
+	r = provision_db_lookup(pdb, test->mcc, test->mnc, test->spn,
+					&items, &n_items);
+	assert(r == test->result);
+
+	if (r < 0)
+		return;
+
+	assert(n_items == test->n_items);
+	for (i = 0; i < n_items; i++) {
+		const struct provision_db_entry *a = items + i;
+		const struct provision_db_entry *b = test->items + i;
+
+		assert(b->type == a->type);
+		assert(b->proto == a->proto);
+		assert(l_streq0(b->apn, a->apn));
+		assert(l_streq0(b->name, a->name));
+		assert(l_streq0(b->username, a->username));
+		assert(l_streq0(b->password, a->password));
+		assert(b->auth_method == a->auth_method);
+		assert(l_streq0(b->message_proxy, a->message_proxy));
+		assert(l_streq0(b->message_center, a->message_center));
+	}
+
+	l_free(items);
+}
+
+int main(int argc, char **argv)
+{
+	int r;
+
+	l_test_init(&argc, &argv);
+
+	l_test_add("Lookup on NULL provision db", null_provision_db, NULL);
+	l_test_add("MCC/MNC input validation", invalid_mcc_mnc, NULL);
+	l_test_add("Unknown MCC/MNC", provision_lookup, &unknown_mcc_mnc);
+	l_test_add("Successful Lookup (Beta)", provision_lookup, &lookup_beta);
+	l_test_add("Two digit MNC", provision_lookup, &two_digit_mnc);
+	l_test_add("Fallback no-SPN", provision_lookup, &fallback_no_spn);
+	l_test_add("Fallback no-SPN#2", provision_lookup, &fallback_no_spn_2);
+	l_test_add("Successful lookup (Alpha)", provision_lookup, &lookup_alpha);
+	l_test_add("Successful lookup (ZYX)", provision_lookup, &lookup_zyx);
+	l_test_add("Exact match (Charlie)", provision_lookup, &lookup_charlie);
+	l_test_add("Exact match (XYZ)", provision_lookup, &lookup_xyz);
+	l_test_add("Exact math (no match)", provision_lookup, &lookup_no_match);
+
+	pdb = provision_db_new(UNITDIR "test-provision.db");
+	assert(pdb);
+
+	r = l_test_run();
+	provision_db_free(pdb);
+
+	return r;
+}
diff --git a/unit/test-provision.json b/unit/test-provision.json
new file mode 100644
index 00000000..61d060ad
--- /dev/null
+++ b/unit/test-provision.json
@@ -0,0 +1,92 @@
+[
+  {
+    "name": "Operator Alpha",
+    "ids": [
+      "99955", "99956", "99901", "99902", "999001", "999002", "999056", "999055"
+    ],
+    "apns": [
+      {
+        "name": "Internet",
+        "apn": "internet",
+        "type": [
+          "internet"
+        ],
+        "authentication": "none",
+        "protocol": "ipv4"
+      },
+      {
+        "name": "IMS+MMS",
+        "apn": "imsmms",
+        "type": [
+          "ims", "mms", "ia"
+        ],
+        "mmsc": "foobar.mmsc:80",
+        "mmsproxy": "mms.proxy.net",
+        "authentication": "pap",
+        "protocol": "ipv6"
+      }
+    ]
+  },
+  {
+    "name": "ZYX (MVNO on Alpha)",
+    "ids": [
+      "999001", "999002", "99901", "99902"
+    ],
+    "spn": "ZYX",
+    "apns": [
+      {
+        "name": "ZYX",
+        "apn": "zyx",
+        "type": [
+          "internet", "ia"
+        ],
+        "authentication": "none",
+        "protocol": "ipv4"
+      }
+    ]
+  },
+  {
+    "name": "Operator Beta",
+    "ids": [
+      "999005", "999006"
+    ],
+    "apns": [
+      {
+        "apn": "beta.internet",
+        "type": [
+          "internet", "ia"
+        ]
+      }
+    ]
+  },
+  {
+    "name": "Operator Charlie",
+    "ids": [
+      "99910", "99911"
+    ],
+    "spn": "Charlie",
+    "apns": [
+      {
+        "apn": "charlie.internet",
+        "type": [
+          "internet", "ia"
+        ]
+      }
+    ]
+  },
+  {
+    "name": "XYZ (MVNO on Charlie)",
+    "ids": [
+      "99911"
+    ],
+    "spn": "XYZ",
+    "apns": [
+      {
+        "apn": "xyz",
+        "type": [
+          "internet", "ia"
+        ]
+      }
+    ]
+  }
+]
