tor-browser

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

test_remote_settings.js (55336B)


      1 /* import-globals-from ../../../common/tests/unit/head_helpers.js */
      2 
      3 const { ObjectUtils } = ChromeUtils.importESModule(
      4  "resource://gre/modules/ObjectUtils.sys.mjs"
      5 );
      6 
      7 const IS_ANDROID = AppConstants.platform == "android";
      8 
      9 const TELEMETRY_COMPONENT = "remotesettings";
     10 const TELEMETRY_EVENTS_FILTERS = {
     11  category: "uptake.remotecontent.result",
     12  method: "uptake",
     13 };
     14 
     15 let server;
     16 let client;
     17 let clientWithDump;
     18 
     19 async function clear_state() {
     20  // Reset preview mode.
     21  RemoteSettings.enablePreviewMode(undefined);
     22  Services.prefs.clearUserPref("services.settings.preview_enabled");
     23 
     24  client.verifySignature = false;
     25  clientWithDump.verifySignature = false;
     26 
     27  // Clear local DB.
     28  await client.db.clear();
     29  // Reset event listeners.
     30  client._listeners.set("sync", []);
     31 
     32  await clientWithDump.db.clear();
     33 
     34  // Clear events snapshot.
     35  TelemetryTestUtils.assertEvents([], {}, { process: "dummy" });
     36 }
     37 
     38 add_task(() => {
     39  // Set up an HTTP Server
     40  server = new HttpServer();
     41  server.start(-1);
     42 
     43  // Point the blocklist clients to use this local HTTP server.
     44  Services.prefs.setStringPref(
     45    "services.settings.server",
     46    `http://localhost:${server.identity.primaryPort}/v1`
     47  );
     48 
     49  Services.prefs.setStringPref("services.settings.loglevel", "debug");
     50 
     51  client = RemoteSettings("password-fields");
     52  clientWithDump = RemoteSettings("language-dictionaries");
     53 
     54  server.registerPathHandler("/v1/", handleResponse);
     55  server.registerPathHandler(
     56    "/v1/buckets/monitor/collections/changes/changeset",
     57    handleResponse
     58  );
     59  server.registerPathHandler(
     60    "/v1/buckets/main/collections/password-fields/changeset",
     61    handleResponse
     62  );
     63  server.registerPathHandler(
     64    "/v1/buckets/main/collections/language-dictionaries/changeset",
     65    handleResponse
     66  );
     67  server.registerPathHandler(
     68    "/v1/buckets/main/collections/with-local-fields/changeset",
     69    handleResponse
     70  );
     71  server.registerPathHandler("/fake-x5u", handleResponse);
     72 
     73  registerCleanupFunction(() => {
     74    server.stop(() => {});
     75  });
     76 });
     77 add_task(clear_state);
     78 
     79 add_task(async function test_records_obtained_from_server_are_stored_in_db() {
     80  // Test an empty db populates
     81  await client.maybeSync(2000);
     82 
     83  // Open the collection, verify it's been populated:
     84  // Our test data has a single record; it should be in the local collection
     85  const list = await client.get();
     86  equal(list.length, 1);
     87 
     88  const timestamp = await client.db.getLastModified();
     89  equal(timestamp, 3000, "timestamp was stored");
     90 
     91  const { signatures } = await client.db.getMetadata();
     92  equal(signatures[0].signature, "abcdef", "metadata was stored");
     93 });
     94 add_task(clear_state);
     95 
     96 add_task(async function test_client_db_throws_if_not_synced() {
     97  try {
     98    await client.db.list();
     99    Assert.ok(false, "db.list() should throw");
    100  } catch (e) {
    101    Assert.equal(
    102      e.toString(),
    103      'EmptyDatabaseError: "main/password-fields" has not been synced yet'
    104    );
    105  }
    106 
    107  await client.maybeSync(2000);
    108 
    109  const list = await client.db.list();
    110  Assert.ok(Array.isArray(list), "data is an array");
    111 });
    112 add_task(clear_state);
    113 
    114 add_task(
    115  async function test_records_from_dump_are_listed_as_created_in_event() {
    116    if (IS_ANDROID) {
    117      // Skip test: we don't ship remote settings dumps on Android (see package-manifest).
    118      return;
    119    }
    120    let received;
    121    clientWithDump.on("sync", ({ data }) => (received = data));
    122    // Use a timestamp superior to latest record in dump.
    123    const timestamp = 5000000000000; // Fri Jun 11 2128
    124 
    125    await clientWithDump.maybeSync(timestamp);
    126 
    127    const list = await clientWithDump.get();
    128    Assert.greater(
    129      list.length,
    130      20,
    131      `The dump was loaded (${list.length} records)`
    132    );
    133    equal(received.created[0].id, "xx", "Record from the sync come first.");
    134 
    135    const createdById = received.created.reduce((acc, r) => {
    136      acc[r.id] = r;
    137      return acc;
    138    }, {});
    139 
    140    ok(
    141      !(received.deleted[0].id in createdById),
    142      "Deleted records are not listed as created"
    143    );
    144    equal(
    145      createdById[received.updated[0].new.id],
    146      received.updated[0].new,
    147      "The records that were updated should appear as created in their newest form."
    148    );
    149 
    150    equal(
    151      received.created.length,
    152      list.length,
    153      "The list of created records contains the dump"
    154    );
    155    equal(received.current.length, received.created.length);
    156  }
    157 );
    158 add_task(clear_state);
    159 
    160 add_task(async function test_throws_when_network_is_offline() {
    161  const backupOffline = Services.io.offline;
    162  try {
    163    Services.io.offline = true;
    164    const startSnapshot = getUptakeTelemetrySnapshot(
    165      TELEMETRY_COMPONENT,
    166      clientWithDump.identifier
    167    );
    168    let error;
    169    try {
    170      await clientWithDump.maybeSync(2000);
    171    } catch (e) {
    172      error = e;
    173    }
    174    equal(error.name, "NetworkOfflineError");
    175 
    176    const endSnapshot = getUptakeTelemetrySnapshot(
    177      TELEMETRY_COMPONENT,
    178      clientWithDump.identifier
    179    );
    180    const expectedIncrements = {
    181      [UptakeTelemetry.STATUS.SYNC_START]: 1,
    182      [UptakeTelemetry.STATUS.NETWORK_OFFLINE_ERROR]: 1,
    183    };
    184    checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements);
    185  } finally {
    186    Services.io.offline = backupOffline;
    187  }
    188 });
    189 add_task(clear_state);
    190 
    191 add_task(async function test_sync_event_is_sent_even_if_up_to_date() {
    192  if (IS_ANDROID) {
    193    // Skip test: we don't ship remote settings dumps on Android (see package-manifest).
    194    return;
    195  }
    196  // First, determine what is the dump timestamp. Sync will load it.
    197  // Use a timestamp inferior to latest record in dump.
    198  await clientWithDump._importJSONDump();
    199  const uptodateTimestamp = await clientWithDump.db.getLastModified();
    200  await clear_state();
    201 
    202  // Now, simulate that server data wasn't changed since dump was released.
    203  const startSnapshot = getUptakeTelemetrySnapshot(
    204    TELEMETRY_COMPONENT,
    205    clientWithDump.identifier
    206  );
    207  let received;
    208  clientWithDump.on("sync", ({ data }) => (received = data));
    209 
    210  await clientWithDump.maybeSync(uptodateTimestamp);
    211 
    212  ok(!!received.current.length, "Dump records are listed as created");
    213  equal(received.current.length, received.created.length);
    214 
    215  const endSnapshot = getUptakeTelemetrySnapshot(
    216    TELEMETRY_COMPONENT,
    217    clientWithDump.identifier
    218  );
    219  const expectedIncrements = {
    220    [UptakeTelemetry.STATUS.SYNC_START]: 1,
    221    [UptakeTelemetry.STATUS.UP_TO_DATE]: 1,
    222  };
    223  checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements);
    224 });
    225 add_task(clear_state);
    226 
    227 add_task(async function test_records_can_have_local_fields() {
    228  const c = RemoteSettings("with-local-fields", { localFields: ["accepted"] });
    229  c.verifySignature = false;
    230 
    231  await c.maybeSync(2000);
    232 
    233  await c.db.update({
    234    id: "c74279ce-fb0a-42a6-ae11-386b567a6119",
    235    accepted: true,
    236  });
    237  await c.maybeSync(3000); // Does not fail.
    238 });
    239 add_task(clear_state);
    240 
    241 add_task(
    242  async function test_records_changes_are_overwritten_by_server_changes() {
    243    // Create some local conflicting data, and make sure it syncs without error.
    244    await client.db.create({
    245      website: "",
    246      id: "9d500963-d80e-3a91-6e74-66f3811b99cc",
    247    });
    248 
    249    await client.maybeSync(2000);
    250 
    251    const data = await client.get();
    252    equal(data[0].website, "https://some-website.com");
    253  }
    254 );
    255 add_task(clear_state);
    256 
    257 add_task(
    258  async function test_get_returns_an_empty_list_when_database_is_empty() {
    259    const data = await client.get({ syncIfEmpty: false });
    260 
    261    ok(Array.isArray(data), "data is an array");
    262    equal(data.length, 0, "data is empty");
    263  }
    264 );
    265 add_task(clear_state);
    266 
    267 add_task(async function test_get_doesnt_affect_other_calls() {
    268  const c1 = RemoteSettings("password-fields");
    269  const c2 = RemoteSettings("password-fields");
    270 
    271  const result1 = await c1.get({ syncIfEmpty: false });
    272  Assert.deepEqual(result1, [], "data1 is empty");
    273 
    274  try {
    275    await c2.get({ syncIfEmpty: false, emptyListFallback: false });
    276    Assert.ok(false, "get() should throw");
    277  } catch (error) {
    278    Assert.equal(
    279      error.toString(),
    280      'EmptyDatabaseError: "main/password-fields" has not been synced yet'
    281    );
    282  }
    283 });
    284 add_task(clear_state);
    285 
    286 add_task(async function test_get_throws_if_no_empty_fallback_and_no_sync() {
    287  try {
    288    await client.get({ syncIfEmpty: false, emptyListFallback: false });
    289    Assert.ok(false, ".get() should throw");
    290  } catch (error) {
    291    Assert.equal(
    292      error.toString(),
    293      'EmptyDatabaseError: "main/password-fields" has not been synced yet'
    294    );
    295  }
    296 });
    297 add_task(clear_state);
    298 
    299 add_task(
    300  async function test_get_loads_default_records_from_a_local_dump_when_database_is_empty() {
    301    if (IS_ANDROID) {
    302      // Skip test: we don't ship remote settings dumps on Android (see package-manifest).
    303      return;
    304    }
    305 
    306    // When collection has a dump in services/settings/dumps/{bucket}/{collection}.json
    307    const data = await clientWithDump.get();
    308    notEqual(data.length, 0);
    309    // No synchronization happened (responses are not mocked).
    310  }
    311 );
    312 add_task(clear_state);
    313 
    314 add_task(async function test_get_loads_dump_only_once_if_called_in_parallel() {
    315  const backup = clientWithDump._importJSONDump;
    316  let callCount = 0;
    317  clientWithDump._importJSONDump = async () => {
    318    callCount++;
    319    // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
    320    await new Promise(resolve => setTimeout(resolve, 100));
    321    return 42;
    322  };
    323  await Promise.all([clientWithDump.get(), clientWithDump.get()]);
    324  equal(callCount, 1, "JSON dump was called more than once");
    325  clientWithDump._importJSONDump = backup;
    326 });
    327 add_task(clear_state);
    328 
    329 add_task(async function test_get_falls_back_to_dump_if_db_fails() {
    330  if (IS_ANDROID) {
    331    // Skip test: we don't ship remote settings dumps on Android (see package-manifest).
    332    return;
    333  }
    334  const backup = clientWithDump.db.getLastModified;
    335  clientWithDump.db.getLastModified = () => {
    336    throw new Error("Unknown error");
    337  };
    338 
    339  const records = await clientWithDump.get({ dumpFallback: true });
    340  ok(!!records.length, "dump content is returned");
    341 
    342  // If fallback is disabled, error is thrown.
    343  let error;
    344  try {
    345    await clientWithDump.get({ dumpFallback: false });
    346  } catch (e) {
    347    error = e;
    348  }
    349  equal(error.message, "Unknown error");
    350 
    351  clientWithDump.db.getLastModified = backup;
    352 });
    353 add_task(clear_state);
    354 
    355 add_task(async function test_get_sorts_results_if_specified() {
    356  await client.db.importChanges(
    357    {},
    358    42,
    359    [
    360      {
    361        field: 12,
    362        id: "9d500963-d80e-3a91-6e74-66f3811b99cc",
    363      },
    364      {
    365        field: 7,
    366        id: "d83444a4-f348-4cd8-8228-842cb927db9f",
    367      },
    368    ],
    369    { clear: true }
    370  );
    371 
    372  const records = await client.get({ order: "field" });
    373  Assert.less(
    374    records[0].field,
    375    records[records.length - 1].field,
    376    "records are sorted"
    377  );
    378 });
    379 add_task(clear_state);
    380 
    381 add_task(async function test_get_falls_back_sorts_results() {
    382  if (IS_ANDROID) {
    383    // Skip test: we don't ship remote settings dumps on Android (see package-manifest).
    384    return;
    385  }
    386  const backup = clientWithDump.db.getLastModified;
    387  clientWithDump.db.getLastModified = () => {
    388    throw new Error("Unknown error");
    389  };
    390 
    391  const records = await clientWithDump.get({
    392    dumpFallback: true,
    393    order: "-id",
    394  });
    395 
    396  // eslint-disable-next-line mozilla/no-comparison-or-assignment-inside-ok
    397  ok(records[0].id > records[records.length - 1].id, "records are sorted");
    398 
    399  clientWithDump.db.getLastModified = backup;
    400 });
    401 add_task(clear_state);
    402 
    403 add_task(async function test_get_falls_back_to_dump_if_db_fails_later() {
    404  if (IS_ANDROID) {
    405    // Skip test: we don't ship remote settings dumps on Android (see package-manifest).
    406    return;
    407  }
    408  const backup = clientWithDump.db.list;
    409  clientWithDump.db.list = () => {
    410    throw new Error("Unknown error");
    411  };
    412 
    413  const records = await clientWithDump.get({ dumpFallback: true });
    414  ok(!!records.length, "dump content is returned");
    415 
    416  // If fallback is disabled, error is thrown.
    417  let error;
    418  try {
    419    await clientWithDump.get({ dumpFallback: false });
    420  } catch (e) {
    421    error = e;
    422  }
    423  equal(error.message, "Unknown error");
    424 
    425  clientWithDump.db.list = backup;
    426 });
    427 add_task(clear_state);
    428 
    429 add_task(async function test_get_falls_back_to_dump_if_network_fails() {
    430  if (IS_ANDROID) {
    431    // Skip test: we don't ship remote settings dumps on Android (see package-manifest).
    432    return;
    433  }
    434  const backup = clientWithDump.sync;
    435  clientWithDump.sync = () => {
    436    throw new Error("Sync error");
    437  };
    438 
    439  const records = await clientWithDump.get();
    440  ok(!!records.length, "dump content is returned");
    441 
    442  clientWithDump.sync = backup;
    443 });
    444 add_task(clear_state);
    445 
    446 add_task(async function test_get_does_not_sync_if_empty_dump_is_provided() {
    447  if (IS_ANDROID) {
    448    // Skip test: we don't ship remote settings dumps on Android (see package-manifest).
    449    return;
    450  }
    451 
    452  const clientWithEmptyDump = RemoteSettings("example");
    453  Assert.ok(!(await Utils.hasLocalData(clientWithEmptyDump)));
    454 
    455  const data = await clientWithEmptyDump.get();
    456 
    457  equal(data.length, 0);
    458  Assert.ok(await Utils.hasLocalData(clientWithEmptyDump));
    459 });
    460 add_task(clear_state);
    461 
    462 add_task(async function test_get_synchronization_can_be_disabled() {
    463  const data = await client.get({ syncIfEmpty: false });
    464 
    465  equal(data.length, 0);
    466 });
    467 add_task(clear_state);
    468 
    469 add_task(
    470  async function test_get_triggers_synchronization_when_database_is_empty() {
    471    // The "password-fields" collection has no local dump, and no local data.
    472    // Therefore a synchronization will happen.
    473    const data = await client.get();
    474 
    475    // Data comes from mocked HTTP response (see below).
    476    equal(data.length, 1);
    477    equal(data[0].selector, "#webpage[field-pwd]");
    478  }
    479 );
    480 add_task(clear_state);
    481 
    482 add_task(async function test_get_ignores_synchronization_errors_by_default() {
    483  // The monitor endpoint won't contain any information about this collection.
    484  let data = await RemoteSettings("some-unknown-key").get();
    485  equal(data.length, 0);
    486  // The sync endpoints are not mocked, this fails internally.
    487  data = await RemoteSettings("no-mocked-responses").get();
    488  equal(data.length, 0);
    489 });
    490 add_task(clear_state);
    491 
    492 add_task(async function test_get_throws_if_no_empty_fallback() {
    493  // The monitor endpoint won't contain any information about this collection.
    494  try {
    495    await RemoteSettings("some-unknown-key").get({
    496      emptyListFallback: false,
    497    });
    498    Assert.ok(false, ".get() should throw");
    499  } catch (error) {
    500    Assert.ok(
    501      error.message.includes("Response from server unparseable"),
    502      "Server error was thrown"
    503    );
    504  }
    505 });
    506 add_task(clear_state);
    507 
    508 add_task(
    509  async function test_get_throws_on_network_error_with_no_empty_fallback() {
    510    const backup = Utils.fetch;
    511    Utils.fetch = async () => {
    512      throw new Error("Fake Network error");
    513    };
    514 
    515    const clientEmpty = RemoteSettings("no-dump-no-local-data");
    516    let error;
    517    try {
    518      await clientEmpty.get({
    519        emptyListFallback: false,
    520        syncIfEmpty: true, // default value
    521      });
    522    } catch (exc) {
    523      error = exc;
    524    }
    525 
    526    equal(error.toString(), "Error: Fake Network error");
    527    Utils.fetch = backup;
    528  }
    529 );
    530 
    531 add_task(async function test_get_verify_signature_empty_no_sync() {
    532  client.verifySignature = true;
    533  // No data, hence no signature in metadata, and no sync if empty.
    534  let error;
    535  try {
    536    await client.get({
    537      verifySignature: true,
    538      syncIfEmpty: false,
    539      emptyListFallback: false,
    540    });
    541    Assert.ok(false, "get() should throw");
    542  } catch (e) {
    543    error = e;
    544  }
    545  equal(
    546    error.toString(),
    547    'EmptyDatabaseError: "main/password-fields" has not been synced yet'
    548  );
    549 });
    550 add_task(clear_state);
    551 
    552 add_task(async function test_get_verify_signature_no_metadata_no_sync() {
    553  client.verifySignature = true;
    554  // Store records but no metadata.
    555  await client.db.importChanges(undefined, 42, []);
    556  let error;
    557  try {
    558    await client.get({
    559      verifySignature: true,
    560      syncIfEmpty: false,
    561      emptyListFallback: false,
    562    });
    563    Assert.ok(false, "get() should throw");
    564  } catch (e) {
    565    error = e;
    566  }
    567  equal(
    568    error.toString(),
    569    "MissingSignatureError: Missing signature (main/password-fields)"
    570  );
    571 });
    572 add_task(clear_state);
    573 
    574 add_task(async function test_get_can_verify_signature_pulled() {
    575  // Populate the local DB (only records, eg. loaded from dump previously)
    576  await client._importJSONDump();
    577 
    578  let calledSignature;
    579  client._verifier = {
    580    async asyncVerifyContentSignature(serialized, signature) {
    581      calledSignature = signature;
    582      return true;
    583    },
    584  };
    585  client.verifySignature = true;
    586 
    587  // No metadata in local DB, but gets pulled and then verifies.
    588  ok(ObjectUtils.isEmpty(await client.db.getMetadata()), "Metadata is empty");
    589 
    590  await client.get({ verifySignature: true });
    591 
    592  ok(
    593    !ObjectUtils.isEmpty(await client.db.getMetadata()),
    594    "Metadata was pulled"
    595  );
    596  ok(calledSignature.endsWith("some-sig"), "Signature was verified");
    597 });
    598 add_task(clear_state);
    599 
    600 add_task(async function test_get_can_verify_signature() {
    601  // Populate the local DB (record and metadata)
    602  await client.maybeSync(2000);
    603 
    604  client.verifySignature = true;
    605 
    606  // It validates signature that was stored in local DB.
    607  let calledSignature;
    608  client._verifier = {
    609    async asyncVerifyContentSignature(serialized, signature) {
    610      calledSignature = signature;
    611      return JSON.parse(serialized).data.length == 1;
    612    },
    613  };
    614  ok(await Utils.hasLocalData(client), "Local data was populated");
    615  await client.get({ verifySignature: true });
    616 
    617  ok(calledSignature.endsWith("abcdef"), "Signature was verified");
    618 
    619  // It throws when signature does not verify.
    620  await client.db.delete("9d500963-d80e-3a91-6e74-66f3811b99cc");
    621  let error = null;
    622  try {
    623    await client.get({ verifySignature: true });
    624  } catch (e) {
    625    error = e;
    626  }
    627  equal(
    628    error.message,
    629    "Invalid content signature (main/password-fields) using 'fake-x5u' and signer remote-settings.content-signature.mozilla.org"
    630  );
    631 });
    632 add_task(clear_state);
    633 
    634 add_task(async function test_get_does_not_verify_signature_if_load_dump() {
    635  if (IS_ANDROID) {
    636    // Skip test: we don't ship remote settings dumps on Android (see package-manifest).
    637    return;
    638  }
    639 
    640  let called;
    641  clientWithDump._verifier = {
    642    async asyncVerifyContentSignature() {
    643      called = true;
    644      return true;
    645    },
    646  };
    647 
    648  // When dump is loaded, signature is not verified.
    649  const records = await clientWithDump.get({ verifySignature: true });
    650  ok(!!records.length, "dump is loaded");
    651  ok(!called, "signature is missing but not verified");
    652 
    653  // If metadata is missing locally, it is not fetched if `syncIfEmpty` is disabled.
    654  clientWithDump.verifySignature = true;
    655  let error;
    656  try {
    657    await clientWithDump.get({ verifySignature: true, syncIfEmpty: false });
    658  } catch (e) {
    659    error = e;
    660  }
    661  ok(!called, "signer was not called");
    662  equal(
    663    error.message,
    664    "Missing signature (main/language-dictionaries)",
    665    "signature is missing locally"
    666  );
    667 
    668  // If metadata is missing locally, it is fetched by default (`syncIfEmpty: true`)
    669  await clientWithDump.get({ verifySignature: true });
    670  const metadata = await clientWithDump.db.getMetadata();
    671  ok(!!Object.keys(metadata).length, "metadata was fetched");
    672  ok(called, "signature was verified for the data that was in dump");
    673  clientWithDump.verifySignature = true;
    674 });
    675 add_task(clear_state);
    676 
    677 add_task(
    678  async function test_get_does_verify_signature_if_json_loaded_in_parallel() {
    679    const backup = clientWithDump._verifier;
    680    let callCount = 0;
    681    clientWithDump._verifier = {
    682      async asyncVerifyContentSignature() {
    683        callCount++;
    684        return true;
    685      },
    686    };
    687    await Promise.all([
    688      clientWithDump.get({ verifySignature: true }),
    689      clientWithDump.get({ verifySignature: true }),
    690    ]);
    691    equal(callCount, 0, "No need to verify signatures if JSON dump is loaded");
    692    clientWithDump._verifier = backup;
    693  }
    694 );
    695 add_task(clear_state);
    696 
    697 add_task(async function test_get_can_force_a_sync() {
    698  const step0 = await client.db.getLastModified();
    699  await client.get({ forceSync: true });
    700  const step1 = await client.db.getLastModified();
    701  await client.get();
    702  const step2 = await client.db.getLastModified();
    703  await client.get({ forceSync: true });
    704  const step3 = await client.db.getLastModified();
    705 
    706  equal(step0, null);
    707  equal(step1, 3000);
    708  equal(step2, 3000);
    709  equal(step3, 3001);
    710 });
    711 add_task(clear_state);
    712 
    713 add_task(async function test_sync_runs_once_only() {
    714  const backup = Utils.log.warn;
    715  const messages = [];
    716  Utils.log.warn = m => {
    717    messages.push(m);
    718  };
    719 
    720  await Promise.all([client.maybeSync(2000), client.maybeSync(2000)]);
    721 
    722  ok(
    723    messages.includes("main/password-fields sync already running"),
    724    "warning is shown about sync already running"
    725  );
    726  Utils.log.warn = backup;
    727 });
    728 add_task(clear_state);
    729 
    730 add_task(
    731  async function test_sync_pulls_metadata_if_missing_with_dump_is_up_to_date() {
    732    if (IS_ANDROID) {
    733      // Skip test: we don't ship remote settings dumps on Android (see package-manifest).
    734      return;
    735    }
    736 
    737    let called;
    738    clientWithDump._verifier = {
    739      async asyncVerifyContentSignature() {
    740        called = true;
    741        return true;
    742      },
    743    };
    744    // When dump is loaded, signature is not verified.
    745    const records = await clientWithDump.get({ verifySignature: true });
    746    ok(!!records.length, "dump is loaded");
    747    ok(!called, "signature is missing but not verified");
    748 
    749    // Synchronize the collection (local data is up-to-date).
    750    // Signature verification is disabled (see `clear_state()`), so we don't bother with
    751    // fetching metadata.
    752    const uptodateTimestamp = await clientWithDump.db.getLastModified();
    753    await clientWithDump.maybeSync(uptodateTimestamp);
    754    let metadata = await clientWithDump.db.getMetadata();
    755    ok(!metadata, "metadata was not fetched");
    756 
    757    // Synchronize again the collection (up-to-date, since collection last modified still > 42)
    758    clientWithDump.verifySignature = true;
    759    await clientWithDump.maybeSync(42);
    760 
    761    // With signature verification, metadata was fetched.
    762    metadata = await clientWithDump.db.getMetadata();
    763    ok(!!Object.keys(metadata).length, "metadata was fetched");
    764    ok(called, "signature was verified for the data that was in dump");
    765 
    766    // Metadata is present, signature will now verified.
    767    called = false;
    768    await clientWithDump.get({ verifySignature: true });
    769    ok(called, "local signature is verified");
    770  }
    771 );
    772 add_task(clear_state);
    773 
    774 add_task(async function test_sync_event_provides_information_about_records() {
    775  let eventData;
    776  client.on("sync", ({ data }) => (eventData = data));
    777 
    778  await client.maybeSync(2000);
    779  equal(eventData.current.length, 1);
    780 
    781  await client.maybeSync(3001);
    782  equal(eventData.current.length, 2);
    783  equal(eventData.created.length, 1);
    784  equal(eventData.created[0].website, "https://www.other.org/signin");
    785  equal(eventData.updated.length, 1);
    786  equal(eventData.updated[0].old.website, "https://some-website.com");
    787  equal(eventData.updated[0].new.website, "https://some-website.com/login");
    788  equal(eventData.deleted.length, 0);
    789 
    790  await client.maybeSync(4001);
    791  equal(eventData.current.length, 1);
    792  equal(eventData.created.length, 0);
    793  equal(eventData.updated.length, 0);
    794  equal(eventData.deleted.length, 1);
    795  equal(eventData.deleted[0].website, "https://www.other.org/signin");
    796 });
    797 add_task(clear_state);
    798 
    799 add_task(async function test_inspect_method() {
    800  // Synchronize the `password-fields` collection in order to have
    801  // some local data when .inspect() is called.
    802  await client.maybeSync(2000);
    803 
    804  const inspected = await RemoteSettings.inspect();
    805 
    806  // Assertion for global attributes.
    807  const { mainBucket, serverURL, defaultSigner, collections, serverTimestamp } =
    808    inspected;
    809  const rsSigner = "remote-settings.content-signature.mozilla.org";
    810  equal(mainBucket, "main");
    811  equal(serverURL, `http://localhost:${server.identity.primaryPort}/v1`);
    812  equal(defaultSigner, rsSigner);
    813  equal(serverTimestamp, '"5000"');
    814 
    815  // A collection is listed in .inspect() if it has local data or if there
    816  // is a JSON dump for it.
    817  // "password-fields" has no dump but was synchronized above and thus has local data.
    818  let col = collections.pop();
    819  equal(col.collection, "password-fields");
    820  equal(col.serverTimestamp, 3000);
    821  equal(col.localTimestamp, 3000);
    822 
    823  if (!IS_ANDROID) {
    824    // "language-dictionaries" has a local dump (not on Android)
    825    col = collections.pop();
    826    equal(col.collection, "language-dictionaries");
    827    equal(col.serverTimestamp, 4000);
    828    ok(!col.localTimestamp); // not synchronized.
    829  }
    830 });
    831 add_task(clear_state);
    832 
    833 add_task(async function test_inspect_method_uses_a_random_cache_bust() {
    834  const backup = Utils.fetchLatestChanges;
    835  const cacheBusts = [];
    836  Utils.fetchLatestChanges = (url, options) => {
    837    cacheBusts.push(options.expected);
    838    return { changes: [] };
    839  };
    840 
    841  await RemoteSettings.inspect();
    842  await RemoteSettings.inspect();
    843  await RemoteSettings.inspect();
    844 
    845  notEqual(cacheBusts[0], cacheBusts[1]);
    846  notEqual(cacheBusts[1], cacheBusts[2]);
    847  notEqual(cacheBusts[0], cacheBusts[2]);
    848  Utils.fetchLatestChanges = backup;
    849 });
    850 
    851 add_task(async function test_jexl_context_is_shown_in_inspect() {
    852  const { jexlContext } = await RemoteSettings.inspect();
    853  deepEqual(Object.keys(jexlContext).sort(), [
    854    "appinfo",
    855    "channel",
    856    "country",
    857    "formFactor",
    858    "locale",
    859    "os",
    860    "version",
    861  ]);
    862  deepEqual(Object.keys(jexlContext.os).sort(), ["name", "version"]);
    863  deepEqual(Object.keys(jexlContext.appinfo).sort(), ["ID", "OS"]);
    864 });
    865 
    866 add_task(async function test_clearAll_method() {
    867  // Make sure we have some local data.
    868  await client.maybeSync(2000);
    869  await clientWithDump.maybeSync(2000);
    870 
    871  await RemoteSettings.clearAll();
    872 
    873  ok(!(await Utils.hasLocalData(client)), "Local data was deleted");
    874  ok(!(await Utils.hasLocalData(clientWithDump)), "Local data was deleted");
    875  ok(
    876    !Services.prefs.prefHasUserValue(client.lastCheckTimePref),
    877    "Pref was cleaned"
    878  );
    879 
    880  // Synchronization is not broken after resuming.
    881  await client.maybeSync(2000);
    882  await clientWithDump.maybeSync(2000);
    883  ok(await Utils.hasLocalData(client), "Local data was populated");
    884  ok(await Utils.hasLocalData(clientWithDump), "Local data was populated");
    885 });
    886 add_task(clear_state);
    887 
    888 add_task(async function test_listeners_are_not_deduplicated() {
    889  let count = 0;
    890  const plus1 = () => {
    891    count += 1;
    892  };
    893 
    894  client.on("sync", plus1);
    895  client.on("sync", plus1);
    896  client.on("sync", plus1);
    897 
    898  await client.maybeSync(2000);
    899 
    900  equal(count, 3);
    901 });
    902 add_task(clear_state);
    903 
    904 add_task(async function test_listeners_can_be_removed() {
    905  let count = 0;
    906  const onSync = () => {
    907    count += 1;
    908  };
    909 
    910  client.on("sync", onSync);
    911  client.off("sync", onSync);
    912 
    913  await client.maybeSync(2000);
    914 
    915  equal(count, 0);
    916 });
    917 add_task(clear_state);
    918 
    919 add_task(async function test_all_listeners_are_executed_if_one_fails() {
    920  let count = 0;
    921  client.on("sync", () => {
    922    count += 1;
    923  });
    924  client.on("sync", () => {
    925    throw new Error("boom");
    926  });
    927  client.on("sync", () => {
    928    count += 2;
    929  });
    930 
    931  let error;
    932  try {
    933    await client.maybeSync(2000);
    934  } catch (e) {
    935    error = e;
    936  }
    937 
    938  equal(count, 3);
    939  equal(error.message, "boom");
    940 });
    941 add_task(clear_state);
    942 
    943 add_task(async function test_telemetry_reports_up_to_date() {
    944  await client.maybeSync(2000);
    945  const startSnapshot = getUptakeTelemetrySnapshot(
    946    TELEMETRY_COMPONENT,
    947    client.identifier
    948  );
    949 
    950  await client.maybeSync(3000);
    951 
    952  // No Telemetry was sent.
    953  const endSnapshot = getUptakeTelemetrySnapshot(
    954    TELEMETRY_COMPONENT,
    955    client.identifier
    956  );
    957  const expectedIncrements = { [UptakeTelemetry.STATUS.UP_TO_DATE]: 1 };
    958  checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements);
    959 });
    960 add_task(clear_state);
    961 
    962 add_task(async function test_telemetry_if_sync_succeeds() {
    963  // We test each client because Telemetry requires preleminary declarations.
    964  const startSnapshot = getUptakeTelemetrySnapshot(
    965    TELEMETRY_COMPONENT,
    966    client.identifier
    967  );
    968 
    969  await client.maybeSync(2000);
    970 
    971  const endSnapshot = getUptakeTelemetrySnapshot(
    972    TELEMETRY_COMPONENT,
    973    client.identifier
    974  );
    975  const expectedIncrements = {
    976    [UptakeTelemetry.STATUS.SYNC_START]: 1,
    977    [UptakeTelemetry.STATUS.SUCCESS]: 1,
    978  };
    979  checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements);
    980 });
    981 add_task(clear_state);
    982 
    983 add_task(
    984  async function test_synchronization_duration_is_reported_in_uptake_status() {
    985    await client.maybeSync(2000);
    986 
    987    TelemetryTestUtils.assertEvents(
    988      [
    989        [
    990          "uptake.remotecontent.result",
    991          "uptake",
    992          "remotesettings",
    993          UptakeTelemetry.STATUS.SYNC_START,
    994          {
    995            source: client.identifier,
    996            trigger: "manual",
    997          },
    998        ],
    999        [
   1000          "uptake.remotecontent.result",
   1001          "uptake",
   1002          "remotesettings",
   1003          UptakeTelemetry.STATUS.SUCCESS,
   1004          {
   1005            source: client.identifier,
   1006            duration: v => v > 0,
   1007            trigger: "manual",
   1008          },
   1009        ],
   1010      ],
   1011      TELEMETRY_EVENTS_FILTERS
   1012    );
   1013  }
   1014 );
   1015 add_task(clear_state);
   1016 
   1017 add_task(async function test_telemetry_reports_if_application_fails() {
   1018  const startSnapshot = getUptakeTelemetrySnapshot(
   1019    TELEMETRY_COMPONENT,
   1020    client.identifier
   1021  );
   1022  client.on("sync", () => {
   1023    throw new Error("boom");
   1024  });
   1025 
   1026  try {
   1027    await client.maybeSync(2000);
   1028  } catch (e) {}
   1029 
   1030  const endSnapshot = getUptakeTelemetrySnapshot(
   1031    TELEMETRY_COMPONENT,
   1032    client.identifier
   1033  );
   1034  const expectedIncrements = {
   1035    [UptakeTelemetry.STATUS.SYNC_START]: 1,
   1036    [UptakeTelemetry.STATUS.APPLY_ERROR]: 1,
   1037  };
   1038  checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements);
   1039 });
   1040 add_task(clear_state);
   1041 
   1042 add_task(async function test_telemetry_reports_if_sync_fails() {
   1043  await client.db.importChanges({}, 9999);
   1044 
   1045  const startSnapshot = getUptakeTelemetrySnapshot(
   1046    TELEMETRY_COMPONENT,
   1047    client.identifier
   1048  );
   1049 
   1050  try {
   1051    await client.maybeSync(10000);
   1052  } catch (e) {}
   1053 
   1054  const endSnapshot = getUptakeTelemetrySnapshot(
   1055    TELEMETRY_COMPONENT,
   1056    client.identifier
   1057  );
   1058  const expectedIncrements = {
   1059    [UptakeTelemetry.STATUS.SYNC_START]: 1,
   1060    [UptakeTelemetry.STATUS.SERVER_ERROR]: 1,
   1061  };
   1062  checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements);
   1063 });
   1064 add_task(clear_state);
   1065 
   1066 add_task(async function test_telemetry_reports_if_parsing_fails() {
   1067  await client.db.importChanges({}, 10000);
   1068 
   1069  const startSnapshot = getUptakeTelemetrySnapshot(
   1070    TELEMETRY_COMPONENT,
   1071    client.identifier
   1072  );
   1073 
   1074  try {
   1075    await client.maybeSync(10001);
   1076  } catch (e) {}
   1077 
   1078  const endSnapshot = getUptakeTelemetrySnapshot(
   1079    TELEMETRY_COMPONENT,
   1080    client.identifier
   1081  );
   1082  const expectedIncrements = {
   1083    [UptakeTelemetry.STATUS.SYNC_START]: 1,
   1084    [UptakeTelemetry.STATUS.PARSE_ERROR]: 1,
   1085  };
   1086  checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements);
   1087 });
   1088 add_task(clear_state);
   1089 
   1090 add_task(async function test_telemetry_reports_if_fetching_signature_fails() {
   1091  await client.db.importChanges({}, 11000);
   1092 
   1093  const startSnapshot = getUptakeTelemetrySnapshot(
   1094    TELEMETRY_COMPONENT,
   1095    client.identifier
   1096  );
   1097 
   1098  try {
   1099    await client.maybeSync(11001);
   1100  } catch (e) {}
   1101 
   1102  const endSnapshot = getUptakeTelemetrySnapshot(
   1103    TELEMETRY_COMPONENT,
   1104    client.identifier
   1105  );
   1106  const expectedIncrements = {
   1107    [UptakeTelemetry.STATUS.SYNC_START]: 1,
   1108    [UptakeTelemetry.STATUS.SERVER_ERROR]: 1,
   1109  };
   1110  checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements);
   1111 });
   1112 add_task(clear_state);
   1113 
   1114 add_task(async function test_telemetry_reports_unknown_errors() {
   1115  const backup = client.db.getLastModified;
   1116  client.db.getLastModified = () => {
   1117    throw new Error("Internal");
   1118  };
   1119  const startSnapshot = getUptakeTelemetrySnapshot(
   1120    TELEMETRY_COMPONENT,
   1121    client.identifier
   1122  );
   1123 
   1124  try {
   1125    await client.maybeSync(2000);
   1126  } catch (e) {}
   1127 
   1128  client.db.getLastModified = backup;
   1129  const endSnapshot = getUptakeTelemetrySnapshot(
   1130    TELEMETRY_COMPONENT,
   1131    client.identifier
   1132  );
   1133  const expectedIncrements = {
   1134    [UptakeTelemetry.STATUS.SYNC_START]: 1,
   1135    [UptakeTelemetry.STATUS.UNKNOWN_ERROR]: 1,
   1136  };
   1137  checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements);
   1138 });
   1139 add_task(clear_state);
   1140 
   1141 add_task(async function test_telemetry_reports_indexeddb_as_custom_1() {
   1142  const backup = client.db.getLastModified;
   1143  const msg =
   1144    "IndexedDB getLastModified() The operation failed for reasons unrelated to the database itself";
   1145  client.db.getLastModified = () => {
   1146    throw new Error(msg);
   1147  };
   1148  const startSnapshot = getUptakeTelemetrySnapshot(
   1149    TELEMETRY_COMPONENT,
   1150    client.identifier
   1151  );
   1152 
   1153  try {
   1154    await client.maybeSync(2000);
   1155  } catch (e) {}
   1156 
   1157  client.db.getLastModified = backup;
   1158  const endSnapshot = getUptakeTelemetrySnapshot(
   1159    TELEMETRY_COMPONENT,
   1160    client.identifier
   1161  );
   1162  const expectedIncrements = {
   1163    [UptakeTelemetry.STATUS.SYNC_START]: 1,
   1164    [UptakeTelemetry.STATUS.CUSTOM_1_ERROR]: 1,
   1165  };
   1166  checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements);
   1167 });
   1168 add_task(clear_state);
   1169 
   1170 add_task(async function test_telemetry_reports_error_name_as_event_nightly() {
   1171  const backup = client.db.getLastModified;
   1172  client.db.getLastModified = () => {
   1173    const e = new Error("Some unknown error");
   1174    e.name = "ThrownError";
   1175    throw e;
   1176  };
   1177 
   1178  try {
   1179    await client.maybeSync(2000);
   1180  } catch (e) {}
   1181 
   1182  TelemetryTestUtils.assertEvents(
   1183    [
   1184      [
   1185        "uptake.remotecontent.result",
   1186        "uptake",
   1187        "remotesettings",
   1188        UptakeTelemetry.STATUS.SYNC_START,
   1189        {
   1190          source: client.identifier,
   1191          trigger: "manual",
   1192        },
   1193      ],
   1194      [
   1195        "uptake.remotecontent.result",
   1196        "uptake",
   1197        "remotesettings",
   1198        UptakeTelemetry.STATUS.UNKNOWN_ERROR,
   1199        {
   1200          source: client.identifier,
   1201          trigger: "manual",
   1202          duration: v => v >= 0,
   1203          errorName: "ThrownError",
   1204        },
   1205      ],
   1206    ],
   1207    TELEMETRY_EVENTS_FILTERS
   1208  );
   1209 
   1210  client.db.getLastModified = backup;
   1211 });
   1212 add_task(clear_state);
   1213 
   1214 add_task(async function test_bucketname_changes_when_preview_mode_is_enabled() {
   1215  equal(client.bucketName, "main");
   1216 
   1217  RemoteSettings.enablePreviewMode(true);
   1218 
   1219  equal(client.bucketName, "main-preview");
   1220 });
   1221 add_task(clear_state);
   1222 
   1223 add_task(
   1224  async function test_preview_mode_pref_affects_bucket_names_before_instantiated() {
   1225    Services.prefs.setBoolPref("services.settings.preview_enabled", true);
   1226 
   1227    let clientWithDefaultBucket = RemoteSettings("other");
   1228    let clientWithBucket = RemoteSettings("coll", { bucketName: "buck" });
   1229 
   1230    equal(clientWithDefaultBucket.bucketName, "main-preview");
   1231    equal(clientWithBucket.bucketName, "buck-preview");
   1232  }
   1233 );
   1234 add_task(clear_state);
   1235 
   1236 add_task(
   1237  async function test_preview_enabled_pref_ignored_when_mode_is_set_explicitly() {
   1238    Services.prefs.setBoolPref("services.settings.preview_enabled", true);
   1239 
   1240    let clientWithDefaultBucket = RemoteSettings("other");
   1241    let clientWithBucket = RemoteSettings("coll", { bucketName: "buck" });
   1242 
   1243    equal(clientWithDefaultBucket.bucketName, "main-preview");
   1244    equal(clientWithBucket.bucketName, "buck-preview");
   1245 
   1246    RemoteSettings.enablePreviewMode(false);
   1247 
   1248    equal(clientWithDefaultBucket.bucketName, "main");
   1249    equal(clientWithBucket.bucketName, "buck");
   1250  }
   1251 );
   1252 add_task(clear_state);
   1253 
   1254 add_task(
   1255  async function test_get_loads_default_records_from_a_local_dump_when_preview_mode_is_enabled() {
   1256    if (IS_ANDROID) {
   1257      // Skip test: we don't ship remote settings dumps on Android (see package-manifest).
   1258      return;
   1259    }
   1260    RemoteSettings.enablePreviewMode(true);
   1261    // When collection has a dump in services/settings/dumps/{bucket}/{collection}.json
   1262    const data = await clientWithDump.get();
   1263    notEqual(data.length, 0);
   1264    // No synchronization happened (responses are not mocked).
   1265  }
   1266 );
   1267 add_task(clear_state);
   1268 
   1269 add_task(async function test_local_db_distinguishes_preview_records() {
   1270  RemoteSettings.enablePreviewMode(true);
   1271  client.db.importChanges({}, Date.now(), [{ id: "record-1" }], {
   1272    clear: true,
   1273  });
   1274 
   1275  RemoteSettings.enablePreviewMode(false);
   1276  client.db.importChanges({}, Date.now(), [{ id: "record-2" }], {
   1277    clear: true,
   1278  });
   1279 
   1280  deepEqual(await client.get(), [{ id: "record-2" }]);
   1281 });
   1282 add_task(clear_state);
   1283 
   1284 add_task(
   1285  async function test_inspect_changes_the_list_when_preview_mode_is_enabled() {
   1286    if (IS_ANDROID) {
   1287      // Skip test: we don't ship remote settings dumps on Android (see package-manifest),
   1288      // and this test relies on the fact that clients are instantiated if a dump is packaged.
   1289      return;
   1290    }
   1291 
   1292    // Register a client only listed in -preview...
   1293    RemoteSettings("crash-rate");
   1294 
   1295    const { collections: before, previewMode: previewModeBefore } =
   1296      await RemoteSettings.inspect();
   1297 
   1298    Assert.ok(!previewModeBefore, "preview is not enabled");
   1299 
   1300    // These two collections are listed in the main bucket in monitor/changes (one with dump, one registered).
   1301    deepEqual(before.map(c => c.collection).sort(), [
   1302      "language-dictionaries",
   1303      "password-fields",
   1304    ]);
   1305 
   1306    // Switch to preview mode.
   1307    RemoteSettings.enablePreviewMode(true);
   1308 
   1309    const {
   1310      collections: after,
   1311      mainBucket,
   1312      previewMode,
   1313    } = await RemoteSettings.inspect();
   1314 
   1315    Assert.ok(previewMode, "preview is enabled");
   1316 
   1317    // These two collections are listed in the main bucket in monitor/changes (both are registered).
   1318    deepEqual(after.map(c => c.collection).sort(), [
   1319      "crash-rate",
   1320      "password-fields",
   1321    ]);
   1322    equal(mainBucket, "main-preview");
   1323  }
   1324 );
   1325 add_task(clear_state);
   1326 
   1327 add_task(async function test_sync_event_is_not_sent_from_get_when_no_dump() {
   1328  let called = false;
   1329  client.on("sync", () => {
   1330    called = true;
   1331  });
   1332 
   1333  await client.get();
   1334 
   1335  Assert.ok(!called, "sync event is not sent from .get()");
   1336 });
   1337 add_task(clear_state);
   1338 
   1339 add_task(async function test_get_can_be_called_from_sync_event_callback() {
   1340  let fromGet;
   1341  let fromEvent;
   1342 
   1343  client.on("sync", async ({ data: { current } }) => {
   1344    // Before fixing Bug 1761953 this would result in a deadlock.
   1345    fromGet = await client.get();
   1346    fromEvent = current;
   1347  });
   1348 
   1349  await client.maybeSync(2000);
   1350 
   1351  Assert.ok(fromGet, "sync callback was called");
   1352  Assert.deepEqual(fromGet, fromEvent, ".get() gives current records list");
   1353 });
   1354 add_task(clear_state);
   1355 
   1356 add_task(async function test_attachments_are_pruned_when_sync_from_timer() {
   1357  await client.db.saveAttachment("bar", {
   1358    record: { id: "bar" },
   1359    blob: new Blob(["456"]),
   1360  });
   1361 
   1362  await client.maybeSync(2000, { trigger: "broadcast" });
   1363 
   1364  Assert.ok(
   1365    await client.attachments.cacheImpl.get("bar"),
   1366    "Extra attachment was not deleted on broadcast"
   1367  );
   1368 
   1369  await client.maybeSync(3001, { trigger: "timer" });
   1370 
   1371  Assert.ok(
   1372    !(await client.attachments.cacheImpl.get("bar")),
   1373    "Extra attachment was deleted on timer"
   1374  );
   1375 });
   1376 add_task(clear_state);
   1377 
   1378 function handleResponse(request, response) {
   1379  try {
   1380    const sample = getSampleResponse(request, server.identity.primaryPort);
   1381    if (!sample) {
   1382      do_throw(
   1383        `unexpected ${request.method} request for ${request.path}?${request.queryString}`
   1384      );
   1385    }
   1386 
   1387    response.setStatusLine(
   1388      null,
   1389      sample.status.status,
   1390      sample.status.statusText
   1391    );
   1392    // send the headers
   1393    for (let headerLine of sample.sampleHeaders) {
   1394      let headerElements = headerLine.split(":");
   1395      response.setHeader(headerElements[0], headerElements[1].trimLeft());
   1396    }
   1397    response.setHeader("Date", new Date().toUTCString());
   1398 
   1399    const body =
   1400      typeof sample.responseBody == "string"
   1401        ? sample.responseBody
   1402        : JSON.stringify(sample.responseBody);
   1403    response.write(body);
   1404    response.finish();
   1405  } catch (e) {
   1406    info(e);
   1407  }
   1408 }
   1409 
   1410 function getSampleResponse(req, port) {
   1411  const responses = {
   1412    OPTIONS: {
   1413      sampleHeaders: [
   1414        "Access-Control-Allow-Headers: Content-Length,Expires,Backoff,Retry-After,Last-Modified,Total-Records,ETag,Pragma,Cache-Control,authorization,content-type,if-none-match,Alert,Next-Page",
   1415        "Access-Control-Allow-Methods: GET,HEAD,OPTIONS,POST,DELETE,OPTIONS",
   1416        "Access-Control-Allow-Origin: *",
   1417        "Content-Type: application/json; charset=UTF-8",
   1418        "Server: waitress",
   1419      ],
   1420      status: { status: 200, statusText: "OK" },
   1421      responseBody: null,
   1422    },
   1423    "GET:/v1/": {
   1424      sampleHeaders: [
   1425        "Access-Control-Allow-Origin: *",
   1426        "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
   1427        "Content-Type: application/json; charset=UTF-8",
   1428        "Server: waitress",
   1429      ],
   1430      status: { status: 200, statusText: "OK" },
   1431      responseBody: {
   1432        settings: {
   1433          batch_max_requests: 25,
   1434        },
   1435        url: `http://localhost:${port}/v1/`,
   1436        documentation: "https://kinto.readthedocs.org/",
   1437        version: "1.5.1",
   1438        commit: "cbc6f58",
   1439        hello: "kinto",
   1440      },
   1441    },
   1442    "GET:/v1/buckets/monitor/collections/changes/changeset": {
   1443      sampleHeaders: [
   1444        "Access-Control-Allow-Origin: *",
   1445        "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
   1446        "Content-Type: application/json; charset=UTF-8",
   1447        "Server: waitress",
   1448        `Date: ${new Date().toUTCString()}`,
   1449        'Etag: "5000"',
   1450      ],
   1451      status: { status: 200, statusText: "OK" },
   1452      responseBody: {
   1453        timestamp: 5000,
   1454        changes: [
   1455          {
   1456            id: "4676f0c7-9757-4796-a0e8-b40a5a37a9c9",
   1457            bucket: "main",
   1458            collection: "unknown-locally",
   1459            last_modified: 5000,
   1460          },
   1461          {
   1462            id: "4676f0c7-9757-4796-a0e8-b40a5a37a9c9",
   1463            bucket: "main",
   1464            collection: "language-dictionaries",
   1465            last_modified: 4000,
   1466          },
   1467          {
   1468            id: "0af8da0b-3e03-48fb-8d0d-2d8e4cb7514d",
   1469            bucket: "main",
   1470            collection: "password-fields",
   1471            last_modified: 3000,
   1472          },
   1473          {
   1474            id: "4acda969-3bd3-4074-a678-ff311eeb076e",
   1475            bucket: "main-preview",
   1476            collection: "password-fields",
   1477            last_modified: 2000,
   1478          },
   1479          {
   1480            id: "58697bd1-315f-4185-9bee-3371befc2585",
   1481            bucket: "main-preview",
   1482            collection: "crash-rate",
   1483            last_modified: 1000,
   1484          },
   1485        ],
   1486      },
   1487    },
   1488    "GET:/fake-x5u": {
   1489      sampleHeaders: ["Content-Type: application/octet-stream"],
   1490      status: { status: 200, statusText: "OK" },
   1491      responseBody: `-----BEGIN CERTIFICATE-----
   1492 MIIGYTCCBEmgAwIBAgIBATANBgkqhkiG9w0BAQwFADB9MQswCQYDVQQGEwJVU
   1493 ZARKjbu1TuYQHf0fs+GwID8zeLc2zJL7UzcHFwwQ6Nda9OJN4uPAuC/BKaIpxCLL
   1494 26b24/tRam4SJjqpiq20lynhUrmTtt6hbG3E1Hpy3bmkt2DYnuMFwEx2gfXNcnbT
   1495 wNuvFqc=
   1496 -----END CERTIFICATE-----`,
   1497    },
   1498    "GET:/v1/buckets/main/collections/password-fields/changeset?_expected=2000":
   1499      {
   1500        sampleHeaders: [
   1501          "Access-Control-Allow-Origin: *",
   1502          "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
   1503          "Content-Type: application/json; charset=UTF-8",
   1504          "Server: waitress",
   1505          'Etag: "3000"',
   1506        ],
   1507        status: { status: 200, statusText: "OK" },
   1508        responseBody: {
   1509          timestamp: 3000,
   1510          metadata: {
   1511            id: "password-fields",
   1512            last_modified: 1234,
   1513            signatures: [
   1514              {
   1515                signature: "abcdef",
   1516                x5u: `http://localhost:${port}/fake-x5u`,
   1517              },
   1518            ],
   1519          },
   1520          changes: [
   1521            {
   1522              id: "9d500963-d80e-3a91-6e74-66f3811b99cc",
   1523              last_modified: 3000,
   1524              website: "https://some-website.com",
   1525              selector: "#user[password]",
   1526            },
   1527          ],
   1528        },
   1529      },
   1530    "GET:/v1/buckets/main/collections/password-fields/changeset?_expected=3001&_since=%223000%22":
   1531      {
   1532        sampleHeaders: [
   1533          "Access-Control-Allow-Origin: *",
   1534          "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
   1535          "Content-Type: application/json; charset=UTF-8",
   1536          "Server: waitress",
   1537          'Etag: "4000"',
   1538        ],
   1539        status: { status: 200, statusText: "OK" },
   1540        responseBody: {
   1541          metadata: {
   1542            signatures: [{}],
   1543          },
   1544          timestamp: 4000,
   1545          changes: [
   1546            {
   1547              id: "aabad965-e556-ffe7-4191-074f5dee3df3",
   1548              last_modified: 4000,
   1549              website: "https://www.other.org/signin",
   1550              selector: "#signinpassword",
   1551            },
   1552            {
   1553              id: "9d500963-d80e-3a91-6e74-66f3811b99cc",
   1554              last_modified: 3500,
   1555              website: "https://some-website.com/login",
   1556              selector: "input#user[password]",
   1557            },
   1558          ],
   1559        },
   1560      },
   1561    "GET:/v1/buckets/main/collections/password-fields/changeset?_expected=4001&_since=%224000%22":
   1562      {
   1563        sampleHeaders: [
   1564          "Access-Control-Allow-Origin: *",
   1565          "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
   1566          "Content-Type: application/json; charset=UTF-8",
   1567          "Server: waitress",
   1568          'Etag: "5000"',
   1569        ],
   1570        status: { status: 200, statusText: "OK" },
   1571        responseBody: {
   1572          metadata: {
   1573            signatures: [{}],
   1574          },
   1575          timestamp: 5000,
   1576          changes: [
   1577            {
   1578              id: "aabad965-e556-ffe7-4191-074f5dee3df3",
   1579              deleted: true,
   1580            },
   1581          ],
   1582        },
   1583      },
   1584    "GET:/v1/buckets/main/collections/password-fields/changeset?_expected=10000&_since=%229999%22":
   1585      {
   1586        sampleHeaders: [
   1587          "Access-Control-Allow-Origin: *",
   1588          "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
   1589          "Content-Type: application/json; charset=UTF-8",
   1590          "Server: waitress",
   1591        ],
   1592        status: { status: 503, statusText: "Service Unavailable" },
   1593        responseBody: {
   1594          code: 503,
   1595          errno: 999,
   1596          error: "Service Unavailable",
   1597        },
   1598      },
   1599    "GET:/v1/buckets/main/collections/password-fields/changeset?_expected=10001&_since=%2210000%22":
   1600      {
   1601        sampleHeaders: [
   1602          "Access-Control-Allow-Origin: *",
   1603          "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
   1604          "Content-Type: application/json; charset=UTF-8",
   1605          "Server: waitress",
   1606          'Etag: "10001"',
   1607        ],
   1608        status: { status: 200, statusText: "OK" },
   1609        responseBody: "<invalid json",
   1610      },
   1611    "GET:/v1/buckets/main/collections/password-fields/changeset?_expected=11001&_since=%2211000%22":
   1612      {
   1613        sampleHeaders: [
   1614          "Access-Control-Allow-Origin: *",
   1615          "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
   1616          "Content-Type: application/json; charset=UTF-8",
   1617          "Server: waitress",
   1618        ],
   1619        status: { status: 503, statusText: "Service Unavailable" },
   1620        responseBody: {
   1621          changes: [
   1622            {
   1623              id: "c4f021e3-f68c-4269-ad2a-d4ba87762b35",
   1624              last_modified: 4000,
   1625              website: "https://www.eff.org",
   1626              selector: "#pwd",
   1627            },
   1628          ],
   1629        },
   1630      },
   1631    "GET:/v1/buckets/main/collections/password-fields?_expected=11001": {
   1632      sampleHeaders: [
   1633        "Access-Control-Allow-Origin: *",
   1634        "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
   1635        "Content-Type: application/json; charset=UTF-8",
   1636        "Server: waitress",
   1637      ],
   1638      status: { status: 503, statusText: "Service Unavailable" },
   1639      responseBody: {
   1640        code: 503,
   1641        errno: 999,
   1642        error: "Service Unavailable",
   1643      },
   1644    },
   1645    "GET:/v1/buckets/monitor/collections/changes/changeset?collection=password-fields&bucket=main&_expected=0":
   1646      {
   1647        sampleHeaders: [
   1648          "Access-Control-Allow-Origin: *",
   1649          "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
   1650          "Content-Type: application/json; charset=UTF-8",
   1651          "Server: waitress",
   1652          `Date: ${new Date().toUTCString()}`,
   1653          'Etag: "1338"',
   1654        ],
   1655        status: { status: 200, statusText: "OK" },
   1656        responseBody: {
   1657          timestamp: 1338,
   1658          changes: [
   1659            {
   1660              id: "fe5758d0-c67a-42d0-bb4f-8f2d75106b65",
   1661              bucket: "main",
   1662              collection: "password-fields",
   1663              last_modified: 1337,
   1664            },
   1665          ],
   1666        },
   1667      },
   1668    "GET:/v1/buckets/main/collections/password-fields/changeset?_expected=1337":
   1669      {
   1670        sampleHeaders: [
   1671          "Access-Control-Allow-Origin: *",
   1672          "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
   1673          "Content-Type: application/json; charset=UTF-8",
   1674          "Server: waitress",
   1675          'Etag: "3000"',
   1676        ],
   1677        status: { status: 200, statusText: "OK" },
   1678        responseBody: {
   1679          metadata: {
   1680            signatures: [
   1681              {
   1682                signature: "some-sig",
   1683                x5u: `http://localhost:${port}/fake-x5u`,
   1684              },
   1685            ],
   1686          },
   1687          timestamp: 3000,
   1688          changes: [
   1689            {
   1690              id: "312cc78d-9c1f-4291-a4fa-a1be56f6cc69",
   1691              last_modified: 3000,
   1692              website: "https://some-website.com",
   1693              selector: "#webpage[field-pwd]",
   1694            },
   1695          ],
   1696        },
   1697      },
   1698    "GET:/v1/buckets/main/collections/password-fields/changeset?_expected=1337&_since=%223000%22":
   1699      {
   1700        sampleHeaders: [
   1701          "Access-Control-Allow-Origin: *",
   1702          "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
   1703          "Content-Type: application/json; charset=UTF-8",
   1704          "Server: waitress",
   1705          'Etag: "3001"',
   1706        ],
   1707        status: { status: 200, statusText: "OK" },
   1708        responseBody: {
   1709          metadata: {
   1710            signatures: [
   1711              {
   1712                signature: "some-sig",
   1713                x5u: `http://localhost:${port}/fake-x5u`,
   1714              },
   1715            ],
   1716          },
   1717          timestamp: 3001,
   1718          changes: [
   1719            {
   1720              id: "312cc78d-9c1f-4291-a4fa-a1be56f6cc69",
   1721              last_modified: 3001,
   1722              website: "https://some-website-2.com",
   1723              selector: "#webpage[field-pwd]",
   1724            },
   1725          ],
   1726        },
   1727      },
   1728    "GET:/v1/buckets/main/collections/language-dictionaries/changeset": {
   1729      sampleHeaders: [
   1730        "Access-Control-Allow-Origin: *",
   1731        "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
   1732        "Content-Type: application/json; charset=UTF-8",
   1733        "Server: waitress",
   1734        'Etag: "5000000000000"',
   1735      ],
   1736      status: { status: 200, statusText: "OK" },
   1737      responseBody: {
   1738        timestamp: 5000000000000,
   1739        metadata: {
   1740          id: "language-dictionaries",
   1741          last_modified: 1234,
   1742          signatures: [
   1743            {
   1744              signature: "xyz",
   1745              x5u: `http://localhost:${port}/fake-x5u`,
   1746            },
   1747          ],
   1748        },
   1749        changes: [
   1750          {
   1751            id: "xx",
   1752            last_modified: 5000000000000,
   1753            dictionaries: ["xx-XX@dictionaries.addons.mozilla.org"],
   1754          },
   1755          {
   1756            id: "fr",
   1757            last_modified: 5000000000000 - 1,
   1758            deleted: true,
   1759          },
   1760          {
   1761            id: "pt-BR",
   1762            last_modified: 5000000000000 - 2,
   1763            dictionaries: ["pt-BR@for-tests"],
   1764          },
   1765        ],
   1766      },
   1767    },
   1768    "GET:/v1/buckets/main/collections/with-local-fields/changeset?_expected=2000":
   1769      {
   1770        sampleHeaders: [
   1771          "Access-Control-Allow-Origin: *",
   1772          "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
   1773          "Content-Type: application/json; charset=UTF-8",
   1774          "Server: waitress",
   1775          'Etag: "2000"',
   1776        ],
   1777        status: { status: 200, statusText: "OK" },
   1778        responseBody: {
   1779          timestamp: 2000,
   1780          metadata: {
   1781            id: "with-local-fields",
   1782            last_modified: 1234,
   1783            signatures: [
   1784              {
   1785                signature: "xyz",
   1786                x5u: `http://localhost:${port}/fake-x5u`,
   1787              },
   1788            ],
   1789          },
   1790          changes: [
   1791            {
   1792              id: "c74279ce-fb0a-42a6-ae11-386b567a6119",
   1793              last_modified: 2000,
   1794            },
   1795          ],
   1796        },
   1797      },
   1798    "GET:/v1/buckets/main/collections/with-local-fields/changeset?_expected=3000&_since=%222000%22":
   1799      {
   1800        sampleHeaders: [
   1801          "Access-Control-Allow-Origin: *",
   1802          "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
   1803          "Content-Type: application/json; charset=UTF-8",
   1804          "Server: waitress",
   1805          'Etag: "3000"',
   1806        ],
   1807        status: { status: 200, statusText: "OK" },
   1808        responseBody: {
   1809          timestamp: 3000,
   1810          metadata: {
   1811            signatures: [{}],
   1812          },
   1813          changes: [
   1814            {
   1815              id: "1f5c98b9-6d93-4c13-aa26-978b38695096",
   1816              last_modified: 3000,
   1817            },
   1818          ],
   1819        },
   1820      },
   1821    "GET:/v1/buckets/monitor/collections/changes/changeset?collection=no-mocked-responses&bucket=main&_expected=0":
   1822      {
   1823        sampleHeaders: [
   1824          "Access-Control-Allow-Origin: *",
   1825          "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
   1826          "Content-Type: application/json; charset=UTF-8",
   1827          "Server: waitress",
   1828          `Date: ${new Date().toUTCString()}`,
   1829          'Etag: "713705"',
   1830        ],
   1831        status: { status: 200, statusText: "OK" },
   1832        responseBody: {
   1833          data: [
   1834            {
   1835              id: "07a98d1b-7c62-4344-ab18-76856b3facd8",
   1836              bucket: "main",
   1837              collection: "no-mocked-responses",
   1838              last_modified: 713705,
   1839            },
   1840          ],
   1841        },
   1842      },
   1843  };
   1844  return (
   1845    responses[`${req.method}:${req.path}?${req.queryString}`] ||
   1846    responses[`${req.method}:${req.path}`] ||
   1847    responses[req.method]
   1848  );
   1849 }
   1850 
   1851 add_task(clear_state);
   1852 
   1853 add_task(async function test_hasAttachments_works_as_expected() {
   1854  let res = await client.db.hasAttachments();
   1855  Assert.equal(res, false, "Should return false, no attachments at start");
   1856 
   1857  await client.db.saveAttachment("foo", {
   1858    record: { id: "foo" },
   1859    blob: new Blob(["foo"]),
   1860  });
   1861 
   1862  res = await client.db.hasAttachments();
   1863  Assert.equal(res, true, "Should return true, just saved an attachment");
   1864 
   1865  await client.db.pruneAttachments([]);
   1866 
   1867  res = await client.db.hasAttachments();
   1868  Assert.equal(res, false, "Should return false after attachments are pruned");
   1869 });
   1870 add_task(clear_state);