tor-browser

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

test_dictionary_replacement.js (21065B)


      1 /**
      2 * Tests for HTTP Compression Dictionary replacement functionality
      3 * - Verify that when a dictionary resource is reloaded without Use-As-Dictionary,
      4 *   the dictionary metadata is properly removed
      5 * - Test that Available-Dictionary header is no longer sent for matching resources
      6 *   after dictionary is replaced with non-dictionary content
      7 *
      8 * This tests the fix for the race condition in DictionaryOriginReader::OnCacheEntryAvailable
      9 * where mEntry was not set for existing origins loaded from disk.
     10 */
     11 
     12 "use strict";
     13 
     14 // Load cache helpers
     15 Services.scriptloader.loadSubScript("resource://test/head_cache.js", this);
     16 
     17 const { NodeHTTPSServer } = ChromeUtils.importESModule(
     18  "resource://testing-common/NodeServer.sys.mjs"
     19 );
     20 
     21 const DICTIONARY_CONTENT = "DICTIONARY_CONTENT_FOR_COMPRESSION_TEST_DATA";
     22 const REPLACEMENT_CONTENT = "REPLACED_CONTENT_WITHOUT_DICTIONARY_HEADER";
     23 
     24 let server = null;
     25 
     26 add_setup(async function () {
     27  Services.prefs.setBoolPref("network.http.dictionaries.enable", true);
     28 
     29  server = new NodeHTTPSServer();
     30  await server.start();
     31 
     32  // Clear any existing cache
     33  let lci = Services.loadContextInfo.custom(false, {
     34    partitionKey: `(https,localhost)`,
     35  });
     36  evict_cache_entries("all", lci);
     37 
     38  registerCleanupFunction(async () => {
     39    try {
     40      await server.stop();
     41    } catch (e) {
     42      // Ignore server stop errors during cleanup
     43    }
     44  });
     45 });
     46 
     47 function makeChan(url, bypassCache = false) {
     48  let chan = NetUtil.newChannel({
     49    uri: url,
     50    loadUsingSystemPrincipal: true,
     51    contentPolicyType: Ci.nsIContentPolicy.TYPE_DOCUMENT,
     52  }).QueryInterface(Ci.nsIHttpChannel);
     53 
     54  if (bypassCache) {
     55    chan.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
     56  }
     57 
     58  return chan;
     59 }
     60 
     61 function channelOpenPromise(chan, intermittentFail = false) {
     62  return new Promise(resolve => {
     63    function finish(req, buffer) {
     64      resolve([req, buffer]);
     65    }
     66    if (intermittentFail) {
     67      chan.asyncOpen(
     68        new SimpleChannelListener(finish, null, CL_ALLOW_UNKNOWN_CL)
     69      );
     70    } else {
     71      chan.asyncOpen(new ChannelListener(finish, null, CL_ALLOW_UNKNOWN_CL));
     72    }
     73  });
     74 }
     75 
     76 function verifyDictionaryStored(url, shouldExist) {
     77  return new Promise(resolve => {
     78    let lci = Services.loadContextInfo.custom(false, {
     79      partitionKey: `(https,localhost)`,
     80    });
     81    asyncCheckCacheEntryPresence(url, "disk", shouldExist, lci, resolve);
     82  });
     83 }
     84 
     85 function syncCache() {
     86  return new Promise(resolve => {
     87    syncWithCacheIOThread(resolve, true);
     88  });
     89 }
     90 
     91 // Clear in-memory DictionaryCache and purge cache entries from memory.
     92 // This forces dictionary origin entries to be reloaded from disk on next access,
     93 // triggering DictionaryOriginReader::OnCacheEntryAvailable.
     94 async function clearDictionaryCacheAndPurgeMemory() {
     95  // Clear the DictionaryCache in-memory hashmap
     96  let testingInterface = Services.cache2.QueryInterface(Ci.nsICacheTesting);
     97  testingInterface.clearDictionaryCacheMemory();
     98 
     99  // Force GC to release references to cache entries.  Probably not strictly needed
    100  gc();
    101 }
    102 
    103 /**
    104 * Test that replacing a dictionary resource with non-dictionary content
    105 * properly removes the dictionary metadata.
    106 *
    107 * Steps:
    108 * 1. Load a resource with Use-As-Dictionary header (creates dictionary entry)
    109 * 2. Verify Available-Dictionary is sent for matching resources
    110 * 3. Force-reload the dictionary resource WITHOUT Use-As-Dictionary
    111 * 4. Verify Available-Dictionary is NO LONGER sent for matching resources
    112 */
    113 add_task(async function test_dictionary_replacement_removes_metadata() {
    114  // Track Available-Dictionary headers received by server
    115  let receivedAvailableDictionary = null;
    116 
    117  // Register dictionary endpoint that returns dictionary content
    118  await server.registerPathHandler(
    119    "/dict/resource",
    120    function (request, response) {
    121      response.writeHead(200, {
    122        "Content-Type": "application/octet-stream",
    123        "Use-As-Dictionary": 'match="/matching/*", id="test-dict", type=raw',
    124        "Cache-Control": "max-age=3600",
    125      });
    126      response.end("DICTIONARY_CONTENT_FOR_COMPRESSION_TEST_DATA", "binary");
    127    }
    128  );
    129 
    130  // Register matching resource endpoint
    131  await server.registerPathHandler(
    132    "/matching/test",
    133    function (request, response) {
    134      // Store the Available-Dictionary header value in global for later retrieval
    135      global.lastAvailableDictionary =
    136        request.headers["available-dictionary"] || null;
    137      response.writeHead(200, {
    138        "Content-Type": "text/plain",
    139        "Cache-Control": "no-cache",
    140      });
    141      response.end("CONTENT_THAT_SHOULD_MATCH_DICTIONARY", "binary");
    142    }
    143  );
    144 
    145  dump("**** Step 1: Load dictionary resource with Use-As-Dictionary\n");
    146 
    147  let dictUrl = `https://localhost:${server.port()}/dict/resource`;
    148  let chan = makeChan(dictUrl);
    149  let [, data] = await channelOpenPromise(chan);
    150 
    151  Assert.equal(data, DICTIONARY_CONTENT, "Dictionary content should match");
    152 
    153  // Verify dictionary is stored in cache
    154  await verifyDictionaryStored(dictUrl, true);
    155 
    156  // Sync to ensure everything is written to disk
    157  await syncCache();
    158 
    159  dump("**** Step 1.5: Clear in-memory caches to force reload from disk\n");
    160 
    161  // Clear in-memory DictionaryCache and purge cache entries from memory.
    162  // This forces dictionary entries to be reloaded from disk via
    163  // DictionaryOriginReader::OnCacheEntryAvailable, which is the code path
    164  // with the bug we're testing.
    165  await clearDictionaryCacheAndPurgeMemory();
    166 
    167  dump(
    168    "**** Step 2: Verify Available-Dictionary is sent for matching resource\n"
    169  );
    170 
    171  let matchingUrl = `https://localhost:${server.port()}/matching/test`;
    172  chan = makeChan(matchingUrl);
    173  await channelOpenPromise(chan);
    174 
    175  // Get the Available-Dictionary value from the server
    176  receivedAvailableDictionary = await server.execute(
    177    "global.lastAvailableDictionary"
    178  );
    179 
    180  Assert.notStrictEqual(
    181    receivedAvailableDictionary,
    182    null,
    183    "Available-Dictionary header should be sent for matching resource"
    184  );
    185  Assert.ok(
    186    receivedAvailableDictionary.includes(":"),
    187    "Available-Dictionary should contain a hash"
    188  );
    189 
    190  dump(`**** Received Available-Dictionary: ${receivedAvailableDictionary}\n`);
    191 
    192  dump(
    193    "**** Step 3: Force-reload dictionary resource WITHOUT Use-As-Dictionary\n"
    194  );
    195 
    196  // Re-register the dictionary endpoint to return content WITHOUT Use-As-Dictionary
    197  await server.registerPathHandler(
    198    "/dict/resource",
    199    function (request, response) {
    200      response.writeHead(200, {
    201        "Content-Type": "application/octet-stream",
    202        "Cache-Control": "max-age=3600",
    203        // No Use-As-Dictionary header!
    204      });
    205      response.end("REPLACED_CONTENT_WITHOUT_DICTIONARY_HEADER", "binary");
    206    }
    207  );
    208 
    209  chan = makeChan(dictUrl, true /* bypassCache */);
    210  [, data] = await channelOpenPromise(chan);
    211 
    212  Assert.equal(data, REPLACEMENT_CONTENT, "Replacement content should match");
    213 
    214  // Sync to ensure cache operations complete
    215  await syncCache();
    216 
    217  dump("**** Step 4: Verify Available-Dictionary is NOT sent anymore\n");
    218 
    219  // Reset the server's stored value
    220  await server.execute("global.lastAvailableDictionary = null");
    221 
    222  // Now request the matching resource again
    223  chan = makeChan(matchingUrl);
    224  await channelOpenPromise(chan);
    225 
    226  receivedAvailableDictionary = await server.execute(
    227    "global.lastAvailableDictionary"
    228  );
    229 
    230  Assert.equal(
    231    receivedAvailableDictionary,
    232    null,
    233    "Available-Dictionary header should NOT be sent after dictionary is replaced"
    234  );
    235 
    236  dump("**** Test passed: Dictionary metadata was properly removed\n");
    237 });
    238 
    239 /**
    240 * Test the same scenario but with gzip-compressed replacement content.
    241 * This simulates the real-world case where a server might return
    242 * compressed content without Use-As-Dictionary.
    243 */
    244 add_task(async function test_dictionary_replacement_with_compressed_content() {
    245  dump("**** Clear cache and start fresh\n");
    246  let lci = Services.loadContextInfo.custom(false, {
    247    partitionKey: `(https,localhost)`,
    248  });
    249  evict_cache_entries("all", lci);
    250 
    251  // Also clear in-memory DictionaryCache to start fresh
    252  let testingInterface = Services.cache2.QueryInterface(Ci.nsICacheTesting);
    253  testingInterface.clearDictionaryCacheMemory();
    254 
    255  await syncCache();
    256 
    257  let receivedAvailableDictionary = null;
    258 
    259  // Register dictionary endpoint
    260  await server.registerPathHandler(
    261    "/dict/compressed",
    262    function (request, response) {
    263      response.writeHead(200, {
    264        "Content-Type": "application/octet-stream",
    265        "Use-As-Dictionary":
    266          'match="/compressed-match/*", id="compressed-dict", type=raw',
    267        "Cache-Control": "max-age=3600",
    268      });
    269      response.end("DICTIONARY_FOR_COMPRESSED_TEST", "binary");
    270    }
    271  );
    272 
    273  // Register matching resource endpoint
    274  await server.registerPathHandler(
    275    "/compressed-match/test",
    276    function (request, response) {
    277      global.lastCompressedAvailDict =
    278        request.headers["available-dictionary"] || null;
    279      response.writeHead(200, {
    280        "Content-Type": "text/plain",
    281        "Cache-Control": "no-cache",
    282      });
    283      response.end("MATCHING_CONTENT", "binary");
    284    }
    285  );
    286 
    287  dump("**** Step 1: Load dictionary resource with Use-As-Dictionary\n");
    288 
    289  let dictUrl = `https://localhost:${server.port()}/dict/compressed`;
    290  let chan = makeChan(dictUrl);
    291  let [, data] = await channelOpenPromise(chan);
    292 
    293  Assert.equal(
    294    data,
    295    "DICTIONARY_FOR_COMPRESSED_TEST",
    296    "Dictionary content should match"
    297  );
    298  await verifyDictionaryStored(dictUrl, true);
    299  await syncCache();
    300 
    301  dump("**** Step 1.5: Clear in-memory caches to force reload from disk\n");
    302  await clearDictionaryCacheAndPurgeMemory();
    303 
    304  dump("**** Step 2: Verify Available-Dictionary is sent\n");
    305 
    306  let matchingUrl = `https://localhost:${server.port()}/compressed-match/test`;
    307  chan = makeChan(matchingUrl);
    308  await channelOpenPromise(chan);
    309 
    310  receivedAvailableDictionary = await server.execute(
    311    "global.lastCompressedAvailDict"
    312  );
    313  Assert.notStrictEqual(
    314    receivedAvailableDictionary,
    315    null,
    316    "Available-Dictionary should be sent initially"
    317  );
    318 
    319  dump(
    320    "**** Step 3: Force-reload with gzip-compressed content (no Use-As-Dictionary)\n"
    321  );
    322 
    323  // Re-register to return gzip-compressed content without Use-As-Dictionary
    324  await server.registerPathHandler(
    325    "/dict/compressed",
    326    function (request, response) {
    327      // Gzip-compressed version of "GZIP_COMPRESSED_REPLACEMENT"
    328      // Using Node.js zlib in the handler
    329      const zlib = require("zlib");
    330      const compressed = zlib.gzipSync("GZIP_COMPRESSED_REPLACEMENT");
    331 
    332      response.writeHead(200, {
    333        "Content-Type": "application/octet-stream",
    334        "Content-Encoding": "gzip",
    335        "Cache-Control": "max-age=3600",
    336        // No Use-As-Dictionary header!
    337      });
    338      response.end(compressed);
    339    }
    340  );
    341 
    342  chan = makeChan(dictUrl, true /* bypassCache */);
    343  [, data] = await channelOpenPromise(chan);
    344 
    345  // Content should be decompressed by the channel
    346  Assert.equal(
    347    data,
    348    "GZIP_COMPRESSED_REPLACEMENT",
    349    "Decompressed replacement content should match"
    350  );
    351 
    352  dump("**** Step 4: Verify Available-Dictionary is NOT sent anymore\n");
    353 
    354  await server.execute("global.lastCompressedAvailDict = null");
    355  chan = makeChan(matchingUrl);
    356  await channelOpenPromise(chan);
    357 
    358  receivedAvailableDictionary = await server.execute(
    359    "global.lastCompressedAvailDict"
    360  );
    361 
    362  Assert.equal(
    363    receivedAvailableDictionary,
    364    null,
    365    "Available-Dictionary should NOT be sent after dictionary replaced with compressed content"
    366  );
    367 
    368  dump(
    369    "**** Test passed: Dictionary metadata removed even with compressed replacement\n"
    370  );
    371 });
    372 
    373 /**
    374 * Test that multiple sequential replacements work correctly.
    375 * Dictionary -> Non-dictionary -> Dictionary -> Non-dictionary
    376 */
    377 add_task(async function test_dictionary_multiple_replacements() {
    378  dump("**** Clear cache and start fresh\n");
    379  let lci = Services.loadContextInfo.custom(false, {
    380    partitionKey: `(https,localhost)`,
    381  });
    382  evict_cache_entries("all", lci);
    383 
    384  // Also clear in-memory DictionaryCache to start fresh
    385  let testingInterface = Services.cache2.QueryInterface(Ci.nsICacheTesting);
    386  testingInterface.clearDictionaryCacheMemory();
    387 
    388  await syncCache();
    389 
    390  let receivedAvailableDictionary = null;
    391 
    392  // Register matching resource endpoint
    393  await server.registerPathHandler(
    394    "/multi-match/test",
    395    function (request, response) {
    396      global.lastMultiAvailDict =
    397        request.headers["available-dictionary"] || null;
    398      response.writeHead(200, {
    399        "Content-Type": "text/plain",
    400        "Cache-Control": "no-cache",
    401      });
    402      response.end("MULTI_MATCHING_CONTENT", "binary");
    403    }
    404  );
    405 
    406  let dictUrl = `https://localhost:${server.port()}/dict/multi`;
    407  let matchingUrl = `https://localhost:${server.port()}/multi-match/test`;
    408 
    409  // === First: Load as dictionary ===
    410  dump("**** Load as dictionary (first time)\n");
    411  await server.registerPathHandler("/dict/multi", function (request, response) {
    412    response.writeHead(200, {
    413      "Content-Type": "application/octet-stream",
    414      "Use-As-Dictionary":
    415        'match="/multi-match/*", id="multi-dict-1", type=raw',
    416      "Cache-Control": "max-age=3600",
    417    });
    418    response.end("DICTIONARY_CONTENT_V1", "binary");
    419  });
    420 
    421  let chan = makeChan(dictUrl);
    422  await channelOpenPromise(chan);
    423  await syncCache();
    424 
    425  // Clear in-memory caches to force reload from disk
    426  await clearDictionaryCacheAndPurgeMemory();
    427 
    428  chan = makeChan(matchingUrl);
    429  await channelOpenPromise(chan);
    430  receivedAvailableDictionary = await server.execute(
    431    "global.lastMultiAvailDict"
    432  );
    433  Assert.notStrictEqual(
    434    receivedAvailableDictionary,
    435    null,
    436    "Available-Dictionary should be sent (first dictionary)"
    437  );
    438 
    439  // === Second: Replace with non-dictionary ===
    440  dump("**** Replace with non-dictionary\n");
    441  await server.registerPathHandler("/dict/multi", function (request, response) {
    442    response.writeHead(200, {
    443      "Content-Type": "application/octet-stream",
    444      "Cache-Control": "max-age=3600",
    445    });
    446    response.end("NON_DICTIONARY_CONTENT", "binary");
    447  });
    448 
    449  chan = makeChan(dictUrl, true);
    450  await channelOpenPromise(chan);
    451  await syncCache();
    452  await new Promise(resolve => do_timeout(200, resolve));
    453 
    454  await server.execute("global.lastMultiAvailDict = null");
    455  chan = makeChan(matchingUrl);
    456  await channelOpenPromise(chan);
    457  receivedAvailableDictionary = await server.execute(
    458    "global.lastMultiAvailDict"
    459  );
    460  Assert.equal(
    461    receivedAvailableDictionary,
    462    null,
    463    "Available-Dictionary should NOT be sent (after first replacement)"
    464  );
    465 
    466  // === Third: Load as dictionary again ===
    467  dump("**** Load as dictionary (second time)\n");
    468  await server.registerPathHandler("/dict/multi", function (request, response) {
    469    response.writeHead(200, {
    470      "Content-Type": "application/octet-stream",
    471      "Use-As-Dictionary":
    472        'match="/multi-match/*", id="multi-dict-2", type=raw',
    473      "Cache-Control": "max-age=3600",
    474    });
    475    response.end("DICTIONARY_CONTENT_V2", "binary");
    476  });
    477 
    478  chan = makeChan(dictUrl, true);
    479  await channelOpenPromise(chan);
    480  await syncCache();
    481 
    482  // Clear in-memory caches to force reload from disk
    483  await clearDictionaryCacheAndPurgeMemory();
    484 
    485  chan = makeChan(matchingUrl);
    486  await channelOpenPromise(chan);
    487  receivedAvailableDictionary = await server.execute(
    488    "global.lastMultiAvailDict"
    489  );
    490  Assert.notStrictEqual(
    491    receivedAvailableDictionary,
    492    null,
    493    "Available-Dictionary should be sent (second dictionary)"
    494  );
    495 
    496  // === Fourth: Replace with non-dictionary again ===
    497  dump("**** Replace with non-dictionary again\n");
    498  await server.registerPathHandler("/dict/multi", function (request, response) {
    499    response.writeHead(200, {
    500      "Content-Type": "application/octet-stream",
    501      "Cache-Control": "max-age=3600",
    502    });
    503    response.end("NON_DICTIONARY_CONTENT_V2", "binary");
    504  });
    505 
    506  chan = makeChan(dictUrl, true);
    507  await channelOpenPromise(chan);
    508  await syncCache();
    509  await new Promise(resolve => do_timeout(200, resolve));
    510 
    511  await server.execute("global.lastMultiAvailDict = null");
    512  chan = makeChan(matchingUrl);
    513  await channelOpenPromise(chan);
    514  receivedAvailableDictionary = await server.execute(
    515    "global.lastMultiAvailDict"
    516  );
    517  Assert.equal(
    518    receivedAvailableDictionary,
    519    null,
    520    "Available-Dictionary should NOT be sent (after second replacement)"
    521  );
    522 
    523  dump("**** Test passed: Multiple replacements work correctly\n");
    524 });
    525 
    526 /**
    527 * Test that hash mismatch during dictionary load causes the request to fail
    528 * and the corrupted dictionary entry to be removed.
    529 *
    530 * Steps:
    531 * 1. Load a resource with Use-As-Dictionary header (creates dictionary entry)
    532 * 2. Verify Available-Dictionary is sent for matching resources
    533 * 3. Corrupt the hash using the testing API
    534 * 4. Clear memory cache to force reload from disk
    535 * 5. Request a matching resource - dictionary prefetch should fail
    536 * 6. Verify the dictionary entry was removed (Available-Dictionary no longer sent)
    537 */
    538 add_task(async function test_dictionary_hash_mismatch() {
    539  dump("**** Clear cache and start fresh\n");
    540  let lci = Services.loadContextInfo.custom(false, {
    541    partitionKey: `(https,localhost)`,
    542  });
    543  evict_cache_entries("all", lci);
    544 
    545  let testingInterface = Services.cache2.QueryInterface(Ci.nsICacheTesting);
    546  testingInterface.clearDictionaryCacheMemory();
    547 
    548  await syncCache();
    549 
    550  let receivedAvailableDictionary = null;
    551 
    552  // Register dictionary endpoint
    553  await server.registerPathHandler(
    554    "/dict/hash-test",
    555    function (request, response) {
    556      response.writeHead(200, {
    557        "Content-Type": "application/octet-stream",
    558        "Use-As-Dictionary":
    559          'match="/hash-match/*", id="hash-test-dict", type=raw',
    560        "Cache-Control": "max-age=3600",
    561      });
    562      response.end("DICTIONARY_FOR_HASH_TEST", "binary");
    563    }
    564  );
    565 
    566  // Register matching resource endpoint
    567  await server.registerPathHandler(
    568    "/hash-match/test",
    569    function (request, response) {
    570      global.lastHashTestAvailDict =
    571        request.headers["available-dictionary"] || null;
    572      response.writeHead(200, {
    573        "Content-Type": "text/plain",
    574        "Cache-Control": "no-cache",
    575      });
    576      response.end("HASH_MATCHING_CONTENT", "binary");
    577    }
    578  );
    579 
    580  dump("**** Step 1: Load dictionary resource with Use-As-Dictionary\n");
    581 
    582  let dictUrl = `https://localhost:${server.port()}/dict/hash-test`;
    583  let chan = makeChan(dictUrl);
    584  let [, data] = await channelOpenPromise(chan);
    585 
    586  Assert.equal(
    587    data,
    588    "DICTIONARY_FOR_HASH_TEST",
    589    "Dictionary content should match"
    590  );
    591  await verifyDictionaryStored(dictUrl, true);
    592  await syncCache();
    593 
    594  dump("**** Step 2: Verify Available-Dictionary is sent\n");
    595 
    596  let matchingUrl = `https://localhost:${server.port()}/hash-match/test`;
    597  chan = makeChan(matchingUrl);
    598  await channelOpenPromise(chan);
    599 
    600  receivedAvailableDictionary = await server.execute(
    601    "global.lastHashTestAvailDict"
    602  );
    603  Assert.notStrictEqual(
    604    receivedAvailableDictionary,
    605    null,
    606    "Available-Dictionary should be sent initially"
    607  );
    608 
    609  dump("**** Step 3: Corrupt the dictionary hash\n");
    610 
    611  testingInterface.corruptDictionaryHash(dictUrl);
    612 
    613  dump("**** Step 4: Clear dictionary data to force reload from disk\n");
    614 
    615  // Clear dictionary data while keeping the corrupted hash.
    616  // When next prefetch happens, data will be reloaded and compared
    617  // against the corrupted hash, causing a mismatch.
    618  testingInterface.clearDictionaryDataForTesting(dictUrl);
    619 
    620  dump(
    621    "**** Step 5: Request matching resource - should fail due to hash mismatch\n"
    622  );
    623 
    624  await server.execute("global.lastHashTestAvailDict = null");
    625 
    626  // The request for the matching resource will try to prefetch the dictionary,
    627  // which will fail due to hash mismatch. The channel should be cancelled.
    628  chan = makeChan(matchingUrl);
    629  try {
    630    await channelOpenPromise(chan, true); // intermittent failure
    631  } catch (e) {
    632    dump(`**** Request failed with: ${e}\n`);
    633  }
    634 
    635  // Note: The request may or may not fail depending on timing. The important
    636  // thing is that the dictionary entry should be removed.
    637 
    638  dump("**** Step 6: Verify dictionary entry was removed\n");
    639 
    640  // Wait a bit for the removal to complete
    641  await syncCache();
    642 
    643  await server.execute("global.lastHashTestAvailDict = null");
    644  chan = makeChan(matchingUrl);
    645  await channelOpenPromise(chan);
    646 
    647  receivedAvailableDictionary = await server.execute(
    648    "global.lastHashTestAvailDict"
    649  );
    650  Assert.equal(
    651    receivedAvailableDictionary,
    652    null,
    653    "Available-Dictionary should NOT be sent after dictionary was removed due to hash mismatch"
    654  );
    655 
    656  dump("**** Test passed: Hash mismatch properly handled\n");
    657 });
    658 
    659 // Cleanup
    660 add_task(async function cleanup() {
    661  let lci = Services.loadContextInfo.custom(false, {
    662    partitionKey: `(https,localhost)`,
    663  });
    664  evict_cache_entries("all", lci);
    665  dump("**** All dictionary replacement tests completed\n");
    666 });