diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | 5f8de423f190bbb79a62f804151bc24824fa32d8 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /dom/push/test | |
parent | 49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff) | |
download | UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip |
Add m-esr52 at 52.6.0
Diffstat (limited to 'dom/push/test')
84 files changed, 9426 insertions, 0 deletions
diff --git a/dom/push/test/error_worker.js b/dom/push/test/error_worker.js new file mode 100644 index 000000000..a50f83804 --- /dev/null +++ b/dom/push/test/error_worker.js @@ -0,0 +1,10 @@ +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 000000000..50036db15 --- /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 000000000..46e713f4e --- /dev/null +++ b/dom/push/test/lifetime_worker.js @@ -0,0 +1,85 @@ +var state = "from_scope"; +var resolvePromiseCallback; + +onfetch = function(event) { + if (event.request.url.indexOf("lifetime_frame.html") >= 0) { + event.respondWith(new Response("iframe_lifetime")); + return; + } + + var currentState = state; + event.waitUntil( + clients.matchAll() + .then(clients => { + clients.forEach(client => { + client.postMessage({type: "fetch", state: currentState}); + }); + }) + ); + + if (event.request.url.indexOf("update") >= 0) { + state = "update"; + } else if (event.request.url.indexOf("wait") >= 0) { + 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.indexOf("release") >= 0) { + state = "release"; + resolvePromise(); + } +} + +function resolvePromise() { + if (resolvePromiseCallback === undefined || resolvePromiseCallback == null) { + dump("ERROR: wait promise was not set.\n"); + return; + } + resolvePromiseCallback(); + resolvePromiseCallback = null; +} + +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}); +} + +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" + 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}); + + 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 000000000..adb1c39d7 --- /dev/null +++ b/dom/push/test/mochitest.ini @@ -0,0 +1,24 @@ +[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] +[test_register_key.html] +[test_multiple_register.html] +[test_multiple_register_during_service_activation.html] +[test_unregister.html] +[test_multiple_register_different_scope.html] +[test_subscription_change.html] +[test_data.html] +[test_try_registering_offline_disabled.html] +[test_serviceworker_lifetime.html] +[test_error_reporting.html] diff --git a/dom/push/test/mockpushserviceparent.js b/dom/push/test/mockpushserviceparent.js new file mode 100644 index 000000000..78cf246ee --- /dev/null +++ b/dom/push/test/mockpushserviceparent.js @@ -0,0 +1,168 @@ +"use strict"; + +const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +/** + * 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: XPCOMUtils.generateQI([ + Ci.nsISupports, + Ci.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: name, + id: id, + params: params, + }); + }); + }, + + 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: messageId, + reason: reason, + }); + }, +}; + +addMessageListener("service-replace", function () { + pushService.service = MockService; +}); + +addMessageListener("service-restore", function () { + pushService.service = null; +}); + +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 000000000..8de873fce --- /dev/null +++ b/dom/push/test/test_data.html @@ -0,0 +1,218 @@ +<!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 type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.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"> + 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(function* start() { + yield setupPrefsAndMockSocket(mockSocket); + yield setPushPermission(true); + + var url = "worker.js" + "?" + (Math.random()); + registration = yield navigator.serviceWorker.register(url, {scope: "."}); + yield waitForActive(registration); + }); + + var controlledFrame; + add_task(function* createControlledIFrame() { + controlledFrame = yield injectControlledFrame(); + }); + + var pushSubscription; + add_task(function* subscribe() { + pushSubscription = yield registration.pushManager.subscribe(); + }); + + 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; + } + + add_task(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(function* comparePublicKey() { + var data = yield 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")); + ok(authSecret.length, 16, "Auth secret should be 16 octets"); + isDeeply( + authSecret, + new Uint8Array(data.auth), + "Mismatched auth secret" + ); + }); + + var version = 0; + function sendEncryptedMsg(pushSubscription, message) { + return webPushEncrypt(pushSubscription, message) + .then((encryptedData) => { + mockSocket.serverSendMsg(JSON.stringify({ + messageType: 'notification', + version: version++, + channelID: channelID, + data: encryptedData.data, + headers: { + encryption: encryptedData.encryption, + encryption_key: encryptedData.encryption_key, + encoding: encryptedData.encoding + } + })); + }); + } + + function waitForMessage(pushSubscription, message) { + return Promise.all([ + controlledFrame.waitOnWorkerMessage("finished"), + sendEncryptedMsg(pushSubscription, message), + ]).then(([message]) => message); + } + + add_task(function* sendPushMessageFromPage() { + var typedArray = new Uint8Array([226, 130, 40, 240, 40, 140, 188]); + var json = { hello: "world" }; + + var message = yield waitForMessage(pushSubscription, "Text message from page"); + is(message.data.text, "Text message from page", "Wrong text message data"); + + message = yield waitForMessage( + pushSubscription, + typedArray + ); + isDeeply(new Uint8Array(message.data.arrayBuffer), typedArray, + "Wrong array buffer message data"); + + message = yield 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 = yield 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 = yield 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 = yield 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: channelID + })); + + var message = yield finishedPromise; + ok(!message.data, "Should exclude data for blank messages"); + }); + + add_task(function* unsubscribe() { + controlledFrame.remove(); + yield pushSubscription.unsubscribe(); + }); + + add_task(function* unregister() { + yield 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 000000000..9564cd510 --- /dev/null +++ b/dom/push/test/test_error_reporting.html @@ -0,0 +1,130 @@ +<!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 type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.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(function* start() { + yield setupPrefsAndReplaceService({ + reportDeliveryError(messageId, reason) { + ok(reporters.has(messageId), + 'Unexpected error reported for message ' + messageId); + var resolve = reporters.get(messageId); + reporters.delete(messageId); + resolve(reason); + }, + }); + yield setPushPermission(true); + + var url = "error_worker.js" + "?" + (Math.random()); + registration = yield navigator.serviceWorker.register(url, {scope: "."}); + yield waitForActive(registration); + }); + + var controlledFrame; + add_task(function* createControlledIFrame() { + controlledFrame = yield 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.length, data); + }); + } + + add_task(function* reportDeliveryErrors() { + var reason = yield waitForDeliveryError({ type: "exception" }); + is(reason, SpecialPowers.Ci.nsIPushErrorReporter.DELIVERY_UNCAUGHT_EXCEPTION, + "Should report uncaught exceptions"); + + reason = yield waitForDeliveryError({ type: "rejection" }); + is(reason, SpecialPowers.Ci.nsIPushErrorReporter.DELIVERY_UNHANDLED_REJECTION, + "Should report unhandled rejections"); + }); + + add_task(function* reportDecryptionError() { + var message = yield new Promise(resolve => { + var consoleService = SpecialPowers.Cc["@mozilla.org/consoleservice;1"] + .getService(SpecialPowers.Ci.nsIConsoleService); + + var listener = SpecialPowers.wrapCallbackObject({ + QueryInterface(iid) { + if (!SpecialPowers.Ci.nsISupports.equals(iid) && + !SpecialPowers.Ci.nsIConsoleListener.equals(iid)) { + throw SpecialPowers.Cr.NS_ERROR_NO_INTERFACE; + } + return this; + }, + + observe(message) { + let error = message; + try { + error.QueryInterface(SpecialPowers.Ci.nsIScriptError); + } catch (error) { + return; + } + if (message.innerWindowID == controlledFrame.innerWindowId()) { + consoleService.unregisterListener(listener); + resolve(error); + } + }, + }); + consoleService.registerListener(listener); + + 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(function* unsubscribe() { + controlledFrame.remove(); + }); + + add_task(function* unregister() { + yield 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 000000000..00857b5fd --- /dev/null +++ b/dom/push/test/test_has_permissions.html @@ -0,0 +1,84 @@ +<!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 type="text/javascript" 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"); + } + + 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 hasPermission(swr) { + var p = new Promise(function(res, rej) { + swr.pushManager.permissionState().then( + function(state) { + debug("state: " + state); + ok(["granted", "denied", "prompt"].indexOf(state) >= 0, "permissionState() returned a valid state."); + res(swr); + }, function(error) { + ok(false, "permissionState() failed."); + res(swr); + } + ); + }); + return p; + } + + function runTest() { + start() + .then(hasPermission) + .then(unregister) + .catch(function(e) { + ok(false, "Some test failed with error " + e); + }).then(SimpleTest.finish); + } + + SpecialPowers.addPermission("desktop-notification", false, document); + SpecialPowers.pushPrefEnv({"set": [ + ["dom.push.enabled", true], + ["dom.push.connection.enabled", true], + ["dom.serviceWorkers.exemptFromPerDomainMax", true], + ["dom.serviceWorkers.enabled", true], + ["dom.serviceWorkers.testing.enabled", true] + ]}, runTest); + SimpleTest.waitForExplicitFinish(); + +</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 000000000..1834882a5 --- /dev/null +++ b/dom/push/test/test_multiple_register.html @@ -0,0 +1,130 @@ +<!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 type="text/javascript" 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"); + } + + 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: swr, pushSubscription: 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 000000000..4540ba247 --- /dev/null +++ b/dom/push/test/test_multiple_register_different_scope.html @@ -0,0 +1,123 @@ +<!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 type="text/javascript" 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: 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 000000000..98aef4a3a --- /dev/null +++ b/dom/push/test/test_multiple_register_during_service_activation.html @@ -0,0 +1,111 @@ +<!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 type="text/javascript" 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 000000000..1d78e34f8 --- /dev/null +++ b/dom/push/test/test_permissions.html @@ -0,0 +1,106 @@ +<!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 type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.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(function* start() { + yield setupPrefsAndMockSocket(new MockWebSocket()); + yield setPushPermission(false); + + var url = "worker.js" + "?" + Math.random(); + registration = yield navigator.serviceWorker.register(url, {scope: "."}); + yield waitForActive(registration); + }); + + add_task(function* denySubscribe() { + try { + yield 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(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 = yield sendRequestToWorker({ + type: "denySubscribe", + }); + ok(errorInfo.isDOMException, "Wrong exception type"); + is(errorInfo.name, "NotAllowedError", "Wrong exception name"); + }); + + add_task(function* getEndpoint() { + var pushSubscription = yield registration.pushManager.getSubscription(); + is(pushSubscription, null, "getSubscription() should return null because no permission for push"); + }); + + add_task(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) { + yield setPushPermission(test.action); + var state = yield registration.pushManager.permissionState(); + is(state, test.state, JSON.stringify(test)); + try { + yield SpecialPowers.pushPrefEnv({ set: [ + ["dom.push.testing.ignorePermission", true]] }); + state = yield registration.pushManager.permissionState(); + is(state, "granted", `Should ignore ${ + test.action} if the override pref is set`); + } finally { + yield SpecialPowers.flushPrefEnv(); + } + } + }); + + add_task(function* unregister() { + var result = yield 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 000000000..70071b09d --- /dev/null +++ b/dom/push/test/test_register.html @@ -0,0 +1,109 @@ +<!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 type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.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(function* start() { + yield setupPrefsAndMockSocket(mockSocket); + yield setPushPermission(true); + + var url = "worker.js" + "?" + (Math.random()); + registration = yield navigator.serviceWorker.register(url, {scope: "."}); + yield waitForActive(registration); + }); + + var controlledFrame; + add_task(function* createControlledIFrame() { + controlledFrame = yield injectControlledFrame(); + }); + + add_task(function* checkPermissionState() { + var state = yield registration.pushManager.permissionState(); + is(state, "granted", "permissionState() should resolve to granted."); + }); + + var pushSubscription; + add_task(function* subscribe() { + pushSubscription = yield registration.pushManager.subscribe(); + is(pushSubscription.options.applicationServerKey, null, + "Subscription should not have an app server key"); + }); + + add_task(function* resubscribe() { + var data = yield sendRequestToWorker({ + type: "resubscribe", + endpoint: pushSubscription.endpoint, + }); + pushSubscription = yield registration.pushManager.getSubscription(); + is(data.endpoint, pushSubscription.endpoint, + "Subscription endpoints should match after resubscribing in worker"); + }); + + add_task(function* waitForPushNotification() { + var finishedPromise = controlledFrame.waitOnWorkerMessage("finished"); + + // Send a blank message. + mockSocket.serverSendMsg(JSON.stringify({ + messageType: "notification", + version: "vDummy", + channelID: channelID + })); + + yield finishedPromise; + }); + + add_task(function* unsubscribe() { + controlledFrame.remove(); + yield pushSubscription.unsubscribe(); + }); + + add_task(function* unregister() { + var result = yield 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 000000000..23ecf2f01 --- /dev/null +++ b/dom/push/test/test_register_key.html @@ -0,0 +1,210 @@ +<!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 type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.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(function* start() { + yield setupPrefsAndReplaceService({ + register(pageRecord) { + ok(pageRecord.appServerKey.length > 0, + "App server key should not be empty"); + if (pageRecord.appServerKey.length != 65) { + throw { result: + SpecialPowers.Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR }; + } + if (isTestingMismatchedKey) { + 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, + }; + }, + }); + yield setPushPermission(true); + testKey = yield generateKey(); + + var url = "worker.js" + "?" + (Math.random()); + registration = yield navigator.serviceWorker.register(url, {scope: "."}); + yield waitForActive(registration); + }); + + var controlledFrame; + add_task(function* createControlledIFrame() { + controlledFrame = yield injectControlledFrame(); + }); + + add_task(function* emptyKey() { + try { + yield 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(function* invalidKey() { + try { + yield 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(function* validKey() { + var pushSubscription = yield registration.pushManager.subscribe({ + applicationServerKey: yield 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(function* retrieveKey() { + var pushSubscription = yield 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(function* mismatchedKey() { + isTestingMismatchedKey = true; + try { + yield registration.pushManager.subscribe({ + applicationServerKey: yield 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(function* emptyKeyInWorker() { + var errorInfo = yield 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(function* invalidKeyInWorker() { + var errorInfo = yield 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(function* validKeyInWorker() { + var key = yield generateKey(); + var data = yield sendRequestToWorker({ + type: "subscribeWithKey", + key: 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(function* mismatchedKeyInWorker() { + isTestingMismatchedKey = true; + try { + var errorInfo = yield sendRequestToWorker({ + type: "subscribeWithKey", + key: yield 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(function* unsubscribe() { + is(subscriptions, 2, "Wrong subscription count"); + controlledFrame.remove(); + }); + + add_task(function* unregister() { + yield 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 000000000..03f66887a --- /dev/null +++ b/dom/push/test/test_serviceworker_lifetime.html @@ -0,0 +1,362 @@ +<!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 type="text/javascript" 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.parentNode.removeChild(ctx.iframe); + 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(expected, resolve, event) { + ok(event.data.type == expected.type, "Received correct message type: " + expected.type); + ok(event.data.state == expected.state, "Service worker is in the correct state: " + expected.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); + } + } + + 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: function(subject, topic, data) { + ok((topic == "service-worker-shutdown") && expectingEvent, "Service worker was terminated."); + this.remove(ctx); + }, + remove: function(ctx) { + SpecialPowers.removeObserver(this, "service-worker-shutdown"); + ctx.observer = null; + res(ctx); + } + } + SpecialPowers.addObserver(ctx.observer, "service-worker-shutdown", false); + }); + + 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((ctx) => res(ctx)); + } + + 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: function(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: function(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: function(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: function(ctx) { + return new Promise(function(res, rej) { + ctx.iframe.contentWindow.fetch("update"); + res(ctx); + }); + } + } + + // 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 000000000..3f2e45e5a --- /dev/null +++ b/dom/push/test/test_subscription_change.html @@ -0,0 +1,69 @@ +<!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 type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.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(function* start() { + yield setupPrefsAndMockSocket(new MockWebSocket()); + yield setPushPermission(true); + + var url = "worker.js" + "?" + (Math.random()); + registration = yield navigator.serviceWorker.register(url, {scope: "."}); + yield waitForActive(registration); + }); + + var controlledFrame; + add_task(function* createControlledIFrame() { + controlledFrame = yield injectControlledFrame(); + }); + + add_task(function* togglePermission() { + var subscription = yield registration.pushManager.subscribe(); + ok(subscription, "Should create a push subscription"); + + yield setPushPermission(false); + var permissionState = yield registration.pushManager.permissionState(); + is(permissionState, "denied", "Should deny push permission"); + + var subscription = yield registration.pushManager.getSubscription(); + is(subscription, null, "Should not return subscription when permission is revoked"); + + var changePromise = controlledFrame.waitOnWorkerMessage("changed"); + yield setPushPermission(true); + yield changePromise; + + subscription = yield registration.pushManager.getSubscription(); + is(subscription, null, "Should drop subscription after reinstating permission"); + }); + + add_task(function* unsubscribe() { + controlledFrame.remove(); + yield 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 000000000..d0d16e39c --- /dev/null +++ b/dom/push/test/test_try_registering_offline_disabled.html @@ -0,0 +1,305 @@ +<!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 type="text/javascript" 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: "."}) + .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 "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 chromeScript = SpecialPowers.loadChromeScript(_ => { + var { classes: Cc, interfaces: Ci } = Components; + var ioService = Cc["@mozilla.org/network/io-service;1"] + .getService(Ci.nsIIOService); + addMessageListener("change-status", function(offline) { + ioService.offline = offline; + }); + }); + + function offlineObserver(res) { + this._res = res; + } + offlineObserver.prototype = { + _res: null, + + observe: function(subject, topic, data) { + debug("observe: " + subject + " " + topic + " " + data); + if (topic === "network:offline-status-changed") { + var 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) { + var obsService = SpecialPowers.Cc["@mozilla.org/observer-service;1"] + .getService(SpecialPowers.Ci.nsIObserverService); + obsService.addObserver(SpecialPowers.wrapCallbackObject(new offlineObserver(res)), + "network:offline-status-changed", + false); + chromeScript.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(sub => unsubscribe(sub)) + ) + .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(sub => unsubscribe(sub)) + ) + .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(sub => unsubscribe(sub)) + ) + .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(sub => unsubscribe(sub)) + ) + .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(sub => unsubscribe(sub)) + ) + .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(sub => unsubscribe(sub)) + ) + .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 000000000..f15b36c47 --- /dev/null +++ b/dom/push/test/test_unregister.html @@ -0,0 +1,81 @@ +<!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 type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.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(function* start() { + yield setupPrefsAndMockSocket(new MockWebSocket()); + yield setPushPermission(true); + + registration = yield navigator.serviceWorker.register( + generateURL(), {scope: "."}); + yield waitForActive(registration); + }); + + var pushSubscription; + add_task(function* setupPushNotification() { + pushSubscription = yield registration.pushManager.subscribe(); + ok(pushSubscription, "successful registered for push notification"); + }); + + add_task(function* unregisterPushNotification() { + var result = yield pushSubscription.unsubscribe(); + ok(result, "unsubscribe() on existing subscription should return true."); + }); + + add_task(function* unregisterAgain() { + var result = yield pushSubscription.unsubscribe(); + ok(!result, "unsubscribe() on previously unsubscribed subscription should return false."); + }); + + add_task(function* subscribeAgain() { + pushSubscription = yield registration.pushManager.subscribe(); + ok(pushSubscription, "Should create a new push subscription"); + + var result = yield registration.unregister(); + ok(result, "Should unregister the service worker"); + + registration = yield navigator.serviceWorker.register( + generateURL(), {scope: "."}); + yield waitForActive(registration); + var pushSubscription = yield registration.pushManager.getSubscription(); + ok(!pushSubscription, + "Unregistering a service worker should drop its subscription"); + }); + + add_task(function* unregister() { + var result = yield 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 000000000..efd2f9dd7 --- /dev/null +++ b/dom/push/test/test_utils.js @@ -0,0 +1,245 @@ +(function (g) { + "use strict"; + + let url = SimpleTest.getTestFileURL("mockpushserviceparent.js"); + let 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. + */ + function replacePushService(mockService) { + chromeScript.sendSyncMessage("service-replace"); + 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: result, + }); + }, error => { + chromeScript.sendAsyncMessage("service-response", { + id: msg.id, + error: error, + }); + }); + }); + } + + function restorePushService() { + chromeScript.sendSyncMessage("service-restore"); + } + + let userAgentID = "8e1c93a9-139b-419c-b200-e715bb1e8ce8"; + + 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.sendSyncMessage("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.sendSyncMessage("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. + */ + function MockWebSocket() {} + + let registerCount = 0; + + // Default implementation to make the push server work minimally. + // Override methods to implement custom functionality. + MockWebSocket.prototype = { + // 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. + _isActive: false, + + onHello(request) { + this.serverSendMsg(JSON.stringify({ + messageType: "hello", + uaid: userAgentID, + status: 200, + use_webpush: true, + })); + }, + + onRegister(request) { + this.serverSendMsg(JSON.stringify({ + messageType: "register", + uaid: userAgentID, + channelID: request.channelID, + status: 200, + pushEndpoint: "https://example.com/endpoint/" + 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); + } + }, + }; + + g.MockWebSocket = MockWebSocket; + g.setupMockPushSocket = setupMockPushSocket; + g.teardownMockPushSocket = teardownMockPushSocket; + g.replacePushService = replacePushService; + g.restorePushService = restorePushService; +}(this)); + +// Remove permissions and prefs when the test finishes. +SimpleTest.registerCleanupFunction(() => { + return new Promise(resolve => + SpecialPowers.flushPermissions(resolve) + ).then(_ => SpecialPowers.flushPrefEnv()).then(_ => { + restorePushService(); + return teardownMockPushSocket(); + }); +}); + +function setPushPermission(allow) { + return new Promise(resolve => { + SpecialPowers.pushPermissions([ + { type: "desktop-notification", allow, context: document }, + ], resolve); + }); +} + +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] + ]}); +} + +function setupPrefsAndReplaceService(mockService) { + replacePushService(mockService); + return 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() { + var utils = SpecialPowers.getDOMWindowUtils(iframe.contentWindow); + return utils.currentInnerWindowID; + }, + }; + + 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); + } + }); + }); +} diff --git a/dom/push/test/webpush.js b/dom/push/test/webpush.js new file mode 100644 index 000000000..6aacc5ae1 --- /dev/null +++ b/dom/push/test/webpush.js @@ -0,0 +1,186 @@ +/* + * 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. + * Uses the fetch API. Polyfill: https://github.com/github/fetch + */ + +(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: function(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: function(s, i) { + return base64url._strmap.indexOf(s.charAt(i)); + }, + decode: function(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 000000000..0e26f228d --- /dev/null +++ b/dom/push/test/worker.js @@ -0,0 +1,152 @@ +// 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. + +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 000000000..d63f32c97 --- /dev/null +++ b/dom/push/test/xpcshell/PushServiceHandler.js @@ -0,0 +1,31 @@ +// 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 {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.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, false); + Services.obs.addObserver(this, pushService.subscriptionChangeTopic, false); + Services.obs.addObserver(this, pushService.subscriptionModifiedTopic, false); +} + +PushServiceHandler.prototype = { + classID: Components.ID("{bb7c5199-c0f7-4976-9f6d-1306e32c5591}"), + QueryInterface: XPCOMUtils.generateQI([]), + + observe(subject, topic, data) { + this.observed.push({ subject, topic, data }); + }, +} + +this.NSGetFactory = XPCOMUtils.generateNSGetFactory([PushServiceHandler]); diff --git a/dom/push/test/xpcshell/PushServiceHandler.manifest b/dom/push/test/xpcshell/PushServiceHandler.manifest new file mode 100644 index 000000000..f25b49896 --- /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/head-http2.js b/dom/push/test/xpcshell/head-http2.js new file mode 100644 index 000000000..9c502bdcc --- /dev/null +++ b/dom/push/test/xpcshell/head-http2.js @@ -0,0 +1,62 @@ +// 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}`); + } + do_print(`Using HTTP/2 server on port ${port}`); + return port; +} + +// Support for making sure we can talk to the invalid cert the server presents +var CertOverrideListener = function(host, port, bits) { + this.host = host; + this.port = port || 443; + this.bits = bits; +}; + +CertOverrideListener.prototype = { + host: null, + bits: null, + + getInterface: function(aIID) { + return this.QueryInterface(aIID); + }, + + QueryInterface: function(aIID) { + if (aIID.equals(Ci.nsIBadCertListener2) || + aIID.equals(Ci.nsIInterfaceRequestor) || + aIID.equals(Ci.nsISupports)) + return this; + throw Components.results.NS_ERROR_NO_INTERFACE; + }, + + notifyCertProblem: function(socketInfo, sslStatus, targetHost) { + var cert = sslStatus.QueryInterface(Ci.nsISSLStatus).serverCert; + var cos = Cc["@mozilla.org/security/certoverride;1"]. + getService(Ci.nsICertOverrideService); + cos.rememberValidityOverride(this.host, this.port, cert, this.bits, false); + dump("Certificate Override in place\n"); + return true; + }, +}; + +function addCertOverride(host, port, bits) { + var req = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"] + .createInstance(Ci.nsIXMLHttpRequest); + try { + var url; + if (port && (port > 0) && (port !== 443)) { + url = "https://" + host + ":" + port + "/"; + } else { + url = "https://" + host + "/"; + } + req.open("GET", url, false); + req.channel.notificationCallbacks = new CertOverrideListener(host, port, bits); + req.send(null); + } catch (e) { + // This will fail since the server is not trusted yet + } +} diff --git a/dom/push/test/xpcshell/head.js b/dom/push/test/xpcshell/head.js new file mode 100644 index 000000000..9751a1cb1 --- /dev/null +++ b/dom/push/test/xpcshell/head.js @@ -0,0 +1,463 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +'use strict'; + +var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import('resource://gre/modules/XPCOMUtils.jsm'); +Cu.import('resource://gre/modules/Services.jsm'); +Cu.import('resource://gre/modules/Task.jsm'); +Cu.import('resource://gre/modules/Timer.jsm'); +Cu.import('resource://gre/modules/Promise.jsm'); +Cu.import('resource://gre/modules/Preferences.jsm'); +Cu.import('resource://gre/modules/PlacesUtils.jsm'); +Cu.import('resource://gre/modules/ObjectUtils.jsm'); + +XPCOMUtils.defineLazyModuleGetter(this, 'PlacesTestUtils', + 'resource://testing-common/PlacesTestUtils.jsm'); +XPCOMUtils.defineLazyServiceGetter(this, 'PushServiceComponent', + '@mozilla.org/push/Service;1', 'nsIPushService'); + +const serviceExports = Cu.import('resource://gre/modules/PushService.jsm', {}); +const servicePrefs = new Preferences('dom.push.'); + +const WEBSOCKET_CLOSE_GOING_AWAY = 1001; + +const MS_IN_ONE_DAY = 24 * 60 * 60 * 1000; + +var isParent = Cc['@mozilla.org/xre/runtime;1'] + .getService(Ci.nsIXULRuntime).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, false); + 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', false); + +/** + * 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) { + return 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, topic, data) { + let matches = typeof matchFunc != 'function' || matchFunc(subject, data); + if (!matches) { + return; + } + Services.obs.removeObserver(observe, topic, false); + resolve({subject, data}); + }, topic, false); + }); +} + +/** + * 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(target, property) { + if (!stubs || typeof stubs != 'object' || !(property in stubs)) { + return target[property]; + } + let stub = stubs[property]; + if (typeof stub != 'function') { + return stub; + } + let original = target[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) { + return a > b ? 1 : 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; +} + +MockWebSocket.prototype = { + _originalURI: null, + _onHello: null, + _onRegister: null, + _onUnregister: null, + _onACK: null, + _onPing: null, + + _listener: null, + _context: null, + + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsISupports, + Ci.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; + + 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 = Task.async(function* (service, db) { + if (!isParent) { + return; + } + + let userAgentID = 'ce704e41-cb77-4206-b07b-5bf47114791b'; + setPrefs({ + userAgentID: userAgentID, + }); + + yield 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, + }); + yield 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, + }); + yield 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: function(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 = Task.async(function* (db) { + if (!isParent) { + return; + } + + let record = yield db.getByIdentifiers({ + scope: 'https://example.com/sub/ok', + originAttributes: '', + }); + ok(record.pushEndpoint.startsWith('https://example.org/push'), + 'Wrong push endpoint in subscription record'); + + record = yield db.getByIdentifiers({ + scope: 'https://example.net/scope/1', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: 1, inIsolatedMozBrowser: true }), + }); + ok(record.pushEndpoint.startsWith('https://example.org/push'), + 'Wrong push endpoint in app record'); + + record = yield 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: scope, + pushCount: 0, + lastPush: 0, + version: null, + originAttributes: '', + quota: 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 000000000..a6f542318 --- /dev/null +++ b/dom/push/test/xpcshell/moz.build @@ -0,0 +1,4 @@ +EXTRA_COMPONENTS += [ + 'PushServiceHandler.js', + 'PushServiceHandler.manifest', +] 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 000000000..b8060a141 --- /dev/null +++ b/dom/push/test/xpcshell/test_clearAll_successful.js @@ -0,0 +1,115 @@ +/* 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: userAgentID, + }); + run_next_test(); +} + +add_task(function* setup() { + db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(_ => db.drop().then(_ => db.close())); + + // Active subscriptions; should be expired then dropped. + yield putTestRecord(db, 'active-1', 'https://example.info/some-page', 8); + yield putTestRecord(db, 'active-2', 'https://example.com/another-page', 16); + + // Expired subscription; should be dropped. + yield 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`. + yield putTestRecord(db, 'privileged', 'app://chrome/only', Infinity); + + let handshakeDone; + let handshakePromise = new Promise(r => handshakeDone = r); + PushService.init({ + serverURI: 'wss://push.example.org/', + db: 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(); + }, + }); + }, + }); + yield handshakePromise; +}); + +add_task(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; + }), + ]); + + yield PushService.clear({ + domain: '*', + }); + + yield 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 = yield 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 000000000..4db75c026 --- /dev/null +++ b/dom/push/test/xpcshell/test_clear_forgetAboutSite.js @@ -0,0 +1,128 @@ +'use strict'; + +const {PushService, PushServiceWebSocket} = serviceExports; +const {ForgetAboutSite} = Cu.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: userAgentID, + }); + run_next_test(); +} + +add_task(function* setup() { + db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(_ => 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. + yield putTestRecord(db, 'active-sub', 'https://sub.example.com/sub-page', 4); + yield putTestRecord(db, 'expired-sub', 'https://sub.example.com/yet-another-page', 0); + + // Active subscriptions for another subdomain. Should be unsubscribed and + // dropped. + yield putTestRecord(db, 'active-1', 'https://sub2.example.com/some-page', 8); + yield 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`. + yield 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: 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. + yield handshakePromise; +}); + +add_task(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; + } + ), + ]); + yield ForgetAboutSite.removeDataFromDomain('sub.example.com'); + yield promiseForgetSubs; + + deepEqual(modifiedScopes.sort(compareAscending), [ + 'https://sub.example.com/sub-page', + ], 'Should fire modified notifications for active subscriptions'); + + let remainingIDs = yield getAllKeyIDs(db); + deepEqual(remainingIDs, ['active-1', 'active-2', 'privileged'], + 'Should only forget subscriptions for subdomain'); +}); + +add_task(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; + } + ), + ]); + + yield ForgetAboutSite.removeDataFromDomain('example.com'); + yield 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 = yield 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 000000000..6bb500782 --- /dev/null +++ b/dom/push/test/xpcshell/test_clear_origin_data.js @@ -0,0 +1,141 @@ +/* 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 = Task.async(function* (testRecords, pattern) { + let patternString = JSON.stringify(pattern); + yield PushService._clearOriginData(patternString); + + for (let length = testRecords.length; length--;) { + let test = testRecords[length]; + let originSuffix = ChromeUtils.originAttributesToSuffix( + test.originAttributes); + + let registration = yield 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(function* test_webapps_cleardata() { + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {return db.drop().then(_ => db.close());}); + + let testRecords = [{ + scope: 'https://example.org/1', + originAttributes: { appId: 1 }, + clearIf: { appId: 1, inIsolatedMozBrowser: false }, + }, { + scope: 'https://example.org/1', + originAttributes: { appId: 1, inIsolatedMozBrowser: true }, + clearIf: { appId: 1 }, + }, { + scope: 'https://example.org/1', + originAttributes: { appId: 2, inIsolatedMozBrowser: true }, + clearIf: { appId: 2, inIsolatedMozBrowser: true }, + }, { + scope: 'https://example.org/2', + originAttributes: { appId: 1 }, + clearIf: { appId: 1, inIsolatedMozBrowser: false }, + }, { + scope: 'https://example.org/2', + originAttributes: { appId: 2, inIsolatedMozBrowser: true }, + clearIf: { appId: 2, inIsolatedMozBrowser: true }, + }, { + scope: 'https://example.org/3', + originAttributes: { appId: 3, inIsolatedMozBrowser: true }, + clearIf: { inIsolatedMozBrowser: true }, + }, { + scope: 'https://example.org/3', + originAttributes: { appId: 4, inIsolatedMozBrowser: true }, + clearIf: { inIsolatedMozBrowser: true }, + }]; + + 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'); + equal(data.uaid, userAgentID, 'Handshake: wrong device ID'); + 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(); + }, + }); + } + }); + + yield Promise.all(testRecords.map(test => + PushService.register({ + scope: test.scope, + originAttributes: ChromeUtils.originAttributesToSuffix( + test.originAttributes), + }) + )); + + // Removes records for all scopes with the same app ID. Excludes records + // where `inIsolatedMozBrowser` is true. + yield clearForPattern(testRecords, { appId: 1, inIsolatedMozBrowser: false }); + + // Removes the remaining record for app ID 1, where `inIsolatedMozBrowser` is true. + yield clearForPattern(testRecords, { appId: 1 }); + + // Removes all records for all scopes with the same app ID, where + // `inIsolatedMozBrowser` is true. + yield clearForPattern(testRecords, { appId: 2, inIsolatedMozBrowser: true }); + + // Removes all records where `inIsolatedMozBrowser` is true. + yield clearForPattern(testRecords, { inIsolatedMozBrowser: true }); + + equal(testRecords.length, 0, 'Should remove all test records'); + yield unregisterPromise; +}); diff --git a/dom/push/test/xpcshell/test_crypto.js b/dom/push/test/xpcshell/test_crypto.js new file mode 100644 index 000000000..e32f50260 --- /dev/null +++ b/dom/push/test/xpcshell/test_crypto.js @@ -0,0 +1,249 @@ +'use strict'; + +const { + getCryptoParams, + PushCrypto, +} = Cu.import('resource://gre/modules/PushCrypto.jsm', {}); + +function run_test() { + run_next_test(); +} + +add_task(function* test_crypto_getCryptoParams() { + // 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: { + dh: 'Iy1Je2Kv11A', + salt: 'upk1yFkp1xI', + rs: 4096, + padSize: 2, + }, + }, { + desc: 'aesgcm with quoted key param', + headers: { + encoding: 'aesgcm', + crypto_key: 'dh="byfHbUffc-k"', + encryption: 'salt=C11AvAsp6Gc', + }, + params: { + dh: 'byfHbUffc-k', + salt: 'C11AvAsp6Gc', + rs: 4096, + padSize: 2, + }, + }, { + desc: 'aesgcm with Crypto-Key and rs = 24', + headers: { + encoding: 'aesgcm', + crypto_key: 'dh="ybuT4VDz-Bg"', + encryption: 'salt=H7U7wcIoIKs; rs=24', + }, + params: { + dh: 'ybuT4VDz-Bg', + salt: 'H7U7wcIoIKs', + rs: 24, + padSize: 2, + }, + }, { + 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: { + dh: 'LqrDQuVl9lY', + salt: 'YngI8B7YapM', + rs: 2, + padSize: 1, + }, + }, { + desc: 'aesgcm128 with Encryption-Key', + headers: { + encoding: 'aesgcm128', + encryption_key: 'keyid=v2; dh=VA6wmY1IpiE', + encryption: 'keyid=v2; salt=khtpyXhpDKM', + }, + params: { + dh: 'VA6wmY1IpiE', + salt: 'khtpyXhpDKM', + rs: 4096, + padSize: 1, + } + }]; + for (let test of shouldParse) { + let params = getCryptoParams(test.headers); + deepEqual(params, test.params, 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', + }, + }, { + desc: 'Invalid encoding', + headers: { + encoding: 'nonexistent', + }, + }, { + desc: 'Invalid record size', + headers: { + encoding: 'aesgcm', + crypto_key: 'dh=pbmv1QkcEDY', + encryption: 'dh=Esao8aTBfIk;rs=bad', + }, + }, { + desc: 'Insufficiently large record size', + headers: { + encoding: 'aesgcm', + crypto_key: 'dh=fK0EXaw5IU8', + encryption: 'salt=orbLLmlbJfM;rs=1', + }, + }, { + desc: 'aesgcm with Encryption-Key', + headers: { + encoding: 'aesgcm', + encryption_key: 'dh=FplK5KkvUF0', + encryption: 'salt=p6YHhFF3BQY', + }, + }]; + for (let test of shouldThrow) { + throws(() => getCryptoParams(test.headers), test.desc); + } +}); + +add_task(function* test_crypto_decodeMsg() { + let privateKey = { + crv: 'P-256', + d: '4h23G_KkXC9TvBSK2v0Q7ImpS2YAuRd8hQyN0rFAwBg', + ext: true, + key_ops: ['deriveBits'], + kty: 'EC', + x: 'sd85ZCbEG6dEkGMCmDyGBIt454Qy-Yo-1xhbaT2Jlk4', + y: 'vr3cKpQ-Sp1kpZ9HipNjUCwSA55yy0uM8N9byE8dmLs', + }; + let publicKey = ChromeUtils.base64URLDecode('BLHfOWQmxBunRJBjApg8hgSLeOeEMvmKPtcYW2k9iZZOvr3cKpQ-Sp1kpZ9HipNjUCwSA55yy0uM8N9byE8dmLs', { + padding: "reject", + }); + + let expectedSuccesses = [{ + desc: 'padSize = 2, rs = 24, pad = 0', + result: 'Some message', + data: 'Oo34w2F9VVnTMFfKtdx48AZWQ9Li9M6DauWJVgXU', + authSecret: 'aTDc6JebzR6eScy2oLo4RQ', + 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', + headers: { + crypto_key: 'dh=BEaA4gzA3i0JDuirGhiLgymS4hfFX7TNTdEhSk_HBlLpkjgCpjPL5c-GL9uBGIfa_fhGNKKFhXz1k9Kyens2ZpQ', + encryption: 'salt=ZFhzj0S-n29g9P2p4-I7tA; rs=8', + encoding: 'aesgcm', + }, + }, { + desc: 'padSize = 1, rs = 4096, pad = 2', + result: 'aesgcm128 encrypted message', + data: 'ljBJ44NPzJFH9EuyT5xWMU4vpZ90MdAqaq1TC1kOLRoPNHtNFXeJ0GtuSaE', + headers: { + encryption_key: 'dh=BOmnfg02vNd6RZ7kXWWrCGFF92bI-rQ-bV0Pku3-KmlHwbGv4ejWqgasEdLGle5Rhmp6SKJunZw2l2HxKvrIjfI', + encryption: 'salt=btxxUtclbmgcc30b9rT3Bg; rs=4096', + encoding: 'aesgcm128', + }, + }, { + 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', + headers: { + crypto_key: 'dh=BCg6ZIGuE2ZNm2ti6Arf4CDVD_8--aLXAGLYhpghwjl1xxVjTLLpb7zihuEOGGbyt8Qj0_fYHBP4ObxwJNl56bk', + encryption: 'salt=5LIDBXbvkBvvb7ZdD-T4PQ; rs=3', + encoding: 'aesgcm', + }, + }]; + for (let test of expectedSuccesses) { + let authSecret = test.authSecret ? ChromeUtils.base64URLDecode(test.authSecret, { + padding: "reject", + }) : null; + let data = ChromeUtils.base64URLDecode(test.data, { + padding: "reject", + }); + let result = yield PushCrypto.decrypt(privateKey, publicKey, authSecret, + test.headers, data); + let decoder = new TextDecoder('utf-8'); + equal(decoder.decode(new Uint8Array(result)), test.result, test.desc); + } + + let expectedFailures = [{ + desc: 'padSize = 1, rs = 4096, auth secret, pad = 8', + data: 'h0FmyldY8aT5EQ6CJrbfRn_IdDvytoLeHb9_q5CjtdFRfgDRknxLmOzavLaVG4oOiS0r', + senderPublicKey: '', + authSecret: 'Sxb6u0gJIhGEogyLawjmCw', + headers: { + crypto_key: 'dh=BCXHk7O8CE-9AOp6xx7g7c-NCaNpns1PyyHpdcmDaijLbT6IdGq0ezGatBwtFc34BBfscFxdk4Tjksa2Mx5rRCM', + encryption: 'salt=aGBpoKklLtrLcAUCcCr7JQ', + encoding: 'aesgcm128', + }, + }, { + desc: 'Missing padding', + data: 'anvsHj7oBQTPMhv7XSJEsvyMS4-8EtbC7HgFZsKaTg', + headers: { + crypto_key: 'dh=BMSqfc3ohqw2DDgu3nsMESagYGWubswQPGxrW1bAbYKD18dIHQBUmD3ul_lu7MyQiT5gNdzn5JTXQvCcpf-oZE4', + encryption: 'salt=Czx2i18rar8XWOXAVDnUuw', + encoding: 'aesgcm128', + }, + }, { + desc: 'padSize > rs', + data: 'Ct_h1g7O55e6GvuhmpjLsGnv8Rmwvxgw8iDESNKGxk_8E99iHKDzdV8wJPyHA-6b2E6kzuVa5UWiQ7s4Zms1xzJ4FKgoxvBObXkc_r_d4mnb-j245z3AcvRmcYGk5_HZ0ci26SfhAN3lCgxGzTHS4nuHBRkGwOb4Tj4SFyBRlLoTh2jyVK2jYugNjH9tTrGOBg7lP5lajLTQlxOi91-RYZSfFhsLX3LrAkXuRoN7G1CdiI7Y3_eTgbPIPabDcLCnGzmFBTvoJSaQF17huMl_UnWoCj2WovA4BwK_TvWSbdgElNnQ4CbArJ1h9OqhDOphVu5GUGr94iitXRQR-fqKPMad0ULLjKQWZOnjuIdV1RYEZ873r62Yyd31HoveJcSDb1T8l_QK2zVF8V4k0xmK9hGuC0rF5YJPYPHgl5__usknzxMBnRrfV5_MOL5uPZwUEFsu', + headers: { + crypto_key: 'dh=BAcMdWLJRGx-kPpeFtwqR3GE1LWzd1TYh2rg6CEFu53O-y3DNLkNe_BtGtKRR4f7ZqpBMVS6NgfE2NwNPm3Ndls', + encryption: 'salt=NQVTKhB0rpL7ZzKkotTGlA; rs=1', + encoding: 'aesgcm', + }, + }, { + desc: 'Encrypted with padSize = 1, decrypted with padSize = 2 and auth secret', + data: 'fwkuwTTChcLnrzsbDI78Y2EoQzfnbMI8Ax9Z27_rwX8', + authSecret: 'BhbpNTWyO5wVJmVKTV6XaA', + headers: { + crypto_key: 'dh=BCHn-I-J3dfPRLJBlNZ3xFoAqaBLZ6qdhpaz9W7Q00JW1oD-hTxyEECn6KYJNK8AxKUyIDwn6Icx_PYWJiEYjQ0', + encryption: 'salt=c6JQl9eJ0VvwrUVCQDxY7Q', + encoding: 'aesgcm', + }, + }, { + desc: 'Truncated input', + data: 'AlDjj6NvT5HGyrHbT8M5D6XBFSra6xrWS9B2ROaCIjwSu3RyZ1iyuv0', + headers: { + crypto_key: 'dh=BCHn-I-J3dfPRLJBlNZ3xFoAqaBLZ6qdhpaz9W7Q00JW1oD-hTxyEECn6KYJNK8AxKUyIDwn6Icx_PYWJiEYjQ0', + encryption: 'salt=c6JQl9eJ0VvwrUVCQDxY7Q; rs=25', + encoding: 'aesgcm', + }, + }]; + for (let test of expectedFailures) { + let authSecret = test.authSecret ? ChromeUtils.base64URLDecode(test.authSecret, { + padding: "reject", + }) : null; + let data = ChromeUtils.base64URLDecode(test.data, { + padding: "reject", + }); + yield rejects( + PushCrypto.decrypt(privateKey, publicKey, authSecret, + test.headers, data), + test.desc + ); + } +}); 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 000000000..4444753e8 --- /dev/null +++ b/dom/push/test/xpcshell/test_drop_expired.js @@ -0,0 +1,154 @@ +/* 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: uri, + title: uri.spec, + visitDate: timestamp * 1000, + transition: Ci.nsINavHistoryService.TRANSITION_LINK + }); +} + +var putRecord = Task.async(function* ({scope, perm, quota, lastPush, lastVisit}) { + let uri = Services.io.newURI(scope, null, null); + + Services.perms.add(uri, 'desktop-notification', + Ci.nsIPermissionManager[perm]); + do_register_cleanup(() => { + Services.perms.remove(uri, 'desktop-notification'); + }); + + yield visitURI(uri, lastVisit); + + yield db.put({ + channelID: uri.path, + pushEndpoint: 'https://example.org/push' + uri.path, + scope: uri.spec, + pushCount: 0, + lastPush: lastPush, + version: null, + originAttributes: '', + quota: quota, + }); + + return uri; +}); + +function run_test() { + do_get_profile(); + setPrefs({ + userAgentID: userAgentID, + }); + + db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {return db.drop().then(_ => db.close());}); + + run_next_test(); +} + +add_task(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. + yield 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 = yield 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 = yield 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. + yield 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, + })); + }, + }); + }, + }); + + yield subChangePromise; +}); + +add_task(function* test_site_visited() { + let subChangePromise = promiseObserverNotification( + PushServiceComponent.subscriptionChangeTopic, + (subject, data) => data == 'https://example.xyz/expired-quota-exceeded' + ); + + yield visitURI(quotaURI, Date.now()); + PushService.observe(null, 'idle-daily', ''); + + yield subChangePromise; +}); + +add_task(function* test_perm_restored() { + let subChangePromise = promiseObserverNotification( + PushServiceComponent.subscriptionChangeTopic, + (subject, data) => data == 'https://example.info/expired-perm-revoked' + ); + + Services.perms.add(permURI, 'desktop-notification', + Ci.nsIPermissionManager.ALLOW_ACTION); + + yield 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 000000000..fd80d506d --- /dev/null +++ b/dom/push/test/xpcshell/test_handler_service.js @@ -0,0 +1,47 @@ +"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 000000000..19c5a158a --- /dev/null +++ b/dom/push/test/xpcshell/test_notification_ack.js @@ -0,0 +1,125 @@ +/* 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(function* test_notification_ack() { + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {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) { + yield 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); + } + } + }); + } + }); + + yield notifyPromise; + yield 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 000000000..1969bcbd3 --- /dev/null +++ b/dom/push/test/xpcshell/test_notification_data.js @@ -0,0 +1,280 @@ +/* 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: userAgentID, + }); + run_next_test(); +} + +function putRecord(channelID, scope, publicKey, privateKey, authSecret) { + return db.put({ + channelID: channelID, + pushEndpoint: 'https://example.org/push/' + channelID, + scope: 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(function* test_notification_ack_data_setup() { + db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {return db.drop().then(_ => db.close());}); + + yield 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' + ); + yield 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' + ); + yield 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); + } + } + }); + } + }); + yield setupDonePromise; +}); + +add_task(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]); + }; + + yield 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 000000000..3f48f71e0 --- /dev/null +++ b/dom/push/test/xpcshell/test_notification_duplicate.js @@ -0,0 +1,140 @@ +/* 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: userAgentID, + }); + run_next_test(); +} + +// Should acknowledge duplicate notifications, but not notify apps. +add_task(function* test_notification_duplicate() { + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {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) { + yield 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: channelID, + version: 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(expectedAck => + expectedAck.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(); + }, + }); + } + }); + + yield notifyPromise; + yield ackPromise; + + for (let {channelID, recents} of testData) { + let record = yield 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 000000000..74631f4f8 --- /dev/null +++ b/dom/push/test/xpcshell/test_notification_error.js @@ -0,0 +1,117 @@ +/* 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: userAgentID, + }); + run_next_test(); +} + +add_task(function* test_notification_error() { + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {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: 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: 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: originAttributes, + version: 3, + quota: Infinity, + systemRecord: true, + }]; + for (let record of records) { + yield 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 + }); + } + }); + + yield 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'); + + yield ackPromise; + + let aRecord = yield db.getByIdentifiers({scope: 'https://example.com/a', + originAttributes: 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 = yield db.getByIdentifiers({scope: 'https://example.com/b', + originAttributes: 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 = yield db.getByIdentifiers({scope: 'https://example.com/c', + originAttributes: 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_http2.js b/dom/push/test/xpcshell/test_notification_http2.js new file mode 100644 index 000000000..1b334bfba --- /dev/null +++ b/dom/push/test/xpcshell/test_notification_http2.js @@ -0,0 +1,189 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +'use strict'; + +Cu.import("resource://gre/modules/Services.jsm"); + +const {PushDB, PushService, PushServiceHttp2} = serviceExports; + +var prefs; +var tlsProfile; + +var serverPort = -1; + +function run_test() { + serverPort = getTestServerPort(); + + do_get_profile(); + setPrefs({ + 'testing.allowInsecureServerURL': true, + }); + prefs = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefBranch); + + tlsProfile = prefs.getBoolPref("network.http.spdy.enforce-tls-profile"); + + // Set to allow the cert presented by our H2 server + var oldPref = prefs.getIntPref("network.http.speculative-parallel-limit"); + prefs.setIntPref("network.http.speculative-parallel-limit", 0); + prefs.setBoolPref("network.http.spdy.enforce-tls-profile", false); + prefs.setBoolPref("dom.push.enabled", true); + prefs.setBoolPref("dom.push.connection.enabled", true); + + addCertOverride("localhost", serverPort, + Ci.nsICertOverrideService.ERROR_UNTRUSTED | + Ci.nsICertOverrideService.ERROR_MISMATCH | + Ci.nsICertOverrideService.ERROR_TIME); + + prefs.setIntPref("network.http.speculative-parallel-limit", oldPref); + + run_next_test(); +} + +add_task(function* test_pushNotifications() { + + // /pushNotifications/subscription1 will send a message with no rs and padding + // length 1. + // /pushNotifications/subscription2 will send a message with no rs and padding + // length 16. + // /pushNotifications/subscription3 will send a message with rs equal 24 and + // padding length 16. + // /pushNotifications/subscription4 will send a message with no rs and padding + // length 256. + + let db = PushServiceHttp2.newPushDB(); + do_register_cleanup(() => { + return db.drop().then(_ => db.close()); + }); + + var serverURL = "https://localhost:" + serverPort; + + let records = [{ + subscriptionUri: serverURL + '/pushNotifications/subscription1', + pushEndpoint: serverURL + '/pushEndpoint1', + pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint1', + scope: 'https://example.com/page/1', + 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: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + quota: Infinity, + systemRecord: true, + }, { + subscriptionUri: serverURL + '/pushNotifications/subscription2', + pushEndpoint: serverURL + '/pushEndpoint2', + pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint2', + scope: 'https://example.com/page/2', + p256dhPublicKey: 'BPnWyUo7yMnuMlyKtERuLfWE8a09dtdjHSW2lpC9_BqR5TZ1rK8Ldih6ljyxVwnBA-nygQHGRpEmu1jV5K8437E', + p256dhPrivateKey: { + crv: 'P-256', + d: 'lFm4nPsUKYgNGBJb5nXXKxl8bspCSp0bAhCYxbveqT4', + ext: true, + key_ops: ["deriveBits"], + kty: 'EC', + x: '-dbJSjvIye4yXIq0RG4t9YTxrT1212MdJbaWkL38GpE', + y: '5TZ1rK8Ldih6ljyxVwnBA-nygQHGRpEmu1jV5K8437E' + }, + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + quota: Infinity, + systemRecord: true, + }, { + subscriptionUri: serverURL + '/pushNotifications/subscription3', + pushEndpoint: serverURL + '/pushEndpoint3', + pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint3', + scope: 'https://example.com/page/3', + p256dhPublicKey: 'BDhUHITSeVrWYybFnb7ylVTCDDLPdQWMpf8gXhcWwvaaJa6n3YH8TOcH8narDF6t8mKVvg2ioLW-8MH5O4dzGcI', + p256dhPrivateKey: { + crv: 'P-256', + d: 'Q1_SE1NySTYzjbqgWwPgrYh7XRg3adqZLkQPsy319G8', + ext: true, + key_ops: ["deriveBits"], + kty: 'EC', + x: 'OFQchNJ5WtZjJsWdvvKVVMIMMs91BYyl_yBeFxbC9po', + y: 'Ja6n3YH8TOcH8narDF6t8mKVvg2ioLW-8MH5O4dzGcI' + }, + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + quota: Infinity, + systemRecord: true, + }, { + subscriptionUri: serverURL + '/pushNotifications/subscription4', + pushEndpoint: serverURL + '/pushEndpoint4', + pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint4', + scope: 'https://example.com/page/4', + p256dhPublicKey: ChromeUtils.base64URLDecode('BEcvDzkWCrUtjU_wygL98sbQCQrW1lY9irtgGnlCc4B0JJXLCHB9MTM73qD6GZYfL0YOvKo8XLOflh-J4dMGklU', { + padding: "reject", + }), + p256dhPrivateKey: { + crv: 'P-256', + d: 'fWi7tZaX0Pk6WnLrjQ3kiRq_g5XStL5pdH4pllNCqXw', + ext: true, + key_ops: ["deriveBits"], + kty: 'EC', + x: 'Ry8PORYKtS2NT_DKAv3yxtAJCtbWVj2Ku2AaeUJzgHQ', + y: 'JJXLCHB9MTM73qD6GZYfL0YOvKo8XLOflh-J4dMGklU' + }, + authenticationSecret: ChromeUtils.base64URLDecode('cwDVC1iwAn8E37mkR3tMSg', { + padding: "reject", + }), + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + quota: Infinity, + systemRecord: true, + }]; + + for (let record of records) { + yield db.put(record); + } + + let notifyPromise = Promise.all([ + promiseObserverNotification(PushServiceComponent.pushTopic, function(subject, data) { + var message = subject.QueryInterface(Ci.nsIPushMessage).data; + if (message && (data == "https://example.com/page/1")){ + equal(message.text(), "Some message", "decoded message is incorrect"); + return true; + } + }), + promiseObserverNotification(PushServiceComponent.pushTopic, function(subject, data) { + var message = subject.QueryInterface(Ci.nsIPushMessage).data; + if (message && (data == "https://example.com/page/2")){ + equal(message.text(), "Some message", "decoded message is incorrect"); + return true; + } + }), + promiseObserverNotification(PushServiceComponent.pushTopic, function(subject, data) { + var message = subject.QueryInterface(Ci.nsIPushMessage).data; + if (message && (data == "https://example.com/page/3")){ + equal(message.text(), "Some message", "decoded message is incorrect"); + return true; + } + }), + promiseObserverNotification(PushServiceComponent.pushTopic, function(subject, data) { + var message = subject.QueryInterface(Ci.nsIPushMessage).data; + if (message && (data == "https://example.com/page/4")){ + equal(message.text(), "Yet another message", "decoded message is incorrect"); + return true; + } + }), + ]); + + PushService.init({ + serverURI: serverURL, + db + }); + + yield notifyPromise; +}); + +add_task(function* test_complete() { + prefs.setBoolPref("network.http.spdy.enforce-tls-profile", tlsProfile); +}); 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 000000000..ed2cec986 --- /dev/null +++ b/dom/push/test/xpcshell/test_notification_incomplete.js @@ -0,0 +1,130 @@ +/* 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: userAgentID, + }); + run_next_test(); +} + +add_task(function* test_notification_incomplete() { + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {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) { + yield db.put(record); + } + + function observeMessage(subject, topic, data) { + ok(false, 'Should not deliver malformed updates'); + } + do_register_cleanup(() => + Services.obs.removeObserver(observeMessage, PushServiceComponent.pushTopic)); + Services.obs.addObserver(observeMessage, PushServiceComponent.pushTopic, false); + + 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'); + } + }); + } + }); + + yield notificationPromise; + + let storeRecords = yield 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 000000000..aa39c2f89 --- /dev/null +++ b/dom/push/test/xpcshell/test_notification_version_string.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 = 'ba31ac13-88d4-4984-8e6b-8731315a7cf8'; + +function run_test() { + do_get_profile(); + setPrefs({ + userAgentID: userAgentID, + }); + run_next_test(); +} + +add_task(function* test_notification_version_string() { + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {return db.drop().then(_ => db.close());}); + yield 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, data: scope} = yield notifyPromise; + equal(message.QueryInterface(Ci.nsIPushMessage).data, null, + 'Unexpected data for Simple Push message'); + + yield ackPromise; + + let storeRecord = yield 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 000000000..2a610475a --- /dev/null +++ b/dom/push/test/xpcshell/test_observer_data.js @@ -0,0 +1,42 @@ +'use strict'; + +var pushNotifier = Cc['@mozilla.org/push/Notifier;1'] + .getService(Ci.nsIPushNotifier); +var systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal(); + +function run_test() { + run_next_test(); +} + +add_task(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.length, payload); + + let data = (yield 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(function* test_empty_notifyWithData() { + let notifyPromise = + promiseObserverNotification(PushServiceComponent.pushTopic); + pushNotifier.notifyPushWithData('chrome://notify-test', systemPrincipal, + '' /* messageId */, 0, null); + + let data = (yield notifyPromise).subject.QueryInterface( + Ci.nsIPushMessage).data; + throws(_ => data.json(), + '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 000000000..80903bed3 --- /dev/null +++ b/dom/push/test/xpcshell/test_observer_remoting.js @@ -0,0 +1,111 @@ +'use strict'; + +const pushNotifier = Cc['@mozilla.org/push/Notifier;1'] + .getService(Ci.nsIPushNotifier); + +add_task(function* test_observer_remoting() { + if (isParent) { + yield testInParent(); + } else { + yield testInChild(); + } +}); + +const childTests = [{ + text: 'Hello from child!', + principal: Services.scriptSecurityManager.getSystemPrincipal(), +}]; + +const parentTests = [{ + text: 'Hello from parent!', + principal: Services.scriptSecurityManager.getSystemPrincipal(), +}]; + +function* testInParent() { + // 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'); + yield promiseNotifications; + + // Wait until the child is listening for notifications from the parent. + yield do_await_remote_message('push_test_observer_remoting_child_ready'); + + // Fire an observer notification in the parent that should be forwarded to + // the child. + yield parentTests.reduce( + (p, test) => p.then(_ => waitForNotifierObservers(test, /* shouldNotify = */ true)), + Promise.resolve() + ); + + // Wait for the child to exit. + yield promiseFinished; +} + +function* testInChild() { + // Fire an observer notification in the child that should be forwarded to + // the parent. + yield 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'); + yield promiseNotifierObservers; +} + +var waitForNotifierObservers = Task.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.length, data); + pushNotifier.notifySubscriptionChange(scope, principal); + pushNotifier.notifySubscriptionModified(scope, principal); + } + + let { + data: notifyScope, + subject: notifySubject, + } = yield 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, + } = yield 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, + } = yield 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 000000000..ff9de26ad --- /dev/null +++ b/dom/push/test/xpcshell/test_permissions.js @@ -0,0 +1,296 @@ +/* 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(); + do_register_cleanup(() => {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: XPCOMUtils.generateQI([Ci.nsIPermission]), + capability: Ci.nsIPermissionManager[capability], + expireTime: 0, + expireType: Ci.nsIPermissionManager.EXPIRE_NEVER, + principal: Services.scriptSecurityManager.getCodebasePrincipal( + Services.io.newURI(url, null, null) + ), + 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(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. + yield putTestRecord(db, 'active-allow', 'https://example.info/page/1', 8); + + // Expired registration; should be dropped. + yield putTestRecord(db, 'expired-allow', 'https://example.info/page/2', 0); + + // Active registration; should be expired when we change the permission + // to "deny". + yield 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. + yield putTestRecord(db, 'active-deny-added-1', 'https://example.net/ham', 16); + yield 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. + yield 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`. + yield putTestRecord(db, 'never-expires', 'app://chrome/only', Infinity); + + // A registration that should be dropped when we clear the permission + // list. + yield 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) {}, + }); + } + }); + yield handshakePromise; +}); + +add_task(function* test_permissions_allow_added() { + let subChangePromise = promiseSubscriptionChanges(1); + + yield PushService._onPermissionChange( + makePushPermission('https://example.info', 'ALLOW_ACTION'), + 'added' + ); + let notifiedScopes = yield subChangePromise; + + deepEqual(notifiedScopes, [ + 'https://example.info/page/2', + ], 'Wrong scopes after adding allow'); + + let record = yield db.getByKeyID('active-allow'); + equal(record.quota, 16, + 'Should reset quota for active records after adding allow'); + + record = yield db.getByKeyID('expired-allow'); + ok(!record, 'Should drop expired records after adding allow'); +}); + +add_task(function* test_permissions_allow_deleted() { + let subModifiedPromise = promiseSubscriptionModifications(1); + + let unregisterPromise = promiseUnregister('active-allow'); + + yield PushService._onPermissionChange( + makePushPermission('https://example.info', 'ALLOW_ACTION'), + 'deleted' + ); + + yield unregisterPromise; + + let notifiedScopes = yield subModifiedPromise; + deepEqual(notifiedScopes, [ + 'https://example.info/page/1', + ], 'Wrong scopes modified after deleting allow'); + + let record = yield db.getByKeyID('active-allow'); + ok(record.isExpired(), + 'Should expire active record after deleting allow'); +}); + +add_task(function* test_permissions_deny_added() { + let subModifiedPromise = promiseSubscriptionModifications(2); + + let unregisterPromise = Promise.all([ + promiseUnregister('active-deny-added-1'), + promiseUnregister('active-deny-added-2'), + ]); + + yield PushService._onPermissionChange( + makePushPermission('https://example.net', 'DENY_ACTION'), + 'added' + ); + yield unregisterPromise; + + let notifiedScopes = yield subModifiedPromise; + deepEqual(notifiedScopes, [ + 'https://example.net/green', + 'https://example.net/ham', + ], 'Wrong scopes modified after adding deny'); + + let isExpired = yield allExpired( + 'active-deny-added-1', + 'expired-deny-added' + ); + ok(isExpired, 'Should expire all registrations after adding deny'); +}); + +add_task(function* test_permissions_deny_deleted() { + yield PushService._onPermissionChange( + makePushPermission('https://example.net', 'DENY_ACTION'), + 'deleted' + ); + + let isExpired = yield allExpired( + 'active-deny-added-1', + 'expired-deny-added' + ); + ok(isExpired, 'Should retain expired registrations after deleting deny'); +}); + +add_task(function* test_permissions_allow_changed() { + let subChangePromise = promiseSubscriptionChanges(3); + + yield PushService._onPermissionChange( + makePushPermission('https://example.net', 'ALLOW_ACTION'), + 'changed' + ); + + let notifiedScopes = yield subChangePromise; + + deepEqual(notifiedScopes, [ + 'https://example.net/eggs', + 'https://example.net/green', + 'https://example.net/ham' + ], 'Wrong scopes after changing to allow'); + + let droppedRecords = yield 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(function* test_permissions_deny_changed() { + let subModifiedPromise = promiseSubscriptionModifications(1); + + let unregisterPromise = promiseUnregister('active-deny-changed'); + + yield PushService._onPermissionChange( + makePushPermission('https://example.xyz', 'DENY_ACTION'), + 'changed' + ); + + yield unregisterPromise; + + let notifiedScopes = yield subModifiedPromise; + deepEqual(notifiedScopes, [ + 'https://example.xyz/page/1', + ], 'Wrong scopes modified after changing to deny'); + + let record = yield db.getByKeyID('active-deny-changed'); + ok(record.isExpired(), + 'Should expire active record after changing to deny'); +}); + +add_task(function* test_permissions_clear() { + let subModifiedPromise = promiseSubscriptionModifications(3); + + deepEqual(yield 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'), + ]); + + yield PushService._onPermissionChange(null, 'cleared'); + + yield unregisterPromise; + + let notifiedScopes = yield subModifiedPromise; + deepEqual(notifiedScopes, [ + 'https://example.edu/lonely', + 'https://example.info/page/1', + 'https://example.xyz/page/1', + ], 'Wrong scopes modified after clearing registrations'); + + deepEqual(yield 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 000000000..1982fe04c --- /dev/null +++ b/dom/push/test/xpcshell/test_quota_exceeded.js @@ -0,0 +1,141 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +'use strict'; + +const {PushDB, PushService, PushServiceWebSocket} = serviceExports; + +Cu.import("resource://gre/modules/Task.jsm"); + +const userAgentID = '7eb873f9-8d47-4218-804b-fff78dc04e88'; + +function run_test() { + do_get_profile(); + setPrefs({ + userAgentID, + 'testing.ignorePermission': true, + }); + run_next_test(); +} + +add_task(function* test_expiration_origin_threshold() { + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => db.drop().then(_ => db.close())); + + yield 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, + }); + yield 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. + yield 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) {}, + }); + }, + }); + + yield unregisterPromise; + + yield notifyPromise; + + let expiredRecord = yield 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 000000000..9401a5c86 --- /dev/null +++ b/dom/push/test/xpcshell/test_quota_observer.js @@ -0,0 +1,183 @@ +/* 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 = Task.async(function* (perm, record) { + let uri = Services.io.newURI(record.scope, null, null); + + Services.perms.add(uri, 'desktop-notification', + Ci.nsIPermissionManager[perm]); + do_register_cleanup(() => { + Services.perms.remove(uri, 'desktop-notification'); + }); + + yield db.put(record); +}); + +add_task(function* test_expiration_history_observer() { + db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => db.drop().then(_ => db.close())); + + // A registration that we'll expire... + yield 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. + yield 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, + }); + + yield 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) {}, + }); + } + }); + + yield subChangePromise; + yield unregisterPromise; + + let expiredRecord = yield 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. + yield 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. + yield 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... + yield 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. + yield subChangePromise; + deepEqual(notifiedScopes.sort(), [ + 'https://example.com/auctions', + 'https://example.com/deals' + ], 'Wrong scopes for subscription changes'); + + let aRecord = yield db.getByKeyID('379c0668-8323-44d2-a315-4ee83f1a9ee9'); + ok(!aRecord, 'Should drop expired record'); + + let bRecord = yield 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. + yield 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.isCodebasePrincipal, + 'Should pass subscription principal as the subject'); + return true; + } + }); + let record = yield PushService.registration({ + scope: 'https://example.net/sales', + originAttributes: '', + }); + ok(!record, 'Should not return evicted record'); + ok(!(yield db.getByKeyID('6b2d13fe-d848-4c5f-bdda-e9fc89727dca')), + 'Should drop evicted record on fetch'); + yield 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 000000000..556cc9d0c --- /dev/null +++ b/dom/push/test/xpcshell/test_quota_with_notification.js @@ -0,0 +1,120 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +'use strict'; + +const {PushDB, PushService, PushServiceWebSocket} = serviceExports; + +Cu.import("resource://gre/modules/Task.jsm"); + +const userAgentID = 'aaabf1f8-2f68-44f1-a920-b88e9e7d7559'; +const nsIPushQuotaManager = Components.interfaces.nsIPushQuotaManager; + +function run_test() { + do_get_profile(); + setPrefs({ + userAgentID, + 'testing.ignorePermission': true, + }); + run_next_test(); +} + +add_task(function* test_expiration_origin_threshold() { + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => { + 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"); + + yield 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. + yield 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) {}, + }); + }, + }); + + yield notifyPromise; + + yield updateQuotaPromise; + yield modifiedPromise; + + let expiredRecord = yield 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 000000000..d8a21789d --- /dev/null +++ b/dom/push/test/xpcshell/test_reconnect_retry.js @@ -0,0 +1,73 @@ +/* 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(function* test_reconnect_retry() { + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {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 = yield PushService.register({ + scope: 'https://example.com/page/1', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }); + let retryEndpoint = 'https://example.org/push/' + channelID; + equal(registration.endpoint, retryEndpoint, 'Wrong endpoint for retried request'); + + registration = yield PushService.register({ + scope: 'https://example.com/page/2', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, 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 000000000..7807fb9d3 --- /dev/null +++ b/dom/push/test/xpcshell/test_record.js @@ -0,0 +1,93 @@ +'use strict'; + +const {PushRecord} = Cu.import('resource://gre/modules/PushRecord.jsm', {}); + +function run_test() { + run_next_test(); +} + +add_task(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(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); + equal(record.uri.spec, props.scope, + `Record URI should match scope URL for ${JSON.stringify(props)}`); + if (props.originAttributes) { + let 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`); + let permURI = Services.io.newURI(props.scope, null, null); + Services.perms.add(permURI, 'desktop-notification', + Ci.nsIPermissionManager.ALLOW_ACTION); + try { + ok(record.hasPermission(), `Record ${ + JSON.stringify(props)} should have permission`); + } finally { + Services.perms.remove(permURI, 'desktop-notification'); + } +} + +add_task(function* test_principal_permissions() { + let testProps = [{ + scope: 'https://example.com/', + }, { + scope: 'https://example.com/', + originAttributes: '^userContextId=1', + }, { + scope: 'https://блог.фанфрог.рф/', + }, { + scope: 'https://блог.фанфрог.рф/', + 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 000000000..8199481e4 --- /dev/null +++ b/dom/push/test/xpcshell/test_register_5xxCode_http2.js @@ -0,0 +1,112 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +'use strict'; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.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) { + do_check_true(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(function* test1() { + + let db = PushServiceHttp2.newPushDB(); + do_register_cleanup(() => { + 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({ + appId: Ci.nsIScriptSecurityManager.NO_APP_ID, + inIsolatedMozBrowser: false, + }); + let newRecord = yield PushService.register({ + scope: 'https://example.com/retry5xxCode', + originAttributes: 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 = yield 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 000000000..98670c742 --- /dev/null +++ b/dom/push/test/xpcshell/test_register_case.js @@ -0,0 +1,56 @@ +/* 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(function* test_register_case() { + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {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 = yield PushService.register({ + scope: 'https://example.net/case', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }); + equal(newRecord.endpoint, 'https://example.com/update/case', + 'Wrong push endpoint in registration record'); + + let record = yield 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_error_http2.js b/dom/push/test/xpcshell/test_register_error_http2.js new file mode 100644 index 000000000..eeb3b64b0 --- /dev/null +++ b/dom/push/test/xpcshell/test_register_error_http2.js @@ -0,0 +1,201 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +'use strict'; + +Cu.import("resource://gre/modules/Services.jsm"); + +const {PushDB, PushService, PushServiceHttp2} = serviceExports; + +var prefs; +var tlsProfile; +var serverURL; + +var serverPort = -1; + +function run_test() { + serverPort = getTestServerPort(); + + do_get_profile(); + prefs = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefBranch); + + tlsProfile = prefs.getBoolPref("network.http.spdy.enforce-tls-profile"); + + serverURL = "https://localhost:" + serverPort; + + run_next_test(); +} + +// Connection will fail because of the certificates. +add_task(function* test_pushSubscriptionNoConnection() { + + let db = PushServiceHttp2.newPushDB(); + do_register_cleanup(() => { + return db.drop().then(_ => db.close()); + }); + + PushService.init({ + serverURI: serverURL + "/pushSubscriptionNoConnection/subscribe", + db + }); + + yield rejects( + PushService.register({ + scope: 'https://example.net/page/invalid-response', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }), + 'Expected error for not being able to establish connecion.' + ); + + let record = yield db.getAllKeyIDs(); + ok(record.length === 0, "Should not store records when connection couldn't be established."); + PushService.uninit(); +}); + +add_task(function* test_TLS() { + // Set to allow the cert presented by our H2 server + var oldPref = prefs.getIntPref("network.http.speculative-parallel-limit"); + prefs.setIntPref("network.http.speculative-parallel-limit", 0); + prefs.setBoolPref("network.http.spdy.enforce-tls-profile", false); + + addCertOverride("localhost", serverPort, + Ci.nsICertOverrideService.ERROR_UNTRUSTED | + Ci.nsICertOverrideService.ERROR_MISMATCH | + Ci.nsICertOverrideService.ERROR_TIME); + + prefs.setIntPref("network.http.speculative-parallel-limit", oldPref); +}); + +add_task(function* test_pushSubscriptionMissingLocation() { + + let db = PushServiceHttp2.newPushDB(); + do_register_cleanup(() => { + return db.drop().then(_ => db.close()); + }); + + PushService.init({ + serverURI: serverURL + "/pushSubscriptionMissingLocation/subscribe", + db + }); + + yield rejects( + PushService.register({ + scope: 'https://example.net/page/invalid-response', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }), + 'Expected error for the missing location header.' + ); + + let record = yield db.getAllKeyIDs(); + ok(record.length === 0, 'Should not store records when the location header is missing.'); + PushService.uninit(); +}); + +add_task(function* test_pushSubscriptionMissingLink() { + + let db = PushServiceHttp2.newPushDB(); + do_register_cleanup(() => { + return db.drop().then(_ => db.close()); + }); + + PushService.init({ + serverURI: serverURL + "/pushSubscriptionMissingLink/subscribe", + db + }); + + yield rejects( + PushService.register({ + scope: 'https://example.net/page/invalid-response', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }), + 'Expected error for the missing link header.' + ); + + let record = yield db.getAllKeyIDs(); + ok(record.length === 0, 'Should not store records when a link header is missing.'); + PushService.uninit(); +}); + +add_task(function* test_pushSubscriptionMissingLink1() { + + let db = PushServiceHttp2.newPushDB(); + do_register_cleanup(() => { + return db.drop().then(_ => db.close()); + }); + + PushService.init({ + serverURI: serverURL + "/pushSubscriptionMissingLink1/subscribe", + db + }); + + yield rejects( + PushService.register({ + scope: 'https://example.net/page/invalid-response', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }), + 'Expected error for the missing push endpoint.' + ); + + let record = yield db.getAllKeyIDs(); + ok(record.length === 0, 'Should not store records when the push endpoint is missing.'); + PushService.uninit(); +}); + +add_task(function* test_pushSubscriptionLocationBogus() { + + let db = PushServiceHttp2.newPushDB(); + do_register_cleanup(() => { + return db.drop().then(_ => db.close()); + }); + + PushService.init({ + serverURI: serverURL + "/pushSubscriptionLocationBogus/subscribe", + db + }); + + yield rejects( + PushService.register({ + scope: 'https://example.net/page/invalid-response', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }), + 'Expected error for the bogus location' + ); + + let record = yield db.getAllKeyIDs(); + ok(record.length === 0, 'Should not store records when location header is bogus.'); + PushService.uninit(); +}); + +add_task(function* test_pushSubscriptionNot2xxCode() { + + let db = PushServiceHttp2.newPushDB(); + do_register_cleanup(() => { + return db.drop().then(_ => db.close()); + }); + + PushService.init({ + serverURI: serverURL + "/pushSubscriptionNot201Code/subscribe", + db + }); + + yield rejects( + PushService.register({ + scope: 'https://example.net/page/invalid-response', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }), + 'Expected error for not 201 responce code.' + ); + + let record = yield db.getAllKeyIDs(); + ok(record.length === 0, 'Should not store records when respons code is not 201.'); +}); + +add_task(function* test_complete() { + prefs.setBoolPref("network.http.spdy.enforce-tls-profile", tlsProfile); +}); 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 000000000..49d2fe674 --- /dev/null +++ b/dom/push/test/xpcshell/test_register_flush.js @@ -0,0 +1,96 @@ +/* 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(function* test_register_flush() { + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {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, + }; + yield 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 = yield 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} = yield notifyPromise; + equal(scope, 'https://example.com/page/1', 'Wrong notification scope'); + + yield ackPromise; + + let prevRecord = yield 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 = yield 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 000000000..cd82ebef3 --- /dev/null +++ b/dom/push/test/xpcshell/test_register_invalid_channel.js @@ -0,0 +1,57 @@ +/* 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(function* test_register_invalid_channel() { + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {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' + })); + } + }); + } + }); + + yield rejects( + PushService.register({ + scope: 'https://example.com/invalid-channel', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }), + 'Expected error for invalid channel ID' + ); + + let record = yield 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 000000000..03b9efbaf --- /dev/null +++ b/dom/push/test/xpcshell/test_register_invalid_endpoint.js @@ -0,0 +1,58 @@ +/* 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(function* test_register_invalid_endpoint() { + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {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: '!@#$%^&*' + })); + } + }); + } + }); + + yield rejects( + PushService.register({ + scope: 'https://example.net/page/invalid-endpoint', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }), + 'Expected error for invalid endpoint' + ); + + let record = yield 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 000000000..a2ec51588 --- /dev/null +++ b/dom/push/test/xpcshell/test_register_invalid_json.js @@ -0,0 +1,58 @@ +/* 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(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++; + } + }); + } + }); + + yield rejects( + PushService.register({ + scope: 'https://example.net/page/invalid-json', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }), + 'Expected error for invalid JSON response' + ); + + yield 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 000000000..815dff1dd --- /dev/null +++ b/dom/push/test/xpcshell/test_register_no_id.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; + +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(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 + })); + } + }); + } + }); + + yield rejects( + PushService.register({ + scope: 'https://example.com/incomplete', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }), + 'Expected error for incomplete register response' + ); + + yield 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 000000000..75ca1d348 --- /dev/null +++ b/dom/push/test/xpcshell/test_register_request_queue.js @@ -0,0 +1,61 @@ +/* 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(function* test_register_request_queue() { + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {return db.drop().then(_ => db.close());}); + + let onHello; + let helloPromise = new Promise(resolve => onHello = after(2, function onHello(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( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }); + let secondRegister = PushService.register({ + scope: 'https://example.com/page/1', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }); + + yield Promise.all([ + rejects(firstRegister, 'Should time out the first request'), + rejects(secondRegister, 'Should time out the second request') + ]); + + yield 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 000000000..5a316257b --- /dev/null +++ b/dom/push/test/xpcshell/test_register_rollback.js @@ -0,0 +1,87 @@ +/* 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(function* test_register_rollback() { + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {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++; + equal(request.uaid, userAgentID, 'Handshake: wrong device ID'); + 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. + yield rejects( + PushService.register({ + scope: 'https://example.com/storage-error', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }), + 'Expected error for unregister database failure' + ); + + // Should send an out-of-band unregister request. + yield 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 000000000..94d09546a --- /dev/null +++ b/dom/push/test/xpcshell/test_register_success.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 = '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(function* test_register_success() { + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {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'); + equal(data.uaid, userAgentID, 'Handshake: wrong device ID'); + 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: channelID, + uaid: userAgentID, + pushEndpoint: 'https://example.com/update/1', + })); + } + }); + } + }); + + let subModifiedPromise = promiseObserverNotification( + PushServiceComponent.subscriptionModifiedTopic); + + let newRecord = yield PushService.register({ + scope: 'https://example.org/1', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }); + equal(newRecord.endpoint, 'https://example.com/update/1', + 'Wrong push endpoint in registration record'); + + let {data: subModifiedScope} = yield subModifiedPromise; + equal(subModifiedScope, 'https://example.org/1', + 'Should fire a subscription modified event after subscribing'); + + let record = yield 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_success_http2.js b/dom/push/test/xpcshell/test_register_success_http2.js new file mode 100644 index 000000000..b4dbb09e3 --- /dev/null +++ b/dom/push/test/xpcshell/test_register_success_http2.js @@ -0,0 +1,128 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +'use strict'; + +Cu.import("resource://gre/modules/Services.jsm"); + +const {PushDB, PushService, PushServiceHttp2} = serviceExports; + +var prefs; +var tlsProfile; +var serverURL; +var serverPort = -1; +var pushEnabled; +var pushConnectionEnabled; +var db; + +function run_test() { + serverPort = getTestServerPort(); + + do_get_profile(); + prefs = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefBranch); + + tlsProfile = prefs.getBoolPref("network.http.spdy.enforce-tls-profile"); + pushEnabled = prefs.getBoolPref("dom.push.enabled"); + pushConnectionEnabled = prefs.getBoolPref("dom.push.connection.enabled"); + + // Set to allow the cert presented by our H2 server + var oldPref = prefs.getIntPref("network.http.speculative-parallel-limit"); + prefs.setIntPref("network.http.speculative-parallel-limit", 0); + prefs.setBoolPref("network.http.spdy.enforce-tls-profile", false); + prefs.setBoolPref("dom.push.enabled", true); + prefs.setBoolPref("dom.push.connection.enabled", true); + + addCertOverride("localhost", serverPort, + Ci.nsICertOverrideService.ERROR_UNTRUSTED | + Ci.nsICertOverrideService.ERROR_MISMATCH | + Ci.nsICertOverrideService.ERROR_TIME); + + prefs.setIntPref("network.http.speculative-parallel-limit", oldPref); + + serverURL = "https://localhost:" + serverPort; + + run_next_test(); +} + +add_task(function* test_setup() { + + db = PushServiceHttp2.newPushDB(); + do_register_cleanup(() => { + return db.drop().then(_ => db.close()); + }); + +}); + +add_task(function* test_pushSubscriptionSuccess() { + + PushService.init({ + serverURI: serverURL + "/pushSubscriptionSuccess/subscribe", + db + }); + + let newRecord = yield PushService.register({ + scope: 'https://example.org/1', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }); + + var subscriptionUri = serverURL + '/pushSubscriptionSuccesss'; + var pushEndpoint = serverURL + '/pushEndpointSuccess'; + var pushReceiptEndpoint = serverURL + '/receiptPushEndpointSuccess'; + equal(newRecord.endpoint, pushEndpoint, + 'Wrong push endpoint in registration record'); + + equal(newRecord.pushReceiptEndpoint, pushReceiptEndpoint, + 'Wrong push endpoint receipt in registration record'); + + let record = yield 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.org/1', + 'Wrong scope in database record'); + + PushService.uninit() +}); + +add_task(function* test_pushSubscriptionMissingLink2() { + + PushService.init({ + serverURI: serverURL + "/pushSubscriptionMissingLink2/subscribe", + db + }); + + let newRecord = yield PushService.register({ + scope: 'https://example.org/no_receiptEndpoint', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }); + + var subscriptionUri = serverURL + '/subscriptionMissingLink2'; + var pushEndpoint = serverURL + '/pushEndpointMissingLink2'; + var pushReceiptEndpoint = ''; + equal(newRecord.endpoint, pushEndpoint, + 'Wrong push endpoint in registration record'); + + equal(newRecord.pushReceiptEndpoint, pushReceiptEndpoint, + 'Wrong push endpoint receipt in registration record'); + + let record = yield 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.org/no_receiptEndpoint', + 'Wrong scope in database record'); +}); + +add_task(function* test_complete() { + prefs.setBoolPref("network.http.spdy.enforce-tls-profile", tlsProfile); + prefs.setBoolPref("dom.push.enabled", pushEnabled); + prefs.setBoolPref("dom.push.connection.enabled", pushConnectionEnabled); +}); 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 000000000..c2da107f8 --- /dev/null +++ b/dom/push/test/xpcshell/test_register_timeout.js @@ -0,0 +1,87 @@ +/* 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(function* test_register_timeout() { + let handshakes = 0; + let timeoutDone; + let timeoutPromise = new Promise(resolve => timeoutDone = resolve); + let registers = 0; + + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {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 (handshakes === 0) { + equal(request.uaid, null, 'Should not include device ID'); + } else if (handshakes === 1) { + // Should use the previously-issued device ID when reconnecting, + // but should not include the timed-out channel ID. + equal(request.uaid, userAgentID, + 'Should include device ID on reconnect'); + } else { + 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'); + setTimeout(() => { + // Should ignore replies for timed-out requests. + this.serverSendMsg(JSON.stringify({ + messageType: 'register', + status: 200, + channelID: channelID, + uaid: userAgentID, + pushEndpoint: 'https://example.com/update/timeout', + })); + timeoutDone(); + }, 2000); + registers++; + } + }); + } + }); + + yield rejects( + PushService.register({ + scope: 'https://example.net/page/timeout', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }), + 'Expected error for request timeout' + ); + + let record = yield db.getByKeyID(channelID); + ok(!record, 'Should not store records for timed-out responses'); + + yield 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 000000000..a929ada03 --- /dev/null +++ b/dom/push/test/xpcshell/test_register_wrong_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; + +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(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 + })); + } + }); + } + }); + + yield rejects( + PushService.register({ + scope: 'https://example.com/mismatched', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }), + 'Expected error for mismatched register reply' + ); + + yield 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 000000000..ade84ed76 --- /dev/null +++ b/dom/push/test/xpcshell/test_register_wrong_type.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} = serviceExports; + +const userAgentID = 'c293fdc5-a75e-4eb1-af88-a203991c0787'; + +function run_test() { + do_get_profile(); + setPrefs({ + requestTimeout: 1000, + retryBaseInterval: 150 + }); + run_next_test(); +} + +add_task(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' + })); + } + }); + } + }); + + yield rejects( + PushService.register({ + scope: 'https://example.com/mistyped', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }), + 'Expected error for non-string channel ID' + ); + + yield 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 000000000..bdade78cc --- /dev/null +++ b/dom/push/test/xpcshell/test_registration_error.js @@ -0,0 +1,43 @@ +/* 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(function* test_registrations_error() { + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {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); + } + }); + + yield rejects( + PushService.registration({ + scope: 'https://example.net/1', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, 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 000000000..d4935787c --- /dev/null +++ b/dom/push/test/xpcshell/test_registration_error_http2.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, PushServiceHttp2} = serviceExports; + +function run_test() { + do_get_profile(); + run_next_test(); +} + +add_task(function* test_registrations_error() { + let db = PushServiceHttp2.newPushDB(); + do_register_cleanup(() => {return db.drop().then(_ => db.close());}); + + PushService.init({ + serverURI: "https://push.example.org/", + db: makeStub(db, { + getByIdentifiers() { + return Promise.reject('Database error'); + } + }), + }); + + yield rejects( + PushService.registration({ + scope: 'https://example.net/1', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, 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 000000000..a30fad9eb --- /dev/null +++ b/dom/push/test/xpcshell/test_registration_missing_scope.js @@ -0,0 +1,25 @@ +/* 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(function* test_registration_missing_scope() { + PushService.init({ + serverURI: "wss://push.example.org/", + makeWebSocket(uri) { + return new MockWebSocket(uri); + } + }); + yield rejects( + PushService.registration({ scope: '', originAttributes: '' }), + '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 000000000..7c5b7118c --- /dev/null +++ b/dom/push/test/xpcshell/test_registration_none.js @@ -0,0 +1,31 @@ +/* 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(function* test_registration_none() { + PushService.init({ + serverURI: "wss://push.example.org/", + makeWebSocket(uri) { + return new MockWebSocket(uri); + } + }); + + let registration = yield PushService.registration({ + scope: 'https://example.net/1', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, 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 000000000..8c579dfc4 --- /dev/null +++ b/dom/push/test/xpcshell/test_registration_success.js @@ -0,0 +1,78 @@ +/* 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(function* test_registration_success() { + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {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, + }, { + channelID: 'f6edfbcd-79d6-49b8-9766-48b9dcfeff0f', + pushEndpoint: 'https://example.com/update/same-manifest/2', + scope: 'https://example.net/b', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: 42 }), + version: 10, + quota: Infinity, + }, { + channelID: 'b1cf38c9-6836-4d29-8a30-a3e98d59b728', + pushEndpoint: 'https://example.org/update/different-manifest', + scope: 'https://example.org/c', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: 42, inIsolatedMozBrowser: true }), + version: 15, + quota: Infinity, + }]; + for (let record of records) { + yield 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(); + } + }); + } + }); + + yield handshakePromise; + + let registration = yield 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 000000000..010108ca3 --- /dev/null +++ b/dom/push/test/xpcshell/test_registration_success_http2.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +'use strict'; + +Cu.import("resource://gre/modules/Services.jsm"); + +const {PushDB, PushService, PushServiceHttp2} = serviceExports; + +var prefs; + +var serverPort = -1; + +function run_test() { + serverPort = getTestServerPort(); + + do_get_profile(); + prefs = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefBranch); + + run_next_test(); +} + +add_task(function* test_pushNotifications() { + + let db = PushServiceHttp2.newPushDB(); + do_register_cleanup(() => { + 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( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + quota: Infinity, + }, { + subscriptionUri: serverURL + '/subscriptionB', + pushEndpoint: serverURL + '/pushEndpointB', + pushReceiptEndpoint: serverURL + '/pushReceiptEndpointB', + scope: 'https://example.net/b', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + quota: Infinity, + }, { + subscriptionUri: serverURL + '/subscriptionC', + pushEndpoint: serverURL + '/pushEndpointC', + pushReceiptEndpoint: serverURL + '/pushReceiptEndpointC', + scope: 'https://example.net/c', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + quota: Infinity, + }]; + + for (let record of records) { + yield db.put(record); + } + + PushService.init({ + serverURI: serverURL, + db + }); + + let registration = yield PushService.registration({ + scope: 'https://example.net/a', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, 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 000000000..17db69f0e --- /dev/null +++ b/dom/push/test/xpcshell/test_resubscribe_4xxCode_http2.js @@ -0,0 +1,103 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +'use strict'; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.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) { + do_check_true(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(function* test1() { + + let db = PushServiceHttp2.newPushDB(); + do_register_cleanup(() => { + 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) { + yield db.put(record); + } + + PushService.init({ + serverURI: serverURL + "/subscribe", + db + }); + + yield handlerPromise; + + let record = yield 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 000000000..bbe634d90 --- /dev/null +++ b/dom/push/test/xpcshell/test_resubscribe_5xxCode_http2.js @@ -0,0 +1,106 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +'use strict'; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.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) { + do_check_true(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(function* test1() { + + let db = PushServiceHttp2.newPushDB(); + do_register_cleanup(() => { + 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) { + yield db.put(record); + } + + PushService.init({ + serverURI: serverURL + "/subscribe", + db + }); + + yield handlerPromise; + + let record = yield 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 000000000..660e27f11 --- /dev/null +++ b/dom/push/test/xpcshell/test_resubscribe_listening_for_msg_error_http2.js @@ -0,0 +1,105 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +'use strict'; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.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) { + do_check_true(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(function* test1() { + + let db = PushServiceHttp2.newPushDB(); + do_register_cleanup(() => { + 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) { + yield db.put(record); + } + + PushService.init({ + serverURI: serverURL + "/subscribe", + db + }); + + yield handlerPromise; + + let record = yield 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 000000000..05f261629 --- /dev/null +++ b/dom/push/test/xpcshell/test_retry_ws.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 = '05f7b940-51b6-4b6f-8032-b83ebb577ded'; + +function run_test() { + do_get_profile(); + setPrefs({ + userAgentID: userAgentID, + pingInterval: 2000, + retryBaseInterval: 25, + }); + run_next_test(); +} + +add_task(function* test_ws_retry() { + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {return db.drop().then(_ => db.close());}); + + yield 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(); + }, + }); + }, + }); + + yield 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 000000000..8426936b8 --- /dev/null +++ b/dom/push/test/xpcshell/test_service_child.js @@ -0,0 +1,307 @@ +/* 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(); + do_register_cleanup(() => {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.length, + 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.length, + 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.length, + 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_app_principal() { + let principal = Services.scriptSecurityManager.getAppCodebasePrincipal( + Services.io.newURI('https://example.net/app/1', null, null), + 1, /* appId */ + true /* browserOnly */ + ); + + do_test_pending(); + PushServiceComponent.subscribe('https://example.net/scope/1', principal, (result, subscription) => { + ok(Components.isSuccessCode(result), 'Error creating subscription'); + ok(subscription.endpoint.startsWith('https://example.org/push'), + 'Wrong push endpoint in app subscription'); + ok(!subscription.isSystemSubscription, + 'Unexpected system subscription for app principal'); + equal(subscription.quota, 16, 'Wrong quota for app subscription'); + + do_test_finished(); + run_next_test(); + }); +}); + +add_test(function test_subscribe_origin_principal() { + let scope = 'https://example.net/origin-principal'; + let principal = + Services.scriptSecurityManager.createCodebasePrincipalFromOrigin(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 000000000..3b08d641d --- /dev/null +++ b/dom/push/test/xpcshell/test_service_parent.js @@ -0,0 +1,28 @@ +/* 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(); + run_next_test(); +} + +add_task(function* test_service_parent() { + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {return db.drop().then(_ => db.close());}); + yield 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"); + + yield run_test_in_child('./test_service_child.js'); + + yield tearDownServiceInParent(db); +}); diff --git a/dom/push/test/xpcshell/test_startup_error.js b/dom/push/test/xpcshell/test_startup_error.js new file mode 100644 index 000000000..b01b8a917 --- /dev/null +++ b/dom/push/test/xpcshell/test_startup_error.js @@ -0,0 +1,71 @@ +'use strict'; + +const {PushService, PushServiceWebSocket} = serviceExports; + +function run_test() { + setPrefs(); + do_get_profile(); + run_next_test(); +} + +add_task(function* test_startup_error() { + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {return db.drop().then(_ => db.close());}); + + PushService.init({ + serverURI: 'wss://push.example.org/', + db: makeStub(db, { + getAllExpired(prev) { + return Promise.reject('database corruption on startup'); + }, + }), + makeWebSocket(uri) { + return new MockWebSocket(uri, { + onHello(request) { + ok(false, 'Unexpected handshake'); + }, + onRegister(request) { + ok(false, 'Unexpected register request'); + }, + }); + }, + }); + + yield rejects( + PushService.register({ + scope: `https://example.net/1`, + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }), + 'Should not register if startup failed' + ); + + PushService.uninit(); + + PushService.init({ + serverURI: 'wss://push.example.org/', + db: makeStub(db, { + getAllUnexpired(prev) { + return Promise.reject('database corruption on connect'); + }, + }), + makeWebSocket(uri) { + return new MockWebSocket(uri, { + onHello(request) { + ok(false, 'Unexpected handshake'); + }, + onRegister(request) { + ok(false, 'Unexpected register request'); + }, + }); + }, + }); + yield rejects( + PushService.registration({ + scope: `https://example.net/1`, + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }), + 'Should not return registration if connection failed' + ); +}); 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 000000000..32b12f9e4 --- /dev/null +++ b/dom/push/test/xpcshell/test_unregister_empty_scope.js @@ -0,0 +1,38 @@ +/* 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(function* test_unregister_empty_scope() { + 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' + })); + } + }); + } + }); + + yield rejects( + PushService.unregister({ + scope: '', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }), + '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 000000000..53d592918 --- /dev/null +++ b/dom/push/test/xpcshell/test_unregister_error.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 channelID = '00c7fa13-7b71-447d-bd27-a91abc09d1b2'; + +function run_test() { + do_get_profile(); + setPrefs(); + run_next_test(); +} + +add_task(function* test_unregister_error() { + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {return db.drop().then(_ => db.close());}); + yield db.put({ + channelID: 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(); + } + }); + } + }); + + yield PushService.unregister({ + scope: 'https://example.net/page/failure', + originAttributes: '', + }); + + let result = yield db.getByKeyID(channelID); + ok(!result, 'Deleted push record exists'); + + // Make sure we send a request to the server. + yield 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 000000000..28c10e999 --- /dev/null +++ b/dom/push/test/xpcshell/test_unregister_invalid_json.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; + +const userAgentID = '7f0af1bb-7e1f-4fb8-8e4a-e8de434abde3'; + +function run_test() { + do_get_profile(); + setPrefs({ + userAgentID, + requestTimeout: 150, + retryBaseInterval: 150 + }); + run_next_test(); +} + +add_task(function* test_unregister_invalid_json() { + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {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) { + yield 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(); + } + }); + } + }); + + yield rejects( + PushService.unregister({ + scope: 'https://example.edu/page/1', + originAttributes: '', + }), + 'Expected error for first invalid JSON response' + ); + + let record = yield db.getByKeyID( + '87902e90-c57e-4d18-8354-013f4a556559'); + ok(!record, 'Failed to delete unregistered record'); + + yield rejects( + PushService.unregister({ + scope: 'https://example.net/page/1', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }), + 'Expected error for second invalid JSON response' + ); + + record = yield db.getByKeyID( + '057caa8f-9b99-47ff-891c-adad18ce603e'); + ok(!record, + 'Failed to delete unregistered record after receiving invalid JSON'); + + yield 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 000000000..4bd677613 --- /dev/null +++ b/dom/push/test/xpcshell/test_unregister_not_found.js @@ -0,0 +1,36 @@ +/* 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(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 = yield PushService.unregister({ + scope: 'https://example.net/nonexistent', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, 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 000000000..6bf6dff3f --- /dev/null +++ b/dom/push/test/xpcshell/test_unregister_success.js @@ -0,0 +1,76 @@ +/* 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: userAgentID, + }); + run_next_test(); +} + +add_task(function* test_unregister_success() { + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {return db.drop().then(_ => db.close());}); + yield 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); + + yield PushService.unregister({ + scope: 'https://example.com/page/unregister-success', + originAttributes: '', + }); + + let {data: subModifiedScope} = yield subModifiedPromise; + equal(subModifiedScope, 'https://example.com/page/unregister-success', + 'Should fire a subscription modified event after unsubscribing'); + + let record = yield db.getByKeyID(channelID); + ok(!record, 'Unregister did not remove record'); + + yield 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 000000000..f2eb35331 --- /dev/null +++ b/dom/push/test/xpcshell/test_unregister_success_http2.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +'use strict'; + +Cu.import("resource://gre/modules/Services.jsm"); + +const {PushDB, PushService, PushServiceHttp2} = serviceExports; + +var prefs; +var tlsProfile; +var pushEnabled; +var pushConnectionEnabled; + +var serverPort = -1; + +function run_test() { + serverPort = getTestServerPort(); + + do_get_profile(); + prefs = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefBranch); + + tlsProfile = prefs.getBoolPref("network.http.spdy.enforce-tls-profile"); + pushEnabled = prefs.getBoolPref("dom.push.enabled"); + pushConnectionEnabled = prefs.getBoolPref("dom.push.connection.enabled"); + + // Set to allow the cert presented by our H2 server + var oldPref = prefs.getIntPref("network.http.speculative-parallel-limit"); + prefs.setIntPref("network.http.speculative-parallel-limit", 0); + prefs.setBoolPref("network.http.spdy.enforce-tls-profile", false); + prefs.setBoolPref("dom.push.enabled", true); + prefs.setBoolPref("dom.push.connection.enabled", true); + + addCertOverride("localhost", serverPort, + Ci.nsICertOverrideService.ERROR_UNTRUSTED | + Ci.nsICertOverrideService.ERROR_MISMATCH | + Ci.nsICertOverrideService.ERROR_TIME); + + prefs.setIntPref("network.http.speculative-parallel-limit", oldPref); + + run_next_test(); +} + +add_task(function* test_pushUnsubscriptionSuccess() { + let db = PushServiceHttp2.newPushDB(); + do_register_cleanup(() => { + return db.drop().then(_ => db.close()); + }); + + var serverURL = "https://localhost:" + serverPort; + + yield db.put({ + subscriptionUri: serverURL + '/subscriptionUnsubscriptionSuccess', + pushEndpoint: serverURL + '/pushEndpointUnsubscriptionSuccess', + pushReceiptEndpoint: serverURL + '/receiptPushEndpointUnsubscriptionSuccess', + scope: 'https://example.com/page/unregister-success', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + quota: Infinity, + }); + + PushService.init({ + serverURI: serverURL, + db + }); + + yield PushService.unregister({ + scope: 'https://example.com/page/unregister-success', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }); + let record = yield db.getByKeyID(serverURL + '/subscriptionUnsubscriptionSuccess'); + ok(!record, 'Unregister did not remove record'); + +}); + +add_task(function* test_complete() { + prefs.setBoolPref("network.http.spdy.enforce-tls-profile", tlsProfile); + prefs.setBoolPref("dom.push.enabled", pushEnabled); + 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 000000000..0704344c2 --- /dev/null +++ b/dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_http2.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +'use strict'; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.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) { + do_check_true(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(function* test1() { + + let db = PushServiceHttp2.newPushDB(); + do_register_cleanup(_ => { + 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, + }; + + yield db.put(record); + + let notifyPromise = promiseObserverNotification(PushServiceComponent.subscriptionChangeTopic, + _ => true); + + PushService.init({ + serverURI: serverURL + "/subscribe", + db + }); + + yield notifyPromise; + + let aRecord = yield 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 000000000..d135a39a0 --- /dev/null +++ b/dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_ws.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, PushCrypto} = serviceExports; + +const userAgentID = '4dffd396-6582-471d-8c0c-84f394e9f7db'; + +function run_test() { + do_get_profile(); + setPrefs({ + userAgentID, + }); + run_next_test(); +} + +add_task(function* test_with_data_enabled() { + let db = PushServiceWebSocket.newPushDB(); + do_register_cleanup(() => {return db.drop().then(_ => db.close());}); + + let [publicKey, privateKey] = yield 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) { + yield 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 = yield PushService.register({ + scope: 'https://example.com/page/3', + originAttributes: ChromeUtils.originAttributesToSuffix( + { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }), + }); + ok(newRecord.p256dhKey, 'Should generate public keys for new records'); + + let record = yield 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 = yield 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 000000000..63ddfcc81 --- /dev/null +++ b/dom/push/test/xpcshell/xpcshell.ini @@ -0,0 +1,83 @@ +[DEFAULT] +head = head.js head-http2.js +tail = +# Push notifications and alarms are currently disabled on Android. +skip-if = toolkit == 'android' + +[test_clear_forgetAboutSite.js] +[test_clear_origin_data.js] +[test_crypto.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] + +[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] +[test_reconnect_retry.js] +[test_retry_ws.js] +[test_service_parent.js] +[test_service_child.js] +[test_startup_error.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_register_success_http2.js] +skip-if = !hasNode +run-sequentially = node server exceptions dont replay well +[test_register_error_http2.js] +skip-if = !hasNode +run-sequentially = node server exceptions dont replay well +[test_unregister_success_http2.js] +skip-if = !hasNode +run-sequentially = node server exceptions dont replay well +[test_notification_http2.js] +skip-if = !hasNode +run-sequentially = node server exceptions dont replay well +[test_registration_success_http2.js] +skip-if = !hasNode +run-sequentially = node server exceptions dont replay well +[test_registration_error_http2.js] +skip-if = !hasNode +run-sequentially = node server exceptions dont replay well +[test_clearAll_successful.js] +skip-if = !hasNode +run-sequentially = This will delete all existing push subscriptions. |