test_dictionary_retrieval.js (15738B)
1 /** 2 * Tests for HTTP Compression Dictionary retrieval functionality 3 * - Dictionary lookup by origin and pattern matching 4 * - Available-Dictionary header generation and formatting 5 * - Dictionary cache hit/miss scenarios 6 * - Dictionary precedence and selection logic 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 dictionaries with different patterns and priorities 19 const RETRIEVAL_TEST_DICTIONARIES = { 20 api_v1: { 21 id: "api-v1-dict", 22 content: "API_V1_COMMON_DATA", 23 pattern: "/api/v1/*", 24 type: "raw", 25 }, 26 api_generic: { 27 id: "api-generic-dict", 28 content: "API_GENERIC_DATA", 29 pattern: "/api/*", 30 type: "raw", 31 }, 32 wildcard: { 33 id: "wildcard-dict", 34 content: "WILDCARD_DATA", 35 pattern: "*", 36 type: "raw", 37 }, 38 js_files: { 39 id: "js-dict", 40 content: "JS_COMMON_CODE", 41 pattern: "*.js", 42 type: "raw", 43 }, 44 }; 45 46 let server = null; 47 let requestLog = []; // Track requests for verification 48 49 async function sync_to_server() { 50 if (server.processId) { 51 await server.execute(`global.requestLog = ${JSON.stringify(requestLog)};`); 52 } else { 53 dump("Server not running?\n"); 54 } 55 } 56 57 async function sync_from_server() { 58 if (server.processId) { 59 requestLog = await server.execute(`global.requestLog`); 60 } else { 61 dump("Server not running? (from)\n"); 62 } 63 } 64 65 add_setup(async function () { 66 Services.prefs.setBoolPref("network.http.dictionaries.enable", true); 67 if (!server) { 68 server = await setupServer(); 69 } 70 // Setup baseline dictionaries for compression testing 71 72 // Clear any existing cache 73 let lci = Services.loadContextInfo.custom(false, { 74 partitionKey: `(https,localhost)`, 75 }); 76 evict_cache_entries("all", lci); 77 }); 78 79 // Calculate expected SHA-256 hash for dictionary content 80 async function calculateDictionaryHash(content) { 81 let hasher = Cc["@mozilla.org/security/hash;1"].createInstance( 82 Ci.nsICryptoHash 83 ); 84 hasher.init(Ci.nsICryptoHash.SHA256); 85 let bytes = new TextEncoder().encode(content); 86 hasher.update(bytes, bytes.length); 87 let hash = hasher.finish(false); 88 return btoa(hash); // Convert to base64 89 } 90 91 // Setup dictionary test server 92 async function setupServer() { 93 if (!server) { 94 server = new NodeHTTPSServer(); 95 await server.start(); 96 97 registerCleanupFunction(async () => { 98 try { 99 await server.stop(); 100 } catch (e) { 101 // Ignore server stop errors during cleanup 102 } 103 }); 104 } 105 return server; 106 } 107 108 // Create channel for dictionary requests 109 function makeChan(url) { 110 let chan = NetUtil.newChannel({ 111 uri: url, 112 loadUsingSystemPrincipal: true, 113 contentPolicyType: Ci.nsIContentPolicy.TYPE_DOCUMENT, 114 }).QueryInterface(Ci.nsIHttpChannel); 115 return chan; 116 } 117 118 function channelOpenPromise(chan) { 119 return new Promise(resolve => { 120 function finish(req, buffer) { 121 resolve([req, buffer]); 122 } 123 chan.asyncOpen(new ChannelListener(finish, null, CL_ALLOW_UNKNOWN_CL)); 124 }); 125 } 126 127 // Verify dictionary is stored in cache 128 function verifyDictionaryStored(url, shouldExist, callback) { 129 let lci = Services.loadContextInfo.custom(false, { 130 partitionKey: `(https,localhost)`, 131 }); 132 asyncCheckCacheEntryPresence(url, "disk", shouldExist, lci, callback); 133 } 134 135 // Setup server endpoint that expects specific dictionary headers 136 async function registerDictionaryAwareEndpoint( 137 httpServer, 138 path, 139 responseContent 140 ) { 141 // We have to put all values and functions referenced in the handler into 142 // this string which will be turned into a function for the handler, because 143 // NodeHTTPSServer handlers can't access items in the local or global scopes of the 144 // containing file 145 let func = ` 146 // Log the request for analysis 147 global.requestLog[global.requestLog.length] = { 148 path: "${path}", 149 hasAvailableDict: request.headers['available-dictionary'] !== undefined, 150 availableDict: request.headers['available-dictionary'] || null 151 }; 152 153 response.writeHead(200, { 154 "Content-Type": "text/plain", 155 }); 156 response.end("${responseContent}", "binary"); 157 `; 158 let handler = new Function("request", "response", func); 159 return httpServer.registerPathHandler(path, handler); 160 } 161 162 // Setup retrieval test server with dictionaries and resources 163 async function setupRetrievalTestServer() { 164 await setupServer(); 165 166 // Dictionary endpoints - store dictionaries with different patterns 167 await server.registerPathHandler( 168 "/dict/api-v1", 169 function (request, response) { 170 const RETRIEVAL_TEST_DICTIONARIES = { 171 api_v1: { 172 id: "api-v1-dict", 173 content: "API_V1_COMMON_DATA", 174 pattern: "/api/v1/*", 175 type: "raw", 176 }, 177 }; 178 let dict = RETRIEVAL_TEST_DICTIONARIES.api_v1; 179 response.writeHead(200, { 180 "Content-Type": "application/octet-stream", 181 "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, 182 "Cache-Control": "max-age=3600", 183 }); 184 response.end(dict.content, "binary"); 185 } 186 ); 187 188 await server.registerPathHandler( 189 "/dict/api-generic", 190 function (request, response) { 191 const RETRIEVAL_TEST_DICTIONARIES = { 192 api_generic: { 193 id: "api-generic-dict", 194 content: "API_GENERIC_DATA", 195 pattern: "/api/*", 196 type: "raw", 197 }, 198 }; 199 let dict = RETRIEVAL_TEST_DICTIONARIES.api_generic; 200 response.writeHead(200, { 201 "Content-Type": "application/octet-stream", 202 "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, 203 "Cache-Control": "max-age=3600", 204 }); 205 response.end(dict.content, "binary"); 206 } 207 ); 208 209 await server.registerPathHandler("/wildcard", function (request, response) { 210 const RETRIEVAL_TEST_DICTIONARIES = { 211 wildcard: { 212 id: "wildcard-dict", 213 content: "WILDCARD_DATA", 214 pattern: "*", 215 type: "raw", 216 }, 217 }; 218 let dict = RETRIEVAL_TEST_DICTIONARIES.wildcard; 219 response.writeHead(200, { 220 "Content-Type": "application/octet-stream", 221 "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, 222 "Cache-Control": "max-age=3600", 223 }); 224 response.end(dict.content, "binary"); 225 }); 226 227 await server.registerPathHandler("/js", function (request, response) { 228 const RETRIEVAL_TEST_DICTIONARIES = { 229 js_files: { 230 id: "js-dict", 231 content: "JS_COMMON_CODE", 232 pattern: "*.js", 233 type: "raw", 234 }, 235 }; 236 let dict = RETRIEVAL_TEST_DICTIONARIES.js_files; 237 response.writeHead(200, { 238 "Content-Type": "application/octet-stream", 239 "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, 240 "Cache-Control": "max-age=3600", 241 }); 242 response.end(dict.content, "binary"); 243 }); 244 245 // Resource endpoints that should trigger dictionary retrieval 246 await registerDictionaryAwareEndpoint( 247 server, 248 "/api/v1/users", 249 "API V1 USERS DATA" 250 ); 251 await registerDictionaryAwareEndpoint( 252 server, 253 "/api/v2/posts", 254 "API V2 POSTS DATA" 255 ); 256 await registerDictionaryAwareEndpoint( 257 server, 258 "/api/generic", 259 "GENERIC API DATA" 260 ); 261 await registerDictionaryAwareEndpoint(server, "/web/page", "WEB PAGE DATA"); 262 await registerDictionaryAwareEndpoint( 263 server, 264 "/scripts/app.js", 265 "JAVASCRIPT CODE" 266 ); 267 await registerDictionaryAwareEndpoint( 268 server, 269 "/styles/main.css", 270 "CSS STYLES" 271 ); 272 273 return server; 274 } 275 276 // Setup baseline dictionaries for retrieval testing 277 add_task(async function test_setup_dictionaries() { 278 await setupRetrievalTestServer(); 279 280 // Clear any existing cache 281 let lci = Services.loadContextInfo.custom(false, { 282 partitionKey: `(https,localhost)`, 283 }); 284 evict_cache_entries("all", lci); 285 requestLog = []; 286 await sync_to_server(); 287 288 // Store all test dictionaries 289 const dictPaths = ["/dict/api-v1", "/dict/api-generic", "/wildcard", "/js"]; 290 291 for (let path of dictPaths) { 292 let url = `https://localhost:${server.port()}${path}`; 293 294 let chan = makeChan(url); 295 let [, data] = await channelOpenPromise(chan); 296 dump(`**** Dictionary loaded: ${path}, data length: ${data.length}\n`); 297 298 // Verify dictionary was stored 299 await new Promise(resolve => { 300 verifyDictionaryStored(url, true, resolve); 301 }); 302 } 303 dump("**** Setup complete\n"); 304 }); 305 306 // Test basic dictionary lookup and Available-Dictionary header generation 307 add_task(async function test_basic_dictionary_retrieval() { 308 let url = `https://localhost:${server.port()}/api/v1/users`; 309 requestLog = []; 310 await sync_to_server(); 311 312 // Calculate expected hash for api_v1 dictionary 313 let expectedHash = await calculateDictionaryHash( 314 RETRIEVAL_TEST_DICTIONARIES.api_v1.content 315 ); 316 317 let chan = makeChan(url); 318 let [, data] = await channelOpenPromise(chan); 319 320 Assert.equal(data, "API V1 USERS DATA", "Resource content matches"); 321 322 // Check request log to see if Available-Dictionary header was sent 323 await sync_from_server(); 324 let logEntry = requestLog.find(entry => entry.path === "/api/v1/users"); 325 Assert.ok(logEntry && logEntry.hasAvailableDict, "Has Available-Dictionary"); 326 Assert.ok( 327 logEntry.availableDict.includes(expectedHash), 328 "Available-Dictionary header should contain expected hash" 329 ); 330 dump("**** Basic retrieval test complete\n"); 331 }); 332 333 // Test URL pattern matching logic for dictionary selection 334 add_task(async function test_dictionary_pattern_matching() { 335 const patternMatchTests = [ 336 { url: "/api/v1/users", expectedPattern: "/api/v1/*", dictKey: "api_v1" }, 337 { url: "/api/v2/posts", expectedPattern: "/api/*", dictKey: "api_generic" }, 338 { url: "/api/generic", expectedPattern: "/api/*", dictKey: "api_generic" }, 339 { url: "/scripts/app.js", expectedPattern: "*.js", dictKey: "js_files" }, 340 { url: "/web/page", expectedPattern: "*", dictKey: "wildcard" }, // Only wildcard should match 341 { url: "/styles/main.css", expectedPattern: "*", dictKey: "wildcard" }, 342 ]; 343 344 requestLog = []; 345 await sync_to_server(); 346 347 for (let test of patternMatchTests) { 348 let url = `https://localhost:${server.port()}${test.url}`; 349 let expectedDict = RETRIEVAL_TEST_DICTIONARIES[test.dictKey]; 350 let expectedHash = await calculateDictionaryHash(expectedDict.content); 351 352 let chan = makeChan(url); 353 let [, data] = await channelOpenPromise(chan); 354 355 Assert.greater(data.length, 0, `Resource ${test.url} should have content`); 356 357 // Check request log 358 await sync_from_server(); 359 let logEntry = requestLog.find(entry => entry.path === test.url); 360 Assert.ok( 361 logEntry && logEntry.hasAvailableDict, 362 `Available-Dictionary header should be present for ${test.url}` 363 ); 364 if (logEntry && logEntry.hasAvailableDict) { 365 Assert.ok( 366 logEntry.availableDict.includes(expectedHash), 367 `Available-Dictionary header should contain expected hash for ${test.url}` 368 ); 369 } 370 } 371 }); 372 373 // Test dictionary precedence when multiple patterns match 374 add_task(async function test_dictionary_precedence() { 375 // Test URL that matches multiple patterns: /api/v1/users 376 // Should match: "/api/v1/*" (most specific), "/api/*", "*" (wildcard) 377 // Most specific pattern should take precedence 378 379 let url = `https://localhost:${server.port()}/api/v1/users`; 380 requestLog = []; 381 await sync_to_server(); 382 383 let mostSpecificHash = await calculateDictionaryHash( 384 RETRIEVAL_TEST_DICTIONARIES.api_v1.content 385 ); 386 387 let chan = makeChan(url); 388 let [, data] = await channelOpenPromise(chan); 389 390 Assert.equal(data, "API V1 USERS DATA", "Content should match"); 391 392 // Check request log for precedence 393 await sync_from_server(); 394 let logEntry = requestLog.find(entry => entry.path === "/api/v1/users"); 395 Assert.ok( 396 logEntry && logEntry.hasAvailableDict, 397 "Available-Dictionary header should be present for precedence test" 398 ); 399 if (logEntry && logEntry.hasAvailableDict) { 400 // The most specific pattern (/api/v1/*) should be included 401 // Implementation may include multiple matching dictionaries 402 Assert.ok( 403 logEntry.availableDict.includes(mostSpecificHash), 404 "Available-Dictionary header should contain most specific pattern hash" 405 ); 406 } 407 }); 408 409 // Test successful dictionary lookup and usage 410 add_task(async function test_dictionary_cache_hit() { 411 let url = `https://localhost:${server.port()}/api/generic`; 412 requestLog = []; 413 await sync_to_server(); 414 415 let expectedHash = await calculateDictionaryHash( 416 RETRIEVAL_TEST_DICTIONARIES.api_generic.content 417 ); 418 419 let chan = makeChan(url); 420 let [, data] = await channelOpenPromise(chan); 421 422 Assert.equal(data, "GENERIC API DATA", "Content should match"); 423 424 // Verify dictionary lookup succeeded 425 await sync_from_server(); 426 let logEntry = requestLog.find(entry => entry.path === "/api/generic"); 427 Assert.ok( 428 logEntry && logEntry.hasAvailableDict, 429 "Available-Dictionary header should be present for cache hit" 430 ); 431 if (logEntry && logEntry.hasAvailableDict) { 432 Assert.ok( 433 logEntry.availableDict.includes(expectedHash), 434 "Available-Dictionary header should contain expected hash for cache hit" 435 ); 436 } 437 }); 438 439 // Test Available-Dictionary header hash format compliance 440 add_task(async function test_dictionary_hash_format() { 441 // Test that dictionary hashes follow IETF spec format: :base64hash: 442 443 let testDict = RETRIEVAL_TEST_DICTIONARIES.api_v1; 444 let calculatedHash = await calculateDictionaryHash(testDict.content); 445 446 // Verify hash is base64 format 447 Assert.greater(calculatedHash.length, 0, "Hash should not be empty"); 448 449 // Verify base64 pattern (rough check) 450 let base64Pattern = /^[A-Za-z0-9+/]*={0,2}$/; 451 Assert.ok(base64Pattern.test(calculatedHash), "Hash should be valid base64"); 452 453 // The hash format should be structured field byte sequence: :base64: 454 let structuredFieldFormat = `:${calculatedHash}:`; 455 Assert.ok( 456 structuredFieldFormat.includes(calculatedHash), 457 "Hash should follow structured field format" 458 ); 459 }); 460 461 // Test retrieval with multiple dictionary matches 462 add_task(async function test_multiple_dictionary_matches() { 463 // Create a request that could match multiple dictionaries 464 let url = `https://localhost:${server.port()}/api/test`; 465 requestLog = []; 466 await sync_to_server(); 467 468 await registerDictionaryAwareEndpoint(server, "/api/test", "API TEST DATA"); 469 470 let apiGenericHash = await calculateDictionaryHash( 471 RETRIEVAL_TEST_DICTIONARIES.api_generic.content 472 ); 473 let chan = makeChan(url); 474 let [, data] = await channelOpenPromise(chan); 475 476 Assert.equal(data, "API TEST DATA", "Content should match"); 477 478 // Check for multiple dictionary hashes in Available-Dictionary header 479 await sync_from_server(); 480 let logEntry = requestLog.find(entry => entry.path === "/api/test"); 481 Assert.ok( 482 logEntry && logEntry.hasAvailableDict, 483 "Available-Dictionary header should be present for multiple matches" 484 ); 485 if (logEntry && logEntry.hasAvailableDict) { 486 // Could match both /api/* and * patterns - verify the longest pattern's hash is present 487 // (IETF spec says the longest match should be used) 488 let hasApiGenericHash = logEntry.availableDict.includes(apiGenericHash); 489 Assert.ok( 490 hasApiGenericHash, 491 "Available-Dictionary header should contain at least one expected hash for multiple matches" 492 ); 493 } 494 }); 495 496 // Cleanup 497 add_task(async function cleanup() { 498 // Clear cache 499 let lci = Services.loadContextInfo.custom(false, { 500 partitionKey: `(https,localhost)`, 501 }); 502 evict_cache_entries("all", lci); 503 });