diff options
Diffstat (limited to 'dom/push/test')
84 files changed, 10880 insertions, 0 deletions
diff --git a/dom/push/test/.eslintrc.js b/dom/push/test/.eslintrc.js new file mode 100644 index 0000000000..845ed3f013 --- /dev/null +++ b/dom/push/test/.eslintrc.js @@ -0,0 +1,5 @@ +"use strict"; + +module.exports = { + extends: ["plugin:mozilla/mochitest-test"], +}; diff --git a/dom/push/test/error_worker.js b/dom/push/test/error_worker.js new file mode 100644 index 0000000000..eb39e5871b --- /dev/null +++ b/dom/push/test/error_worker.js @@ -0,0 +1,9 @@ +this.onpush = function(event) { + var request = event.data.json(); + if (request.type == "exception") { + throw new Error("Uncaught exception"); + } + if (request.type == "rejection") { + event.waitUntil(Promise.reject(new Error("Unhandled rejection"))); + } +}; diff --git a/dom/push/test/frame.html b/dom/push/test/frame.html new file mode 100644 index 0000000000..50036db15e --- /dev/null +++ b/dom/push/test/frame.html @@ -0,0 +1,24 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta http-equiv="Content-type" content="text/html;charset=UTF-8"> + <script> + + function waitOnWorkerMessage(type) { + return new Promise(function(res, rej) { + function onMessage(e) { + if (e.data.type == type) { + navigator.serviceWorker.removeEventListener("message", onMessage); + (e.data.okay == "yes" ? res : rej)(e.data); + } + } + navigator.serviceWorker.addEventListener("message", onMessage); + }); + } + + </script> +</head> +<body> + +</body> +</html> diff --git a/dom/push/test/lifetime_worker.js b/dom/push/test/lifetime_worker.js new file mode 100644 index 0000000000..bdb34ea552 --- /dev/null +++ b/dom/push/test/lifetime_worker.js @@ -0,0 +1,90 @@ +var state = "from_scope"; +var resolvePromiseCallback; + +self.onfetch = function(event) { + if (event.request.url.includes("lifetime_frame.html")) { + event.respondWith(new Response("iframe_lifetime")); + return; + } + + var currentState = state; + event.waitUntil( + self.clients.matchAll().then(clients => { + clients.forEach(client => { + client.postMessage({ type: "fetch", state: currentState }); + }); + }) + ); + + if (event.request.url.includes("update")) { + state = "update"; + } else if (event.request.url.includes("wait")) { + event.respondWith( + new Promise(function(res, rej) { + if (resolvePromiseCallback) { + dump("ERROR: service worker was already waiting on a promise.\n"); + } + resolvePromiseCallback = function() { + res(new Response("resolve_respondWithPromise")); + }; + }) + ); + state = "wait"; + } else if (event.request.url.includes("release")) { + state = "release"; + resolvePromise(); + } +}; + +function resolvePromise() { + if (resolvePromiseCallback === undefined || resolvePromiseCallback == null) { + dump("ERROR: wait promise was not set.\n"); + return; + } + resolvePromiseCallback(); + resolvePromiseCallback = null; +} + +self.onmessage = function(event) { + var lastState = state; + state = event.data; + if (state === "wait") { + event.waitUntil( + new Promise(function(res, rej) { + if (resolvePromiseCallback) { + dump("ERROR: service worker was already waiting on a promise.\n"); + } + resolvePromiseCallback = res; + }) + ); + } else if (state === "release") { + resolvePromise(); + } + event.source.postMessage({ type: "message", state: lastState }); +}; + +self.onpush = function(event) { + var pushResolve; + event.waitUntil( + new Promise(function(resolve) { + pushResolve = resolve; + }) + ); + + // FIXME(catalinb): push message carry no data. So we assume the only + // push message we get is "wait" + self.clients.matchAll().then(function(client) { + if (client.length == 0) { + dump("ERROR: no clients to send the response to.\n"); + } + + client[0].postMessage({ type: "push", state }); + + state = "wait"; + if (resolvePromiseCallback) { + dump("ERROR: service worker was already waiting on a promise.\n"); + } else { + resolvePromiseCallback = pushResolve; + } + }); +}; diff --git a/dom/push/test/mochitest.ini b/dom/push/test/mochitest.ini new file mode 100644 index 0000000000..d7ad884b1e --- /dev/null +++ b/dom/push/test/mochitest.ini @@ -0,0 +1,34 @@ +[DEFAULT] +skip-if = os == "android" +support-files = + worker.js + frame.html + webpush.js + lifetime_worker.js + test_utils.js + mockpushserviceparent.js + error_worker.js + + +[test_has_permissions.html] +[test_permissions.html] +[test_register.html] +skip-if = os == "win" # Bug 1373346 +[test_register_key.html] +scheme = https +[test_multiple_register.html] +[test_multiple_register_during_service_activation.html] +skip-if = (os == "win") || (os == "linux") || (os == "mac") #Bug 1274773 +[test_unregister.html] +[test_multiple_register_different_scope.html] +[test_subscription_change.html] +skip-if = os == "win" # Bug 1373346 +[test_data.html] +skip-if = os == "win" # Bug 1373346 +scheme = https +[test_try_registering_offline_disabled.html] +skip-if = os == "win" # Bug 1373346 +[test_serviceworker_lifetime.html] +skip-if = serviceworker_e10s || !e10s || os == "win" || os =="linux" && bits == 64 || os =="mac" # e10s: Bug 1433689, Windows: Bug 1373346, Bug 1578333, Bug 1578374 +[test_error_reporting.html] +skip-if = serviceworker_e10s diff --git a/dom/push/test/mockpushserviceparent.js b/dom/push/test/mockpushserviceparent.js new file mode 100644 index 0000000000..6ac4733622 --- /dev/null +++ b/dom/push/test/mockpushserviceparent.js @@ -0,0 +1,201 @@ +/* eslint-env mozilla/frame-script */ + +"use strict"; + +/** + * 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); +} + +/** + * Minimal implementation of a mock WebSocket connect to be used with + * PushService. Forwards and receive messages from the implementation + * that lives in the content process. + */ +function MockWebSocketParent(originalURI) { + this._originalURI = originalURI; +} + +MockWebSocketParent.prototype = { + _originalURI: 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)); + }, + + sendMsg(msg) { + sendAsyncMessage("socket-client-msg", msg); + }, + + close() { + waterfall(() => this._listener.onStop(this._context, Cr.NS_OK)); + }, + + serverSendMsg(msg) { + waterfall( + () => this._listener.onMessageAvailable(this._context, msg), + () => this._listener.onAcknowledge(this._context, 0) + ); + }, +}; + +var pushService = Cc["@mozilla.org/push/Service;1"].getService( + Ci.nsIPushService +).wrappedJSObject; + +var mockSocket; +var serverMsgs = []; + +addMessageListener("socket-setup", function() { + pushService.replaceServiceBackend({ + serverURI: "wss://push.example.org/", + makeWebSocket(uri) { + mockSocket = new MockWebSocketParent(uri); + while (serverMsgs.length > 0) { + let msg = serverMsgs.shift(); + mockSocket.serverSendMsg(msg); + } + return mockSocket; + }, + }); +}); + +addMessageListener("socket-teardown", function(msg) { + pushService + .restoreServiceBackend() + .then(_ => { + serverMsgs.length = 0; + if (mockSocket) { + mockSocket.close(); + mockSocket = null; + } + sendAsyncMessage("socket-server-teardown"); + }) + .catch(error => { + Cu.reportError(`Error restoring service backend: ${error}`); + }); +}); + +addMessageListener("socket-server-msg", function(msg) { + if (mockSocket) { + mockSocket.serverSendMsg(msg); + } else { + serverMsgs.push(msg); + } +}); + +var MockService = { + requestID: 1, + resolvers: new Map(), + + sendRequest(name, params) { + return new Promise((resolve, reject) => { + let id = this.requestID++; + this.resolvers.set(id, { resolve, reject }); + sendAsyncMessage("service-request", { + name, + id, + // The request params from the real push service may contain a + // principal, which cannot be passed to the unprivileged + // mochitest scope, and will cause the message to be dropped if + // present. The mochitest scope fortunately does not need the + // principal, though, so set it to null before sending. + params: Object.assign({}, params, { principal: null }), + }); + }); + }, + + handleResponse(response) { + if (!this.resolvers.has(response.id)) { + Cu.reportError(`Unexpected response for request ${response.id}`); + return; + } + let resolver = this.resolvers.get(response.id); + this.resolvers.delete(response.id); + if (response.error) { + resolver.reject(response.error); + } else { + resolver.resolve(response.result); + } + }, + + init() {}, + + register(pageRecord) { + return this.sendRequest("register", pageRecord); + }, + + registration(pageRecord) { + return this.sendRequest("registration", pageRecord); + }, + + unregister(pageRecord) { + return this.sendRequest("unregister", pageRecord); + }, + + reportDeliveryError(messageId, reason) { + sendAsyncMessage("service-delivery-error", { + messageId, + reason, + }); + }, + + uninit() { + return Promise.resolve(); + }, +}; + +async function replaceService(service) { + await pushService.service.uninit(); + pushService.service = service; + await pushService.service.init(); +} + +addMessageListener("service-replace", function() { + replaceService(MockService) + .then(_ => { + sendAsyncMessage("service-replaced"); + }) + .catch(error => { + Cu.reportError(`Error replacing service: ${error}`); + }); +}); + +addMessageListener("service-restore", function() { + replaceService(null) + .then(_ => { + sendAsyncMessage("service-restored"); + }) + .catch(error => { + Cu.reportError(`Error restoring service: ${error}`); + }); +}); + +addMessageListener("service-response", function(response) { + MockService.handleResponse(response); +}); diff --git a/dom/push/test/test_data.html b/dom/push/test/test_data.html new file mode 100644 index 0000000000..a2f043b7d9 --- /dev/null +++ b/dom/push/test/test_data.html @@ -0,0 +1,191 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1185544: Add data delivery to the WebSocket backend. + +Any copyright is dedicated to the Public Domain. +http://creativecommons.org/licenses/publicdomain/ + +--> +<head> + <title>Test for Bug 1185544</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script> + <script type="text/javascript" src="/tests/dom/push/test/webpush.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <meta http-equiv="Content-type" content="text/html;charset=UTF-8"> +</head> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1185544">Mozilla Bug 1185544</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> + +<script class="testbody" type="text/javascript"> + /* globals webPushEncrypt */ + + var userAgentID = "ac44402c-85fc-41e4-a0d0-483316d15351"; + var channelID = null; + + var mockSocket = new MockWebSocket(); + mockSocket.onRegister = function(request) { + channelID = request.channelID; + this.serverSendMsg(JSON.stringify({ + messageType: "register", + uaid: userAgentID, + channelID, + status: 200, + pushEndpoint: "https://example.com/endpoint/1", + })); + }; + + var registration; + add_task(async function start() { + await setupPrefsAndMockSocket(mockSocket); + await setPushPermission(true); + + var url = "worker.js?" + (Math.random()); + registration = await navigator.serviceWorker.register(url, {scope: "."}); + await waitForActive(registration); + }); + + var controlledFrame; + add_task(async function createControlledIFrame() { + controlledFrame = await injectControlledFrame(); + }); + + var pushSubscription; + add_task(async function subscribe() { + pushSubscription = await registration.pushManager.subscribe(); + }); + + add_task(async function compareJSONSubscription() { + var json = pushSubscription.toJSON(); + is(json.endpoint, pushSubscription.endpoint, "Wrong endpoint"); + + ["p256dh", "auth"].forEach(keyName => { + isDeeply( + base64UrlDecode(json.keys[keyName]), + new Uint8Array(pushSubscription.getKey(keyName)), + "Mismatched Base64-encoded key: " + keyName + ); + }); + }); + + add_task(async function comparePublicKey() { + var data = await sendRequestToWorker({ type: "publicKey" }); + var p256dhKey = new Uint8Array(pushSubscription.getKey("p256dh")); + is(p256dhKey.length, 65, "Key share should be 65 octets"); + isDeeply( + p256dhKey, + new Uint8Array(data.p256dh), + "Mismatched key share" + ); + var authSecret = new Uint8Array(pushSubscription.getKey("auth")); + is(authSecret.length, 16, "Auth secret should be 16 octets"); + isDeeply( + authSecret, + new Uint8Array(data.auth), + "Mismatched auth secret" + ); + }); + + var version = 0; + function sendEncryptedMsg(pushSub, message) { + return webPushEncrypt(pushSub, message) + .then((encryptedData) => { + mockSocket.serverSendMsg(JSON.stringify({ + messageType: "notification", + version: version++, + channelID, + data: encryptedData.data, + headers: { + encryption: encryptedData.encryption, + encryption_key: encryptedData.encryption_key, + encoding: encryptedData.encoding, + }, + })); + }); + } + + function waitForMessage(pushSub, message) { + return Promise.all([ + controlledFrame.waitOnWorkerMessage("finished"), + sendEncryptedMsg(pushSub, message), + ]).then(([msg]) => msg); + } + + add_task(async function sendPushMessageFromPage() { + var typedArray = new Uint8Array([226, 130, 40, 240, 40, 140, 188]); + var json = { hello: "world" }; + + var message = await waitForMessage(pushSubscription, "Text message from page"); + is(message.data.text, "Text message from page", "Wrong text message data"); + + message = await waitForMessage( + pushSubscription, + typedArray + ); + isDeeply(new Uint8Array(message.data.arrayBuffer), typedArray, + "Wrong array buffer message data"); + + message = await waitForMessage( + pushSubscription, + JSON.stringify(json) + ); + ok(message.data.json.ok, "Unexpected error parsing JSON"); + isDeeply(message.data.json.value, json, "Wrong JSON message data"); + + message = await waitForMessage( + pushSubscription, + "" + ); + ok(message, "Should include data for empty messages"); + is(message.data.text, "", "Wrong text for empty message"); + is(message.data.arrayBuffer.byteLength, 0, "Wrong buffer length for empty message"); + ok(!message.data.json.ok, "Expected JSON parse error for empty message"); + + message = await waitForMessage( + pushSubscription, + new Uint8Array([0x48, 0x69, 0x21, 0x20, 0xf0, 0x9f, 0x91, 0x80]) + ); + is(message.data.text, "Hi! \ud83d\udc40", "Wrong text for message with emoji"); + var text = await new Promise((resolve, reject) => { + var reader = new FileReader(); + reader.onloadend = event => { + if (reader.error) { + reject(reader.error); + } else { + resolve(reader.result); + } + }; + reader.readAsText(message.data.blob); + }); + is(text, "Hi! \ud83d\udc40", "Wrong blob data for message with emoji"); + + var finishedPromise = controlledFrame.waitOnWorkerMessage("finished"); + // Send a blank message. + mockSocket.serverSendMsg(JSON.stringify({ + messageType: "notification", + version: "vDummy", + channelID, + })); + + var msg = await finishedPromise; + ok(!msg.data, "Should exclude data for blank messages"); + }); + + add_task(async function unsubscribe() { + controlledFrame.remove(); + await pushSubscription.unsubscribe(); + }); + + add_task(async function unregister() { + await registration.unregister(); + }); + +</script> +</body> +</html> diff --git a/dom/push/test/test_error_reporting.html b/dom/push/test/test_error_reporting.html new file mode 100644 index 0000000000..b76a9dff24 --- /dev/null +++ b/dom/push/test/test_error_reporting.html @@ -0,0 +1,112 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1246341: Report message delivery failures to the Push server. + +Any copyright is dedicated to the Public Domain. +http://creativecommons.org/licenses/publicdomain/ + +--> +<head> + <title>Test for Bug 1246341</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <meta http-equiv="Content-type" content="text/html;charset=UTF-8"> +</head> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1246341">Mozilla Bug 1246341</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> + +<script class="testbody" type="text/javascript"> + var pushNotifier = SpecialPowers.Cc["@mozilla.org/push/Notifier;1"] + .getService(SpecialPowers.Ci.nsIPushNotifier); + + var reporters = new Map(); + + var registration; + add_task(async function start() { + await setupPrefsAndReplaceService({ + reportDeliveryError(messageId, reason) { + ok(reporters.has(messageId), + "Unexpected error reported for message " + messageId); + var resolve = reporters.get(messageId); + reporters.delete(messageId); + resolve(reason); + }, + }); + await setPushPermission(true); + + var url = "error_worker.js?" + (Math.random()); + registration = await navigator.serviceWorker.register(url, {scope: "."}); + await waitForActive(registration); + }); + + var controlledFrame; + add_task(async function createControlledIFrame() { + controlledFrame = await injectControlledFrame(); + }); + + var idCounter = 1; + function waitForDeliveryError(request) { + return new Promise(resolve => { + var data = new TextEncoder("utf-8").encode(JSON.stringify(request)); + var principal = SpecialPowers.wrap(document).nodePrincipal; + + let messageId = "message-" + (idCounter++); + reporters.set(messageId, resolve); + pushNotifier.notifyPushWithData(registration.scope, principal, messageId, + data); + }); + } + + add_task(async function reportDeliveryErrors() { + var reason = await waitForDeliveryError({ type: "exception" }); + is(reason, SpecialPowers.Ci.nsIPushErrorReporter.DELIVERY_UNCAUGHT_EXCEPTION, + "Should report uncaught exceptions"); + + reason = await waitForDeliveryError({ type: "rejection" }); + is(reason, SpecialPowers.Ci.nsIPushErrorReporter.DELIVERY_UNHANDLED_REJECTION, + "Should report unhandled rejections"); + }); + + add_task(async function reportDecryptionError() { + var message = await new Promise(resolve => { + SpecialPowers.registerConsoleListener(msg => { + if (!msg.isScriptError && !msg.isConsoleEvent) { + return; + } + const scope = "http://mochi.test:8888/tests/dom/push/test/"; + if (msg.innerWindowID === "ServiceWorker" && + msg.windowID === scope) { + SpecialPowers.postConsoleSentinel(); + resolve(msg); + } + }); + + var principal = SpecialPowers.wrap(document).nodePrincipal; + pushNotifier.notifyError(registration.scope, principal, "Push error", + SpecialPowers.Ci.nsIScriptError.errorFlag); + }); + + is(message.sourceName, registration.scope, + "Should use the qualified scope URL as the source"); + is(message.errorMessage, "Push error", + "Should report the given error string"); + }); + + add_task(async function unsubscribe() { + controlledFrame.remove(); + }); + + add_task(async function unregister() { + await registration.unregister(); + }); + +</script> +</body> +</html> diff --git a/dom/push/test/test_has_permissions.html b/dom/push/test/test_has_permissions.html new file mode 100644 index 0000000000..0bef8fe19f --- /dev/null +++ b/dom/push/test/test_has_permissions.html @@ -0,0 +1,53 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1038811: Push tests. + +Any copyright is dedicated to the Public Domain. +http://creativecommons.org/licenses/publicdomain/ + +--> +<head> + <title>Test for Bug 1038811</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <meta http-equiv="Content-type" content="text/html;charset=UTF-8"> +</head> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1038811">Mozilla Bug 1038811</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> + +<script class="testbody" type="text/javascript"> + function debug(str) { + // console.log(str + "\n"); + } + + var registration; + + add_task(async function start() { + await setupPrefsAndMockSocket(new MockWebSocket()); + + var url = "worker.js?" + Math.random(); + registration = await navigator.serviceWorker.register(url, {scope: "."}); + await waitForActive(registration); + }); + + add_task(async function hasPermission() { + var state = await registration.pushManager.permissionState(); + debug("state: " + state); + ok(["granted", "denied", "prompt"].includes(state), "permissionState() returned a valid state."); + }); + + add_task(async function unregister() { + var result = await registration.unregister(); + ok(result, "Unregister should return true."); + }); + +</script> +</body> +</html> diff --git a/dom/push/test/test_multiple_register.html b/dom/push/test/test_multiple_register.html new file mode 100644 index 0000000000..20a5c5bf9a --- /dev/null +++ b/dom/push/test/test_multiple_register.html @@ -0,0 +1,131 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1038811: Push tests. + +Any copyright is dedicated to the Public Domain. +http://creativecommons.org/licenses/publicdomain/ + +--> +<head> + <title>Test for Bug 1038811</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <meta http-equiv="Content-type" content="text/html;charset=UTF-8"> +</head> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1038811">Mozilla Bug 1038811</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> + +<script class="testbody" type="text/javascript"> + function debug(str) { + // console.log(str + "\n"); + } + + var registration; + + function start() { + return navigator.serviceWorker.register("worker.js?" + (Math.random()), {scope: "."}) + .then((swr) => { + registration = swr; + return waitForActive(registration); + }); + } + + function unregister() { + return registration.unregister().then(function(result) { + ok(result, "Unregister should return true."); + }, function(e) { + dump("Unregistering the SW failed with " + e + "\n"); + }); + } + + function setupPushNotification(swr) { + var p = new Promise(function(res, rej) { + swr.pushManager.subscribe().then( + function(pushSubscription) { + ok(true, "successful registered for push notification"); + res({swr, pushSubscription}); + }, function(error) { + ok(false, "could not register for push notification"); + res(null); + } + ); + }); + return p; + } + + function setupSecondEndpoint(result) { + var p = new Promise(function(res, rej) { + result.swr.pushManager.subscribe().then( + function(pushSubscription) { + ok(result.pushSubscription.endpoint == pushSubscription.endpoint, "setupSecondEndpoint - Got the same endpoint back."); + res(result); + }, function(error) { + ok(false, "could not register for push notification"); + res(null); + } + ); + }); + return p; + } + + function getEndpointExpectNull(swr) { + var p = new Promise(function(res, rej) { + swr.pushManager.getSubscription().then( + function(pushSubscription) { + ok(pushSubscription == null, "getEndpoint should return null when app not subscribed."); + res(swr); + }, function(error) { + ok(false, "could not register for push notification"); + res(null); + } + ); + }); + return p; + } + + function getEndpoint(result) { + var p = new Promise(function(res, rej) { + result.swr.pushManager.getSubscription().then( + function(pushSubscription) { + ok(result.pushSubscription.endpoint == pushSubscription.endpoint, "getEndpoint - Got the same endpoint back."); + + res(pushSubscription); + }, function(error) { + ok(false, "could not register for push notification"); + res(null); + } + ); + }); + return p; + } + + function unregisterPushNotification(pushSubscription) { + return pushSubscription.unsubscribe(); + } + + function runTest() { + start() + .then(getEndpointExpectNull) + .then(setupPushNotification) + .then(setupSecondEndpoint) + .then(getEndpoint) + .then(unregisterPushNotification) + .then(unregister) + .catch(function(e) { + ok(false, "Some test failed with error " + e); + }).then(SimpleTest.finish); + } + + setupPrefsAndMockSocket(new MockWebSocket()).then(_ => runTest()); + SpecialPowers.addPermission("desktop-notification", true, document); + SimpleTest.waitForExplicitFinish(); +</script> +</body> +</html> diff --git a/dom/push/test/test_multiple_register_different_scope.html b/dom/push/test/test_multiple_register_different_scope.html new file mode 100644 index 0000000000..75435a1dfc --- /dev/null +++ b/dom/push/test/test_multiple_register_different_scope.html @@ -0,0 +1,122 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1150812: Test registering for two different scopes. + +Any copyright is dedicated to the Public Domain. +http://creativecommons.org/licenses/publicdomain/ + +--> +<head> + <title>Test for Bug 1150812</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <meta http-equiv="Content-type" content="text/html;charset=UTF-8"> +</head> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1150812">Mozilla Bug 1150812</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> + +<script class="testbody" type="text/javascript"> + var scopeA = "./a/"; + var scopeB = "./b/"; + + function debug(str) { + // console.log(str + "\n"); + } + + function registerServiceWorker(scope) { + return navigator.serviceWorker.register("worker.js?" + (Math.random()), {scope}) + .then(swr => waitForActive(swr)); + } + + function unregister(swr) { + return swr.unregister() + .then(result => { + ok(result, "Unregister should return true."); + }, err => { + ok(false, "Unregistering the SW failed with " + err + "\n"); + throw err; + }); + } + + function subscribe(swr) { + return swr.pushManager.subscribe() + .then(sub => { + ok(true, "successful registered for push notification"); + return sub; + }, err => { + ok(false, "could not register for push notification"); + throw err; + }); + } + + + function setupMultipleSubscriptions(swr1, swr2) { + return Promise.all([ + subscribe(swr1), + subscribe(swr2), + ]).then(a => { + ok(a[0].endpoint != a[1].endpoint, "setupMultipleSubscriptions - Got different endpoints."); + return a; + }); + } + + function getEndpointExpectNull(swr) { + return swr.pushManager.getSubscription() + .then(pushSubscription => { + ok(pushSubscription == null, "getEndpoint should return null when app not subscribed."); + }, err => { + ok(false, "could not register for push notification"); + throw err; + }); + } + + function getEndpoint(swr, results) { + return swr.pushManager.getSubscription() + .then(sub => { + ok((results[0].endpoint == sub.endpoint) || + (results[1].endpoint == sub.endpoint), "getEndpoint - Got the same endpoint back."); + return results; + }, err => { + ok(false, "could not register for push notification"); + throw err; + }); + } + + function unsubscribe(result) { + return result[0].unsubscribe() + .then(_ => result[1].unsubscribe()); + } + + function runTest() { + registerServiceWorker(scopeA) + .then(swrA => + registerServiceWorker(scopeB) + .then(swrB => + getEndpointExpectNull(swrA) + .then(_ => getEndpointExpectNull(swrB)) + .then(_ => setupMultipleSubscriptions(swrA, swrB)) + .then(results => getEndpoint(swrA, results)) + .then(results => getEndpoint(swrB, results)) + .then(results => unsubscribe(results)) + .then(_ => unregister(swrA)) + .then(_ => unregister(swrB)) + ) + ) + .catch(err => { + ok(false, "Some test failed with error " + err); + }).then(SimpleTest.finish); + } + + setupPrefsAndMockSocket(new MockWebSocket()).then(_ => runTest()); + SpecialPowers.addPermission("desktop-notification", true, document); + SimpleTest.waitForExplicitFinish(); +</script> +</body> +</html> diff --git a/dom/push/test/test_multiple_register_during_service_activation.html b/dom/push/test/test_multiple_register_during_service_activation.html new file mode 100644 index 0000000000..be043a523e --- /dev/null +++ b/dom/push/test/test_multiple_register_during_service_activation.html @@ -0,0 +1,110 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1150812: If service is in activating or no connection state it can not send +request immediately, but the requests are queued. This test test the case of +multiple subscription for the same scope during activation. + +Any copyright is dedicated to the Public Domain. +http://creativecommons.org/licenses/publicdomain/ + +--> +<head> + <title>Test for Bug 1150812</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <meta http-equiv="Content-type" content="text/html;charset=UTF-8"> +</head> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1150812">Mozilla Bug 1150812</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> + +<script class="testbody" type="text/javascript"> + function debug(str) { + // console.log(str + "\n"); + } + + function registerServiceWorker() { + return navigator.serviceWorker.register("worker.js?" + (Math.random()), {scope: "."}); + } + + function unregister(swr) { + return swr.unregister() + .then(result => { + ok(result, "Unregister should return true."); + }, err => { + dump("Unregistering the SW failed with " + err + "\n"); + throw err; + }); + } + + function subscribe(swr) { + return swr.pushManager.subscribe() + .then(sub => { + ok(true, "successful registered for push notification"); + return sub; + }, err => { + ok(false, "could not register for push notification"); + throw err; + }); + } + + function setupMultipleSubscriptions(swr) { + // We need to do this to restart service so that a queue will be formed. + let promiseTeardown = teardownMockPushSocket(); + setupMockPushSocket(new MockWebSocket()); + + var pushSubscription; + return Promise.all([ + subscribe(swr), + subscribe(swr), + ]).then(a => { + ok(a[0].endpoint == a[1].endpoint, "setupMultipleSubscriptions - Got the same endpoint back."); + pushSubscription = a[0]; + return promiseTeardown; + }).then(_ => { + return pushSubscription; + }, err => { + ok(false, "could not register for push notification"); + throw err; + }); + } + + function getEndpointExpectNull(swr) { + return swr.pushManager.getSubscription() + .then(pushSubscription => { + ok(pushSubscription == null, "getEndpoint should return null when app not subscribed."); + }, err => { + ok(false, "could not register for push notification"); + throw err; + }); + } + + function unsubscribe(sub) { + return sub.unsubscribe(); + } + + function runTest() { + registerServiceWorker() + .then(swr => + getEndpointExpectNull(swr) + .then(_ => setupMultipleSubscriptions(swr)) + .then(sub => unsubscribe(sub)) + .then(_ => unregister(swr)) + ) + .catch(err => { + ok(false, "Some test failed with error " + err); + }).then(SimpleTest.finish); + } + + setupPrefsAndMockSocket(new MockWebSocket()).then(_ => runTest()); + SpecialPowers.addPermission("desktop-notification", true, document); + SimpleTest.waitForExplicitFinish(); +</script> +</body> +</html> diff --git a/dom/push/test/test_permissions.html b/dom/push/test/test_permissions.html new file mode 100644 index 0000000000..442cfe4a09 --- /dev/null +++ b/dom/push/test/test_permissions.html @@ -0,0 +1,104 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1038811: Push tests. + +Any copyright is dedicated to the Public Domain. +http://creativecommons.org/licenses/publicdomain/ + +--> +<head> + <title>Test for Bug 1038811</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <meta http-equiv="Content-type" content="text/html;charset=UTF-8"> +</head> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1038811">Mozilla Bug 1038811</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> + +<script class="testbody" type="text/javascript"> + function debug(str) { + // console.log(str + "\n"); + } + + var registration; + add_task(async function start() { + await setupPrefsAndMockSocket(new MockWebSocket()); + await setPushPermission(false); + + var url = "worker.js?" + Math.random(); + registration = await navigator.serviceWorker.register(url, {scope: "."}); + await waitForActive(registration); + }); + + add_task(async function denySubscribe() { + try { + await registration.pushManager.subscribe(); + ok(false, "subscribe() should fail because no permission for push"); + } catch (error) { + ok(error instanceof DOMException, "Wrong exception type"); + is(error.name, "NotAllowedError", "Wrong exception name"); + } + }); + + add_task(async function denySubscribeInWorker() { + // If permission is revoked, `getSubscription()` should return `null`, and + // `subscribe()` should reject immediately. Calling these from the worker + // should not deadlock the main thread (see bug 1228723). + var errorInfo = await sendRequestToWorker({ + type: "denySubscribe", + }); + ok(errorInfo.isDOMException, "Wrong exception type"); + is(errorInfo.name, "NotAllowedError", "Wrong exception name"); + }); + + add_task(async function getEndpoint() { + var pushSubscription = await registration.pushManager.getSubscription(); + is(pushSubscription, null, "getSubscription() should return null because no permission for push"); + }); + + add_task(async function checkPermissionState() { + var permissionManager = SpecialPowers.Ci.nsIPermissionManager; + var tests = [{ + action: permissionManager.ALLOW_ACTION, + state: "granted", + }, { + action: permissionManager.DENY_ACTION, + state: "denied", + }, { + action: permissionManager.PROMPT_ACTION, + state: "prompt", + }, { + action: permissionManager.UNKNOWN_ACTION, + state: "prompt", + }]; + for (var test of tests) { + await setPushPermission(test.action); + var state = await registration.pushManager.permissionState(); + is(state, test.state, JSON.stringify(test)); + try { + await SpecialPowers.pushPrefEnv({ set: [ + ["dom.push.testing.ignorePermission", true]] }); + state = await registration.pushManager.permissionState(); + is(state, "granted", `Should ignore ${ + test.action} if the override pref is set`); + } finally { + await SpecialPowers.flushPrefEnv(); + } + } + }); + + add_task(async function unregister() { + var result = await registration.unregister(); + ok(result, "Unregister should return true."); + }); + +</script> +</body> +</html> diff --git a/dom/push/test/test_register.html b/dom/push/test/test_register.html new file mode 100644 index 0000000000..541e4a2d8d --- /dev/null +++ b/dom/push/test/test_register.html @@ -0,0 +1,107 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1038811: Push tests. + +Any copyright is dedicated to the Public Domain. +http://creativecommons.org/licenses/publicdomain/ + +--> +<head> + <title>Test for Bug 1038811</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <meta http-equiv="Content-type" content="text/html;charset=UTF-8"> +</head> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1038811">Mozilla Bug 1038811</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> + +<script class="testbody" type="text/javascript"> + function debug(str) { + // console.log(str + "\n"); + } + + var mockSocket = new MockWebSocket(); + + var channelID = null; + + mockSocket.onRegister = function(request) { + channelID = request.channelID; + this.serverSendMsg(JSON.stringify({ + messageType: "register", + uaid: "c69e2014-9e15-438d-b253-d79cc2df60a8", + channelID, + status: 200, + pushEndpoint: "https://example.com/endpoint/1", + })); + }; + + var registration; + add_task(async function start() { + await setupPrefsAndMockSocket(mockSocket); + await setPushPermission(true); + + var url = "worker.js?" + (Math.random()); + registration = await navigator.serviceWorker.register(url, {scope: "."}); + await waitForActive(registration); + }); + + var controlledFrame; + add_task(async function createControlledIFrame() { + controlledFrame = await injectControlledFrame(); + }); + + add_task(async function checkPermissionState() { + var state = await registration.pushManager.permissionState(); + is(state, "granted", "permissionState() should resolve to granted."); + }); + + var pushSubscription; + add_task(async function subscribe() { + pushSubscription = await registration.pushManager.subscribe(); + is(pushSubscription.options.applicationServerKey, null, + "Subscription should not have an app server key"); + }); + + add_task(async function resubscribe() { + var data = await sendRequestToWorker({ + type: "resubscribe", + endpoint: pushSubscription.endpoint, + }); + pushSubscription = await registration.pushManager.getSubscription(); + is(data.endpoint, pushSubscription.endpoint, + "Subscription endpoints should match after resubscribing in worker"); + }); + + add_task(async function waitForPushNotification() { + var finishedPromise = controlledFrame.waitOnWorkerMessage("finished"); + + // Send a blank message. + mockSocket.serverSendMsg(JSON.stringify({ + messageType: "notification", + version: "vDummy", + channelID, + })); + + await finishedPromise; + }); + + add_task(async function unsubscribe() { + controlledFrame.remove(); + await pushSubscription.unsubscribe(); + }); + + add_task(async function unregister() { + var result = await registration.unregister(); + ok(result, "Unregister should return true."); + }); + +</script> +</body> +</html> diff --git a/dom/push/test/test_register_key.html b/dom/push/test/test_register_key.html new file mode 100644 index 0000000000..c151d110ed --- /dev/null +++ b/dom/push/test/test_register_key.html @@ -0,0 +1,308 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1247685: Implement `applicationServerKey` for subscription association. + +Any copyright is dedicated to the Public Domain. +http://creativecommons.org/licenses/publicdomain/ + +--> +<head> + <title>Test for Bug 1247685</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <meta http-equiv="Content-type" content="text/html;charset=UTF-8"> +</head> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1247685">Mozilla Bug 1247685</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> + +<script class="testbody" type="text/javascript"> + var isTestingMismatchedKey = false; + var subscriptions = 0; + var testKey; // Generated in `start`. + + 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)); + } + + var registration; + add_task(async function start() { + await setupPrefsAndReplaceService({ + register(pageRecord) { + ok(pageRecord.appServerKey.length > 0, + "App server key should not be empty"); + if (pageRecord.appServerKey.length != 65) { + // eslint-disable-next-line no-throw-literal + throw { result: + SpecialPowers.Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR }; + } + if (isTestingMismatchedKey) { + // eslint-disable-next-line no-throw-literal + throw { result: + SpecialPowers.Cr.NS_ERROR_DOM_PUSH_MISMATCHED_KEY_ERR }; + } + return { + endpoint: "https://example.com/push/" + (++subscriptions), + appServerKey: pageRecord.appServerKey, + }; + }, + + registration(pageRecord) { + return { + endpoint: "https://example.com/push/subWithKey", + appServerKey: testKey, + }; + }, + }); + await setPushPermission(true); + testKey = await generateKey(); + + var url = "worker.js?" + (Math.random()); + registration = await navigator.serviceWorker.register(url, {scope: "."}); + await waitForActive(registration); + }); + + var controlledFrame; + add_task(async function createControlledIFrame() { + controlledFrame = await injectControlledFrame(); + }); + + add_task(async function emptyKey() { + try { + await registration.pushManager.subscribe({ + applicationServerKey: new ArrayBuffer(0), + }); + ok(false, "Should reject for empty app server keys"); + } catch (error) { + ok(error instanceof DOMException, + "Wrong exception type for empty key"); + is(error.name, "InvalidAccessError", + "Wrong exception name for empty key"); + } + }); + + add_task(async function invalidKey() { + try { + await registration.pushManager.subscribe({ + applicationServerKey: new Uint8Array([0]), + }); + ok(false, "Should reject for invalid app server keys"); + } catch (error) { + ok(error instanceof DOMException, + "Wrong exception type for invalid key"); + is(error.name, "InvalidAccessError", + "Wrong exception name for invalid key"); + } + }); + + add_task(async function validKey() { + var pushSubscription = await registration.pushManager.subscribe({ + applicationServerKey: await generateKey(), + }); + is(pushSubscription.endpoint, "https://example.com/push/1", + "Wrong endpoint for subscription with key"); + is(pushSubscription.options.applicationServerKey, + pushSubscription.options.applicationServerKey, + "App server key getter should return the same object"); + }); + + add_task(async function retrieveKey() { + var pushSubscription = await registration.pushManager.getSubscription(); + is(pushSubscription.endpoint, "https://example.com/push/subWithKey", + "Got wrong endpoint for subscription with key"); + isDeeply( + new Uint8Array(pushSubscription.options.applicationServerKey), + testKey, + "Got wrong app server key" + ); + }); + + add_task(async function mismatchedKey() { + isTestingMismatchedKey = true; + try { + await registration.pushManager.subscribe({ + applicationServerKey: await generateKey(), + }); + ok(false, "Should reject for mismatched app server keys"); + } catch (error) { + ok(error instanceof DOMException, + "Wrong exception type for mismatched key"); + is(error.name, "InvalidStateError", + "Wrong exception name for mismatched key"); + } finally { + isTestingMismatchedKey = false; + } + }); + + add_task(async function emptyKeyInWorker() { + var errorInfo = await sendRequestToWorker({ + type: "subscribeWithKey", + key: new ArrayBuffer(0), + }); + ok(errorInfo.isDOMException, + "Wrong exception type in worker for empty key"); + is(errorInfo.name, "InvalidAccessError", + "Wrong exception name in worker for empty key"); + }); + + add_task(async function invalidKeyInWorker() { + var errorInfo = await sendRequestToWorker({ + type: "subscribeWithKey", + key: new Uint8Array([1]), + }); + ok(errorInfo.isDOMException, + "Wrong exception type in worker for invalid key"); + is(errorInfo.name, "InvalidAccessError", + "Wrong exception name in worker for invalid key"); + }); + + add_task(async function validKeyInWorker() { + var key = await generateKey(); + var data = await sendRequestToWorker({ + type: "subscribeWithKey", + key, + }); + is(data.endpoint, "https://example.com/push/2", + "Wrong endpoint for subscription with key created in worker"); + isDeeply(new Uint8Array(data.key), key, + "Wrong app server key for subscription created in worker"); + }); + + add_task(async function mismatchedKeyInWorker() { + isTestingMismatchedKey = true; + try { + var errorInfo = await sendRequestToWorker({ + type: "subscribeWithKey", + key: await generateKey(), + }); + ok(errorInfo.isDOMException, + "Wrong exception type in worker for mismatched key"); + is(errorInfo.name, "InvalidStateError", + "Wrong exception name in worker for mismatched key"); + } finally { + isTestingMismatchedKey = false; + } + }); + + add_task(async function validKeyBuffer() { + var key = await generateKey(); + var pushSubscription = await registration.pushManager.subscribe({ + applicationServerKey: key.buffer, + }); + is(pushSubscription.endpoint, "https://example.com/push/3", + "Wrong endpoint for subscription created with key buffer"); + var subscriptionKey = pushSubscription.options.applicationServerKey; + isDeeply(new Uint8Array(subscriptionKey), key, + "App server key getter should match given key"); + }); + + add_task(async function validKeyBufferInWorker() { + var key = await generateKey(); + var data = await sendRequestToWorker({ + type: "subscribeWithKey", + key: key.buffer, + }); + is(data.endpoint, "https://example.com/push/4", + "Wrong endpoint for subscription with key buffer created in worker"); + isDeeply(new Uint8Array(data.key), key, + "App server key getter should match given key for subscription created in worker"); + }); + + add_task(async function validKeyString() { + var base64Key = "BOp8kf30nj6mKFFSPw_w3JAMS99Bac8zneMJ6B6lmKixUO5XTf4AtdPgYUgWke-XE25JHdcooyLgJML1R57jhKY"; + var key = base64UrlDecode(base64Key); + var pushSubscription = await registration.pushManager.subscribe({ + applicationServerKey: base64Key, + }); + is(pushSubscription.endpoint, "https://example.com/push/5", + "Wrong endpoint for subscription created with Base64-encoded key"); + isDeeply(new Uint8Array(pushSubscription.options.applicationServerKey), key, + "App server key getter should match Base64-decoded key"); + }); + + add_task(async function validKeyStringInWorker() { + var base64Key = "BOp8kf30nj6mKFFSPw_w3JAMS99Bac8zneMJ6B6lmKixUO5XTf4AtdPgYUgWke-XE25JHdcooyLgJML1R57jhKY"; + var key = base64UrlDecode(base64Key); + var data = await sendRequestToWorker({ + type: "subscribeWithKey", + key: base64Key, + }); + is(data.endpoint, "https://example.com/push/6", + "Wrong endpoint for subscription created with Base64-encoded key in worker"); + isDeeply(new Uint8Array(data.key), key, + "App server key getter should match decoded key for subscription created in worker"); + }); + + add_task(async function invalidKeyString() { + try { + await registration.pushManager.subscribe({ + applicationServerKey: "!@#$^&*", + }); + ok(false, "Should reject for invalid Base64-encoded keys"); + } catch (error) { + ok(error instanceof DOMException, + "Wrong exception type for invalid Base64-encoded key"); + is(error.name, "InvalidCharacterError", + "Wrong exception name for invalid Base64-encoded key"); + } + }); + + add_task(async function invalidKeyStringInWorker() { + var errorInfo = await sendRequestToWorker({ + type: "subscribeWithKey", + key: "!@#$^&*", + }); + ok(errorInfo.isDOMException, + "Wrong exception type in worker for invalid Base64-encoded key"); + is(errorInfo.name, "InvalidCharacterError", + "Wrong exception name in worker for invalid Base64-encoded key"); + }); + + add_task(async function emptyKeyString() { + try { + await registration.pushManager.subscribe({ + applicationServerKey: "", + }); + ok(false, "Should reject for empty key strings"); + } catch (error) { + ok(error instanceof DOMException, + "Wrong exception type for empty key string"); + is(error.name, "InvalidAccessError", + "Wrong exception name for empty key string"); + } + }); + + add_task(async function emptyKeyStringInWorker() { + var errorInfo = await sendRequestToWorker({ + type: "subscribeWithKey", + key: "", + }); + ok(errorInfo.isDOMException, + "Wrong exception type in worker for empty key string"); + is(errorInfo.name, "InvalidAccessError", + "Wrong exception name in worker for empty key string"); + }); + + add_task(async function unsubscribe() { + is(subscriptions, 6, "Wrong subscription count"); + controlledFrame.remove(); + }); + + add_task(async function unregister() { + await registration.unregister(); + }); + +</script> +</body> +</html> diff --git a/dom/push/test/test_serviceworker_lifetime.html b/dom/push/test/test_serviceworker_lifetime.html new file mode 100644 index 0000000000..6e837ad5d7 --- /dev/null +++ b/dom/push/test/test_serviceworker_lifetime.html @@ -0,0 +1,367 @@ +<!DOCTYPE HTML> +<html> +<!-- + Test the lifetime management of service workers. We keep this test in + dom/push/tests to pass the external network check when connecting to + the mozilla push service. + + How this test works: + - the service worker maintains a state variable and a promise used for + extending its lifetime. Note that the terminating the worker will reset + these variables to their default values. + - we send 3 types of requests to the service worker: + |update|, |wait| and |release|. All three requests will cause the sw to update + its state to the new value and reply with a message containing + its previous state. Furthermore, |wait| will set a waitUntil or a respondWith + promise that's not resolved until the next |release| message. + - Each subtest will use a combination of values for the timeouts and check + if the service worker is in the correct state as we send it different + events. + - We also wait and assert for service worker termination using an event dispatched + through nsIObserverService. + --> +<head> + <title>Test for Bug 1188545</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <meta http-equiv="Content-type" content="text/html;charset=UTF-8"> +</head> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1188545">Mozilla Bug 118845</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> + +<script class="testbody" type="text/javascript"> + function start() { + return navigator.serviceWorker.register("lifetime_worker.js", {scope: "./"}) + .then((swr) => ({registration: swr})); + } + + function waitForActiveServiceWorker(ctx) { + return waitForActive(ctx.registration).then(function(result) { + ok(ctx.registration.active, "Service Worker is active"); + return ctx; + }); + } + + function unregister(ctx) { + return ctx.registration.unregister().then(function(result) { + ok(result, "Unregister should return true."); + }, function(e) { + dump("Unregistering the SW failed with " + e + "\n"); + }); + } + + function registerPushNotification(ctx) { + var p = new Promise(function(res, rej) { + ctx.registration.pushManager.subscribe().then( + function(pushSubscription) { + ok(true, "successful registered for push notification"); + ctx.subscription = pushSubscription; + res(ctx); + }, function(error) { + ok(false, "could not register for push notification"); + res(ctx); + }); + }); + return p; + } + + var mockSocket = new MockWebSocket(); + var endpoint = "https://example.com/endpoint/1"; + var channelID = null; + mockSocket.onRegister = function(request) { + channelID = request.channelID; + this.serverSendMsg(JSON.stringify({ + messageType: "register", + uaid: "fa8f2e4b-5ddc-4408-b1e3-5f25a02abff0", + channelID, + status: 200, + pushEndpoint: endpoint, + })); + }; + + function sendPushToPushServer(pushEndpoint) { + is(pushEndpoint, endpoint, "Got unexpected endpoint"); + mockSocket.serverSendMsg(JSON.stringify({ + messageType: "notification", + version: "vDummy", + channelID, + })); + } + + function unregisterPushNotification(ctx) { + return ctx.subscription.unsubscribe().then(function(result) { + ok(result, "unsubscribe should succeed."); + ctx.subscription = null; + return ctx; + }); + } + + function createIframe(ctx) { + var p = new Promise(function(res, rej) { + var iframe = document.createElement("iframe"); + // This file doesn't exist, the service worker will give us an empty + // document. + iframe.src = "http://mochi.test:8888/tests/dom/push/test/lifetime_frame.html"; + + iframe.onload = function() { + ctx.iframe = iframe; + res(ctx); + }; + document.body.appendChild(iframe); + }); + return p; + } + + function closeIframe(ctx) { + ctx.iframe.remove(); + return new Promise(function(res, rej) { + // XXXcatalinb: give the worker more time to "notice" it stopped + // controlling documents + ctx.iframe = null; + setTimeout(res, 0); + }).then(() => ctx); + } + + function waitAndCheckMessage(contentWindow, expected) { + function checkMessage({ type, state }, resolve, event) { + ok(event.data.type == type, "Received correct message type: " + type); + ok(event.data.state == state, "Service worker is in the correct state: " + state); + this.navigator.serviceWorker.onmessage = null; + resolve(); + } + return new Promise(function(res, rej) { + contentWindow.navigator.serviceWorker.onmessage = + checkMessage.bind(contentWindow, expected, res); + }); + } + + function fetchEvent(ctx, expected_state, new_state) { + var expected = { type: "fetch", state: expected_state }; + var p = waitAndCheckMessage(ctx.iframe.contentWindow, expected); + ctx.iframe.contentWindow.fetch(new_state); + return p; + } + + function pushEvent(ctx, expected_state, new_state) { + var expected = {type: "push", state: expected_state}; + var p = waitAndCheckMessage(ctx.iframe.contentWindow, expected); + sendPushToPushServer(ctx.subscription.endpoint); + return p; + } + + function messageEventIframe(ctx, expected_state, new_state) { + var expected = {type: "message", state: expected_state}; + var p = waitAndCheckMessage(ctx.iframe.contentWindow, expected); + ctx.iframe.contentWindow.navigator.serviceWorker.controller.postMessage(new_state); + return p; + } + + function messageEvent(ctx, expected_state, new_state) { + var expected = {type: "message", state: expected_state}; + var p = waitAndCheckMessage(window, expected); + ctx.registration.active.postMessage(new_state); + return p; + } + + function checkStateAndUpdate(eventFunction, expected_state, new_state) { + return function(ctx) { + return eventFunction(ctx, expected_state, new_state) + .then(() => ctx); + }; + } + + let shutdownTopic = + SpecialPowers.getBoolPref("dom.serviceWorkers.parent_intercept", false) ? + "specialpowers-service-worker-shutdown" : + "service-worker-shutdown"; + SpecialPowers.registerObservers("service-worker-shutdown"); + + function setShutdownObserver(expectingEvent) { + info("Setting shutdown observer: expectingEvent=" + expectingEvent); + return function(ctx) { + cancelShutdownObserver(ctx); + + ctx.observer_promise = new Promise(function(res, rej) { + ctx.observer = { + observe(subject, topic, data) { + ok((topic == shutdownTopic) && expectingEvent, "Service worker was terminated."); + this.remove(ctx); + }, + remove(context) { + SpecialPowers.removeObserver(this, shutdownTopic); + context.observer = null; + res(context); + }, + }; + SpecialPowers.addObserver(ctx.observer, shutdownTopic); + }); + + return ctx; + }; + } + + function waitOnShutdownObserver(ctx) { + info("Waiting on worker to shutdown."); + return ctx.observer_promise; + } + + function cancelShutdownObserver(ctx) { + if (ctx.observer) { + ctx.observer.remove(ctx); + } + return ctx.observer_promise; + } + + function subTest(test) { + return function(ctx) { + return new Promise(function(res, rej) { + function run() { + test.steps(ctx).catch(function(e) { + ok(false, "Some test failed with error: " + e); + }).then(res); + } + + SpecialPowers.pushPrefEnv({"set": test.prefs}, run); + }); + }; + } + + var test1 = { + prefs: [ + ["dom.serviceWorkers.idle_timeout", 0], + ["dom.serviceWorkers.idle_extended_timeout", 2999999], + ], + // Test that service workers are terminated after the grace period expires + // when there are no pending waitUntil or respondWith promises. + steps(ctx) { + // Test with fetch events and respondWith promises + return createIframe(ctx) + .then(setShutdownObserver(true)) + .then(checkStateAndUpdate(fetchEvent, "from_scope", "update")) + .then(waitOnShutdownObserver) + .then(setShutdownObserver(false)) + .then(checkStateAndUpdate(fetchEvent, "from_scope", "wait")) + .then(checkStateAndUpdate(fetchEvent, "wait", "update")) + .then(checkStateAndUpdate(fetchEvent, "update", "update")) + .then(setShutdownObserver(true)) + // The service worker should be terminated when the promise is resolved. + .then(checkStateAndUpdate(fetchEvent, "update", "release")) + .then(waitOnShutdownObserver) + .then(setShutdownObserver(false)) + .then(closeIframe) + .then(cancelShutdownObserver) + + // Test with push events and message events + .then(setShutdownObserver(true)) + .then(createIframe) + // Make sure we are shutdown before entering our "no shutdown" sequence + // to avoid races. + .then(waitOnShutdownObserver) + .then(setShutdownObserver(false)) + .then(checkStateAndUpdate(pushEvent, "from_scope", "wait")) + .then(checkStateAndUpdate(messageEventIframe, "wait", "update")) + .then(checkStateAndUpdate(messageEventIframe, "update", "update")) + .then(setShutdownObserver(true)) + .then(checkStateAndUpdate(messageEventIframe, "update", "release")) + .then(waitOnShutdownObserver) + .then(closeIframe); + }, + }; + + var test2 = { + prefs: [ + ["dom.serviceWorkers.idle_timeout", 0], + ["dom.serviceWorkers.idle_extended_timeout", 2999999], + ], + steps(ctx) { + // Older versions used to terminate workers when the last controlled + // window was closed. This should no longer happen, though. Verify + // the new behavior. + setShutdownObserver(true)(ctx); + return createIframe(ctx) + // Make sure we are shutdown before entering our "no shutdown" sequence + // to avoid races. + .then(waitOnShutdownObserver) + .then(setShutdownObserver(false)) + .then(checkStateAndUpdate(fetchEvent, "from_scope", "wait")) + .then(closeIframe) + .then(setShutdownObserver(true)) + .then(checkStateAndUpdate(messageEvent, "wait", "release")) + .then(waitOnShutdownObserver) + + // Push workers were exempt from the old rule and should continue to + // survive past the closing of the last controlled window. + .then(setShutdownObserver(true)) + .then(createIframe) + // Make sure we are shutdown before entering our "no shutdown" sequence + // to avoid races. + .then(waitOnShutdownObserver) + .then(setShutdownObserver(false)) + .then(checkStateAndUpdate(pushEvent, "from_scope", "wait")) + .then(closeIframe) + .then(setShutdownObserver(true)) + .then(checkStateAndUpdate(messageEvent, "wait", "release")) + .then(waitOnShutdownObserver); + }, + }; + + var test3 = { + prefs: [ + ["dom.serviceWorkers.idle_timeout", 2999999], + ["dom.serviceWorkers.idle_extended_timeout", 0], + ], + steps(ctx) { + // set the grace period to 0 and dispatch a message which will reset + // the internal sw timer to the new value. + var test3_1 = { + prefs: [ + ["dom.serviceWorkers.idle_timeout", 0], + ["dom.serviceWorkers.idle_extended_timeout", 0], + ], + steps(context) { + return new Promise(function(res, rej) { + context.iframe.contentWindow.navigator.serviceWorker.controller.postMessage("ping"); + res(context); + }); + }, + }; + + // Test that service worker is closed when the extended timeout expired + return createIframe(ctx) + .then(setShutdownObserver(false)) + .then(checkStateAndUpdate(messageEvent, "from_scope", "update")) + .then(checkStateAndUpdate(messageEventIframe, "update", "update")) + .then(checkStateAndUpdate(fetchEvent, "update", "wait")) + .then(setShutdownObserver(true)) + .then(subTest(test3_1)) // This should cause the internal timer to expire. + .then(waitOnShutdownObserver) + .then(closeIframe); + }, + }; + + function runTest() { + start() + .then(waitForActiveServiceWorker) + .then(registerPushNotification) + .then(subTest(test1)) + .then(subTest(test2)) + .then(subTest(test3)) + .then(unregisterPushNotification) + .then(unregister) + .catch(function(e) { + ok(false, "Some test failed with error " + e); + }).then(SimpleTest.finish); + } + + setupPrefsAndMockSocket(mockSocket).then(_ => runTest()); + SpecialPowers.addPermission("desktop-notification", true, document); + SimpleTest.waitForExplicitFinish(); +</script> +</body> +</html> diff --git a/dom/push/test/test_subscription_change.html b/dom/push/test/test_subscription_change.html new file mode 100644 index 0000000000..6d8df58364 --- /dev/null +++ b/dom/push/test/test_subscription_change.html @@ -0,0 +1,67 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1205109: Make `pushsubscriptionchange` extendable. + +Any copyright is dedicated to the Public Domain. +http://creativecommons.org/licenses/publicdomain/ + +--> +<head> + <title>Test for Bug 1205109</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <meta http-equiv="Content-type" content="text/html;charset=UTF-8"> +</head> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1205109">Mozilla Bug 1205109</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> + +<script class="testbody" type="text/javascript"> + var registration; + add_task(async function start() { + await setupPrefsAndMockSocket(new MockWebSocket()); + await setPushPermission(true); + + var url = "worker.js?" + (Math.random()); + registration = await navigator.serviceWorker.register(url, {scope: "."}); + await waitForActive(registration); + }); + + var controlledFrame; + add_task(async function createControlledIFrame() { + controlledFrame = await injectControlledFrame(); + }); + + add_task(async function togglePermission() { + var subscription = await registration.pushManager.subscribe(); + ok(subscription, "Should create a push subscription"); + + await setPushPermission(false); + var permissionState = await registration.pushManager.permissionState(); + is(permissionState, "denied", "Should deny push permission"); + + subscription = await registration.pushManager.getSubscription(); + is(subscription, null, "Should not return subscription when permission is revoked"); + + var changePromise = controlledFrame.waitOnWorkerMessage("changed"); + await setPushPermission(true); + await changePromise; + + subscription = await registration.pushManager.getSubscription(); + is(subscription, null, "Should drop subscription after reinstating permission"); + }); + + add_task(async function unsubscribe() { + controlledFrame.remove(); + await registration.unregister(); + }); + +</script> +</body> +</html> diff --git a/dom/push/test/test_try_registering_offline_disabled.html b/dom/push/test/test_try_registering_offline_disabled.html new file mode 100644 index 0000000000..82ef2b8755 --- /dev/null +++ b/dom/push/test/test_try_registering_offline_disabled.html @@ -0,0 +1,307 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1150812: Try to register when serviced if offline or connection is disabled. + +Any copyright is dedicated to the Public Domain. +http://creativecommons.org/licenses/publicdomain/ + +--> +<head> + <title>Test for Bug 1150812</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <meta http-equiv="Content-type" content="text/html;charset=UTF-8"> +</head> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1150812">Mozilla Bug 1150812</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> + +<script class="testbody" type="text/javascript"> + /* eslint-env mozilla/frame-script */ + + function debug(str) { + // console.log(str + "\n"); + } + + function registerServiceWorker() { + return navigator.serviceWorker.register("worker.js?" + (Math.random()), {scope: "."}) + .then(swr => waitForActive(swr)); + } + + function unregister(swr) { + return swr.unregister() + .then(result => { + ok(result, "Unregister should return true."); + }, err => { + dump("Unregistering the SW failed with " + err + "\n"); + throw err; + }); + } + + function subscribe(swr) { + return swr.pushManager.subscribe() + .then(sub => { + ok(true, "successful registered for push notification"); + return sub; + }, err => { + ok(false, "could not register for push notification"); + throw err; + }); + } + + function subscribeFail(swr) { + return new Promise((res, rej) => { + swr.pushManager.subscribe() + .then(sub => { + ok(false, "successful registered for push notification"); + throw new Error("Should fail"); + }, err => { + ok(true, "could not register for push notification"); + res(swr); + }); + }); + } + + function getEndpointExpectNull(swr) { + return swr.pushManager.getSubscription() + .then(pushSubscription => { + ok(pushSubscription == null, "getEndpoint should return null when app not subscribed."); + }, err => { + ok(false, "could not register for push notification"); + throw err; + }); + } + + function getEndpoint(swr, subOld) { + return swr.pushManager.getSubscription() + .then(sub => { + ok(subOld.endpoint == sub.endpoint, "getEndpoint - Got the same endpoint back."); + return sub; + }, err => { + ok(false, "could not register for push notification"); + throw err; + }); + } + + // Load chrome script to change offline status in the + // parent process. + var offlineChromeScript = SpecialPowers.loadChromeScript(_ => { + addMessageListener("change-status", function(offline) { + // eslint-disable-next-line mozilla/use-services + const ioService = Cc["@mozilla.org/network/io-service;1"] + .getService(Ci.nsIIOService); + ioService.offline = offline; + }); + }); + + function offlineObserver(res) { + this._res = res; + } + offlineObserver.prototype = { + _res: null, + + observe(subject, topic, data) { + debug("observe: " + subject + " " + topic + " " + data); + if (topic === "network:offline-status-changed") { + // eslint-disable-next-line mozilla/use-services + const obsService = SpecialPowers.Cc["@mozilla.org/observer-service;1"] + .getService(SpecialPowers.Ci.nsIObserverService); + obsService.removeObserver(this, topic); + this._res(null); + } + }, + }; + + function changeOfflineState(offline) { + return new Promise(function(res, rej) { + // eslint-disable-next-line mozilla/use-services + const obsService = SpecialPowers.Cc["@mozilla.org/observer-service;1"] + .getService(SpecialPowers.Ci.nsIObserverService); + obsService.addObserver(SpecialPowers.wrapCallbackObject(new offlineObserver(res)), + "network:offline-status-changed"); + offlineChromeScript.sendAsyncMessage("change-status", offline); + }); + } + + function changePushServerConnectionEnabled(enable) { + debug("changePushServerConnectionEnabled"); + SpecialPowers.setBoolPref("dom.push.connection.enabled", enable); + } + + function unsubscribe(sub) { + return sub.unsubscribe() + .then(_ => { ok(true, "Unsubscribed!"); }); + } + + // go offline then go online + function runTest1() { + return registerServiceWorker() + .then(swr => + getEndpointExpectNull(swr) + .then(_ => changeOfflineState(true)) + .then(_ => subscribeFail(swr)) + .then(_ => getEndpointExpectNull(swr)) + .then(_ => changeOfflineState(false)) + .then(_ => subscribe(swr)) + .then(sub => getEndpoint(swr, sub) + .then(s => unsubscribe(s)) + ) + .then(_ => getEndpointExpectNull(swr)) + .then(_ => unregister(swr)) + ) + .catch(err => { + ok(false, "Some test failed with error " + err); + }); + } + + // disable - enable push connection. + function runTest2() { + return registerServiceWorker() + .then(swr => + getEndpointExpectNull(swr) + .then(_ => changePushServerConnectionEnabled(false)) + .then(_ => subscribeFail(swr)) + .then(_ => getEndpointExpectNull(swr)) + .then(_ => changePushServerConnectionEnabled(true)) + .then(_ => subscribe(swr)) + .then(sub => getEndpoint(swr, sub) + .then(s => unsubscribe(s)) + ) + .then(_ => getEndpointExpectNull(swr)) + .then(_ => unregister(swr)) + ) + .catch(err => { + ok(false, "Some test failed with error " + err); + }); + } + + // go offline - disable - enable - go online + function runTest3() { + return registerServiceWorker() + .then(swr => + getEndpointExpectNull(swr) + .then(_ => changeOfflineState(true)) + .then(_ => subscribeFail(swr)) + .then(_ => getEndpointExpectNull(swr)) + .then(_ => changePushServerConnectionEnabled(false)) + .then(_ => subscribeFail(swr)) + .then(_ => getEndpointExpectNull(swr)) + .then(_ => changePushServerConnectionEnabled(true)) + .then(_ => subscribeFail(swr)) + .then(_ => getEndpointExpectNull(swr)) + .then(_ => changeOfflineState(false)) + .then(_ => subscribe(swr)) + .then(sub => getEndpoint(swr, sub) + .then(s => unsubscribe(s)) + ) + .then(_ => getEndpointExpectNull(swr)) + .then(_ => unregister(swr)) + ) + .catch(err => { + ok(false, "Some test failed with error " + err); + }); + } + + // disable - offline - online - enable. + function runTest4() { + return registerServiceWorker() + .then(swr => + getEndpointExpectNull(swr) + .then(_ => changePushServerConnectionEnabled(false)) + .then(_ => subscribeFail(swr)) + .then(_ => getEndpointExpectNull(swr)) + .then(_ => changeOfflineState(true)) + .then(_ => subscribeFail(swr)) + .then(_ => getEndpointExpectNull(swr)) + .then(_ => changeOfflineState(false)) + .then(_ => subscribeFail(swr)) + .then(_ => getEndpointExpectNull(swr)) + .then(_ => changePushServerConnectionEnabled(true)) + .then(_ => subscribe(swr)) + .then(sub => getEndpoint(swr, sub) + .then(s => unsubscribe(s)) + ) + .then(_ => getEndpointExpectNull(swr)) + .then(_ => unregister(swr)) + ) + .catch(err => { + ok(false, "Some test failed with error " + err); + }); + } + + // go offline - disable - go online - enable + function runTest5() { + return registerServiceWorker() + .then(swr => + getEndpointExpectNull(swr) + .then(_ => changeOfflineState(true)) + .then(_ => subscribeFail(swr)) + .then(_ => getEndpointExpectNull(swr)) + .then(_ => changePushServerConnectionEnabled(false)) + .then(_ => subscribeFail(swr)) + .then(_ => getEndpointExpectNull(swr)) + .then(_ => changeOfflineState(false)) + .then(_ => subscribeFail(swr)) + .then(_ => getEndpointExpectNull(swr)) + .then(_ => changePushServerConnectionEnabled(true)) + .then(_ => subscribe(swr)) + .then(sub => getEndpoint(swr, sub) + .then(s => unsubscribe(s)) + ) + .then(_ => getEndpointExpectNull(swr)) + .then(_ => unregister(swr)) + ) + .catch(err => { + ok(false, "Some test failed with error " + err); + }); + } + + // disable - go offline - enable - go online. + function runTest6() { + return registerServiceWorker() + .then(swr => + getEndpointExpectNull(swr) + .then(_ => changePushServerConnectionEnabled(false)) + .then(_ => subscribeFail(swr)) + .then(_ => getEndpointExpectNull(swr)) + .then(_ => changeOfflineState(true)) + .then(_ => subscribeFail(swr)) + .then(_ => getEndpointExpectNull(swr)) + .then(_ => changePushServerConnectionEnabled(true)) + .then(_ => subscribeFail(swr)) + .then(_ => getEndpointExpectNull(swr)) + .then(_ => changeOfflineState(false)) + .then(_ => subscribe(swr)) + .then(sub => getEndpoint(swr, sub) + .then(s => unsubscribe(s)) + ) + .then(_ => getEndpointExpectNull(swr)) + .then(_ => unregister(swr)) + ) + .catch(err => { + ok(false, "Some test failed with error " + err); + }); + } + + function runTest() { + runTest1() + .then(_ => runTest2()) + .then(_ => runTest3()) + .then(_ => runTest4()) + .then(_ => runTest5()) + .then(_ => runTest6()) + .then(SimpleTest.finish); + } + + setupPrefsAndMockSocket(new MockWebSocket()).then(_ => runTest()); + SpecialPowers.addPermission("desktop-notification", true, document); + SimpleTest.waitForExplicitFinish(); +</script> +</body> +</html> diff --git a/dom/push/test/test_unregister.html b/dom/push/test/test_unregister.html new file mode 100644 index 0000000000..51f215c29b --- /dev/null +++ b/dom/push/test/test_unregister.html @@ -0,0 +1,78 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1170817: Push tests. + +Any copyright is dedicated to the Public Domain. +http://creativecommons.org/licenses/publicdomain/ + +--> +<head> + <title>Test for Bug 1170817</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <meta http-equiv="Content-type" content="text/html;charset=UTF-8"> +</head> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1170817">Mozilla Bug 1170817</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> + +<script class="testbody" type="text/javascript"> + function generateURL() { + return "worker.js?" + (Math.random()); + } + + var registration; + add_task(async function start() { + await setupPrefsAndMockSocket(new MockWebSocket()); + await setPushPermission(true); + + registration = await navigator.serviceWorker.register( + generateURL(), {scope: "."}); + await waitForActive(registration); + }); + + var pushSubscription; + add_task(async function setupPushNotification() { + pushSubscription = await registration.pushManager.subscribe(); + ok(pushSubscription, "successful registered for push notification"); + }); + + add_task(async function unregisterPushNotification() { + var result = await pushSubscription.unsubscribe(); + ok(result, "unsubscribe() on existing subscription should return true."); + }); + + add_task(async function unregisterAgain() { + var result = await pushSubscription.unsubscribe(); + ok(!result, "unsubscribe() on previously unsubscribed subscription should return false."); + }); + + add_task(async function subscribeAgain() { + pushSubscription = await registration.pushManager.subscribe(); + ok(pushSubscription, "Should create a new push subscription"); + + var result = await registration.unregister(); + ok(result, "Should unregister the service worker"); + + registration = await navigator.serviceWorker.register( + generateURL(), {scope: "."}); + await waitForActive(registration); + pushSubscription = await registration.pushManager.getSubscription(); + ok(!pushSubscription, + "Unregistering a service worker should drop its subscription"); + }); + + add_task(async function unregister() { + var result = await registration.unregister(); + ok(result, "Unregister should return true."); + }); + +</script> +</body> +</html> diff --git a/dom/push/test/test_utils.js b/dom/push/test/test_utils.js new file mode 100644 index 0000000000..b704f17138 --- /dev/null +++ b/dom/push/test/test_utils.js @@ -0,0 +1,283 @@ +"use strict"; + +const url = SimpleTest.getTestFileURL("mockpushserviceparent.js"); +const chromeScript = SpecialPowers.loadChromeScript(url); + +/** + * Replaces `PushService.jsm` with a mock implementation that handles requests + * from the DOM API. This allows tests to simulate local errors and error + * reporting, bypassing the `PushService.jsm` machinery. + */ +async function replacePushService(mockService) { + chromeScript.addMessageListener("service-delivery-error", function(msg) { + mockService.reportDeliveryError(msg.messageId, msg.reason); + }); + chromeScript.addMessageListener("service-request", function(msg) { + let promise; + try { + let handler = mockService[msg.name]; + promise = Promise.resolve(handler(msg.params)); + } catch (error) { + promise = Promise.reject(error); + } + promise.then( + result => { + chromeScript.sendAsyncMessage("service-response", { + id: msg.id, + result, + }); + }, + error => { + chromeScript.sendAsyncMessage("service-response", { + id: msg.id, + error, + }); + } + ); + }); + await new Promise(resolve => { + chromeScript.addMessageListener("service-replaced", function onReplaced() { + chromeScript.removeMessageListener("service-replaced", onReplaced); + resolve(); + }); + chromeScript.sendAsyncMessage("service-replace"); + }); +} + +async function restorePushService() { + await new Promise(resolve => { + chromeScript.addMessageListener("service-restored", function onRestored() { + chromeScript.removeMessageListener("service-restored", onRestored); + resolve(); + }); + chromeScript.sendAsyncMessage("service-restore"); + }); +} + +let currentMockSocket = null; + +/** + * Sets up a mock connection for the WebSocket backend. This only replaces + * the transport layer; `PushService.jsm` still handles DOM API requests, + * observes permission changes, writes to IndexedDB, and notifies service + * workers of incoming push messages. + */ +function setupMockPushSocket(mockWebSocket) { + currentMockSocket = mockWebSocket; + currentMockSocket._isActive = true; + chromeScript.sendAsyncMessage("socket-setup"); + chromeScript.addMessageListener("socket-client-msg", function(msg) { + mockWebSocket.handleMessage(msg); + }); +} + +function teardownMockPushSocket() { + if (currentMockSocket) { + return new Promise(resolve => { + currentMockSocket._isActive = false; + chromeScript.addMessageListener("socket-server-teardown", resolve); + chromeScript.sendAsyncMessage("socket-teardown"); + }); + } + return Promise.resolve(); +} + +/** + * Minimal implementation of web sockets for use in testing. Forwards + * messages to a mock web socket in the parent process that is used + * by the push service. + */ +class MockWebSocket { + // Default implementation to make the push server work minimally. + // Override methods to implement custom functionality. + constructor() { + this.userAgentID = "8e1c93a9-139b-419c-b200-e715bb1e8ce8"; + this.registerCount = 0; + // We only allow one active mock web socket to talk to the parent. + // This flag is used to keep track of which mock web socket is active. + this._isActive = false; + } + + onHello(request) { + this.serverSendMsg( + JSON.stringify({ + messageType: "hello", + uaid: this.userAgentID, + status: 200, + use_webpush: true, + }) + ); + } + + onRegister(request) { + this.serverSendMsg( + JSON.stringify({ + messageType: "register", + uaid: this.userAgentID, + channelID: request.channelID, + status: 200, + pushEndpoint: "https://example.com/endpoint/" + this.registerCount++, + }) + ); + } + + onUnregister(request) { + this.serverSendMsg( + JSON.stringify({ + messageType: "unregister", + channelID: request.channelID, + status: 200, + }) + ); + } + + onAck(request) { + // Do nothing. + } + + handleMessage(msg) { + let request = JSON.parse(msg); + let messageType = request.messageType; + switch (messageType) { + case "hello": + this.onHello(request); + break; + case "register": + this.onRegister(request); + break; + case "unregister": + this.onUnregister(request); + break; + case "ack": + this.onAck(request); + break; + default: + throw new Error("Unexpected message: " + messageType); + } + } + + serverSendMsg(msg) { + if (this._isActive) { + chromeScript.sendAsyncMessage("socket-server-msg", msg); + } + } +} + +// Remove permissions and prefs when the test finishes. +SimpleTest.registerCleanupFunction(async function() { + await new Promise(resolve => SpecialPowers.flushPermissions(resolve)); + await SpecialPowers.flushPrefEnv(); + await restorePushService(); + await teardownMockPushSocket(); +}); + +function setPushPermission(allow) { + return SpecialPowers.pushPermissions([ + { type: "desktop-notification", allow, context: document }, + ]); +} + +function setupPrefs() { + return SpecialPowers.pushPrefEnv({ + set: [ + ["dom.push.enabled", true], + ["dom.push.connection.enabled", true], + ["dom.push.maxRecentMessageIDsPerSubscription", 0], + ["dom.serviceWorkers.exemptFromPerDomainMax", true], + ["dom.serviceWorkers.enabled", true], + ["dom.serviceWorkers.testing.enabled", true], + ], + }); +} + +async function setupPrefsAndReplaceService(mockService) { + await replacePushService(mockService); + await setupPrefs(); +} + +function setupPrefsAndMockSocket(mockSocket) { + setupMockPushSocket(mockSocket); + return setupPrefs(); +} + +function injectControlledFrame(target = document.body) { + return new Promise(function(res, rej) { + var iframe = document.createElement("iframe"); + iframe.src = "/tests/dom/push/test/frame.html"; + + var controlledFrame = { + remove() { + target.removeChild(iframe); + iframe = null; + }, + waitOnWorkerMessage(type) { + return iframe + ? iframe.contentWindow.waitOnWorkerMessage(type) + : Promise.reject(new Error("Frame removed from document")); + }, + innerWindowId() { + return SpecialPowers.wrap(iframe).browsingContext.currentWindowContext + .innerWindowId; + }, + }; + + iframe.onload = () => res(controlledFrame); + target.appendChild(iframe); + }); +} + +function sendRequestToWorker(request) { + return navigator.serviceWorker.ready.then(registration => { + return new Promise((resolve, reject) => { + var channel = new MessageChannel(); + channel.port1.onmessage = e => { + (e.data.error ? reject : resolve)(e.data); + }; + registration.active.postMessage(request, [channel.port2]); + }); + }); +} + +function waitForActive(swr) { + let sw = swr.installing || swr.waiting || swr.active; + return new Promise(resolve => { + if (sw.state === "activated") { + resolve(swr); + return; + } + sw.addEventListener("statechange", function onStateChange(evt) { + if (sw.state === "activated") { + sw.removeEventListener("statechange", onStateChange); + resolve(swr); + } + }); + }); +} + +function base64UrlDecode(s) { + s = s.replace(/-/g, "+").replace(/_/g, "/"); + + // Replace padding if it was stripped by the sender. + // See http://tools.ietf.org/html/rfc4648#section-4 + switch (s.length % 4) { + case 0: + break; // No pad chars in this case + case 2: + s += "=="; + break; // Two pad chars + case 3: + s += "="; + break; // One pad char + default: + throw new Error("Illegal base64url string!"); + } + + // With correct padding restored, apply the standard base64 decoder + var decoded = atob(s); + + var array = new Uint8Array(new ArrayBuffer(decoded.length)); + for (var i = 0; i < decoded.length; i++) { + array[i] = decoded.charCodeAt(i); + } + return array; +} diff --git a/dom/push/test/webpush.js b/dom/push/test/webpush.js new file mode 100644 index 0000000000..91bc0fce85 --- /dev/null +++ b/dom/push/test/webpush.js @@ -0,0 +1,230 @@ +/* + * Browser-based Web Push client for the application server piece. + * + * Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/licenses/publicdomain/ + * + * Uses the WebCrypto API. + * + * Note that this test file uses the old, deprecated aesgcm128 encryption + * scheme. PushCrypto.encrypt() exists and uses the later aes128gcm, but + * there's no good reason to upgrade this at this time (and having mochitests + * use PushCrypto directly is easier said than done.) + */ + +(function(g) { + "use strict"; + + var P256DH = { + name: "ECDH", + namedCurve: "P-256", + }; + var webCrypto = g.crypto.subtle; + var ENCRYPT_INFO = new TextEncoder("utf-8").encode( + "Content-Encoding: aesgcm128" + ); + var NONCE_INFO = new TextEncoder("utf-8").encode("Content-Encoding: nonce"); + + function chunkArray(array, size) { + var start = array.byteOffset || 0; + array = array.buffer || array; + var index = 0; + var result = []; + while (index + size <= array.byteLength) { + result.push(new Uint8Array(array, start + index, size)); + index += size; + } + if (index < array.byteLength) { + result.push(new Uint8Array(array, start + index)); + } + return result; + } + + /* I can't believe that this is needed here, in this day and age ... + * Note: these are not efficient, merely expedient. + */ + var base64url = { + _strmap: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_", + encode(data) { + data = new Uint8Array(data); + var len = Math.ceil((data.length * 4) / 3); + return chunkArray(data, 3) + .map(chunk => + [ + chunk[0] >>> 2, + ((chunk[0] & 0x3) << 4) | (chunk[1] >>> 4), + ((chunk[1] & 0xf) << 2) | (chunk[2] >>> 6), + chunk[2] & 0x3f, + ] + .map(v => base64url._strmap[v]) + .join("") + ) + .join("") + .slice(0, len); + }, + _lookup(s, i) { + return base64url._strmap.indexOf(s.charAt(i)); + }, + decode(str) { + var v = new Uint8Array(Math.floor((str.length * 3) / 4)); + var vi = 0; + for (var si = 0; si < str.length; ) { + var w = base64url._lookup(str, si++); + var x = base64url._lookup(str, si++); + var y = base64url._lookup(str, si++); + var z = base64url._lookup(str, si++); + v[vi++] = (w << 2) | (x >>> 4); + v[vi++] = (x << 4) | (y >>> 2); + v[vi++] = (y << 6) | z; + } + return v; + }, + }; + + g.base64url = base64url; + + /* Coerces data into a Uint8Array */ + function ensureView(data) { + if (typeof data === "string") { + return new TextEncoder("utf-8").encode(data); + } + if (data instanceof ArrayBuffer) { + return new Uint8Array(data); + } + if (ArrayBuffer.isView(data)) { + return new Uint8Array(data.buffer); + } + throw new Error("webpush() needs a string or BufferSource"); + } + + function bsConcat(arrays) { + var size = arrays.reduce((total, a) => total + a.byteLength, 0); + var index = 0; + return arrays.reduce((result, a) => { + result.set(new Uint8Array(a), index); + index += a.byteLength; + return result; + }, new Uint8Array(size)); + } + + function hmac(key) { + this.keyPromise = webCrypto.importKey( + "raw", + key, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + } + hmac.prototype.hash = function(input) { + return this.keyPromise.then(k => webCrypto.sign("HMAC", k, input)); + }; + + function hkdf(salt, ikm) { + this.prkhPromise = new hmac(salt).hash(ikm).then(prk => new hmac(prk)); + } + + hkdf.prototype.generate = function(info, len) { + var input = bsConcat([info, new Uint8Array([1])]); + return this.prkhPromise + .then(prkh => prkh.hash(input)) + .then(h => { + if (h.byteLength < len) { + throw new Error("Length is too long"); + } + return h.slice(0, len); + }); + }; + + /* generate a 96-bit IV for use in GCM, 48-bits of which are populated */ + function generateNonce(base, index) { + var nonce = base.slice(0, 12); + for (var i = 0; i < 6; ++i) { + nonce[nonce.length - 1 - i] ^= (index / Math.pow(256, i)) & 0xff; + } + return nonce; + } + + function encrypt(localKey, remoteShare, salt, data) { + return webCrypto + .importKey("raw", remoteShare, P256DH, false, ["deriveBits"]) + .then(remoteKey => + webCrypto.deriveBits( + { name: P256DH.name, public: remoteKey }, + localKey, + 256 + ) + ) + .then(rawKey => { + var kdf = new hkdf(salt, rawKey); + return Promise.all([ + kdf + .generate(ENCRYPT_INFO, 16) + .then(gcmBits => + webCrypto.importKey("raw", gcmBits, "AES-GCM", false, ["encrypt"]) + ), + kdf.generate(NONCE_INFO, 12), + ]); + }) + .then(([key, nonce]) => { + if (data.byteLength === 0) { + // Send an authentication tag for empty messages. + return webCrypto + .encrypt( + { + name: "AES-GCM", + iv: generateNonce(nonce, 0), + }, + key, + new Uint8Array([0]) + ) + .then(value => [value]); + } + // 4096 is the default size, though we burn 1 for padding + return Promise.all( + chunkArray(data, 4095).map((slice, index) => { + var padded = bsConcat([new Uint8Array([0]), slice]); + return webCrypto.encrypt( + { + name: "AES-GCM", + iv: generateNonce(nonce, index), + }, + key, + padded + ); + }) + ); + }) + .then(bsConcat); + } + + function webPushEncrypt(subscription, data) { + data = ensureView(data); + + var salt = g.crypto.getRandomValues(new Uint8Array(16)); + return webCrypto + .generateKey(P256DH, false, ["deriveBits"]) + .then(localKey => { + return Promise.all([ + encrypt( + localKey.privateKey, + subscription.getKey("p256dh"), + salt, + data + ), + // 1337 p-256 specific haxx to get the raw value out of the spki value + webCrypto.exportKey("raw", localKey.publicKey), + ]); + }) + .then(([payload, pubkey]) => { + return { + data: base64url.encode(payload), + encryption: "keyid=p256dh;salt=" + base64url.encode(salt), + encryption_key: "keyid=p256dh;dh=" + base64url.encode(pubkey), + encoding: "aesgcm128", + }; + }); + } + + g.webPushEncrypt = webPushEncrypt; +})(this); diff --git a/dom/push/test/worker.js b/dom/push/test/worker.js new file mode 100644 index 0000000000..bcdbf0e0ad --- /dev/null +++ b/dom/push/test/worker.js @@ -0,0 +1,174 @@ +// Any copyright is dedicated to the Public Domain. +// http://creativecommons.org/licenses/publicdomain/ + +// This worker is used for two types of tests. `handlePush` sends messages to +// `frame.html`, which verifies that the worker can receive push messages. + +// `handleMessage` receives messages from `test_push_manager_worker.html` +// and `test_data.html`, and verifies that `PushManager` can be used from +// the worker. + +/* globals PushEvent */ + +this.onpush = handlePush; +this.onmessage = handleMessage; +this.onpushsubscriptionchange = handlePushSubscriptionChange; + +function getJSON(data) { + var result = { + ok: false, + }; + try { + result.value = data.json(); + result.ok = true; + } catch (e) { + // Ignore syntax errors for invalid JSON. + } + return result; +} + +function assert(value, message) { + if (!value) { + throw new Error(message); + } +} + +function broadcast(event, promise) { + event.waitUntil( + Promise.resolve(promise).then(message => { + return self.clients.matchAll().then(clients => { + clients.forEach(client => client.postMessage(message)); + }); + }) + ); +} + +function reply(event, promise) { + event.waitUntil( + Promise.resolve(promise) + .then(result => { + event.ports[0].postMessage(result); + }) + .catch(error => { + event.ports[0].postMessage({ + error: String(error), + }); + }) + ); +} + +function handlePush(event) { + if (event instanceof PushEvent) { + if (!("data" in event)) { + broadcast(event, { type: "finished", okay: "yes" }); + return; + } + var message = { + type: "finished", + okay: "yes", + }; + if (event.data) { + message.data = { + text: event.data.text(), + arrayBuffer: event.data.arrayBuffer(), + json: getJSON(event.data), + blob: event.data.blob(), + }; + } + broadcast(event, message); + return; + } + broadcast(event, { type: "finished", okay: "no" }); +} + +var testHandlers = { + publicKey(data) { + return self.registration.pushManager + .getSubscription() + .then(subscription => ({ + p256dh: subscription.getKey("p256dh"), + auth: subscription.getKey("auth"), + })); + }, + + resubscribe(data) { + return self.registration.pushManager + .getSubscription() + .then(subscription => { + assert( + subscription.endpoint == data.endpoint, + "Wrong push endpoint in worker" + ); + return subscription.unsubscribe(); + }) + .then(result => { + assert(result, "Error unsubscribing in worker"); + return self.registration.pushManager.getSubscription(); + }) + .then(subscription => { + assert(!subscription, "Subscription not removed in worker"); + return self.registration.pushManager.subscribe(); + }) + .then(subscription => { + return { + endpoint: subscription.endpoint, + }; + }); + }, + + denySubscribe(data) { + return self.registration.pushManager + .getSubscription() + .then(subscription => { + assert( + !subscription, + "Should not return worker subscription with revoked permission" + ); + return self.registration.pushManager.subscribe().then( + _ => { + assert(false, "Expected error subscribing with revoked permission"); + }, + error => { + return { + isDOMException: error instanceof DOMException, + name: error.name, + }; + } + ); + }); + }, + + subscribeWithKey(data) { + return self.registration.pushManager + .subscribe({ + applicationServerKey: data.key, + }) + .then( + subscription => { + return { + endpoint: subscription.endpoint, + key: subscription.options.applicationServerKey, + }; + }, + error => { + return { + isDOMException: error instanceof DOMException, + name: error.name, + }; + } + ); + }, +}; + +function handleMessage(event) { + var handler = testHandlers[event.data.type]; + if (handler) { + reply(event, handler(event.data)); + } else { + reply(event, Promise.reject("Invalid message type: " + event.data.type)); + } +} + +function handlePushSubscriptionChange(event) { + broadcast(event, { type: "changed", okay: "yes" }); +} 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. |