diff options
Diffstat (limited to 'dom/push/test/xpcshell')
62 files changed, 7773 insertions, 0 deletions
diff --git a/dom/push/test/xpcshell/PushServiceHandler.js b/dom/push/test/xpcshell/PushServiceHandler.js new file mode 100644 index 0000000000..b5b95775f5 --- /dev/null +++ b/dom/push/test/xpcshell/PushServiceHandler.js @@ -0,0 +1,33 @@ +// An XPCOM service that's registered with the category manager in the parent +// process for handling push notifications with scope "chrome://test-scope" +"use strict"; + +const { ComponentUtils } = ChromeUtils.import( + "resource://gre/modules/ComponentUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +let pushService = Cc["@mozilla.org/push/Service;1"].getService( + Ci.nsIPushService +); + +function PushServiceHandler() { + // So JS code can reach into us. + this.wrappedJSObject = this; + // 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 }); + }, +}; + +this.NSGetFactory = ComponentUtils.generateNSGetFactory([PushServiceHandler]); diff --git a/dom/push/test/xpcshell/PushServiceHandler.manifest b/dom/push/test/xpcshell/PushServiceHandler.manifest new file mode 100644 index 0000000000..f25b498963 --- /dev/null +++ b/dom/push/test/xpcshell/PushServiceHandler.manifest @@ -0,0 +1,4 @@ +component {bb7c5199-c0f7-4976-9f6d-1306e32c5591} PushServiceHandler.js +contract @mozilla.org/dom/push/test/PushServiceHandler;1 {bb7c5199-c0f7-4976-9f6d-1306e32c5591} + +category push chrome://test-scope @mozilla.org/dom/push/test/PushServiceHandler;1 diff --git a/dom/push/test/xpcshell/broadcast_handler.jsm b/dom/push/test/xpcshell/broadcast_handler.jsm new file mode 100644 index 0000000000..eb9f3625eb --- /dev/null +++ b/dom/push/test/xpcshell/broadcast_handler.jsm @@ -0,0 +1,16 @@ +"use strict"; + +var EXPORTED_SYMBOLS = ["broadcastHandler"]; + +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..5292f975a1 --- /dev/null +++ b/dom/push/test/xpcshell/head-http2.js @@ -0,0 +1,44 @@ +const { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm"); + +// Returns the test H/2 server port, throwing if it's missing or invalid. +function getTestServerPort() { + let portEnv = Cc["@mozilla.org/process/environment;1"] + .getService(Ci.nsIEnvironment) + .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..ccac159a71 --- /dev/null +++ b/dom/push/test/xpcshell/head.js @@ -0,0 +1,511 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +var { + clearInterval, + clearTimeout, + setInterval, + setIntervalWithTarget, + setTimeout, + setTimeoutWithTarget, +} = ChromeUtils.import("resource://gre/modules/Timer.jsm"); +var { Preferences } = ChromeUtils.import( + "resource://gre/modules/Preferences.jsm" +); +var { PlacesUtils } = ChromeUtils.import( + "resource://gre/modules/PlacesUtils.jsm" +); +var { ObjectUtils } = ChromeUtils.import( + "resource://gre/modules/ObjectUtils.jsm" +); +var { PermissionTestUtils } = ChromeUtils.import( + "resource://testing-common/PermissionTestUtils.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "PlacesTestUtils", + "resource://testing-common/PlacesTestUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "pushBroadcastService", + "resource://gre/modules/PushBroadcastService.jsm", + {} +); +XPCOMUtils.defineLazyServiceGetter( + this, + "PushServiceComponent", + "@mozilla.org/push/Service;1", + "nsIPushService" +); + +const serviceExports = ChromeUtils.import( + "resource://gre/modules/PushService.jsm", + null +); +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); + serviceExports.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) { + Cu.reportError(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(Cu.reportError); +} + +/** + * 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, 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..2ee9d49cb9 --- /dev/null +++ b/dom/push/test/xpcshell/moz.build @@ -0,0 +1,8 @@ +EXTRA_COMPONENTS += [ + "PushServiceHandler.js", + "PushServiceHandler.manifest", +] + +TESTING_JS_MODULES += [ + "broadcast_handler.jsm", +] 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..ea8bdab74e --- /dev/null +++ b/dom/push/test/xpcshell/test_broadcast_success.js @@ -0,0 +1,426 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; +const { BroadcastService } = ChromeUtils.import( + "resource://gre/modules/PushBroadcastService.jsm", + null +); +const { JSONFile } = ChromeUtils.import("resource://gre/modules/JSONFile.jsm"); + +const { FileTestUtils } = ChromeUtils.import( + "resource://testing-common/FileTestUtils.jsm" +); +const { broadcastHandler } = ChromeUtils.import( + "resource://test/broadcast_handler.jsm" +); + +const broadcastService = pushBroadcastService; +const assert = Assert; +const userAgentID = "bd744428-f125-436a-b6d0-dd0c9845837f"; +const channelID = "0ef2ad4a-6c49-41ad-af6e-95d2425276bf"; + +function run_test() { + do_get_profile(); + 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.jsm", + 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.jsm", + 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.jsm", + 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..f881ed18a1 --- /dev/null +++ b/dom/push/test/xpcshell/test_clearAll_successful.js @@ -0,0 +1,132 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..4c496e2504 --- /dev/null +++ b/dom/push/test/xpcshell/test_clear_forgetAboutSite.js @@ -0,0 +1,164 @@ +"use strict"; + +const { PushService, PushServiceWebSocket } = serviceExports; +const { ForgetAboutSite } = ChromeUtils.import( + "resource://gre/modules/ForgetAboutSite.jsm" +); + +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, + "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 + ); + + // 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-2", "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, + ["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..deb5877042 --- /dev/null +++ b/dom/push/test/xpcshell/test_clear_origin_data.js @@ -0,0 +1,134 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..ac3161b595 --- /dev/null +++ b/dom/push/test/xpcshell/test_crypto.js @@ -0,0 +1,677 @@ +"use strict"; + +const { getCryptoParamsFromHeaders, PushCrypto } = ChromeUtils.import( + "resource://gre/modules/PushCrypto.jsm", + null +); + +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..73f8b4a2fa --- /dev/null +++ b/dom/push/test/xpcshell/test_crypto_encrypt.js @@ -0,0 +1,200 @@ +// Test PushCrypto.encrypt() +"use strict"; + +Cu.importGlobalProperties(["crypto"]); + +const { PushCrypto } = ChromeUtils.import( + "resource://gre/modules/PushCrypto.jsm" +); + +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("utf-8").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("utf-8").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("utf-8").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("utf-8").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("utf-8").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..475ec3eaed --- /dev/null +++ b/dom/push/test/xpcshell/test_drop_expired.js @@ -0,0 +1,166 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..87c64ea5e1 --- /dev/null +++ b/dom/push/test/xpcshell/test_handler_service.js @@ -0,0 +1,55 @@ +"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. + +// This component is registered for "chrome://test-scope" +const kServiceContractID = "@mozilla.org/dom/push/test/PushServiceHandler;1"; + +let pushService = Cc["@mozilla.org/push/Service;1"].getService( + Ci.nsIPushService +); + +add_test(function test_service_instantiation() { + do_load_manifest("PushServiceHandler.manifest"); + + let scope = "chrome://test-scope"; + let pushNotifier = Cc["@mozilla.org/push/Notifier;1"].getService( + Ci.nsIPushNotifier + ); + let principal = Services.scriptSecurityManager.getSystemPrincipal(); + pushNotifier.notifyPush(scope, principal, ""); + + // Now get a handle to our service and check it received the notification. + let handlerService = Cc[kServiceContractID].getService(Ci.nsISupports) + .wrappedJSObject; + + 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..f911c7fb83 --- /dev/null +++ b/dom/push/test/xpcshell/test_notification_ack.js @@ -0,0 +1,165 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..2cb9ae853d --- /dev/null +++ b/dom/push/test/xpcshell/test_notification_data.js @@ -0,0 +1,314 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..2dc8cd988d --- /dev/null +++ b/dom/push/test/xpcshell/test_notification_duplicate.js @@ -0,0 +1,177 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..cf01fd053e --- /dev/null +++ b/dom/push/test/xpcshell/test_notification_error.js @@ -0,0 +1,153 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..a46fb9d295 --- /dev/null +++ b/dom/push/test/xpcshell/test_notification_incomplete.js @@ -0,0 +1,152 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..4e50a56ebf --- /dev/null +++ b/dom/push/test/xpcshell/test_notification_version_string.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..4a976d99c2 --- /dev/null +++ b/dom/push/test/xpcshell/test_observer_data.js @@ -0,0 +1,59 @@ +"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("utf-8").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..d584a0beb9 --- /dev/null +++ b/dom/push/test/xpcshell/test_observer_remoting.js @@ -0,0 +1,143 @@ +"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("utf-8").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..52f0ffc011 --- /dev/null +++ b/dom/push/test/xpcshell/test_permissions.js @@ -0,0 +1,334 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..077e6967f6 --- /dev/null +++ b/dom/push/test/xpcshell/test_quota_exceeded.js @@ -0,0 +1,158 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..6609de0a3f --- /dev/null +++ b/dom/push/test/xpcshell/test_quota_observer.js @@ -0,0 +1,212 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..0f4ed2d606 --- /dev/null +++ b/dom/push/test/xpcshell/test_quota_with_notification.js @@ -0,0 +1,132 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +const userAgentID = "aaabf1f8-2f68-44f1-a920-b88e9e7d7559"; +const nsIPushQuotaManager = Ci.nsIPushQuotaManager; + +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..86afef4588 --- /dev/null +++ b/dom/push/test/xpcshell/test_reconnect_retry.js @@ -0,0 +1,92 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..50102cfea0 --- /dev/null +++ b/dom/push/test/xpcshell/test_record.js @@ -0,0 +1,132 @@ +"use strict"; + +const { PushRecord } = ChromeUtils.import( + "resource://gre/modules/PushRecord.jsm" +); + +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..8e6029c750 --- /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.import("resource://testing-common/httpd.js"); + +const { PushDB, PushService, PushServiceHttp2 } = serviceExports; + +var httpServer = null; + +XPCOMUtils.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"); + ok(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..c724793a1b --- /dev/null +++ b/dom/push/test/xpcshell/test_register_case.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..39eeb331d6 --- /dev/null +++ b/dom/push/test/xpcshell/test_register_flush.js @@ -0,0 +1,119 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +const userAgentID = "9ce1e6d3-7bdb-4fe9-90a5-def1d64716f1"; +const channelID = "c26892c5-6e08-4c16-9f0c-0044697b4d85"; + +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..9014fb1503 --- /dev/null +++ b/dom/push/test/xpcshell/test_register_invalid_channel.js @@ -0,0 +1,65 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..19e92099aa --- /dev/null +++ b/dom/push/test/xpcshell/test_register_invalid_endpoint.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..fafaba2568 --- /dev/null +++ b/dom/push/test/xpcshell/test_register_invalid_json.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..2285c842ac --- /dev/null +++ b/dom/push/test/xpcshell/test_register_no_id.js @@ -0,0 +1,68 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..387c637939 --- /dev/null +++ b/dom/push/test/xpcshell/test_register_request_queue.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..931947740a --- /dev/null +++ b/dom/push/test/xpcshell/test_register_rollback.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..a72b46f93b --- /dev/null +++ b/dom/push/test/xpcshell/test_register_success.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..5f7ef2ff63 --- /dev/null +++ b/dom/push/test/xpcshell/test_register_timeout.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..4e2e43145c --- /dev/null +++ b/dom/push/test/xpcshell/test_register_wrong_id.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..9e47b621ef --- /dev/null +++ b/dom/push/test/xpcshell/test_register_wrong_type.js @@ -0,0 +1,68 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService } = serviceExports; + +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..441b1e162c --- /dev/null +++ b/dom/push/test/xpcshell/test_registration_error.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..7352fc3bde --- /dev/null +++ b/dom/push/test/xpcshell/test_registration_error_http2.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceHttp2 } = serviceExports; + +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..2cc91476a4 --- /dev/null +++ b/dom/push/test/xpcshell/test_registration_missing_scope.js @@ -0,0 +1,26 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService } = serviceExports; + +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..603402b8c7 --- /dev/null +++ b/dom/push/test/xpcshell/test_registration_none.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService } = serviceExports; + +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..5ad37479e2 --- /dev/null +++ b/dom/push/test/xpcshell/test_registration_success.js @@ -0,0 +1,68 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..db9c768b49 --- /dev/null +++ b/dom/push/test/xpcshell/test_registration_success_http2.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceHttp2 } = serviceExports; + +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..00f89752d8 --- /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.import("resource://testing-common/httpd.js"); + +const { PushDB, PushService, PushServiceHttp2 } = serviceExports; + +var httpServer = null; + +XPCOMUtils.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..c1872904d8 --- /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.import("resource://testing-common/httpd.js"); + +const { PushDB, PushService, PushServiceHttp2 } = serviceExports; + +var httpServer = null; + +XPCOMUtils.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"); + ok(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..4103c045b6 --- /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.import("resource://testing-common/httpd.js"); + +const { PushDB, PushService, PushServiceHttp2 } = serviceExports; + +var httpServer = null; + +XPCOMUtils.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..cc9e9e2ac4 --- /dev/null +++ b/dom/push/test/xpcshell/test_retry_ws.js @@ -0,0 +1,75 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..50615581d5 --- /dev/null +++ b/dom/push/test/xpcshell/test_service_child.js @@ -0,0 +1,357 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.importGlobalProperties(["crypto"]); + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..ce8e3074c8 --- /dev/null +++ b/dom/push/test/xpcshell/test_service_parent.js @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..754cec9b61 --- /dev/null +++ b/dom/push/test/xpcshell/test_unregister_empty_scope.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService } = serviceExports; + +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..fd104e7045 --- /dev/null +++ b/dom/push/test/xpcshell/test_unregister_error.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..1f7072b554 --- /dev/null +++ b/dom/push/test/xpcshell/test_unregister_invalid_json.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..1c1a9897b2 --- /dev/null +++ b/dom/push/test/xpcshell/test_unregister_not_found.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService } = serviceExports; + +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, + }), + }); + ok( + 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..aabbe0cb89 --- /dev/null +++ b/dom/push/test/xpcshell/test_unregister_success.js @@ -0,0 +1,86 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceWebSocket } = serviceExports; + +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..5dccdd29df --- /dev/null +++ b/dom/push/test/xpcshell/test_unregister_success_http2.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PushDB, PushService, PushServiceHttp2 } = serviceExports; + +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..702a52ad73 --- /dev/null +++ b/dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_http2.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js"); + +const { PushDB, PushService, PushServiceHttp2 } = serviceExports; + +var httpServer = null; + +XPCOMUtils.defineLazyGetter(this, "serverPort", function() { + return httpServer.identity.primaryPort; +}); + +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..2f24e563c2 --- /dev/null +++ b/dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_ws.js @@ -0,0 +1,109 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { + PushDB, + PushService, + PushServiceWebSocket, + PushCrypto, +} = serviceExports; + +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.ini b/dom/push/test/xpcshell/xpcshell.ini new file mode 100644 index 0000000000..fedead6d45 --- /dev/null +++ b/dom/push/test/xpcshell/xpcshell.ini @@ -0,0 +1,71 @@ +[DEFAULT] +head = head.js head-http2.js +# Push notifications and alarms are currently disabled on Android. +skip-if = toolkit == 'android' +support-files = broadcast_handler.jsm + +[test_broadcast_success.js] +[test_clear_forgetAboutSite.js] +[test_clear_origin_data.js] +[test_crypto.js] +[test_crypto_encrypt.js] +[test_drop_expired.js] +[test_handler_service.js] +support-files = PushServiceHandler.js PushServiceHandler.manifest +[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_record.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_unregister_empty_scope.js] +[test_unregister_error.js] +[test_unregister_invalid_json.js] +[test_unregister_not_found.js] +[test_unregister_success.js] +[test_updateRecordNoEncryptionKeys_ws.js] +skip-if = os == "linux" # Bug 1265233 +[test_reconnect_retry.js] +[test_retry_ws.js] +[test_service_parent.js] +[test_service_child.js] + +#http2 test +[test_resubscribe_4xxCode_http2.js] +[test_resubscribe_5xxCode_http2.js] +[test_resubscribe_listening_for_msg_error_http2.js] +[test_register_5xxCode_http2.js] +[test_updateRecordNoEncryptionKeys_http2.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. |