test_commands.js (19881B)
1 /* Any copyright is dedicated to the Public Domain. 2 * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 4 "use strict"; 5 6 const { FxAccountsCommands, SendTab } = ChromeUtils.importESModule( 7 "resource://gre/modules/FxAccountsCommands.sys.mjs" 8 ); 9 10 const { FxAccountsClient } = ChromeUtils.importESModule( 11 "resource://gre/modules/FxAccountsClient.sys.mjs" 12 ); 13 14 const { COMMAND_SENDTAB, COMMAND_SENDTAB_TAIL } = ChromeUtils.importESModule( 15 "resource://gre/modules/FxAccountsCommon.sys.mjs" 16 ); 17 18 class TelemetryMock { 19 constructor() { 20 this._events = []; 21 this._uuid_counter = 0; 22 } 23 24 recordEvent(object, method, value, extra = undefined) { 25 this._events.push({ object, method, value, extra }); 26 } 27 28 generateFlowID() { 29 this._uuid_counter += 1; 30 return this._uuid_counter.toString(); 31 } 32 33 sanitizeDeviceId(id) { 34 return id + "-san"; 35 } 36 } 37 38 function FxaInternalMock() { 39 return { 40 telemetry: new TelemetryMock(), 41 }; 42 } 43 44 function MockFxAccountsClient() { 45 FxAccountsClient.apply(this); 46 } 47 48 MockFxAccountsClient.prototype = {}; 49 Object.setPrototypeOf( 50 MockFxAccountsClient.prototype, 51 FxAccountsClient.prototype 52 ); 53 54 add_setup(function () { 55 do_get_profile(); // FOG requires a profile dir. 56 Services.fog.initializeFOG(); 57 }); 58 59 add_task(async function test_sendtab_isDeviceCompatible() { 60 const sendTab = new SendTab(null, null); 61 let device = { name: "My device" }; 62 Assert.ok(!sendTab.isDeviceCompatible(device)); 63 device = { name: "My device", availableCommands: {} }; 64 Assert.ok(!sendTab.isDeviceCompatible(device)); 65 device = { 66 name: "My device", 67 availableCommands: { 68 "https://identity.mozilla.com/cmd/open-uri": "payload", 69 }, 70 }; 71 Assert.ok(sendTab.isDeviceCompatible(device)); 72 }); 73 74 add_task(async function test_sendtab_send() { 75 // Clear events from other test cases. 76 Services.fog.testResetFOG(); 77 78 const commands = { 79 invoke: sinon.spy((cmd, device, payload) => { 80 if (device.name == "Device 1") { 81 throw new Error("Invoke error!"); 82 } 83 Assert.equal(payload.encrypted, "encryptedpayload"); 84 }), 85 }; 86 const fxai = FxaInternalMock(); 87 const sendTab = new SendTab(commands, fxai); 88 sendTab._encrypt = (bytes, device) => { 89 if (device.name == "Device 2") { 90 throw new Error("Encrypt error!"); 91 } 92 return "encryptedpayload"; 93 }; 94 const to = [ 95 { name: "Device 1" }, 96 { name: "Device 2" }, 97 { id: "dev3", name: "Device 3" }, 98 ]; 99 // although we are sending to 3 devices, only 1 is successful - so there's 100 // only 1 streamID we care about. However, we've created IDs even for the 101 // failing items - so it's "4" 102 const expectedTelemetryStreamID = "4"; 103 const tab = { title: "Foo", url: "https://foo.bar/" }; 104 const report = await sendTab.send(to, tab); 105 Assert.equal(report.succeeded.length, 1); 106 Assert.equal(report.failed.length, 2); 107 Assert.equal(report.succeeded[0].name, "Device 3"); 108 Assert.equal(report.failed[0].device.name, "Device 1"); 109 Assert.equal(report.failed[0].error.message, "Invoke error!"); 110 Assert.equal(report.failed[1].device.name, "Device 2"); 111 Assert.equal(report.failed[1].error.message, "Encrypt error!"); 112 Assert.ok(commands.invoke.calledTwice); 113 Assert.deepEqual(fxai.telemetry._events, [ 114 { 115 object: "command-sent", 116 method: COMMAND_SENDTAB_TAIL, 117 value: "dev3-san", 118 extra: { flowID: "1", streamID: expectedTelemetryStreamID }, 119 }, 120 ]); 121 const sendEvents = Glean.fxa.sendtabSent.testGetValue(); 122 Assert.equal(sendEvents.length, 1); 123 Assert.deepEqual(sendEvents[0].extra, { 124 flow_id: "1", 125 hashed_device_id: "dev3-san", 126 stream_id: expectedTelemetryStreamID, 127 }); 128 }); 129 130 add_task(async function test_sendtab_send_rate_limit() { 131 const rateLimitReject = { 132 code: 429, 133 retryAfter: 5, 134 retryAfterLocalized: "retry after 5 seconds", 135 }; 136 const fxAccounts = { 137 fxAccountsClient: new MockFxAccountsClient(), 138 getUserAccountData() { 139 return {}; 140 }, 141 telemetry: new TelemetryMock(), 142 }; 143 let rejected = false; 144 let invoked = 0; 145 fxAccounts.fxAccountsClient.invokeCommand = async function invokeCommand() { 146 invoked++; 147 Assert.lessOrEqual(invoked, 2, "only called twice and not more"); 148 if (rejected) { 149 return {}; 150 } 151 rejected = true; 152 return Promise.reject(rateLimitReject); 153 }; 154 const commands = new FxAccountsCommands(fxAccounts); 155 const sendTab = new SendTab(commands, fxAccounts); 156 sendTab._encrypt = () => "encryptedpayload"; 157 158 const tab = { title: "Foo", url: "https://foo.bar/" }; 159 let report = await sendTab.send([{ name: "Device 1" }], tab); 160 Assert.equal(report.succeeded.length, 0); 161 Assert.equal(report.failed.length, 1); 162 Assert.equal(report.failed[0].error, rateLimitReject); 163 164 report = await sendTab.send([{ name: "Device 1" }], tab); 165 Assert.equal(report.succeeded.length, 0); 166 Assert.equal(report.failed.length, 1); 167 Assert.ok( 168 report.failed[0].error.message.includes( 169 "Invoke for " + 170 "https://identity.mozilla.com/cmd/open-uri is rate-limited" 171 ) 172 ); 173 174 commands._invokeRateLimitExpiry = Date.now() - 1000; 175 report = await sendTab.send([{ name: "Device 1" }], tab); 176 Assert.equal(report.succeeded.length, 1); 177 Assert.equal(report.failed.length, 0); 178 }); 179 180 add_task(async function test_sendtab_receive() { 181 // We are testing 'receive' here, but might as well go through 'send' 182 // to package the data and for additional testing... 183 const commands = { 184 _invokes: [], 185 invoke(cmd, device, payload) { 186 this._invokes.push({ cmd, device, payload }); 187 }, 188 }; 189 190 // Clear events from other test cases. 191 Services.fog.testResetFOG(); 192 193 const fxai = FxaInternalMock(); 194 const sendTab = new SendTab(commands, fxai); 195 sendTab._encrypt = bytes => { 196 return bytes; 197 }; 198 sendTab._decrypt = bytes => { 199 return bytes; 200 }; 201 const tab = { title: "tab title", url: "http://example.com" }; 202 const to = [{ id: "devid", name: "The Device" }]; 203 const reason = "push"; 204 205 await sendTab.send(to, tab); 206 Assert.equal(commands._invokes.length, 1); 207 208 for (let { cmd, device, payload } of commands._invokes) { 209 Assert.equal(cmd, COMMAND_SENDTAB); 210 // Older Firefoxes would send a plaintext flowID in the top-level payload. 211 // Test that we sensibly ignore it. 212 Assert.ok(!payload.hasOwnProperty("flowID")); 213 // change it - ensure we still get what we expect in telemetry later. 214 payload.flowID = "ignore-me"; 215 Assert.deepEqual(await sendTab.handle(device.id, payload, reason), { 216 title: "tab title", 217 uri: "http://example.com", 218 }); 219 } 220 221 Assert.deepEqual(fxai.telemetry._events, [ 222 { 223 object: "command-sent", 224 method: COMMAND_SENDTAB_TAIL, 225 value: "devid-san", 226 extra: { flowID: "1", streamID: "2" }, 227 }, 228 { 229 object: "command-received", 230 method: COMMAND_SENDTAB_TAIL, 231 value: "devid-san", 232 extra: { flowID: "1", streamID: "2", reason }, 233 }, 234 ]); 235 const sendEvents = Glean.fxa.sendtabSent.testGetValue(); 236 Assert.equal(sendEvents.length, 1); 237 Assert.deepEqual(sendEvents[0].extra, { 238 flow_id: "1", 239 hashed_device_id: "devid-san", 240 stream_id: "2", 241 }); 242 const recdEvents = Glean.fxa.sendtabReceived.testGetValue(); 243 Assert.equal(recdEvents.length, 1); 244 Assert.deepEqual(recdEvents[0].extra, { 245 flow_id: "1", 246 hashed_device_id: "devid-san", 247 reason, 248 stream_id: "2", 249 }); 250 }); 251 252 // Test that a client which only sends the flowID in the envelope and not in the 253 // encrypted body gets recorded without the flowID. 254 add_task(async function test_sendtab_receive_old_client() { 255 // Clear events from other test cases. 256 Services.fog.testResetFOG(); 257 258 const fxai = FxaInternalMock(); 259 const sendTab = new SendTab(null, fxai); 260 sendTab._decrypt = bytes => { 261 return bytes; 262 }; 263 const data = { entries: [{ title: "title", url: "url" }] }; 264 // No 'flowID' in the encrypted payload, no 'streamID' anywhere. 265 const payload = { 266 flowID: "flow-id", 267 encrypted: new TextEncoder().encode(JSON.stringify(data)), 268 }; 269 const reason = "push"; 270 await sendTab.handle("sender-id", payload, reason); 271 Assert.deepEqual(fxai.telemetry._events, [ 272 { 273 object: "command-received", 274 method: COMMAND_SENDTAB_TAIL, 275 value: "sender-id-san", 276 // deepEqual doesn't ignore undefined, but our telemetry code and 277 // JSON.stringify() do... 278 extra: { flowID: undefined, streamID: undefined, reason }, 279 }, 280 ]); 281 const recdEvents = Glean.fxa.sendtabReceived.testGetValue(); 282 Assert.equal(recdEvents.length, 1); 283 Assert.deepEqual(recdEvents[0].extra, { 284 hashed_device_id: "sender-id-san", 285 reason, 286 }); 287 }); 288 289 add_task(function test_commands_getReason() { 290 const fxAccounts = { 291 async withCurrentAccountState(cb) { 292 await cb({}); 293 }, 294 }; 295 const commands = new FxAccountsCommands(fxAccounts); 296 const testCases = [ 297 { 298 receivedIndex: 0, 299 currentIndex: 0, 300 expectedReason: "poll", 301 message: "should return reason 'poll'", 302 }, 303 { 304 receivedIndex: 7, 305 currentIndex: 3, 306 expectedReason: "push-missed", 307 message: "should return reason 'push-missed'", 308 }, 309 { 310 receivedIndex: 2, 311 currentIndex: 8, 312 expectedReason: "push", 313 message: "should return reason 'push'", 314 }, 315 ]; 316 for (const tc of testCases) { 317 const reason = commands._getReason(tc.receivedIndex, tc.currentIndex); 318 Assert.equal(reason, tc.expectedReason, tc.message); 319 } 320 }); 321 322 add_task(async function test_commands_pollDeviceCommands_push() { 323 // Server state. 324 const remoteMessages = [ 325 { 326 index: 11, 327 data: {}, 328 }, 329 { 330 index: 12, 331 data: {}, 332 }, 333 ]; 334 const remoteIndex = 12; 335 336 // Local state. 337 const pushIndexReceived = 11; 338 const accountState = { 339 data: { 340 device: { 341 lastCommandIndex: 10, 342 }, 343 }, 344 getUserAccountData() { 345 return this.data; 346 }, 347 updateUserAccountData(data) { 348 this.data = data; 349 }, 350 }; 351 352 const fxAccounts = { 353 async withCurrentAccountState(cb) { 354 await cb(accountState); 355 }, 356 }; 357 const commands = new FxAccountsCommands(fxAccounts); 358 const mockCommands = sinon.mock(commands); 359 mockCommands.expects("_fetchDeviceCommands").once().withArgs(11).returns({ 360 index: remoteIndex, 361 messages: remoteMessages, 362 }); 363 mockCommands 364 .expects("_handleCommands") 365 .once() 366 .withArgs(remoteMessages, pushIndexReceived); 367 await commands.pollDeviceCommands(pushIndexReceived); 368 369 mockCommands.verify(); 370 Assert.equal(accountState.data.device.lastCommandIndex, 12); 371 }); 372 373 add_task( 374 async function test_commands_pollDeviceCommands_push_already_fetched() { 375 // Local state. 376 const pushIndexReceived = 12; 377 const accountState = { 378 data: { 379 device: { 380 lastCommandIndex: 12, 381 }, 382 }, 383 getUserAccountData() { 384 return this.data; 385 }, 386 updateUserAccountData(data) { 387 this.data = data; 388 }, 389 }; 390 391 const fxAccounts = { 392 async withCurrentAccountState(cb) { 393 await cb(accountState); 394 }, 395 }; 396 const commands = new FxAccountsCommands(fxAccounts); 397 const mockCommands = sinon.mock(commands); 398 mockCommands.expects("_fetchDeviceCommands").never(); 399 mockCommands.expects("_handleCommands").never(); 400 await commands.pollDeviceCommands(pushIndexReceived); 401 402 mockCommands.verify(); 403 Assert.equal(accountState.data.device.lastCommandIndex, 12); 404 } 405 ); 406 407 add_task(async function test_commands_handleCommands() { 408 // This test ensures that `_getReason` is being called by 409 // `_handleCommands` with the expected parameters. 410 const pushIndexReceived = 12; 411 const senderID = "6d09f6c4-89b2-41b3-a0ac-e4c2502b5485"; 412 const remoteMessageIndex = 8; 413 const remoteMessages = [ 414 { 415 index: remoteMessageIndex, 416 data: { 417 command: COMMAND_SENDTAB, 418 payload: { 419 encrypted: {}, 420 }, 421 sender: senderID, 422 }, 423 }, 424 ]; 425 426 const fxAccounts = { 427 async withCurrentAccountState(cb) { 428 await cb({}); 429 }, 430 }; 431 const commands = new FxAccountsCommands(fxAccounts); 432 commands.sendTab.handle = () => { 433 return { 434 title: "testTitle", 435 uri: "https://testURI", 436 }; 437 }; 438 commands._fxai.device = { 439 refreshDeviceList: () => {}, 440 recentDeviceList: [ 441 { 442 id: senderID, 443 }, 444 ], 445 }; 446 const mockCommands = sinon.mock(commands); 447 mockCommands 448 .expects("_getReason") 449 .once() 450 .withExactArgs(pushIndexReceived, remoteMessageIndex); 451 mockCommands.expects("_notifyFxATabsReceived").once(); 452 await commands._handleCommands(remoteMessages, pushIndexReceived); 453 mockCommands.verify(); 454 }); 455 456 add_task(async function test_commands_handleCommands_invalid_tab() { 457 // This test ensures that `_getReason` is being called by 458 // `_handleCommands` with the expected parameters. 459 const pushIndexReceived = 12; 460 const senderID = "6d09f6c4-89b2-41b3-a0ac-e4c2502b5485"; 461 const remoteMessageIndex = 8; 462 const remoteMessages = [ 463 { 464 index: remoteMessageIndex, 465 data: { 466 command: COMMAND_SENDTAB, 467 payload: { 468 encrypted: {}, 469 }, 470 sender: senderID, 471 }, 472 }, 473 ]; 474 475 const fxAccounts = { 476 async withCurrentAccountState(cb) { 477 await cb({}); 478 }, 479 }; 480 const commands = new FxAccountsCommands(fxAccounts); 481 commands.sendTab.handle = () => { 482 return { 483 title: "badUriTab", 484 uri: "file://path/to/pdf", 485 }; 486 }; 487 commands._fxai.device = { 488 refreshDeviceList: () => {}, 489 recentDeviceList: [ 490 { 491 id: senderID, 492 }, 493 ], 494 }; 495 const mockCommands = sinon.mock(commands); 496 mockCommands 497 .expects("_getReason") 498 .once() 499 .withExactArgs(pushIndexReceived, remoteMessageIndex); 500 // We shouldn't have tried to open a tab with an invalid uri 501 mockCommands.expects("_notifyFxATabsReceived").never(); 502 503 await commands._handleCommands(remoteMessages, pushIndexReceived); 504 mockCommands.verify(); 505 }); 506 507 add_task( 508 async function test_commands_pollDeviceCommands_push_local_state_empty() { 509 // Server state. 510 const remoteMessages = [ 511 { 512 index: 11, 513 data: {}, 514 }, 515 { 516 index: 12, 517 data: {}, 518 }, 519 ]; 520 const remoteIndex = 12; 521 522 // Local state. 523 const pushIndexReceived = 11; 524 const accountState = { 525 data: { 526 device: {}, 527 }, 528 getUserAccountData() { 529 return this.data; 530 }, 531 updateUserAccountData(data) { 532 this.data = data; 533 }, 534 }; 535 536 const fxAccounts = { 537 async withCurrentAccountState(cb) { 538 await cb(accountState); 539 }, 540 }; 541 const commands = new FxAccountsCommands(fxAccounts); 542 const mockCommands = sinon.mock(commands); 543 mockCommands.expects("_fetchDeviceCommands").once().withArgs(11).returns({ 544 index: remoteIndex, 545 messages: remoteMessages, 546 }); 547 mockCommands 548 .expects("_handleCommands") 549 .once() 550 .withArgs(remoteMessages, pushIndexReceived); 551 await commands.pollDeviceCommands(pushIndexReceived); 552 553 mockCommands.verify(); 554 Assert.equal(accountState.data.device.lastCommandIndex, 12); 555 } 556 ); 557 558 add_task(async function test_commands_pollDeviceCommands_scheduled_local() { 559 // Server state. 560 const remoteMessages = [ 561 { 562 index: 11, 563 data: {}, 564 }, 565 { 566 index: 12, 567 data: {}, 568 }, 569 ]; 570 const remoteIndex = 12; 571 const pushIndexReceived = 0; 572 // Local state. 573 const accountState = { 574 data: { 575 device: { 576 lastCommandIndex: 10, 577 }, 578 }, 579 getUserAccountData() { 580 return this.data; 581 }, 582 updateUserAccountData(data) { 583 this.data = data; 584 }, 585 }; 586 587 const fxAccounts = { 588 async withCurrentAccountState(cb) { 589 await cb(accountState); 590 }, 591 }; 592 const commands = new FxAccountsCommands(fxAccounts); 593 const mockCommands = sinon.mock(commands); 594 mockCommands.expects("_fetchDeviceCommands").once().withArgs(11).returns({ 595 index: remoteIndex, 596 messages: remoteMessages, 597 }); 598 mockCommands 599 .expects("_handleCommands") 600 .once() 601 .withArgs(remoteMessages, pushIndexReceived); 602 await commands.pollDeviceCommands(); 603 604 mockCommands.verify(); 605 Assert.equal(accountState.data.device.lastCommandIndex, 12); 606 }); 607 608 add_task( 609 async function test_commands_pollDeviceCommands_scheduled_local_state_empty() { 610 // Server state. 611 const remoteMessages = [ 612 { 613 index: 11, 614 data: {}, 615 }, 616 { 617 index: 12, 618 data: {}, 619 }, 620 ]; 621 const remoteIndex = 12; 622 const pushIndexReceived = 0; 623 // Local state. 624 const accountState = { 625 data: { 626 device: {}, 627 }, 628 getUserAccountData() { 629 return this.data; 630 }, 631 updateUserAccountData(data) { 632 this.data = data; 633 }, 634 }; 635 636 const fxAccounts = { 637 async withCurrentAccountState(cb) { 638 await cb(accountState); 639 }, 640 }; 641 const commands = new FxAccountsCommands(fxAccounts); 642 const mockCommands = sinon.mock(commands); 643 mockCommands.expects("_fetchDeviceCommands").once().withArgs(0).returns({ 644 index: remoteIndex, 645 messages: remoteMessages, 646 }); 647 mockCommands 648 .expects("_handleCommands") 649 .once() 650 .withArgs(remoteMessages, pushIndexReceived); 651 await commands.pollDeviceCommands(); 652 653 mockCommands.verify(); 654 Assert.equal(accountState.data.device.lastCommandIndex, 12); 655 } 656 ); 657 658 add_task(async function test_send_tab_keys_regenerated_if_lost() { 659 const commands = { 660 _invokes: [], 661 invoke(cmd, device, payload) { 662 this._invokes.push({ cmd, device, payload }); 663 }, 664 }; 665 666 // Local state. 667 const accountState = { 668 data: { 669 // Since the device object has no 670 // sendTabKeys, it will recover 671 // when we attempt to get the 672 // encryptedSendTabKeys 673 device: { 674 lastCommandIndex: 10, 675 }, 676 encryptedSendTabKeys: "keys", 677 }, 678 getUserAccountData() { 679 return this.data; 680 }, 681 updateUserAccountData(data) { 682 this.data = data; 683 }, 684 }; 685 686 const fxAccounts = { 687 async withCurrentAccountState(cb) { 688 await cb(accountState); 689 }, 690 async getUserAccountData(data) { 691 return accountState.getUserAccountData(data); 692 }, 693 telemetry: new TelemetryMock(), 694 }; 695 const sendTab = new SendTab(commands, fxAccounts); 696 let generateEncryptedKeysCalled = false; 697 sendTab._generateAndPersistEncryptedCommandKey = async () => { 698 generateEncryptedKeysCalled = true; 699 }; 700 await sendTab.getEncryptedCommandKeys(); 701 Assert.ok(generateEncryptedKeysCalled); 702 }); 703 704 add_task(async function test_send_tab_keys_are_not_regenerated_if_not_lost() { 705 const commands = { 706 _invokes: [], 707 invoke(cmd, device, payload) { 708 this._invokes.push({ cmd, device, payload }); 709 }, 710 }; 711 712 // Local state. 713 const accountState = { 714 data: { 715 // Since the device object has 716 // sendTabKeys, it will not try 717 // to regenerate them 718 // when we attempt to get the 719 // encryptedSendTabKeys 720 device: { 721 lastCommandIndex: 10, 722 sendTabKeys: "keys", 723 }, 724 encryptedSendTabKeys: "encrypted-keys", 725 }, 726 getUserAccountData() { 727 return this.data; 728 }, 729 updateUserAccountData(data) { 730 this.data = data; 731 }, 732 }; 733 734 const fxAccounts = { 735 async withCurrentAccountState(cb) { 736 await cb(accountState); 737 }, 738 async getUserAccountData(data) { 739 return accountState.getUserAccountData(data); 740 }, 741 telemetry: new TelemetryMock(), 742 }; 743 const sendTab = new SendTab(commands, fxAccounts); 744 let generateEncryptedKeysCalled = false; 745 sendTab._generateAndPersistEncryptedCommandKey = async () => { 746 generateEncryptedKeysCalled = true; 747 }; 748 await sendTab.getEncryptedCommandKeys(); 749 Assert.ok(!generateEncryptedKeysCalled); 750 });