head.js (14257B)
1 /* Any copyright is dedicated to the Public Domain. 2 http://creativecommons.org/publicdomain/zero/1.0/ */ 3 4 "use strict"; 5 6 var { XPCOMUtils } = ChromeUtils.importESModule( 7 "resource://gre/modules/XPCOMUtils.sys.mjs" 8 ); 9 10 ChromeUtils.defineESModuleGetters(this, { 11 ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs", 12 PermissionTestUtils: "resource://testing-common/PermissionTestUtils.sys.mjs", 13 PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", 14 PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", 15 Preferences: "resource://gre/modules/Preferences.sys.mjs", 16 PushCrypto: "resource://gre/modules/PushCrypto.sys.mjs", 17 PushService: "resource://gre/modules/PushService.sys.mjs", 18 PushServiceWebSocket: "resource://gre/modules/PushServiceWebSocket.sys.mjs", 19 pushBroadcastService: "resource://gre/modules/PushBroadcastService.sys.mjs", 20 }); 21 22 var { 23 clearInterval, 24 clearTimeout, 25 setInterval, 26 setIntervalWithTarget, 27 setTimeout, 28 setTimeoutWithTarget, 29 } = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs"); 30 31 XPCOMUtils.defineLazyServiceGetter( 32 this, 33 "PushServiceComponent", 34 "@mozilla.org/push/Service;1", 35 Ci.nsIPushService 36 ); 37 38 const servicePrefs = new Preferences("dom.push."); 39 40 const WEBSOCKET_CLOSE_GOING_AWAY = 1001; 41 42 const MS_IN_ONE_DAY = 24 * 60 * 60 * 1000; 43 44 var isParent = 45 Services.appinfo.processType == Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT; 46 47 // Stop and clean up after the PushService. 48 Services.obs.addObserver(function observe(subject, topic) { 49 Services.obs.removeObserver(observe, topic); 50 PushService.uninit(); 51 // Occasionally, `profile-change-teardown` and `xpcom-shutdown` will fire 52 // before the PushService and AlarmService finish writing to IndexedDB. This 53 // causes spurious errors and crashes, so we spin the event loop to let the 54 // writes finish. 55 let done = false; 56 setTimeout(() => (done = true), 1000); 57 let thread = Services.tm.mainThread; 58 while (!done) { 59 try { 60 thread.processNextEvent(true); 61 } catch (e) { 62 console.error(e); 63 } 64 } 65 }, "profile-change-net-teardown"); 66 67 /** 68 * Gates a function so that it is called only after the wrapper is called a 69 * given number of times. 70 * 71 * @param {number} times The number of wrapper calls before |func| is called. 72 * @param {Function} func The function to gate. 73 * @returns {Function} The gated function wrapper. 74 */ 75 function after(times, func) { 76 return function afterFunc() { 77 if (--times <= 0) { 78 func.apply(this, arguments); 79 } 80 }; 81 } 82 83 /** 84 * Defers one or more callbacks until the next turn of the event loop. Multiple 85 * callbacks are executed in order. 86 * 87 * @param {Function[]} callbacks The callbacks to execute. One callback will be 88 * executed per tick. 89 */ 90 function waterfall(...callbacks) { 91 callbacks 92 .reduce( 93 (promise, callback) => 94 promise.then(() => { 95 callback(); 96 }), 97 Promise.resolve() 98 ) 99 .catch(console.error); 100 } 101 102 /** 103 * Waits for an observer notification to fire. 104 * 105 * @param {string} topic The notification topic. 106 * @returns {Promise} A promise that fulfills when the notification is fired. 107 */ 108 function promiseObserverNotification(topic, matchFunc) { 109 return new Promise(resolve => { 110 Services.obs.addObserver(function observe(subject, aTopic, data) { 111 let matches = typeof matchFunc != "function" || matchFunc(subject, data); 112 if (!matches) { 113 return; 114 } 115 Services.obs.removeObserver(observe, aTopic); 116 resolve({ subject, data }); 117 }, topic); 118 }); 119 } 120 121 /** 122 * Wraps an object in a proxy that traps property gets and returns stubs. If 123 * the stub is a function, the original value will be passed as the first 124 * argument. If the original value is a function, the proxy returns a wrapper 125 * that calls the stub; otherwise, the stub is called as a getter. 126 * 127 * @param {object} target The object to wrap. 128 * @param {object} stubs An object containing stubbed values and functions. 129 * @returns {Proxy} A proxy that returns stubs for property gets. 130 */ 131 function makeStub(target, stubs) { 132 return new Proxy(target, { 133 get(aTarget, property) { 134 if (!stubs || typeof stubs != "object" || !(property in stubs)) { 135 return aTarget[property]; 136 } 137 let stub = stubs[property]; 138 if (typeof stub != "function") { 139 return stub; 140 } 141 let original = aTarget[property]; 142 if (typeof original != "function") { 143 return stub.call(this, original); 144 } 145 return function callStub(...params) { 146 return stub.call(this, original, ...params); 147 }; 148 }, 149 }); 150 } 151 152 /** 153 * Sets default PushService preferences. All pref names are prefixed with 154 * `dom.push.`; any additional preferences will override the defaults. 155 * 156 * @param {object} [prefs] Additional preferences to set. 157 */ 158 function setPrefs(prefs = {}) { 159 let defaultPrefs = Object.assign( 160 { 161 loglevel: "all", 162 serverURL: "wss://push.example.org", 163 "connection.enabled": true, 164 userAgentID: "", 165 enabled: true, 166 // Defaults taken from /modules/libpref/init/all.js. 167 requestTimeout: 10000, 168 retryBaseInterval: 5000, 169 pingInterval: 30 * 60 * 1000, 170 // Misc. defaults. 171 maxQuotaPerSubscription: 16, 172 quotaUpdateDelay: 3000, 173 "testing.notifyWorkers": false, 174 }, 175 prefs 176 ); 177 for (let pref in defaultPrefs) { 178 servicePrefs.set(pref, defaultPrefs[pref]); 179 } 180 } 181 182 function compareAscending(a, b) { 183 if (a > b) { 184 return 1; 185 } 186 return a < b ? -1 : 0; 187 } 188 189 /** 190 * Creates a mock WebSocket object that implements a subset of the 191 * nsIWebSocketChannel interface used by the PushService. 192 * 193 * The given protocol handlers are invoked for each Simple Push command sent 194 * by the PushService. The ping handler is optional; all others will throw if 195 * the PushService sends a command for which no handler is registered. 196 * 197 * All nsIWebSocketListener methods will be called asynchronously. 198 * serverSendMsg() and serverClose() can be used to respond to client messages 199 * and close the "server" end of the connection, respectively. 200 * 201 * @param {nsIURI} originalURI The original WebSocket URL. 202 * @param {Function} options.onHello The "hello" handshake command handler. 203 * @param {Function} options.onRegister The "register" command handler. 204 * @param {Function} options.onUnregister The "unregister" command handler. 205 * @param {Function} options.onACK The "ack" command handler. 206 * @param {Function} [options.onPing] An optional ping handler. 207 */ 208 function MockWebSocket(originalURI, handlers = {}) { 209 this._originalURI = originalURI; 210 this._onHello = handlers.onHello; 211 this._onRegister = handlers.onRegister; 212 this._onUnregister = handlers.onUnregister; 213 this._onACK = handlers.onACK; 214 this._onPing = handlers.onPing; 215 this._onBroadcastSubscribe = handlers.onBroadcastSubscribe; 216 } 217 218 MockWebSocket.prototype = { 219 _originalURI: null, 220 _onHello: null, 221 _onRegister: null, 222 _onUnregister: null, 223 _onACK: null, 224 _onPing: null, 225 226 _listener: null, 227 _context: null, 228 229 QueryInterface: ChromeUtils.generateQI(["nsIWebSocketChannel"]), 230 231 get originalURI() { 232 return this._originalURI; 233 }, 234 235 asyncOpen(uri, origin, originAttributes, windowId, listener, context) { 236 this._listener = listener; 237 this._context = context; 238 waterfall(() => this._listener.onStart(this._context)); 239 }, 240 241 _handleMessage(msg) { 242 let messageType, request; 243 if (msg == "{}") { 244 request = {}; 245 messageType = "ping"; 246 } else { 247 request = JSON.parse(msg); 248 messageType = request.messageType; 249 } 250 switch (messageType) { 251 case "hello": 252 if (typeof this._onHello != "function") { 253 throw new Error("Unexpected handshake request"); 254 } 255 this._onHello(request); 256 break; 257 258 case "register": 259 if (typeof this._onRegister != "function") { 260 throw new Error("Unexpected register request"); 261 } 262 this._onRegister(request); 263 break; 264 265 case "unregister": 266 if (typeof this._onUnregister != "function") { 267 throw new Error("Unexpected unregister request"); 268 } 269 this._onUnregister(request); 270 break; 271 272 case "ack": 273 if (typeof this._onACK != "function") { 274 throw new Error("Unexpected acknowledgement"); 275 } 276 this._onACK(request); 277 break; 278 279 case "ping": 280 if (typeof this._onPing == "function") { 281 this._onPing(request); 282 } else { 283 // Echo ping packets. 284 this.serverSendMsg("{}"); 285 } 286 break; 287 288 case "broadcast_subscribe": 289 if (typeof this._onBroadcastSubscribe != "function") { 290 throw new Error("Unexpected broadcast_subscribe"); 291 } 292 this._onBroadcastSubscribe(request); 293 break; 294 295 default: 296 throw new Error("Unexpected message: " + messageType); 297 } 298 }, 299 300 sendMsg(msg) { 301 this._handleMessage(msg); 302 }, 303 304 close() { 305 waterfall(() => this._listener.onStop(this._context, Cr.NS_OK)); 306 }, 307 308 /** 309 * Responds with the given message, calling onMessageAvailable() and 310 * onAcknowledge() synchronously. Throws if the message is not a string. 311 * Used by the tests to respond to client commands. 312 * 313 * @param {string} msg The message to send to the client. 314 */ 315 serverSendMsg(msg) { 316 if (typeof msg != "string") { 317 throw new Error("Invalid response message"); 318 } 319 waterfall( 320 () => this._listener.onMessageAvailable(this._context, msg), 321 () => this._listener.onAcknowledge(this._context, 0) 322 ); 323 }, 324 325 /** 326 * Closes the server end of the connection, calling onServerClose() 327 * followed by onStop(). Used to test abrupt connection termination. 328 * 329 * @param {number} [statusCode] The WebSocket connection close code. 330 * @param {string} [reason] The connection close reason. 331 */ 332 serverClose(statusCode, reason = "") { 333 if (!isFinite(statusCode)) { 334 statusCode = WEBSOCKET_CLOSE_GOING_AWAY; 335 } 336 waterfall( 337 () => this._listener.onServerClose(this._context, statusCode, reason), 338 () => this._listener.onStop(this._context, Cr.NS_BASE_STREAM_CLOSED) 339 ); 340 }, 341 342 serverInterrupt(result = Cr.NS_ERROR_NET_RESET) { 343 waterfall(() => this._listener.onStop(this._context, result)); 344 }, 345 }; 346 347 var setUpServiceInParent = async function (service, db) { 348 if (!isParent) { 349 return; 350 } 351 352 let userAgentID = "ce704e41-cb77-4206-b07b-5bf47114791b"; 353 setPrefs({ 354 userAgentID, 355 }); 356 357 await db.put({ 358 channelID: "6e2814e1-5f84-489e-b542-855cc1311f09", 359 pushEndpoint: "https://example.org/push/get", 360 scope: "https://example.com/get/ok", 361 originAttributes: "", 362 version: 1, 363 pushCount: 10, 364 lastPush: 1438360548322, 365 quota: 16, 366 }); 367 await db.put({ 368 channelID: "3a414737-2fd0-44c0-af05-7efc172475fc", 369 pushEndpoint: "https://example.org/push/unsub", 370 scope: "https://example.com/unsub/ok", 371 originAttributes: "", 372 version: 2, 373 pushCount: 10, 374 lastPush: 1438360848322, 375 quota: 4, 376 }); 377 await db.put({ 378 channelID: "ca3054e8-b59b-4ea0-9c23-4a3c518f3161", 379 pushEndpoint: "https://example.org/push/stale", 380 scope: "https://example.com/unsub/fail", 381 originAttributes: "", 382 version: 3, 383 pushCount: 10, 384 lastPush: 1438362348322, 385 quota: 1, 386 }); 387 388 service.init({ 389 serverURI: "wss://push.example.org/", 390 db: makeStub(db, { 391 put(prev, record) { 392 if (record.scope == "https://example.com/sub/fail") { 393 return Promise.reject("synergies not aligned"); 394 } 395 return prev.call(this, record); 396 }, 397 delete(prev, channelID) { 398 if (channelID == "ca3054e8-b59b-4ea0-9c23-4a3c518f3161") { 399 return Promise.reject("splines not reticulated"); 400 } 401 return prev.call(this, channelID); 402 }, 403 getByIdentifiers(prev, identifiers) { 404 if (identifiers.scope == "https://example.com/get/fail") { 405 return Promise.reject("qualia unsynchronized"); 406 } 407 return prev.call(this, identifiers); 408 }, 409 }), 410 makeWebSocket(uri) { 411 return new MockWebSocket(uri, { 412 onHello() { 413 this.serverSendMsg( 414 JSON.stringify({ 415 messageType: "hello", 416 uaid: userAgentID, 417 status: 200, 418 }) 419 ); 420 }, 421 onRegister(request) { 422 if (request.key) { 423 let appServerKey = new Uint8Array( 424 ChromeUtils.base64URLDecode(request.key, { 425 padding: "require", 426 }) 427 ); 428 equal(appServerKey.length, 65, "Wrong app server key length"); 429 equal(appServerKey[0], 4, "Wrong app server key format"); 430 } 431 this.serverSendMsg( 432 JSON.stringify({ 433 messageType: "register", 434 uaid: userAgentID, 435 channelID: request.channelID, 436 status: 200, 437 pushEndpoint: "https://example.org/push/" + request.channelID, 438 }) 439 ); 440 }, 441 onUnregister(request) { 442 this.serverSendMsg( 443 JSON.stringify({ 444 messageType: "unregister", 445 channelID: request.channelID, 446 status: 200, 447 }) 448 ); 449 }, 450 }); 451 }, 452 }); 453 }; 454 455 var tearDownServiceInParent = async function (db) { 456 if (!isParent) { 457 return; 458 } 459 460 let record = await db.getByIdentifiers({ 461 scope: "https://example.com/sub/ok", 462 originAttributes: "", 463 }); 464 ok( 465 record.pushEndpoint.startsWith("https://example.org/push"), 466 "Wrong push endpoint in subscription record" 467 ); 468 469 record = await db.getByKeyID("3a414737-2fd0-44c0-af05-7efc172475fc"); 470 ok(!record, "Unsubscribed record should not exist"); 471 }; 472 473 function putTestRecord(db, keyID, scope, quota) { 474 return db.put({ 475 channelID: keyID, 476 pushEndpoint: "https://example.org/push/" + keyID, 477 scope, 478 pushCount: 0, 479 lastPush: 0, 480 version: null, 481 originAttributes: "", 482 quota, 483 systemRecord: quota == Infinity, 484 }); 485 } 486 487 function getAllKeyIDs(db) { 488 return db 489 .getAllKeyIDs() 490 .then(records => 491 records.map(record => record.keyID).sort(compareAscending) 492 ); 493 }