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