test_commands_closetab.js (18588B)
1 /* Any copyright is dedicated to the Public Domain. 2 * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 4 "use strict"; 5 6 const { CloseRemoteTab, CommandQueue } = ChromeUtils.importESModule( 7 "resource://gre/modules/FxAccountsCommands.sys.mjs" 8 ); 9 10 const { COMMAND_CLOSETAB, COMMAND_CLOSETAB_TAIL } = ChromeUtils.importESModule( 11 "resource://gre/modules/FxAccountsCommon.sys.mjs" 12 ); 13 14 const { getRemoteCommandStore, RemoteCommand } = ChromeUtils.importESModule( 15 "resource://services-sync/TabsStore.sys.mjs" 16 ); 17 18 const { NimbusTestUtils } = ChromeUtils.importESModule( 19 "resource://testing-common/NimbusTestUtils.sys.mjs" 20 ); 21 22 NimbusTestUtils.init(this); 23 24 class TelemetryMock { 25 constructor() { 26 this._events = []; 27 this._uuid_counter = 0; 28 } 29 30 recordEvent(object, method, value, extra = undefined) { 31 this._events.push({ object, method, value, extra }); 32 } 33 34 generateFlowID() { 35 this._uuid_counter += 1; 36 return this._uuid_counter.toString(); 37 } 38 39 sanitizeDeviceId(id) { 40 return id + "-san"; 41 } 42 } 43 44 function FxaInternalMock(recentDeviceList) { 45 return { 46 telemetry: new TelemetryMock(), 47 device: { 48 recentDeviceList, 49 }, 50 }; 51 } 52 53 add_setup(function () { 54 do_get_profile(); // FOG requires a profile dir. 55 Services.fog.initializeFOG(); 56 }); 57 58 add_task(async function test_closetab_isDeviceCompatible() { 59 const closeTab = new CloseRemoteTab(null, null); 60 let device = { name: "My device" }; 61 Assert.ok(!closeTab.isDeviceCompatible(device)); 62 device = { name: "My device", availableCommands: {} }; 63 Assert.ok(!closeTab.isDeviceCompatible(device)); 64 device = { 65 name: "My device", 66 availableCommands: { 67 "https://identity.mozilla.com/cmd/close-uri/v1": "payload", 68 }, 69 }; 70 // The feature should be on by default 71 Assert.ok(closeTab.isDeviceCompatible(device)); 72 73 // Disable the feature 74 Services.prefs.setBoolPref( 75 "identity.fxaccounts.commands.remoteTabManagement.enabled", 76 false 77 ); 78 Assert.ok(!closeTab.isDeviceCompatible(device)); 79 80 // clear the pref to test overriding with nimbus 81 Services.prefs.clearUserPref( 82 "identity.fxaccounts.commands.remoteTabManagement.enabled" 83 ); 84 Assert.ok(closeTab.isDeviceCompatible(device)); 85 86 const { cleanup } = await NimbusTestUtils.setupTest(); 87 88 // Verify that nimbus can remotely override the pref 89 let doExperimentCleanup = await NimbusTestUtils.enrollWithFeatureConfig({ 90 featureId: "remoteTabManagement", 91 // You can add values for each variable you added to the manifest 92 value: { 93 closeTabsEnabled: false, 94 }, 95 }); 96 97 // Feature successfully disabled 98 Assert.ok(!closeTab.isDeviceCompatible(device)); 99 100 await doExperimentCleanup(); 101 await cleanup(); 102 }); 103 104 add_task(async function test_closetab_send() { 105 const targetDevice = { id: "dev1", name: "Device 1" }; 106 107 const fxai = FxaInternalMock([targetDevice]); 108 let fxaCommands = {}; 109 const closeTab = (fxaCommands.closeTab = new CloseRemoteTab( 110 fxaCommands, 111 fxai 112 )); 113 const commandQueue = (fxaCommands.commandQueue = new CommandQueue( 114 fxaCommands, 115 fxai 116 )); 117 let commandMock = sinon.mock(closeTab); 118 let queueMock = sinon.mock(commandQueue); 119 120 // freeze "now" to a specific time 121 let now = Date.now(); 122 commandQueue.now = () => now; 123 124 // Set the delay to 10ms 125 commandQueue.DELAY = 10; 126 127 const store = await getRemoteCommandStore(); 128 129 // Queue 3 tabs to close with different timings 130 const command1 = new RemoteCommand.CloseTab({ 131 url: "https://foo.bar/must-send", 132 }); 133 await store.addRemoteCommandAt(targetDevice.id, command1, now - 15); 134 135 const command2 = new RemoteCommand.CloseTab({ 136 url: "https://foo.bar/can-send", 137 }); 138 await store.addRemoteCommandAt(targetDevice.id, command2, now - 12); 139 140 const command3 = new RemoteCommand.CloseTab({ url: "https://foo.bar/early" }); 141 await store.addRemoteCommandAt(targetDevice.id, command3, now - 5); 142 143 // Verify initial state 144 let pending = await store.getUnsentCommands(); 145 Assert.equal(pending.length, 3); 146 147 commandMock.expects("sendCloseTabsCommand").never(); 148 // We expect command1 to be "overdue": 10ms slop + 5ms + 10ms delay 149 queueMock.expects("_ensureTimer").once().withArgs(16); 150 151 // Run the flush 152 await commandQueue.flushQueue(); 153 154 // Verify state after flush - all commands should still be there 155 pending = await store.getUnsentCommands(); 156 Assert.equal(pending.length, 3); 157 158 commandMock.verify(); 159 queueMock.verify(); 160 161 // Move time forward by 15ms 162 now += 15; 163 164 // Reset mocks 165 commandMock = sinon.mock(closeTab); 166 queueMock = sinon.mock(commandQueue); 167 168 commandMock 169 .expects("sendCloseTabsCommand") 170 .once() 171 .withArgs(targetDevice, [ 172 "https://foo.bar/early", 173 "https://foo.bar/can-send", 174 "https://foo.bar/must-send", 175 ]) 176 .resolves(true); 177 178 queueMock.expects("_ensureTimer").never(); 179 180 await commandQueue.flushQueue(); 181 182 // Verify final state - all commands should be sent 183 pending = await store.getUnsentCommands(); 184 Assert.equal(pending.length, 0); 185 186 commandMock.verify(); 187 queueMock.verify(); 188 189 // Testing we don't send commands if there are 190 // no "overdue" items but there are "due" ones 191 192 // Queue 2 more tabs 193 let command4 = new RemoteCommand.CloseTab({ url: "https://foo.bar/due" }); 194 await store.addRemoteCommandAt(targetDevice.id, command4, now - 5); 195 let command5 = new RemoteCommand.CloseTab({ url: "https://foo.bar/due2" }); 196 await store.addRemoteCommandAt(targetDevice.id, command5, now); 197 198 // Verify initial state 199 pending = await store.getUnsentCommands(); 200 Assert.equal(pending.length, 2); 201 202 commandMock = sinon.mock(closeTab); 203 queueMock = sinon.mock(commandQueue); 204 205 commandMock.expects("sendCloseTabsCommand").never(); 206 queueMock.expects("_ensureTimer").once().withArgs(16); // 10ms slop + 5ms + 1ms delay 207 208 // Move the timer a little but not due enough 209 now += 5; 210 211 // Run the flush 212 await commandQueue.flushQueue(); 213 214 // all commands should still be there 215 pending = await store.getUnsentCommands(); 216 Assert.equal(pending.length, 2); 217 218 commandMock.verify(); 219 queueMock.verify(); 220 221 // Clean up unsent commands 222 await store.removeRemoteCommand(targetDevice.id, command4); 223 await store.removeRemoteCommand(targetDevice.id, command5); 224 225 commandMock.restore(); 226 queueMock.restore(); 227 commandQueue.shutdown(); 228 }); 229 230 add_task(async function test_closetab_send() { 231 const targetDevice = { id: "dev1", name: "Device 1" }; 232 233 const fxai = FxaInternalMock([targetDevice]); 234 let fxaCommands = {}; 235 const closeTab = (fxaCommands.closeTab = new CloseRemoteTab( 236 fxaCommands, 237 fxai 238 )); 239 const commandQueue = (fxaCommands.commandQueue = new CommandQueue( 240 fxaCommands, 241 fxai 242 )); 243 let commandMock = sinon.mock(closeTab); 244 let queueMock = sinon.mock(commandQueue); 245 246 // freeze "now" to <= when the command was sent. 247 let now = Date.now(); 248 commandQueue.now = () => now; 249 250 // Set the delay to 10ms 251 commandQueue.DELAY = 10; 252 253 // Our command will be written and have a timer set in 21ms. 254 queueMock.expects("_ensureTimer").once().withArgs(21); 255 256 // In this test we expect no commands sent but a timer instead. 257 closeTab.invoke = sinon.spy((cmd, device, payload) => { 258 Assert.equal(payload.encrypted, "encryptedpayload"); 259 }); 260 261 const store = await getRemoteCommandStore(); 262 Assert.equal((await store.getUnsentCommands()).length, 0); 263 // queue a tab to close, recent enough that it remains queued and a new timer is set for it. 264 const command = new RemoteCommand.CloseTab({ 265 url: "https://foo.bar/send-at-shutdown", 266 }); 267 Assert.ok( 268 await store.addRemoteCommandAt(targetDevice.id, command, now), 269 "adding the remote command should work" 270 ); 271 272 // We have the tab queued 273 const pending = await store.getUnsentCommands(); 274 Assert.equal(pending.length, 1); 275 276 await commandQueue.flushQueue(); 277 // A timer was set for it. 278 Assert.equal((await store.getUnsentCommands()).length, 1); 279 280 commandMock.verify(); 281 queueMock.verify(); 282 283 // now pretend we are being shutdown - we should force the send even though the time 284 // criteria has not been met. 285 commandMock = sinon.mock(closeTab); 286 queueMock = sinon.mock(commandQueue); 287 queueMock.expects("_ensureTimer").never(); 288 commandMock 289 .expects("sendCloseTabsCommand") 290 .once() 291 .withArgs(targetDevice, ["https://foo.bar/send-at-shutdown"]) 292 .resolves(true); 293 294 await commandQueue.flushQueue(true); 295 // No tabs waiting 296 Assert.equal((await store.getUnsentCommands()).length, 0); 297 298 commandMock.verify(); 299 queueMock.verify(); 300 commandMock.restore(); 301 queueMock.restore(); 302 commandQueue.shutdown(); 303 }); 304 305 add_task(async function test_multiple_devices() { 306 const device1 = { 307 id: "dev1", 308 name: "Device 1", 309 }; 310 const device2 = { 311 id: "dev2", 312 name: "Device 2", 313 }; 314 const fxai = FxaInternalMock([device1, device2]); 315 let fxaCommands = {}; 316 const closeTab = (fxaCommands.closeTab = new CloseRemoteTab( 317 fxaCommands, 318 fxai 319 )); 320 const commandQueue = (fxaCommands.commandQueue = new CommandQueue( 321 fxaCommands, 322 fxai 323 )); 324 325 const store = await getRemoteCommandStore(); 326 327 const tab1 = "https://foo.bar"; 328 const tab2 = "https://example.com"; 329 330 let commandMock = sinon.mock(closeTab); 331 let queueMock = sinon.mock(commandQueue); 332 333 let now = Date.now(); 334 commandQueue.now = () => now; 335 336 // Set the delay to 10ms 337 commandQueue.DELAY = 10; 338 339 commandMock.expects("sendCloseTabsCommand").twice().resolves(true); 340 341 // In this test we expect no commands sent but a timer instead. 342 closeTab.invoke = sinon.spy((cmd, device, payload) => { 343 Assert.equal(payload.encrypted, "encryptedpayload"); 344 }); 345 346 let command1 = new RemoteCommand.CloseTab({ url: tab1 }); 347 Assert.ok( 348 await store.addRemoteCommandAt(device1.id, command1, now - 15), 349 "adding the remote command should work" 350 ); 351 352 let command2 = new RemoteCommand.CloseTab({ url: tab2 }); 353 Assert.ok( 354 await store.addRemoteCommandAt(device2.id, command2, now), 355 "adding the remote command should work" 356 ); 357 358 // both tabs should remain pending. 359 let unsentCommands = await store.getUnsentCommands(); 360 Assert.equal(unsentCommands.length, 2); 361 362 // Verify both unsent commands looks as expected for each device 363 Assert.equal(unsentCommands[0].deviceId, "dev1"); 364 Assert.equal(unsentCommands[0].command.url, "https://foo.bar"); 365 Assert.equal(unsentCommands[1].deviceId, "dev2"); 366 Assert.equal(unsentCommands[1].command.url, "https://example.com"); 367 368 // move "now" to be 20ms timer - ie, pretending the timer fired. 369 now += 20; 370 371 await commandQueue.flushQueue(); 372 373 // no more in queue 374 unsentCommands = await store.getUnsentCommands(); 375 Assert.equal(unsentCommands.length, 0); 376 377 // This will verify the expectation set after the mock init 378 commandMock.verify(); 379 queueMock.verify(); 380 commandQueue.shutdown(); 381 commandMock.restore(); 382 queueMock.restore(); 383 }); 384 385 add_task(async function test_timer_reset_on_new_tab() { 386 const targetDevice = { 387 id: "dev1", 388 name: "Device 1", 389 availableCommands: { [COMMAND_CLOSETAB]: "payload" }, 390 }; 391 const fxai = FxaInternalMock([targetDevice]); 392 let fxaCommands = {}; 393 const closeTab = (fxaCommands.closeTab = new CloseRemoteTab( 394 fxaCommands, 395 fxai 396 )); 397 const commandQueue = (fxaCommands.commandQueue = new CommandQueue( 398 fxaCommands, 399 fxai 400 )); 401 const store = await getRemoteCommandStore(); 402 403 const tab1 = "https://foo.bar/"; 404 const tab2 = "https://example.com/"; 405 406 let commandMock = sinon.mock(closeTab); 407 let queueMock = sinon.mock(commandQueue); 408 409 let now = Date.now(); 410 commandQueue.now = () => now; 411 412 // Set the delay to 10ms 413 commandQueue.DELAY = 10; 414 415 const ensureTimerSpy = sinon.spy(commandQueue, "_ensureTimer"); 416 417 commandMock.expects("sendCloseTabsCommand").never(); 418 419 let command1 = new RemoteCommand.CloseTab({ url: tab1 }); 420 Assert.ok( 421 await store.addRemoteCommandAt(targetDevice.id, command1, now - 5), 422 "adding the remote command should work" 423 ); 424 await commandQueue.flushQueue(); 425 426 let command2 = new RemoteCommand.CloseTab({ url: tab2 }); 427 Assert.ok( 428 await store.addRemoteCommandAt(targetDevice.id, command2, now), 429 "adding the remote command should work" 430 ); 431 await commandQueue.flushQueue(); 432 433 // both tabs should remain pending. 434 let unsentCmds = await store.getUnsentCommands(); 435 Assert.equal(unsentCmds.length, 2); 436 437 // _ensureTimer should've been called at least twice 438 Assert.greater(ensureTimerSpy.callCount, 1); 439 commandMock.verify(); 440 queueMock.verify(); 441 commandQueue.shutdown(); 442 commandMock.restore(); 443 queueMock.restore(); 444 445 // Clean up any unsent commands for future tests 446 for await (const cmd of unsentCmds) { 447 console.log(cmd); 448 await store.removeRemoteCommand(cmd.deviceId, cmd.command); 449 } 450 }); 451 452 // Test that once we see the first tab sync complete we wait for the idle service then check the queue. 453 add_task(async function test_idle_flush() { 454 const commandQueue = new CommandQueue({}, {}); 455 456 let addIdleObserver = (obs, duration) => { 457 Assert.equal(duration, 3); 458 obs(); 459 }; 460 let spyAddIdleObserver = sinon.spy(addIdleObserver); 461 let idleService = { 462 addIdleObserver: spyAddIdleObserver, 463 removeIdleObserver: sinon.mock(), 464 }; 465 commandQueue._getIdleService = () => { 466 return idleService; 467 }; 468 let spyFlushQueue = sinon.spy(commandQueue, "flushQueue"); 469 470 // send the notification twice - should flush once. 471 Services.obs.notifyObservers(null, "weave:engine:sync:finish", "tabs"); 472 Services.obs.notifyObservers(null, "weave:engine:sync:finish", "tabs"); 473 474 Assert.ok(spyAddIdleObserver.calledOnce); 475 Assert.ok(spyFlushQueue.calledOnce); 476 commandQueue.shutdown(); 477 spyFlushQueue.restore(); 478 }); 479 480 add_task(async function test_telemetry_on_sendCloseTabsCommand() { 481 // Clear events from other test cases 482 Services.fog.testResetFOG(); 483 484 const targetDevice = { 485 id: "dev1", 486 name: "Device 1", 487 availableCommands: { [COMMAND_CLOSETAB]: "payload" }, 488 }; 489 const fxai = FxaInternalMock([targetDevice]); 490 491 // Stub out invoke and _encrypt since we're mainly testing 492 // the telemetry gets called okay 493 const commands = { 494 _invokes: [], 495 invoke(cmd, device, payload) { 496 this._invokes.push({ cmd, device, payload }); 497 }, 498 }; 499 const closeTab = (commands.closeTab = new CloseRemoteTab(commands, fxai)); 500 const commandQueue = (commands.commandQueue = new CommandQueue( 501 commands, 502 fxai 503 )); 504 505 closeTab._encrypt = () => "encryptedpayload"; 506 507 // freeze "now" to <= when the command was sent. 508 let now = Date.now(); 509 commandQueue.now = () => now; 510 511 // Set the delay to 10ms 512 commandQueue.DELAY = 10; 513 514 let command1 = new RemoteCommand.CloseTab({ url: "https://foo.bar/" }); 515 516 const store = await getRemoteCommandStore(); 517 Assert.ok( 518 await store.addRemoteCommandAt(targetDevice.id, command1, now - 15), 519 "adding the remote command should work" 520 ); 521 522 await commandQueue.flushQueue(); 523 // Validate that sendCloseTabsCommand was called correctly 524 Assert.deepEqual(fxai.telemetry._events, [ 525 { 526 object: "command-sent", 527 method: COMMAND_CLOSETAB_TAIL, 528 value: "dev1-san", 529 extra: { flowID: "1", streamID: "2" }, 530 }, 531 ]); 532 const sendEvents = Glean.fxa.closetabSent.testGetValue(); 533 Assert.equal(sendEvents.length, 1); 534 Assert.deepEqual(sendEvents[0].extra, { 535 flow_id: "1", 536 hashed_device_id: "dev1-san", 537 stream_id: "2", 538 }); 539 540 commandQueue.shutdown(); 541 }); 542 543 // Should match the one in the FxAccountsCommands 544 const COMMAND_MAX_PAYLOAD_SIZE = 16 * 1024; 545 add_task(async function test_closetab_chunking() { 546 const targetDevice = { id: "dev1", name: "Device 1" }; 547 548 const fxai = FxaInternalMock([targetDevice]); 549 let fxaCommands = {}; 550 const closeTab = (fxaCommands.closeTab = new CloseRemoteTab( 551 fxaCommands, 552 fxai 553 )); 554 const commandQueue = (fxaCommands.commandQueue = new CommandQueue( 555 fxaCommands, 556 fxai 557 )); 558 let commandMock = sinon.mock(closeTab); 559 let queueMock = sinon.mock(commandQueue); 560 561 // freeze "now" to <= when the command was sent. 562 let now = Date.now(); 563 commandQueue.now = () => now; 564 565 // Set the delay to 10ms 566 commandQueue.DELAY = 10; 567 568 // Generate a large number of commands to exceed the 16KB payload limit 569 const largeNumberOfCommands = []; 570 for (let i = 0; i < 300; i++) { 571 largeNumberOfCommands.push( 572 new RemoteCommand.CloseTab({ 573 url: `https://example.com/addingsomeextralongstring/tab${i}`, 574 }) 575 ); 576 } 577 578 // Add these commands to the store 579 const store = await getRemoteCommandStore(); 580 for (let command of largeNumberOfCommands) { 581 await store.addRemoteCommandAt(targetDevice.id, command, now - 15); 582 } 583 584 const encoder = new TextEncoder(); 585 // Calculate expected number of chunks 586 const totalPayloadSize = encoder.encode( 587 JSON.stringify(largeNumberOfCommands.map(cmd => cmd.url)) 588 ).byteLength; 589 const expectedChunks = Math.ceil(totalPayloadSize / COMMAND_MAX_PAYLOAD_SIZE); 590 591 let flowIDUsed; 592 let chunksSent = 0; 593 commandMock 594 .expects("sendCloseTabsCommand") 595 .exactly(expectedChunks) 596 .callsFake((device, urls, flowID) => { 597 console.log( 598 "Chunk sent with size:", 599 encoder.encode(JSON.stringify(urls)).length 600 ); 601 chunksSent++; 602 if (!flowIDUsed) { 603 flowIDUsed = flowID; 604 } else { 605 Assert.equal( 606 flowID, 607 flowIDUsed, 608 "FlowID should be consistent across chunks" 609 ); 610 } 611 612 const chunkSize = encoder.encode(JSON.stringify(urls)).length; 613 Assert.lessOrEqual( 614 chunkSize, 615 COMMAND_MAX_PAYLOAD_SIZE, 616 `Chunk size (${chunkSize}) should not exceed max payload size (${COMMAND_MAX_PAYLOAD_SIZE})` 617 ); 618 619 return Promise.resolve(true); 620 }); 621 622 await commandQueue.flushQueue(); 623 624 // Check that all commands have been sent 625 Assert.equal((await store.getUnsentCommands()).length, 0); 626 Assert.equal( 627 chunksSent, 628 expectedChunks, 629 `Should have sent ${expectedChunks} chunks` 630 ); 631 632 commandMock.verify(); 633 queueMock.verify(); 634 635 // Test edge case: URL exceeding max size 636 const oversizedCommand = new RemoteCommand.CloseTab({ 637 url: "https://example.com/" + "a".repeat(COMMAND_MAX_PAYLOAD_SIZE), 638 }); 639 await store.addRemoteCommandAt(targetDevice.id, oversizedCommand, now); 640 641 await commandQueue.flushQueue(); 642 643 // The oversized command should still be unsent 644 Assert.equal((await store.getUnsentCommands()).length, 1); 645 646 commandMock.verify(); 647 queueMock.verify(); 648 commandQueue.shutdown(); 649 commandMock.restore(); 650 queueMock.restore(); 651 });