tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 });