summaryrefslogtreecommitdiffstats
path: root/toolkit/components/normandy/test/unit
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/normandy/test/unit')
-rw-r--r--toolkit/components/normandy/test/unit/cookie_server.sjs12
-rw-r--r--toolkit/components/normandy/test/unit/echo_server.sjs21
-rw-r--r--toolkit/components/normandy/test/unit/head_xpc.js5
-rw-r--r--toolkit/components/normandy/test/unit/invalid_recipe_signature_api/api/v1/index.json4
-rw-r--r--toolkit/components/normandy/test/unit/invalid_recipe_signature_api/api/v1/recipe/signed/index.json24
-rw-r--r--toolkit/components/normandy/test/unit/invalid_recipe_signature_api/normandy.content-signature.mozilla.org-20210705.dev.chain123
-rw-r--r--toolkit/components/normandy/test/unit/mock_api/api/v1/classify_client/index.json4
-rw-r--r--toolkit/components/normandy/test/unit/mock_api/api/v1/extension/1/index.json9
-rw-r--r--toolkit/components/normandy/test/unit/mock_api/api/v1/extension/index.json0
-rw-r--r--toolkit/components/normandy/test/unit/mock_api/api/v1/index.json5
-rw-r--r--toolkit/components/normandy/test/unit/mock_api/api/v1/recipe/signed/index.json24
-rw-r--r--toolkit/components/normandy/test/unit/mock_api/normandy.content-signature.mozilla.org-20210705.dev.chain123
-rw-r--r--toolkit/components/normandy/test/unit/query_server.sjs34
-rw-r--r--toolkit/components/normandy/test/unit/test_Normandy.js95
-rw-r--r--toolkit/components/normandy/test/unit/test_NormandyApi.js257
-rw-r--r--toolkit/components/normandy/test/unit/test_PrefUtils.js223
-rw-r--r--toolkit/components/normandy/test/unit/test_RecipeRunner.js34
-rw-r--r--toolkit/components/normandy/test/unit/test_addon_unenroll.js310
-rw-r--r--toolkit/components/normandy/test/unit/utils.js135
-rw-r--r--toolkit/components/normandy/test/unit/xpcshell.ini17
20 files changed, 1459 insertions, 0 deletions
diff --git a/toolkit/components/normandy/test/unit/cookie_server.sjs b/toolkit/components/normandy/test/unit/cookie_server.sjs
new file mode 100644
index 0000000000..ab6099f6a4
--- /dev/null
+++ b/toolkit/components/normandy/test/unit/cookie_server.sjs
@@ -0,0 +1,12 @@
+/**
+ * Sends responses that sets a cookie.
+ */
+function handleRequest(request, response) {
+ // Allow cross-origin, so you can XHR to it!
+ response.setHeader("Access-Control-Allow-Origin", "*", false);
+ // Avoid confusing cache behaviors
+ response.setHeader("Cache-Control", "no-cache", false);
+ // Set a cookie
+ response.setHeader("Set-Cookie", "type=chocolate-chip", false);
+ response.write("");
+}
diff --git a/toolkit/components/normandy/test/unit/echo_server.sjs b/toolkit/components/normandy/test/unit/echo_server.sjs
new file mode 100644
index 0000000000..012f2b406e
--- /dev/null
+++ b/toolkit/components/normandy/test/unit/echo_server.sjs
@@ -0,0 +1,21 @@
+/**
+ * Reads an HTTP status code and response body from the querystring and sends
+ * back a matching response.
+ */
+function handleRequest(request, response) {
+ // Allow cross-origin, so you can XHR to it!
+ response.setHeader("Access-Control-Allow-Origin", "*", false);
+ // Avoid confusing cache behaviors
+ response.setHeader("Cache-Control", "no-cache", false);
+
+ const params = request.queryString.split("&");
+ for (const param of params) {
+ const [key, value] = param.split("=");
+ if (key === "status") {
+ response.setStatusLine(null, value);
+ } else if (key === "body") {
+ response.write(value);
+ }
+ }
+ response.write("");
+}
diff --git a/toolkit/components/normandy/test/unit/head_xpc.js b/toolkit/components/normandy/test/unit/head_xpc.js
new file mode 100644
index 0000000000..ad2192be4b
--- /dev/null
+++ b/toolkit/components/normandy/test/unit/head_xpc.js
@@ -0,0 +1,5 @@
+"use strict";
+
+var { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
diff --git a/toolkit/components/normandy/test/unit/invalid_recipe_signature_api/api/v1/index.json b/toolkit/components/normandy/test/unit/invalid_recipe_signature_api/api/v1/index.json
new file mode 100644
index 0000000000..5bef8d1302
--- /dev/null
+++ b/toolkit/components/normandy/test/unit/invalid_recipe_signature_api/api/v1/index.json
@@ -0,0 +1,4 @@
+{
+ "recipe-signed": "/api/v1/recipe/signed/",
+ "classify-client": "/api/v1/classify_client/"
+}
diff --git a/toolkit/components/normandy/test/unit/invalid_recipe_signature_api/api/v1/recipe/signed/index.json b/toolkit/components/normandy/test/unit/invalid_recipe_signature_api/api/v1/recipe/signed/index.json
new file mode 100644
index 0000000000..d5495fa87f
--- /dev/null
+++ b/toolkit/components/normandy/test/unit/invalid_recipe_signature_api/api/v1/recipe/signed/index.json
@@ -0,0 +1,24 @@
+[
+ {
+ "recipe": {
+ "action": "console-log",
+ "arguments": { "message": "this signature does not match this recipe" },
+ "channels": [],
+ "countries": [],
+ "enabled": true,
+ "extra_filter_expression": "true || true",
+ "filter_expression": "true || true",
+ "id": 1,
+ "last_updated": "2017-02-17T18:29:09.839239Z",
+ "locales": [],
+ "name": "system-addon-test",
+ "revision_id": "b2cb8a26e132182d7d02cf50695d2c7f06cf3b954ff2ff63bca49d724ee91950"
+ },
+ "signature": {
+ "public_key": "MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEVEKiCAIkwRg1VFsP8JOYdSF6a3qvgbRPoEK9eTuLbrB6QixozscKR4iWJ8ZOOX6RPCRgFdfVDoZqjFBFNJN9QtRBk0mVtHbnErx64d2vMF0oWencS1hyLW2whgOgOz7p",
+ "signature": "p4g3eurmPsJK5UcGT97BRyKstpwZ_2mNJkDGpd6QXlkXfvgwprjeyb5yeIEkKUXqc6krWid4obB_OP9-CwOi9tvKY1pV8p98CT5BhF0IVgpF3b7KBW1a0BVdg5owoG5W",
+ "timestamp": "2017-02-17T18:29:09.847614Z",
+ "x5u": "/normandy.content-signature.mozilla.org-20210705.dev.chain"
+ }
+ }
+]
diff --git a/toolkit/components/normandy/test/unit/invalid_recipe_signature_api/normandy.content-signature.mozilla.org-20210705.dev.chain b/toolkit/components/normandy/test/unit/invalid_recipe_signature_api/normandy.content-signature.mozilla.org-20210705.dev.chain
new file mode 100644
index 0000000000..5bf53787d8
--- /dev/null
+++ b/toolkit/components/normandy/test/unit/invalid_recipe_signature_api/normandy.content-signature.mozilla.org-20210705.dev.chain
@@ -0,0 +1,123 @@
+-----BEGIN CERTIFICATE-----
+MIIGRTCCBC2gAwIBAgIEAQAABTANBgkqhkiG9w0BAQwFADBrMQswCQYDVQQGEwJV
+UzEQMA4GA1UEChMHQWxsaXpvbTEXMBUGA1UECxMOQ2xvdWQgU2VydmljZXMxMTAv
+BgNVBAMTKERldnppbGxhIFNpZ25pbmcgU2VydmljZXMgSW50ZXJtZWRpYXRlIDEw
+HhcNMTYwNzA2MjE1NzE1WhcNMjEwNzA1MjE1NzE1WjCBrzELMAkGA1UEBhMCVVMx
+EzARBgNVBAgTCkNhbGlmb3JuaWExHDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRp
+b24xFzAVBgNVBAsTDkNsb3VkIFNlcnZpY2VzMS8wLQYDVQQDEyZub3JtYW5keS5j
+b250ZW50LXNpZ25hdHVyZS5tb3ppbGxhLm9yZzEjMCEGCSqGSIb3DQEJARYUc2Vj
+dXJpdHlAbW96aWxsYS5vcmcwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARUQqIIAiTB
+GDVUWw/wk5h1IXpreq+BtE+gQr15O4tusHpCLGjOxwpHiJYnxk45fpE8JGAV19UO
+hmqMUEU0k31C1EGTSZW0ducSvHrh3a8wXShZ6dxLWHItbbCGA6A7PumjggJYMIIC
+VDAdBgNVHQ4EFgQUVfksSjlZ0i1TBiS1vcoObaMeXn0wge8GA1UdIwSB5zCB5IAU
+/YboUIXAovChEpudDBuodHKbjUuhgcWkgcIwgb8xCzAJBgNVBAYTAlVTMQswCQYD
+VQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UEChMdQ29udGVu
+dCBTaWduYXR1cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5jb250ZW50LXNp
+Z25hdHVyZS5yb290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNlYytkZXZyb290
+Y29udGVudHNpZ25hdHVyZUBtb3ppbGxhLmNvbYIEAQAABDAMBgNVHRMBAf8EAjAA
+MA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAzBEBgNVHR8E
+PTA7MDmgN6A1hjNodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmRldi5tb3phd3Mu
+bmV0L2NhL2NybC5wZW0wQgYJYIZIAYb4QgEEBDUWM2h0dHBzOi8vY29udGVudC1z
+aWduYXR1cmUuZGV2Lm1vemF3cy5uZXQvY2EvY3JsLnBlbTBOBggrBgEFBQcBAQRC
+MEAwPgYIKwYBBQUHMAKGMmh0dHBzOi8vY29udGVudC1zaWduYXR1cmUuZGV2Lm1v
+emF3cy5uZXQvY2EvY2EucGVtMDEGA1UdEQQqMCiCJm5vcm1hbmR5LmNvbnRlbnQt
+c2lnbmF0dXJlLm1vemlsbGEub3JnMA0GCSqGSIb3DQEBDAUAA4ICAQCwb+8JTAB7
+ZfQmFqPUIV2cQQv696AaDPQCtA9YS4zmUfcLMvfZVAbK397zFr0RMDdLiTUQDoeq
+rBEmPXhJRPiv6JAK4n7Jf6Y6XfXcNxx+q3garR09Vm/0CnEq/iV+ZAtPkoKIO9kr
+Nkzecd894yQCF4hIuPQ5qtMySeqJmH3Dp13eq4T0Oew1Bu32rNHuBJh2xYBkWdun
+aAw/YX0I5EqZBP/XA6gbiA160tTK+hnpnlMtw/ljkvfhHbWpICD4aSiTL8L3vABQ
+j7bqjMKR5xDkuGWshZfcmonpvQhGTye/RZ1vz5IzA3VOJt1mz5bdZlitpaOm/Yv0
+x6aODz8GP/PiRWFQ5CW8Uf/7pGc5rSyvnfZV2ix8EzFlo8cUtuN1fjrPFPOFOLvG
+iiB6S9nlXiKBGYIDdd8V8iC5xJpzjiAWJQigwSNzuc2K30+iPo3w0zazkwe5V8jW
+gj6gItYxh5xwVQTPHD0EOd9HvV1ou42+rH5Y+ISFUm25zz02UtUHEK0BKtL0lmdt
+DwVq5jcHn6bx2/iwUtlKvPXtfM/6JjTJlkLZLtS7U5/pwcS0owo9zAL0qg3bdm16
++v/olmPqQFLUHmamJTzv3rojj5X/uVdx1HMM3wBjV9tRYoYaZw9RIInRmM8Z1pHv
+JJ+CIZgCyd5vgp57BKiodRZcgHoCH+BkOQ==
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIHijCCBXKgAwIBAgIEAQAABDANBgkqhkiG9w0BAQwFADCBvzELMAkGA1UEBhMC
+VVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MSYwJAYDVQQK
+Ex1Db250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEmMCQGA1UEAxMdZGV2LmNv
+bnRlbnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG9w0BCQEWLGNsb3Vkc2Vj
+K2RldnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEuY29tMB4XDTE2MDcwNjIx
+NDkyNloXDTIxMDcwNTIxNDkyNlowazELMAkGA1UEBhMCVVMxEDAOBgNVBAoTB0Fs
+bGl6b20xFzAVBgNVBAsTDkNsb3VkIFNlcnZpY2VzMTEwLwYDVQQDEyhEZXZ6aWxs
+YSBTaWduaW5nIFNlcnZpY2VzIEludGVybWVkaWF0ZSAxMIICIjANBgkqhkiG9w0B
+AQEFAAOCAg8AMIICCgKCAgEAypIfUURYgWzGw8G/1Pz9zW+Tsjirx2owThiv2gys
+wJiWL/9/2gzKOrYDEqlDUudfA/BjVRtT9+NbYgnhaCkNfADOAacWS83aMhedAqhP
+bVd5YhGQdpijI7f1AVTSb0ehrU2nhOZHvHX5Tk2fbRx3ryefIazNTLFGpiMBbsNv
+tSI/+fjW8s0MhKNqlLnk6a9mZKo0mEy7HjGYV8nzsgI17rKLx/s2HG4TFG0+JQzc
+UGlum3Tg58ritDzWdyKIkmKNZ48oLBX99Qc8j8B1UyiLv6TZmjVX0I+Ds7eSWHZk
+0axLEpTyf2r7fHvN4iDNCPajw+ZpuuBfbs80Ha8b8MHvnpf9fbwiirodNQOVpY4c
+t5E3Us3eYwBKdqDEbECWxCKGAS2/iVVUCNKHsg0sSxgqcwxrxyrddQRUQ0EM38DZ
+F/vHt+vTdHt07kezbjJe0Kklel59uSpghA0iL4vxzbTns1fuwYOgVrNGs3acTkiB
+GhFOxRXUPGtpdYmv+AaR9WlWJQY1GIEoVrinPVH7bcCwyh1CcUbHL+oAFTcmc6kZ
+7azNg21tWILIRL7R0IZYQm0tF5TTwCsjVC7FuHaBtkxtVrrZqeKjvVXQ8TK5VoI0
+BUQ6BKHGeTtm+0HBpheYBDy3wkOsEGbGHLEM6cMeiz6PyCXF8wXli8Qb/TjN3LHZ
+e30CAwEAAaOCAd8wggHbMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGG
+MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMDMB0GA1UdDgQWBBT9huhQhcCi8KESm50M
+G6h0cpuNSzCB7AYDVR0jBIHkMIHhgBSDx8s0qJaMyQCehKcuzgzVNRA75qGBxaSB
+wjCBvzELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFp
+biBWaWV3MSYwJAYDVQQKEx1Db250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEm
+MCQGA1UEAxMdZGV2LmNvbnRlbnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG
+9w0BCQEWLGNsb3Vkc2VjK2RldnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEu
+Y29tggEBMEIGCWCGSAGG+EIBBAQ1FjNodHRwczovL2NvbnRlbnQtc2lnbmF0dXJl
+LmRldi5tb3phd3MubmV0L2NhL2NybC5wZW0wTgYIKwYBBQUHAQEEQjBAMD4GCCsG
+AQUFBzAChjJodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmRldi5tb3phd3MubmV0
+L2NhL2NhLnBlbTANBgkqhkiG9w0BAQwFAAOCAgEAbum0z0ccqI1Wp49VtsGmUPHA
+gjPPy2Xa5NePmqY87WrGdhjN3xbLVb3hx8T2N6pqDjMY2rEynXKEOek3oJkQ3C6J
+8AFP6Y93gaAlNz6EA0J0mqdW5TMI8YEYsu2ma+dQQ8wm9f/5b+/Y8qwfhztP06W5
+H6IG04/RvgUwYMnSR4QvT309fu5UmCRUDzsO53ZmQCfmN94u3NxHc4S6n0Q6AKAM
+kh5Ld9SQnlqqqDykzn7hYDi8nTLWc7IYqkGfNMilDEKbAl4CjnSfyEvpdFAJ9sPR
+UL+kaWFSMvaqIPNpxS5OpoPZjmxEc9HHnCHxtfDHWdXTJILjijPrCdMaxOCHfIqV
+5roOJggI4RZ0YM68IL1MfN4IEVOrHhKjDHtd1gcmy2KU4jfj9LQq9YTnyvZ2z1yS
+lS310HG3or1K8Nnu5Utfe7T6ppX8bLRMkS1/w0p7DKxHaf4D/GJcCtM9lcSt9JpW
+6ACKFikjWR4ZxczYKgApc0wcjd7XBuO5777xtOeyEUDHdDft3jiXA91dYM5UAzc3
+69z/3zmaELzo0gWcrjLXh7fU9AvbU4EUF6rwzxbPGF78jJcGK+oBf8uWUCkBykDt
+VsAEZI1u4EDg8e/C1nFqaH9gNMArAgquYIB9rve+hdprIMnva0S147pflWopBWcb
+jwzgpfquuYnnxe0CNBA=
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIH3DCCBcSgAwIBAgIBATANBgkqhkiG9w0BAQwFADCBvzELMAkGA1UEBhMCVVMx
+CzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MSYwJAYDVQQKEx1D
+b250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEmMCQGA1UEAxMdZGV2LmNvbnRl
+bnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG9w0BCQEWLGNsb3Vkc2VjK2Rl
+dnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEuY29tMB4XDTE2MDcwNjE4MTUy
+MloXDTI2MDcwNDE4MTUyMlowgb8xCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJDQTEW
+MBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UEChMdQ29udGVudCBTaWduYXR1
+cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5jb250ZW50LXNpZ25hdHVyZS5y
+b290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNlYytkZXZyb290Y29udGVudHNp
+Z25hdHVyZUBtb3ppbGxhLmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
+ggIBAJcPcXhD8MzF6OTn5qZ0L7lX1+PEgLKhI9g1HxxDYDVup4Zm0kZhPGmFSlml
+6eVO99OvvHdAlHhQGCIG7h+w1cp66mWjfpcvtQH23uRoKZfiW3jy1jUWrvdXolxR
+t1taZosjzo+9OP8TvG6LpJj7AvqUiYD4wYnQJtt0jNRN4d6MUfQwiavSS5uTBuxd
+ZJ4TsPvEI+Iv4A4PSobSzxkg79LTMvsGtDLQv7nN5hMs9T18EL5GnIKoqnSQCU0d
+n2CN7S3QiQ+cbORWsSYqCTj1gUbFh6X3duEB/ypLcyWFbqeJmPHikjY8q8pLjZSB
+IYiTJYLyvYlKdM5QleC/xuBNnMPCftrwwLHjWE4Dd7C9t7k0R5xyOetuiHLCwOcQ
+tuckp7RgFKoviMNv3gdkzwVapOklcsaRkRscv6OMTKJNsdJVIDLrPF1wMABhbEQB
+64BL0uL4lkFtpXXbJzQ6mgUNQveJkfUWOoB+cA/6GtI4J0aQfvQgloCYI6jxNn/e
+Nvk5OV9KFOhXS2dnDft3wHU46sg5yXOuds1u6UrOoATBNFlkS95m4zIX1Svu091+
+CKTiLK85+ZiFtAlU2bPr3Bk3GhL3Z586ae6a4QUEx6SPQVXc18ezB4qxKqFc+avI
+ylccYMRhVP+ruADxtUM5Vy6x3U8BwBK5RLdecRx2FEBDImY1AgMBAAGjggHfMIIB
+2zAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAWBgNVHSUBAf8EDDAK
+BggrBgEFBQcDAzAdBgNVHQ4EFgQUg8fLNKiWjMkAnoSnLs4M1TUQO+YwgewGA1Ud
+IwSB5DCB4YAUg8fLNKiWjMkAnoSnLs4M1TUQO+ahgcWkgcIwgb8xCzAJBgNVBAYT
+AlVTMQswCQYDVQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UE
+ChMdQ29udGVudCBTaWduYXR1cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5j
+b250ZW50LXNpZ25hdHVyZS5yb290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNl
+YytkZXZyb290Y29udGVudHNpZ25hdHVyZUBtb3ppbGxhLmNvbYIBATBCBglghkgB
+hvhCAQQENRYzaHR0cHM6Ly9jb250ZW50LXNpZ25hdHVyZS5kZXYubW96YXdzLm5l
+dC9jYS9jcmwucGVtME4GCCsGAQUFBwEBBEIwQDA+BggrBgEFBQcwAoYyaHR0cHM6
+Ly9jb250ZW50LXNpZ25hdHVyZS5kZXYubW96YXdzLm5ldC9jYS9jYS5wZW0wDQYJ
+KoZIhvcNAQEMBQADggIBAAAQ+fotZE79FfZ8Lz7eiTUzlwHXCdSE2XD3nMROu6n6
+uLTBPrf2C+k+U1FjKVvL5/WCUj6hIzP2X6Sb8+o0XHX9mKN0yoMORTEYJOnazYPK
+KSUUFnE4vGgQkr6k/31gGRMTICdnf3VOOAlUCQ5bOmGIuWi401E3sbd85U+TJe0A
+nHlU+XjtfzlqcBvQivdbA0s+GEG55uRPvn952aTBEMHfn+2JqKeLShl4AtUAfu+h
+6md3Z2HrEC7B3GK8ekWPu0G/ZuWTuFvOimZ+5C8IPRJXcIR/siPQl1x6dpTCew6t
+lPVcVuvg6SQwzvxetkNrGUe2Wb2s9+PK2PUvfOS8ee25SNmfG3XK9qJpqGUhzSBX
+T8QQgyxd0Su5G7Wze7aaHZd/fqIm/G8YFR0HiC2xni/lnDTXFDPCe+HCnSk0bH6U
+wpr6I1yK8oZ2IdnNVfuABGMmGOhvSQ8r7//ea9WKhCsGNQawpVWVioY7hpyNAJ0O
+Vn4xqG5f6allz8lgpwAQ+AeEEClHca6hh6mj9KhD1Of1CC2Vx52GHNh/jMYEc3/g
+zLKniencBqn3Y2XH2daITGJddcleN09+a1NaTkT3hgr7LumxM8EVssPkC+z9j4Vf
+Gbste+8S5QCMhh00g5vR9QF8EaFqdxCdSxrsA4GmpCa5UQl8jtCnpp2DLKXuOh72
+-----END CERTIFICATE-----
diff --git a/toolkit/components/normandy/test/unit/mock_api/api/v1/classify_client/index.json b/toolkit/components/normandy/test/unit/mock_api/api/v1/classify_client/index.json
new file mode 100644
index 0000000000..a9b6239e48
--- /dev/null
+++ b/toolkit/components/normandy/test/unit/mock_api/api/v1/classify_client/index.json
@@ -0,0 +1,4 @@
+{
+ "country": "US",
+ "request_time": "2017-02-22T17:43:24.657841Z"
+}
diff --git a/toolkit/components/normandy/test/unit/mock_api/api/v1/extension/1/index.json b/toolkit/components/normandy/test/unit/mock_api/api/v1/extension/1/index.json
new file mode 100644
index 0000000000..f088592a9b
--- /dev/null
+++ b/toolkit/components/normandy/test/unit/mock_api/api/v1/extension/1/index.json
@@ -0,0 +1,9 @@
+{
+ "id": 1,
+ "name": "Normandy Fixture",
+ "xpi": "http://example.com/browser/toolkit/components/normandy/test/browser/fixtures/normandy.xpi",
+ "extension_id": "normandydriver@example.com",
+ "version": "1.0",
+ "hash": "ade1c14196ec4fe0aa0a6ba40ac433d7c8d1ec985581a8a94d43dc58991b5171",
+ "hash_algorithm": "sha256"
+}
diff --git a/toolkit/components/normandy/test/unit/mock_api/api/v1/extension/index.json b/toolkit/components/normandy/test/unit/mock_api/api/v1/extension/index.json
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/toolkit/components/normandy/test/unit/mock_api/api/v1/extension/index.json
diff --git a/toolkit/components/normandy/test/unit/mock_api/api/v1/index.json b/toolkit/components/normandy/test/unit/mock_api/api/v1/index.json
new file mode 100644
index 0000000000..d2414056c0
--- /dev/null
+++ b/toolkit/components/normandy/test/unit/mock_api/api/v1/index.json
@@ -0,0 +1,5 @@
+{
+ "classify-client": "/api/v1/classify_client/",
+ "extension-list": "/api/v1/extension/",
+ "recipe-signed": "/api/v1/recipe/signed/"
+}
diff --git a/toolkit/components/normandy/test/unit/mock_api/api/v1/recipe/signed/index.json b/toolkit/components/normandy/test/unit/mock_api/api/v1/recipe/signed/index.json
new file mode 100644
index 0000000000..5f3515dc97
--- /dev/null
+++ b/toolkit/components/normandy/test/unit/mock_api/api/v1/recipe/signed/index.json
@@ -0,0 +1,24 @@
+[
+ {
+ "recipe": {
+ "action": "console-log",
+ "arguments": { "message": "asdfasfda sdf sa" },
+ "channels": [],
+ "countries": [],
+ "enabled": true,
+ "extra_filter_expression": "true || true",
+ "filter_expression": "true || true",
+ "id": 1,
+ "last_updated": "2017-02-17T18:29:09.839239Z",
+ "locales": [],
+ "name": "system-addon-test",
+ "revision_id": "b2cb8a26e132182d7d02cf50695d2c7f06cf3b954ff2ff63bca49d724ee91950"
+ },
+ "signature": {
+ "public_key": "MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEVEKiCAIkwRg1VFsP8JOYdSF6a3qvgbRPoEK9eTuLbrB6QixozscKR4iWJ8ZOOX6RPCRgFdfVDoZqjFBFNJN9QtRBk0mVtHbnErx64d2vMF0oWencS1hyLW2whgOgOz7p",
+ "signature": "p4g3eurmPsJK5UcGT97BRyKstpwZ_2mNJkDGpd6QXlkXfvgwprjeyb5yeIEkKUXqc6krWid4obB_OP9-CwOi9tvKY1pV8p98CT5BhF0IVgpF3b7KBW1a0BVdg5owoG5W",
+ "timestamp": "2017-02-17T18:29:09.847614Z",
+ "x5u": "/normandy.content-signature.mozilla.org-20210705.dev.chain"
+ }
+ }
+]
diff --git a/toolkit/components/normandy/test/unit/mock_api/normandy.content-signature.mozilla.org-20210705.dev.chain b/toolkit/components/normandy/test/unit/mock_api/normandy.content-signature.mozilla.org-20210705.dev.chain
new file mode 100644
index 0000000000..5bf53787d8
--- /dev/null
+++ b/toolkit/components/normandy/test/unit/mock_api/normandy.content-signature.mozilla.org-20210705.dev.chain
@@ -0,0 +1,123 @@
+-----BEGIN CERTIFICATE-----
+MIIGRTCCBC2gAwIBAgIEAQAABTANBgkqhkiG9w0BAQwFADBrMQswCQYDVQQGEwJV
+UzEQMA4GA1UEChMHQWxsaXpvbTEXMBUGA1UECxMOQ2xvdWQgU2VydmljZXMxMTAv
+BgNVBAMTKERldnppbGxhIFNpZ25pbmcgU2VydmljZXMgSW50ZXJtZWRpYXRlIDEw
+HhcNMTYwNzA2MjE1NzE1WhcNMjEwNzA1MjE1NzE1WjCBrzELMAkGA1UEBhMCVVMx
+EzARBgNVBAgTCkNhbGlmb3JuaWExHDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRp
+b24xFzAVBgNVBAsTDkNsb3VkIFNlcnZpY2VzMS8wLQYDVQQDEyZub3JtYW5keS5j
+b250ZW50LXNpZ25hdHVyZS5tb3ppbGxhLm9yZzEjMCEGCSqGSIb3DQEJARYUc2Vj
+dXJpdHlAbW96aWxsYS5vcmcwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARUQqIIAiTB
+GDVUWw/wk5h1IXpreq+BtE+gQr15O4tusHpCLGjOxwpHiJYnxk45fpE8JGAV19UO
+hmqMUEU0k31C1EGTSZW0ducSvHrh3a8wXShZ6dxLWHItbbCGA6A7PumjggJYMIIC
+VDAdBgNVHQ4EFgQUVfksSjlZ0i1TBiS1vcoObaMeXn0wge8GA1UdIwSB5zCB5IAU
+/YboUIXAovChEpudDBuodHKbjUuhgcWkgcIwgb8xCzAJBgNVBAYTAlVTMQswCQYD
+VQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UEChMdQ29udGVu
+dCBTaWduYXR1cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5jb250ZW50LXNp
+Z25hdHVyZS5yb290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNlYytkZXZyb290
+Y29udGVudHNpZ25hdHVyZUBtb3ppbGxhLmNvbYIEAQAABDAMBgNVHRMBAf8EAjAA
+MA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAzBEBgNVHR8E
+PTA7MDmgN6A1hjNodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmRldi5tb3phd3Mu
+bmV0L2NhL2NybC5wZW0wQgYJYIZIAYb4QgEEBDUWM2h0dHBzOi8vY29udGVudC1z
+aWduYXR1cmUuZGV2Lm1vemF3cy5uZXQvY2EvY3JsLnBlbTBOBggrBgEFBQcBAQRC
+MEAwPgYIKwYBBQUHMAKGMmh0dHBzOi8vY29udGVudC1zaWduYXR1cmUuZGV2Lm1v
+emF3cy5uZXQvY2EvY2EucGVtMDEGA1UdEQQqMCiCJm5vcm1hbmR5LmNvbnRlbnQt
+c2lnbmF0dXJlLm1vemlsbGEub3JnMA0GCSqGSIb3DQEBDAUAA4ICAQCwb+8JTAB7
+ZfQmFqPUIV2cQQv696AaDPQCtA9YS4zmUfcLMvfZVAbK397zFr0RMDdLiTUQDoeq
+rBEmPXhJRPiv6JAK4n7Jf6Y6XfXcNxx+q3garR09Vm/0CnEq/iV+ZAtPkoKIO9kr
+Nkzecd894yQCF4hIuPQ5qtMySeqJmH3Dp13eq4T0Oew1Bu32rNHuBJh2xYBkWdun
+aAw/YX0I5EqZBP/XA6gbiA160tTK+hnpnlMtw/ljkvfhHbWpICD4aSiTL8L3vABQ
+j7bqjMKR5xDkuGWshZfcmonpvQhGTye/RZ1vz5IzA3VOJt1mz5bdZlitpaOm/Yv0
+x6aODz8GP/PiRWFQ5CW8Uf/7pGc5rSyvnfZV2ix8EzFlo8cUtuN1fjrPFPOFOLvG
+iiB6S9nlXiKBGYIDdd8V8iC5xJpzjiAWJQigwSNzuc2K30+iPo3w0zazkwe5V8jW
+gj6gItYxh5xwVQTPHD0EOd9HvV1ou42+rH5Y+ISFUm25zz02UtUHEK0BKtL0lmdt
+DwVq5jcHn6bx2/iwUtlKvPXtfM/6JjTJlkLZLtS7U5/pwcS0owo9zAL0qg3bdm16
++v/olmPqQFLUHmamJTzv3rojj5X/uVdx1HMM3wBjV9tRYoYaZw9RIInRmM8Z1pHv
+JJ+CIZgCyd5vgp57BKiodRZcgHoCH+BkOQ==
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIHijCCBXKgAwIBAgIEAQAABDANBgkqhkiG9w0BAQwFADCBvzELMAkGA1UEBhMC
+VVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MSYwJAYDVQQK
+Ex1Db250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEmMCQGA1UEAxMdZGV2LmNv
+bnRlbnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG9w0BCQEWLGNsb3Vkc2Vj
+K2RldnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEuY29tMB4XDTE2MDcwNjIx
+NDkyNloXDTIxMDcwNTIxNDkyNlowazELMAkGA1UEBhMCVVMxEDAOBgNVBAoTB0Fs
+bGl6b20xFzAVBgNVBAsTDkNsb3VkIFNlcnZpY2VzMTEwLwYDVQQDEyhEZXZ6aWxs
+YSBTaWduaW5nIFNlcnZpY2VzIEludGVybWVkaWF0ZSAxMIICIjANBgkqhkiG9w0B
+AQEFAAOCAg8AMIICCgKCAgEAypIfUURYgWzGw8G/1Pz9zW+Tsjirx2owThiv2gys
+wJiWL/9/2gzKOrYDEqlDUudfA/BjVRtT9+NbYgnhaCkNfADOAacWS83aMhedAqhP
+bVd5YhGQdpijI7f1AVTSb0ehrU2nhOZHvHX5Tk2fbRx3ryefIazNTLFGpiMBbsNv
+tSI/+fjW8s0MhKNqlLnk6a9mZKo0mEy7HjGYV8nzsgI17rKLx/s2HG4TFG0+JQzc
+UGlum3Tg58ritDzWdyKIkmKNZ48oLBX99Qc8j8B1UyiLv6TZmjVX0I+Ds7eSWHZk
+0axLEpTyf2r7fHvN4iDNCPajw+ZpuuBfbs80Ha8b8MHvnpf9fbwiirodNQOVpY4c
+t5E3Us3eYwBKdqDEbECWxCKGAS2/iVVUCNKHsg0sSxgqcwxrxyrddQRUQ0EM38DZ
+F/vHt+vTdHt07kezbjJe0Kklel59uSpghA0iL4vxzbTns1fuwYOgVrNGs3acTkiB
+GhFOxRXUPGtpdYmv+AaR9WlWJQY1GIEoVrinPVH7bcCwyh1CcUbHL+oAFTcmc6kZ
+7azNg21tWILIRL7R0IZYQm0tF5TTwCsjVC7FuHaBtkxtVrrZqeKjvVXQ8TK5VoI0
+BUQ6BKHGeTtm+0HBpheYBDy3wkOsEGbGHLEM6cMeiz6PyCXF8wXli8Qb/TjN3LHZ
+e30CAwEAAaOCAd8wggHbMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGG
+MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMDMB0GA1UdDgQWBBT9huhQhcCi8KESm50M
+G6h0cpuNSzCB7AYDVR0jBIHkMIHhgBSDx8s0qJaMyQCehKcuzgzVNRA75qGBxaSB
+wjCBvzELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFp
+biBWaWV3MSYwJAYDVQQKEx1Db250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEm
+MCQGA1UEAxMdZGV2LmNvbnRlbnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG
+9w0BCQEWLGNsb3Vkc2VjK2RldnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEu
+Y29tggEBMEIGCWCGSAGG+EIBBAQ1FjNodHRwczovL2NvbnRlbnQtc2lnbmF0dXJl
+LmRldi5tb3phd3MubmV0L2NhL2NybC5wZW0wTgYIKwYBBQUHAQEEQjBAMD4GCCsG
+AQUFBzAChjJodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmRldi5tb3phd3MubmV0
+L2NhL2NhLnBlbTANBgkqhkiG9w0BAQwFAAOCAgEAbum0z0ccqI1Wp49VtsGmUPHA
+gjPPy2Xa5NePmqY87WrGdhjN3xbLVb3hx8T2N6pqDjMY2rEynXKEOek3oJkQ3C6J
+8AFP6Y93gaAlNz6EA0J0mqdW5TMI8YEYsu2ma+dQQ8wm9f/5b+/Y8qwfhztP06W5
+H6IG04/RvgUwYMnSR4QvT309fu5UmCRUDzsO53ZmQCfmN94u3NxHc4S6n0Q6AKAM
+kh5Ld9SQnlqqqDykzn7hYDi8nTLWc7IYqkGfNMilDEKbAl4CjnSfyEvpdFAJ9sPR
+UL+kaWFSMvaqIPNpxS5OpoPZjmxEc9HHnCHxtfDHWdXTJILjijPrCdMaxOCHfIqV
+5roOJggI4RZ0YM68IL1MfN4IEVOrHhKjDHtd1gcmy2KU4jfj9LQq9YTnyvZ2z1yS
+lS310HG3or1K8Nnu5Utfe7T6ppX8bLRMkS1/w0p7DKxHaf4D/GJcCtM9lcSt9JpW
+6ACKFikjWR4ZxczYKgApc0wcjd7XBuO5777xtOeyEUDHdDft3jiXA91dYM5UAzc3
+69z/3zmaELzo0gWcrjLXh7fU9AvbU4EUF6rwzxbPGF78jJcGK+oBf8uWUCkBykDt
+VsAEZI1u4EDg8e/C1nFqaH9gNMArAgquYIB9rve+hdprIMnva0S147pflWopBWcb
+jwzgpfquuYnnxe0CNBA=
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIH3DCCBcSgAwIBAgIBATANBgkqhkiG9w0BAQwFADCBvzELMAkGA1UEBhMCVVMx
+CzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MSYwJAYDVQQKEx1D
+b250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEmMCQGA1UEAxMdZGV2LmNvbnRl
+bnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG9w0BCQEWLGNsb3Vkc2VjK2Rl
+dnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEuY29tMB4XDTE2MDcwNjE4MTUy
+MloXDTI2MDcwNDE4MTUyMlowgb8xCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJDQTEW
+MBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UEChMdQ29udGVudCBTaWduYXR1
+cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5jb250ZW50LXNpZ25hdHVyZS5y
+b290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNlYytkZXZyb290Y29udGVudHNp
+Z25hdHVyZUBtb3ppbGxhLmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
+ggIBAJcPcXhD8MzF6OTn5qZ0L7lX1+PEgLKhI9g1HxxDYDVup4Zm0kZhPGmFSlml
+6eVO99OvvHdAlHhQGCIG7h+w1cp66mWjfpcvtQH23uRoKZfiW3jy1jUWrvdXolxR
+t1taZosjzo+9OP8TvG6LpJj7AvqUiYD4wYnQJtt0jNRN4d6MUfQwiavSS5uTBuxd
+ZJ4TsPvEI+Iv4A4PSobSzxkg79LTMvsGtDLQv7nN5hMs9T18EL5GnIKoqnSQCU0d
+n2CN7S3QiQ+cbORWsSYqCTj1gUbFh6X3duEB/ypLcyWFbqeJmPHikjY8q8pLjZSB
+IYiTJYLyvYlKdM5QleC/xuBNnMPCftrwwLHjWE4Dd7C9t7k0R5xyOetuiHLCwOcQ
+tuckp7RgFKoviMNv3gdkzwVapOklcsaRkRscv6OMTKJNsdJVIDLrPF1wMABhbEQB
+64BL0uL4lkFtpXXbJzQ6mgUNQveJkfUWOoB+cA/6GtI4J0aQfvQgloCYI6jxNn/e
+Nvk5OV9KFOhXS2dnDft3wHU46sg5yXOuds1u6UrOoATBNFlkS95m4zIX1Svu091+
+CKTiLK85+ZiFtAlU2bPr3Bk3GhL3Z586ae6a4QUEx6SPQVXc18ezB4qxKqFc+avI
+ylccYMRhVP+ruADxtUM5Vy6x3U8BwBK5RLdecRx2FEBDImY1AgMBAAGjggHfMIIB
+2zAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAWBgNVHSUBAf8EDDAK
+BggrBgEFBQcDAzAdBgNVHQ4EFgQUg8fLNKiWjMkAnoSnLs4M1TUQO+YwgewGA1Ud
+IwSB5DCB4YAUg8fLNKiWjMkAnoSnLs4M1TUQO+ahgcWkgcIwgb8xCzAJBgNVBAYT
+AlVTMQswCQYDVQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UE
+ChMdQ29udGVudCBTaWduYXR1cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5j
+b250ZW50LXNpZ25hdHVyZS5yb290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNl
+YytkZXZyb290Y29udGVudHNpZ25hdHVyZUBtb3ppbGxhLmNvbYIBATBCBglghkgB
+hvhCAQQENRYzaHR0cHM6Ly9jb250ZW50LXNpZ25hdHVyZS5kZXYubW96YXdzLm5l
+dC9jYS9jcmwucGVtME4GCCsGAQUFBwEBBEIwQDA+BggrBgEFBQcwAoYyaHR0cHM6
+Ly9jb250ZW50LXNpZ25hdHVyZS5kZXYubW96YXdzLm5ldC9jYS9jYS5wZW0wDQYJ
+KoZIhvcNAQEMBQADggIBAAAQ+fotZE79FfZ8Lz7eiTUzlwHXCdSE2XD3nMROu6n6
+uLTBPrf2C+k+U1FjKVvL5/WCUj6hIzP2X6Sb8+o0XHX9mKN0yoMORTEYJOnazYPK
+KSUUFnE4vGgQkr6k/31gGRMTICdnf3VOOAlUCQ5bOmGIuWi401E3sbd85U+TJe0A
+nHlU+XjtfzlqcBvQivdbA0s+GEG55uRPvn952aTBEMHfn+2JqKeLShl4AtUAfu+h
+6md3Z2HrEC7B3GK8ekWPu0G/ZuWTuFvOimZ+5C8IPRJXcIR/siPQl1x6dpTCew6t
+lPVcVuvg6SQwzvxetkNrGUe2Wb2s9+PK2PUvfOS8ee25SNmfG3XK9qJpqGUhzSBX
+T8QQgyxd0Su5G7Wze7aaHZd/fqIm/G8YFR0HiC2xni/lnDTXFDPCe+HCnSk0bH6U
+wpr6I1yK8oZ2IdnNVfuABGMmGOhvSQ8r7//ea9WKhCsGNQawpVWVioY7hpyNAJ0O
+Vn4xqG5f6allz8lgpwAQ+AeEEClHca6hh6mj9KhD1Of1CC2Vx52GHNh/jMYEc3/g
+zLKniencBqn3Y2XH2daITGJddcleN09+a1NaTkT3hgr7LumxM8EVssPkC+z9j4Vf
+Gbste+8S5QCMhh00g5vR9QF8EaFqdxCdSxrsA4GmpCa5UQl8jtCnpp2DLKXuOh72
+-----END CERTIFICATE-----
diff --git a/toolkit/components/normandy/test/unit/query_server.sjs b/toolkit/components/normandy/test/unit/query_server.sjs
new file mode 100644
index 0000000000..dd00d74bf6
--- /dev/null
+++ b/toolkit/components/normandy/test/unit/query_server.sjs
@@ -0,0 +1,34 @@
+const CC = Components.Constructor;
+const BinaryInputStream = CC(
+ "@mozilla.org/binaryinputstream;1",
+ "nsIBinaryInputStream",
+ "setInputStream"
+);
+
+// Returns a JSON string containing the query string arguments and the
+// request body parsed as JSON.
+function handleRequest(request, response) {
+ // Allow cross-origin, so you can XHR to it!
+ response.setHeader("Access-Control-Allow-Origin", "*", false);
+ // Avoid confusing cache behaviors
+ response.setHeader("Cache-Control", "no-cache", false);
+ response.setHeader("Content-Type", "application/json", false);
+
+ // Read request body
+ const inputStream = new BinaryInputStream(request.bodyInputStream);
+ let bytes = [];
+ let available;
+ while ((available = inputStream.available()) > 0) {
+ bytes = bytes.concat(inputStream.readByteArray(available));
+ }
+ const body = String.fromCharCode.apply(null, bytes);
+
+ // Write response body
+ const data = { queryString: {}, body: body ? JSON.parse(body) : {} };
+ const params = request.queryString.split("&");
+ for (const param of params) {
+ const [key, value] = param.split("=");
+ data.queryString[key] = value;
+ }
+ response.write(JSON.stringify(data));
+}
diff --git a/toolkit/components/normandy/test/unit/test_Normandy.js b/toolkit/components/normandy/test/unit/test_Normandy.js
new file mode 100644
index 0000000000..5bb1655fb8
--- /dev/null
+++ b/toolkit/components/normandy/test/unit/test_Normandy.js
@@ -0,0 +1,95 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { Normandy } = ChromeUtils.importESModule(
+ "resource://normandy/Normandy.sys.mjs"
+);
+const { NormandyMigrations } = ChromeUtils.importESModule(
+ "resource://normandy/NormandyMigrations.sys.mjs"
+);
+const { PromiseUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromiseUtils.sys.mjs"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ TestUtils: "resource://testing-common/TestUtils.sys.mjs",
+});
+
+/* import-globals-from utils.js */
+load("utils.js");
+
+NormandyTestUtils.init({ add_task });
+const { decorate_task } = NormandyTestUtils;
+
+// Normandy's initialization function should set the start preferences before
+// its first `await`.
+decorate_task(
+ NormandyTestUtils.withStub(Normandy, "finishInit"),
+ NormandyTestUtils.withStub(NormandyMigrations, "applyAll"),
+ NormandyTestUtils.withMockPreferences(),
+ async function test_normandy_init_applies_startup_prefs_synchronously({
+ mockPreferences,
+ }) {
+ const experimentPref = "test.experiment";
+ const rolloutPref = "test.rollout";
+ const experimentStartupPref = `app.normandy.startupExperimentPrefs.${experimentPref}`;
+ const rolloutStartupPref = `app.normandy.startupRolloutPrefs.${rolloutPref}`;
+
+ mockPreferences.preserve(experimentPref, "default");
+ mockPreferences.preserve(rolloutPref, "default");
+ mockPreferences.set(experimentStartupPref, "experiment");
+ mockPreferences.set(rolloutStartupPref, "rollout");
+
+ Assert.equal(
+ Services.prefs.getCharPref(experimentPref, "default"),
+ "default"
+ );
+ Assert.equal(Services.prefs.getCharPref(rolloutPref, "default"), "default");
+
+ let initPromise = Normandy.init({ runAsync: false });
+
+ // note: There are no awaits before these asserts, so only the part of
+ // Normandy's initialization before its first await can run.
+ Assert.equal(
+ Services.prefs.getCharPref(experimentPref, "default"),
+ "experiment"
+ );
+ Assert.equal(Services.prefs.getCharPref(rolloutPref, "default"), "rollout");
+
+ await initPromise;
+ await Normandy.uninit();
+ }
+);
+
+// Normandy's initialization function should register the observer for UI
+// startup before it's first await.
+decorate_task(
+ NormandyTestUtils.withStub(Normandy, "finishInit"),
+ NormandyTestUtils.withStub(NormandyMigrations, "applyAll"),
+ async function test_normandy_init_applies_startup_prefs_synchronously({
+ applyAllStub,
+ }) {
+ let originalDeferred = Normandy.uiAvailableNotificationObserved;
+ let mockUiAvailableDeferred = PromiseUtils.defer();
+ Normandy.uiAvailableNotificationObserved = mockUiAvailableDeferred;
+
+ let applyAllDeferred = PromiseUtils.defer();
+ applyAllStub.returns(applyAllStub);
+
+ let promiseResolvedCount = 0;
+ mockUiAvailableDeferred.promise.then(() => promiseResolvedCount++);
+
+ let initPromise = Normandy.init();
+
+ Assert.equal(promiseResolvedCount, 0);
+ Normandy.observe(null, "sessionstore-windows-restored");
+ await TestUtils.waitForCondition(() => promiseResolvedCount === 1);
+
+ applyAllDeferred.resolve();
+
+ await initPromise;
+ await Normandy.uninit();
+ Normandy.uiAvailableNotificationObserved = originalDeferred;
+ }
+);
diff --git a/toolkit/components/normandy/test/unit/test_NormandyApi.js b/toolkit/components/normandy/test/unit/test_NormandyApi.js
new file mode 100644
index 0000000000..885bd9fbdb
--- /dev/null
+++ b/toolkit/components/normandy/test/unit/test_NormandyApi.js
@@ -0,0 +1,257 @@
+/* globals sinon */
+"use strict";
+
+const { PromiseUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromiseUtils.sys.mjs"
+);
+
+/* import-globals-from utils.js */
+load("utils.js");
+
+NormandyTestUtils.init({ add_task });
+const { decorate_task } = NormandyTestUtils;
+
+Cu.importGlobalProperties(["fetch"]);
+
+decorate_task(withMockApiServer(), async function test_get({ serverUrl }) {
+ // Test that NormandyApi can fetch from the test server.
+ const response = await NormandyApi.get(`${serverUrl}/api/v1/`);
+ const data = await response.json();
+ equal(
+ data["recipe-signed"],
+ "/api/v1/recipe/signed/",
+ "Expected data in response"
+ );
+});
+
+decorate_task(
+ withMockApiServer(),
+ async function test_getApiUrl({ serverUrl }) {
+ const apiBase = `${serverUrl}/api/v1`;
+ // Test that NormandyApi can use the self-describing API's index
+ const recipeListUrl = await NormandyApi.getApiUrl("extension-list");
+ equal(
+ recipeListUrl,
+ `${apiBase}/extension/`,
+ "Can retrieve extension-list URL from API"
+ );
+ }
+);
+
+decorate_task(
+ withMockApiServer(),
+ async function test_getApiUrlSlashes({ serverUrl, mockPreferences }) {
+ const fakeResponse = new MockResponse(
+ JSON.stringify({ "test-endpoint": `${serverUrl}/test/` })
+ );
+ const mockGet = sinon
+ .stub(NormandyApi, "get")
+ .callsFake(async () => fakeResponse);
+
+ // without slash
+ {
+ NormandyApi.clearIndexCache();
+ mockPreferences.set("app.normandy.api_url", `${serverUrl}/api/v1`);
+ const endpoint = await NormandyApi.getApiUrl("test-endpoint");
+ equal(endpoint, `${serverUrl}/test/`);
+ ok(
+ mockGet.calledWithExactly(`${serverUrl}/api/v1/`),
+ "trailing slash was added"
+ );
+ mockGet.resetHistory();
+ }
+
+ // with slash
+ {
+ NormandyApi.clearIndexCache();
+ mockPreferences.set("app.normandy.api_url", `${serverUrl}/api/v1/`);
+ const endpoint = await NormandyApi.getApiUrl("test-endpoint");
+ equal(endpoint, `${serverUrl}/test/`);
+ ok(
+ mockGet.calledWithExactly(`${serverUrl}/api/v1/`),
+ "existing trailing slash was preserved"
+ );
+ mockGet.resetHistory();
+ }
+
+ NormandyApi.clearIndexCache();
+ mockGet.restore();
+ }
+);
+
+// Test validation errors due to validation throwing an exception (e.g. when
+// parameters passed to validation are malformed).
+decorate_task(
+ withMockApiServer(),
+ async function test_validateSignedObject_validation_error() {
+ // Mock the x5u URL
+ const getStub = sinon.stub(NormandyApi, "get").callsFake(async url => {
+ ok(url.endsWith("x5u/"), "the only request should be to fetch the x5u");
+ return new MockResponse("certchain");
+ });
+
+ const signedObject = { a: 1, b: 2 };
+ const signature = {
+ signature: "invalidsignature",
+ x5u: "http://localhost/x5u/",
+ };
+
+ // Validation should fail due to a malformed x5u and signature.
+ try {
+ await NormandyApi.verifyObjectSignature(
+ signedObject,
+ signature,
+ "object"
+ );
+ ok(false, "validateSignedObject did not throw for a validation error");
+ } catch (err) {
+ ok(
+ err instanceof NormandyApi.InvalidSignatureError,
+ "Error is an InvalidSignatureError"
+ );
+ ok(/signature/.test(err), "Error is due to a validation error");
+ }
+
+ getStub.restore();
+ }
+);
+
+// Test validation errors due to validation returning false (e.g. when parameters
+// passed to validation are correctly formed, but not valid for the data).
+decorate_task(
+ withMockApiServer("invalid_recipe_signature_api"),
+ async function test_verifySignedObject_invalid_signature() {
+ // Get the test recipe and signature from the mock server.
+ const recipesUrl = await NormandyApi.getApiUrl("recipe-signed");
+ const recipeResponse = await NormandyApi.get(recipesUrl);
+ const recipes = await recipeResponse.json();
+ equal(recipes.length, 1, "Test data has one recipe");
+ const [{ recipe, signature }] = recipes;
+
+ try {
+ await NormandyApi.verifyObjectSignature(recipe, signature, "recipe");
+ ok(false, "verifyObjectSignature did not throw for an invalid signature");
+ } catch (err) {
+ ok(
+ err instanceof NormandyApi.InvalidSignatureError,
+ "Error is an InvalidSignatureError"
+ );
+ ok(/signature/.test(err), "Error is due to an invalid signature");
+ }
+ }
+);
+
+decorate_task(withMockApiServer(), async function test_classifyClient() {
+ const classification = await NormandyApi.classifyClient();
+ Assert.deepEqual(classification, {
+ country: "US",
+ request_time: new Date("2017-02-22T17:43:24.657841Z"),
+ });
+});
+
+decorate_task(withMockApiServer(), async function test_fetchExtensionDetails() {
+ const extensionDetails = await NormandyApi.fetchExtensionDetails(1);
+ deepEqual(extensionDetails, {
+ id: 1,
+ name: "Normandy Fixture",
+ xpi: "http://example.com/browser/toolkit/components/normandy/test/browser/fixtures/normandy.xpi",
+ extension_id: "normandydriver@example.com",
+ version: "1.0",
+ hash: "ade1c14196ec4fe0aa0a6ba40ac433d7c8d1ec985581a8a94d43dc58991b5171",
+ hash_algorithm: "sha256",
+ });
+});
+
+decorate_task(
+ withScriptServer("query_server.sjs"),
+ async function test_getTestServer({ serverUrl }) {
+ // Test that NormandyApi can fetch from the test server.
+ const response = await NormandyApi.get(serverUrl);
+ const data = await response.json();
+ Assert.deepEqual(
+ data,
+ { queryString: {}, body: {} },
+ "NormandyApi returned incorrect server data."
+ );
+ }
+);
+
+decorate_task(
+ withScriptServer("query_server.sjs"),
+ async function test_getQueryString({ serverUrl }) {
+ // Test that NormandyApi can send query string parameters to the test server.
+ const response = await NormandyApi.get(serverUrl, {
+ foo: "bar",
+ baz: "biff",
+ });
+ const data = await response.json();
+ Assert.deepEqual(
+ data,
+ { queryString: { foo: "bar", baz: "biff" }, body: {} },
+ "NormandyApi sent an incorrect query string."
+ );
+ }
+);
+
+// Test that no credentials are sent, even if the cookie store contains them.
+decorate_task(
+ withScriptServer("cookie_server.sjs"),
+ async function test_sendsNoCredentials({ serverUrl }) {
+ // This test uses cookie_server.sjs, which responds to all requests with a
+ // response that sets a cookie.
+
+ // send a request, to store a cookie in the cookie store
+ await fetch(serverUrl);
+
+ // A normal request should send that cookie
+ const cookieExpectedDeferred = PromiseUtils.defer();
+ function cookieExpectedObserver(aSubject, aTopic, aData) {
+ equal(
+ aTopic,
+ "http-on-modify-request",
+ "Only the expected topic should be observed"
+ );
+ let httpChannel = aSubject.QueryInterface(Ci.nsIHttpChannel);
+ equal(
+ httpChannel.getRequestHeader("Cookie"),
+ "type=chocolate-chip",
+ "The header should be sent"
+ );
+ Services.obs.removeObserver(
+ cookieExpectedObserver,
+ "http-on-modify-request"
+ );
+ cookieExpectedDeferred.resolve();
+ }
+ Services.obs.addObserver(cookieExpectedObserver, "http-on-modify-request");
+ await fetch(serverUrl);
+ await cookieExpectedDeferred.promise;
+
+ // A request through the NormandyApi method should not send that cookie
+ const cookieNotExpectedDeferred = PromiseUtils.defer();
+ function cookieNotExpectedObserver(aSubject, aTopic, aData) {
+ equal(
+ aTopic,
+ "http-on-modify-request",
+ "Only the expected topic should be observed"
+ );
+ let httpChannel = aSubject.QueryInterface(Ci.nsIHttpChannel);
+ Assert.throws(
+ () => httpChannel.getRequestHeader("Cookie"),
+ /NS_ERROR_NOT_AVAILABLE/,
+ "The cookie header should not be sent"
+ );
+ Services.obs.removeObserver(
+ cookieNotExpectedObserver,
+ "http-on-modify-request"
+ );
+ cookieNotExpectedDeferred.resolve();
+ }
+ Services.obs.addObserver(
+ cookieNotExpectedObserver,
+ "http-on-modify-request"
+ );
+ await NormandyApi.get(serverUrl);
+ await cookieNotExpectedDeferred.promise;
+ }
+);
diff --git a/toolkit/components/normandy/test/unit/test_PrefUtils.js b/toolkit/components/normandy/test/unit/test_PrefUtils.js
new file mode 100644
index 0000000000..57130d8783
--- /dev/null
+++ b/toolkit/components/normandy/test/unit/test_PrefUtils.js
@@ -0,0 +1,223 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { PrefUtils } = ChromeUtils.importESModule(
+ "resource://normandy/lib/PrefUtils.sys.mjs"
+);
+
+add_task(function getPrefGetsValues() {
+ const defaultBranch = Services.prefs.getDefaultBranch("");
+ const userBranch = Services.prefs;
+
+ defaultBranch.setBoolPref("test.bool", false);
+ userBranch.setBoolPref("test.bool", true);
+ defaultBranch.setIntPref("test.int", 1);
+ userBranch.setIntPref("test.int", 2);
+ defaultBranch.setStringPref("test.string", "default");
+ userBranch.setStringPref("test.string", "user");
+
+ equal(
+ PrefUtils.getPref("test.bool", { branch: "user" }),
+ true,
+ "should read user branch bools"
+ );
+ equal(
+ PrefUtils.getPref("test.int", { branch: "user" }),
+ 2,
+ "should read user branch ints"
+ );
+ equal(
+ PrefUtils.getPref("test.string", { branch: "user" }),
+ "user",
+ "should read user branch strings"
+ );
+
+ equal(
+ PrefUtils.getPref("test.bool", { branch: "default" }),
+ false,
+ "should read default branch bools"
+ );
+ equal(
+ PrefUtils.getPref("test.int", { branch: "default" }),
+ 1,
+ "should read default branch ints"
+ );
+ equal(
+ PrefUtils.getPref("test.string", { branch: "default" }),
+ "default",
+ "should read default branch strings"
+ );
+
+ equal(
+ PrefUtils.getPref("test.bool"),
+ true,
+ "should read bools from the user branch by default"
+ );
+ equal(
+ PrefUtils.getPref("test.int"),
+ 2,
+ "should read ints from the user branch by default"
+ );
+ equal(
+ PrefUtils.getPref("test.string"),
+ "user",
+ "should read strings from the user branch by default"
+ );
+
+ equal(
+ PrefUtils.getPref("test.does_not_exist"),
+ null,
+ "Should return null for non-existent prefs by default"
+ );
+ let defaultValue = Symbol();
+ equal(
+ PrefUtils.getPref("test.does_not_exist", { defaultValue }),
+ defaultValue,
+ "Should use the passed default value"
+ );
+});
+
+// This is an important test because the pref system can behave in strange ways
+// when the user branch has a value, but the default branch does not.
+add_task(function getPrefHandlesUserValueNoDefaultValue() {
+ Services.prefs.setStringPref("test.only-user-value", "user");
+
+ let defaultValue = Symbol();
+ equal(
+ PrefUtils.getPref("test.only-user-value", {
+ branch: "default",
+ defaultValue,
+ }),
+ defaultValue
+ );
+ equal(PrefUtils.getPref("test.only-user-value", { branch: "default" }), null);
+ equal(PrefUtils.getPref("test.only-user-value", { branch: "user" }), "user");
+ equal(PrefUtils.getPref("test.only-user-value"), "user");
+});
+
+add_task(function getPrefInvalidBranch() {
+ Assert.throws(
+ () => PrefUtils.getPref("test.pref", { branch: "invalid" }),
+ PrefUtils.UnexpectedPreferenceBranch
+ );
+});
+
+add_task(function setPrefSetsValues() {
+ const defaultBranch = Services.prefs.getDefaultBranch("");
+ const userBranch = Services.prefs;
+
+ defaultBranch.setIntPref("test.int", 1);
+ userBranch.setIntPref("test.int", 2);
+ defaultBranch.setStringPref("test.string", "default");
+ userBranch.setStringPref("test.string", "user");
+ defaultBranch.setBoolPref("test.bool", false);
+ userBranch.setBoolPref("test.bool", true);
+
+ PrefUtils.setPref("test.int", 3);
+ equal(
+ userBranch.getIntPref("test.int"),
+ 3,
+ "the user branch should change for ints"
+ );
+ PrefUtils.setPref("test.int", 4, { branch: "default" });
+ equal(
+ userBranch.getIntPref("test.int"),
+ 3,
+ "changing the default branch shouldn't affect the user branch for ints"
+ );
+ PrefUtils.setPref("test.int", null, { branch: "user" });
+ equal(
+ userBranch.getIntPref("test.int"),
+ 4,
+ "clearing the user branch should reveal the default value for ints"
+ );
+
+ PrefUtils.setPref("test.string", "user override");
+ equal(
+ userBranch.getStringPref("test.string"),
+ "user override",
+ "the user branch should change for strings"
+ );
+ PrefUtils.setPref("test.string", "default override", { branch: "default" });
+ equal(
+ userBranch.getStringPref("test.string"),
+ "user override",
+ "changing the default branch shouldn't affect the user branch for strings"
+ );
+ PrefUtils.setPref("test.string", null, { branch: "user" });
+ equal(
+ userBranch.getStringPref("test.string"),
+ "default override",
+ "clearing the user branch should reveal the default value for strings"
+ );
+
+ PrefUtils.setPref("test.bool", false);
+ equal(
+ userBranch.getBoolPref("test.bool"),
+ false,
+ "the user branch should change for bools"
+ );
+ // The above effectively unsets the user branch, since it is now the same as the default branch
+ PrefUtils.setPref("test.bool", true, { branch: "default" });
+ equal(
+ userBranch.getBoolPref("test.bool"),
+ true,
+ "the default branch should change for bools"
+ );
+
+ defaultBranch.setBoolPref("test.bool", false);
+ userBranch.setBoolPref("test.bool", true);
+ equal(
+ userBranch.getBoolPref("test.bool"),
+ true,
+ "the precondition should hold"
+ );
+ PrefUtils.setPref("test.bool", null, { branch: "user" });
+ equal(
+ userBranch.getBoolPref("test.bool"),
+ false,
+ "setting the user branch to null should reveal the default value for bools"
+ );
+});
+
+add_task(function setPrefInvalidBranch() {
+ Assert.throws(
+ () => PrefUtils.setPref("test.pref", "value", { branch: "invalid" }),
+ PrefUtils.UnexpectedPreferenceBranch
+ );
+});
+
+add_task(function clearPrefClearsValues() {
+ const defaultBranch = Services.prefs.getDefaultBranch("");
+ const userBranch = Services.prefs;
+
+ defaultBranch.setStringPref("test.string", "default");
+ userBranch.setStringPref("test.string", "user");
+ equal(
+ userBranch.getStringPref("test.string"),
+ "user",
+ "the precondition should hold"
+ );
+ PrefUtils.clearPref("test.string");
+ equal(
+ userBranch.getStringPref("test.string"),
+ "default",
+ "clearing the user branch should reveal the default value for bools"
+ );
+
+ PrefUtils.clearPref("test.string", { branch: "default" });
+ equal(
+ userBranch.getStringPref("test.string"),
+ "default",
+ "clearing the default branch shouldn't do anything"
+ );
+});
+
+add_task(function clearPrefInvalidBranch() {
+ Assert.throws(
+ () => PrefUtils.clearPref("test.pref", { branch: "invalid" }),
+ PrefUtils.UnexpectedPreferenceBranch
+ );
+});
diff --git a/toolkit/components/normandy/test/unit/test_RecipeRunner.js b/toolkit/components/normandy/test/unit/test_RecipeRunner.js
new file mode 100644
index 0000000000..710ac4d507
--- /dev/null
+++ b/toolkit/components/normandy/test/unit/test_RecipeRunner.js
@@ -0,0 +1,34 @@
+/**
+ * Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const { updateAppInfo } = ChromeUtils.importESModule(
+ "resource://testing-common/AppInfo.sys.mjs"
+);
+const { RecipeRunner } = ChromeUtils.importESModule(
+ "resource://normandy/lib/RecipeRunner.sys.mjs"
+);
+
+// Test that new build IDs trigger immediate recipe runs
+add_task(async () => {
+ updateAppInfo({
+ appBuildID: "new-build-id",
+ lastAppBuildID: "old-build-id",
+ });
+ const runStub = sinon.stub(RecipeRunner, "run");
+ const registerTimerStub = sinon.stub(RecipeRunner, "registerTimer");
+ sinon.stub(RecipeRunner, "watchPrefs");
+
+ Services.prefs.setBoolPref("app.normandy.first_run", false);
+
+ await RecipeRunner.init();
+ Assert.deepEqual(
+ runStub.args,
+ [[{ trigger: "newBuildID" }]],
+ "RecipeRunner.run is called immediately on a new build ID"
+ );
+ ok(registerTimerStub.called, "RecipeRunner.registerTimer registers a timer");
+
+ sinon.restore();
+});
diff --git a/toolkit/components/normandy/test/unit/test_addon_unenroll.js b/toolkit/components/normandy/test/unit/test_addon_unenroll.js
new file mode 100644
index 0000000000..98750fc976
--- /dev/null
+++ b/toolkit/components/normandy/test/unit/test_addon_unenroll.js
@@ -0,0 +1,310 @@
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+const { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+const { BranchedAddonStudyAction } = ChromeUtils.importESModule(
+ "resource://normandy/actions/BranchedAddonStudyAction.sys.mjs"
+);
+const { BaseAction } = ChromeUtils.importESModule(
+ "resource://normandy/actions/BaseAction.sys.mjs"
+);
+const { TelemetryEvents } = ChromeUtils.importESModule(
+ "resource://normandy/lib/TelemetryEvents.sys.mjs"
+);
+const { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+const { AddonStudies } = ChromeUtils.importESModule(
+ "resource://normandy/lib/AddonStudies.sys.mjs"
+);
+const { PromiseUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromiseUtils.sys.mjs"
+);
+
+/* import-globals-from utils.js */
+load("utils.js");
+
+NormandyTestUtils.init({ add_task });
+const { decorate_task } = NormandyTestUtils;
+
+const global = this;
+
+add_task(async () => {
+ ExtensionTestUtils.init(global);
+ AddonTestUtils.init(global);
+ AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "1.9.2"
+ );
+ AddonTestUtils.overrideCertDB();
+ await AddonTestUtils.promiseStartupManager();
+
+ TelemetryEvents.init();
+});
+
+decorate_task(
+ withMockApiServer(),
+ AddonStudies.withStudies([]),
+ async function test_addon_unenroll({ server: apiServer }) {
+ const ID = "study@tests.mozilla.org";
+
+ // Create a test extension that uses webextension experiments to install
+ // an unenroll listener.
+ let xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version: "1.0",
+
+ browser_specific_settings: {
+ gecko: { id: ID },
+ },
+
+ experiment_apis: {
+ study: {
+ schema: "schema.json",
+ parent: {
+ scopes: ["addon_parent"],
+ script: "api.js",
+ paths: [["study"]],
+ },
+ },
+ },
+ },
+
+ files: {
+ "schema.json": JSON.stringify([
+ {
+ namespace: "study",
+ events: [
+ {
+ name: "onStudyEnded",
+ type: "function",
+ },
+ ],
+ },
+ ]),
+
+ "api.js": () => {
+ // The code below is serialized into a file embedded in an extension.
+ // But by including it here as code, eslint can analyze it. However,
+ // this code runs in a different environment with different globals,
+ // the following two lines avoid false eslint warnings:
+ /* globals browser, ExtensionAPI */
+ /* eslint-disable-next-line no-shadow */
+ const { AddonStudies } = ChromeUtils.importESModule(
+ "resource://normandy/lib/AddonStudies.sys.mjs"
+ );
+ const { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+ );
+ this.study = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ study: {
+ onStudyEnded: new ExtensionCommon.EventManager({
+ context,
+ name: "study.onStudyEnded",
+ register: fire => {
+ AddonStudies.addUnenrollListener(
+ this.extension.id,
+ reason => fire.sync(reason)
+ );
+ return () => {};
+ },
+ }).api(),
+ },
+ };
+ }
+ };
+ },
+ },
+
+ background() {
+ browser.study.onStudyEnded.addListener(reason => {
+ browser.test.sendMessage("got-event", reason);
+ return new Promise(resolve => {
+ browser.test.onMessage.addListener(resolve);
+ });
+ });
+ },
+ });
+
+ const server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+ server.registerFile("/study.xpi", xpi);
+
+ const API_ID = 999;
+ apiServer.registerPathHandler(
+ `/api/v1/extension/${API_ID}/`,
+ (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write(
+ JSON.stringify({
+ id: API_ID,
+ name: "Addon Unenroll Fixture",
+ xpi: "http://example.com/study.xpi",
+ extension_id: ID,
+ version: "1.0",
+ hash: CryptoUtils.getFileHash(xpi, "sha256"),
+ hash_algorithm: "sha256",
+ })
+ );
+ }
+ );
+
+ // Begin by telling Normandy to install the test extension above
+ // that uses a webextension experiment to register a blocking callback
+ // to be invoked when the study ends.
+ let extension = ExtensionTestUtils.expectExtension(ID);
+
+ const RECIPE_ID = 1;
+ const UNENROLL_REASON = "test-ending";
+ let action = new BranchedAddonStudyAction();
+ await action.processRecipe(
+ {
+ id: RECIPE_ID,
+ type: "addon-study",
+ arguments: {
+ slug: "addon-unenroll-test",
+ userFacingDescription: "A recipe to test add-on unenrollment",
+ userFacingName: "Add-on Unenroll Test",
+ isEnrollmentPaused: false,
+ branches: [
+ {
+ ratio: 1,
+ slug: "only",
+ extensionApiId: API_ID,
+ },
+ ],
+ },
+ },
+ BaseAction.suitability.FILTER_MATCH
+ );
+
+ await extension.awaitStartup();
+
+ let addon = await AddonManager.getAddonByID(ID);
+ ok(addon, "Extension is installed");
+
+ // Tell Normandy to end the study, the extension event should be fired.
+ let unenrollPromise = action.unenroll(RECIPE_ID, UNENROLL_REASON);
+
+ let receivedReason = await extension.awaitMessage("got-event");
+ info("Got onStudyEnded event in extension");
+ equal(receivedReason, UNENROLL_REASON, "Unenroll reason should be passed");
+
+ // The extension has not yet finished its unenrollment tasks, so it
+ // should not yet be uninstalled.
+ addon = await AddonManager.getAddonByID(ID);
+ ok(addon, "Extension has not yet been uninstalled");
+
+ // Once the extension does resolve the promise returned from the
+ // event listener, the uninstall can proceed.
+ extension.sendMessage("resolve");
+ await unenrollPromise;
+
+ addon = await AddonManager.getAddonByID(ID);
+ equal(
+ addon,
+ null,
+ "After resolving studyEnded promise, extension is uninstalled"
+ );
+ }
+);
+
+/* Test that a broken unenroll listener doesn't stop the add-on from being removed */
+decorate_task(
+ withMockApiServer(),
+ AddonStudies.withStudies([]),
+ async function test_addon_unenroll({ server: apiServer }) {
+ const ID = "study@tests.mozilla.org";
+
+ // Create a dummy webextension
+ // an unenroll listener that throws an error.
+ let xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version: "1.0",
+
+ browser_specific_settings: {
+ gecko: { id: ID },
+ },
+ },
+ });
+
+ const server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+ server.registerFile("/study.xpi", xpi);
+
+ const API_ID = 999;
+ apiServer.registerPathHandler(
+ `/api/v1/extension/${API_ID}/`,
+ (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write(
+ JSON.stringify({
+ id: API_ID,
+ name: "Addon Fixture",
+ xpi: "http://example.com/study.xpi",
+ extension_id: ID,
+ version: "1.0",
+ hash: CryptoUtils.getFileHash(xpi, "sha256"),
+ hash_algorithm: "sha256",
+ })
+ );
+ }
+ );
+
+ // Begin by telling Normandy to install the test extension above that uses a
+ // webextension experiment to register a callback when the study ends that
+ // throws an error.
+ let extension = ExtensionTestUtils.expectExtension(ID);
+
+ const RECIPE_ID = 1;
+ const UNENROLL_REASON = "test-ending";
+ let action = new BranchedAddonStudyAction();
+ await action.processRecipe(
+ {
+ id: RECIPE_ID,
+ type: "addon-study",
+ arguments: {
+ slug: "addon-unenroll-test",
+ userFacingDescription: "A recipe to test add-on unenrollment",
+ userFacingName: "Add-on Unenroll Test",
+ isEnrollmentPaused: false,
+ branches: [
+ {
+ ratio: 1,
+ slug: "only",
+ extensionApiId: API_ID,
+ },
+ ],
+ },
+ },
+ BaseAction.suitability.FILTER_MATCH
+ );
+
+ await extension.startupPromise;
+
+ let addon = await AddonManager.getAddonByID(ID);
+ ok(addon, "Extension is installed");
+
+ let listenerDeferred = PromiseUtils.defer();
+
+ AddonStudies.addUnenrollListener(ID, () => {
+ listenerDeferred.resolve();
+ throw new Error("This listener is busted");
+ });
+
+ // Tell Normandy to end the study, the extension event should be fired.
+ await action.unenroll(RECIPE_ID, UNENROLL_REASON);
+ await listenerDeferred;
+
+ addon = await AddonManager.getAddonByID(ID);
+ equal(
+ addon,
+ null,
+ "Extension is uninstalled even though it threw an exception in the callback"
+ );
+ }
+);
diff --git a/toolkit/components/normandy/test/unit/utils.js b/toolkit/components/normandy/test/unit/utils.js
new file mode 100644
index 0000000000..cffe634c91
--- /dev/null
+++ b/toolkit/components/normandy/test/unit/utils.js
@@ -0,0 +1,135 @@
+"use strict";
+/* eslint-disable no-unused-vars */
+
+// Loaded into the same scope as head_xpc.js
+/* import-globals-from head_xpc.js */
+
+const { Preferences } = ChromeUtils.importESModule(
+ "resource://gre/modules/Preferences.sys.mjs"
+);
+const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+
+const { NormandyApi } = ChromeUtils.importESModule(
+ "resource://normandy/lib/NormandyApi.sys.mjs"
+);
+const { NormandyTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/NormandyTestUtils.sys.mjs"
+);
+
+const CryptoHash = Components.Constructor(
+ "@mozilla.org/security/hash;1",
+ "nsICryptoHash",
+ "initWithString"
+);
+const FileInputStream = Components.Constructor(
+ "@mozilla.org/network/file-input-stream;1",
+ "nsIFileInputStream",
+ "init"
+);
+
+class MockResponse {
+ constructor(content) {
+ this.content = content;
+ }
+
+ async text() {
+ return this.content;
+ }
+
+ async json() {
+ return JSON.parse(this.content);
+ }
+}
+
+function withServer(server) {
+ return function (testFunction) {
+ return NormandyTestUtils.decorate(
+ NormandyTestUtils.withMockPreferences(),
+ async function inner({ mockPreferences, ...args }) {
+ const serverUrl = `http://localhost:${server.identity.primaryPort}`;
+ mockPreferences.set("app.normandy.api_url", `${serverUrl}/api/v1`);
+ NormandyApi.clearIndexCache();
+
+ try {
+ await testFunction({ ...args, serverUrl, mockPreferences, server });
+ } finally {
+ await new Promise(resolve => server.stop(resolve));
+ }
+ }
+ );
+ };
+}
+
+function makeScriptServer(scriptPath) {
+ const server = new HttpServer();
+ server.registerContentType("sjs", "sjs");
+ server.registerFile("/", do_get_file(scriptPath));
+ server.start(-1);
+ return server;
+}
+
+function withScriptServer(scriptPath) {
+ return withServer(makeScriptServer(scriptPath));
+}
+
+function makeMockApiServer(directory) {
+ const server = new HttpServer();
+ server.registerDirectory("/", directory);
+
+ server.setIndexHandler(async function (request, response) {
+ response.processAsync();
+ const dir = request.getProperty("directory");
+ const index = dir.clone();
+ index.append("index.json");
+
+ if (!index.exists()) {
+ response.setStatusLine("1.1", 404, "Not Found");
+ response.write(`Cannot find path ${index.path}`);
+ response.finish();
+ return;
+ }
+
+ try {
+ const contents = await IOUtils.readUTF8(index.path);
+ response.write(contents);
+ } catch (e) {
+ response.setStatusLine("1.1", 500, "Server error");
+ response.write(e.toString());
+ } finally {
+ response.finish();
+ }
+ });
+
+ server.start(-1);
+ return server;
+}
+
+function withMockApiServer(apiName = "mock_api") {
+ return withServer(makeMockApiServer(do_get_file(apiName)));
+}
+
+const CryptoUtils = {
+ _getHashStringForCrypto(aCrypto) {
+ // return the two-digit hexadecimal code for a byte
+ let toHexString = charCode => ("0" + charCode.toString(16)).slice(-2);
+
+ // convert the binary hash data to a hex string.
+ let binary = aCrypto.finish(false);
+ let hash = Array.from(binary, c => toHexString(c.charCodeAt(0)));
+ return hash.join("").toLowerCase();
+ },
+
+ /**
+ * Get the computed hash for a given file
+ * @param {nsIFile} file The file to be hashed
+ * @param {string} [algorithm] The hashing algorithm to use
+ */
+ getFileHash(file, algorithm = "sha256") {
+ const crypto = CryptoHash(algorithm);
+ const fis = new FileInputStream(file, -1, -1, false);
+ crypto.updateFromStream(fis, file.fileSize);
+ const hash = this._getHashStringForCrypto(crypto);
+ fis.close();
+ return hash;
+ },
+};
diff --git a/toolkit/components/normandy/test/unit/xpcshell.ini b/toolkit/components/normandy/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..e2ec476ce9
--- /dev/null
+++ b/toolkit/components/normandy/test/unit/xpcshell.ini
@@ -0,0 +1,17 @@
+[DEFAULT]
+head = head_xpc.js
+firefox-appdir = browser
+support-files =
+ mock_api/**
+ invalid_recipe_signature_api/**
+ query_server.sjs
+ echo_server.sjs
+ cookie_server.sjs
+ utils.js
+tags = normandy
+
+[test_Normandy.js]
+[test_PrefUtils.js]
+[test_addon_unenroll.js]
+[test_NormandyApi.js]
+[test_RecipeRunner.js]