tor-browser

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

test_attachments_downloader.js (27037B)


      1 const { Downloader } = ChromeUtils.importESModule(
      2  "resource://services-settings/Attachments.sys.mjs"
      3 );
      4 
      5 const RECORD = {
      6  id: "1f3a0802-648d-11ea-bd79-876a8b69c377",
      7  attachment: {
      8    hash: "f41ed47d0f43325c9f089d03415c972ce1d3f1ecab6e4d6260665baf3db3ccee",
      9    size: 1597,
     10    filename: "test_file.pem",
     11    location:
     12      "main-workspace/some-collection/65650a0f-7c22-4c10-9744-2d67e301f5f4.pem",
     13    mimetype: "application/x-pem-file",
     14  },
     15 };
     16 
     17 const RECORD_OF_DUMP = {
     18  id: "filename-of-dump.txt",
     19  attachment: {
     20    filename: "filename-of-dump.txt",
     21    hash: "4c46ef7e4f1951d210fe54c21e07c09bab265fd122580083ed1d6121547a8c6b",
     22    size: 25,
     23  },
     24  last_modified: 1234567,
     25  some_key: "some metadata",
     26 };
     27 
     28 let downloader;
     29 let server;
     30 
     31 add_setup(() => {
     32  server = new HttpServer();
     33  server.start(-1);
     34  registerCleanupFunction(() => server.stop(() => {}));
     35 
     36  server.registerDirectory(
     37    "/cdn/main-workspace/some-collection/",
     38    do_get_file("test_attachments_downloader")
     39  );
     40  server.registerDirectory(
     41    "/cdn/bundles/",
     42    do_get_file("test_attachments_downloader")
     43  );
     44 
     45  // For this test, we are using a server other than production. Force
     46  // LOAD_DUMPS to true so that we can still load attachments from dumps.
     47  delete Utils.LOAD_DUMPS;
     48  Utils.LOAD_DUMPS = true;
     49 });
     50 
     51 async function clear_state() {
     52  Services.prefs.setStringPref(
     53    "services.settings.server",
     54    `http://localhost:${server.identity.primaryPort}/v1`
     55  );
     56 
     57  downloader = new Downloader("main", "some-collection");
     58  downloader.cache = {};
     59  const memCacheImpl = {
     60    get: async id => {
     61      return downloader.cache[id];
     62    },
     63    set: async (id, obj) => {
     64      downloader.cache[id] = obj;
     65    },
     66    setMultiple: async idsObjs => {
     67      idsObjs.forEach(([id, obj]) => (downloader.cache[id] = obj));
     68    },
     69    delete: async id => {
     70      delete downloader.cache[id];
     71    },
     72    hasData: async () => {
     73      return !!Object.keys(downloader.cache).length;
     74    },
     75  };
     76  // The download() method requires a cacheImpl, but the Downloader
     77  // class does not have one. Define a dummy no-op one.
     78  Object.defineProperty(downloader, "cacheImpl", {
     79    value: memCacheImpl,
     80    // Writable to allow specific tests to override cacheImpl.
     81    writable: true,
     82  });
     83  await downloader.deleteDownloaded(RECORD);
     84 
     85  server.registerPathHandler("/v1/", (request, response) => {
     86    response.write(
     87      JSON.stringify({
     88        capabilities: {
     89          attachments: {
     90            base_url: `http://localhost:${server.identity.primaryPort}/cdn/`,
     91          },
     92        },
     93      })
     94    );
     95    response.setHeader("Content-Type", "application/json; charset=UTF-8");
     96    response.setStatusLine(null, 200, "OK");
     97  });
     98 
     99  // For tests that use a real client and DB cache, clear the local DB too.
    100  const client = RemoteSettings("some-collection");
    101  await client.db.clear();
    102  await client.db.pruneAttachments([]);
    103 }
    104 add_task(clear_state);
    105 
    106 add_task(
    107  async function test_download_throws_server_info_error_if_invalid_response() {
    108    server.registerPathHandler("/v1/", (request, response) => {
    109      response.write("{bad json content");
    110      response.setHeader("Content-Type", "application/json; charset=UTF-8");
    111      response.setStatusLine(null, 200, "OK");
    112    });
    113 
    114    let error;
    115    try {
    116      await downloader.download(RECORD);
    117    } catch (e) {
    118      error = e;
    119    }
    120 
    121    Assert.ok(error instanceof Downloader.ServerInfoError);
    122  }
    123 );
    124 add_task(clear_state);
    125 
    126 add_task(async function test_download_is_retried_3_times_if_download_fails() {
    127  const record = {
    128    id: "abc",
    129    attachment: {
    130      ...RECORD.attachment,
    131      location: "404-error.pem",
    132    },
    133  };
    134 
    135  let called = 0;
    136  const _fetchAttachment = downloader._fetchAttachment;
    137  downloader._fetchAttachment = async url => {
    138    called++;
    139    return _fetchAttachment(url);
    140  };
    141 
    142  let error;
    143  try {
    144    await downloader.download(record);
    145  } catch (e) {
    146    error = e;
    147  }
    148 
    149  Assert.equal(called, 4); // 1 + 3 retries
    150  Assert.ok(error instanceof Downloader.DownloadError);
    151 });
    152 add_task(clear_state);
    153 
    154 add_task(async function test_download_as_bytes() {
    155  const bytes = await downloader.downloadAsBytes(RECORD);
    156 
    157  // See *.pem file in tests data.
    158  Assert.greater(
    159    bytes.byteLength,
    160    1500,
    161    `Wrong bytes size: ${bytes.byteLength}`
    162  );
    163 });
    164 add_task(clear_state);
    165 
    166 add_task(async function test_download_is_retried_3_times_if_content_fails() {
    167  const record = {
    168    id: "abc",
    169    attachment: {
    170      ...RECORD.attachment,
    171      hash: "always-wrong",
    172    },
    173  };
    174  let called = 0;
    175  downloader._fetchAttachment = async () => {
    176    called++;
    177    return new ArrayBuffer();
    178  };
    179 
    180  let error;
    181  try {
    182    await downloader.download(record);
    183  } catch (e) {
    184    error = e;
    185  }
    186 
    187  Assert.equal(called, 4); // 1 + 3 retries
    188  Assert.ok(error instanceof Downloader.BadContentError);
    189 });
    190 add_task(clear_state);
    191 
    192 add_task(async function test_delete_all() {
    193  const client = RemoteSettings("some-collection");
    194  await client.db.create(RECORD);
    195  await downloader.download(RECORD);
    196 
    197  await client.attachments.deleteAll();
    198 
    199  Assert.ok(!(await client.attachments.cacheImpl.get(RECORD.id)));
    200 });
    201 add_task(clear_state);
    202 
    203 add_task(async function test_downloader_reports_download_errors() {
    204  const client = RemoteSettings("some-collection");
    205 
    206  const record = {
    207    attachment: {
    208      ...RECORD.attachment,
    209      location: "404-error.pem",
    210    },
    211  };
    212 
    213  try {
    214    await client.attachments.download(record, { retry: 0 });
    215  } catch (e) {}
    216 
    217  TelemetryTestUtils.assertEvents([
    218    [
    219      "uptake.remotecontent.result",
    220      "uptake",
    221      "remotesettings",
    222      UptakeTelemetry.STATUS.DOWNLOAD_START,
    223      {
    224        source: client.identifier,
    225      },
    226    ],
    227    [
    228      "uptake.remotecontent.result",
    229      "uptake",
    230      "remotesettings",
    231      UptakeTelemetry.STATUS.DOWNLOAD_ERROR,
    232      {
    233        source: client.identifier,
    234      },
    235    ],
    236  ]);
    237 });
    238 add_task(clear_state);
    239 
    240 add_task(async function test_downloader_reports_offline_error() {
    241  const backupOffline = Services.io.offline;
    242  Services.io.offline = true;
    243 
    244  try {
    245    const client = RemoteSettings("some-collection");
    246    const record = {
    247      attachment: {
    248        ...RECORD.attachment,
    249        location: "will-try-and-fail.pem",
    250      },
    251    };
    252    try {
    253      await client.attachments.download(record, { retry: 0 });
    254    } catch (e) {}
    255 
    256    TelemetryTestUtils.assertEvents([
    257      [
    258        "uptake.remotecontent.result",
    259        "uptake",
    260        "remotesettings",
    261        UptakeTelemetry.STATUS.DOWNLOAD_START,
    262        {
    263          source: client.identifier,
    264        },
    265      ],
    266      [
    267        "uptake.remotecontent.result",
    268        "uptake",
    269        "remotesettings",
    270        UptakeTelemetry.STATUS.NETWORK_OFFLINE_ERROR,
    271        {
    272          source: client.identifier,
    273        },
    274      ],
    275    ]);
    276  } finally {
    277    Services.io.offline = backupOffline;
    278  }
    279 });
    280 add_task(clear_state);
    281 
    282 // Common code for test_download_cache_hit and test_download_cache_corruption.
    283 async function doTestDownloadCacheImpl({
    284  simulateCorruption,
    285  expectedReads = 1,
    286  expectedWrites = 1,
    287  downloadOptions = {},
    288 }) {
    289  let readCount = 0;
    290  let writeCount = 0;
    291  const cacheImpl = {
    292    async get(attachmentId) {
    293      Assert.equal(attachmentId, RECORD.id, "expected attachmentId");
    294      ++readCount;
    295      if (simulateCorruption) {
    296        throw new Error("Simulation of corrupted cache (read)");
    297      }
    298    },
    299    async set(attachmentId, attachment) {
    300      Assert.equal(attachmentId, RECORD.id, "expected attachmentId");
    301      Assert.deepEqual(attachment.record, RECORD, "expected record");
    302      ++writeCount;
    303      if (simulateCorruption) {
    304        throw new Error("Simulation of corrupted cache (write)");
    305      }
    306    },
    307    async delete() {},
    308  };
    309  Object.defineProperty(downloader, "cacheImpl", { value: cacheImpl });
    310 
    311  let downloadResult = await downloader.download(RECORD, downloadOptions);
    312  Assert.equal(downloadResult._source, "remote_match", "expected source");
    313  Assert.equal(downloadResult.buffer.byteLength, 1597, "expected result");
    314  Assert.equal(readCount, expectedReads, "expected cache read attempts");
    315  Assert.equal(writeCount, expectedWrites, "expected cache write attempts");
    316 }
    317 
    318 add_task(async function test_download_cache_hit() {
    319  await doTestDownloadCacheImpl({ simulateCorruption: false });
    320 });
    321 add_task(clear_state);
    322 
    323 // Verify that the downloader works despite a broken cache implementation.
    324 add_task(async function test_download_cache_corruption() {
    325  await doTestDownloadCacheImpl({ simulateCorruption: true });
    326 });
    327 add_task(clear_state);
    328 
    329 add_task(async function test_download_with_cache_enabled() {
    330  await doTestDownloadCacheImpl({
    331    simulateCorruption: false,
    332    downloadOptions: {
    333      cacheResult: true,
    334    },
    335  });
    336 });
    337 add_task(clear_state);
    338 
    339 add_task(async function test_download_with_cache_disabled() {
    340  await doTestDownloadCacheImpl({
    341    simulateCorruption: false,
    342    expectedWrites: 0,
    343    downloadOptions: {
    344      cacheResult: false,
    345    },
    346  });
    347 });
    348 add_task(clear_state);
    349 
    350 add_task(async function test_download_cached() {
    351  const client = RemoteSettings("main", "some-collection");
    352  const attachmentId = "dummy filename";
    353  const badRecord = {
    354    attachment: {
    355      ...RECORD.attachment,
    356      hash: "non-matching hash",
    357      location: "non-existing-location-should-fail.bin",
    358    },
    359  };
    360  async function downloadWithCache(record, options) {
    361    options = { ...options, useCache: true };
    362    return client.attachments.download(record, options);
    363  }
    364  function checkInfo(downloadResult, expectedSource, msg) {
    365    Assert.deepEqual(
    366      downloadResult.record,
    367      RECORD,
    368      `${msg} : expected identical record`
    369    );
    370    // Simple check: assume that content is identical if the size matches.
    371    Assert.equal(
    372      downloadResult.buffer.byteLength,
    373      RECORD.attachment.size,
    374      `${msg} : expected buffer`
    375    );
    376    Assert.equal(
    377      downloadResult._source,
    378      expectedSource,
    379      `${msg} : expected source of the result`
    380    );
    381  }
    382 
    383  await Assert.rejects(
    384    downloadWithCache(null, { attachmentId }),
    385    /DownloadError: Could not download dummy filename/,
    386    "Download without record or cache should fail."
    387  );
    388 
    389  // Populate cache.
    390  const info1 = await downloadWithCache(RECORD, { attachmentId });
    391  checkInfo(info1, "remote_match", "first time download");
    392 
    393  await Assert.rejects(
    394    downloadWithCache(null, { attachmentId }),
    395    /DownloadError: Could not download dummy filename/,
    396    "Download without record still fails even if there is a cache."
    397  );
    398 
    399  await Assert.rejects(
    400    downloadWithCache(badRecord, { attachmentId }),
    401    /DownloadError: Could not download .*non-existing-location-should-fail.bin/,
    402    "Download with non-matching record still fails even if there is a cache."
    403  );
    404 
    405  // Download from cache.
    406  const info2 = await downloadWithCache(RECORD, { attachmentId });
    407  checkInfo(info2, "cache_match", "download matching record from cache");
    408 
    409  const info3 = await downloadWithCache(RECORD, {
    410    attachmentId,
    411    fallbackToCache: true,
    412  });
    413  checkInfo(info3, "cache_match", "fallbackToCache accepts matching record");
    414 
    415  const info4 = await downloadWithCache(null, {
    416    attachmentId,
    417    fallbackToCache: true,
    418  });
    419  checkInfo(info4, "cache_fallback", "fallbackToCache accepts null record");
    420 
    421  const info5 = await downloadWithCache(badRecord, {
    422    attachmentId,
    423    fallbackToCache: true,
    424  });
    425  checkInfo(info5, "cache_fallback", "fallbackToCache ignores bad record");
    426 
    427  // Bye bye cache.
    428  await client.attachments.deleteDownloaded({ id: attachmentId });
    429  await Assert.rejects(
    430    downloadWithCache(null, { attachmentId, fallbackToCache: true }),
    431    /DownloadError: Could not download dummy filename/,
    432    "Download without cache should fail again."
    433  );
    434  await Assert.rejects(
    435    downloadWithCache(badRecord, { attachmentId, fallbackToCache: true }),
    436    /DownloadError: Could not download .*non-existing-location-should-fail.bin/,
    437    "Download should fail to fall back to a download of a non-existing record"
    438  );
    439 });
    440 add_task(clear_state);
    441 
    442 add_task(async function test_download_from_dump() {
    443  const client = RemoteSettings("dump-collection", {
    444    bucketName: "dump-bucket",
    445  });
    446 
    447  // Temporarily replace the resource:-URL with another resource:-URL.
    448  const orig_RESOURCE_BASE_URL = Downloader._RESOURCE_BASE_URL;
    449  Downloader._RESOURCE_BASE_URL = "resource://rs-downloader-test";
    450  const resProto = Services.io
    451    .getProtocolHandler("resource")
    452    .QueryInterface(Ci.nsIResProtocolHandler);
    453  resProto.setSubstitution(
    454    "rs-downloader-test",
    455    Services.io.newFileURI(do_get_file("test_attachments_downloader"))
    456  );
    457 
    458  function checkInfo(result, expectedSource, expectedRecord = RECORD_OF_DUMP) {
    459    Assert.equal(
    460      new TextDecoder().decode(new Uint8Array(result.buffer)),
    461      "This would be a RS dump.\n",
    462      "expected content from dump"
    463    );
    464    Assert.deepEqual(result.record, expectedRecord, "expected record for dump");
    465    Assert.equal(result._source, expectedSource, "expected source of dump");
    466  }
    467 
    468  // If record matches, should happen before network request.
    469  const dump1 = await client.attachments.download(RECORD_OF_DUMP, {
    470    // Note: attachmentId not set, so should fall back to record.id.
    471    fallbackToDump: true,
    472  });
    473  checkInfo(dump1, "dump_match");
    474 
    475  // If no record given, should try network first, but then fall back to dump.
    476  const dump2 = await client.attachments.download(null, {
    477    attachmentId: RECORD_OF_DUMP.id,
    478    fallbackToDump: true,
    479  });
    480  checkInfo(dump2, "dump_fallback");
    481 
    482  // Fill the cache with the same data as the dump for the next part.
    483  await client.db.saveAttachment(RECORD_OF_DUMP.id, {
    484    record: RECORD_OF_DUMP,
    485    blob: new Blob([dump1.buffer]),
    486  });
    487  // The dump should take precedence over the cache.
    488  const dump3 = await client.attachments.download(RECORD_OF_DUMP, {
    489    fallbackToCache: true,
    490    fallbackToDump: true,
    491  });
    492  checkInfo(dump3, "dump_match");
    493 
    494  // When the record is not given, the dump takes precedence over the cache
    495  // as a fallback (when the cache and dump are identical).
    496  const dump4 = await client.attachments.download(null, {
    497    attachmentId: RECORD_OF_DUMP.id,
    498    fallbackToCache: true,
    499    fallbackToDump: true,
    500  });
    501  checkInfo(dump4, "dump_fallback");
    502 
    503  // Store a record in the cache that is newer than the dump.
    504  const RECORD_NEWER_THAN_DUMP = {
    505    ...RECORD_OF_DUMP,
    506    last_modified: RECORD_OF_DUMP.last_modified + 1,
    507  };
    508  await client.db.saveAttachment(RECORD_OF_DUMP.id, {
    509    record: RECORD_NEWER_THAN_DUMP,
    510    blob: new Blob([dump1.buffer]),
    511  });
    512 
    513  // When the record is not given, use the cache if it has a more recent record.
    514  const dump5 = await client.attachments.download(null, {
    515    attachmentId: RECORD_OF_DUMP.id,
    516    fallbackToCache: true,
    517    fallbackToDump: true,
    518  });
    519  checkInfo(dump5, "cache_fallback", RECORD_NEWER_THAN_DUMP);
    520 
    521  // When a record is given, use whichever that has the matching last_modified.
    522  const dump6 = await client.attachments.download(RECORD_OF_DUMP, {
    523    fallbackToCache: true,
    524    fallbackToDump: true,
    525  });
    526  checkInfo(dump6, "dump_match");
    527  const dump7 = await client.attachments.download(RECORD_NEWER_THAN_DUMP, {
    528    fallbackToCache: true,
    529    fallbackToDump: true,
    530  });
    531  checkInfo(dump7, "cache_match", RECORD_NEWER_THAN_DUMP);
    532 
    533  await client.attachments.deleteDownloaded(RECORD_OF_DUMP);
    534 
    535  await Assert.rejects(
    536    client.attachments.download(null, {
    537      attachmentId: "filename-without-meta.txt",
    538      fallbackToDump: true,
    539    }),
    540    /DownloadError: Could not download filename-without-meta.txt/,
    541    "Cannot download dump that lacks a .meta.json file"
    542  );
    543 
    544  await Assert.rejects(
    545    client.attachments.download(null, {
    546      attachmentId: "filename-without-content.txt",
    547      fallbackToDump: true,
    548    }),
    549    /Could not download resource:\/\/rs-downloader-test\/settings\/dump-bucket\/dump-collection\/filename-without-content\.txt(?!\.meta\.json)/,
    550    "Cannot download dump that is missing, despite the existing .meta.json"
    551  );
    552 
    553  // Restore, just in case.
    554  Downloader._RESOURCE_BASE_URL = orig_RESOURCE_BASE_URL;
    555  resProto.setSubstitution("rs-downloader-test", null);
    556 });
    557 // Not really needed because the last test doesn't modify the main collection,
    558 // but added for consistency with other tests tasks around here.
    559 add_task(clear_state);
    560 
    561 add_task(
    562  async function test_download_from_dump_fails_when_load_dumps_is_false() {
    563    const client = RemoteSettings("dump-collection", {
    564      bucketName: "dump-bucket",
    565    });
    566 
    567    // Temporarily replace the resource:-URL with another resource:-URL.
    568    const orig_RESOURCE_BASE_URL = Downloader._RESOURCE_BASE_URL;
    569    Downloader._RESOURCE_BASE_URL = "resource://rs-downloader-test";
    570    const resProto = Services.io
    571      .getProtocolHandler("resource")
    572      .QueryInterface(Ci.nsIResProtocolHandler);
    573    resProto.setSubstitution(
    574      "rs-downloader-test",
    575      Services.io.newFileURI(do_get_file("test_attachments_downloader"))
    576    );
    577 
    578    function checkInfo(
    579      result,
    580      expectedSource,
    581      expectedRecord = RECORD_OF_DUMP
    582    ) {
    583      Assert.equal(
    584        new TextDecoder().decode(new Uint8Array(result.buffer)),
    585        "This would be a RS dump.\n",
    586        "expected content from dump"
    587      );
    588      Assert.deepEqual(
    589        result.record,
    590        expectedRecord,
    591        "expected record for dump"
    592      );
    593      Assert.equal(result._source, expectedSource, "expected source of dump");
    594    }
    595 
    596    // Download the dump so that we can use it to fill the cache.
    597    const dump1 = await client.attachments.download(RECORD_OF_DUMP, {
    598      // Note: attachmentId not set, so should fall back to record.id.
    599      fallbackToDump: true,
    600    });
    601    checkInfo(dump1, "dump_match");
    602 
    603    // Fill the cache with the same data as the dump for the next part.
    604    await client.db.saveAttachment(RECORD_OF_DUMP.id, {
    605      record: RECORD_OF_DUMP,
    606      blob: new Blob([dump1.buffer]),
    607    });
    608 
    609    // Now turn off loading dumps, and check we no longer load from the dump,
    610    // but use the cache instead.
    611    Utils.LOAD_DUMPS = false;
    612 
    613    const dump2 = await client.attachments.download(RECORD_OF_DUMP, {
    614      // Note: attachmentId not set, so should fall back to record.id.
    615      fallbackToDump: true,
    616    });
    617    checkInfo(dump2, "cache_match");
    618 
    619    // When the record is not given, the dump would take precedence over the
    620    // cache but we have disabled dumps, so we should load from the cache.
    621    const dump4 = await client.attachments.download(null, {
    622      attachmentId: RECORD_OF_DUMP.id,
    623      fallbackToCache: true,
    624      fallbackToDump: true,
    625    });
    626    checkInfo(dump4, "cache_fallback");
    627 
    628    // Restore, just in case.
    629    Utils.LOAD_DUMPS = true;
    630    Downloader._RESOURCE_BASE_URL = orig_RESOURCE_BASE_URL;
    631    resProto.setSubstitution("rs-downloader-test", null);
    632  }
    633 );
    634 
    635 add_task(async function test_attachment_get() {
    636  // Since get() is largely a wrapper around the same code as download(),
    637  // we only test a couple of parts to check it functions as expected, and
    638  // rely on the download() testing for the rest.
    639 
    640  await Assert.rejects(
    641    downloader.get(RECORD),
    642    /NotFoundError: Could not find /,
    643    "get() fails when there is no local cache nor dump"
    644  );
    645 
    646  const client = RemoteSettings("dump-collection", {
    647    bucketName: "dump-bucket",
    648  });
    649 
    650  // Temporarily replace the resource:-URL with another resource:-URL.
    651  const orig_RESOURCE_BASE_URL = Downloader._RESOURCE_BASE_URL;
    652  Downloader._RESOURCE_BASE_URL = "resource://rs-downloader-test";
    653  const resProto = Services.io
    654    .getProtocolHandler("resource")
    655    .QueryInterface(Ci.nsIResProtocolHandler);
    656  resProto.setSubstitution(
    657    "rs-downloader-test",
    658    Services.io.newFileURI(do_get_file("test_attachments_downloader"))
    659  );
    660 
    661  function checkInfo(result, expectedSource, expectedRecord = RECORD_OF_DUMP) {
    662    Assert.equal(
    663      new TextDecoder().decode(new Uint8Array(result.buffer)),
    664      "This would be a RS dump.\n",
    665      "expected content from dump"
    666    );
    667    Assert.deepEqual(result.record, expectedRecord, "expected record for dump");
    668    Assert.equal(result._source, expectedSource, "expected source of dump");
    669  }
    670 
    671  // When a record is given, use whichever that has the matching last_modified.
    672  const dump = await client.attachments.get(RECORD_OF_DUMP);
    673  checkInfo(dump, "dump_match");
    674 
    675  await client.attachments.deleteDownloaded(RECORD_OF_DUMP);
    676 
    677  await Assert.rejects(
    678    client.attachments.get(null, {
    679      attachmentId: "filename-without-meta.txt",
    680      fallbackToDump: true,
    681    }),
    682    /NotFoundError: Could not find filename-without-meta.txt in cache or dump/,
    683    "Cannot download dump that lacks a .meta.json file"
    684  );
    685 
    686  await Assert.rejects(
    687    client.attachments.get(null, {
    688      attachmentId: "filename-without-content.txt",
    689      fallbackToDump: true,
    690    }),
    691    /Could not download resource:\/\/rs-downloader-test\/settings\/dump-bucket\/dump-collection\/filename-without-content\.txt(?!\.meta\.json)/,
    692    "Cannot download dump that is missing, despite the existing .meta.json"
    693  );
    694 
    695  // Restore, just in case.
    696  Downloader._RESOURCE_BASE_URL = orig_RESOURCE_BASE_URL;
    697  resProto.setSubstitution("rs-downloader-test", null);
    698 });
    699 // Not really needed because the last test doesn't modify the main collection,
    700 // but added for consistency with other tests tasks around here.
    701 add_task(clear_state);
    702 
    703 add_task(async function test_obsolete_attachments_are_pruned() {
    704  const RECORD2 = {
    705    ...RECORD,
    706    id: "another-id",
    707  };
    708  const client = RemoteSettings("some-collection");
    709  // Store records and related attachments directly in the cache.
    710  await client.db.importChanges({}, 42, [RECORD, RECORD2], { clear: true });
    711  await client.db.saveAttachment(RECORD.id, {
    712    record: RECORD,
    713    blob: new Blob(["123"]),
    714  });
    715  await client.db.saveAttachment("custom-id", {
    716    record: RECORD2,
    717    blob: new Blob(["456"]),
    718  });
    719  // Store an extraneous cached attachment.
    720  await client.db.saveAttachment("bar", {
    721    record: { id: "bar" },
    722    blob: new Blob(["789"]),
    723  });
    724 
    725  const recordAttachment = await client.attachments.cacheImpl.get(RECORD.id);
    726  Assert.equal(
    727    await recordAttachment.blob.text(),
    728    "123",
    729    "Record has a cached attachment"
    730  );
    731  const record2Attachment = await client.attachments.cacheImpl.get("custom-id");
    732  Assert.equal(
    733    await record2Attachment.blob.text(),
    734    "456",
    735    "Record 2 has a cached attachment"
    736  );
    737  const { blob: cachedExtra } = await client.attachments.cacheImpl.get("bar");
    738  Assert.equal(await cachedExtra.text(), "789", "There is an extra attachment");
    739 
    740  await client.attachments.prune([]);
    741 
    742  Assert.ok(
    743    await client.attachments.cacheImpl.get(RECORD.id),
    744    "Record attachment was kept"
    745  );
    746  Assert.ok(
    747    await client.attachments.cacheImpl.get("custom-id"),
    748    "Record 2 attachment was kept"
    749  );
    750  Assert.ok(
    751    !(await client.attachments.cacheImpl.get("bar")),
    752    "Extra was deleted"
    753  );
    754 });
    755 add_task(clear_state);
    756 
    757 add_task(
    758  async function test_obsolete_attachments_listed_as_excluded_are_not_pruned() {
    759    const client = RemoteSettings("some-collection");
    760    // Store records and related attachments directly in the cache.
    761    await client.db.importChanges({}, 42, [], { clear: true });
    762    await client.db.saveAttachment(RECORD.id, {
    763      record: RECORD,
    764      blob: new Blob(["123"]),
    765    });
    766 
    767    const recordAttachment = await client.attachments.cacheImpl.get(RECORD.id);
    768    Assert.equal(
    769      await recordAttachment.blob.text(),
    770      "123",
    771      "Record has a cached attachment"
    772    );
    773 
    774    await client.attachments.prune([RECORD.id]);
    775 
    776    Assert.ok(
    777      await client.attachments.cacheImpl.get(RECORD.id),
    778      "Record attachment was kept"
    779    );
    780  }
    781 );
    782 
    783 add_task(clear_state);
    784 
    785 add_task(async function test_cacheAll_happy_path() {
    786  // verify bundle is downloaded succesfully
    787  const allSuccess = await downloader.cacheAll();
    788  Assert.ok(
    789    allSuccess,
    790    "Attachments cacheAll succesfully downloaded a bundle and saved all attachments"
    791  );
    792 
    793  // verify accuracy of attachments downloaded
    794  Assert.equal(
    795    downloader.cache["1"].record.title,
    796    "test1",
    797    "Test record 1 meta content appears accurate."
    798  );
    799  Assert.equal(
    800    await downloader.cache["1"].blob.text(),
    801    "test1\n",
    802    "Test file 1 content is accurate."
    803  );
    804  Assert.equal(
    805    downloader.cache["2"].record.title,
    806    "test2",
    807    "Test record 2 meta content appears accurate."
    808  );
    809  Assert.equal(
    810    await downloader.cache["2"].blob.text(),
    811    "test2\n",
    812    "Test file 2 content is accurate."
    813  );
    814 });
    815 
    816 add_task(async function test_cacheAll_using_real_db() {
    817  const client = RemoteSettings("some-collection");
    818 
    819  const allSuccess = await client.attachments.cacheAll();
    820 
    821  Assert.ok(
    822    allSuccess,
    823    "Attachments cacheAll succesfully downloaded a bundle and saved all attachments"
    824  );
    825 
    826  Assert.equal(
    827    (await client.attachments.cacheImpl.get("2")).record.title,
    828    "test2",
    829    "Test record 2 meta content appears accurate."
    830  );
    831  Assert.equal(
    832    await (await client.attachments.cacheImpl.get("2")).blob.text(),
    833    "test2\n",
    834    "Test file 2 content is accurate."
    835  );
    836 });
    837 
    838 add_task(clear_state);
    839 
    840 add_task(async function test_cacheAll_skips_with_existing_data() {
    841  downloader.cache = {
    842    1: "1",
    843  };
    844  const allSuccess = await downloader.cacheAll();
    845  Assert.equal(
    846    allSuccess,
    847    null,
    848    "Attachments cacheAll skips downloads if data already exists"
    849  );
    850 });
    851 
    852 add_task(async function test_cacheAll_does_not_skip_if_force_is_true() {
    853  downloader.cache = {
    854    1: "1",
    855  };
    856  const allSuccess = await downloader.cacheAll(true);
    857  Assert.equal(
    858    allSuccess,
    859    true,
    860    "Attachments cacheAll does not skip downloads if force is true"
    861  );
    862 });
    863 
    864 add_task(clear_state);
    865 
    866 add_task(async function test_cacheAll_failed_request() {
    867  downloader.bucketName = "fake-bucket";
    868  downloader.collectionName = "fake-collection";
    869  const allSuccess = await downloader.cacheAll();
    870  Assert.equal(
    871    allSuccess,
    872    false,
    873    "Attachments cacheAll request failed to download a bundle and returned false"
    874  );
    875 });
    876 
    877 add_task(clear_state);
    878 
    879 add_task(async function test_cacheAll_failed_unzip() {
    880  downloader.bucketName = "error-bucket";
    881  downloader.collectionName = "bad-zip";
    882  const allSuccess = await downloader.cacheAll();
    883  Assert.equal(
    884    allSuccess,
    885    false,
    886    "Attachments cacheAll request failed to extract a bundle and returned false"
    887  );
    888 });
    889 
    890 add_task(clear_state);
    891 
    892 add_task(async function test_cacheAll_failed_save() {
    893  const client = RemoteSettings("some-collection");
    894 
    895  const backup = client.db.saveAttachments;
    896  client.db.saveAttachments = () => {
    897    throw new Error("boom");
    898  };
    899 
    900  const allSuccess = await client.attachments.cacheAll();
    901 
    902  Assert.equal(
    903    allSuccess,
    904    false,
    905    "Attachments cacheAll failed to save entries in DB and returned false"
    906  );
    907  client.db.saveAttachments = backup;
    908 });
    909 
    910 add_task(clear_state);