/* import-globals-from ../../../common/tests/unit/head_helpers.js */ const { RemoteSettings } = ChromeUtils.importESModule( "resource://services-settings/remote-settings.sys.mjs" ); const { UptakeTelemetry } = ChromeUtils.importESModule( "resource://services-common/uptake-telemetry.sys.mjs" ); const { Downloader } = ChromeUtils.importESModule( "resource://services-settings/Attachments.sys.mjs" ); const { TelemetryTestUtils } = ChromeUtils.importESModule( "resource://testing-common/TelemetryTestUtils.sys.mjs" ); const RECORD = { id: "1f3a0802-648d-11ea-bd79-876a8b69c377", attachment: { hash: "f41ed47d0f43325c9f089d03415c972ce1d3f1ecab6e4d6260665baf3db3ccee", size: 1597, filename: "test_file.pem", location: "main-workspace/some-collection/65650a0f-7c22-4c10-9744-2d67e301f5f4.pem", mimetype: "application/x-pem-file", }, }; const RECORD_OF_DUMP = { id: "filename-of-dump.txt", attachment: { filename: "filename-of-dump.txt", hash: "4c46ef7e4f1951d210fe54c21e07c09bab265fd122580083ed1d6121547a8c6b", size: 25, }, last_modified: 1234567, some_key: "some metadata", }; let downloader; let server; function pathFromURL(url) { const uri = Services.io.newURI(url); const file = uri.QueryInterface(Ci.nsIFileURL).file; return file.path; } const PROFILE_URL = PathUtils.toFileURI(PathUtils.localProfileDir); add_setup(() => { server = new HttpServer(); server.start(-1); registerCleanupFunction(() => server.stop(() => {})); server.registerDirectory( "/cdn/main-workspace/some-collection/", do_get_file("test_attachments_downloader") ); }); async function clear_state() { Services.prefs.setStringPref( "services.settings.server", `http://localhost:${server.identity.primaryPort}/v1` ); downloader = new Downloader("main", "some-collection"); const dummyCacheImpl = { get: async () => {}, set: async () => {}, delete: async () => {}, }; // The download() method requires a cacheImpl, but the Downloader // class does not have one. Define a dummy no-op one. Object.defineProperty(downloader, "cacheImpl", { value: dummyCacheImpl, // Writable to allow specific tests to override cacheImpl. writable: true, }); await downloader.deleteDownloaded(RECORD); server.registerPathHandler("/v1/", (request, response) => { response.write( JSON.stringify({ capabilities: { attachments: { base_url: `http://localhost:${server.identity.primaryPort}/cdn/`, }, }, }) ); response.setHeader("Content-Type", "application/json; charset=UTF-8"); response.setStatusLine(null, 200, "OK"); }); } add_task(clear_state); add_task(async function test_base_attachment_url_depends_on_server() { const before = await downloader._baseAttachmentsURL(); Services.prefs.setStringPref( "services.settings.server", `http://localhost:${server.identity.primaryPort}/v2` ); server.registerPathHandler("/v2/", (request, response) => { response.write( JSON.stringify({ capabilities: { attachments: { base_url: "http://some-cdn-url.org", }, }, }) ); response.setHeader("Content-Type", "application/json; charset=UTF-8"); response.setStatusLine(null, 200, "OK"); }); const after = await downloader._baseAttachmentsURL(); Assert.notEqual(before, after, "base URL was changed"); Assert.equal(after, "http://some-cdn-url.org/", "A trailing slash is added"); }); add_task(clear_state); add_task( async function test_download_throws_server_info_error_if_invalid_response() { server.registerPathHandler("/v1/", (request, response) => { response.write("{bad json content"); response.setHeader("Content-Type", "application/json; charset=UTF-8"); response.setStatusLine(null, 200, "OK"); }); let error; try { await downloader.download(RECORD); } catch (e) { error = e; } Assert.ok(error instanceof Downloader.ServerInfoError); } ); add_task(clear_state); add_task(async function test_download_writes_file_in_profile() { const fileURL = await downloader.downloadToDisk(RECORD); const localFilePath = pathFromURL(fileURL); Assert.equal( fileURL, PROFILE_URL + "/settings/main/some-collection/test_file.pem" ); Assert.ok(await IOUtils.exists(localFilePath)); const stat = await IOUtils.stat(localFilePath); Assert.equal(stat.size, 1597); }); add_task(clear_state); add_task(async function test_download_as_bytes() { const bytes = await downloader.downloadAsBytes(RECORD); // See *.pem file in tests data. Assert.ok(bytes.byteLength > 1500, `Wrong bytes size: ${bytes.byteLength}`); }); add_task(clear_state); add_task(async function test_file_is_redownloaded_if_size_does_not_match() { const fileURL = await downloader.downloadToDisk(RECORD); const localFilePath = pathFromURL(fileURL); await IOUtils.writeUTF8(localFilePath, "bad-content"); let stat = await IOUtils.stat(localFilePath); Assert.notEqual(stat.size, 1597); await downloader.downloadToDisk(RECORD); stat = await IOUtils.stat(localFilePath); Assert.equal(stat.size, 1597); }); add_task(clear_state); add_task(async function test_file_is_redownloaded_if_corrupted() { const fileURL = await downloader.downloadToDisk(RECORD); const localFilePath = pathFromURL(fileURL); const byteArray = await IOUtils.read(localFilePath); byteArray[0] = 42; await IOUtils.write(localFilePath, byteArray); let content = await IOUtils.readUTF8(localFilePath); Assert.notEqual(content.slice(0, 5), "-----"); await downloader.downloadToDisk(RECORD); content = await IOUtils.readUTF8(localFilePath); Assert.equal(content.slice(0, 5), "-----"); }); add_task(clear_state); add_task(async function test_download_is_retried_3_times_if_download_fails() { const record = { id: "abc", attachment: { ...RECORD.attachment, location: "404-error.pem", }, }; let called = 0; const _fetchAttachment = downloader._fetchAttachment; downloader._fetchAttachment = async url => { called++; return _fetchAttachment(url); }; let error; try { await downloader.download(record); } catch (e) { error = e; } Assert.equal(called, 4); // 1 + 3 retries Assert.ok(error instanceof Downloader.DownloadError); }); add_task(clear_state); add_task(async function test_download_is_retried_3_times_if_content_fails() { const record = { id: "abc", attachment: { ...RECORD.attachment, hash: "always-wrong", }, }; let called = 0; downloader._fetchAttachment = async () => { called++; return new ArrayBuffer(); }; let error; try { await downloader.download(record); } catch (e) { error = e; } Assert.equal(called, 4); // 1 + 3 retries Assert.ok(error instanceof Downloader.BadContentError); }); add_task(clear_state); add_task(async function test_delete_removes_local_file() { const fileURL = await downloader.downloadToDisk(RECORD); const localFilePath = pathFromURL(fileURL); Assert.ok(await IOUtils.exists(localFilePath)); await downloader.deleteFromDisk(RECORD); Assert.ok(!(await IOUtils.exists(localFilePath))); // And removes parent folders. const parentFolder = PathUtils.join( PathUtils.localProfileDir, ...downloader.folders ); Assert.ok(!(await IOUtils.exists(parentFolder))); }); add_task(clear_state); add_task(async function test_delete_all() { const client = RemoteSettings("some-collection"); await client.db.create(RECORD); await downloader.download(RECORD); const fileURL = await downloader.downloadToDisk(RECORD); const localFilePath = pathFromURL(fileURL); Assert.ok(await IOUtils.exists(localFilePath)); await client.attachments.deleteAll(); Assert.ok(!(await IOUtils.exists(localFilePath))); Assert.ok(!(await client.attachments.cacheImpl.get(RECORD.id))); }); add_task(clear_state); add_task(async function test_downloader_is_accessible_via_client() { const client = RemoteSettings("some-collection"); const fileURL = await client.attachments.downloadToDisk(RECORD); Assert.equal( fileURL, [ PROFILE_URL, "settings", client.bucketName, client.collectionName, RECORD.attachment.filename, ].join("/") ); }); add_task(clear_state); add_task(async function test_downloader_reports_download_errors() { await withFakeChannel("nightly", async () => { const client = RemoteSettings("some-collection"); const record = { attachment: { ...RECORD.attachment, location: "404-error.pem", }, }; try { await client.attachments.download(record, { retry: 0 }); } catch (e) {} TelemetryTestUtils.assertEvents([ [ "uptake.remotecontent.result", "uptake", "remotesettings", UptakeTelemetry.STATUS.DOWNLOAD_ERROR, { source: client.identifier, }, ], ]); }); }); add_task(clear_state); add_task(async function test_downloader_reports_offline_error() { const backupOffline = Services.io.offline; Services.io.offline = true; await withFakeChannel("nightly", async () => { try { const client = RemoteSettings("some-collection"); const record = { attachment: { ...RECORD.attachment, location: "will-try-and-fail.pem", }, }; try { await client.attachments.download(record, { retry: 0 }); } catch (e) {} TelemetryTestUtils.assertEvents([ [ "uptake.remotecontent.result", "uptake", "remotesettings", UptakeTelemetry.STATUS.NETWORK_OFFLINE_ERROR, { source: client.identifier, }, ], ]); } finally { Services.io.offline = backupOffline; } }); }); add_task(clear_state); // Common code for test_download_cache_hit and test_download_cache_corruption. async function doTestDownloadCacheImpl({ simulateCorruption }) { let readCount = 0; let writeCount = 0; const cacheImpl = { async get(attachmentId) { Assert.equal(attachmentId, RECORD.id, "expected attachmentId"); ++readCount; if (simulateCorruption) { throw new Error("Simulation of corrupted cache (read)"); } }, async set(attachmentId, attachment) { Assert.equal(attachmentId, RECORD.id, "expected attachmentId"); Assert.deepEqual(attachment.record, RECORD, "expected record"); ++writeCount; if (simulateCorruption) { throw new Error("Simulation of corrupted cache (write)"); } }, async delete() {}, }; Object.defineProperty(downloader, "cacheImpl", { value: cacheImpl }); let downloadResult = await downloader.download(RECORD); Assert.equal(downloadResult._source, "remote_match", "expected source"); Assert.equal(downloadResult.buffer.byteLength, 1597, "expected result"); Assert.equal(readCount, 1, "expected cache read attempts"); Assert.equal(writeCount, 1, "expected cache write attempts"); } add_task(async function test_download_cache_hit() { await doTestDownloadCacheImpl({ simulateCorruption: false }); }); add_task(clear_state); // Verify that the downloader works despite a broken cache implementation. add_task(async function test_download_cache_corruption() { await doTestDownloadCacheImpl({ simulateCorruption: true }); }); add_task(clear_state); add_task(async function test_download_cached() { const client = RemoteSettings("main", "some-collection"); const attachmentId = "dummy filename"; const badRecord = { attachment: { ...RECORD.attachment, hash: "non-matching hash", location: "non-existing-location-should-fail.bin", }, }; async function downloadWithCache(record, options) { options = { ...options, useCache: true }; return client.attachments.download(record, options); } function checkInfo(downloadResult, expectedSource, msg) { Assert.deepEqual( downloadResult.record, RECORD, `${msg} : expected identical record` ); // Simple check: assume that content is identical if the size matches. Assert.equal( downloadResult.buffer.byteLength, RECORD.attachment.size, `${msg} : expected buffer` ); Assert.equal( downloadResult._source, expectedSource, `${msg} : expected source of the result` ); } await Assert.rejects( downloadWithCache(null, { attachmentId }), /DownloadError: Could not download dummy filename/, "Download without record or cache should fail." ); // Populate cache. const info1 = await downloadWithCache(RECORD, { attachmentId }); checkInfo(info1, "remote_match", "first time download"); await Assert.rejects( downloadWithCache(null, { attachmentId }), /DownloadError: Could not download dummy filename/, "Download without record still fails even if there is a cache." ); await Assert.rejects( downloadWithCache(badRecord, { attachmentId }), /DownloadError: Could not download .*non-existing-location-should-fail.bin/, "Download with non-matching record still fails even if there is a cache." ); // Download from cache. const info2 = await downloadWithCache(RECORD, { attachmentId }); checkInfo(info2, "cache_match", "download matching record from cache"); const info3 = await downloadWithCache(RECORD, { attachmentId, fallbackToCache: true, }); checkInfo(info3, "cache_match", "fallbackToCache accepts matching record"); const info4 = await downloadWithCache(null, { attachmentId, fallbackToCache: true, }); checkInfo(info4, "cache_fallback", "fallbackToCache accepts null record"); const info5 = await downloadWithCache(badRecord, { attachmentId, fallbackToCache: true, }); checkInfo(info5, "cache_fallback", "fallbackToCache ignores bad record"); // Bye bye cache. await client.attachments.deleteDownloaded({ id: attachmentId }); await Assert.rejects( downloadWithCache(null, { attachmentId, fallbackToCache: true }), /DownloadError: Could not download dummy filename/, "Download without cache should fail again." ); await Assert.rejects( downloadWithCache(badRecord, { attachmentId, fallbackToCache: true }), /DownloadError: Could not download .*non-existing-location-should-fail.bin/, "Download should fail to fall back to a download of a non-existing record" ); }); add_task(clear_state); add_task(async function test_download_from_dump() { const client = RemoteSettings("dump-collection", { bucketName: "dump-bucket", }); // Temporarily replace the resource:-URL with another resource:-URL. const orig_RESOURCE_BASE_URL = Downloader._RESOURCE_BASE_URL; Downloader._RESOURCE_BASE_URL = "resource://rs-downloader-test"; const resProto = Services.io .getProtocolHandler("resource") .QueryInterface(Ci.nsIResProtocolHandler); resProto.setSubstitution( "rs-downloader-test", Services.io.newFileURI(do_get_file("test_attachments_downloader")) ); function checkInfo(result, expectedSource, expectedRecord = RECORD_OF_DUMP) { Assert.equal( new TextDecoder().decode(new Uint8Array(result.buffer)), "This would be a RS dump.\n", "expected content from dump" ); Assert.deepEqual(result.record, expectedRecord, "expected record for dump"); Assert.equal(result._source, expectedSource, "expected source of dump"); } // If record matches, should happen before network request. const dump1 = await client.attachments.download(RECORD_OF_DUMP, { // Note: attachmentId not set, so should fall back to record.id. fallbackToDump: true, }); checkInfo(dump1, "dump_match"); // If no record given, should try network first, but then fall back to dump. const dump2 = await client.attachments.download(null, { attachmentId: RECORD_OF_DUMP.id, fallbackToDump: true, }); checkInfo(dump2, "dump_fallback"); // Fill the cache with the same data as the dump for the next part. await client.db.saveAttachment(RECORD_OF_DUMP.id, { record: RECORD_OF_DUMP, blob: new Blob([dump1.buffer]), }); // The dump should take precedence over the cache. const dump3 = await client.attachments.download(RECORD_OF_DUMP, { fallbackToCache: true, fallbackToDump: true, }); checkInfo(dump3, "dump_match"); // When the record is not given, the dump takes precedence over the cache // as a fallback (when the cache and dump are identical). const dump4 = await client.attachments.download(null, { attachmentId: RECORD_OF_DUMP.id, fallbackToCache: true, fallbackToDump: true, }); checkInfo(dump4, "dump_fallback"); // Store a record in the cache that is newer than the dump. const RECORD_NEWER_THAN_DUMP = { ...RECORD_OF_DUMP, last_modified: RECORD_OF_DUMP.last_modified + 1, }; await client.db.saveAttachment(RECORD_OF_DUMP.id, { record: RECORD_NEWER_THAN_DUMP, blob: new Blob([dump1.buffer]), }); // When the record is not given, use the cache if it has a more recent record. const dump5 = await client.attachments.download(null, { attachmentId: RECORD_OF_DUMP.id, fallbackToCache: true, fallbackToDump: true, }); checkInfo(dump5, "cache_fallback", RECORD_NEWER_THAN_DUMP); // When a record is given, use whichever that has the matching last_modified. const dump6 = await client.attachments.download(RECORD_OF_DUMP, { fallbackToCache: true, fallbackToDump: true, }); checkInfo(dump6, "dump_match"); const dump7 = await client.attachments.download(RECORD_NEWER_THAN_DUMP, { fallbackToCache: true, fallbackToDump: true, }); checkInfo(dump7, "cache_match", RECORD_NEWER_THAN_DUMP); await client.attachments.deleteDownloaded(RECORD_OF_DUMP); await Assert.rejects( client.attachments.download(null, { attachmentId: "filename-without-meta.txt", fallbackToDump: true, }), /DownloadError: Could not download filename-without-meta.txt/, "Cannot download dump that lacks a .meta.json file" ); await Assert.rejects( client.attachments.download(null, { attachmentId: "filename-without-content.txt", fallbackToDump: true, }), /Could not download resource:\/\/rs-downloader-test\/settings\/dump-bucket\/dump-collection\/filename-without-content\.txt(?!\.meta\.json)/, "Cannot download dump that is missing, despite the existing .meta.json" ); // Restore, just in case. Downloader._RESOURCE_BASE_URL = orig_RESOURCE_BASE_URL; resProto.setSubstitution("rs-downloader-test", null); }); // Not really needed because the last test doesn't modify the main collection, // but added for consistency with other tests tasks around here. add_task(clear_state); add_task(async function test_attachment_get() { // Since get() is largely a wrapper around the same code as download(), // we only test a couple of parts to check it functions as expected, and // rely on the download() testing for the rest. await Assert.rejects( downloader.get(RECORD), /NotFoundError: Could not find /, "get() fails when there is no local cache nor dump" ); const client = RemoteSettings("dump-collection", { bucketName: "dump-bucket", }); // Temporarily replace the resource:-URL with another resource:-URL. const orig_RESOURCE_BASE_URL = Downloader._RESOURCE_BASE_URL; Downloader._RESOURCE_BASE_URL = "resource://rs-downloader-test"; const resProto = Services.io .getProtocolHandler("resource") .QueryInterface(Ci.nsIResProtocolHandler); resProto.setSubstitution( "rs-downloader-test", Services.io.newFileURI(do_get_file("test_attachments_downloader")) ); function checkInfo(result, expectedSource, expectedRecord = RECORD_OF_DUMP) { Assert.equal( new TextDecoder().decode(new Uint8Array(result.buffer)), "This would be a RS dump.\n", "expected content from dump" ); Assert.deepEqual(result.record, expectedRecord, "expected record for dump"); Assert.equal(result._source, expectedSource, "expected source of dump"); } // When a record is given, use whichever that has the matching last_modified. const dump = await client.attachments.get(RECORD_OF_DUMP); checkInfo(dump, "dump_match"); await client.attachments.deleteDownloaded(RECORD_OF_DUMP); await Assert.rejects( client.attachments.get(null, { attachmentId: "filename-without-meta.txt", fallbackToDump: true, }), /NotFoundError: Could not find filename-without-meta.txt in cache or dump/, "Cannot download dump that lacks a .meta.json file" ); await Assert.rejects( client.attachments.get(null, { attachmentId: "filename-without-content.txt", fallbackToDump: true, }), /Could not download resource:\/\/rs-downloader-test\/settings\/dump-bucket\/dump-collection\/filename-without-content\.txt(?!\.meta\.json)/, "Cannot download dump that is missing, despite the existing .meta.json" ); // Restore, just in case. Downloader._RESOURCE_BASE_URL = orig_RESOURCE_BASE_URL; resProto.setSubstitution("rs-downloader-test", null); }); // Not really needed because the last test doesn't modify the main collection, // but added for consistency with other tests tasks around here. add_task(clear_state); add_task(async function test_obsolete_attachments_are_pruned() { const RECORD2 = { ...RECORD, id: "another-id", }; const client = RemoteSettings("some-collection"); // Store records and related attachments directly in the cache. await client.db.importChanges({}, 42, [RECORD, RECORD2], { clear: true }); await client.db.saveAttachment(RECORD.id, { record: RECORD, blob: new Blob(["123"]), }); await client.db.saveAttachment("custom-id", { record: RECORD2, blob: new Blob(["456"]), }); // Store an extraneous cached attachment. await client.db.saveAttachment("bar", { record: { id: "bar" }, blob: new Blob(["789"]), }); const recordAttachment = await client.attachments.cacheImpl.get(RECORD.id); Assert.equal( await recordAttachment.blob.text(), "123", "Record has a cached attachment" ); const record2Attachment = await client.attachments.cacheImpl.get("custom-id"); Assert.equal( await record2Attachment.blob.text(), "456", "Record 2 has a cached attachment" ); const { blob: cachedExtra } = await client.attachments.cacheImpl.get("bar"); Assert.equal(await cachedExtra.text(), "789", "There is an extra attachment"); await client.attachments.prune([]); Assert.ok( await client.attachments.cacheImpl.get(RECORD.id), "Record attachment was kept" ); Assert.ok( await client.attachments.cacheImpl.get("custom-id"), "Record 2 attachment was kept" ); Assert.ok( !(await client.attachments.cacheImpl.get("bar")), "Extra was deleted" ); }); add_task(clear_state); add_task( async function test_obsolete_attachments_listed_as_excluded_are_not_pruned() { const client = RemoteSettings("some-collection"); // Store records and related attachments directly in the cache. await client.db.importChanges({}, 42, [], { clear: true }); await client.db.saveAttachment(RECORD.id, { record: RECORD, blob: new Blob(["123"]), }); const recordAttachment = await client.attachments.cacheImpl.get(RECORD.id); Assert.equal( await recordAttachment.blob.text(), "123", "Record has a cached attachment" ); await client.attachments.prune([RECORD.id]); Assert.ok( await client.attachments.cacheImpl.get(RECORD.id), "Record attachment was kept" ); } ); add_task(clear_state);