diff options
Diffstat (limited to 'dom/push/test/xpcshell/head.js')
-rw-r--r-- | dom/push/test/xpcshell/head.js | 499 |
1 files changed, 499 insertions, 0 deletions
diff --git a/dom/push/test/xpcshell/head.js b/dom/push/test/xpcshell/head.js new file mode 100644 index 0000000000..497fac9d34 --- /dev/null +++ b/dom/push/test/xpcshell/head.js @@ -0,0 +1,499 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + PermissionTestUtils: "resource://testing-common/PermissionTestUtils.sys.mjs", + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + Preferences: "resource://gre/modules/Preferences.sys.mjs", + PushCrypto: "resource://gre/modules/PushCrypto.sys.mjs", + PushService: "resource://gre/modules/PushService.sys.mjs", + PushServiceHttp2: "resource://gre/modules/PushService.sys.mjs", + PushServiceWebSocket: "resource://gre/modules/PushService.sys.mjs", + pushBroadcastService: "resource://gre/modules/PushBroadcastService.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + ObjectUtils: "resource://gre/modules/ObjectUtils.jsm", +}); +var { + clearInterval, + clearTimeout, + setInterval, + setIntervalWithTarget, + setTimeout, + setTimeoutWithTarget, +} = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs"); + +XPCOMUtils.defineLazyServiceGetter( + this, + "PushServiceComponent", + "@mozilla.org/push/Service;1", + "nsIPushService" +); + +const servicePrefs = new Preferences("dom.push."); + +const WEBSOCKET_CLOSE_GOING_AWAY = 1001; + +const MS_IN_ONE_DAY = 24 * 60 * 60 * 1000; + +var isParent = + Services.appinfo.processType == Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT; + +// Stop and clean up after the PushService. +Services.obs.addObserver(function observe(subject, topic, data) { + Services.obs.removeObserver(observe, topic); + PushService.uninit(); + // Occasionally, `profile-change-teardown` and `xpcom-shutdown` will fire + // before the PushService and AlarmService finish writing to IndexedDB. This + // causes spurious errors and crashes, so we spin the event loop to let the + // writes finish. + let done = false; + setTimeout(() => (done = true), 1000); + let thread = Services.tm.mainThread; + while (!done) { + try { + thread.processNextEvent(true); + } catch (e) { + console.error(e); + } + } +}, "profile-change-net-teardown"); + +/** + * Gates a function so that it is called only after the wrapper is called a + * given number of times. + * + * @param {Number} times The number of wrapper calls before |func| is called. + * @param {Function} func The function to gate. + * @returns {Function} The gated function wrapper. + */ +function after(times, func) { + return function afterFunc() { + if (--times <= 0) { + func.apply(this, arguments); + } + }; +} + +/** + * Defers one or more callbacks until the next turn of the event loop. Multiple + * callbacks are executed in order. + * + * @param {Function[]} callbacks The callbacks to execute. One callback will be + * executed per tick. + */ +function waterfall(...callbacks) { + callbacks + .reduce( + (promise, callback) => + promise.then(() => { + callback(); + }), + Promise.resolve() + ) + .catch(console.error); +} + +/** + * Waits for an observer notification to fire. + * + * @param {String} topic The notification topic. + * @returns {Promise} A promise that fulfills when the notification is fired. + */ +function promiseObserverNotification(topic, matchFunc) { + return new Promise((resolve, reject) => { + Services.obs.addObserver(function observe(subject, aTopic, data) { + let matches = typeof matchFunc != "function" || matchFunc(subject, data); + if (!matches) { + return; + } + Services.obs.removeObserver(observe, aTopic); + resolve({ subject, data }); + }, topic); + }); +} + +/** + * Wraps an object in a proxy that traps property gets and returns stubs. If + * the stub is a function, the original value will be passed as the first + * argument. If the original value is a function, the proxy returns a wrapper + * that calls the stub; otherwise, the stub is called as a getter. + * + * @param {Object} target The object to wrap. + * @param {Object} stubs An object containing stubbed values and functions. + * @returns {Proxy} A proxy that returns stubs for property gets. + */ +function makeStub(target, stubs) { + return new Proxy(target, { + get(aTarget, property) { + if (!stubs || typeof stubs != "object" || !(property in stubs)) { + return aTarget[property]; + } + let stub = stubs[property]; + if (typeof stub != "function") { + return stub; + } + let original = aTarget[property]; + if (typeof original != "function") { + return stub.call(this, original); + } + return function callStub(...params) { + return stub.call(this, original, ...params); + }; + }, + }); +} + +/** + * Sets default PushService preferences. All pref names are prefixed with + * `dom.push.`; any additional preferences will override the defaults. + * + * @param {Object} [prefs] Additional preferences to set. + */ +function setPrefs(prefs = {}) { + let defaultPrefs = Object.assign( + { + loglevel: "all", + serverURL: "wss://push.example.org", + "connection.enabled": true, + userAgentID: "", + enabled: true, + // Defaults taken from /modules/libpref/init/all.js. + requestTimeout: 10000, + retryBaseInterval: 5000, + pingInterval: 30 * 60 * 1000, + // Misc. defaults. + "http2.maxRetries": 2, + "http2.retryInterval": 500, + "http2.reset_retry_count_after_ms": 60000, + maxQuotaPerSubscription: 16, + quotaUpdateDelay: 3000, + "testing.notifyWorkers": false, + }, + prefs + ); + for (let pref in defaultPrefs) { + servicePrefs.set(pref, defaultPrefs[pref]); + } +} + +function compareAscending(a, b) { + if (a > b) { + return 1; + } + return a < b ? -1 : 0; +} + +/** + * Creates a mock WebSocket object that implements a subset of the + * nsIWebSocketChannel interface used by the PushService. + * + * The given protocol handlers are invoked for each Simple Push command sent + * by the PushService. The ping handler is optional; all others will throw if + * the PushService sends a command for which no handler is registered. + * + * All nsIWebSocketListener methods will be called asynchronously. + * serverSendMsg() and serverClose() can be used to respond to client messages + * and close the "server" end of the connection, respectively. + * + * @param {nsIURI} originalURI The original WebSocket URL. + * @param {Function} options.onHello The "hello" handshake command handler. + * @param {Function} options.onRegister The "register" command handler. + * @param {Function} options.onUnregister The "unregister" command handler. + * @param {Function} options.onACK The "ack" command handler. + * @param {Function} [options.onPing] An optional ping handler. + */ +function MockWebSocket(originalURI, handlers = {}) { + this._originalURI = originalURI; + this._onHello = handlers.onHello; + this._onRegister = handlers.onRegister; + this._onUnregister = handlers.onUnregister; + this._onACK = handlers.onACK; + this._onPing = handlers.onPing; + this._onBroadcastSubscribe = handlers.onBroadcastSubscribe; +} + +MockWebSocket.prototype = { + _originalURI: null, + _onHello: null, + _onRegister: null, + _onUnregister: null, + _onACK: null, + _onPing: null, + + _listener: null, + _context: null, + + QueryInterface: ChromeUtils.generateQI(["nsIWebSocketChannel"]), + + get originalURI() { + return this._originalURI; + }, + + asyncOpen(uri, origin, originAttributes, windowId, listener, context) { + this._listener = listener; + this._context = context; + waterfall(() => this._listener.onStart(this._context)); + }, + + _handleMessage(msg) { + let messageType, request; + if (msg == "{}") { + request = {}; + messageType = "ping"; + } else { + request = JSON.parse(msg); + messageType = request.messageType; + } + switch (messageType) { + case "hello": + if (typeof this._onHello != "function") { + throw new Error("Unexpected handshake request"); + } + this._onHello(request); + break; + + case "register": + if (typeof this._onRegister != "function") { + throw new Error("Unexpected register request"); + } + this._onRegister(request); + break; + + case "unregister": + if (typeof this._onUnregister != "function") { + throw new Error("Unexpected unregister request"); + } + this._onUnregister(request); + break; + + case "ack": + if (typeof this._onACK != "function") { + throw new Error("Unexpected acknowledgement"); + } + this._onACK(request); + break; + + case "ping": + if (typeof this._onPing == "function") { + this._onPing(request); + } else { + // Echo ping packets. + this.serverSendMsg("{}"); + } + break; + + case "broadcast_subscribe": + if (typeof this._onBroadcastSubscribe != "function") { + throw new Error("Unexpected broadcast_subscribe"); + } + this._onBroadcastSubscribe(request); + break; + + default: + throw new Error("Unexpected message: " + messageType); + } + }, + + sendMsg(msg) { + this._handleMessage(msg); + }, + + close(code, reason) { + waterfall(() => this._listener.onStop(this._context, Cr.NS_OK)); + }, + + /** + * Responds with the given message, calling onMessageAvailable() and + * onAcknowledge() synchronously. Throws if the message is not a string. + * Used by the tests to respond to client commands. + * + * @param {String} msg The message to send to the client. + */ + serverSendMsg(msg) { + if (typeof msg != "string") { + throw new Error("Invalid response message"); + } + waterfall( + () => this._listener.onMessageAvailable(this._context, msg), + () => this._listener.onAcknowledge(this._context, 0) + ); + }, + + /** + * Closes the server end of the connection, calling onServerClose() + * followed by onStop(). Used to test abrupt connection termination. + * + * @param {Number} [statusCode] The WebSocket connection close code. + * @param {String} [reason] The connection close reason. + */ + serverClose(statusCode, reason = "") { + if (!isFinite(statusCode)) { + statusCode = WEBSOCKET_CLOSE_GOING_AWAY; + } + waterfall( + () => this._listener.onServerClose(this._context, statusCode, reason), + () => this._listener.onStop(this._context, Cr.NS_BASE_STREAM_CLOSED) + ); + }, + + serverInterrupt(result = Cr.NS_ERROR_NET_RESET) { + waterfall(() => this._listener.onStop(this._context, result)); + }, +}; + +var setUpServiceInParent = async function (service, db) { + if (!isParent) { + return; + } + + let userAgentID = "ce704e41-cb77-4206-b07b-5bf47114791b"; + setPrefs({ + userAgentID, + }); + + await db.put({ + channelID: "6e2814e1-5f84-489e-b542-855cc1311f09", + pushEndpoint: "https://example.org/push/get", + scope: "https://example.com/get/ok", + originAttributes: "", + version: 1, + pushCount: 10, + lastPush: 1438360548322, + quota: 16, + }); + await db.put({ + channelID: "3a414737-2fd0-44c0-af05-7efc172475fc", + pushEndpoint: "https://example.org/push/unsub", + scope: "https://example.com/unsub/ok", + originAttributes: "", + version: 2, + pushCount: 10, + lastPush: 1438360848322, + quota: 4, + }); + await db.put({ + channelID: "ca3054e8-b59b-4ea0-9c23-4a3c518f3161", + pushEndpoint: "https://example.org/push/stale", + scope: "https://example.com/unsub/fail", + originAttributes: "", + version: 3, + pushCount: 10, + lastPush: 1438362348322, + quota: 1, + }); + + service.init({ + serverURI: "wss://push.example.org/", + db: makeStub(db, { + put(prev, record) { + if (record.scope == "https://example.com/sub/fail") { + return Promise.reject("synergies not aligned"); + } + return prev.call(this, record); + }, + delete(prev, channelID) { + if (channelID == "ca3054e8-b59b-4ea0-9c23-4a3c518f3161") { + return Promise.reject("splines not reticulated"); + } + return prev.call(this, channelID); + }, + getByIdentifiers(prev, identifiers) { + if (identifiers.scope == "https://example.com/get/fail") { + return Promise.reject("qualia unsynchronized"); + } + return prev.call(this, identifiers); + }, + }), + makeWebSocket(uri) { + return new MockWebSocket(uri, { + onHello(request) { + this.serverSendMsg( + JSON.stringify({ + messageType: "hello", + uaid: userAgentID, + status: 200, + }) + ); + }, + onRegister(request) { + if (request.key) { + let appServerKey = new Uint8Array( + ChromeUtils.base64URLDecode(request.key, { + padding: "require", + }) + ); + equal(appServerKey.length, 65, "Wrong app server key length"); + equal(appServerKey[0], 4, "Wrong app server key format"); + } + this.serverSendMsg( + JSON.stringify({ + messageType: "register", + uaid: userAgentID, + channelID: request.channelID, + status: 200, + pushEndpoint: "https://example.org/push/" + request.channelID, + }) + ); + }, + onUnregister(request) { + this.serverSendMsg( + JSON.stringify({ + messageType: "unregister", + channelID: request.channelID, + status: 200, + }) + ); + }, + }); + }, + }); +}; + +var tearDownServiceInParent = async function (db) { + if (!isParent) { + return; + } + + let record = await db.getByIdentifiers({ + scope: "https://example.com/sub/ok", + originAttributes: "", + }); + ok( + record.pushEndpoint.startsWith("https://example.org/push"), + "Wrong push endpoint in subscription record" + ); + + record = await db.getByKeyID("3a414737-2fd0-44c0-af05-7efc172475fc"); + ok(!record, "Unsubscribed record should not exist"); +}; + +function putTestRecord(db, keyID, scope, quota) { + return db.put({ + channelID: keyID, + pushEndpoint: "https://example.org/push/" + keyID, + scope, + pushCount: 0, + lastPush: 0, + version: null, + originAttributes: "", + quota, + systemRecord: quota == Infinity, + }); +} + +function getAllKeyIDs(db) { + return db + .getAllKeyIDs() + .then(records => + records.map(record => record.keyID).sort(compareAscending) + ); +} |