test_dictionary_storage.js (20538B)
1 /** 2 * Tests for HTTP Compression Dictionary storage functionality 3 * - Use-As-Dictionary header parsing and validation 4 * - Dictionary storage in cache with proper metadata 5 * - Pattern matching and hash validation 6 * - Error handling and edge cases 7 */ 8 9 "use strict"; 10 11 // Load cache helpers 12 Services.scriptloader.loadSubScript("resource://test/head_cache.js", this); 13 14 const { NodeHTTPSServer } = ChromeUtils.importESModule( 15 "resource://testing-common/NodeServer.sys.mjs" 16 ); 17 18 // Test data constants 19 const TEST_DICTIONARIES = { 20 small: { 21 id: "test-dict-small", 22 content: "COMMON_PREFIX_DATA_FOR_COMPRESSION", 23 pattern: "/api/v1/*", 24 type: "raw", 25 }, 26 large: { 27 id: "test-dict-large", 28 content: "C".repeat(1024 * 100), // 100KB dictionary 29 pattern: "*.html", 30 type: "raw", 31 }, 32 large_url: { 33 id: "test-dict-large-url", 34 content: "large URL content", 35 pattern: "file", 36 type: "raw", 37 }, 38 too_large_url: { 39 id: "test-dict-too-large-url", 40 content: "too large URL content", 41 pattern: "too_large", 42 type: "raw", 43 }, 44 regexp_group: { 45 id: "test-regexp-group", 46 content: "content", 47 pattern: "api/:version(v[0-9]+)/*", 48 type: "raw", 49 }, 50 }; 51 52 let server = null; 53 54 add_setup(async function () { 55 Services.prefs.setBoolPref("network.http.dictionaries.enable", true); 56 if (!server) { 57 server = await setupServer(); 58 } 59 // Setup baseline dictionaries for compression testing 60 61 // Clear any existing cache 62 let lci = Services.loadContextInfo.custom(false, { 63 partitionKey: `(https,localhost)`, 64 }); 65 evict_cache_entries("all", lci); 66 }); 67 68 // Utility function to calculate SHA-256 hash 69 async function calculateSHA256(data) { 70 let hasher = Cc["@mozilla.org/security/hash;1"].createInstance( 71 Ci.nsICryptoHash 72 ); 73 hasher.init(Ci.nsICryptoHash.SHA256); 74 75 // Convert string to UTF-8 bytes 76 let bytes = new TextEncoder().encode(data); 77 hasher.update(bytes, bytes.length); 78 return hasher.finish(false); 79 } 80 81 // Setup dictionary test server 82 async function setupServer() { 83 let httpServer = new NodeHTTPSServer(); 84 await httpServer.start(); 85 86 // Basic dictionary endpoint 87 await httpServer.registerPathHandler( 88 "/dict/small", 89 function (request, response) { 90 // Test data constants 91 const TEST_DICTIONARIES = { 92 small: { 93 id: "test-dict-small", 94 content: "COMMON_PREFIX_DATA_FOR_COMPRESSION", 95 pattern: "/api/v1/*", 96 type: "raw", 97 }, 98 }; 99 100 let dict = TEST_DICTIONARIES.small; 101 response.writeHead(200, { 102 "Content-Type": "application/octet-stream", 103 "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, 104 "Cache-Control": "max-age=3600", 105 }); 106 response.end(dict.content, "binary"); 107 } 108 ); 109 110 // Dictionary with expiration 111 await httpServer.registerPathHandler( 112 "/dict/expires", 113 function (request, response) { 114 response.writeHead(200, { 115 "Content-Type": "application/octet-stream", 116 "Use-As-Dictionary": `match="expires/*", id="expires-dict", type=raw`, 117 "Cache-Control": "max-age=1", 118 }); 119 response.end("EXPIRING_DICTIONARY_DATA", "binary"); 120 } 121 ); 122 123 // Dictionary with invalid header 124 await httpServer.registerPathHandler( 125 "/dict/invalid", 126 function (request, response) { 127 global.test = 1; 128 response.writeHead(200, { 129 "Content-Type": "application/octet-stream", 130 "Use-As-Dictionary": "invalid-header-format", 131 }); 132 response.end("INVALID_DICTIONARY_DATA", "binary"); 133 } 134 ); 135 136 // Large dictionary 137 await httpServer.registerPathHandler( 138 "/dict/large", 139 function (request, response) { 140 // Test data constants 141 const TEST_DICTIONARIES = { 142 large: { 143 id: "test-dict-large", 144 content: "C".repeat(1024 * 100), // 100KB dictionary 145 pattern: "*.html", 146 type: "raw", 147 }, 148 }; 149 150 let dict = TEST_DICTIONARIES.large; 151 response.writeHead(200, { 152 "Content-Type": "application/octet-stream", 153 "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, 154 "Cache-Control": "max-age=3600", 155 }); 156 response.end(dict.content, "binary"); 157 } 158 ); 159 160 // Large dictionary URL 161 await httpServer.registerPathHandler( 162 "/dict/large/" + "A".repeat(1024 * 20), 163 function (request, response) { 164 // Test data constants 165 const TEST_DICTIONARIES = { 166 large_url: { 167 id: "test-dict-large-url", 168 content: "large URL content", 169 pattern: "file", 170 type: "raw", 171 }, 172 }; 173 174 let dict = TEST_DICTIONARIES.large_url; 175 response.writeHead(200, { 176 "Content-Type": "application/octet-stream", 177 "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, 178 "Cache-Control": "max-age=3600", 179 }); 180 response.end(dict.content, "binary"); 181 } 182 ); 183 184 // Too Large dictionary URL 185 await httpServer.registerPathHandler( 186 "/dict/large/" + "B".repeat(1024 * 100), 187 function (request, response) { 188 // Test data constants 189 const TEST_DICTIONARIES = { 190 too_large_url: { 191 id: "test-dict-too-large-url", 192 content: "too large URL content", 193 pattern: "too_large", 194 type: "raw", 195 }, 196 }; 197 198 let dict = TEST_DICTIONARIES.too_large_url; 199 response.writeHead(200, { 200 "Content-Type": "application/octet-stream", 201 "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, 202 "Cache-Control": "max-age=3600", 203 }); 204 response.end(dict.content, "binary"); 205 } 206 ); 207 208 // pattern with a regexp (should be ignored) 209 await httpServer.registerPathHandler( 210 "/api/regexp", 211 function (request, response) { 212 // Test data constants 213 const TEST_DICTIONARIES = { 214 regexp_group: { 215 id: "test-regexp-group", 216 content: "content", 217 pattern: "/api/:version(v[0-9]+)/*", 218 type: "raw", 219 }, 220 }; 221 222 let dict = TEST_DICTIONARIES.regexp_group; 223 response.writeHead(200, { 224 "Content-Type": "application/octet-stream", 225 "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, 226 "Cache-Control": "max-age=3600", 227 }); 228 response.end(dict.content, "binary"); 229 } 230 ); 231 232 registerCleanupFunction(async () => { 233 try { 234 await httpServer.stop(); 235 } catch (e) { 236 // Ignore server stop errors during cleanup 237 } 238 }); 239 240 return httpServer; 241 } 242 243 // Verify dictionary is stored in cache 244 function verifyDictionaryStored(url, shouldExist, callback) { 245 let lci = Services.loadContextInfo.custom(false, { 246 partitionKey: `(https,localhost)`, 247 }); 248 asyncCheckCacheEntryPresence(url, "disk", shouldExist, lci, callback); 249 } 250 251 // Create channel for dictionary requests 252 function makeChan(url) { 253 let chan = NetUtil.newChannel({ 254 uri: url, 255 loadUsingSystemPrincipal: true, 256 contentPolicyType: Ci.nsIContentPolicy.TYPE_DOCUMENT, 257 }).QueryInterface(Ci.nsIHttpChannel); 258 return chan; 259 } 260 261 function channelOpenPromise(chan) { 262 return new Promise(resolve => { 263 function finish(req, buffer) { 264 resolve([req, buffer]); 265 } 266 chan.asyncOpen(new ChannelListener(finish, null, CL_ALLOW_UNKNOWN_CL)); 267 }); 268 } 269 270 // Test basic dictionary storage with Use-As-Dictionary header 271 add_task(async function test_basic_dictionary_storage() { 272 // Clear any existing cache 273 evict_cache_entries("all"); 274 275 let url = `https://localhost:${server.port()}/dict/small`; 276 let dict = TEST_DICTIONARIES.small; 277 278 let chan = makeChan(url); 279 let [req, data] = await channelOpenPromise(chan); 280 281 Assert.equal(data, dict.content, "Dictionary content matches"); 282 283 // Verify Use-As-Dictionary header was processed 284 try { 285 let headerValue = req.getResponseHeader("Use-As-Dictionary"); 286 Assert.ok( 287 headerValue.includes(`id="${dict.id}"`), 288 "Header contains correct ID" 289 ); 290 Assert.ok( 291 headerValue.includes(`match="${dict.pattern}"`), 292 "Header contains correct pattern" 293 ); 294 } catch (e) { 295 Assert.ok(false, "Use-As-Dictionary header should be present"); 296 } 297 298 // Check that dictionary is stored in cache 299 await new Promise(resolve => { 300 verifyDictionaryStored(url, true, resolve); 301 }); 302 }); 303 304 // Test Use-As-Dictionary header parsing with various formats 305 add_task(async function test_dictionary_header_parsing() { 306 const headerTests = [ 307 { 308 header: 'match="*", id="dict1", type=raw', 309 valid: true, 310 description: "Basic valid header", 311 }, 312 { 313 header: 'match="/api/*", id="api-dict", type=raw', 314 valid: true, 315 description: "Path pattern header", 316 }, 317 { 318 header: 'match="*.js", id="js-dict"', 319 valid: true, 320 description: "Header without type (should default to raw)", 321 }, 322 { 323 header: 'id="dict1", type=raw', 324 valid: false, 325 description: "Missing match parameter", 326 }, 327 { 328 header: 'match="*"', 329 valid: false, 330 description: "Missing id parameter", 331 }, 332 { 333 header: 'match="*", id="", type=raw', 334 valid: false, 335 description: "Empty id parameter", 336 }, 337 ]; 338 339 let testIndex = 0; 340 for (let test of headerTests) { 341 let testPath = `/dict/header-test-${testIndex++}`; 342 let func = ` 343 global.testIndex = 0; 344 let test = ${JSON.stringify(test)}; 345 response.writeHead(200, { 346 "Content-Type": "application/octet-stream", 347 "Use-As-Dictionary": test.header, 348 }); 349 // We won't be using this, so it doesn't really matter 350 response.end("HEADER_TEST_DICT_" + global.testIndex++, "binary"); 351 `; 352 let handler = new Function("request", "response", func); 353 await server.registerPathHandler(testPath, handler); 354 355 let url = `https://localhost:${server.port()}${testPath}`; 356 let chan = makeChan(url); 357 await channelOpenPromise(chan); 358 // XXX test if we have a dictionary entry. Need new APIs to let me test it, 359 // or we can read dict:<origin> and look for this entry 360 361 // Note: Invalid dictionary headers still create regular cache entries, 362 // they just aren't processed as dictionaries. So all should exist in cache. 363 await new Promise(resolve => { 364 verifyDictionaryStored(url, true, resolve); 365 }); 366 } 367 }); 368 369 // Test dictionary hash calculation and validation 370 add_task(async function test_dictionary_hash_calculation() { 371 dump("**** testing hashes\n"); 372 let url = `https://localhost:${server.port()}/dict/small`; 373 let dict = TEST_DICTIONARIES.small; 374 375 // Calculate expected hash 376 let expectedHash = await calculateSHA256(dict.content); 377 Assert.greater(expectedHash.length, 0, "Hash should be calculated"); 378 379 let chan = makeChan(url); 380 await channelOpenPromise(chan); 381 382 // Calculate expected hash 383 let hashCalculatedHash = await calculateSHA256(dict.content); 384 Assert.greater(hashCalculatedHash.length, 0, "Hash should be calculated"); 385 386 // Check cache entry exists 387 await new Promise(resolve => { 388 let lci = Services.loadContextInfo.custom(false, { 389 partitionKey: `(https,localhost)`, 390 }); 391 asyncOpenCacheEntry( 392 url, 393 "disk", 394 Ci.nsICacheStorage.OPEN_READONLY, 395 lci, 396 function (status, entry) { 397 Assert.equal(status, Cr.NS_OK, "Cache entry should exist"); 398 Assert.ok(entry, "Entry should not be null"); 399 400 // Check if entry has dictionary metadata 401 try { 402 let metaData = entry.getMetaDataElement("use-as-dictionary"); 403 Assert.ok(metaData, "Dictionary metadata should exist"); 404 405 // Verify metadata contains hash information 406 // Note: The exact format may vary based on implementation 407 Assert.ok( 408 metaData.includes(dict.id), 409 "Metadata should contain dictionary ID" 410 ); 411 } catch (e) { 412 // Dictionary metadata might be stored differently 413 dump(`Dictionary metadata access failed: ${e}\n`); 414 } 415 416 resolve(); 417 } 418 ); 419 }); 420 }); 421 422 // Test dictionary expiration handling 423 add_task(async function test_dictionary_expiration() { 424 dump("**** testing expiration\n"); 425 let url = `https://localhost:${server.port()}/dict/expires`; 426 427 // Fetch dictionary with 1-second expiration 428 let chan = makeChan(url); 429 let [, data] = await channelOpenPromise(chan); 430 431 Assert.equal(data, "EXPIRING_DICTIONARY_DATA", "Dictionary content matches"); 432 433 // Note: Testing actual expiration behavior requires waiting and is complex 434 // For now, just verify the dictionary was fetched 435 // XXX FIX! 436 }); 437 438 // Test multiple dictionaries per origin with different patterns 439 add_task(async function test_multiple_dictionaries_per_origin() { 440 dump("**** test multiple dictionaries per origin\n"); 441 // Register multiple dictionary endpoints for same origin 442 await server.registerPathHandler("/dict/api", function (request, response) { 443 response.writeHead(200, { 444 "Content-Type": "application/octet-stream", 445 "Use-As-Dictionary": 'match="/api/*", id="api-dict", type=raw', 446 }); 447 response.end("API_DICTIONARY_DATA", "binary"); 448 }); 449 450 await server.registerPathHandler("/dict/web", function (request, response) { 451 response.writeHead(200, { 452 "Content-Type": "application/octet-stream", 453 "Use-As-Dictionary": 'match="/web/*", id="web-dict", type=raw', 454 }); 455 response.end("WEB_DICTIONARY_DATA", "binary"); 456 }); 457 458 let apiUrl = `https://localhost:${server.port()}/dict/api`; 459 let webUrl = `https://localhost:${server.port()}/dict/web`; 460 461 // Fetch both dictionaries 462 let apiChan = makeChan(apiUrl); 463 let [, apiData] = await channelOpenPromise(apiChan); 464 Assert.equal( 465 apiData, 466 "API_DICTIONARY_DATA", 467 "API dictionary content matches" 468 ); 469 470 let webChan = makeChan(webUrl); 471 let [, webData] = await channelOpenPromise(webChan); 472 Assert.equal( 473 webData, 474 "WEB_DICTIONARY_DATA", 475 "Web dictionary content matches" 476 ); 477 478 // Verify both dictionaries are stored 479 await new Promise(resolve => { 480 verifyDictionaryStored(apiUrl, true, () => { 481 verifyDictionaryStored(webUrl, true, resolve); 482 }); 483 }); 484 }); 485 486 // Test dictionary size limits and validation 487 add_task(async function test_dictionary_size_limits() { 488 dump("**** test size limits\n"); 489 let url = `https://localhost:${server.port()}/dict/large`; 490 let dict = TEST_DICTIONARIES.large; 491 492 let chan = makeChan(url); 493 let [, data] = await channelOpenPromise(chan); 494 495 Assert.equal(data, dict.content, "Large dictionary content matches"); 496 Assert.equal(data.length, dict.content.length, "Dictionary size correct"); 497 498 // Verify large dictionary is stored 499 await new Promise(resolve => { 500 verifyDictionaryStored(url, true, resolve); 501 }); 502 }); 503 504 // Test error handling with invalid dictionary headers 505 add_task(async function test_invalid_dictionary_headers() { 506 dump("**** test error handling\n"); 507 let url = `https://localhost:${server.port()}/dict/invalid`; 508 509 let chan = makeChan(url); 510 let [, data] = await channelOpenPromise(chan); 511 512 Assert.equal( 513 data, 514 "INVALID_DICTIONARY_DATA", 515 "Invalid dictionary content received" 516 ); 517 518 // Invalid dictionary should not be stored as dictionary 519 // but the regular cache entry should exist 520 await new Promise(resolve => { 521 asyncOpenCacheEntry( 522 url, 523 "disk", 524 Ci.nsICacheStorage.OPEN_READONLY, 525 null, 526 function (status, entry) { 527 if (status === Cr.NS_OK && entry) { 528 // Regular cache entry should exist 529 // Note: Don't call entry.close() as it doesn't exist on this interface 530 } 531 // But it should not be processed as a dictionary 532 resolve(); 533 } 534 ); 535 }); 536 }); 537 538 // Test cache integration and persistence 539 add_task(async function test_dictionary_cache_persistence() { 540 dump("**** test persistence\n"); 541 // Force cache sync to ensure everything is written 542 await new Promise(resolve => { 543 syncWithCacheIOThread(resolve, true); 544 }); 545 546 // Get cache statistics before 547 await new Promise(resolve => { 548 get_device_entry_count("disk", null, entryCount => { 549 Assert.greater(entryCount, 0, "Cache should have entries"); 550 resolve(); 551 }); 552 }); 553 554 // Verify our test dictionaries are still present 555 let smallUrl = `https://localhost:${server.port()}/dict/small`; 556 let chan = makeChan(smallUrl); 557 await channelOpenPromise(chan); 558 559 await new Promise(resolve => { 560 verifyDictionaryStored(smallUrl, true, resolve); 561 }); 562 }); 563 564 // Test very long url which should fit in metadata 565 add_task(async function test_long_dictionary_url() { 566 // Clear any existing cache 567 evict_cache_entries("all"); 568 569 let url = 570 `https://localhost:${server.port()}/dict/large/` + "A".repeat(1024 * 20); 571 let dict = TEST_DICTIONARIES.large_url; 572 573 let chan = makeChan(url); 574 let [req, data] = await channelOpenPromise(chan); 575 576 Assert.equal(data, dict.content, "Dictionary content matches"); 577 578 // Check that dictionary is stored in cache 579 await new Promise(resolve => { 580 verifyDictionaryStored(url, true, resolve); 581 }); 582 583 // Verify Use-As-Dictionary header was processed and it's an active dictionary 584 url = `https://localhost:${server.port()}/dict/large/file`; 585 chan = makeChan(url); 586 [req, data] = await channelOpenPromise(chan); 587 588 try { 589 let headerValue = req.getRequestHeader("Available-Dictionary"); 590 Assert.ok(headerValue.includes(`:`), "Header contains a hash"); 591 } catch (e) { 592 Assert.ok( 593 false, 594 "Available-Dictionary header should be present with long URL for dictionary" 595 ); 596 } 597 }); 598 599 // Test url too long to store in metadata 600 add_task(async function test_too_long_dictionary_url() { 601 // Clear any existing cache 602 evict_cache_entries("all"); 603 604 let url = 605 `https://localhost:${server.port()}/dict/large/` + "B".repeat(1024 * 100); 606 let dict = TEST_DICTIONARIES.too_large_url; 607 608 let chan = makeChan(url); 609 let [req, data] = await channelOpenPromise(chan); 610 611 Assert.equal(data, dict.content, "Dictionary content matches"); 612 613 // Check that dictionary is stored in cache (even if it's not a dictionary) 614 await new Promise(resolve => { 615 verifyDictionaryStored(url, true, resolve); 616 }); 617 618 // Verify Use-As-Dictionary header was NOT processed and active due to 64K limit to metadata 619 // Since we can't store it on disk, we can't offer it as a dictionary. If we change the 620 // metadata limit, this will need to change 621 url = `https://localhost:${server.port()}/too_large`; 622 chan = makeChan(url); 623 [req, data] = await channelOpenPromise(chan); 624 625 try { 626 // we're just looking to see if it throws 627 // eslint-disable-next-line no-unused-vars 628 let headerValue = req.getRequestHeader("Available-Dictionary"); 629 Assert.ok(false, "Too-long dictionary was offered in Available-Dictionary"); 630 } catch (e) { 631 Assert.ok( 632 true, 633 "Available-Dictionary header should not be present with a too-long URL for dictionary" 634 ); 635 } 636 }); 637 638 // Test that regexp groups cause it to not be stored as a dictionary 639 add_task(async function test_regexp_group() { 640 // Clear any existing cache 641 evict_cache_entries("all"); 642 643 let url = `https://localhost:${server.port()}/api/regexp`; 644 let dict = TEST_DICTIONARIES.regexp_group; 645 646 let chan = makeChan(url); 647 let [req, data] = await channelOpenPromise(chan); 648 649 Assert.equal(data, dict.content, "Dictionary content matches"); 650 651 // Check that dictionary is stored in cache (even if it's not a dictionary) 652 await new Promise(resolve => { 653 verifyDictionaryStored(url, true, resolve); 654 }); 655 656 // Verify Use-As-Dictionary header was NOT processed and active due to 64K limit to metadata 657 // Since we can't store it on disk, we can't offer it as a dictionary. If we change the 658 // metadata limit, this will need to change 659 url = `https://localhost:${server.port()}/api/v2/test.js`; 660 chan = makeChan(url); 661 [req, data] = await channelOpenPromise(chan); 662 663 try { 664 // we're just looking to see if it throws 665 // eslint-disable-next-line no-unused-vars 666 let headerValue = req.getRequestHeader("Available-Dictionary"); 667 Assert.ok( 668 false, 669 "Dictionary with regexp group was offered in Available-Dictionary" 670 ); 671 } catch (e) { 672 Assert.ok( 673 true, 674 "Available-Dictionary header should not be present for a dictionary with regexp groups" 675 ); 676 } 677 }); 678 679 // Cleanup 680 add_task(async function cleanup() { 681 // Clear cache 682 evict_cache_entries("all"); 683 dump("**** all done\n"); 684 });