summaryrefslogtreecommitdiffstats
path: root/dom/push/test/xpcshell
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /dom/push/test/xpcshell
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'dom/push/test/xpcshell')
-rw-r--r--dom/push/test/xpcshell/broadcast_handler.sys.mjs12
-rw-r--r--dom/push/test/xpcshell/head-http2.js44
-rw-r--r--dom/push/test/xpcshell/head.js497
-rw-r--r--dom/push/test/xpcshell/moz.build3
-rw-r--r--dom/push/test/xpcshell/test_broadcast_success.js428
-rw-r--r--dom/push/test/xpcshell/test_clearAll_successful.js130
-rw-r--r--dom/push/test/xpcshell/test_clear_forgetAboutSite.js225
-rw-r--r--dom/push/test/xpcshell/test_clear_origin_data.js132
-rw-r--r--dom/push/test/xpcshell/test_crypto.js666
-rw-r--r--dom/push/test/xpcshell/test_crypto_encrypt.js199
-rw-r--r--dom/push/test/xpcshell/test_drop_expired.js164
-rw-r--r--dom/push/test/xpcshell/test_handler_service.js74
-rw-r--r--dom/push/test/xpcshell/test_notification_ack.js163
-rw-r--r--dom/push/test/xpcshell/test_notification_data.js310
-rw-r--r--dom/push/test/xpcshell/test_notification_duplicate.js175
-rw-r--r--dom/push/test/xpcshell/test_notification_error.js151
-rw-r--r--dom/push/test/xpcshell/test_notification_incomplete.js151
-rw-r--r--dom/push/test/xpcshell/test_notification_version_string.js79
-rw-r--r--dom/push/test/xpcshell/test_observer_data.js61
-rw-r--r--dom/push/test/xpcshell/test_observer_remoting.js139
-rw-r--r--dom/push/test/xpcshell/test_permissions.js332
-rw-r--r--dom/push/test/xpcshell/test_quota_exceeded.js156
-rw-r--r--dom/push/test/xpcshell/test_quota_observer.js210
-rw-r--r--dom/push/test/xpcshell/test_quota_with_notification.js129
-rw-r--r--dom/push/test/xpcshell/test_reconnect_retry.js90
-rw-r--r--dom/push/test/xpcshell/test_record.js132
-rw-r--r--dom/push/test/xpcshell/test_register_5xxCode_http2.js129
-rw-r--r--dom/push/test/xpcshell/test_register_case.js67
-rw-r--r--dom/push/test/xpcshell/test_register_flush.js116
-rw-r--r--dom/push/test/xpcshell/test_register_invalid_channel.js63
-rw-r--r--dom/push/test/xpcshell/test_register_invalid_endpoint.js64
-rw-r--r--dom/push/test/xpcshell/test_register_invalid_json.js60
-rw-r--r--dom/push/test/xpcshell/test_register_no_id.js66
-rw-r--r--dom/push/test/xpcshell/test_register_request_queue.js78
-rw-r--r--dom/push/test/xpcshell/test_register_rollback.js102
-rw-r--r--dom/push/test/xpcshell/test_register_success.js93
-rw-r--r--dom/push/test/xpcshell/test_register_timeout.js102
-rw-r--r--dom/push/test/xpcshell/test_register_wrong_id.js75
-rw-r--r--dom/push/test/xpcshell/test_register_wrong_type.js66
-rw-r--r--dom/push/test/xpcshell/test_registration_error.js44
-rw-r--r--dom/push/test/xpcshell/test_registration_error_http2.js38
-rw-r--r--dom/push/test/xpcshell/test_registration_missing_scope.js24
-rw-r--r--dom/push/test/xpcshell/test_registration_none.js30
-rw-r--r--dom/push/test/xpcshell/test_registration_success.js66
-rw-r--r--dom/push/test/xpcshell/test_registration_success_http2.js77
-rw-r--r--dom/push/test/xpcshell/test_resubscribe_4xxCode_http2.js113
-rw-r--r--dom/push/test/xpcshell/test_resubscribe_5xxCode_http2.js116
-rw-r--r--dom/push/test/xpcshell/test_resubscribe_listening_for_msg_error_http2.js116
-rw-r--r--dom/push/test/xpcshell/test_retry_ws.js73
-rw-r--r--dom/push/test/xpcshell/test_service_child.js352
-rw-r--r--dom/push/test/xpcshell/test_service_parent.js35
-rw-r--r--dom/push/test/xpcshell/test_unregister_empty_scope.js44
-rw-r--r--dom/push/test/xpcshell/test_unregister_error.js72
-rw-r--r--dom/push/test/xpcshell/test_unregister_invalid_json.js102
-rw-r--r--dom/push/test/xpcshell/test_unregister_not_found.js41
-rw-r--r--dom/push/test/xpcshell/test_unregister_success.js84
-rw-r--r--dom/push/test/xpcshell/test_unregister_success_http2.js78
-rw-r--r--dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_http2.js72
-rw-r--r--dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_ws.js102
-rw-r--r--dom/push/test/xpcshell/xpcshell.toml121
60 files changed, 7733 insertions, 0 deletions
diff --git a/dom/push/test/xpcshell/broadcast_handler.sys.mjs b/dom/push/test/xpcshell/broadcast_handler.sys.mjs
new file mode 100644
index 0000000000..eecf220a6f
--- /dev/null
+++ b/dom/push/test/xpcshell/broadcast_handler.sys.mjs
@@ -0,0 +1,12 @@
+export var broadcastHandler = {
+ reset() {
+ this.notifications = [];
+
+ this.wasNotified = new Promise((resolve, reject) => {
+ this.receivedBroadcastMessage = function () {
+ resolve();
+ this.notifications.push(Array.from(arguments));
+ };
+ });
+ },
+};
diff --git a/dom/push/test/xpcshell/head-http2.js b/dom/push/test/xpcshell/head-http2.js
new file mode 100644
index 0000000000..ef9ec61690
--- /dev/null
+++ b/dom/push/test/xpcshell/head-http2.js
@@ -0,0 +1,44 @@
+const { NetUtil } = ChromeUtils.importESModule(
+ "resource://gre/modules/NetUtil.sys.mjs"
+);
+
+// Returns the test H/2 server port, throwing if it's missing or invalid.
+function getTestServerPort() {
+ let portEnv = Services.env.get("MOZHTTP2_PORT");
+ let port = parseInt(portEnv, 10);
+ if (!Number.isFinite(port) || port < 1 || port > 65535) {
+ throw new Error(`Invalid port in MOZHTTP2_PORT env var: ${portEnv}`);
+ }
+ info(`Using HTTP/2 server on port ${port}`);
+ return port;
+}
+
+function readFile(file) {
+ let fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ fstream.init(file, -1, 0, 0);
+ let data = NetUtil.readInputStreamToString(fstream, fstream.available());
+ fstream.close();
+ return data;
+}
+
+function addCertFromFile(certdb, filename, trustString) {
+ let certFile = do_get_file(filename, false);
+ let pem = readFile(certFile)
+ .replace(/-----BEGIN CERTIFICATE-----/, "")
+ .replace(/-----END CERTIFICATE-----/, "")
+ .replace(/[\r\n]/g, "");
+ certdb.addCertFromBase64(pem, trustString);
+}
+
+function trustHttp2CA() {
+ let certdb = Cc["@mozilla.org/security/x509certdb;1"].getService(
+ Ci.nsIX509CertDB
+ );
+ addCertFromFile(
+ certdb,
+ "../../../../netwerk/test/unit/http2-ca.pem",
+ "CTu,u,u"
+ );
+}
diff --git a/dom/push/test/xpcshell/head.js b/dom/push/test/xpcshell/head.js
new file mode 100644
index 0000000000..da50ee3c5c
--- /dev/null
+++ b/dom/push/test/xpcshell/head.js
@@ -0,0 +1,497 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs",
+ PermissionTestUtils: "resource://testing-common/PermissionTestUtils.sys.mjs",
+ PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ Preferences: "resource://gre/modules/Preferences.sys.mjs",
+ PushCrypto: "resource://gre/modules/PushCrypto.sys.mjs",
+ PushService: "resource://gre/modules/PushService.sys.mjs",
+ PushServiceHttp2: "resource://gre/modules/PushService.sys.mjs",
+ PushServiceWebSocket: "resource://gre/modules/PushService.sys.mjs",
+ pushBroadcastService: "resource://gre/modules/PushBroadcastService.sys.mjs",
+});
+
+var {
+ clearInterval,
+ clearTimeout,
+ setInterval,
+ setIntervalWithTarget,
+ setTimeout,
+ setTimeoutWithTarget,
+} = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs");
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "PushServiceComponent",
+ "@mozilla.org/push/Service;1",
+ "nsIPushService"
+);
+
+const servicePrefs = new Preferences("dom.push.");
+
+const WEBSOCKET_CLOSE_GOING_AWAY = 1001;
+
+const MS_IN_ONE_DAY = 24 * 60 * 60 * 1000;
+
+var isParent =
+ Services.appinfo.processType == Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT;
+
+// Stop and clean up after the PushService.
+Services.obs.addObserver(function observe(subject, topic, data) {
+ Services.obs.removeObserver(observe, topic);
+ PushService.uninit();
+ // Occasionally, `profile-change-teardown` and `xpcom-shutdown` will fire
+ // before the PushService and AlarmService finish writing to IndexedDB. This
+ // causes spurious errors and crashes, so we spin the event loop to let the
+ // writes finish.
+ let done = false;
+ setTimeout(() => (done = true), 1000);
+ let thread = Services.tm.mainThread;
+ while (!done) {
+ try {
+ thread.processNextEvent(true);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+}, "profile-change-net-teardown");
+
+/**
+ * Gates a function so that it is called only after the wrapper is called a
+ * given number of times.
+ *
+ * @param {Number} times The number of wrapper calls before |func| is called.
+ * @param {Function} func The function to gate.
+ * @returns {Function} The gated function wrapper.
+ */
+function after(times, func) {
+ return function afterFunc() {
+ if (--times <= 0) {
+ func.apply(this, arguments);
+ }
+ };
+}
+
+/**
+ * Defers one or more callbacks until the next turn of the event loop. Multiple
+ * callbacks are executed in order.
+ *
+ * @param {Function[]} callbacks The callbacks to execute. One callback will be
+ * executed per tick.
+ */
+function waterfall(...callbacks) {
+ callbacks
+ .reduce(
+ (promise, callback) =>
+ promise.then(() => {
+ callback();
+ }),
+ Promise.resolve()
+ )
+ .catch(console.error);
+}
+
+/**
+ * Waits for an observer notification to fire.
+ *
+ * @param {String} topic The notification topic.
+ * @returns {Promise} A promise that fulfills when the notification is fired.
+ */
+function promiseObserverNotification(topic, matchFunc) {
+ return new Promise((resolve, reject) => {
+ Services.obs.addObserver(function observe(subject, aTopic, data) {
+ let matches = typeof matchFunc != "function" || matchFunc(subject, data);
+ if (!matches) {
+ return;
+ }
+ Services.obs.removeObserver(observe, aTopic);
+ resolve({ subject, data });
+ }, topic);
+ });
+}
+
+/**
+ * Wraps an object in a proxy that traps property gets and returns stubs. If
+ * the stub is a function, the original value will be passed as the first
+ * argument. If the original value is a function, the proxy returns a wrapper
+ * that calls the stub; otherwise, the stub is called as a getter.
+ *
+ * @param {Object} target The object to wrap.
+ * @param {Object} stubs An object containing stubbed values and functions.
+ * @returns {Proxy} A proxy that returns stubs for property gets.
+ */
+function makeStub(target, stubs) {
+ return new Proxy(target, {
+ get(aTarget, property) {
+ if (!stubs || typeof stubs != "object" || !(property in stubs)) {
+ return aTarget[property];
+ }
+ let stub = stubs[property];
+ if (typeof stub != "function") {
+ return stub;
+ }
+ let original = aTarget[property];
+ if (typeof original != "function") {
+ return stub.call(this, original);
+ }
+ return function callStub(...params) {
+ return stub.call(this, original, ...params);
+ };
+ },
+ });
+}
+
+/**
+ * Sets default PushService preferences. All pref names are prefixed with
+ * `dom.push.`; any additional preferences will override the defaults.
+ *
+ * @param {Object} [prefs] Additional preferences to set.
+ */
+function setPrefs(prefs = {}) {
+ let defaultPrefs = Object.assign(
+ {
+ loglevel: "all",
+ serverURL: "wss://push.example.org",
+ "connection.enabled": true,
+ userAgentID: "",
+ enabled: true,
+ // Defaults taken from /modules/libpref/init/all.js.
+ requestTimeout: 10000,
+ retryBaseInterval: 5000,
+ pingInterval: 30 * 60 * 1000,
+ // Misc. defaults.
+ "http2.maxRetries": 2,
+ "http2.retryInterval": 500,
+ "http2.reset_retry_count_after_ms": 60000,
+ maxQuotaPerSubscription: 16,
+ quotaUpdateDelay: 3000,
+ "testing.notifyWorkers": false,
+ },
+ prefs
+ );
+ for (let pref in defaultPrefs) {
+ servicePrefs.set(pref, defaultPrefs[pref]);
+ }
+}
+
+function compareAscending(a, b) {
+ if (a > b) {
+ return 1;
+ }
+ return a < b ? -1 : 0;
+}
+
+/**
+ * Creates a mock WebSocket object that implements a subset of the
+ * nsIWebSocketChannel interface used by the PushService.
+ *
+ * The given protocol handlers are invoked for each Simple Push command sent
+ * by the PushService. The ping handler is optional; all others will throw if
+ * the PushService sends a command for which no handler is registered.
+ *
+ * All nsIWebSocketListener methods will be called asynchronously.
+ * serverSendMsg() and serverClose() can be used to respond to client messages
+ * and close the "server" end of the connection, respectively.
+ *
+ * @param {nsIURI} originalURI The original WebSocket URL.
+ * @param {Function} options.onHello The "hello" handshake command handler.
+ * @param {Function} options.onRegister The "register" command handler.
+ * @param {Function} options.onUnregister The "unregister" command handler.
+ * @param {Function} options.onACK The "ack" command handler.
+ * @param {Function} [options.onPing] An optional ping handler.
+ */
+function MockWebSocket(originalURI, handlers = {}) {
+ this._originalURI = originalURI;
+ this._onHello = handlers.onHello;
+ this._onRegister = handlers.onRegister;
+ this._onUnregister = handlers.onUnregister;
+ this._onACK = handlers.onACK;
+ this._onPing = handlers.onPing;
+ this._onBroadcastSubscribe = handlers.onBroadcastSubscribe;
+}
+
+MockWebSocket.prototype = {
+ _originalURI: null,
+ _onHello: null,
+ _onRegister: null,
+ _onUnregister: null,
+ _onACK: null,
+ _onPing: null,
+
+ _listener: null,
+ _context: null,
+
+ QueryInterface: ChromeUtils.generateQI(["nsIWebSocketChannel"]),
+
+ get originalURI() {
+ return this._originalURI;
+ },
+
+ asyncOpen(uri, origin, originAttributes, windowId, listener, context) {
+ this._listener = listener;
+ this._context = context;
+ waterfall(() => this._listener.onStart(this._context));
+ },
+
+ _handleMessage(msg) {
+ let messageType, request;
+ if (msg == "{}") {
+ request = {};
+ messageType = "ping";
+ } else {
+ request = JSON.parse(msg);
+ messageType = request.messageType;
+ }
+ switch (messageType) {
+ case "hello":
+ if (typeof this._onHello != "function") {
+ throw new Error("Unexpected handshake request");
+ }
+ this._onHello(request);
+ break;
+
+ case "register":
+ if (typeof this._onRegister != "function") {
+ throw new Error("Unexpected register request");
+ }
+ this._onRegister(request);
+ break;
+
+ case "unregister":
+ if (typeof this._onUnregister != "function") {
+ throw new Error("Unexpected unregister request");
+ }
+ this._onUnregister(request);
+ break;
+
+ case "ack":
+ if (typeof this._onACK != "function") {
+ throw new Error("Unexpected acknowledgement");
+ }
+ this._onACK(request);
+ break;
+
+ case "ping":
+ if (typeof this._onPing == "function") {
+ this._onPing(request);
+ } else {
+ // Echo ping packets.
+ this.serverSendMsg("{}");
+ }
+ break;
+
+ case "broadcast_subscribe":
+ if (typeof this._onBroadcastSubscribe != "function") {
+ throw new Error("Unexpected broadcast_subscribe");
+ }
+ this._onBroadcastSubscribe(request);
+ break;
+
+ default:
+ throw new Error("Unexpected message: " + messageType);
+ }
+ },
+
+ sendMsg(msg) {
+ this._handleMessage(msg);
+ },
+
+ close(code, reason) {
+ waterfall(() => this._listener.onStop(this._context, Cr.NS_OK));
+ },
+
+ /**
+ * Responds with the given message, calling onMessageAvailable() and
+ * onAcknowledge() synchronously. Throws if the message is not a string.
+ * Used by the tests to respond to client commands.
+ *
+ * @param {String} msg The message to send to the client.
+ */
+ serverSendMsg(msg) {
+ if (typeof msg != "string") {
+ throw new Error("Invalid response message");
+ }
+ waterfall(
+ () => this._listener.onMessageAvailable(this._context, msg),
+ () => this._listener.onAcknowledge(this._context, 0)
+ );
+ },
+
+ /**
+ * Closes the server end of the connection, calling onServerClose()
+ * followed by onStop(). Used to test abrupt connection termination.
+ *
+ * @param {Number} [statusCode] The WebSocket connection close code.
+ * @param {String} [reason] The connection close reason.
+ */
+ serverClose(statusCode, reason = "") {
+ if (!isFinite(statusCode)) {
+ statusCode = WEBSOCKET_CLOSE_GOING_AWAY;
+ }
+ waterfall(
+ () => this._listener.onServerClose(this._context, statusCode, reason),
+ () => this._listener.onStop(this._context, Cr.NS_BASE_STREAM_CLOSED)
+ );
+ },
+
+ serverInterrupt(result = Cr.NS_ERROR_NET_RESET) {
+ waterfall(() => this._listener.onStop(this._context, result));
+ },
+};
+
+var setUpServiceInParent = async function (service, db) {
+ if (!isParent) {
+ return;
+ }
+
+ let userAgentID = "ce704e41-cb77-4206-b07b-5bf47114791b";
+ setPrefs({
+ userAgentID,
+ });
+
+ await db.put({
+ channelID: "6e2814e1-5f84-489e-b542-855cc1311f09",
+ pushEndpoint: "https://example.org/push/get",
+ scope: "https://example.com/get/ok",
+ originAttributes: "",
+ version: 1,
+ pushCount: 10,
+ lastPush: 1438360548322,
+ quota: 16,
+ });
+ await db.put({
+ channelID: "3a414737-2fd0-44c0-af05-7efc172475fc",
+ pushEndpoint: "https://example.org/push/unsub",
+ scope: "https://example.com/unsub/ok",
+ originAttributes: "",
+ version: 2,
+ pushCount: 10,
+ lastPush: 1438360848322,
+ quota: 4,
+ });
+ await db.put({
+ channelID: "ca3054e8-b59b-4ea0-9c23-4a3c518f3161",
+ pushEndpoint: "https://example.org/push/stale",
+ scope: "https://example.com/unsub/fail",
+ originAttributes: "",
+ version: 3,
+ pushCount: 10,
+ lastPush: 1438362348322,
+ quota: 1,
+ });
+
+ service.init({
+ serverURI: "wss://push.example.org/",
+ db: makeStub(db, {
+ put(prev, record) {
+ if (record.scope == "https://example.com/sub/fail") {
+ return Promise.reject("synergies not aligned");
+ }
+ return prev.call(this, record);
+ },
+ delete(prev, channelID) {
+ if (channelID == "ca3054e8-b59b-4ea0-9c23-4a3c518f3161") {
+ return Promise.reject("splines not reticulated");
+ }
+ return prev.call(this, channelID);
+ },
+ getByIdentifiers(prev, identifiers) {
+ if (identifiers.scope == "https://example.com/get/fail") {
+ return Promise.reject("qualia unsynchronized");
+ }
+ return prev.call(this, identifiers);
+ },
+ }),
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ uaid: userAgentID,
+ status: 200,
+ })
+ );
+ },
+ onRegister(request) {
+ if (request.key) {
+ let appServerKey = new Uint8Array(
+ ChromeUtils.base64URLDecode(request.key, {
+ padding: "require",
+ })
+ );
+ equal(appServerKey.length, 65, "Wrong app server key length");
+ equal(appServerKey[0], 4, "Wrong app server key format");
+ }
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ uaid: userAgentID,
+ channelID: request.channelID,
+ status: 200,
+ pushEndpoint: "https://example.org/push/" + request.channelID,
+ })
+ );
+ },
+ onUnregister(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ channelID: request.channelID,
+ status: 200,
+ })
+ );
+ },
+ });
+ },
+ });
+};
+
+var tearDownServiceInParent = async function (db) {
+ if (!isParent) {
+ return;
+ }
+
+ let record = await db.getByIdentifiers({
+ scope: "https://example.com/sub/ok",
+ originAttributes: "",
+ });
+ ok(
+ record.pushEndpoint.startsWith("https://example.org/push"),
+ "Wrong push endpoint in subscription record"
+ );
+
+ record = await db.getByKeyID("3a414737-2fd0-44c0-af05-7efc172475fc");
+ ok(!record, "Unsubscribed record should not exist");
+};
+
+function putTestRecord(db, keyID, scope, quota) {
+ return db.put({
+ channelID: keyID,
+ pushEndpoint: "https://example.org/push/" + keyID,
+ scope,
+ pushCount: 0,
+ lastPush: 0,
+ version: null,
+ originAttributes: "",
+ quota,
+ systemRecord: quota == Infinity,
+ });
+}
+
+function getAllKeyIDs(db) {
+ return db
+ .getAllKeyIDs()
+ .then(records =>
+ records.map(record => record.keyID).sort(compareAscending)
+ );
+}
diff --git a/dom/push/test/xpcshell/moz.build b/dom/push/test/xpcshell/moz.build
new file mode 100644
index 0000000000..fc154ce0d0
--- /dev/null
+++ b/dom/push/test/xpcshell/moz.build
@@ -0,0 +1,3 @@
+TESTING_JS_MODULES += [
+ "broadcast_handler.sys.mjs",
+]
diff --git a/dom/push/test/xpcshell/test_broadcast_success.js b/dom/push/test/xpcshell/test_broadcast_success.js
new file mode 100644
index 0000000000..16f586081b
--- /dev/null
+++ b/dom/push/test/xpcshell/test_broadcast_success.js
@@ -0,0 +1,428 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Create the profile directory early to ensure pushBroadcastService
+// is initialized with the correct path
+do_get_profile();
+const { BroadcastService } = ChromeUtils.importESModule(
+ "resource://gre/modules/PushBroadcastService.sys.mjs"
+);
+const { JSONFile } = ChromeUtils.importESModule(
+ "resource://gre/modules/JSONFile.sys.mjs"
+);
+
+const { FileTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/FileTestUtils.sys.mjs"
+);
+const { broadcastHandler } = ChromeUtils.importESModule(
+ "resource://test/broadcast_handler.sys.mjs"
+);
+
+const broadcastService = pushBroadcastService;
+const assert = Assert;
+const userAgentID = "bd744428-f125-436a-b6d0-dd0c9845837f";
+const channelID = "0ef2ad4a-6c49-41ad-af6e-95d2425276bf";
+
+function run_test() {
+ setPrefs({
+ userAgentID,
+ alwaysConnect: true,
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+function getPushServiceMock() {
+ return {
+ subscribed: [],
+ subscribeBroadcast(broadcastId, version) {
+ this.subscribed.push([broadcastId, version]);
+ },
+ };
+}
+
+add_task(async function test_register_success() {
+ await broadcastService._resetListeners();
+ const db = PushServiceWebSocket.newPushDB();
+ broadcastHandler.reset();
+ const notifications = broadcastHandler.notifications;
+ let socket;
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ await broadcastService.addListener("broadcast-test", "2018-02-01", {
+ moduleURI: "resource://test/broadcast_handler.sys.mjs",
+ symbolName: "broadcastHandler",
+ });
+
+ PushServiceWebSocket._generateID = () => channelID;
+
+ var broadcastSubscriptions = [];
+
+ let handshakeDone;
+ let handshakePromise = new Promise(resolve => (handshakeDone = resolve));
+ await PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(data) {
+ socket = this;
+ deepEqual(
+ data.broadcasts,
+ { "broadcast-test": "2018-02-01" },
+ "Handshake: doesn't consult listeners"
+ );
+ equal(data.messageType, "hello", "Handshake: wrong message type");
+ ok(
+ !data.uaid,
+ "Should not send UAID in handshake without local subscriptions"
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ handshakeDone();
+ },
+
+ onBroadcastSubscribe(data) {
+ broadcastSubscriptions.push(data);
+ },
+ });
+ },
+ });
+ await handshakePromise;
+
+ socket.serverSendMsg(
+ JSON.stringify({
+ messageType: "broadcast",
+ broadcasts: {
+ "broadcast-test": "2018-03-02",
+ },
+ })
+ );
+
+ await broadcastHandler.wasNotified;
+
+ deepEqual(
+ notifications,
+ [
+ [
+ "2018-03-02",
+ "broadcast-test",
+ { phase: broadcastService.PHASES.BROADCAST },
+ ],
+ ],
+ "Broadcast notification didn't get delivered"
+ );
+
+ deepEqual(
+ await broadcastService.getListeners(),
+ {
+ "broadcast-test": "2018-03-02",
+ },
+ "Broadcast version wasn't updated"
+ );
+
+ await broadcastService.addListener("example-listener", "2018-03-01", {
+ moduleURI: "resource://gre/modules/not-real-example.jsm",
+ symbolName: "doesntExist",
+ });
+
+ deepEqual(broadcastSubscriptions, [
+ {
+ messageType: "broadcast_subscribe",
+ broadcasts: { "example-listener": "2018-03-01" },
+ },
+ ]);
+});
+
+add_task(async function test_handle_hello_broadcasts() {
+ PushService.uninit();
+ await broadcastService._resetListeners();
+ let db = PushServiceWebSocket.newPushDB();
+ broadcastHandler.reset();
+ let notifications = broadcastHandler.notifications;
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ await broadcastService.addListener("broadcast-test", "2018-02-01", {
+ moduleURI: "resource://test/broadcast_handler.sys.mjs",
+ symbolName: "broadcastHandler",
+ });
+
+ PushServiceWebSocket._generateID = () => channelID;
+
+ await PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(data) {
+ deepEqual(
+ data.broadcasts,
+ { "broadcast-test": "2018-02-01" },
+ "Handshake: doesn't consult listeners"
+ );
+ equal(data.messageType, "hello", "Handshake: wrong message type");
+ ok(
+ !data.uaid,
+ "Should not send UAID in handshake without local subscriptions"
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ broadcasts: {
+ "broadcast-test": "2018-02-02",
+ },
+ })
+ );
+ },
+
+ onBroadcastSubscribe(data) {},
+ });
+ },
+ });
+
+ await broadcastHandler.wasNotified;
+
+ deepEqual(
+ notifications,
+ [
+ [
+ "2018-02-02",
+ "broadcast-test",
+ { phase: broadcastService.PHASES.HELLO },
+ ],
+ ],
+ "Broadcast notification on hello was delivered"
+ );
+
+ deepEqual(
+ await broadcastService.getListeners(),
+ {
+ "broadcast-test": "2018-02-02",
+ },
+ "Broadcast version wasn't updated"
+ );
+});
+
+add_task(async function test_broadcast_context() {
+ await broadcastService._resetListeners();
+ const db = PushServiceWebSocket.newPushDB();
+ broadcastHandler.reset();
+ registerCleanupFunction(() => {
+ return db.drop().then(() => db.close());
+ });
+
+ const serviceId = "broadcast-test";
+ const version = "2018-02-01";
+ await broadcastService.addListener(serviceId, version, {
+ moduleURI: "resource://test/broadcast_handler.sys.mjs",
+ symbolName: "broadcastHandler",
+ });
+
+ // PushServiceWebSocket._generateID = () => channelID;
+
+ await PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(data) {},
+ });
+ },
+ });
+
+ // Simulate registration.
+ PushServiceWebSocket.sendSubscribeBroadcast(serviceId, version);
+
+ // Simulate broadcast reply received by PushWebSocketListener.
+ const message = JSON.stringify({
+ messageType: "broadcast",
+ broadcasts: {
+ [serviceId]: version,
+ },
+ });
+ PushServiceWebSocket._wsOnMessageAvailable({}, message);
+ await broadcastHandler.wasNotified;
+
+ deepEqual(
+ broadcastHandler.notifications,
+ [[version, serviceId, { phase: broadcastService.PHASES.REGISTER }]],
+ "Broadcast passes REGISTER context"
+ );
+
+ // Simulate broadcast reply, without previous registration.
+ broadcastHandler.reset();
+ PushServiceWebSocket._wsOnMessageAvailable({}, message);
+ await broadcastHandler.wasNotified;
+
+ deepEqual(
+ broadcastHandler.notifications,
+ [[version, serviceId, { phase: broadcastService.PHASES.BROADCAST }]],
+ "Broadcast passes BROADCAST context"
+ );
+});
+
+add_task(async function test_broadcast_unit() {
+ const fakeListenersData = {
+ abc: {
+ version: "2018-03-04",
+ sourceInfo: {
+ moduleURI: "resource://gre/modules/abc.jsm",
+ symbolName: "getAbc",
+ },
+ },
+ def: {
+ version: "2018-04-05",
+ sourceInfo: {
+ moduleURI: "resource://gre/modules/def.jsm",
+ symbolName: "getDef",
+ },
+ },
+ };
+ const path = FileTestUtils.getTempFile("broadcast-listeners.json").path;
+
+ const jsonFile = new JSONFile({ path });
+ jsonFile.data = {
+ listeners: fakeListenersData,
+ };
+ await jsonFile._save();
+
+ const pushServiceMock = getPushServiceMock();
+
+ const mockBroadcastService = new BroadcastService(pushServiceMock, path);
+ const listeners = await mockBroadcastService.getListeners();
+ deepEqual(listeners, {
+ abc: "2018-03-04",
+ def: "2018-04-05",
+ });
+
+ await mockBroadcastService.addListener("ghi", "2018-05-06", {
+ moduleURI: "resource://gre/modules/ghi.jsm",
+ symbolName: "getGhi",
+ });
+
+ deepEqual(pushServiceMock.subscribed, [["ghi", "2018-05-06"]]);
+
+ await mockBroadcastService._saveImmediately();
+
+ const newJSONFile = new JSONFile({ path });
+ await newJSONFile.load();
+
+ deepEqual(newJSONFile.data, {
+ listeners: {
+ ...fakeListenersData,
+ ghi: {
+ version: "2018-05-06",
+ sourceInfo: {
+ moduleURI: "resource://gre/modules/ghi.jsm",
+ symbolName: "getGhi",
+ },
+ },
+ },
+ version: 1,
+ });
+
+ deepEqual(await mockBroadcastService.getListeners(), {
+ abc: "2018-03-04",
+ def: "2018-04-05",
+ ghi: "2018-05-06",
+ });
+});
+
+add_task(async function test_broadcast_initialize_sane() {
+ const path = FileTestUtils.getTempFile("broadcast-listeners.json").path;
+ const mockBroadcastService = new BroadcastService(getPushServiceMock(), path);
+ deepEqual(
+ await mockBroadcastService.getListeners(),
+ {},
+ "listeners should start out sane"
+ );
+ await mockBroadcastService._saveImmediately();
+ let onDiskJSONFile = new JSONFile({ path });
+ await onDiskJSONFile.load();
+ deepEqual(
+ onDiskJSONFile.data,
+ { listeners: {}, version: 1 },
+ "written JSON file has listeners and version fields"
+ );
+
+ await mockBroadcastService.addListener("ghi", "2018-05-06", {
+ moduleURI: "resource://gre/modules/ghi.jsm",
+ symbolName: "getGhi",
+ });
+
+ await mockBroadcastService._saveImmediately();
+
+ onDiskJSONFile = new JSONFile({ path });
+ await onDiskJSONFile.load();
+
+ deepEqual(
+ onDiskJSONFile.data,
+ {
+ listeners: {
+ ghi: {
+ version: "2018-05-06",
+ sourceInfo: {
+ moduleURI: "resource://gre/modules/ghi.jsm",
+ symbolName: "getGhi",
+ },
+ },
+ },
+ version: 1,
+ },
+ "adding listeners to initial state is written OK"
+ );
+});
+
+add_task(async function test_broadcast_reject_invalid_sourceinfo() {
+ const path = FileTestUtils.getTempFile("broadcast-listeners.json").path;
+ const mockBroadcastService = new BroadcastService(getPushServiceMock(), path);
+
+ await assert.rejects(
+ mockBroadcastService.addListener("ghi", "2018-05-06", {
+ moduleName: "resource://gre/modules/ghi.jsm",
+ symbolName: "getGhi",
+ }),
+ /moduleURI must be a string/,
+ "rejects sourceInfo that doesn't have moduleURI"
+ );
+});
+
+add_task(async function test_broadcast_reject_version_not_string() {
+ await assert.rejects(
+ broadcastService.addListener(
+ "ghi",
+ {},
+ {
+ moduleURI: "resource://gre/modules/ghi.jsm",
+ symbolName: "getGhi",
+ }
+ ),
+ /version should be a string/,
+ "rejects version that isn't a string"
+ );
+});
+
+add_task(async function test_broadcast_reject_version_empty_string() {
+ await assert.rejects(
+ broadcastService.addListener("ghi", "", {
+ moduleURI: "resource://gre/modules/ghi.jsm",
+ symbolName: "getGhi",
+ }),
+ /version should not be an empty string/,
+ "rejects version that is an empty string"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_clearAll_successful.js b/dom/push/test/xpcshell/test_clearAll_successful.js
new file mode 100644
index 0000000000..a638fffaaf
--- /dev/null
+++ b/dom/push/test/xpcshell/test_clearAll_successful.js
@@ -0,0 +1,130 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var db;
+var unregisterDefers = {};
+var userAgentID = "4ce480ef-55b2-4f83-924c-dcd35ab978b4";
+
+function promiseUnregister(keyID, code) {
+ return new Promise(r => (unregisterDefers[keyID] = r));
+}
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(async function setup() {
+ db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => db.drop().then(() => db.close()));
+
+ // Active subscriptions; should be expired then dropped.
+ await putTestRecord(db, "active-1", "https://example.info/some-page", 8);
+ await putTestRecord(db, "active-2", "https://example.com/another-page", 16);
+
+ // Expired subscription; should be dropped.
+ await putTestRecord(db, "expired", "https://example.net/yet-another-page", 0);
+
+ // A privileged subscription that should not be affected by sanitizing data
+ // because its quota is set to `Infinity`.
+ await putTestRecord(db, "privileged", "app://chrome/only", Infinity);
+
+ let handshakeDone;
+ let handshakePromise = new Promise(r => (handshakeDone = r));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ uaid: userAgentID,
+ status: 200,
+ use_webpush: true,
+ })
+ );
+ handshakeDone();
+ },
+ onUnregister(request) {
+ let resolve = unregisterDefers[request.channelID];
+ equal(
+ typeof resolve,
+ "function",
+ "Dropped unexpected channel ID " + request.channelID
+ );
+ delete unregisterDefers[request.channelID];
+ equal(request.code, 200, "Expected manual unregister reason");
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ channelID: request.channelID,
+ status: 200,
+ })
+ );
+ resolve();
+ },
+ });
+ },
+ });
+ await handshakePromise;
+});
+
+add_task(async function test_sanitize() {
+ let modifiedScopes = [];
+ let changeScopes = [];
+
+ let promiseCleared = Promise.all([
+ // Active subscriptions should be unregistered.
+ promiseUnregister("active-1"),
+ promiseUnregister("active-2"),
+ promiseObserverNotification(
+ PushServiceComponent.subscriptionModifiedTopic,
+ (subject, data) => {
+ modifiedScopes.push(data);
+ return modifiedScopes.length == 3;
+ }
+ ),
+
+ // Privileged should be recreated.
+ promiseUnregister("privileged"),
+ promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ (subject, data) => {
+ changeScopes.push(data);
+ return changeScopes.length == 1;
+ }
+ ),
+ ]);
+
+ await PushService.clear({
+ domain: "*",
+ });
+
+ await promiseCleared;
+
+ deepEqual(
+ modifiedScopes.sort(compareAscending),
+ [
+ "app://chrome/only",
+ "https://example.com/another-page",
+ "https://example.info/some-page",
+ ],
+ "Should modify active subscription scopes"
+ );
+
+ deepEqual(
+ changeScopes,
+ ["app://chrome/only"],
+ "Should fire change notification for privileged scope"
+ );
+
+ let remainingIDs = await getAllKeyIDs(db);
+ deepEqual(remainingIDs, [], "Should drop all subscriptions");
+});
diff --git a/dom/push/test/xpcshell/test_clear_forgetAboutSite.js b/dom/push/test/xpcshell/test_clear_forgetAboutSite.js
new file mode 100644
index 0000000000..27ae57af25
--- /dev/null
+++ b/dom/push/test/xpcshell/test_clear_forgetAboutSite.js
@@ -0,0 +1,225 @@
+"use strict";
+
+const { ForgetAboutSite } = ChromeUtils.importESModule(
+ "resource://gre/modules/ForgetAboutSite.sys.mjs"
+);
+
+var db;
+var unregisterDefers = {};
+var userAgentID = "4fe01c2d-72ac-4c13-93d2-bb072caf461d";
+
+function promiseUnregister(keyID) {
+ return new Promise(r => (unregisterDefers[keyID] = r));
+}
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(async function setup() {
+ db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => db.drop().then(() => db.close()));
+
+ // Active and expired subscriptions for a subdomain. The active subscription
+ // should be expired, then removed; the expired subscription should be
+ // removed immediately.
+ await putTestRecord(db, "active-sub", "https://sub.example.com/sub-page", 4);
+ await putTestRecord(
+ db,
+ "active-sub-b",
+ "https://sub.example.net/sub-page",
+ 4
+ );
+ await putTestRecord(
+ db,
+ "expired-sub",
+ "https://sub.example.com/yet-another-page",
+ 0
+ );
+
+ // Active subscriptions for another subdomain. Should be unsubscribed and
+ // dropped.
+ await putTestRecord(db, "active-1", "https://sub2.example.com/some-page", 8);
+ await putTestRecord(
+ db,
+ "active-2",
+ "https://sub3.example.com/another-page",
+ 16
+ );
+ await putTestRecord(
+ db,
+ "active-1-b",
+ "https://sub2.example.net/some-page",
+ 8
+ );
+ await putTestRecord(
+ db,
+ "active-2-b",
+ "https://sub3.example.net/another-page",
+ 16
+ );
+
+ // A privileged subscription with a real URL that should not be affected
+ // because its quota is set to `Infinity`.
+ await putTestRecord(
+ db,
+ "privileged",
+ "https://sub.example.com/real-url",
+ Infinity
+ );
+
+ let handshakeDone;
+ let handshakePromise = new Promise(r => (handshakeDone = r));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ uaid: userAgentID,
+ status: 200,
+ use_webpush: true,
+ })
+ );
+ handshakeDone();
+ },
+ onUnregister(request) {
+ let resolve = unregisterDefers[request.channelID];
+ equal(
+ typeof resolve,
+ "function",
+ "Dropped unexpected channel ID " + request.channelID
+ );
+ delete unregisterDefers[request.channelID];
+ equal(request.code, 200, "Expected manual unregister reason");
+ resolve();
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ status: 200,
+ channelID: request.channelID,
+ })
+ );
+ },
+ });
+ },
+ });
+ // For cleared subscriptions, we only send unregister requests in the
+ // background and if we're connected.
+ await handshakePromise;
+});
+
+add_task(async function test_forgetAboutSubdomain() {
+ let modifiedScopes = [];
+ let promiseForgetSubs = Promise.all([
+ // Active subscriptions should be dropped.
+ promiseUnregister("active-sub"),
+ promiseObserverNotification(
+ PushServiceComponent.subscriptionModifiedTopic,
+ (subject, data) => {
+ modifiedScopes.push(data);
+ return modifiedScopes.length == 1;
+ }
+ ),
+ ]);
+ await ForgetAboutSite.removeDataFromDomain("sub.example.com");
+ await promiseForgetSubs;
+
+ deepEqual(
+ modifiedScopes.sort(compareAscending),
+ ["https://sub.example.com/sub-page"],
+ "Should fire modified notifications for active subscriptions"
+ );
+
+ let remainingIDs = await getAllKeyIDs(db);
+ deepEqual(
+ remainingIDs,
+ [
+ "active-1",
+ "active-1-b",
+ "active-2",
+ "active-2-b",
+ "active-sub-b",
+ "privileged",
+ ],
+ "Should only forget subscriptions for subdomain"
+ );
+});
+
+add_task(async function test_forgetAboutRootDomain() {
+ let modifiedScopes = [];
+ let promiseForgetSubs = Promise.all([
+ promiseUnregister("active-1"),
+ promiseUnregister("active-2"),
+ promiseObserverNotification(
+ PushServiceComponent.subscriptionModifiedTopic,
+ (subject, data) => {
+ modifiedScopes.push(data);
+ return modifiedScopes.length == 2;
+ }
+ ),
+ ]);
+
+ await ForgetAboutSite.removeDataFromDomain("example.com");
+ await promiseForgetSubs;
+
+ deepEqual(
+ modifiedScopes.sort(compareAscending),
+ [
+ "https://sub2.example.com/some-page",
+ "https://sub3.example.com/another-page",
+ ],
+ "Should fire modified notifications for entire domain"
+ );
+
+ let remainingIDs = await getAllKeyIDs(db);
+ deepEqual(
+ remainingIDs,
+ ["active-1-b", "active-2-b", "active-sub-b", "privileged"],
+ "Should ignore privileged records with a real URL"
+ );
+});
+
+// Tests the legacy removeDataFromDomain method.
+add_task(async function test_forgetAboutBaseDomain() {
+ let modifiedScopes = [];
+ let promiseForgetSubs = Promise.all([
+ promiseUnregister("active-sub-b"),
+ promiseUnregister("active-1-b"),
+ promiseUnregister("active-2-b"),
+ promiseObserverNotification(
+ PushServiceComponent.subscriptionModifiedTopic,
+ (subject, data) => {
+ modifiedScopes.push(data);
+ return modifiedScopes.length == 3;
+ }
+ ),
+ ]);
+
+ await ForgetAboutSite.removeDataFromDomain("example.net");
+ await promiseForgetSubs;
+
+ deepEqual(
+ modifiedScopes.sort(compareAscending),
+ [
+ "https://sub.example.net/sub-page",
+ "https://sub2.example.net/some-page",
+ "https://sub3.example.net/another-page",
+ ],
+ "Should fire modified notifications for entire domain"
+ );
+
+ let remainingIDs = await getAllKeyIDs(db);
+ deepEqual(
+ remainingIDs,
+ ["privileged"],
+ "Should ignore privileged records with a real URL"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_clear_origin_data.js b/dom/push/test/xpcshell/test_clear_origin_data.js
new file mode 100644
index 0000000000..7c743148a6
--- /dev/null
+++ b/dom/push/test/xpcshell/test_clear_origin_data.js
@@ -0,0 +1,132 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "bd744428-f125-436a-b6d0-dd0c9845837f";
+
+let clearForPattern = async function (testRecords, pattern) {
+ let patternString = JSON.stringify(pattern);
+ await PushService._clearOriginData(patternString);
+
+ for (let length = testRecords.length; length--; ) {
+ let test = testRecords[length];
+ let originSuffix = ChromeUtils.originAttributesToSuffix(
+ test.originAttributes
+ );
+
+ let registration = await PushService.registration({
+ scope: test.scope,
+ originAttributes: originSuffix,
+ });
+
+ let url = test.scope + originSuffix;
+
+ if (ObjectUtils.deepEqual(test.clearIf, pattern)) {
+ ok(
+ !registration,
+ "Should clear registration " + url + " for pattern " + patternString
+ );
+ testRecords.splice(length, 1);
+ } else {
+ ok(
+ registration,
+ "Should not clear registration " + url + " for pattern " + patternString
+ );
+ }
+ }
+};
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_webapps_cleardata() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ let testRecords = [
+ {
+ scope: "https://example.org/1",
+ originAttributes: {},
+ clearIf: { inIsolatedMozBrowser: false },
+ },
+ {
+ scope: "https://example.org/1",
+ originAttributes: { inIsolatedMozBrowser: true },
+ clearIf: {},
+ },
+ ];
+
+ let unregisterDone;
+ let unregisterPromise = new Promise(
+ resolve => (unregisterDone = after(testRecords.length, resolve))
+ );
+
+ PushService.init({
+ serverURI: "wss://push.example.org",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(data) {
+ equal(data.messageType, "hello", "Handshake: wrong message type");
+ ok(
+ !data.uaid,
+ "Should not send UAID in handshake without local subscriptions"
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ },
+ onRegister(data) {
+ equal(data.messageType, "register", "Register: wrong message type");
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ channelID: data.channelID,
+ uaid: userAgentID,
+ pushEndpoint: "https://example.com/update/" + Math.random(),
+ })
+ );
+ },
+ onUnregister(data) {
+ equal(data.code, 200, "Expected manual unregister reason");
+ unregisterDone();
+ },
+ });
+ },
+ });
+
+ await Promise.all(
+ testRecords.map(test =>
+ PushService.register({
+ scope: test.scope,
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ test.originAttributes
+ ),
+ })
+ )
+ );
+
+ // Removes all the records, Excluding where `inIsolatedMozBrowser` is true.
+ await clearForPattern(testRecords, { inIsolatedMozBrowser: false });
+
+ // Removes the all the remaining records where `inIsolatedMozBrowser` is true.
+ await clearForPattern(testRecords, {});
+
+ equal(testRecords.length, 0, "Should remove all test records");
+ await unregisterPromise;
+});
diff --git a/dom/push/test/xpcshell/test_crypto.js b/dom/push/test/xpcshell/test_crypto.js
new file mode 100644
index 0000000000..e6b50e1d96
--- /dev/null
+++ b/dom/push/test/xpcshell/test_crypto.js
@@ -0,0 +1,666 @@
+"use strict";
+
+const { getCryptoParamsFromHeaders, PushCrypto } = ChromeUtils.importESModule(
+ "resource://gre/modules/PushCrypto.sys.mjs"
+);
+
+const REJECT_PADDING = { padding: "reject" };
+
+// A common key to decrypt some aesgcm and aesgcm128 messages. Other decryption
+// tests have their own keys.
+const LEGACY_PRIVATE_KEY = {
+ kty: "EC",
+ crv: "P-256",
+ d: "4h23G_KkXC9TvBSK2v0Q7ImpS2YAuRd8hQyN0rFAwBg",
+ x: "sd85ZCbEG6dEkGMCmDyGBIt454Qy-Yo-1xhbaT2Jlk4",
+ y: "vr3cKpQ-Sp1kpZ9HipNjUCwSA55yy0uM8N9byE8dmLs",
+ ext: true,
+};
+
+const LEGACY_PUBLIC_KEY =
+ "BLHfOWQmxBunRJBjApg8hgSLeOeEMvmKPtcYW2k9iZZOvr3cKpQ-Sp1kpZ9HipNjUCwSA55yy0uM8N9byE8dmLs";
+
+async function assertDecrypts(test, headers) {
+ let privateKey = test.privateKey;
+ let publicKey = ChromeUtils.base64URLDecode(test.publicKey, REJECT_PADDING);
+ let authSecret = null;
+ if (test.authSecret) {
+ authSecret = ChromeUtils.base64URLDecode(test.authSecret, REJECT_PADDING);
+ }
+ let payload = ChromeUtils.base64URLDecode(test.data, REJECT_PADDING);
+ let result = await PushCrypto.decrypt(
+ privateKey,
+ publicKey,
+ authSecret,
+ headers,
+ payload
+ );
+ let decoder = new TextDecoder("utf-8");
+ equal(decoder.decode(new Uint8Array(result)), test.result, test.desc);
+}
+
+async function assertNotDecrypts(test, headers) {
+ let authSecret = null;
+ if (test.authSecret) {
+ authSecret = ChromeUtils.base64URLDecode(test.authSecret, REJECT_PADDING);
+ }
+ let data = ChromeUtils.base64URLDecode(test.data, REJECT_PADDING);
+ let publicKey = ChromeUtils.base64URLDecode(test.publicKey, REJECT_PADDING);
+ let promise = PushCrypto.decrypt(
+ test.privateKey,
+ publicKey,
+ authSecret,
+ headers,
+ data
+ );
+ await Assert.rejects(promise, test.expected, test.desc);
+}
+
+add_task(async function test_crypto_getCryptoParamsFromHeaders() {
+ // These headers should parse correctly.
+ let shouldParse = [
+ {
+ desc: "aesgcm with multiple keys",
+ headers: {
+ encoding: "aesgcm",
+ crypto_key: "keyid=p256dh;dh=Iy1Je2Kv11A,p256ecdsa=o2M8QfiEKuI",
+ encryption: "keyid=p256dh;salt=upk1yFkp1xI",
+ },
+ params: {
+ senderKey: "Iy1Je2Kv11A",
+ salt: "upk1yFkp1xI",
+ rs: 4096,
+ },
+ },
+ {
+ desc: "aesgcm with quoted key param",
+ headers: {
+ encoding: "aesgcm",
+ crypto_key: 'dh="byfHbUffc-k"',
+ encryption: "salt=C11AvAsp6Gc",
+ },
+ params: {
+ senderKey: "byfHbUffc-k",
+ salt: "C11AvAsp6Gc",
+ rs: 4096,
+ },
+ },
+ {
+ desc: "aesgcm with Crypto-Key and rs = 24",
+ headers: {
+ encoding: "aesgcm",
+ crypto_key: 'dh="ybuT4VDz-Bg"',
+ encryption: "salt=H7U7wcIoIKs; rs=24",
+ },
+ params: {
+ senderKey: "ybuT4VDz-Bg",
+ salt: "H7U7wcIoIKs",
+ rs: 24,
+ },
+ },
+ {
+ desc: "aesgcm128 with Encryption-Key and rs = 2",
+ headers: {
+ encoding: "aesgcm128",
+ encryption_key: "keyid=legacy; dh=LqrDQuVl9lY",
+ encryption: "keyid=legacy; salt=YngI8B7YapM; rs=2",
+ },
+ params: {
+ senderKey: "LqrDQuVl9lY",
+ salt: "YngI8B7YapM",
+ rs: 2,
+ },
+ },
+ {
+ desc: "aesgcm128 with Encryption-Key",
+ headers: {
+ encoding: "aesgcm128",
+ encryption_key: "keyid=v2; dh=VA6wmY1IpiE",
+ encryption: "keyid=v2; salt=khtpyXhpDKM",
+ },
+ params: {
+ senderKey: "VA6wmY1IpiE",
+ salt: "khtpyXhpDKM",
+ rs: 4096,
+ },
+ },
+ ];
+ for (let test of shouldParse) {
+ let params = getCryptoParamsFromHeaders(test.headers);
+ let senderKey = ChromeUtils.base64URLDecode(
+ test.params.senderKey,
+ REJECT_PADDING
+ );
+ let salt = ChromeUtils.base64URLDecode(test.params.salt, REJECT_PADDING);
+ deepEqual(
+ new Uint8Array(params.senderKey),
+ new Uint8Array(senderKey),
+ "Sender key should match for " + test.desc
+ );
+ deepEqual(
+ new Uint8Array(params.salt),
+ new Uint8Array(salt),
+ "Salt should match for " + test.desc
+ );
+ equal(
+ params.rs,
+ test.params.rs,
+ "Record size should match for " + test.desc
+ );
+ }
+
+ // These headers should be rejected.
+ let shouldThrow = [
+ {
+ desc: "aesgcm128 with Crypto-Key",
+ headers: {
+ encoding: "aesgcm128",
+ crypto_key: "keyid=v2; dh=VA6wmY1IpiE",
+ encryption: "keyid=v2; salt=F0Im7RtGgNY",
+ },
+ exception: /Missing Encryption-Key header/,
+ },
+ {
+ desc: "Invalid encoding",
+ headers: {
+ encoding: "nonexistent",
+ },
+ exception: /Missing encryption header/,
+ },
+ {
+ desc: "Invalid record size",
+ headers: {
+ encoding: "aesgcm",
+ crypto_key: "dh=pbmv1QkcEDY",
+ encryption: "dh=Esao8aTBfIk;rs=bad",
+ },
+ exception: /Invalid salt parameter/,
+ },
+ {
+ desc: "aesgcm with Encryption-Key",
+ headers: {
+ encoding: "aesgcm",
+ encryption_key: "dh=FplK5KkvUF0",
+ encryption: "salt=p6YHhFF3BQY",
+ },
+ exception: /Missing Crypto-Key header/,
+ },
+ ];
+ for (let test of shouldThrow) {
+ throws(
+ () => getCryptoParamsFromHeaders(test.headers),
+ test.exception,
+ test.desc
+ );
+ }
+});
+
+add_task(async function test_aes128gcm_ok() {
+ let expectedSuccesses = [
+ {
+ desc: "Example from draft-ietf-webpush-encryption-latest",
+ result: "When I grow up, I want to be a watermelon",
+ data: "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN",
+ authSecret: "BTBZMqHH6r4Tts7J_aSIgg",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94",
+ x: "JXGyvs3942BVGq8e0PTNNmwRzr5VX4m8t7GGpTM5FzE",
+ y: "aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4",
+ ext: true,
+ },
+ publicKey:
+ "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4",
+ },
+ {
+ desc: "rs = 24, pad = 0",
+ result:
+ "I am the very model of a modern Major-General; I've information vegetable, animal, and mineral",
+ data: "goagSH7PP0ZGwUsgShmdkwAAABhBBDJVyIuUJbOSVMeWHP8VNPnxY-dZSw86doqOkEzZZZY1ALBWVXTVf0rUDH3oi68I9Hrp-01zA-mr8XKWl5kcH8cX0KiV2PtCwdkEyaQ73YF5fsDxgoWDiaTA3wPqMvuLDqGsZWHnE9Psnfoy7UMEqKlh2a1nE7ZOXiXcOBHLNj260jYzSJnEPV2eXixSXfyWpaSJHAwfj4wVdAAocmViIg6ywk8wFB1hgJpnX2UVEU_qIOcaP6AOIOr1UUQPfosQqC2MEHe5u9gHXF5pi-E267LAlkoYefq01KV_xK_vjbxpw8GAYfSjQEm0L8FG-CN37c8pnQ2Yf61MkihaXac9OctfNeWq_22cN6hn4qsOq0F7QoWIiZqWhB1vS9cJ3KUlyPQvKI9cvevDxw0fJHWeTFzhuwT9BjdILjjb2Vkqc0-qTDOawqD4c8WXsvdGDQCec5Y1x3UhdQXdjR_mhXypxFM37OZTvKJBr1vPCpRXl-bI6iOd7KScgtMM1x5luKhGzZyz25HyuFyj1ec82A",
+ authSecret: "_tK2LDGoIt6be6agJ_nvGA",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "bGViEe3PvjjFJg8lcnLsqu71b2yqWGnZN9J2MTed-9s",
+ x: "auB0GHF0AZ2LAocFnvOXDS7EeCMopnzbg-tS21FMHrU",
+ y: "GpbhrW-_xKj3XhhXA-kDZSicKZ0kn0BuVhqzhLOB-Cc",
+ ext: true,
+ },
+ publicKey:
+ "BGrgdBhxdAGdiwKHBZ7zlw0uxHgjKKZ824PrUttRTB61GpbhrW-_xKj3XhhXA-kDZSicKZ0kn0BuVhqzhLOB-Cc",
+ },
+ {
+ desc: "rs = 49, pad = 84; ciphertext length falls on record boundary",
+ result: "Hello, world",
+ data: "-yiDzsHE_K3W0TcfbqSR4AAAADFBBC1EHuf5_2oDKaZJJ9BST9vnsixvtl4Qq0_cA4-UQgoMo_oo2tNshOyRoWLq4Hj6rSwc7XjegRPhlgKyDolPSXa5c-L89oL6DIzNmvPVv_Ht4W-tWjHOGdOLXh_h94pPrYQrvBAlTCxs3ZaitVKE2XLFPK2MO6yxD19X6w1KQzO2BBAroRfK4pEI-9n2Kai6aWDdAZRbOe03unBsQ0oQ_SvSCU_5JJvNrUUTX1_kX804Bx-LLTlBr9pDmBDXeqyvfOULVDJb9YyVAzN9BzeFoyPfo0M",
+ authSecret: "lfF1cOUI72orKtG09creMw",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "ZwBKTqgg3u2OSdtelIDmPT6jzOGujhpgYJcT1SfQAe8",
+ x: "AU6PFLktoHzgg7k_ljZ-h7IXpH9-8u6TqdNDqgY-V1o",
+ y: "nzDVnGkMajmz_IFbFQyn3RSWAXQTN7U1B6UfQbFzpyE",
+ ext: true,
+ },
+ publicKey:
+ "BAFOjxS5LaB84IO5P5Y2foeyF6R_fvLuk6nTQ6oGPldanzDVnGkMajmz_IFbFQyn3RSWAXQTN7U1B6UfQbFzpyE",
+ },
+ {
+ desc: "rs = 18, pad = 0",
+ result: "1",
+ data: "fK69vCCTjuNAqUbxvU9o8QAAABJBBDfP21Ij2fleqgL27ZQP8i6vBbNiLpSdw86fM15u-bJq6qzKD3QICos2RZLyzMbV7d1DAEtwuRiH0UTZ-pPxbDvH6mj0_VR6lOyoSxbhOKYIAXc",
+ authSecret: "1loE35Xy215gSDn3F9zeeQ",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "J0M_q4lws8tShLYRg--0YoZWLNKnMw2MrpYJEaVXHQw",
+ x: "UV1DJjVWUjmdoksr6SQeYztc8U-vDPOm_WAxe5VMCi8",
+ y: "SEhUgASyewz3SAvIEMa-wDqPt5yOoA_IsF4A-INFY-8",
+ ext: true,
+ },
+ publicKey:
+ "BFFdQyY1VlI5naJLK-kkHmM7XPFPrwzzpv1gMXuVTAovSEhUgASyewz3SAvIEMa-wDqPt5yOoA_IsF4A-INFY-8",
+ },
+ ];
+ for (let test of expectedSuccesses) {
+ let privateKey = test.privateKey;
+ let publicKey = ChromeUtils.base64URLDecode(test.publicKey, {
+ padding: "reject",
+ });
+ let authSecret = ChromeUtils.base64URLDecode(test.authSecret, {
+ padding: "reject",
+ });
+ let payload = ChromeUtils.base64URLDecode(test.data, {
+ padding: "reject",
+ });
+ let result = await PushCrypto.decrypt(
+ privateKey,
+ publicKey,
+ authSecret,
+ {
+ encoding: "aes128gcm",
+ },
+ payload
+ );
+ let decoder = new TextDecoder("utf-8");
+ equal(decoder.decode(new Uint8Array(result)), test.result, test.desc);
+ }
+});
+
+add_task(async function test_aes128gcm_err() {
+ let expectedFailures = [
+ {
+ // Just the payload; no header at all.
+ desc: "Missing header block",
+ data: "RbdNK2m-mwdN47NaqH58FWEd",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "G-g_ODMu8JaB-vPzB7H_LhDKt4zHzatoOsDukqw_buE",
+ x: "26mRyiFTQ_Nr3T6FfK_ePRi_V_GDWygzutQU8IhBYgU",
+ y: "GslqCyRJADfQfPUo5OGOEAoaZOt0R0hUS_HiINq6zyw",
+ ext: true,
+ },
+ publicKey:
+ "BNupkcohU0Pza90-hXyv3j0Yv1fxg1soM7rUFPCIQWIFGslqCyRJADfQfPUo5OGOEAoaZOt0R0hUS_HiINq6zyw",
+ authSecret: "NHG7mEgeAlM785VCvPPbpA",
+ expected: /Truncated header/,
+ },
+ {
+ // The sender key should be 65 bytes; this header contains an invalid key
+ // that's only 1 byte.
+ desc: "Truncated sender key",
+ data: "3ltpa4fxoVy2revdedb5ngAAABIBALa8GCbDfJ9z3WtIWcK1BRgZUg",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "zojo4LMFekdS60yPqTHrYhwwLaWtA7ga9FnPZzVWDK4",
+ x: "oyXZkITEDeDOcioELESNlKMmkXIcp54890XnjGmIYZQ",
+ y: "sCzqGSJBdnlanU27sgc68szW-m8KTHxJaFVr5QKjuoE",
+ ext: true,
+ },
+ publicKey:
+ "BKMl2ZCExA3gznIqBCxEjZSjJpFyHKeePPdF54xpiGGUsCzqGSJBdnlanU27sgc68szW-m8KTHxJaFVr5QKjuoE",
+ authSecret: "XDHg2W2aE5iZrAlp01n3QA",
+ expected: /Invalid sender public key/,
+ },
+ {
+ // The message is encrypted with only the first 12 bytes of the 16-byte
+ // auth secret, so the derived decryption key and nonce won't match.
+ desc: "Encrypted with mismatched auth secret",
+ data: "gRX0mIuMOSp7rLQ8jxrFZQAAABJBBBmUSDxUHpvDmmrwP_cTqndFwoThOKQqJDW3l7IMS2mM9RGLT4VVMXwZDqvr-rdJwWTT9r3r4NRBcZExo1fYiQoTxNvUsW_z3VqD98ka1uBArEJzCn8LPNMkXp-Nb_McdR1BDP0",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "YMdjalF95wOaCsLQ4wZEAHlMeOfgSTmBKaInzuD5qAE",
+ x: "_dBBKKhcBYltf4H-EYvcuIe588H_QYOtxMgk0ShgcwA",
+ y: "6Yay37WmEOWvQ-QIoAcwWE-T49_d_ERzfV8I-y1viRY",
+ ext: true,
+ },
+ publicKey:
+ "BP3QQSioXAWJbX-B_hGL3LiHufPB_0GDrcTIJNEoYHMA6Yay37WmEOWvQ-QIoAcwWE-T49_d_ERzfV8I-y1viRY",
+ authSecret: "NVo4zW2b7xWZDi0zCNvWAA",
+ expected: /Bad encryption/,
+ },
+ {
+ // Multiple records; the first has padding delimiter = 2, but should be 1.
+ desc: "Early final record",
+ data: "2-IVUH0a09Lq6r6ubodNjwAAABJBBHvEND80qDSM3E5GL_x8QKpqjGGnOcTEHUUSVQX3Dp_F-e-oaFLdSI3Pjo6iyvt14Hq9XufJ1cA4uv7weVcbC9opRBHOmMdt0DHA5YBXekmAo3XkXtMEKb4OLunafm34aW0BuOw",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "XdodkYvEB7o82hLLgBTUmqfgJpACggMERmvIADTKkkA",
+ x: "yVxlINrRHo9qG_gDGkDCpO4QRcGQO-BqHfp_gpzOst4",
+ y: "Akga5r0EdhIbEsVTLQsjF4gHfvoGg6W_4NYjObJRyzU",
+ ext: true,
+ },
+ publicKey:
+ "BMlcZSDa0R6Pahv4AxpAwqTuEEXBkDvgah36f4KczrLeAkga5r0EdhIbEsVTLQsjF4gHfvoGg6W_4NYjObJRyzU",
+ authSecret: "QMJB_eQmnuHm1yVZLZgnGA",
+ expected: /Padding is wrong!/,
+ },
+ ];
+ for (let test of expectedFailures) {
+ await assertNotDecrypts(test, { encoding: "aes128gcm" });
+ }
+});
+
+add_task(async function test_aesgcm_ok() {
+ let expectedSuccesses = [
+ {
+ desc: "padSize = 2, rs = 24, pad = 0",
+ result: "Some message",
+ data: "Oo34w2F9VVnTMFfKtdx48AZWQ9Li9M6DauWJVgXU",
+ authSecret: "aTDc6JebzR6eScy2oLo4RQ",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ crypto_key:
+ "dh=BCHFVrflyxibGLlgztLwKelsRZp4gqX3tNfAKFaxAcBhpvYeN1yIUMrxaDKiLh4LNKPtj0BOXGdr-IQ-QP82Wjo",
+ encryption: "salt=zCU18Rw3A5aB_Xi-vfixmA; rs=24",
+ encoding: "aesgcm",
+ },
+ },
+ {
+ desc: "padSize = 2, rs = 8, pad = 16",
+ result: "Yet another message",
+ data: "uEC5B_tR-fuQ3delQcrzrDCp40W6ipMZjGZ78USDJ5sMj-6bAOVG3AK6JqFl9E6AoWiBYYvMZfwThVxmDnw6RHtVeLKFM5DWgl1EwkOohwH2EhiDD0gM3io-d79WKzOPZE9rDWUSv64JstImSfX_ADQfABrvbZkeaWxh53EG59QMOElFJqHue4dMURpsMXg",
+ authSecret: "6plwZnSpVUbF7APDXus3UQ",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ crypto_key:
+ "dh=BEaA4gzA3i0JDuirGhiLgymS4hfFX7TNTdEhSk_HBlLpkjgCpjPL5c-GL9uBGIfa_fhGNKKFhXz1k9Kyens2ZpQ",
+ encryption: "salt=ZFhzj0S-n29g9P2p4-I7tA; rs=8",
+ encoding: "aesgcm",
+ },
+ },
+ {
+ desc: "padSize = 2, rs = 3, pad = 0",
+ result: "Small record size",
+ data: "oY4e5eDatDVt2fpQylxbPJM-3vrfhDasfPc8Q1PWt4tPfMVbz_sDNL_cvr0DXXkdFzS1lxsJsj550USx4MMl01ihjImXCjrw9R5xFgFrCAqJD3GwXA1vzS4T5yvGVbUp3SndMDdT1OCcEofTn7VC6xZ-zP8rzSQfDCBBxmPU7OISzr8Z4HyzFCGJeBfqiZ7yUfNlKF1x5UaZ4X6iU_TXx5KlQy_toV1dXZ2eEAMHJUcSdArvB6zRpFdEIxdcHcJyo1BIYgAYTDdAIy__IJVCPY_b2CE5W_6ohlYKB7xDyH8giNuWWXAgBozUfScLUVjPC38yJTpAUi6w6pXgXUWffende5FreQpnMFL1L4G-38wsI_-ISIOzdO8QIrXHxmtc1S5xzYu8bMqSgCinvCEwdeGFCmighRjj8t1zRWo0D14rHbQLPR_b1P5SvEeJTtS9Nm3iibM",
+ authSecret: "g2rWVHUCpUxgcL9Tz7vyeQ",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ crypto_key:
+ "dh=BCg6ZIGuE2ZNm2ti6Arf4CDVD_8--aLXAGLYhpghwjl1xxVjTLLpb7zihuEOGGbyt8Qj0_fYHBP4ObxwJNl56bk",
+ encryption: "salt=5LIDBXbvkBvvb7ZdD-T4PQ; rs=3",
+ encoding: "aesgcm",
+ },
+ },
+ {
+ desc: "Example from draft-ietf-httpbis-encryption-encoding-02",
+ result: "I am the walrus",
+ data: "6nqAQUME8hNqw5J3kl8cpVVJylXKYqZOeseZG8UueKpA",
+ authSecret: "R29vIGdvbyBnJyBqb29iIQ",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "9FWl15_QUQAWDaD3k3l50ZBZQJ4au27F1V4F0uLSD_M",
+ x: "ISQGPMvxncL6iLZDugTm3Y2n6nuiyMYuD3epQ_TC-pE",
+ y: "T21EEWyf0cQDQcakQMqz4hQKYOQ3il2nNZct4HgAUQU",
+ ext: true,
+ },
+ publicKey:
+ "BCEkBjzL8Z3C-oi2Q7oE5t2Np-p7osjGLg93qUP0wvqRT21EEWyf0cQDQcakQMqz4hQKYOQ3il2nNZct4HgAUQU",
+ headers: {
+ crypto_key:
+ 'keyid="dhkey"; dh="BNoRDbb84JGm8g5Z5CFxurSqsXWJ11ItfXEWYVLE85Y7CYkDjXsIEc4aqxYaQ1G8BqkXCJ6DPpDrWtdWj_mugHU"',
+ encryption: 'keyid="dhkey"; salt="lngarbyKfMoi9Z75xYXmkg"',
+ encoding: "aesgcm",
+ },
+ },
+ ];
+ for (let test of expectedSuccesses) {
+ await assertDecrypts(test, test.headers);
+ }
+});
+
+add_task(async function test_aesgcm_err() {
+ let expectedFailures = [
+ {
+ desc: "aesgcm128 message decrypted as aesgcm",
+ data: "fwkuwTTChcLnrzsbDI78Y2EoQzfnbMI8Ax9Z27_rwX8",
+ authSecret: "BhbpNTWyO5wVJmVKTV6XaA",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ crypto_key:
+ "dh=BCHn-I-J3dfPRLJBlNZ3xFoAqaBLZ6qdhpaz9W7Q00JW1oD-hTxyEECn6KYJNK8AxKUyIDwn6Icx_PYWJiEYjQ0",
+ encryption: "salt=c6JQl9eJ0VvwrUVCQDxY7Q",
+ encoding: "aesgcm",
+ },
+ expected: /Bad encryption/,
+ },
+ {
+ // The plaintext is "O hai". The ciphertext is exactly `rs + 16` bytes,
+ // but we didn't include the empty trailing block that aesgcm requires for
+ // exact multiples.
+ desc: "rs = 7, no trailing block",
+ data: "YG4F-b06y590hRlnSsw_vuOw62V9Iz8",
+ authSecret: "QoDi0u6vcslIVJKiouXMXw",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "2bu4paOAZbL2ef1u-wTzONuTIcDPc00o0zUJgg46XTc",
+ x: "uEvLZUMVn1my0cwnLdcFT0mj1gSU5uzI3HeGwXC7jX8",
+ y: "SfNVLGL-FurydsuzciDfw8K1cUHyoDWnJJ_16UG6Dbo",
+ ext: true,
+ },
+ publicKey:
+ "BLhLy2VDFZ9ZstHMJy3XBU9Jo9YElObsyNx3hsFwu41_SfNVLGL-FurydsuzciDfw8K1cUHyoDWnJJ_16UG6Dbo",
+ headers: {
+ crypto_key:
+ "dh=BD_bsTUpxBMvSv8eksith3vijMLj44D4jhJjO51y7wK1ytbUlsyYBBYYyB5AAe5bnREA_WipTgemDVz00LiWcfM",
+ encryption: "salt=xKWvs_jWWeg4KOsot_uBhA; rs=7",
+ encoding: "aesgcm",
+ },
+ expected: /Encrypted data truncated/,
+ },
+ {
+ // The last block is only 1 byte, but valid blocks must be at least 2 bytes.
+ desc: "Pad size > last block length",
+ data: "JvX9HsJ4lL5gzP8_uCKc6s15iRIaNhD4pFCgq5-dfwbUqEcNUkqv",
+ authSecret: "QtGZeY8MQfCaq-XwKOVGBQ",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "CosERAVXgvTvoh7UkrRC2V-iXoNs0bXle9I68qzkles",
+ x: "_D0YqEwirvTJQJdjG6xXrjstMVpeAzf221cUqZz6hgY",
+ y: "9MnFbM7U14uiYMDI5e2I4jN29tYmsM9F66QodhKmA-c",
+ ext: true,
+ },
+ publicKey:
+ "BPw9GKhMIq70yUCXYxusV647LTFaXgM39ttXFKmc-oYG9MnFbM7U14uiYMDI5e2I4jN29tYmsM9F66QodhKmA-c",
+ headers: {
+ crypto_key:
+ "dh=BBNZNEi5Ew_ID5S4Y9jWBi1NeVDje6Mjs7SDLViUn6A8VAZj-6X3QAuYQ3j20BblqjwTgYst7PRnY6UGrKyLbmU",
+ encryption: "salt=ot8hzbwOo6CYe6ZhdlwKtg; rs=6",
+ encoding: "aesgcm",
+ },
+ expected: /Decoded array is too short/,
+ },
+ {
+ // The last block is 3 bytes (2 bytes for the pad length; 1 byte of data),
+ // but claims its pad length is 2.
+ desc: "Padding length > last block length",
+ data: "oWSOFA-UO5oWq-kI79RHaFfwAejLiQJ4C7eTmrSTBl4gArLXfx7lZ-Y",
+ authSecret: "gKG_P6-de5pyzS8hyH_NyQ",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "9l-ahcBM-I0ykwbWiDS9KRrPdhyvTZ0SxKiPpj2aeaI",
+ x: "qx0tU4EDaQv6ayFA3xvLLBdMmn4mLxjn7SK6mIeIxeg",
+ y: "ymbMcmUOEyh_-rLrBsi26NG4UFCis2MTDs5FG2VdDPI",
+ ext: true,
+ },
+ publicKey:
+ "BKsdLVOBA2kL-mshQN8byywXTJp-Ji8Y5-0iupiHiMXoymbMcmUOEyh_-rLrBsi26NG4UFCis2MTDs5FG2VdDPI",
+ headers: {
+ crypto_key:
+ "dh=BKe2IBO_cwmEzQyTVscSbQcj0Y3uBSzGZ_mHlANMciS8uGpb7U8_Bw7TNdlYfpwWDLd0cxM8YYWNDbNJ_p2Rp4o",
+ encryption: "salt=z7QJ6UR89SiFRkd4RsC4Vg; rs=6",
+ encoding: "aesgcm",
+ },
+ expected: /Padding is wrong/,
+ },
+ {
+ // The first block has no padding, but claims its pad length is 1.
+ desc: "Non-zero padding",
+ data: "Qdvjh0LkZXKu_1Hvv56D0rOSF6Mww3y0F8xkxUNlwVu2U1iakOUUGRs",
+ authSecret: "cMpWQW58BrpDbJ8KqbS9ig",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "IzuaxLqFJmjSu8GjLCo2oEaDZjDButW4m4T0qx02XsM",
+ x: "Xy7vt_TJTynxwWsQyY069BcKmrhkRjhKPFuTi-AphoY",
+ y: "0M10IVM1ourR7Q5AUX2b2fgdmGyTWcYsdHcdFK_b4Hk",
+ ext: true,
+ },
+ publicKey:
+ "BF8u77f0yU8p8cFrEMmNOvQXCpq4ZEY4Sjxbk4vgKYaG0M10IVM1ourR7Q5AUX2b2fgdmGyTWcYsdHcdFK_b4Hk",
+ headers: {
+ crypto_key:
+ "dh=BBicj01QI0ryiFzAaty9VpW_crgq9XbU1bOCtEZI9UNE6tuOgp4lyN_UN0N905ECnLWK5v_sCPUIxnQgOuCseSo",
+ encryption: "salt=SbkGHONbQBBsBcj9dLyIUw; rs=6",
+ encoding: "aesgcm",
+ },
+ expected: /Padding is wrong/,
+ },
+ {
+ // The first record is 22 bytes: 2 bytes for the pad length, 4 bytes of
+ // data, and a 16-byte auth tag. The second "record" is missing the pad
+ // and data, and contains just the auth tag.
+ desc: "rs = 6, second record truncated to only auth tag",
+ data: "C7u3j5AL4Yzh2yYB_umN6tzrVHxrt7D5baTEW9DE1Bk3up9fY4w",
+ authSecret: "3rWhsRCU_KdaqfKPbd3zBQ",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "nhOT9171xuoQBJGkiZ3aqT5qw_ILJ94_PPiVNu1LFSY",
+ x: "lCj7ctQTmRfwzTMcODlNfHjFMAHmgdI44OhTQXX_xpE",
+ y: "WBdgz4GWGtGAisC63O9DtP5l--hnCzPZiV-YZ-a6Lcw",
+ ext: true,
+ },
+ publicKey:
+ "BJQo-3LUE5kX8M0zHDg5TXx4xTAB5oHSOODoU0F1_8aRWBdgz4GWGtGAisC63O9DtP5l--hnCzPZiV-YZ-a6Lcw",
+ headers: {
+ crypto_key:
+ "dh=BI38Qs_OhDmQIxbszc6Nako-MrX3FzAE_8HzxM1wgoEIG4ocxyF-YAAVhfkpJUvDpRyKW2LDHIaoylaZuxQfRhE",
+ encryption: "salt=QClh48OlvGpSjZ0Mg0e8rg; rs=6",
+ encoding: "aesgcm",
+ },
+ expected: /Decoded array is too short/,
+ },
+ ];
+ for (let test of expectedFailures) {
+ await assertNotDecrypts(test, test.headers);
+ }
+});
+
+add_task(async function test_aesgcm128_ok() {
+ let expectedSuccesses = [
+ {
+ desc: "padSize = 1, rs = 4096, pad = 2",
+ result: "aesgcm128 encrypted message",
+ data: "ljBJ44NPzJFH9EuyT5xWMU4vpZ90MdAqaq1TC1kOLRoPNHtNFXeJ0GtuSaE",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ encryption_key:
+ "dh=BOmnfg02vNd6RZ7kXWWrCGFF92bI-rQ-bV0Pku3-KmlHwbGv4ejWqgasEdLGle5Rhmp6SKJunZw2l2HxKvrIjfI",
+ encryption: "salt=btxxUtclbmgcc30b9rT3Bg; rs=4096",
+ encoding: "aesgcm128",
+ },
+ },
+ ];
+ for (let test of expectedSuccesses) {
+ await assertDecrypts(test, test.headers);
+ }
+});
+
+add_task(async function test_aesgcm128_err() {
+ let expectedFailures = [
+ {
+ // aesgcm128 doesn't use an auth secret, but we've mixed one in during
+ // encryption, so the decryption key and nonce won't match.
+ desc: "padSize = 1, rs = 4096, auth secret, pad = 8",
+ data: "h0FmyldY8aT5EQ6CJrbfRn_IdDvytoLeHb9_q5CjtdFRfgDRknxLmOzavLaVG4oOiS0r",
+ authSecret: "Sxb6u0gJIhGEogyLawjmCw",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ crypto_key:
+ "dh=BCXHk7O8CE-9AOp6xx7g7c-NCaNpns1PyyHpdcmDaijLbT6IdGq0ezGatBwtFc34BBfscFxdk4Tjksa2Mx5rRCM",
+ encryption: "salt=aGBpoKklLtrLcAUCcCr7JQ",
+ encoding: "aesgcm128",
+ },
+ expected: /Missing Encryption-Key header/,
+ },
+ {
+ // The first byte of each record must be the pad length.
+ desc: "Missing padding",
+ data: "anvsHj7oBQTPMhv7XSJEsvyMS4-8EtbC7HgFZsKaTg",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ crypto_key:
+ "dh=BMSqfc3ohqw2DDgu3nsMESagYGWubswQPGxrW1bAbYKD18dIHQBUmD3ul_lu7MyQiT5gNdzn5JTXQvCcpf-oZE4",
+ encryption: "salt=Czx2i18rar8XWOXAVDnUuw",
+ encoding: "aesgcm128",
+ },
+ expected: /Missing Encryption-Key header/,
+ },
+ {
+ desc: "Truncated input",
+ data: "AlDjj6NvT5HGyrHbT8M5D6XBFSra6xrWS9B2ROaCIjwSu3RyZ1iyuv0",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ crypto_key:
+ "dh=BCHn-I-J3dfPRLJBlNZ3xFoAqaBLZ6qdhpaz9W7Q00JW1oD-hTxyEECn6KYJNK8AxKUyIDwn6Icx_PYWJiEYjQ0",
+ encryption: "salt=c6JQl9eJ0VvwrUVCQDxY7Q; rs=25",
+ encoding: "aesgcm128",
+ },
+ expected: /Missing Encryption-Key header/,
+ },
+ {
+ desc: "Padding length > rs",
+ data: "Ct_h1g7O55e6GvuhmpjLsGnv8Rmwvxgw8iDESNKGxk_8E99iHKDzdV8wJPyHA-6b2E6kzuVa5UWiQ7s4Zms1xzJ4FKgoxvBObXkc_r_d4mnb-j245z3AcvRmcYGk5_HZ0ci26SfhAN3lCgxGzTHS4nuHBRkGwOb4Tj4SFyBRlLoTh2jyVK2jYugNjH9tTrGOBg7lP5lajLTQlxOi91-RYZSfFhsLX3LrAkXuRoN7G1CdiI7Y3_eTgbPIPabDcLCnGzmFBTvoJSaQF17huMl_UnWoCj2WovA4BwK_TvWSbdgElNnQ4CbArJ1h9OqhDOphVu5GUGr94iitXRQR-fqKPMad0ULLjKQWZOnjuIdV1RYEZ873r62Yyd31HoveJcSDb1T8l_QK2zVF8V4k0xmK9hGuC0rF5YJPYPHgl5__usknzxMBnRrfV5_MOL5uPZwUEFsu",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ crypto_key:
+ "dh=BAcMdWLJRGx-kPpeFtwqR3GE1LWzd1TYh2rg6CEFu53O-y3DNLkNe_BtGtKRR4f7ZqpBMVS6NgfE2NwNPm3Ndls",
+ encryption: "salt=NQVTKhB0rpL7ZzKkotTGlA; rs=1",
+ encoding: "aesgcm128",
+ },
+ expected: /Missing Encryption-Key header/,
+ },
+ ];
+ for (let test of expectedFailures) {
+ await assertNotDecrypts(test, test.headers);
+ }
+});
diff --git a/dom/push/test/xpcshell/test_crypto_encrypt.js b/dom/push/test/xpcshell/test_crypto_encrypt.js
new file mode 100644
index 0000000000..d199ce2220
--- /dev/null
+++ b/dom/push/test/xpcshell/test_crypto_encrypt.js
@@ -0,0 +1,199 @@
+// Test PushCrypto.encrypt()
+"use strict";
+
+const { PushCrypto } = ChromeUtils.importESModule(
+ "resource://gre/modules/PushCrypto.sys.mjs"
+);
+
+let from64 = v => {
+ // allow whitespace in the strings.
+ let stripped = v.replace(/ |\t|\r|\n/g, "");
+ return new Uint8Array(
+ ChromeUtils.base64URLDecode(stripped, { padding: "reject" })
+ );
+};
+
+let to64 = v => ChromeUtils.base64URLEncode(v, { pad: false });
+
+// A helper function to take a public key (as a buffer containing a 65-byte
+// buffer of uncompressed EC points) and a private key (32byte buffer) and
+// return 2 crypto keys.
+async function importKeyPair(publicKeyBuffer, privateKeyBuffer) {
+ let jwk = {
+ kty: "EC",
+ crv: "P-256",
+ x: to64(publicKeyBuffer.slice(1, 33)),
+ y: to64(publicKeyBuffer.slice(33, 65)),
+ ext: true,
+ };
+ let publicKey = await crypto.subtle.importKey(
+ "jwk",
+ jwk,
+ { name: "ECDH", namedCurve: "P-256" },
+ true,
+ []
+ );
+ jwk.d = to64(privateKeyBuffer);
+ let privateKey = await crypto.subtle.importKey(
+ "jwk",
+ jwk,
+ { name: "ECDH", namedCurve: "P-256" },
+ true,
+ ["deriveBits"]
+ );
+ return { publicKey, privateKey };
+}
+
+// The example from draft-ietf-webpush-encryption-09.
+add_task(async function static_aes128gcm() {
+ let fixture = {
+ ciphertext:
+ from64(`DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml
+ mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPT
+ pK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN`),
+ plaintext: new TextEncoder().encode(
+ "When I grow up, I want to be a watermelon"
+ ),
+ authSecret: from64("BTBZMqHH6r4Tts7J_aSIgg"),
+ receiver: {
+ private: from64("q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94"),
+ public: from64(`BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx
+ aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4`),
+ },
+ sender: {
+ private: from64("yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"),
+ public: from64(`BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIg
+ Dll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8`),
+ },
+ salt: from64("DGv6ra1nlYgDCS1FRnbzlw"),
+ };
+
+ let options = {
+ senderKeyPair: await importKeyPair(
+ fixture.sender.public,
+ fixture.sender.private
+ ),
+ salt: fixture.salt,
+ };
+
+ let { ciphertext, encoding } = await PushCrypto.encrypt(
+ fixture.plaintext,
+ fixture.receiver.public,
+ fixture.authSecret,
+ options
+ );
+
+ Assert.deepEqual(ciphertext, fixture.ciphertext);
+ Assert.equal(encoding, "aes128gcm");
+
+ // and for fun, decrypt it and check the plaintext.
+ let recvKeyPair = await importKeyPair(
+ fixture.receiver.public,
+ fixture.receiver.private
+ );
+ let jwk = await crypto.subtle.exportKey("jwk", recvKeyPair.privateKey);
+ let plaintext = await PushCrypto.decrypt(
+ jwk,
+ fixture.receiver.public,
+ fixture.authSecret,
+ { encoding: "aes128gcm" },
+ ciphertext
+ );
+ Assert.deepEqual(plaintext, fixture.plaintext);
+});
+
+// This is how we expect real code to interact with .encrypt.
+add_task(async function aes128gcm_simple() {
+ let [recvPublicKey, recvPrivateKey] = await PushCrypto.generateKeys();
+
+ let message = new TextEncoder().encode("Fast for good.");
+ let authSecret = crypto.getRandomValues(new Uint8Array(16));
+ let { ciphertext, encoding } = await PushCrypto.encrypt(
+ message,
+ recvPublicKey,
+ authSecret
+ );
+ Assert.equal(encoding, "aes128gcm");
+ // and decrypt it.
+ let plaintext = await PushCrypto.decrypt(
+ recvPrivateKey,
+ recvPublicKey,
+ authSecret,
+ { encoding },
+ ciphertext
+ );
+ deepEqual(message, plaintext);
+});
+
+// Variable record size tests
+add_task(async function aes128gcm_rs() {
+ let [recvPublicKey, recvPrivateKey] = await PushCrypto.generateKeys();
+
+ for (let rs of [-1, 0, 1, 17]) {
+ let payload = "x".repeat(1024);
+ info(`testing expected failure with rs=${rs}`);
+ let message = new TextEncoder().encode(payload);
+ let authSecret = crypto.getRandomValues(new Uint8Array(16));
+ await Assert.rejects(
+ PushCrypto.encrypt(message, recvPublicKey, authSecret, { rs }),
+ /recordsize is too small/
+ );
+ }
+ for (let rs of [18, 50, 1024, 4096, 16384]) {
+ info(`testing expected success with rs=${rs}`);
+ let payload = "x".repeat(rs * 3);
+ let message = new TextEncoder().encode(payload);
+ let authSecret = crypto.getRandomValues(new Uint8Array(16));
+ let { ciphertext, encoding } = await PushCrypto.encrypt(
+ message,
+ recvPublicKey,
+ authSecret,
+ { rs }
+ );
+ Assert.equal(encoding, "aes128gcm");
+ // and decrypt it.
+ let plaintext = await PushCrypto.decrypt(
+ recvPrivateKey,
+ recvPublicKey,
+ authSecret,
+ { encoding },
+ ciphertext
+ );
+ deepEqual(message, plaintext);
+ }
+});
+
+// And try and hit some edge-cases.
+add_task(async function aes128gcm_edgecases() {
+ let [recvPublicKey, recvPrivateKey] = await PushCrypto.generateKeys();
+
+ for (let size of [
+ 0,
+ 4096 - 16,
+ 4096 - 16 - 1,
+ 4096 - 16 + 1,
+ 4095,
+ 4096,
+ 4097,
+ 10240,
+ ]) {
+ info(`testing encryption of ${size} byte payload`);
+ let message = new TextEncoder().encode("x".repeat(size));
+ let authSecret = crypto.getRandomValues(new Uint8Array(16));
+ let { ciphertext, encoding } = await PushCrypto.encrypt(
+ message,
+ recvPublicKey,
+ authSecret
+ );
+ Assert.equal(encoding, "aes128gcm");
+ // and decrypt it.
+ let plaintext = await PushCrypto.decrypt(
+ recvPrivateKey,
+ recvPublicKey,
+ authSecret,
+ { encoding },
+ ciphertext
+ );
+ deepEqual(message, plaintext);
+ }
+});
diff --git a/dom/push/test/xpcshell/test_drop_expired.js b/dom/push/test/xpcshell/test_drop_expired.js
new file mode 100644
index 0000000000..823049c21f
--- /dev/null
+++ b/dom/push/test/xpcshell/test_drop_expired.js
@@ -0,0 +1,164 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "2c43af06-ab6e-476a-adc4-16cbda54fb89";
+
+var db;
+var quotaURI;
+var permURI;
+
+function visitURI(uri, timestamp) {
+ return PlacesTestUtils.addVisits({
+ uri,
+ title: uri.spec,
+ visitDate: timestamp * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_LINK,
+ });
+}
+
+var putRecord = async function ({ scope, perm, quota, lastPush, lastVisit }) {
+ let uri = Services.io.newURI(scope);
+
+ PermissionTestUtils.add(
+ uri,
+ "desktop-notification",
+ Ci.nsIPermissionManager[perm]
+ );
+ registerCleanupFunction(() => {
+ PermissionTestUtils.remove(uri, "desktop-notification");
+ });
+
+ await visitURI(uri, lastVisit);
+
+ await db.put({
+ channelID: uri.pathQueryRef,
+ pushEndpoint: "https://example.org/push" + uri.pathQueryRef,
+ scope: uri.spec,
+ pushCount: 0,
+ lastPush,
+ version: null,
+ originAttributes: "",
+ quota,
+ });
+
+ return uri;
+};
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+
+ db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ run_next_test();
+}
+
+add_task(async function setUp() {
+ // An expired registration that should be evicted on startup. Permission is
+ // granted for this origin, and the last visit is more recent than the last
+ // push message.
+ await putRecord({
+ scope: "https://example.com/expired-quota-restored",
+ perm: "ALLOW_ACTION",
+ quota: 0,
+ lastPush: Date.now() - 10,
+ lastVisit: Date.now(),
+ });
+
+ // An expired registration that we should evict when the origin is visited
+ // again.
+ quotaURI = await putRecord({
+ scope: "https://example.xyz/expired-quota-exceeded",
+ perm: "ALLOW_ACTION",
+ quota: 0,
+ lastPush: Date.now() - 10,
+ lastVisit: Date.now() - 20,
+ });
+
+ // An expired registration that we should evict when permission is granted
+ // again.
+ permURI = await putRecord({
+ scope: "https://example.info/expired-perm-revoked",
+ perm: "DENY_ACTION",
+ quota: 0,
+ lastPush: Date.now() - 10,
+ lastVisit: Date.now(),
+ });
+
+ // An active registration that we should leave alone.
+ await putRecord({
+ scope: "https://example.ninja/active",
+ perm: "ALLOW_ACTION",
+ quota: 16,
+ lastPush: Date.now() - 10,
+ lastVisit: Date.now() - 20,
+ });
+
+ let subChangePromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ (subject, data) => data == "https://example.com/expired-quota-restored"
+ );
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ },
+ onUnregister(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ channelID: request.channelID,
+ status: 200,
+ })
+ );
+ },
+ });
+ },
+ });
+
+ await subChangePromise;
+});
+
+add_task(async function test_site_visited() {
+ let subChangePromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ (subject, data) => data == "https://example.xyz/expired-quota-exceeded"
+ );
+
+ await visitURI(quotaURI, Date.now());
+ PushService.observe(null, "idle-daily", "");
+
+ await subChangePromise;
+});
+
+add_task(async function test_perm_restored() {
+ let subChangePromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ (subject, data) => data == "https://example.info/expired-perm-revoked"
+ );
+
+ PermissionTestUtils.add(
+ permURI,
+ "desktop-notification",
+ Ci.nsIPermissionManager.ALLOW_ACTION
+ );
+
+ await subChangePromise;
+});
diff --git a/dom/push/test/xpcshell/test_handler_service.js b/dom/push/test/xpcshell/test_handler_service.js
new file mode 100644
index 0000000000..bee29c1c77
--- /dev/null
+++ b/dom/push/test/xpcshell/test_handler_service.js
@@ -0,0 +1,74 @@
+"use strict";
+
+// Here we test that if an xpcom component is registered with the category
+// manager for push notifications against a specific scope, that service is
+// instantiated before the message is delivered.
+
+const { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+let pushService = Cc["@mozilla.org/push/Service;1"].getService(
+ Ci.nsIPushService
+);
+
+function PushServiceHandler() {
+ // Register a push observer.
+ this.observed = [];
+ Services.obs.addObserver(this, pushService.pushTopic);
+ Services.obs.addObserver(this, pushService.subscriptionChangeTopic);
+ Services.obs.addObserver(this, pushService.subscriptionModifiedTopic);
+}
+
+PushServiceHandler.prototype = {
+ classID: Components.ID("{bb7c5199-c0f7-4976-9f6d-1306e32c5591}"),
+ QueryInterface: ChromeUtils.generateQI([]),
+
+ observe(subject, topic, data) {
+ this.observed.push({ subject, topic, data });
+ },
+};
+
+let handlerService = new PushServiceHandler();
+
+add_test(function test_service_instantiation() {
+ const CONTRACT_ID = "@mozilla.org/dom/push/test/PushServiceHandler;1";
+ let scope = "chrome://test-scope";
+
+ MockRegistrar.register(CONTRACT_ID, handlerService);
+ Services.catMan.addCategoryEntry("push", scope, CONTRACT_ID, false, false);
+
+ let pushNotifier = Cc["@mozilla.org/push/Notifier;1"].getService(
+ Ci.nsIPushNotifier
+ );
+ let principal = Services.scriptSecurityManager.getSystemPrincipal();
+ pushNotifier.notifyPush(scope, principal, "");
+
+ equal(handlerService.observed.length, 1);
+ equal(handlerService.observed[0].topic, pushService.pushTopic);
+ let message = handlerService.observed[0].subject.QueryInterface(
+ Ci.nsIPushMessage
+ );
+ equal(message.principal, principal);
+ strictEqual(message.data, null);
+ equal(handlerService.observed[0].data, scope);
+
+ // and a subscription change.
+ pushNotifier.notifySubscriptionChange(scope, principal);
+ equal(handlerService.observed.length, 2);
+ equal(handlerService.observed[1].topic, pushService.subscriptionChangeTopic);
+ equal(handlerService.observed[1].subject, principal);
+ equal(handlerService.observed[1].data, scope);
+
+ // and a subscription modified event.
+ pushNotifier.notifySubscriptionModified(scope, principal);
+ equal(handlerService.observed.length, 3);
+ equal(
+ handlerService.observed[2].topic,
+ pushService.subscriptionModifiedTopic
+ );
+ equal(handlerService.observed[2].subject, principal);
+ equal(handlerService.observed[2].data, scope);
+
+ run_next_test();
+});
diff --git a/dom/push/test/xpcshell/test_notification_ack.js b/dom/push/test/xpcshell/test_notification_ack.js
new file mode 100644
index 0000000000..87c29f417a
--- /dev/null
+++ b/dom/push/test/xpcshell/test_notification_ack.js
@@ -0,0 +1,163 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var userAgentID = "5ab1d1df-7a3d-4024-a469-b9e1bb399fad";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({ userAgentID });
+ run_next_test();
+}
+
+add_task(async function test_notification_ack() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ let records = [
+ {
+ channelID: "21668e05-6da8-42c9-b8ab-9cc3f4d5630c",
+ pushEndpoint: "https://example.com/update/1",
+ scope: "https://example.org/1",
+ originAttributes: "",
+ version: 1,
+ quota: Infinity,
+ systemRecord: true,
+ },
+ {
+ channelID: "9a5ff87f-47c9-4215-b2b8-0bdd38b4b305",
+ pushEndpoint: "https://example.com/update/2",
+ scope: "https://example.org/2",
+ originAttributes: "",
+ version: 2,
+ quota: Infinity,
+ systemRecord: true,
+ },
+ {
+ channelID: "5477bfda-22db-45d4-9614-fee369630260",
+ pushEndpoint: "https://example.com/update/3",
+ scope: "https://example.org/3",
+ originAttributes: "",
+ version: 3,
+ quota: Infinity,
+ systemRecord: true,
+ },
+ ];
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ let notifyCount = 0;
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic,
+ () => ++notifyCount == 3
+ );
+
+ let acks = 0;
+ let ackDone;
+ let ackPromise = new Promise(resolve => (ackDone = resolve));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ equal(
+ request.uaid,
+ userAgentID,
+ "Should send matching device IDs in handshake"
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ uaid: userAgentID,
+ status: 200,
+ })
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ channelID: "21668e05-6da8-42c9-b8ab-9cc3f4d5630c",
+ version: 2,
+ },
+ ],
+ })
+ );
+ },
+ onACK(request) {
+ equal(request.messageType, "ack", "Should send acknowledgements");
+ let updates = request.updates;
+ switch (++acks) {
+ case 1:
+ deepEqual(
+ [
+ {
+ channelID: "21668e05-6da8-42c9-b8ab-9cc3f4d5630c",
+ version: 2,
+ code: 100,
+ },
+ ],
+ updates,
+ "Wrong updates for acknowledgement 1"
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ channelID: "9a5ff87f-47c9-4215-b2b8-0bdd38b4b305",
+ version: 4,
+ },
+ {
+ channelID: "5477bfda-22db-45d4-9614-fee369630260",
+ version: 6,
+ },
+ ],
+ })
+ );
+ break;
+
+ case 2:
+ deepEqual(
+ [
+ {
+ channelID: "9a5ff87f-47c9-4215-b2b8-0bdd38b4b305",
+ version: 4,
+ code: 100,
+ },
+ ],
+ updates,
+ "Wrong updates for acknowledgement 2"
+ );
+ break;
+
+ case 3:
+ deepEqual(
+ [
+ {
+ channelID: "5477bfda-22db-45d4-9614-fee369630260",
+ version: 6,
+ code: 100,
+ },
+ ],
+ updates,
+ "Wrong updates for acknowledgement 3"
+ );
+ ackDone();
+ break;
+
+ default:
+ ok(false, "Unexpected acknowledgement " + acks);
+ }
+ },
+ });
+ },
+ });
+
+ await notifyPromise;
+ await ackPromise;
+});
diff --git a/dom/push/test/xpcshell/test_notification_data.js b/dom/push/test/xpcshell/test_notification_data.js
new file mode 100644
index 0000000000..9c9cc943a8
--- /dev/null
+++ b/dom/push/test/xpcshell/test_notification_data.js
@@ -0,0 +1,310 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let db;
+let userAgentID = "f5b47f8d-771f-4ea3-b999-91c135f8766d";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+function putRecord(channelID, scope, publicKey, privateKey, authSecret) {
+ return db.put({
+ channelID,
+ pushEndpoint: "https://example.org/push/" + channelID,
+ scope,
+ pushCount: 0,
+ lastPush: 0,
+ originAttributes: "",
+ quota: Infinity,
+ systemRecord: true,
+ p256dhPublicKey: ChromeUtils.base64URLDecode(publicKey, {
+ padding: "reject",
+ }),
+ p256dhPrivateKey: privateKey,
+ authenticationSecret: ChromeUtils.base64URLDecode(authSecret, {
+ padding: "reject",
+ }),
+ });
+}
+
+let ackDone;
+let server;
+add_task(async function test_notification_ack_data_setup() {
+ db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ await putRecord(
+ "subscription1",
+ "https://example.com/page/1",
+ "BPCd4gNQkjwRah61LpdALdzZKLLnU5UAwDztQ5_h0QsT26jk0IFbqcK6-JxhHAm-rsHEwy0CyVJjtnfOcqc1tgA",
+ {
+ crv: "P-256",
+ d: "1jUPhzVsRkzV0vIzwL4ZEsOlKdNOWm7TmaTfzitJkgM",
+ ext: true,
+ key_ops: ["deriveBits"],
+ kty: "EC",
+ x: "8J3iA1CSPBFqHrUul0At3NkosudTlQDAPO1Dn-HRCxM",
+ y: "26jk0IFbqcK6-JxhHAm-rsHEwy0CyVJjtnfOcqc1tgA",
+ },
+ "c_sGN6uCv9Hu7JOQT34jAQ"
+ );
+ await putRecord(
+ "subscription2",
+ "https://example.com/page/2",
+ "BPnWyUo7yMnuMlyKtERuLfWE8a09dtdjHSW2lpC9_BqR5TZ1rK8Ldih6ljyxVwnBA-nygQHGRpEmu1jV5K8437E",
+ {
+ crv: "P-256",
+ d: "lFm4nPsUKYgNGBJb5nXXKxl8bspCSp0bAhCYxbveqT4",
+ ext: true,
+ key_ops: ["deriveBits"],
+ kty: "EC",
+ x: "-dbJSjvIye4yXIq0RG4t9YTxrT1212MdJbaWkL38GpE",
+ y: "5TZ1rK8Ldih6ljyxVwnBA-nygQHGRpEmu1jV5K8437E",
+ },
+ "t3P246Gj9vjKDHHRYaY6hw"
+ );
+ await putRecord(
+ "subscription3",
+ "https://example.com/page/3",
+ "BDhUHITSeVrWYybFnb7ylVTCDDLPdQWMpf8gXhcWwvaaJa6n3YH8TOcH8narDF6t8mKVvg2ioLW-8MH5O4dzGcI",
+ {
+ crv: "P-256",
+ d: "Q1_SE1NySTYzjbqgWwPgrYh7XRg3adqZLkQPsy319G8",
+ ext: true,
+ key_ops: ["deriveBits"],
+ kty: "EC",
+ x: "OFQchNJ5WtZjJsWdvvKVVMIMMs91BYyl_yBeFxbC9po",
+ y: "Ja6n3YH8TOcH8narDF6t8mKVvg2ioLW-8MH5O4dzGcI",
+ },
+ "E0qiXGWvFSR0PS352ES1_Q"
+ );
+
+ let setupDone;
+ let setupDonePromise = new Promise(r => (setupDone = r));
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ equal(
+ request.uaid,
+ userAgentID,
+ "Should send matching device IDs in handshake"
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ uaid: userAgentID,
+ status: 200,
+ use_webpush: true,
+ })
+ );
+ server = this;
+ setupDone();
+ },
+ onACK(request) {
+ if (ackDone) {
+ ackDone(request);
+ }
+ },
+ });
+ },
+ });
+ await setupDonePromise;
+});
+
+add_task(async function test_notification_ack_data() {
+ let allTestData = [
+ {
+ channelID: "subscription1",
+ version: "v1",
+ send: {
+ headers: {
+ encryption_key:
+ 'keyid="notification1"; dh="BO_tgGm-yvYAGLeRe16AvhzaUcpYRiqgsGOlXpt0DRWDRGGdzVLGlEVJMygqAUECarLnxCiAOHTP_znkedrlWoU"',
+ encryption: 'keyid="notification1";salt="uAZaiXpOSfOLJxtOCZ09dA"',
+ encoding: "aesgcm128",
+ },
+ data: "NwrrOWPxLE8Sv5Rr0Kep7n0-r_j3rsYrUw_CXPo",
+ version: "v1",
+ },
+ ackCode: 100,
+ receive: {
+ scope: "https://example.com/page/1",
+ data: "Some message",
+ },
+ },
+ {
+ channelID: "subscription2",
+ version: "v2",
+ send: {
+ headers: {
+ encryption_key:
+ 'keyid="notification2"; dh="BKVdQcgfncpNyNWsGrbecX0zq3eHIlHu5XbCGmVcxPnRSbhjrA6GyBIeGdqsUL69j5Z2CvbZd-9z1UBH0akUnGQ"',
+ encryption: 'keyid="notification2";salt="vFn3t3M_k42zHBdpch3VRw"',
+ encoding: "aesgcm128",
+ },
+ data: "Zt9dEdqgHlyAL_l83385aEtb98ZBilz5tgnGgmwEsl5AOCNgesUUJ4p9qUU",
+ },
+ ackCode: 100,
+ receive: {
+ scope: "https://example.com/page/2",
+ data: "Some message",
+ },
+ },
+ {
+ channelID: "subscription3",
+ version: "v3",
+ send: {
+ headers: {
+ encryption_key:
+ 'keyid="notification3";dh="BD3xV_ACT8r6hdIYES3BJj1qhz9wyv7MBrG9vM2UCnjPzwE_YFVpkD-SGqE-BR2--0M-Yf31wctwNsO1qjBUeMg"',
+ encryption:
+ 'keyid="notification3"; salt="DFq188piWU7osPBgqn4Nlg"; rs=24',
+ encoding: "aesgcm128",
+ },
+ data: "LKru3ZzxBZuAxYtsaCfaj_fehkrIvqbVd1iSwnwAUgnL-cTeDD-83blxHXTq7r0z9ydTdMtC3UjAcWi8LMnfY-BFzi0qJAjGYIikDA",
+ },
+ ackCode: 100,
+ receive: {
+ scope: "https://example.com/page/3",
+ data: "Some message",
+ },
+ },
+ // A message encoded with `aesgcm` (2 bytes of padding, authenticated).
+ {
+ channelID: "subscription1",
+ version: "v5",
+ send: {
+ headers: {
+ crypto_key:
+ 'keyid=v4;dh="BMh_vsnqu79ZZkMTYkxl4gWDLdPSGE72Lr4w2hksSFW398xCMJszjzdblAWXyhSwakRNEU_GopAm4UGzyMVR83w"',
+ encryption: 'keyid="v4";salt="C14Wb7rQTlXzrgcPHtaUzw"',
+ encoding: "aesgcm",
+ },
+ data: "pus4kUaBWzraH34M-d_oN8e0LPpF_X6acx695AMXovDe",
+ },
+ ackCode: 100,
+ receive: {
+ scope: "https://example.com/page/1",
+ data: "Another message",
+ },
+ },
+ // A message with 17 bytes of padding and rs of 24
+ {
+ channelID: "subscription2",
+ version: "v5",
+ send: {
+ headers: {
+ crypto_key:
+ 'keyid="v5"; dh="BOp-DpyR9eLY5Ci11_loIFqeHzWfc_0evJmq7N8NKzgp60UAMMM06XIi2VZp2_TSdw1omk7E19SyeCCwRp76E-U"',
+ encryption: 'keyid=v5;salt="TvjOou1TqJOQY_ZsOYV3Ww";rs=24',
+ encoding: "aesgcm",
+ },
+ data: "rG9WYQ2ZwUgfj_tMlZ0vcIaNpBN05FW-9RUBZAM-UUZf0_9eGpuENBpUDAw3mFmd2XJpmvPvAtLVs54l3rGwg1o",
+ },
+ ackCode: 100,
+ receive: {
+ scope: "https://example.com/page/2",
+ data: "Some message",
+ },
+ },
+ // A message without key identifiers.
+ {
+ channelID: "subscription3",
+ version: "v6",
+ send: {
+ headers: {
+ crypto_key:
+ 'dh="BEEjwWbF5jZKCgW0kmUWgG-wNcRvaa9_3zZElHAF8przHwd4cp5_kQsc-IMNZcVA0iUix31jxuMOytU-5DwWtyQ"',
+ encryption: "salt=aAQcr2khAksgNspPiFEqiQ",
+ encoding: "aesgcm",
+ },
+ data: "pEYgefdI-7L46CYn5dR9TIy2AXGxe07zxclbhstY",
+ },
+ ackCode: 100,
+ receive: {
+ scope: "https://example.com/page/3",
+ data: "Some message",
+ },
+ },
+ // A malformed encrypted message.
+ {
+ channelID: "subscription3",
+ version: "v7",
+ send: {
+ headers: {
+ crypto_key: "dh=AAAAAAAA",
+ encryption: "salt=AAAAAAAA",
+ },
+ data: "AAAAAAAA",
+ },
+ ackCode: 101,
+ receive: null,
+ },
+ ];
+
+ let sendAndReceive = testData => {
+ let messageReceived = testData.receive
+ ? promiseObserverNotification(
+ PushServiceComponent.pushTopic,
+ (subject, data) => {
+ let notification = subject.QueryInterface(Ci.nsIPushMessage).data;
+ equal(
+ notification.text(),
+ testData.receive.data,
+ "Check data for notification " + testData.version
+ );
+ equal(
+ data,
+ testData.receive.scope,
+ "Check scope for notification " + testData.version
+ );
+ return true;
+ }
+ )
+ : Promise.resolve();
+
+ let ackReceived = new Promise(resolve => (ackDone = resolve)).then(
+ ackData => {
+ deepEqual(
+ {
+ messageType: "ack",
+ updates: [
+ {
+ channelID: testData.channelID,
+ version: testData.version,
+ code: testData.ackCode,
+ },
+ ],
+ },
+ ackData,
+ "Check updates for acknowledgment " + testData.version
+ );
+ }
+ );
+
+ let msg = JSON.parse(JSON.stringify(testData.send));
+ msg.messageType = "notification";
+ msg.channelID = testData.channelID;
+ msg.version = testData.version;
+ server.serverSendMsg(JSON.stringify(msg));
+
+ return Promise.all([messageReceived, ackReceived]);
+ };
+
+ await allTestData.reduce((p, testData) => {
+ return p.then(_ => sendAndReceive(testData));
+ }, Promise.resolve());
+});
diff --git a/dom/push/test/xpcshell/test_notification_duplicate.js b/dom/push/test/xpcshell/test_notification_duplicate.js
new file mode 100644
index 0000000000..9812b63149
--- /dev/null
+++ b/dom/push/test/xpcshell/test_notification_duplicate.js
@@ -0,0 +1,175 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "1500e7d9-8cbe-4ee6-98da-7fa5d6a39852";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ maxRecentMessageIDsPerSubscription: 4,
+ userAgentID,
+ });
+ run_next_test();
+}
+
+// Should acknowledge duplicate notifications, but not notify apps.
+add_task(async function test_notification_duplicate() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ let records = [
+ {
+ channelID: "has-recents",
+ pushEndpoint: "https://example.org/update/1",
+ scope: "https://example.com/1",
+ originAttributes: "",
+ recentMessageIDs: ["dupe"],
+ quota: Infinity,
+ systemRecord: true,
+ },
+ {
+ channelID: "no-recents",
+ pushEndpoint: "https://example.org/update/2",
+ scope: "https://example.com/2",
+ originAttributes: "",
+ quota: Infinity,
+ systemRecord: true,
+ },
+ {
+ channelID: "dropped-recents",
+ pushEndpoint: "https://example.org/update/3",
+ scope: "https://example.com/3",
+ originAttributes: "",
+ recentMessageIDs: ["newest", "newer", "older", "oldest"],
+ quota: Infinity,
+ systemRecord: true,
+ },
+ ];
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ let testData = [
+ {
+ channelID: "has-recents",
+ updates: 1,
+ acks: [
+ {
+ version: "dupe",
+ code: 102,
+ },
+ {
+ version: "not-dupe",
+ code: 100,
+ },
+ ],
+ recents: ["not-dupe", "dupe"],
+ },
+ {
+ channelID: "no-recents",
+ updates: 1,
+ acks: [
+ {
+ version: "not-dupe",
+ code: 100,
+ },
+ ],
+ recents: ["not-dupe"],
+ },
+ {
+ channelID: "dropped-recents",
+ acks: [
+ {
+ version: "overflow",
+ code: 100,
+ },
+ {
+ version: "oldest",
+ code: 100,
+ },
+ ],
+ updates: 2,
+ recents: ["oldest", "overflow", "newest", "newer"],
+ },
+ ];
+
+ let expectedUpdates = testData.reduce((sum, { updates }) => sum + updates, 0);
+ let notifiedScopes = [];
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic,
+ (subject, data) => {
+ notifiedScopes.push(data);
+ return notifiedScopes.length == expectedUpdates;
+ }
+ );
+
+ let expectedAcks = testData.reduce((sum, { acks }) => sum + acks.length, 0);
+ let ackDone;
+ let ackPromise = new Promise(
+ resolve => (ackDone = after(expectedAcks, resolve))
+ );
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ use_webpush: true,
+ })
+ );
+ for (let { channelID, acks } of testData) {
+ for (let { version } of acks) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ channelID,
+ version,
+ })
+ );
+ }
+ }
+ },
+ onACK(request) {
+ let [ack] = request.updates;
+ let expectedData = testData.find(
+ test => test.channelID == ack.channelID
+ );
+ ok(expectedData, `Unexpected channel ${ack.channelID}`);
+ let expectedAck = expectedData.acks.find(
+ a => a.version == ack.version
+ );
+ ok(
+ expectedAck,
+ `Unexpected ack for message ${ack.version} on ${ack.channelID}`
+ );
+ equal(
+ expectedAck.code,
+ ack.code,
+ `Wrong ack status for message ${ack.version} on ${ack.channelID}`
+ );
+ ackDone();
+ },
+ });
+ },
+ });
+
+ await notifyPromise;
+ await ackPromise;
+
+ for (let { channelID, recents } of testData) {
+ let record = await db.getByKeyID(channelID);
+ deepEqual(
+ record.recentMessageIDs,
+ recents,
+ `Wrong recent message IDs for ${channelID}`
+ );
+ }
+});
diff --git a/dom/push/test/xpcshell/test_notification_error.js b/dom/push/test/xpcshell/test_notification_error.js
new file mode 100644
index 0000000000..21ab7ab94f
--- /dev/null
+++ b/dom/push/test/xpcshell/test_notification_error.js
@@ -0,0 +1,151 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "3c7462fc-270f-45be-a459-b9d631b0d093";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(async function test_notification_error() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ let originAttributes = "";
+ let records = [
+ {
+ channelID: "f04f1e46-9139-4826-b2d1-9411b0821283",
+ pushEndpoint: "https://example.org/update/success-1",
+ scope: "https://example.com/a",
+ originAttributes,
+ version: 1,
+ quota: Infinity,
+ systemRecord: true,
+ },
+ {
+ channelID: "3c3930ba-44de-40dc-a7ca-8a133ec1a866",
+ pushEndpoint: "https://example.org/update/error",
+ scope: "https://example.com/b",
+ originAttributes,
+ version: 2,
+ quota: Infinity,
+ systemRecord: true,
+ },
+ {
+ channelID: "b63f7bef-0a0d-4236-b41e-086a69dfd316",
+ pushEndpoint: "https://example.org/update/success-2",
+ scope: "https://example.com/c",
+ originAttributes,
+ version: 3,
+ quota: Infinity,
+ systemRecord: true,
+ },
+ ];
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ let scopes = [];
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic,
+ (subject, data) => scopes.push(data) == 2
+ );
+
+ let ackDone;
+ let ackPromise = new Promise(
+ resolve => (ackDone = after(records.length, resolve))
+ );
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db: makeStub(db, {
+ getByKeyID(prev, channelID) {
+ if (channelID == "3c3930ba-44de-40dc-a7ca-8a133ec1a866") {
+ return Promise.reject("splines not reticulated");
+ }
+ return prev.call(this, channelID);
+ },
+ }),
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: records.map(({ channelID, version }) => ({
+ channelID,
+ version: ++version,
+ })),
+ })
+ );
+ },
+ // Should acknowledge all received updates, even if updating
+ // IndexedDB fails.
+ onACK: ackDone,
+ });
+ },
+ });
+
+ await notifyPromise;
+ ok(
+ scopes.includes("https://example.com/a"),
+ "Missing scope for notification A"
+ );
+ ok(
+ scopes.includes("https://example.com/c"),
+ "Missing scope for notification C"
+ );
+
+ await ackPromise;
+
+ let aRecord = await db.getByIdentifiers({
+ scope: "https://example.com/a",
+ originAttributes,
+ });
+ equal(
+ aRecord.channelID,
+ "f04f1e46-9139-4826-b2d1-9411b0821283",
+ "Wrong channel ID for record A"
+ );
+ strictEqual(aRecord.version, 2, "Should return the new version for record A");
+
+ let bRecord = await db.getByIdentifiers({
+ scope: "https://example.com/b",
+ originAttributes,
+ });
+ equal(
+ bRecord.channelID,
+ "3c3930ba-44de-40dc-a7ca-8a133ec1a866",
+ "Wrong channel ID for record B"
+ );
+ strictEqual(
+ bRecord.version,
+ 2,
+ "Should return the previous version for record B"
+ );
+
+ let cRecord = await db.getByIdentifiers({
+ scope: "https://example.com/c",
+ originAttributes,
+ });
+ equal(
+ cRecord.channelID,
+ "b63f7bef-0a0d-4236-b41e-086a69dfd316",
+ "Wrong channel ID for record C"
+ );
+ strictEqual(cRecord.version, 4, "Should return the new version for record C");
+});
diff --git a/dom/push/test/xpcshell/test_notification_incomplete.js b/dom/push/test/xpcshell/test_notification_incomplete.js
new file mode 100644
index 0000000000..48aba51132
--- /dev/null
+++ b/dom/push/test/xpcshell/test_notification_incomplete.js
@@ -0,0 +1,151 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "1ca1cf66-eeb4-4df7-87c1-d5c92906ab90";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(async function test_notification_incomplete() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ let records = [
+ {
+ channelID: "123",
+ pushEndpoint: "https://example.org/update/1",
+ scope: "https://example.com/page/1",
+ version: 1,
+ originAttributes: "",
+ quota: Infinity,
+ },
+ {
+ channelID: "3ad1ed95-d37a-4d88-950f-22cbe2e240d7",
+ pushEndpoint: "https://example.org/update/2",
+ scope: "https://example.com/page/2",
+ version: 1,
+ originAttributes: "",
+ quota: Infinity,
+ },
+ {
+ channelID: "d239498b-1c85-4486-b99b-205866e82d1f",
+ pushEndpoint: "https://example.org/update/3",
+ scope: "https://example.com/page/3",
+ version: 3,
+ originAttributes: "",
+ quota: Infinity,
+ },
+ {
+ channelID: "a50de97d-b496-43ce-8b53-05522feb78db",
+ pushEndpoint: "https://example.org/update/4",
+ scope: "https://example.com/page/4",
+ version: 10,
+ originAttributes: "",
+ quota: Infinity,
+ },
+ ];
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ function observeMessage(subject, topic, data) {
+ ok(false, "Should not deliver malformed updates");
+ }
+ registerCleanupFunction(() =>
+ Services.obs.removeObserver(observeMessage, PushServiceComponent.pushTopic)
+ );
+ Services.obs.addObserver(observeMessage, PushServiceComponent.pushTopic);
+
+ let notificationDone;
+ let notificationPromise = new Promise(
+ resolve => (notificationDone = after(2, resolve))
+ );
+ let prevHandler = PushServiceWebSocket._handleNotificationReply;
+ PushServiceWebSocket._handleNotificationReply =
+ function _handleNotificationReply() {
+ notificationDone();
+ return prevHandler.apply(this, arguments);
+ };
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ // Missing "updates" field; should ignore message.
+ messageType: "notification",
+ })
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ // Wrong channel ID field type.
+ channelID: 123,
+ version: 3,
+ },
+ {
+ // Missing version field.
+ channelID: "3ad1ed95-d37a-4d88-950f-22cbe2e240d7",
+ },
+ {
+ // Wrong version field type.
+ channelID: "d239498b-1c85-4486-b99b-205866e82d1f",
+ version: true,
+ },
+ {
+ // Negative versions should be ignored.
+ channelID: "a50de97d-b496-43ce-8b53-05522feb78db",
+ version: -5,
+ },
+ ],
+ })
+ );
+ },
+ onACK() {
+ ok(false, "Should not acknowledge malformed updates");
+ },
+ });
+ },
+ });
+
+ await notificationPromise;
+
+ let storeRecords = await db.getAllKeyIDs();
+ storeRecords.sort(({ pushEndpoint: a }, { pushEndpoint: b }) =>
+ compareAscending(a, b)
+ );
+ recordsAreEqual(records, storeRecords);
+});
+
+function recordIsEqual(a, b) {
+ strictEqual(a.channelID, b.channelID, "Wrong channel ID in record");
+ strictEqual(a.pushEndpoint, b.pushEndpoint, "Wrong push endpoint in record");
+ strictEqual(a.scope, b.scope, "Wrong scope in record");
+ strictEqual(a.version, b.version, "Wrong version in record");
+}
+
+function recordsAreEqual(a, b) {
+ equal(a.length, b.length, "Mismatched record count");
+ for (let i = 0; i < a.length; i++) {
+ recordIsEqual(a[i], b[i]);
+ }
+}
diff --git a/dom/push/test/xpcshell/test_notification_version_string.js b/dom/push/test/xpcshell/test_notification_version_string.js
new file mode 100644
index 0000000000..7aaaee5269
--- /dev/null
+++ b/dom/push/test/xpcshell/test_notification_version_string.js
@@ -0,0 +1,79 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "ba31ac13-88d4-4984-8e6b-8731315a7cf8";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(async function test_notification_version_string() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ await db.put({
+ channelID: "6ff97d56-d0c0-43bc-8f5b-61b855e1d93b",
+ pushEndpoint: "https://example.org/updates/1",
+ scope: "https://example.com/page/1",
+ originAttributes: "",
+ version: 2,
+ quota: Infinity,
+ systemRecord: true,
+ });
+
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic
+ );
+
+ let ackDone;
+ let ackPromise = new Promise(resolve => (ackDone = resolve));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ channelID: "6ff97d56-d0c0-43bc-8f5b-61b855e1d93b",
+ version: "4",
+ },
+ ],
+ })
+ );
+ },
+ onACK: ackDone,
+ });
+ },
+ });
+
+ let { subject: message } = await notifyPromise;
+ equal(
+ message.QueryInterface(Ci.nsIPushMessage).data,
+ null,
+ "Unexpected data for Simple Push message"
+ );
+
+ await ackPromise;
+
+ let storeRecord = await db.getByKeyID("6ff97d56-d0c0-43bc-8f5b-61b855e1d93b");
+ strictEqual(storeRecord.version, 4, "Wrong record version");
+ equal(storeRecord.quota, Infinity, "Wrong quota");
+});
diff --git a/dom/push/test/xpcshell/test_observer_data.js b/dom/push/test/xpcshell/test_observer_data.js
new file mode 100644
index 0000000000..01c331237c
--- /dev/null
+++ b/dom/push/test/xpcshell/test_observer_data.js
@@ -0,0 +1,61 @@
+"use strict";
+
+var pushNotifier = Cc["@mozilla.org/push/Notifier;1"].getService(
+ Ci.nsIPushNotifier
+);
+var systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
+
+add_task(async function test_notifyWithData() {
+ let textData = '{"hello":"world"}';
+ let payload = new TextEncoder().encode(textData);
+
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic
+ );
+ pushNotifier.notifyPushWithData(
+ "chrome://notify-test",
+ systemPrincipal,
+ "" /* messageId */,
+ payload
+ );
+
+ let data = (await notifyPromise).subject.QueryInterface(
+ Ci.nsIPushMessage
+ ).data;
+ deepEqual(
+ data.json(),
+ {
+ hello: "world",
+ },
+ "Should extract JSON values"
+ );
+ deepEqual(
+ data.binary(),
+ Array.from(payload),
+ "Should extract raw binary data"
+ );
+ equal(data.text(), textData, "Should extract text data");
+});
+
+add_task(async function test_empty_notifyWithData() {
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic
+ );
+ pushNotifier.notifyPushWithData(
+ "chrome://notify-test",
+ systemPrincipal,
+ "" /* messageId */,
+ []
+ );
+
+ let data = (await notifyPromise).subject.QueryInterface(
+ Ci.nsIPushMessage
+ ).data;
+ throws(
+ _ => data.json(),
+ /InvalidStateError/,
+ "Should throw an error when parsing an empty string as JSON"
+ );
+ strictEqual(data.text(), "", "Should return an empty string");
+ deepEqual(data.binary(), [], "Should return an empty array");
+});
diff --git a/dom/push/test/xpcshell/test_observer_remoting.js b/dom/push/test/xpcshell/test_observer_remoting.js
new file mode 100644
index 0000000000..086fdeab80
--- /dev/null
+++ b/dom/push/test/xpcshell/test_observer_remoting.js
@@ -0,0 +1,139 @@
+"use strict";
+
+const pushNotifier = Cc["@mozilla.org/push/Notifier;1"].getService(
+ Ci.nsIPushNotifier
+);
+
+add_task(async function test_observer_remoting() {
+ do_get_profile();
+ if (isParent) {
+ await testInParent();
+ } else {
+ await testInChild();
+ }
+});
+
+const childTests = [
+ {
+ text: "Hello from child!",
+ principal: Services.scriptSecurityManager.getSystemPrincipal(),
+ },
+];
+
+const parentTests = [
+ {
+ text: "Hello from parent!",
+ principal: Services.scriptSecurityManager.getSystemPrincipal(),
+ },
+];
+
+async function testInParent() {
+ setPrefs();
+ // Register observers for notifications from the child, then run the test in
+ // the child and wait for the notifications.
+ let promiseNotifications = childTests.reduce(
+ (p, test) =>
+ p.then(_ => waitForNotifierObservers(test, /* shouldNotify = */ false)),
+ Promise.resolve()
+ );
+ let promiseFinished = run_test_in_child("./test_observer_remoting.js");
+ await promiseNotifications;
+
+ // Wait until the child is listening for notifications from the parent.
+ await do_await_remote_message("push_test_observer_remoting_child_ready");
+
+ // Fire an observer notification in the parent that should be forwarded to
+ // the child.
+ await parentTests.reduce(
+ (p, test) =>
+ p.then(_ => waitForNotifierObservers(test, /* shouldNotify = */ true)),
+ Promise.resolve()
+ );
+
+ // Wait for the child to exit.
+ await promiseFinished;
+}
+
+async function testInChild() {
+ // Fire an observer notification in the child that should be forwarded to
+ // the parent.
+ await childTests.reduce(
+ (p, test) =>
+ p.then(_ => waitForNotifierObservers(test, /* shouldNotify = */ true)),
+ Promise.resolve()
+ );
+
+ // Register observers for notifications from the parent, let the parent know
+ // we're ready, and wait for the notifications.
+ let promiseNotifierObservers = parentTests.reduce(
+ (p, test) =>
+ p.then(_ => waitForNotifierObservers(test, /* shouldNotify = */ false)),
+ Promise.resolve()
+ );
+ do_send_remote_message("push_test_observer_remoting_child_ready");
+ await promiseNotifierObservers;
+}
+
+var waitForNotifierObservers = async function (
+ { text, principal },
+ shouldNotify = false
+) {
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic
+ );
+ let subChangePromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic
+ );
+ let subModifiedPromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionModifiedTopic
+ );
+
+ let scope = "chrome://test-scope";
+ let data = new TextEncoder().encode(text);
+
+ if (shouldNotify) {
+ pushNotifier.notifyPushWithData(scope, principal, "", data);
+ pushNotifier.notifySubscriptionChange(scope, principal);
+ pushNotifier.notifySubscriptionModified(scope, principal);
+ }
+
+ let { data: notifyScope, subject: notifySubject } = await notifyPromise;
+ equal(
+ notifyScope,
+ scope,
+ "Should fire push notifications with the correct scope"
+ );
+ let message = notifySubject.QueryInterface(Ci.nsIPushMessage);
+ equal(
+ message.principal,
+ principal,
+ "Should include the principal in the push message"
+ );
+ strictEqual(message.data.text(), text, "Should include data");
+
+ let { data: subChangeScope, subject: subChangePrincipal } =
+ await subChangePromise;
+ equal(
+ subChangeScope,
+ scope,
+ "Should fire subscription change notifications with the correct scope"
+ );
+ equal(
+ subChangePrincipal,
+ principal,
+ "Should pass the principal as the subject of a change notification"
+ );
+
+ let { data: subModifiedScope, subject: subModifiedPrincipal } =
+ await subModifiedPromise;
+ equal(
+ subModifiedScope,
+ scope,
+ "Should fire subscription modified notifications with the correct scope"
+ );
+ equal(
+ subModifiedPrincipal,
+ principal,
+ "Should pass the principal as the subject of a modified notification"
+ );
+};
diff --git a/dom/push/test/xpcshell/test_permissions.js b/dom/push/test/xpcshell/test_permissions.js
new file mode 100644
index 0000000000..1b3e3282bb
--- /dev/null
+++ b/dom/push/test/xpcshell/test_permissions.js
@@ -0,0 +1,332 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "2c43af06-ab6e-476a-adc4-16cbda54fb89";
+
+let db;
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+
+ db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ run_next_test();
+}
+
+let unregisterDefers = {};
+
+function promiseUnregister(keyID) {
+ return new Promise(r => (unregisterDefers[keyID] = r));
+}
+
+function makePushPermission(url, capability) {
+ return {
+ QueryInterface: ChromeUtils.generateQI(["nsIPermission"]),
+ capability: Ci.nsIPermissionManager[capability],
+ expireTime: 0,
+ expireType: Ci.nsIPermissionManager.EXPIRE_NEVER,
+ principal: Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(url),
+ {}
+ ),
+ type: "desktop-notification",
+ };
+}
+
+function promiseObserverNotifications(topic, count) {
+ let notifiedScopes = [];
+ let subChangePromise = promiseObserverNotification(topic, (subject, data) => {
+ notifiedScopes.push(data);
+ return notifiedScopes.length == count;
+ });
+ return subChangePromise.then(_ => notifiedScopes.sort());
+}
+
+function promiseSubscriptionChanges(count) {
+ return promiseObserverNotifications(
+ PushServiceComponent.subscriptionChangeTopic,
+ count
+ );
+}
+
+function promiseSubscriptionModifications(count) {
+ return promiseObserverNotifications(
+ PushServiceComponent.subscriptionModifiedTopic,
+ count
+ );
+}
+
+function allExpired(...keyIDs) {
+ return Promise.all(keyIDs.map(keyID => db.getByKeyID(keyID))).then(records =>
+ records.every(record => record.isExpired())
+ );
+}
+
+add_task(async function setUp() {
+ // Active registration; quota should be reset to 16. Since the quota isn't
+ // exposed to content, we shouldn't receive a subscription change event.
+ await putTestRecord(db, "active-allow", "https://example.info/page/1", 8);
+
+ // Expired registration; should be dropped.
+ await putTestRecord(db, "expired-allow", "https://example.info/page/2", 0);
+
+ // Active registration; should be expired when we change the permission
+ // to "deny".
+ await putTestRecord(
+ db,
+ "active-deny-changed",
+ "https://example.xyz/page/1",
+ 16
+ );
+
+ // Two active registrations for a visited site. These will expire when we
+ // add a "deny" permission.
+ await putTestRecord(db, "active-deny-added-1", "https://example.net/ham", 16);
+ await putTestRecord(
+ db,
+ "active-deny-added-2",
+ "https://example.net/green",
+ 8
+ );
+
+ // An already-expired registration for a visited site. We shouldn't send an
+ // `unregister` request for this one, but still receive an observer
+ // notification when we restore permissions.
+ await putTestRecord(db, "expired-deny-added", "https://example.net/eggs", 0);
+
+ // A registration that should not be affected by permission list changes
+ // because its quota is set to `Infinity`.
+ await putTestRecord(db, "never-expires", "app://chrome/only", Infinity);
+
+ // A registration that should be dropped when we clear the permission
+ // list.
+ await putTestRecord(db, "drop-on-clear", "https://example.edu/lonely", 16);
+
+ let handshakeDone;
+ let handshakePromise = new Promise(resolve => (handshakeDone = resolve));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ handshakeDone();
+ },
+ onUnregister(request) {
+ let resolve = unregisterDefers[request.channelID];
+ equal(
+ typeof resolve,
+ "function",
+ "Dropped unexpected channel ID " + request.channelID
+ );
+ delete unregisterDefers[request.channelID];
+ equal(
+ request.code,
+ 202,
+ "Expected permission revoked unregister reason"
+ );
+ resolve();
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ status: 200,
+ channelID: request.channelID,
+ })
+ );
+ },
+ onACK(request) {},
+ });
+ },
+ });
+ await handshakePromise;
+});
+
+add_task(async function test_permissions_allow_added() {
+ let subChangePromise = promiseSubscriptionChanges(1);
+
+ await PushService._onPermissionChange(
+ makePushPermission("https://example.info", "ALLOW_ACTION"),
+ "added"
+ );
+ let notifiedScopes = await subChangePromise;
+
+ deepEqual(
+ notifiedScopes,
+ ["https://example.info/page/2"],
+ "Wrong scopes after adding allow"
+ );
+
+ let record = await db.getByKeyID("active-allow");
+ equal(
+ record.quota,
+ 16,
+ "Should reset quota for active records after adding allow"
+ );
+
+ record = await db.getByKeyID("expired-allow");
+ ok(!record, "Should drop expired records after adding allow");
+});
+
+add_task(async function test_permissions_allow_deleted() {
+ let subModifiedPromise = promiseSubscriptionModifications(1);
+
+ let unregisterPromise = promiseUnregister("active-allow");
+
+ await PushService._onPermissionChange(
+ makePushPermission("https://example.info", "ALLOW_ACTION"),
+ "deleted"
+ );
+
+ await unregisterPromise;
+
+ let notifiedScopes = await subModifiedPromise;
+ deepEqual(
+ notifiedScopes,
+ ["https://example.info/page/1"],
+ "Wrong scopes modified after deleting allow"
+ );
+
+ let record = await db.getByKeyID("active-allow");
+ ok(record.isExpired(), "Should expire active record after deleting allow");
+});
+
+add_task(async function test_permissions_deny_added() {
+ let subModifiedPromise = promiseSubscriptionModifications(2);
+
+ let unregisterPromise = Promise.all([
+ promiseUnregister("active-deny-added-1"),
+ promiseUnregister("active-deny-added-2"),
+ ]);
+
+ await PushService._onPermissionChange(
+ makePushPermission("https://example.net", "DENY_ACTION"),
+ "added"
+ );
+ await unregisterPromise;
+
+ let notifiedScopes = await subModifiedPromise;
+ deepEqual(
+ notifiedScopes,
+ ["https://example.net/green", "https://example.net/ham"],
+ "Wrong scopes modified after adding deny"
+ );
+
+ let isExpired = await allExpired("active-deny-added-1", "expired-deny-added");
+ ok(isExpired, "Should expire all registrations after adding deny");
+});
+
+add_task(async function test_permissions_deny_deleted() {
+ await PushService._onPermissionChange(
+ makePushPermission("https://example.net", "DENY_ACTION"),
+ "deleted"
+ );
+
+ let isExpired = await allExpired("active-deny-added-1", "expired-deny-added");
+ ok(isExpired, "Should retain expired registrations after deleting deny");
+});
+
+add_task(async function test_permissions_allow_changed() {
+ let subChangePromise = promiseSubscriptionChanges(3);
+
+ await PushService._onPermissionChange(
+ makePushPermission("https://example.net", "ALLOW_ACTION"),
+ "changed"
+ );
+
+ let notifiedScopes = await subChangePromise;
+
+ deepEqual(
+ notifiedScopes,
+ [
+ "https://example.net/eggs",
+ "https://example.net/green",
+ "https://example.net/ham",
+ ],
+ "Wrong scopes after changing to allow"
+ );
+
+ let droppedRecords = await Promise.all([
+ db.getByKeyID("active-deny-added-1"),
+ db.getByKeyID("active-deny-added-2"),
+ db.getByKeyID("expired-deny-added"),
+ ]);
+ ok(
+ !droppedRecords.some(Boolean),
+ "Should drop all expired registrations after changing to allow"
+ );
+});
+
+add_task(async function test_permissions_deny_changed() {
+ let subModifiedPromise = promiseSubscriptionModifications(1);
+
+ let unregisterPromise = promiseUnregister("active-deny-changed");
+
+ await PushService._onPermissionChange(
+ makePushPermission("https://example.xyz", "DENY_ACTION"),
+ "changed"
+ );
+
+ await unregisterPromise;
+
+ let notifiedScopes = await subModifiedPromise;
+ deepEqual(
+ notifiedScopes,
+ ["https://example.xyz/page/1"],
+ "Wrong scopes modified after changing to deny"
+ );
+
+ let record = await db.getByKeyID("active-deny-changed");
+ ok(record.isExpired(), "Should expire active record after changing to deny");
+});
+
+add_task(async function test_permissions_clear() {
+ let subModifiedPromise = promiseSubscriptionModifications(3);
+
+ deepEqual(
+ await getAllKeyIDs(db),
+ ["active-allow", "active-deny-changed", "drop-on-clear", "never-expires"],
+ "Wrong records in database before clearing"
+ );
+
+ let unregisterPromise = Promise.all([
+ promiseUnregister("active-allow"),
+ promiseUnregister("active-deny-changed"),
+ promiseUnregister("drop-on-clear"),
+ ]);
+
+ await PushService._onPermissionChange(null, "cleared");
+
+ await unregisterPromise;
+
+ let notifiedScopes = await subModifiedPromise;
+ deepEqual(
+ notifiedScopes,
+ [
+ "https://example.edu/lonely",
+ "https://example.info/page/1",
+ "https://example.xyz/page/1",
+ ],
+ "Wrong scopes modified after clearing registrations"
+ );
+
+ deepEqual(
+ await getAllKeyIDs(db),
+ ["never-expires"],
+ "Unrestricted registrations should not be dropped"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_quota_exceeded.js b/dom/push/test/xpcshell/test_quota_exceeded.js
new file mode 100644
index 0000000000..f8365aa888
--- /dev/null
+++ b/dom/push/test/xpcshell/test_quota_exceeded.js
@@ -0,0 +1,156 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "7eb873f9-8d47-4218-804b-fff78dc04e88";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ "testing.ignorePermission": true,
+ });
+ run_next_test();
+}
+
+add_task(async function test_expiration_origin_threshold() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => db.drop().then(_ => db.close()));
+
+ await db.put({
+ channelID: "eb33fc90-c883-4267-b5cb-613969e8e349",
+ pushEndpoint: "https://example.org/push/1",
+ scope: "https://example.com/auctions",
+ pushCount: 0,
+ lastPush: 0,
+ version: null,
+ originAttributes: "",
+ quota: 16,
+ });
+ await db.put({
+ channelID: "46cc6f6a-c106-4ffa-bb7c-55c60bd50c41",
+ pushEndpoint: "https://example.org/push/2",
+ scope: "https://example.com/deals",
+ pushCount: 0,
+ lastPush: 0,
+ version: null,
+ originAttributes: "",
+ quota: 16,
+ });
+
+ // The notification threshold is per-origin, even with multiple service
+ // workers for different scopes.
+ await PlacesTestUtils.addVisits([
+ {
+ uri: "https://example.com/login",
+ title: "Sign in to see your auctions",
+ visitDate: (Date.now() - 7 * 24 * 60 * 60 * 1000) * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_LINK,
+ },
+ // We'll always use your most recent visit to an origin.
+ {
+ uri: "https://example.com/auctions",
+ title: "Your auctions",
+ visitDate: (Date.now() - 2 * 24 * 60 * 60 * 1000) * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_LINK,
+ },
+ // ...But we won't count downloads or embeds.
+ {
+ uri: "https://example.com/invoices/invoice.pdf",
+ title: "Invoice #123",
+ visitDate: (Date.now() - 1 * 24 * 60 * 60 * 1000) * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_EMBED,
+ },
+ {
+ uri: "https://example.com/invoices/invoice.pdf",
+ title: "Invoice #123",
+ visitDate: Date.now() * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD,
+ },
+ ]);
+
+ // We expect to receive 6 notifications: 5 on the `auctions` channel,
+ // and 1 on the `deals` channel. They're from the same origin, but
+ // different scopes, so each can send 5 notifications before we remove
+ // their subscription.
+ let updates = 0;
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic,
+ (subject, data) => {
+ updates++;
+ return updates == 6;
+ }
+ );
+
+ let unregisterDone;
+ let unregisterPromise = new Promise(resolve => (unregisterDone = resolve));
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ // We last visited the site 2 days ago, so we can send 5
+ // notifications without throttling. Sending a 6th should
+ // drop the registration.
+ for (let version = 1; version <= 6; version++) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ channelID: "eb33fc90-c883-4267-b5cb-613969e8e349",
+ version,
+ },
+ ],
+ })
+ );
+ }
+ // But the limits are per-channel, so we can send 5 more
+ // notifications on a different channel.
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ channelID: "46cc6f6a-c106-4ffa-bb7c-55c60bd50c41",
+ version: 1,
+ },
+ ],
+ })
+ );
+ },
+ onUnregister(request) {
+ equal(
+ request.channelID,
+ "eb33fc90-c883-4267-b5cb-613969e8e349",
+ "Unregistered wrong channel ID"
+ );
+ equal(request.code, 201, "Expected quota exceeded unregister reason");
+ unregisterDone();
+ },
+ // We expect to receive acks, but don't care about their
+ // contents.
+ onACK(request) {},
+ });
+ },
+ });
+
+ await unregisterPromise;
+
+ await notifyPromise;
+
+ let expiredRecord = await db.getByKeyID(
+ "eb33fc90-c883-4267-b5cb-613969e8e349"
+ );
+ strictEqual(expiredRecord.quota, 0, "Expired record not updated");
+});
diff --git a/dom/push/test/xpcshell/test_quota_observer.js b/dom/push/test/xpcshell/test_quota_observer.js
new file mode 100644
index 0000000000..447f509967
--- /dev/null
+++ b/dom/push/test/xpcshell/test_quota_observer.js
@@ -0,0 +1,210 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "28cd09e2-7506-42d8-9e50-b02785adc7ef";
+
+var db;
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+let putRecord = async function (perm, record) {
+ let uri = Services.io.newURI(record.scope);
+
+ PermissionTestUtils.add(
+ uri,
+ "desktop-notification",
+ Ci.nsIPermissionManager[perm]
+ );
+ registerCleanupFunction(() => {
+ PermissionTestUtils.remove(uri, "desktop-notification");
+ });
+
+ await db.put(record);
+};
+
+add_task(async function test_expiration_history_observer() {
+ db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => db.drop().then(_ => db.close()));
+
+ // A registration that we'll expire...
+ await putRecord("ALLOW_ACTION", {
+ channelID: "379c0668-8323-44d2-a315-4ee83f1a9ee9",
+ pushEndpoint: "https://example.org/push/1",
+ scope: "https://example.com/deals",
+ pushCount: 0,
+ lastPush: 0,
+ version: null,
+ originAttributes: "",
+ quota: 16,
+ });
+
+ // ...And a registration that we'll evict on startup.
+ await putRecord("ALLOW_ACTION", {
+ channelID: "4cb6e454-37cf-41c4-a013-4e3a7fdd0bf1",
+ pushEndpoint: "https://example.org/push/3",
+ scope: "https://example.com/stuff",
+ pushCount: 0,
+ lastPush: 0,
+ version: null,
+ originAttributes: "",
+ quota: 0,
+ });
+
+ await PlacesTestUtils.addVisits({
+ uri: "https://example.com/infrequent",
+ title: "Infrequently-visited page",
+ visitDate: (Date.now() - 14 * 24 * 60 * 60 * 1000) * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_LINK,
+ });
+
+ let unregisterDone;
+ let unregisterPromise = new Promise(resolve => (unregisterDone = resolve));
+ let subChangePromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ (subject, data) => data == "https://example.com/stuff"
+ );
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ channelID: "379c0668-8323-44d2-a315-4ee83f1a9ee9",
+ version: 2,
+ },
+ ],
+ })
+ );
+ },
+ onUnregister(request) {
+ equal(
+ request.channelID,
+ "379c0668-8323-44d2-a315-4ee83f1a9ee9",
+ "Dropped wrong channel ID"
+ );
+ equal(request.code, 201, "Expected quota exceeded unregister reason");
+ unregisterDone();
+ },
+ onACK(request) {},
+ });
+ },
+ });
+
+ await subChangePromise;
+ await unregisterPromise;
+
+ let expiredRecord = await db.getByKeyID(
+ "379c0668-8323-44d2-a315-4ee83f1a9ee9"
+ );
+ strictEqual(expiredRecord.quota, 0, "Expired record not updated");
+
+ let notifiedScopes = [];
+ subChangePromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ (subject, data) => {
+ notifiedScopes.push(data);
+ return notifiedScopes.length == 2;
+ }
+ );
+
+ // Add an expired registration that we'll revive later using the idle
+ // observer.
+ await putRecord("ALLOW_ACTION", {
+ channelID: "eb33fc90-c883-4267-b5cb-613969e8e349",
+ pushEndpoint: "https://example.org/push/2",
+ scope: "https://example.com/auctions",
+ pushCount: 0,
+ lastPush: 0,
+ version: null,
+ originAttributes: "",
+ quota: 0,
+ });
+ // ...And an expired registration that we'll revive on fetch.
+ await putRecord("ALLOW_ACTION", {
+ channelID: "6b2d13fe-d848-4c5f-bdda-e9fc89727dca",
+ pushEndpoint: "https://example.org/push/4",
+ scope: "https://example.net/sales",
+ pushCount: 0,
+ lastPush: 0,
+ version: null,
+ originAttributes: "",
+ quota: 0,
+ });
+
+ // Now visit the site...
+ await PlacesTestUtils.addVisits({
+ uri: "https://example.com/another-page",
+ title: "Infrequently-visited page",
+ visitDate: Date.now() * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_LINK,
+ });
+ Services.obs.notifyObservers(null, "idle-daily");
+
+ // And we should receive notifications for both scopes.
+ await subChangePromise;
+ deepEqual(
+ notifiedScopes.sort(),
+ ["https://example.com/auctions", "https://example.com/deals"],
+ "Wrong scopes for subscription changes"
+ );
+
+ let aRecord = await db.getByKeyID("379c0668-8323-44d2-a315-4ee83f1a9ee9");
+ ok(!aRecord, "Should drop expired record");
+
+ let bRecord = await db.getByKeyID("eb33fc90-c883-4267-b5cb-613969e8e349");
+ ok(!bRecord, "Should drop evicted record");
+
+ // Simulate a visit to a site with an expired registration, then fetch the
+ // record. This should drop the expired record and fire an observer
+ // notification.
+ await PlacesTestUtils.addVisits({
+ uri: "https://example.net/sales",
+ title: "Firefox plushies, 99% off",
+ visitDate: Date.now() * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_LINK,
+ });
+ subChangePromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ (subject, data) => {
+ if (data == "https://example.net/sales") {
+ ok(
+ subject.isContentPrincipal,
+ "Should pass subscription principal as the subject"
+ );
+ return true;
+ }
+ return false;
+ }
+ );
+ let record = await PushService.registration({
+ scope: "https://example.net/sales",
+ originAttributes: "",
+ });
+ ok(!record, "Should not return evicted record");
+ ok(
+ !(await db.getByKeyID("6b2d13fe-d848-4c5f-bdda-e9fc89727dca")),
+ "Should drop evicted record on fetch"
+ );
+ await subChangePromise;
+});
diff --git a/dom/push/test/xpcshell/test_quota_with_notification.js b/dom/push/test/xpcshell/test_quota_with_notification.js
new file mode 100644
index 0000000000..d2e6d7cae8
--- /dev/null
+++ b/dom/push/test/xpcshell/test_quota_with_notification.js
@@ -0,0 +1,129 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "aaabf1f8-2f68-44f1-a920-b88e9e7d7559";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ "testing.ignorePermission": true,
+ });
+ run_next_test();
+}
+
+add_task(async function test_expiration_origin_threshold() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ PushService.notificationForOriginClosed("https://example.com");
+ return db.drop().then(_ => db.close());
+ });
+
+ // Simulate a notification being shown for the origin,
+ // this should relax the quota and allow as many push messages
+ // as we want.
+ PushService.notificationForOriginShown("https://example.com");
+
+ await db.put({
+ channelID: "f56645a9-1f32-4655-92ad-ddc37f6d54fb",
+ pushEndpoint: "https://example.org/push/1",
+ scope: "https://example.com/quota",
+ pushCount: 0,
+ lastPush: 0,
+ version: null,
+ originAttributes: "",
+ quota: 16,
+ });
+
+ // A visit one day ago should provide a quota of 8 messages.
+ await PlacesTestUtils.addVisits({
+ uri: "https://example.com/login",
+ title: "Sign in to see your auctions",
+ visitDate: (Date.now() - MS_IN_ONE_DAY) * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_LINK,
+ });
+
+ let numMessages = 10;
+
+ let updates = 0;
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic,
+ (subject, data) => {
+ updates++;
+ return updates == numMessages;
+ }
+ );
+
+ let modifications = 0;
+ let modifiedPromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionModifiedTopic,
+ (subject, data) => {
+ // Each subscription should be modified twice: once to update the message
+ // count and last push time, and the second time to update the quota.
+ modifications++;
+ return modifications == numMessages * 2;
+ }
+ );
+
+ let updateQuotaPromise = new Promise((resolve, reject) => {
+ let quotaUpdateCount = 0;
+ PushService._updateQuotaTestCallback = function () {
+ quotaUpdateCount++;
+ if (quotaUpdateCount == numMessages) {
+ resolve();
+ }
+ };
+ });
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+
+ // If the origin has visible notifications, the
+ // message should not affect quota.
+ for (let version = 1; version <= 10; version++) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ channelID: "f56645a9-1f32-4655-92ad-ddc37f6d54fb",
+ version,
+ },
+ ],
+ })
+ );
+ }
+ },
+ onUnregister(request) {
+ ok(false, "Channel should not be unregistered.");
+ },
+ // We expect to receive acks, but don't care about their
+ // contents.
+ onACK(request) {},
+ });
+ },
+ });
+
+ await notifyPromise;
+
+ await updateQuotaPromise;
+ await modifiedPromise;
+
+ let expiredRecord = await db.getByKeyID(
+ "f56645a9-1f32-4655-92ad-ddc37f6d54fb"
+ );
+ notStrictEqual(expiredRecord.quota, 0, "Expired record not updated");
+});
diff --git a/dom/push/test/xpcshell/test_reconnect_retry.js b/dom/push/test/xpcshell/test_reconnect_retry.js
new file mode 100644
index 0000000000..7ff3740ee8
--- /dev/null
+++ b/dom/push/test/xpcshell/test_reconnect_retry.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ requestTimeout: 10000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_reconnect_retry() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ let registers = 0;
+ let channelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: "083e6c17-1063-4677-8638-ab705aebebc2",
+ })
+ );
+ },
+ onRegister(request) {
+ registers++;
+ if (registers == 1) {
+ channelID = request.channelID;
+ this.serverClose();
+ return;
+ }
+ if (registers == 2) {
+ equal(
+ request.channelID,
+ channelID,
+ "Should retry registers after reconnect"
+ );
+ }
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ channelID: request.channelID,
+ pushEndpoint: "https://example.org/push/" + request.channelID,
+ status: 200,
+ })
+ );
+ },
+ });
+ },
+ });
+
+ let registration = await PushService.register({
+ scope: "https://example.com/page/1",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ let retryEndpoint = "https://example.org/push/" + channelID;
+ equal(
+ registration.endpoint,
+ retryEndpoint,
+ "Wrong endpoint for retried request"
+ );
+
+ registration = await PushService.register({
+ scope: "https://example.com/page/2",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ notEqual(
+ registration.endpoint,
+ retryEndpoint,
+ "Wrong endpoint for new request"
+ );
+
+ equal(registers, 3, "Wrong registration count");
+});
diff --git a/dom/push/test/xpcshell/test_record.js b/dom/push/test/xpcshell/test_record.js
new file mode 100644
index 0000000000..144c0ee346
--- /dev/null
+++ b/dom/push/test/xpcshell/test_record.js
@@ -0,0 +1,132 @@
+"use strict";
+
+const { PushRecord } = ChromeUtils.importESModule(
+ "resource://gre/modules/PushRecord.sys.mjs"
+);
+
+add_task(async function test_updateQuota() {
+ let record = new PushRecord({
+ quota: 8,
+ lastPush: Date.now() - 1 * MS_IN_ONE_DAY,
+ });
+
+ record.updateQuota(Date.now() - 2 * MS_IN_ONE_DAY);
+ equal(
+ record.quota,
+ 8,
+ "Should not update quota if last visit is older than last push"
+ );
+
+ record.updateQuota(Date.now());
+ equal(
+ record.quota,
+ 16,
+ "Should reset quota if last visit is newer than last push"
+ );
+
+ record.reduceQuota();
+ equal(record.quota, 15, "Should reduce quota");
+
+ // Make sure we calculate the quota correctly for visit dates in the
+ // future (bug 1206424).
+ record.updateQuota(Date.now() + 1 * MS_IN_ONE_DAY);
+ equal(
+ record.quota,
+ 16,
+ "Should reset quota to maximum if last visit is in the future"
+ );
+
+ record.updateQuota(-1);
+ strictEqual(record.quota, 0, "Should set quota to 0 if history was cleared");
+ ok(record.isExpired(), "Should expire records once the quota reaches 0");
+ record.reduceQuota();
+ strictEqual(record.quota, 0, "Quota should never be negative");
+});
+
+add_task(async function test_systemRecord_updateQuota() {
+ let systemRecord = new PushRecord({
+ quota: Infinity,
+ systemRecord: true,
+ });
+ systemRecord.updateQuota(Date.now() - 3 * MS_IN_ONE_DAY);
+ equal(
+ systemRecord.quota,
+ Infinity,
+ "System subscriptions should ignore quota updates"
+ );
+ systemRecord.updateQuota(-1);
+ equal(
+ systemRecord.quota,
+ Infinity,
+ "System subscriptions should ignore the last visit time"
+ );
+ systemRecord.reduceQuota();
+ equal(
+ systemRecord.quota,
+ Infinity,
+ "System subscriptions should ignore quota reductions"
+ );
+});
+
+function testPermissionCheck(props) {
+ let record = new PushRecord(props);
+ let originSuffix;
+ equal(
+ record.uri.spec,
+ props.scope,
+ `Record URI should match scope URL for ${JSON.stringify(props)}`
+ );
+ if (props.originAttributes) {
+ originSuffix = ChromeUtils.originAttributesToSuffix(
+ record.principal.originAttributes
+ );
+ equal(
+ originSuffix,
+ props.originAttributes,
+ `Origin suffixes should match for ${JSON.stringify(props)}`
+ );
+ }
+ ok(
+ !record.hasPermission(),
+ `Record ${JSON.stringify(props)} should not have permission yet`
+ );
+ // Adding permission from origin string
+ PermissionTestUtils.add(
+ props.scope + (originSuffix || ""),
+ "desktop-notification",
+ Ci.nsIPermissionManager.ALLOW_ACTION
+ );
+ try {
+ ok(
+ record.hasPermission(),
+ `Record ${JSON.stringify(props)} should have permission`
+ );
+ } finally {
+ PermissionTestUtils.remove(
+ props.scope + (originSuffix || ""),
+ "desktop-notification"
+ );
+ }
+}
+
+add_task(async function test_principal_permissions() {
+ let testProps = [
+ {
+ scope: "https://example.com/",
+ },
+ {
+ scope: "https://example.com/",
+ originAttributes: "^userContextId=1",
+ },
+ {
+ scope: "https://xn--90aexm.xn--80ag3aejvc.xn--p1ai/",
+ },
+ {
+ scope: "https://xn--90aexm.xn--80ag3aejvc.xn--p1ai/",
+ originAttributes: "^userContextId=1",
+ },
+ ];
+ for (let props of testProps) {
+ testPermissionCheck(props);
+ }
+});
diff --git a/dom/push/test/xpcshell/test_register_5xxCode_http2.js b/dom/push/test/xpcshell/test_register_5xxCode_http2.js
new file mode 100644
index 0000000000..4d9e0891b3
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_5xxCode_http2.js
@@ -0,0 +1,129 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { HttpServer } = ChromeUtils.importESModule(
+ "resource://testing-common/httpd.sys.mjs"
+);
+
+var httpServer = null;
+
+ChromeUtils.defineLazyGetter(this, "serverPort", function () {
+ return httpServer.identity.primaryPort;
+});
+
+var retries = 0;
+
+function subscribe5xxCodeHandler(metadata, response) {
+ if (retries == 0) {
+ ok(true, "Subscribe 5xx code");
+ do_test_finished();
+ response.setHeader("Retry-After", "1");
+ response.setStatusLine(metadata.httpVersion, 500, "Retry");
+ } else {
+ ok(true, "Subscribed");
+ do_test_finished();
+ response.setHeader(
+ "Location",
+ "http://localhost:" + serverPort + "/subscription"
+ );
+ response.setHeader(
+ "Link",
+ '</pushEndpoint>; rel="urn:ietf:params:push", ' +
+ '</receiptPushEndpoint>; rel="urn:ietf:params:push:receipt"'
+ );
+ response.setStatusLine(metadata.httpVersion, 201, "OK");
+ }
+ retries++;
+}
+
+function listenSuccessHandler(metadata, response) {
+ Assert.ok(true, "New listener point");
+ Assert.equal(retries, 2, "Should try 2 times.");
+ do_test_finished();
+ response.setHeader("Retry-After", "10");
+ response.setStatusLine(metadata.httpVersion, 500, "Retry");
+}
+
+httpServer = new HttpServer();
+httpServer.registerPathHandler("/subscribe5xxCode", subscribe5xxCodeHandler);
+httpServer.registerPathHandler("/subscription", listenSuccessHandler);
+httpServer.start(-1);
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ "testing.allowInsecureServerURL": true,
+ "http2.retryInterval": 1000,
+ "http2.maxRetries": 2,
+ });
+
+ run_next_test();
+}
+
+add_task(async function test1() {
+ let db = PushServiceHttp2.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ do_test_pending();
+ do_test_pending();
+ do_test_pending();
+ do_test_pending();
+
+ var serverURL = "http://localhost:" + httpServer.identity.primaryPort;
+
+ PushService.init({
+ serverURI: serverURL + "/subscribe5xxCode",
+ db,
+ });
+
+ let originAttributes = ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ });
+ let newRecord = await PushService.register({
+ scope: "https://example.com/retry5xxCode",
+ originAttributes,
+ });
+
+ var subscriptionUri = serverURL + "/subscription";
+ var pushEndpoint = serverURL + "/pushEndpoint";
+ var pushReceiptEndpoint = serverURL + "/receiptPushEndpoint";
+ equal(
+ newRecord.endpoint,
+ pushEndpoint,
+ "Wrong push endpoint in registration record"
+ );
+
+ equal(
+ newRecord.pushReceiptEndpoint,
+ pushReceiptEndpoint,
+ "Wrong push endpoint receipt in registration record"
+ );
+
+ let record = await db.getByKeyID(subscriptionUri);
+ equal(
+ record.subscriptionUri,
+ subscriptionUri,
+ "Wrong subscription ID in database record"
+ );
+ equal(
+ record.pushEndpoint,
+ pushEndpoint,
+ "Wrong push endpoint in database record"
+ );
+ equal(
+ record.pushReceiptEndpoint,
+ pushReceiptEndpoint,
+ "Wrong push endpoint receipt in database record"
+ );
+ equal(
+ record.scope,
+ "https://example.com/retry5xxCode",
+ "Wrong scope in database record"
+ );
+
+ httpServer.stop(do_test_finished);
+});
diff --git a/dom/push/test/xpcshell/test_register_case.js b/dom/push/test/xpcshell/test_register_case.js
new file mode 100644
index 0000000000..1ae6f127f3
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_case.js
@@ -0,0 +1,67 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "1760b1f5-c3ba-40e3-9344-adef7c18ab12";
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(async function test_register_case() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "HELLO",
+ uaid: userAgentID,
+ status: 200,
+ })
+ );
+ },
+ onRegister(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "ReGiStEr",
+ uaid: userAgentID,
+ channelID: request.channelID,
+ status: 200,
+ pushEndpoint: "https://example.com/update/case",
+ })
+ );
+ },
+ });
+ },
+ });
+
+ let newRecord = await PushService.register({
+ scope: "https://example.net/case",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ equal(
+ newRecord.endpoint,
+ "https://example.com/update/case",
+ "Wrong push endpoint in registration record"
+ );
+
+ let record = await db.getByPushEndpoint("https://example.com/update/case");
+ equal(
+ record.scope,
+ "https://example.net/case",
+ "Wrong scope in database record"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_register_flush.js b/dom/push/test/xpcshell/test_register_flush.js
new file mode 100644
index 0000000000..2c12ecb9ba
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_flush.js
@@ -0,0 +1,116 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "9ce1e6d3-7bdb-4fe9-90a5-def1d64716f1";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_flush() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ let record = {
+ channelID: "9bcc7efb-86c7-4457-93ea-e24e6eb59b74",
+ pushEndpoint: "https://example.org/update/1",
+ scope: "https://example.com/page/1",
+ originAttributes: "",
+ version: 2,
+ quota: Infinity,
+ systemRecord: true,
+ };
+ await db.put(record);
+
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic
+ );
+
+ let ackDone;
+ let ackPromise = new Promise(resolve => (ackDone = after(2, resolve)));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ },
+ onRegister(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ channelID: request.channelID,
+ version: 2,
+ },
+ {
+ channelID: "9bcc7efb-86c7-4457-93ea-e24e6eb59b74",
+ version: 3,
+ },
+ ],
+ })
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ channelID: request.channelID,
+ uaid: userAgentID,
+ pushEndpoint: "https://example.org/update/2",
+ })
+ );
+ },
+ onACK: ackDone,
+ });
+ },
+ });
+
+ let newRecord = await PushService.register({
+ scope: "https://example.com/page/2",
+ originAttributes: "",
+ });
+ equal(
+ newRecord.endpoint,
+ "https://example.org/update/2",
+ "Wrong push endpoint in record"
+ );
+
+ let { data: scope } = await notifyPromise;
+ equal(scope, "https://example.com/page/1", "Wrong notification scope");
+
+ await ackPromise;
+
+ let prevRecord = await db.getByKeyID("9bcc7efb-86c7-4457-93ea-e24e6eb59b74");
+ equal(
+ prevRecord.pushEndpoint,
+ "https://example.org/update/1",
+ "Wrong existing push endpoint"
+ );
+ strictEqual(
+ prevRecord.version,
+ 3,
+ "Should record version updates sent before register responses"
+ );
+
+ let registeredRecord = await db.getByPushEndpoint(
+ "https://example.org/update/2"
+ );
+ ok(!registeredRecord.version, "Should not record premature updates");
+});
diff --git a/dom/push/test/xpcshell/test_register_invalid_channel.js b/dom/push/test/xpcshell/test_register_invalid_channel.js
new file mode 100644
index 0000000000..0f1b6cf1b1
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_invalid_channel.js
@@ -0,0 +1,63 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "52b2b04c-b6cc-42c6-abdf-bef9cbdbea00";
+const channelID = "cafed00d";
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(async function test_register_invalid_channel() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushServiceWebSocket._generateID = () => channelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ uaid: userAgentID,
+ status: 200,
+ })
+ );
+ },
+ onRegister(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 403,
+ channelID,
+ error: "Invalid channel ID",
+ })
+ );
+ },
+ });
+ },
+ });
+
+ await Assert.rejects(
+ PushService.register({
+ scope: "https://example.com/invalid-channel",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Registration error/,
+ "Expected error for invalid channel ID"
+ );
+
+ let record = await db.getByKeyID(channelID);
+ ok(!record, "Should not store records for error responses");
+});
diff --git a/dom/push/test/xpcshell/test_register_invalid_endpoint.js b/dom/push/test/xpcshell/test_register_invalid_endpoint.js
new file mode 100644
index 0000000000..64398b97f8
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_invalid_endpoint.js
@@ -0,0 +1,64 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "c9a12e81-ea5e-40f9-8bf4-acee34621671";
+const channelID = "c0660af8-b532-4931-81f0-9fd27a12d6ab";
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(async function test_register_invalid_endpoint() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushServiceWebSocket._generateID = () => channelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ },
+ onRegister(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ channelID,
+ uaid: userAgentID,
+ pushEndpoint: "!@#$%^&*",
+ })
+ );
+ },
+ });
+ },
+ });
+
+ await Assert.rejects(
+ PushService.register({
+ scope: "https://example.net/page/invalid-endpoint",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Registration error/,
+ "Expected error for invalid endpoint"
+ );
+
+ let record = await db.getByKeyID(channelID);
+ ok(!record, "Should not store records with invalid endpoints");
+});
diff --git a/dom/push/test/xpcshell/test_register_invalid_json.js b/dom/push/test/xpcshell/test_register_invalid_json.js
new file mode 100644
index 0000000000..d7a19e789c
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_invalid_json.js
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "8271186b-8073-43a3-adf6-225bd44a8b0a";
+const channelID = "2d08571e-feab-48a0-9f05-8254c3c7e61f";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_invalid_json() {
+ let helloDone;
+ let helloPromise = new Promise(resolve => (helloDone = after(2, resolve)));
+ let registers = 0;
+
+ PushServiceWebSocket._generateID = () => channelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ helloDone();
+ },
+ onRegister(request) {
+ equal(request.channelID, channelID, "Register: wrong channel ID");
+ this.serverSendMsg(");alert(1);(");
+ registers++;
+ },
+ });
+ },
+ });
+
+ await Assert.rejects(
+ PushService.register({
+ scope: "https://example.net/page/invalid-json",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Registration error/,
+ "Expected error for invalid JSON response"
+ );
+
+ await helloPromise;
+ equal(registers, 1, "Wrong register count");
+});
diff --git a/dom/push/test/xpcshell/test_register_no_id.js b/dom/push/test/xpcshell/test_register_no_id.js
new file mode 100644
index 0000000000..763350b11e
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_no_id.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var userAgentID = "9a2f9efe-2ebb-4bcb-a5d9-9e2b73d30afe";
+var channelID = "264c2ba0-f6db-4e84-acdb-bd225b62d9e3";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_no_id() {
+ let registers = 0;
+ let helloDone;
+ let helloPromise = new Promise(resolve => (helloDone = after(2, resolve)));
+
+ PushServiceWebSocket._generateID = () => channelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ helloDone();
+ },
+ onRegister(request) {
+ registers++;
+ equal(request.channelID, channelID, "Register: wrong channel ID");
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ })
+ );
+ },
+ });
+ },
+ });
+
+ await Assert.rejects(
+ PushService.register({
+ scope: "https://example.com/incomplete",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Registration error/,
+ "Expected error for incomplete register response"
+ );
+
+ await helloPromise;
+ equal(registers, 1, "Wrong register count");
+});
diff --git a/dom/push/test/xpcshell/test_register_request_queue.js b/dom/push/test/xpcshell/test_register_request_queue.js
new file mode 100644
index 0000000000..6d7928c52a
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_request_queue.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_request_queue() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ let onHello;
+ let helloPromise = new Promise(
+ resolve =>
+ (onHello = after(2, function onHelloReceived(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: "54b08a9e-59c6-4ed7-bb54-f4fd60d6f606",
+ })
+ );
+ resolve();
+ }))
+ );
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello,
+ onRegister() {
+ ok(false, "Should cancel timed-out requests");
+ },
+ });
+ },
+ });
+
+ let firstRegister = PushService.register({
+ scope: "https://example.com/page/1",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ let secondRegister = PushService.register({
+ scope: "https://example.com/page/1",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+
+ await Promise.all([
+ // eslint-disable-next-line mozilla/rejects-requires-await
+ Assert.rejects(
+ firstRegister,
+ /Registration error/,
+ "Should time out the first request"
+ ),
+ // eslint-disable-next-line mozilla/rejects-requires-await
+ Assert.rejects(
+ secondRegister,
+ /Registration error/,
+ "Should time out the second request"
+ ),
+ ]);
+
+ await helloPromise;
+});
diff --git a/dom/push/test/xpcshell/test_register_rollback.js b/dom/push/test/xpcshell/test_register_rollback.js
new file mode 100644
index 0000000000..9a0233aca8
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_rollback.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "b2546987-4f63-49b1-99f7-739cd3c40e44";
+const channelID = "35a820f7-d7dd-43b3-af21-d65352212ae3";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_rollback() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ let handshakes = 0;
+ let registers = 0;
+ let unregisterDone;
+ let unregisterPromise = new Promise(resolve => (unregisterDone = resolve));
+ PushServiceWebSocket._generateID = () => channelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db: makeStub(db, {
+ put(prev, record) {
+ return Promise.reject("universe has imploded");
+ },
+ }),
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ handshakes++;
+ if (registers > 0) {
+ equal(request.uaid, userAgentID, "Handshake: wrong device ID");
+ } else {
+ ok(
+ !request.uaid,
+ "Should not send UAID in handshake without local subscriptions"
+ );
+ }
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ },
+ onRegister(request) {
+ equal(request.channelID, channelID, "Register: wrong channel ID");
+ registers++;
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ uaid: userAgentID,
+ channelID,
+ pushEndpoint: "https://example.com/update/rollback",
+ })
+ );
+ },
+ onUnregister(request) {
+ equal(request.channelID, channelID, "Unregister: wrong channel ID");
+ equal(request.code, 200, "Expected manual unregister reason");
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ status: 200,
+ channelID,
+ })
+ );
+ unregisterDone();
+ },
+ });
+ },
+ });
+
+ // Should return a rejected promise if storage fails.
+ await Assert.rejects(
+ PushService.register({
+ scope: "https://example.com/storage-error",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /universe has imploded/,
+ "Expected error for unregister database failure"
+ );
+
+ // Should send an out-of-band unregister request.
+ await unregisterPromise;
+ equal(handshakes, 1, "Wrong handshake count");
+ equal(registers, 1, "Wrong register count");
+});
diff --git a/dom/push/test/xpcshell/test_register_success.js b/dom/push/test/xpcshell/test_register_success.js
new file mode 100644
index 0000000000..9b6692ca25
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_success.js
@@ -0,0 +1,93 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "bd744428-f125-436a-b6d0-dd0c9845837f";
+const channelID = "0ef2ad4a-6c49-41ad-af6e-95d2425276bf";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_success() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushServiceWebSocket._generateID = () => channelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(data) {
+ equal(data.messageType, "hello", "Handshake: wrong message type");
+ ok(
+ !data.uaid,
+ "Should not send UAID in handshake without local subscriptions"
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ },
+ onRegister(data) {
+ equal(data.messageType, "register", "Register: wrong message type");
+ equal(data.channelID, channelID, "Register: wrong channel ID");
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ channelID,
+ uaid: userAgentID,
+ pushEndpoint: "https://example.com/update/1",
+ })
+ );
+ },
+ });
+ },
+ });
+
+ let subModifiedPromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionModifiedTopic
+ );
+
+ let newRecord = await PushService.register({
+ scope: "https://example.org/1",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ equal(
+ newRecord.endpoint,
+ "https://example.com/update/1",
+ "Wrong push endpoint in registration record"
+ );
+
+ let { data: subModifiedScope } = await subModifiedPromise;
+ equal(
+ subModifiedScope,
+ "https://example.org/1",
+ "Should fire a subscription modified event after subscribing"
+ );
+
+ let record = await db.getByKeyID(channelID);
+ equal(record.channelID, channelID, "Wrong channel ID in database record");
+ equal(
+ record.pushEndpoint,
+ "https://example.com/update/1",
+ "Wrong push endpoint in database record"
+ );
+ equal(record.quota, 16, "Wrong quota in database record");
+});
diff --git a/dom/push/test/xpcshell/test_register_timeout.js b/dom/push/test/xpcshell/test_register_timeout.js
new file mode 100644
index 0000000000..27a5994e7c
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_timeout.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "a4be0df9-b16d-4b5f-8f58-0f93b6f1e23d";
+const channelID = "e1944e0b-48df-45e7-bdc0-d1fbaa7986d3";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_timeout() {
+ let handshakes = 0;
+ let timeoutDone;
+ let timeoutPromise = new Promise(resolve => (timeoutDone = resolve));
+ let registers = 0;
+
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushServiceWebSocket._generateID = () => channelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ if (registers > 0) {
+ equal(
+ request.uaid,
+ userAgentID,
+ "Should include device ID on reconnect with subscriptions"
+ );
+ } else {
+ ok(
+ !request.uaid,
+ "Should not send UAID in handshake without local subscriptions"
+ );
+ }
+ if (handshakes > 1) {
+ ok(false, "Unexpected reconnect attempt " + handshakes);
+ }
+ handshakes++;
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ },
+ onRegister(request) {
+ equal(
+ request.channelID,
+ channelID,
+ "Wrong channel ID in register request"
+ );
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(() => {
+ // Should ignore replies for timed-out requests.
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ channelID,
+ uaid: userAgentID,
+ pushEndpoint: "https://example.com/update/timeout",
+ })
+ );
+ registers++;
+ timeoutDone();
+ }, 2000);
+ },
+ });
+ },
+ });
+
+ await Assert.rejects(
+ PushService.register({
+ scope: "https://example.net/page/timeout",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Registration error/,
+ "Expected error for request timeout"
+ );
+
+ let record = await db.getByKeyID(channelID);
+ ok(!record, "Should not store records for timed-out responses");
+
+ await timeoutPromise;
+ equal(registers, 1, "Should not handle timed-out register requests");
+});
diff --git a/dom/push/test/xpcshell/test_register_wrong_id.js b/dom/push/test/xpcshell/test_register_wrong_id.js
new file mode 100644
index 0000000000..674ce9f9ff
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_wrong_id.js
@@ -0,0 +1,75 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "84afc774-6995-40d1-9c90-8c34ddcd0cb4";
+const clientChannelID = "4b42a681c99e4dfbbb166a7e01a09b8b";
+const serverChannelID = "3f5aeb89c6e8405a9569619522783436";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_wrong_id() {
+ // Should reconnect after the register request times out.
+ let registers = 0;
+ let helloDone;
+ let helloPromise = new Promise(resolve => (helloDone = after(2, resolve)));
+
+ PushServiceWebSocket._generateID = () => clientChannelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ helloDone();
+ },
+ onRegister(request) {
+ equal(
+ request.channelID,
+ clientChannelID,
+ "Register: wrong channel ID"
+ );
+ registers++;
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ // Reply with a different channel ID. Since the ID is used as a
+ // nonce, the registration request will time out.
+ channelID: serverChannelID,
+ })
+ );
+ },
+ });
+ },
+ });
+
+ await Assert.rejects(
+ PushService.register({
+ scope: "https://example.com/mismatched",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Registration error/,
+ "Expected error for mismatched register reply"
+ );
+
+ await helloPromise;
+ equal(registers, 1, "Wrong register count");
+});
diff --git a/dom/push/test/xpcshell/test_register_wrong_type.js b/dom/push/test/xpcshell/test_register_wrong_type.js
new file mode 100644
index 0000000000..b3b9aaa927
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_wrong_type.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "c293fdc5-a75e-4eb1-af88-a203991c0787";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_wrong_type() {
+ let registers = 0;
+ let helloDone;
+ let helloPromise = new Promise(resolve => (helloDone = after(2, resolve)));
+
+ PushService._generateID = () => "1234";
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ helloDone();
+ },
+ onRegister(request) {
+ registers++;
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ channelID: 1234,
+ uaid: userAgentID,
+ pushEndpoint: "https://example.org/update/wrong-type",
+ })
+ );
+ },
+ });
+ },
+ });
+
+ await Assert.rejects(
+ PushService.register({
+ scope: "https://example.com/mistyped",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Registration error/,
+ "Expected error for non-string channel ID"
+ );
+
+ await helloPromise;
+ equal(registers, 1, "Wrong register count");
+});
diff --git a/dom/push/test/xpcshell/test_registration_error.js b/dom/push/test/xpcshell/test_registration_error.js
new file mode 100644
index 0000000000..cba22ddc1c
--- /dev/null
+++ b/dom/push/test/xpcshell/test_registration_error.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID: "6faed1f0-1439-4aac-a978-db21c81cd5eb",
+ });
+ run_next_test();
+}
+
+add_task(async function test_registrations_error() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db: makeStub(db, {
+ getByIdentifiers(prev, scope) {
+ return Promise.reject("Database error");
+ },
+ }),
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri);
+ },
+ });
+
+ await Assert.rejects(
+ PushService.registration({
+ scope: "https://example.net/1",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ function (error) {
+ return error == "Database error";
+ },
+ "Wrong message"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_registration_error_http2.js b/dom/push/test/xpcshell/test_registration_error_http2.js
new file mode 100644
index 0000000000..101cef8dc6
--- /dev/null
+++ b/dom/push/test/xpcshell/test_registration_error_http2.js
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ do_get_profile();
+ run_next_test();
+}
+
+add_task(async function test_registrations_error() {
+ let db = PushServiceHttp2.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushService.init({
+ serverURI: "https://push.example.org/",
+ db: makeStub(db, {
+ getByIdentifiers() {
+ return Promise.reject("Database error");
+ },
+ }),
+ });
+
+ await Assert.rejects(
+ PushService.registration({
+ scope: "https://example.net/1",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ function (error) {
+ return error == "Database error";
+ },
+ "Wrong message"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_registration_missing_scope.js b/dom/push/test/xpcshell/test_registration_missing_scope.js
new file mode 100644
index 0000000000..dd0dab842c
--- /dev/null
+++ b/dom/push/test/xpcshell/test_registration_missing_scope.js
@@ -0,0 +1,24 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(async function test_registration_missing_scope() {
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri);
+ },
+ });
+ await Assert.rejects(
+ PushService.registration({ scope: "", originAttributes: "" }),
+ /Invalid page record/,
+ "Record missing page and manifest URLs"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_registration_none.js b/dom/push/test/xpcshell/test_registration_none.js
new file mode 100644
index 0000000000..3e9235b0ad
--- /dev/null
+++ b/dom/push/test/xpcshell/test_registration_none.js
@@ -0,0 +1,30 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "a722e448-c481-4c48-aea0-fc411cb7c9ed";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({ userAgentID });
+ run_next_test();
+}
+
+// Should not open a connection if the client has no registrations.
+add_task(async function test_registration_none() {
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri);
+ },
+ });
+
+ let registration = await PushService.registration({
+ scope: "https://example.net/1",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ ok(!registration, "Should not open a connection without registration");
+});
diff --git a/dom/push/test/xpcshell/test_registration_success.js b/dom/push/test/xpcshell/test_registration_success.js
new file mode 100644
index 0000000000..e3113e2007
--- /dev/null
+++ b/dom/push/test/xpcshell/test_registration_success.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "997ee7ba-36b1-4526-ae9e-2d3f38d6efe8";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({ userAgentID });
+ run_next_test();
+}
+
+add_task(async function test_registration_success() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ let records = [
+ {
+ channelID: "bf001fe0-2684-42f2-bc4d-a3e14b11dd5b",
+ pushEndpoint: "https://example.com/update/same-manifest/1",
+ scope: "https://example.net/a",
+ originAttributes: "",
+ version: 5,
+ quota: Infinity,
+ },
+ ];
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ let handshakeDone;
+ let handshakePromise = new Promise(resolve => (handshakeDone = resolve));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ equal(request.uaid, userAgentID, "Wrong device ID in handshake");
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ handshakeDone();
+ },
+ });
+ },
+ });
+
+ await handshakePromise;
+
+ let registration = await PushService.registration({
+ scope: "https://example.net/a",
+ originAttributes: "",
+ });
+ equal(
+ registration.endpoint,
+ "https://example.com/update/same-manifest/1",
+ "Wrong push endpoint for scope"
+ );
+ equal(registration.version, 5, "Wrong version for scope");
+});
diff --git a/dom/push/test/xpcshell/test_registration_success_http2.js b/dom/push/test/xpcshell/test_registration_success_http2.js
new file mode 100644
index 0000000000..ebb4cb3f26
--- /dev/null
+++ b/dom/push/test/xpcshell/test_registration_success_http2.js
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var serverPort = -1;
+
+function run_test() {
+ serverPort = getTestServerPort();
+
+ do_get_profile();
+
+ run_next_test();
+}
+
+add_task(async function test_pushNotifications() {
+ let db = PushServiceHttp2.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ var serverURL = "https://localhost:" + serverPort;
+
+ let records = [
+ {
+ subscriptionUri: serverURL + "/subscriptionA",
+ pushEndpoint: serverURL + "/pushEndpointA",
+ pushReceiptEndpoint: serverURL + "/pushReceiptEndpointA",
+ scope: "https://example.net/a",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ quota: Infinity,
+ },
+ {
+ subscriptionUri: serverURL + "/subscriptionB",
+ pushEndpoint: serverURL + "/pushEndpointB",
+ pushReceiptEndpoint: serverURL + "/pushReceiptEndpointB",
+ scope: "https://example.net/b",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ quota: Infinity,
+ },
+ {
+ subscriptionUri: serverURL + "/subscriptionC",
+ pushEndpoint: serverURL + "/pushEndpointC",
+ pushReceiptEndpoint: serverURL + "/pushReceiptEndpointC",
+ scope: "https://example.net/c",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ quota: Infinity,
+ },
+ ];
+
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ PushService.init({
+ serverURI: serverURL,
+ db,
+ });
+
+ let registration = await PushService.registration({
+ scope: "https://example.net/a",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ equal(
+ registration.endpoint,
+ serverURL + "/pushEndpointA",
+ "Wrong push endpoint for scope"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_resubscribe_4xxCode_http2.js b/dom/push/test/xpcshell/test_resubscribe_4xxCode_http2.js
new file mode 100644
index 0000000000..c922d13bc6
--- /dev/null
+++ b/dom/push/test/xpcshell/test_resubscribe_4xxCode_http2.js
@@ -0,0 +1,113 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { HttpServer } = ChromeUtils.importESModule(
+ "resource://testing-common/httpd.sys.mjs"
+);
+
+var httpServer = null;
+
+ChromeUtils.defineLazyGetter(this, "serverPort", function () {
+ return httpServer.identity.primaryPort;
+});
+
+var handlerDone;
+var handlerPromise = new Promise(r => (handlerDone = after(3, r)));
+
+function listen4xxCodeHandler(metadata, response) {
+ ok(true, "Listener point error");
+ handlerDone();
+ response.setStatusLine(metadata.httpVersion, 410, "GONE");
+}
+
+function resubscribeHandler(metadata, response) {
+ ok(true, "Ask for new subscription");
+ handlerDone();
+ response.setHeader(
+ "Location",
+ "http://localhost:" + serverPort + "/newSubscription"
+ );
+ response.setHeader(
+ "Link",
+ '</newPushEndpoint>; rel="urn:ietf:params:push", ' +
+ '</newReceiptPushEndpoint>; rel="urn:ietf:params:push:receipt"'
+ );
+ response.setStatusLine(metadata.httpVersion, 201, "OK");
+}
+
+function listenSuccessHandler(metadata, response) {
+ Assert.ok(true, "New listener point");
+ httpServer.stop(handlerDone);
+ response.setStatusLine(metadata.httpVersion, 204, "Try again");
+}
+
+httpServer = new HttpServer();
+httpServer.registerPathHandler("/subscription4xxCode", listen4xxCodeHandler);
+httpServer.registerPathHandler("/subscribe", resubscribeHandler);
+httpServer.registerPathHandler("/newSubscription", listenSuccessHandler);
+httpServer.start(-1);
+
+function run_test() {
+ do_get_profile();
+
+ setPrefs({
+ "testing.allowInsecureServerURL": true,
+ "testing.notifyWorkers": false,
+ "testing.notifyAllObservers": true,
+ });
+
+ run_next_test();
+}
+
+add_task(async function test1() {
+ let db = PushServiceHttp2.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ var serverURL = "http://localhost:" + httpServer.identity.primaryPort;
+
+ let records = [
+ {
+ subscriptionUri: serverURL + "/subscription4xxCode",
+ pushEndpoint: serverURL + "/pushEndpoint",
+ pushReceiptEndpoint: serverURL + "/pushReceiptEndpoint",
+ scope: "https://example.com/page",
+ originAttributes: "",
+ quota: Infinity,
+ },
+ ];
+
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ PushService.init({
+ serverURI: serverURL + "/subscribe",
+ db,
+ });
+
+ await handlerPromise;
+
+ let record = await db.getByIdentifiers({
+ scope: "https://example.com/page",
+ originAttributes: "",
+ });
+ equal(
+ record.keyID,
+ serverURL + "/newSubscription",
+ "Should update subscription URL"
+ );
+ equal(
+ record.pushEndpoint,
+ serverURL + "/newPushEndpoint",
+ "Should update push endpoint"
+ );
+ equal(
+ record.pushReceiptEndpoint,
+ serverURL + "/newReceiptPushEndpoint",
+ "Should update push receipt endpoint"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_resubscribe_5xxCode_http2.js b/dom/push/test/xpcshell/test_resubscribe_5xxCode_http2.js
new file mode 100644
index 0000000000..5ef1a4f9e6
--- /dev/null
+++ b/dom/push/test/xpcshell/test_resubscribe_5xxCode_http2.js
@@ -0,0 +1,116 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { HttpServer } = ChromeUtils.importESModule(
+ "resource://testing-common/httpd.sys.mjs"
+);
+
+var httpServer = null;
+
+ChromeUtils.defineLazyGetter(this, "serverPort", function () {
+ return httpServer.identity.primaryPort;
+});
+
+var retries = 0;
+var handlerDone;
+var handlerPromise = new Promise(r => (handlerDone = after(5, r)));
+
+function listen5xxCodeHandler(metadata, response) {
+ ok(true, "Listener 5xx code");
+ handlerDone();
+ retries++;
+ response.setHeader("Retry-After", "1");
+ response.setStatusLine(metadata.httpVersion, 500, "Retry");
+}
+
+function resubscribeHandler(metadata, response) {
+ ok(true, "Ask for new subscription");
+ Assert.equal(retries, 3, "Should retry 2 times.");
+ handlerDone();
+ response.setHeader(
+ "Location",
+ "http://localhost:" + serverPort + "/newSubscription"
+ );
+ response.setHeader(
+ "Link",
+ '</newPushEndpoint>; rel="urn:ietf:params:push", ' +
+ '</newReceiptPushEndpoint>; rel="urn:ietf:params:push:receipt"'
+ );
+ response.setStatusLine(metadata.httpVersion, 201, "OK");
+}
+
+function listenSuccessHandler(metadata, response) {
+ Assert.ok(true, "New listener point");
+ httpServer.stop(handlerDone);
+ response.setStatusLine(metadata.httpVersion, 204, "Try again");
+}
+
+httpServer = new HttpServer();
+httpServer.registerPathHandler("/subscription5xxCode", listen5xxCodeHandler);
+httpServer.registerPathHandler("/subscribe", resubscribeHandler);
+httpServer.registerPathHandler("/newSubscription", listenSuccessHandler);
+httpServer.start(-1);
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ "testing.allowInsecureServerURL": true,
+ "http2.retryInterval": 1000,
+ "http2.maxRetries": 2,
+ });
+
+ run_next_test();
+}
+
+add_task(async function test1() {
+ let db = PushServiceHttp2.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ var serverURL = "http://localhost:" + httpServer.identity.primaryPort;
+
+ let records = [
+ {
+ subscriptionUri: serverURL + "/subscription5xxCode",
+ pushEndpoint: serverURL + "/pushEndpoint",
+ pushReceiptEndpoint: serverURL + "/pushReceiptEndpoint",
+ scope: "https://example.com/page",
+ originAttributes: "",
+ quota: Infinity,
+ },
+ ];
+
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ PushService.init({
+ serverURI: serverURL + "/subscribe",
+ db,
+ });
+
+ await handlerPromise;
+
+ let record = await db.getByIdentifiers({
+ scope: "https://example.com/page",
+ originAttributes: "",
+ });
+ equal(
+ record.keyID,
+ serverURL + "/newSubscription",
+ "Should update subscription URL"
+ );
+ equal(
+ record.pushEndpoint,
+ serverURL + "/newPushEndpoint",
+ "Should update push endpoint"
+ );
+ equal(
+ record.pushReceiptEndpoint,
+ serverURL + "/newReceiptPushEndpoint",
+ "Should update push receipt endpoint"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_resubscribe_listening_for_msg_error_http2.js b/dom/push/test/xpcshell/test_resubscribe_listening_for_msg_error_http2.js
new file mode 100644
index 0000000000..1373c3c91b
--- /dev/null
+++ b/dom/push/test/xpcshell/test_resubscribe_listening_for_msg_error_http2.js
@@ -0,0 +1,116 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { HttpServer } = ChromeUtils.importESModule(
+ "resource://testing-common/httpd.sys.mjs"
+);
+
+var httpServer = null;
+
+ChromeUtils.defineLazyGetter(this, "serverPort", function () {
+ return httpServer.identity.primaryPort;
+});
+
+var handlerDone;
+var handlerPromise = new Promise(r => (handlerDone = after(2, r)));
+
+function resubscribeHandler(metadata, response) {
+ ok(true, "Ask for new subscription");
+ handlerDone();
+ response.setHeader(
+ "Location",
+ "http://localhost:" + serverPort + "/newSubscription"
+ );
+ response.setHeader(
+ "Link",
+ '</newPushEndpoint>; rel="urn:ietf:params:push", ' +
+ '</newReceiptPushEndpoint>; rel="urn:ietf:params:push:receipt"'
+ );
+ response.setStatusLine(metadata.httpVersion, 201, "OK");
+}
+
+function listenSuccessHandler(metadata, response) {
+ Assert.ok(true, "New listener point");
+ httpServer.stop(handlerDone);
+ response.setStatusLine(metadata.httpVersion, 204, "Try again");
+}
+
+httpServer = new HttpServer();
+httpServer.registerPathHandler("/subscribe", resubscribeHandler);
+httpServer.registerPathHandler("/newSubscription", listenSuccessHandler);
+httpServer.start(-1);
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ "testing.allowInsecureServerURL": true,
+ "http2.retryInterval": 1000,
+ "http2.maxRetries": 2,
+ });
+
+ run_next_test();
+}
+
+add_task(async function test1() {
+ let db = PushServiceHttp2.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ var serverURL = "http://localhost:" + httpServer.identity.primaryPort;
+
+ let records = [
+ {
+ subscriptionUri: "http://localhost/subscriptionNotExist",
+ pushEndpoint: serverURL + "/pushEndpoint",
+ pushReceiptEndpoint: serverURL + "/pushReceiptEndpoint",
+ scope: "https://example.com/page",
+ p256dhPublicKey:
+ "BPCd4gNQkjwRah61LpdALdzZKLLnU5UAwDztQ5_h0QsT26jk0IFbqcK6-JxhHAm-rsHEwy0CyVJjtnfOcqc1tgA",
+ p256dhPrivateKey: {
+ crv: "P-256",
+ d: "1jUPhzVsRkzV0vIzwL4ZEsOlKdNOWm7TmaTfzitJkgM",
+ ext: true,
+ key_ops: ["deriveBits"],
+ kty: "EC",
+ x: "8J3iA1CSPBFqHrUul0At3NkosudTlQDAPO1Dn-HRCxM",
+ y: "26jk0IFbqcK6-JxhHAm-rsHEwy0CyVJjtnfOcqc1tgA",
+ },
+ originAttributes: "",
+ quota: Infinity,
+ },
+ ];
+
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ PushService.init({
+ serverURI: serverURL + "/subscribe",
+ db,
+ });
+
+ await handlerPromise;
+
+ let record = await db.getByIdentifiers({
+ scope: "https://example.com/page",
+ originAttributes: "",
+ });
+ equal(
+ record.keyID,
+ serverURL + "/newSubscription",
+ "Should update subscription URL"
+ );
+ equal(
+ record.pushEndpoint,
+ serverURL + "/newPushEndpoint",
+ "Should update push endpoint"
+ );
+ equal(
+ record.pushReceiptEndpoint,
+ serverURL + "/newReceiptPushEndpoint",
+ "Should update push receipt endpoint"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_retry_ws.js b/dom/push/test/xpcshell/test_retry_ws.js
new file mode 100644
index 0000000000..19df72acd3
--- /dev/null
+++ b/dom/push/test/xpcshell/test_retry_ws.js
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "05f7b940-51b6-4b6f-8032-b83ebb577ded";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ pingInterval: 2000,
+ retryBaseInterval: 25,
+ });
+ run_next_test();
+}
+
+add_task(async function test_ws_retry() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ await db.put({
+ channelID: "61770ba9-2d57-4134-b949-d40404630d5b",
+ pushEndpoint: "https://example.org/push/1",
+ scope: "https://example.net/push/1",
+ version: 1,
+ originAttributes: "",
+ quota: Infinity,
+ });
+
+ // Use a mock timer to avoid waiting for the backoff interval.
+ let reconnects = 0;
+ PushServiceWebSocket._backoffTimer = {
+ init(observer, delay, type) {
+ reconnects++;
+ ok(
+ delay >= 5 && delay <= 2000,
+ `Backoff delay ${delay} out of range for attempt ${reconnects}`
+ );
+ observer.observe(this, "timer-callback", null);
+ },
+
+ cancel() {},
+ };
+
+ let handshakeDone;
+ let handshakePromise = new Promise(resolve => (handshakeDone = resolve));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ if (reconnects == 10) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ handshakeDone();
+ return;
+ }
+ this.serverInterrupt();
+ },
+ });
+ },
+ });
+
+ await handshakePromise;
+});
diff --git a/dom/push/test/xpcshell/test_service_child.js b/dom/push/test/xpcshell/test_service_child.js
new file mode 100644
index 0000000000..a22f1f4d73
--- /dev/null
+++ b/dom/push/test/xpcshell/test_service_child.js
@@ -0,0 +1,352 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var db;
+
+function done() {
+ do_test_finished();
+ run_next_test();
+}
+
+function generateKey() {
+ return crypto.subtle
+ .generateKey(
+ {
+ name: "ECDSA",
+ namedCurve: "P-256",
+ },
+ true,
+ ["sign", "verify"]
+ )
+ .then(cryptoKey => crypto.subtle.exportKey("raw", cryptoKey.publicKey))
+ .then(publicKey => new Uint8Array(publicKey));
+}
+
+function run_test() {
+ if (isParent) {
+ do_get_profile();
+ }
+ run_next_test();
+}
+
+if (isParent) {
+ add_test(function setUp() {
+ db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ setUpServiceInParent(PushService, db).then(run_next_test, run_next_test);
+ });
+}
+
+add_test(function test_subscribe_success() {
+ do_test_pending();
+ PushServiceComponent.subscribe(
+ "https://example.com/sub/ok",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, subscription) => {
+ ok(Components.isSuccessCode(result), "Error creating subscription");
+ ok(subscription.isSystemSubscription, "Expected system subscription");
+ ok(
+ subscription.endpoint.startsWith("https://example.org/push"),
+ "Wrong endpoint prefix"
+ );
+ equal(subscription.pushCount, 0, "Wrong push count");
+ equal(subscription.lastPush, 0, "Wrong last push time");
+ equal(subscription.quota, -1, "Wrong quota for system subscription");
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_subscribeWithKey_error() {
+ do_test_pending();
+
+ let invalidKey = [0, 1];
+ PushServiceComponent.subscribeWithKey(
+ "https://example.com/sub-key/invalid",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ invalidKey,
+ (result, subscription) => {
+ ok(
+ !Components.isSuccessCode(result),
+ "Expected error creating subscription with invalid key"
+ );
+ equal(
+ result,
+ Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR,
+ "Wrong error code for invalid key"
+ );
+ strictEqual(subscription, null, "Unexpected subscription");
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_subscribeWithKey_success() {
+ do_test_pending();
+
+ generateKey().then(
+ key => {
+ PushServiceComponent.subscribeWithKey(
+ "https://example.com/sub-key/ok",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ key,
+ (result, subscription) => {
+ ok(
+ Components.isSuccessCode(result),
+ "Error creating subscription with key"
+ );
+ notStrictEqual(subscription, null, "Expected subscription");
+ done();
+ }
+ );
+ },
+ error => {
+ ok(false, "Error generating app server key");
+ done();
+ }
+ );
+});
+
+add_test(function test_subscribeWithKey_conflict() {
+ do_test_pending();
+
+ generateKey().then(
+ differentKey => {
+ PushServiceComponent.subscribeWithKey(
+ "https://example.com/sub-key/ok",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ differentKey,
+ (result, subscription) => {
+ ok(
+ !Components.isSuccessCode(result),
+ "Expected error creating subscription with conflicting key"
+ );
+ equal(
+ result,
+ Cr.NS_ERROR_DOM_PUSH_MISMATCHED_KEY_ERR,
+ "Wrong error code for mismatched key"
+ );
+ strictEqual(subscription, null, "Unexpected subscription");
+ done();
+ }
+ );
+ },
+ error => {
+ ok(false, "Error generating different app server key");
+ done();
+ }
+ );
+});
+
+add_test(function test_subscribe_error() {
+ do_test_pending();
+ PushServiceComponent.subscribe(
+ "https://example.com/sub/fail",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, subscription) => {
+ ok(
+ !Components.isSuccessCode(result),
+ "Expected error creating subscription"
+ );
+ strictEqual(subscription, null, "Unexpected subscription");
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_getSubscription_exists() {
+ do_test_pending();
+ PushServiceComponent.getSubscription(
+ "https://example.com/get/ok",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, subscription) => {
+ ok(Components.isSuccessCode(result), "Error getting subscription");
+
+ equal(
+ subscription.endpoint,
+ "https://example.org/push/get",
+ "Wrong endpoint"
+ );
+ equal(subscription.pushCount, 10, "Wrong push count");
+ equal(subscription.lastPush, 1438360548322, "Wrong last push");
+ equal(subscription.quota, 16, "Wrong quota for subscription");
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_getSubscription_missing() {
+ do_test_pending();
+ PushServiceComponent.getSubscription(
+ "https://example.com/get/missing",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, subscription) => {
+ ok(
+ Components.isSuccessCode(result),
+ "Error getting nonexistent subscription"
+ );
+ strictEqual(
+ subscription,
+ null,
+ "Nonexistent subscriptions should return null"
+ );
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_getSubscription_error() {
+ do_test_pending();
+ PushServiceComponent.getSubscription(
+ "https://example.com/get/fail",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, subscription) => {
+ ok(
+ !Components.isSuccessCode(result),
+ "Expected error getting subscription"
+ );
+ strictEqual(subscription, null, "Unexpected subscription");
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_unsubscribe_success() {
+ do_test_pending();
+ PushServiceComponent.unsubscribe(
+ "https://example.com/unsub/ok",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, success) => {
+ ok(Components.isSuccessCode(result), "Error unsubscribing");
+ strictEqual(success, true, "Expected successful unsubscribe");
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_unsubscribe_nonexistent() {
+ do_test_pending();
+ PushServiceComponent.unsubscribe(
+ "https://example.com/unsub/ok",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, success) => {
+ ok(
+ Components.isSuccessCode(result),
+ "Error removing nonexistent subscription"
+ );
+ strictEqual(
+ success,
+ false,
+ "Nonexistent subscriptions should return false"
+ );
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_unsubscribe_error() {
+ do_test_pending();
+ PushServiceComponent.unsubscribe(
+ "https://example.com/unsub/fail",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, success) => {
+ ok(!Components.isSuccessCode(result), "Expected error unsubscribing");
+ strictEqual(success, false, "Unexpected successful unsubscribe");
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_subscribe_origin_principal() {
+ let scope = "https://example.net/origin-principal";
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(scope);
+
+ do_test_pending();
+ PushServiceComponent.subscribe(scope, principal, (result, subscription) => {
+ ok(
+ Components.isSuccessCode(result),
+ "Expected error creating subscription with origin principal"
+ );
+ ok(
+ !subscription.isSystemSubscription,
+ "Unexpected system subscription for origin principal"
+ );
+ equal(subscription.quota, 16, "Wrong quota for origin subscription");
+
+ do_test_finished();
+ run_next_test();
+ });
+});
+
+add_test(function test_subscribe_null_principal() {
+ do_test_pending();
+ PushServiceComponent.subscribe(
+ "chrome://push/null-principal",
+ Services.scriptSecurityManager.createNullPrincipal({}),
+ (result, subscription) => {
+ ok(
+ !Components.isSuccessCode(result),
+ "Expected error creating subscription with null principal"
+ );
+ strictEqual(
+ subscription,
+ null,
+ "Unexpected subscription with null principal"
+ );
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_subscribe_missing_principal() {
+ do_test_pending();
+ PushServiceComponent.subscribe(
+ "chrome://push/missing-principal",
+ null,
+ (result, subscription) => {
+ ok(
+ !Components.isSuccessCode(result),
+ "Expected error creating subscription without principal"
+ );
+ strictEqual(
+ subscription,
+ null,
+ "Unexpected subscription without principal"
+ );
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+if (isParent) {
+ add_test(function tearDown() {
+ tearDownServiceInParent(db).then(run_next_test, run_next_test);
+ });
+}
diff --git a/dom/push/test/xpcshell/test_service_parent.js b/dom/push/test/xpcshell/test_service_parent.js
new file mode 100644
index 0000000000..7dda1db775
--- /dev/null
+++ b/dom/push/test/xpcshell/test_service_parent.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(async function test_service_parent() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(() => db.close());
+ });
+ await setUpServiceInParent(PushService, db);
+
+ // Accessing the lazy service getter will start the service in the main
+ // process.
+ equal(
+ PushServiceComponent.pushTopic,
+ "push-message",
+ "Wrong push message observer topic"
+ );
+ equal(
+ PushServiceComponent.subscriptionChangeTopic,
+ "push-subscription-change",
+ "Wrong subscription change observer topic"
+ );
+
+ await run_test_in_child("./test_service_child.js");
+
+ await tearDownServiceInParent(db);
+});
diff --git a/dom/push/test/xpcshell/test_unregister_empty_scope.js b/dom/push/test/xpcshell/test_unregister_empty_scope.js
new file mode 100644
index 0000000000..b5b6109ddb
--- /dev/null
+++ b/dom/push/test/xpcshell/test_unregister_empty_scope.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(async function test_unregister_empty_scope() {
+ let handshakeDone;
+ let handshakePromise = new Promise(resolve => (handshakeDone = resolve));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: "5619557c-86fe-4711-8078-d1fd6987aef7",
+ })
+ );
+ handshakeDone();
+ },
+ });
+ },
+ });
+ await handshakePromise;
+
+ await Assert.rejects(
+ PushService.unregister({
+ scope: "",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Invalid page record/,
+ "Expected error for empty endpoint"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_unregister_error.js b/dom/push/test/xpcshell/test_unregister_error.js
new file mode 100644
index 0000000000..bc56dc49b4
--- /dev/null
+++ b/dom/push/test/xpcshell/test_unregister_error.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const channelID = "00c7fa13-7b71-447d-bd27-a91abc09d1b2";
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(async function test_unregister_error() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ await db.put({
+ channelID,
+ pushEndpoint: "https://example.org/update/failure",
+ scope: "https://example.net/page/failure",
+ originAttributes: "",
+ version: 1,
+ quota: Infinity,
+ });
+
+ let unregisterDone;
+ let unregisterPromise = new Promise(resolve => (unregisterDone = resolve));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: "083e6c17-1063-4677-8638-ab705aebebc2",
+ })
+ );
+ },
+ onUnregister(request) {
+ // The server is notified out-of-band. Since channels may be pruned,
+ // any failures are swallowed.
+ equal(request.channelID, channelID, "Unregister: wrong channel ID");
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ status: 500,
+ error: "omg, everything is exploding",
+ channelID,
+ })
+ );
+ unregisterDone();
+ },
+ });
+ },
+ });
+
+ await PushService.unregister({
+ scope: "https://example.net/page/failure",
+ originAttributes: "",
+ });
+
+ let result = await db.getByKeyID(channelID);
+ ok(!result, "Deleted push record exists");
+
+ // Make sure we send a request to the server.
+ await unregisterPromise;
+});
diff --git a/dom/push/test/xpcshell/test_unregister_invalid_json.js b/dom/push/test/xpcshell/test_unregister_invalid_json.js
new file mode 100644
index 0000000000..fa709c8eae
--- /dev/null
+++ b/dom/push/test/xpcshell/test_unregister_invalid_json.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "7f0af1bb-7e1f-4fb8-8e4a-e8de434abde3";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ requestTimeout: 150,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_unregister_invalid_json() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ let records = [
+ {
+ channelID: "87902e90-c57e-4d18-8354-013f4a556559",
+ pushEndpoint: "https://example.org/update/1",
+ scope: "https://example.edu/page/1",
+ originAttributes: "",
+ version: 1,
+ quota: Infinity,
+ },
+ {
+ channelID: "057caa8f-9b99-47ff-891c-adad18ce603e",
+ pushEndpoint: "https://example.com/update/2",
+ scope: "https://example.net/page/1",
+ originAttributes: "",
+ version: 1,
+ quota: Infinity,
+ },
+ ];
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ let unregisterDone;
+ let unregisterPromise = new Promise(
+ resolve => (unregisterDone = after(2, resolve))
+ );
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ use_webpush: true,
+ })
+ );
+ },
+ onUnregister(request) {
+ this.serverSendMsg(");alert(1);(");
+ unregisterDone();
+ },
+ });
+ },
+ });
+
+ await Assert.rejects(
+ PushService.unregister({
+ scope: "https://example.edu/page/1",
+ originAttributes: "",
+ }),
+ /Request timed out/,
+ "Expected error for first invalid JSON response"
+ );
+
+ let record = await db.getByKeyID("87902e90-c57e-4d18-8354-013f4a556559");
+ ok(!record, "Failed to delete unregistered record");
+
+ await Assert.rejects(
+ PushService.unregister({
+ scope: "https://example.net/page/1",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Request timed out/,
+ "Expected error for second invalid JSON response"
+ );
+
+ record = await db.getByKeyID("057caa8f-9b99-47ff-891c-adad18ce603e");
+ ok(
+ !record,
+ "Failed to delete unregistered record after receiving invalid JSON"
+ );
+
+ await unregisterPromise;
+});
diff --git a/dom/push/test/xpcshell/test_unregister_not_found.js b/dom/push/test/xpcshell/test_unregister_not_found.js
new file mode 100644
index 0000000000..a7693f3cf5
--- /dev/null
+++ b/dom/push/test/xpcshell/test_unregister_not_found.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(async function test_unregister_not_found() {
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: "f074ed80-d479-44fa-ba65-792104a79ea9",
+ })
+ );
+ },
+ });
+ },
+ });
+
+ let result = await PushService.unregister({
+ scope: "https://example.net/nonexistent",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ Assert.strictEqual(
+ result,
+ false,
+ "unregister should resolve with false for nonexistent scope"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_unregister_success.js b/dom/push/test/xpcshell/test_unregister_success.js
new file mode 100644
index 0000000000..ccd0d31495
--- /dev/null
+++ b/dom/push/test/xpcshell/test_unregister_success.js
@@ -0,0 +1,84 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "fbe865a6-aeb8-446f-873c-aeebdb8d493c";
+const channelID = "db0a7021-ec2d-4bd3-8802-7a6966f10ed8";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(async function test_unregister_success() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ await db.put({
+ channelID,
+ pushEndpoint: "https://example.org/update/unregister-success",
+ scope: "https://example.com/page/unregister-success",
+ originAttributes: "",
+ version: 1,
+ quota: Infinity,
+ });
+
+ let unregisterDone;
+ let unregisterPromise = new Promise(resolve => (unregisterDone = resolve));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ use_webpush: true,
+ })
+ );
+ },
+ onUnregister(request) {
+ equal(request.channelID, channelID, "Should include the channel ID");
+ equal(request.code, 200, "Expected manual unregister reason");
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ status: 200,
+ channelID,
+ })
+ );
+ unregisterDone();
+ },
+ });
+ },
+ });
+
+ let subModifiedPromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionModifiedTopic
+ );
+
+ await PushService.unregister({
+ scope: "https://example.com/page/unregister-success",
+ originAttributes: "",
+ });
+
+ let { data: subModifiedScope } = await subModifiedPromise;
+ equal(
+ subModifiedScope,
+ "https://example.com/page/unregister-success",
+ "Should fire a subscription modified event after unsubscribing"
+ );
+
+ let record = await db.getByKeyID(channelID);
+ ok(!record, "Unregister did not remove record");
+
+ await unregisterPromise;
+});
diff --git a/dom/push/test/xpcshell/test_unregister_success_http2.js b/dom/push/test/xpcshell/test_unregister_success_http2.js
new file mode 100644
index 0000000000..8ae2ebafdb
--- /dev/null
+++ b/dom/push/test/xpcshell/test_unregister_success_http2.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var pushEnabled;
+var pushConnectionEnabled;
+
+var serverPort = -1;
+
+function run_test() {
+ serverPort = getTestServerPort();
+
+ do_get_profile();
+ pushEnabled = Services.prefs.getBoolPref("dom.push.enabled");
+ pushConnectionEnabled = Services.prefs.getBoolPref(
+ "dom.push.connection.enabled"
+ );
+
+ // Set to allow the cert presented by our H2 server
+ var oldPref = Services.prefs.getIntPref(
+ "network.http.speculative-parallel-limit"
+ );
+ Services.prefs.setIntPref("network.http.speculative-parallel-limit", 0);
+ Services.prefs.setBoolPref("dom.push.enabled", true);
+ Services.prefs.setBoolPref("dom.push.connection.enabled", true);
+
+ trustHttp2CA();
+
+ Services.prefs.setIntPref("network.http.speculative-parallel-limit", oldPref);
+
+ run_next_test();
+}
+
+add_task(async function test_pushUnsubscriptionSuccess() {
+ let db = PushServiceHttp2.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ var serverURL = "https://localhost:" + serverPort;
+
+ await db.put({
+ subscriptionUri: serverURL + "/subscriptionUnsubscriptionSuccess",
+ pushEndpoint: serverURL + "/pushEndpointUnsubscriptionSuccess",
+ pushReceiptEndpoint:
+ serverURL + "/receiptPushEndpointUnsubscriptionSuccess",
+ scope: "https://example.com/page/unregister-success",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ quota: Infinity,
+ });
+
+ PushService.init({
+ serverURI: serverURL,
+ db,
+ });
+
+ await PushService.unregister({
+ scope: "https://example.com/page/unregister-success",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ let record = await db.getByKeyID(
+ serverURL + "/subscriptionUnsubscriptionSuccess"
+ );
+ ok(!record, "Unregister did not remove record");
+});
+
+add_task(async function test_complete() {
+ Services.prefs.setBoolPref("dom.push.enabled", pushEnabled);
+ Services.prefs.setBoolPref(
+ "dom.push.connection.enabled",
+ pushConnectionEnabled
+ );
+});
diff --git a/dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_http2.js b/dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_http2.js
new file mode 100644
index 0000000000..0d58cb60b6
--- /dev/null
+++ b/dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_http2.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { HttpServer } = ChromeUtils.importESModule(
+ "resource://testing-common/httpd.sys.mjs"
+);
+
+var httpServer = null;
+
+function listenHandler(metadata, response) {
+ Assert.ok(true, "Start listening");
+ httpServer.stop(do_test_finished);
+ response.setHeader("Retry-After", "10");
+ response.setStatusLine(metadata.httpVersion, 500, "Retry");
+}
+
+httpServer = new HttpServer();
+httpServer.registerPathHandler("/subscriptionNoKey", listenHandler);
+httpServer.start(-1);
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ "testing.allowInsecureServerURL": true,
+ "http2.retryInterval": 1000,
+ "http2.maxRetries": 2,
+ });
+
+ run_next_test();
+}
+
+add_task(async function test1() {
+ let db = PushServiceHttp2.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(() => db.close());
+ });
+
+ do_test_pending();
+
+ var serverURL = "http://localhost:" + httpServer.identity.primaryPort;
+
+ let record = {
+ subscriptionUri: serverURL + "/subscriptionNoKey",
+ pushEndpoint: serverURL + "/pushEndpoint",
+ pushReceiptEndpoint: serverURL + "/pushReceiptEndpoint",
+ scope: "https://example.com/page",
+ originAttributes: "",
+ quota: Infinity,
+ systemRecord: true,
+ };
+
+ await db.put(record);
+
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ _ => true
+ );
+
+ PushService.init({
+ serverURI: serverURL + "/subscribe",
+ db,
+ });
+
+ await notifyPromise;
+
+ let aRecord = await db.getByKeyID(serverURL + "/subscriptionNoKey");
+ ok(aRecord, "The record should still be there");
+ ok(aRecord.p256dhPublicKey, "There should be a public key");
+ ok(aRecord.p256dhPrivateKey, "There should be a private key");
+});
diff --git a/dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_ws.js b/dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_ws.js
new file mode 100644
index 0000000000..b54735c042
--- /dev/null
+++ b/dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_ws.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "4dffd396-6582-471d-8c0c-84f394e9f7db";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(async function test_with_data_enabled() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ let [publicKey, privateKey] = await PushCrypto.generateKeys();
+ let records = [
+ {
+ channelID: "eb18f12a-cc42-4f14-accb-3bfc1227f1aa",
+ pushEndpoint: "https://example.org/push/no-key/1",
+ scope: "https://example.com/page/1",
+ originAttributes: "",
+ quota: Infinity,
+ },
+ {
+ channelID: "0d8886b9-8da1-4778-8f5d-1cf93a877ed6",
+ pushEndpoint: "https://example.org/push/key",
+ scope: "https://example.com/page/2",
+ originAttributes: "",
+ p256dhPublicKey: publicKey,
+ p256dhPrivateKey: privateKey,
+ quota: Infinity,
+ },
+ ];
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ ok(
+ request.use_webpush,
+ "Should use Web Push if data delivery is enabled"
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: request.uaid,
+ use_webpush: true,
+ })
+ );
+ },
+ onRegister(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ uaid: userAgentID,
+ channelID: request.channelID,
+ pushEndpoint: "https://example.org/push/new",
+ })
+ );
+ },
+ });
+ },
+ });
+
+ let newRecord = await PushService.register({
+ scope: "https://example.com/page/3",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ ok(newRecord.p256dhKey, "Should generate public keys for new records");
+
+ let record = await db.getByKeyID("eb18f12a-cc42-4f14-accb-3bfc1227f1aa");
+ ok(record.p256dhPublicKey, "Should add public key to partial record");
+ ok(record.p256dhPrivateKey, "Should add private key to partial record");
+
+ record = await db.getByKeyID("0d8886b9-8da1-4778-8f5d-1cf93a877ed6");
+ deepEqual(
+ record.p256dhPublicKey,
+ publicKey,
+ "Should leave existing public key"
+ );
+ deepEqual(
+ record.p256dhPrivateKey,
+ privateKey,
+ "Should leave existing private key"
+ );
+});
diff --git a/dom/push/test/xpcshell/xpcshell.toml b/dom/push/test/xpcshell/xpcshell.toml
new file mode 100644
index 0000000000..476677506c
--- /dev/null
+++ b/dom/push/test/xpcshell/xpcshell.toml
@@ -0,0 +1,121 @@
+[DEFAULT]
+head = "head.js head-http2.js"
+# Push notifications and alarms are currently disabled on Android.
+skip-if = ["os == 'android'"]
+support-files = ["broadcast_handler.sys.mjs"]
+
+["test_broadcast_success.js"]
+
+["test_clearAll_successful.js"]
+# This used to be hasNode, but that caused too many issues with tests being
+# silently disabled, so now we explicitly call out the platforms not known
+# to have node installed.
+skip-if = ["os == 'android'"]
+run-sequentially = "This will delete all existing push subscriptions."
+
+["test_clear_forgetAboutSite.js"]
+
+["test_clear_origin_data.js"]
+
+["test_crypto.js"]
+
+["test_crypto_encrypt.js"]
+
+["test_drop_expired.js"]
+
+["test_handler_service.js"]
+
+["test_notification_ack.js"]
+
+["test_notification_data.js"]
+
+["test_notification_duplicate.js"]
+
+["test_notification_error.js"]
+
+["test_notification_incomplete.js"]
+
+["test_notification_version_string.js"]
+
+["test_observer_data.js"]
+
+["test_observer_remoting.js"]
+skip-if = ["serviceworker_e10s"]
+
+["test_permissions.js"]
+run-sequentially = "This will delete all existing push subscriptions."
+
+["test_quota_exceeded.js"]
+
+["test_quota_observer.js"]
+
+["test_quota_with_notification.js"]
+
+["test_reconnect_retry.js"]
+
+["test_record.js"]
+
+["test_register_5xxCode_http2.js"]
+
+["test_register_case.js"]
+
+["test_register_flush.js"]
+
+["test_register_invalid_channel.js"]
+
+["test_register_invalid_endpoint.js"]
+
+["test_register_invalid_json.js"]
+
+["test_register_no_id.js"]
+
+["test_register_request_queue.js"]
+
+["test_register_rollback.js"]
+
+["test_register_success.js"]
+
+["test_register_timeout.js"]
+
+["test_register_wrong_id.js"]
+
+["test_register_wrong_type.js"]
+
+["test_registration_error.js"]
+
+["test_registration_missing_scope.js"]
+
+["test_registration_none.js"]
+
+["test_registration_success.js"]
+
+["test_resubscribe_4xxCode_http2.js"]
+
+["test_resubscribe_5xxCode_http2.js"]
+
+["test_resubscribe_listening_for_msg_error_http2.js"]
+
+["test_retry_ws.js"]
+
+["test_service_child.js"]
+
+#http2 test
+
+["test_service_parent.js"]
+
+["test_unregister_empty_scope.js"]
+
+["test_unregister_error.js"]
+
+["test_unregister_invalid_json.js"]
+skip-if = ["os == 'win'"] # Bug 1627379
+run-sequentially = "very high failure rate in parallel"
+
+["test_unregister_not_found.js"]
+
+["test_unregister_success.js"]
+
+["test_updateRecordNoEncryptionKeys_http2.js"]
+
+["test_updateRecordNoEncryptionKeys_ws.js"]
+skip-if = ["os == 'linux'"] # Bug 1265233