test_search_telemetry_categorization_sync.js (20448B)
1 /* Any copyright is dedicated to the Public Domain. 2 http://creativecommons.org/publicdomain/zero/1.0/ */ 3 4 /** 5 * Tests the integration of Remote Settings with SERP domain categorization. 6 */ 7 8 "use strict"; 9 10 ChromeUtils.defineESModuleGetters(this, { 11 RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", 12 Region: "resource://gre/modules/Region.sys.mjs", 13 SERPCategorization: 14 "moz-src:///browser/components/search/SERPCategorization.sys.mjs", 15 SERPDomainToCategoriesMap: 16 "moz-src:///browser/components/search/SERPCategorization.sys.mjs", 17 TELEMETRY_CATEGORIZATION_KEY: 18 "moz-src:///browser/components/search/SERPCategorization.sys.mjs", 19 TestUtils: "resource://testing-common/TestUtils.sys.mjs", 20 }); 21 22 ChromeUtils.defineLazyGetter(this, "gCryptoHash", () => { 23 return Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash); 24 }); 25 26 function convertDomainsToHashes(domainsToCategories) { 27 let newObj = {}; 28 for (let [key, value] of Object.entries(domainsToCategories)) { 29 gCryptoHash.init(gCryptoHash.SHA256); 30 let bytes = new TextEncoder().encode(key); 31 gCryptoHash.update(bytes, key.length); 32 let hash = gCryptoHash.finish(true); 33 newObj[hash] = value; 34 } 35 return newObj; 36 } 37 38 async function waitForDomainToCategoriesUpdate() { 39 return TestUtils.topicObserved("domain-to-categories-map-update-complete"); 40 } 41 42 async function mockRecordWithCachedAttachment({ 43 id, 44 version, 45 filename, 46 mapping, 47 includeRegions, 48 excludeRegions, 49 }) { 50 // Get the bytes of the file for the hash and size for attachment metadata. 51 let buffer = new TextEncoder().encode(JSON.stringify(mapping)).buffer; 52 let stream = Cc["@mozilla.org/io/arraybuffer-input-stream;1"].createInstance( 53 Ci.nsIArrayBufferInputStream 54 ); 55 stream.setData(buffer, 0, buffer.byteLength); 56 57 // Generate a hash. 58 let hasher = Cc["@mozilla.org/security/hash;1"].createInstance( 59 Ci.nsICryptoHash 60 ); 61 hasher.init(Ci.nsICryptoHash.SHA256); 62 hasher.updateFromStream(stream, -1); 63 let hash = hasher.finish(false); 64 hash = Array.from(hash, (_, i) => 65 ("0" + hash.charCodeAt(i).toString(16)).slice(-2) 66 ).join(""); 67 68 let record = { 69 id, 70 version, 71 includeRegions, 72 excludeRegions, 73 attachment: { 74 hash, 75 location: `main-workspace/search-categorization/${filename}`, 76 filename, 77 size: buffer.byteLength, 78 mimetype: "application/json", 79 }, 80 }; 81 82 client.attachments.cacheImpl.set(id, { 83 record, 84 blob: new Blob([buffer]), 85 }); 86 87 return record; 88 } 89 90 const RECORD_A_ID = Services.uuid.generateUUID().number.slice(1, -1); 91 const RECORD_B_ID = Services.uuid.generateUUID().number.slice(1, -1); 92 const RECORD_C_ID = Services.uuid.generateUUID().number.slice(1, -1); 93 94 const client = RemoteSettings(TELEMETRY_CATEGORIZATION_KEY); 95 const db = client.db; 96 97 const RECORDS = { 98 record1a: { 99 id: RECORD_A_ID, 100 version: 1, 101 filename: "domain_category_mappings_1a.json", 102 mapping: convertDomainsToHashes({ 103 "example.com": [1, 100], 104 }), 105 includeRegions: ["US"], 106 excludeRegions: [], 107 }, 108 record1b: { 109 id: RECORD_B_ID, 110 version: 1, 111 filename: "domain_category_mappings_1b.json", 112 mapping: convertDomainsToHashes({ 113 "example.org": [2, 90], 114 }), 115 includeRegions: ["US"], 116 excludeRegions: [], 117 }, 118 record1c: { 119 id: RECORD_C_ID, 120 version: 1, 121 filename: "domain_category_mappings_1c.json", 122 mapping: convertDomainsToHashes({ 123 "example.ca": [2, 90], 124 }), 125 includeRegions: ["CA"], 126 excludeRegions: [], 127 }, 128 record2a: { 129 id: RECORD_A_ID, 130 version: 2, 131 filename: "domain_category_mappings_2a.json", 132 mapping: convertDomainsToHashes({ 133 "example.com": [1, 80], 134 }), 135 includeRegions: ["US"], 136 excludeRegions: [], 137 }, 138 record2b: { 139 id: RECORD_B_ID, 140 version: 2, 141 filename: "domain_category_mappings_2b.json", 142 mapping: convertDomainsToHashes({ 143 "example.org": [2, 50, 4, 80], 144 }), 145 includeRegions: ["US"], 146 excludeRegions: [], 147 }, 148 record2c: { 149 id: RECORD_C_ID, 150 version: 2, 151 filename: "domain_category_mappings_2c.json", 152 mapping: convertDomainsToHashes({ 153 "example.ca": [2, 75], 154 }), 155 includeRegions: ["CA"], 156 excludeRegions: [], 157 }, 158 }; 159 160 add_setup(async () => { 161 // Testing with Remote Settings requires a profile. 162 do_get_profile(); 163 await Region.init(); 164 let originalRegion = Region.home; 165 Region._setHomeRegion("US"); 166 await db.clear(); 167 registerCleanupFunction(() => { 168 Region._setHomeRegion(originalRegion); 169 }); 170 }); 171 172 add_task(async function test_initial_import() { 173 info("Create record containing domain_category_mappings_1a.json attachment."); 174 let record1a = await mockRecordWithCachedAttachment(RECORDS.record1a); 175 await db.create(record1a); 176 177 info("Create record containing domain_category_mappings_1b.json attachment."); 178 let record1b = await mockRecordWithCachedAttachment(RECORDS.record1b); 179 await db.create(record1b); 180 181 info("Add data to Remote Settings DB."); 182 await db.importChanges({}, Date.now()); 183 184 info("Initialize search categorization mappings."); 185 let promise = waitForDomainToCategoriesUpdate(); 186 await SERPDomainToCategoriesMap.init(); 187 await promise; 188 189 Assert.deepEqual( 190 await SERPDomainToCategoriesMap.get("example.com"), 191 [{ category: 1, score: 100 }], 192 "Return value from lookup of example.com should be the same." 193 ); 194 195 Assert.deepEqual( 196 await SERPDomainToCategoriesMap.get("example.org"), 197 [{ category: 2, score: 90 }], 198 "Return value from lookup of example.org should be the same." 199 ); 200 201 // Clean up. 202 await db.clear(); 203 await SERPDomainToCategoriesMap.uninit(true); 204 }); 205 206 add_task(async function test_update_records() { 207 info("Create record containing domain_category_mappings_1a.json attachment."); 208 let record1a = await mockRecordWithCachedAttachment(RECORDS.record1a); 209 await db.create(record1a); 210 211 info("Create record containing domain_category_mappings_1b.json attachment."); 212 let record1b = await mockRecordWithCachedAttachment(RECORDS.record1b); 213 await db.create(record1b); 214 215 info("Add data to Remote Settings DB."); 216 await db.importChanges({}, Date.now()); 217 218 info("Initialize search categorization mappings."); 219 let promise = waitForDomainToCategoriesUpdate(); 220 await SERPDomainToCategoriesMap.init(); 221 await promise; 222 223 info("Send update from Remote Settings with updates to attachments."); 224 let record2a = await mockRecordWithCachedAttachment(RECORDS.record2a); 225 let record2b = await mockRecordWithCachedAttachment(RECORDS.record2b); 226 const payload = { 227 current: [record2a, record2b], 228 created: [], 229 updated: [ 230 { old: record1a, new: record2a }, 231 { old: record1b, new: record2b }, 232 ], 233 deleted: [], 234 }; 235 promise = waitForDomainToCategoriesUpdate(); 236 await client.emit("sync", { 237 data: payload, 238 }); 239 await promise; 240 241 Assert.deepEqual( 242 await SERPDomainToCategoriesMap.get("example.com"), 243 [{ category: 1, score: 80 }], 244 "Return value from lookup of example.com should have changed." 245 ); 246 247 Assert.deepEqual( 248 await SERPDomainToCategoriesMap.get("example.org"), 249 [ 250 { category: 2, score: 50 }, 251 { category: 4, score: 80 }, 252 ], 253 "Return value from lookup of example.org should have changed." 254 ); 255 256 Assert.equal( 257 SERPDomainToCategoriesMap.version, 258 2, 259 "Version should be correct." 260 ); 261 262 // Clean up. 263 await db.clear(); 264 await SERPDomainToCategoriesMap.uninit(true); 265 }); 266 267 add_task(async function test_delayed_initial_import() { 268 info("Initialize search categorization mappings."); 269 let observeNoRecordsFound = TestUtils.consoleMessageObserved(msg => { 270 return ( 271 typeof msg.wrappedJSObject.arguments?.[0] == "string" && 272 msg.wrappedJSObject.arguments[0].includes( 273 "No records found for domain-to-categories map." 274 ) 275 ); 276 }); 277 info("Initialize without records."); 278 await SERPDomainToCategoriesMap.init(); 279 await observeNoRecordsFound; 280 281 Assert.ok(SERPDomainToCategoriesMap.empty, "Map is empty."); 282 283 info("Send update from Remote Settings with updates to attachments."); 284 let record1a = await mockRecordWithCachedAttachment(RECORDS.record1a); 285 let record1b = await mockRecordWithCachedAttachment(RECORDS.record1b); 286 const payload = { 287 current: [record1a, record1b], 288 created: [record1a, record1b], 289 updated: [], 290 deleted: [], 291 }; 292 let promise = waitForDomainToCategoriesUpdate(); 293 await client.emit("sync", { 294 data: payload, 295 }); 296 await promise; 297 298 Assert.deepEqual( 299 await SERPDomainToCategoriesMap.get("example.com"), 300 [{ category: 1, score: 100 }], 301 "Return value from lookup of example.com should be the same." 302 ); 303 304 Assert.deepEqual( 305 await SERPDomainToCategoriesMap.get("example.org"), 306 [{ category: 2, score: 90 }], 307 "Return value from lookup of example.org should be the same." 308 ); 309 310 Assert.equal( 311 SERPDomainToCategoriesMap.version, 312 1, 313 "Version should be correct." 314 ); 315 316 // Clean up. 317 await db.clear(); 318 await SERPDomainToCategoriesMap.uninit(true); 319 }); 320 321 add_task(async function test_remove_record() { 322 info("Create record containing domain_category_mappings_2a.json attachment."); 323 let record2a = await mockRecordWithCachedAttachment(RECORDS.record2a); 324 await db.create(record2a); 325 326 info("Create record containing domain_category_mappings_2b.json attachment."); 327 let record2b = await mockRecordWithCachedAttachment(RECORDS.record2b); 328 await db.create(record2b); 329 330 info("Add data to Remote Settings DB."); 331 await db.importChanges({}, Date.now()); 332 333 info("Initialize search categorization mappings."); 334 let promise = waitForDomainToCategoriesUpdate(); 335 await SERPDomainToCategoriesMap.init(); 336 await promise; 337 338 Assert.deepEqual( 339 await SERPDomainToCategoriesMap.get("example.com"), 340 [{ category: 1, score: 80 }], 341 "Initialized properly." 342 ); 343 344 info("Send update from Remote Settings with one removed record."); 345 const payload = { 346 current: [record2a], 347 created: [], 348 updated: [], 349 deleted: [record2b], 350 }; 351 promise = waitForDomainToCategoriesUpdate(); 352 await client.emit("sync", { 353 data: payload, 354 }); 355 await promise; 356 357 Assert.deepEqual( 358 await SERPDomainToCategoriesMap.get("example.com"), 359 [{ category: 1, score: 80 }], 360 "Return value from lookup of example.com should remain unchanged." 361 ); 362 363 Assert.deepEqual( 364 await SERPDomainToCategoriesMap.get("example.org"), 365 [], 366 "Return value from lookup of example.org should be empty." 367 ); 368 369 Assert.equal( 370 SERPDomainToCategoriesMap.version, 371 2, 372 "Version should be correct." 373 ); 374 375 // Clean up. 376 await db.clear(); 377 await SERPDomainToCategoriesMap.uninit(true); 378 }); 379 380 add_task(async function test_different_versions_coexisting() { 381 info("Create record containing domain_category_mappings_1a.json attachment."); 382 let record1a = await mockRecordWithCachedAttachment(RECORDS.record1a); 383 await db.create(record1a); 384 385 info("Create record containing domain_category_mappings_2b.json attachment."); 386 let record2b = await mockRecordWithCachedAttachment(RECORDS.record2b); 387 await db.create(record2b); 388 389 info("Add data to Remote Settings DB."); 390 await db.importChanges({}, Date.now()); 391 392 info("Initialize search categorization mappings."); 393 let promise = waitForDomainToCategoriesUpdate(); 394 await SERPDomainToCategoriesMap.init(); 395 await promise; 396 397 Assert.deepEqual( 398 await SERPDomainToCategoriesMap.get("example.com"), 399 [ 400 { 401 category: 1, 402 score: 100, 403 }, 404 ], 405 "Should have a record from an older version." 406 ); 407 408 Assert.deepEqual( 409 await SERPDomainToCategoriesMap.get("example.org"), 410 [ 411 { category: 2, score: 50 }, 412 { category: 4, score: 80 }, 413 ], 414 "Return value from lookup of example.org should have the most recent value." 415 ); 416 417 Assert.equal( 418 SERPDomainToCategoriesMap.version, 419 2, 420 "Version should be the latest." 421 ); 422 423 // Clean up. 424 await db.clear(); 425 await SERPDomainToCategoriesMap.uninit(true); 426 }); 427 428 add_task(async function test_download_error() { 429 info("Create record containing domain_category_mappings_1a.json attachment."); 430 let record1a = await mockRecordWithCachedAttachment(RECORDS.record1a); 431 await db.create(record1a); 432 433 info("Add data to Remote Settings DB."); 434 await db.importChanges({}, Date.now()); 435 436 info("Initialize search categorization mappings."); 437 let promise = waitForDomainToCategoriesUpdate(); 438 await SERPDomainToCategoriesMap.init(); 439 await promise; 440 441 Assert.deepEqual( 442 await SERPDomainToCategoriesMap.get("example.com"), 443 [ 444 { 445 category: 1, 446 score: 100, 447 }, 448 ], 449 "Domain should have an entry in the map." 450 ); 451 452 Assert.equal( 453 SERPDomainToCategoriesMap.version, 454 1, 455 "Version should be present." 456 ); 457 458 info("Delete attachment from local cache."); 459 client.attachments.cacheImpl.delete(RECORD_A_ID); 460 461 const payload = { 462 current: [record1a], 463 created: [], 464 updated: [{ old: record1a, new: record1a }], 465 deleted: [], 466 }; 467 468 info("Sync payload."); 469 let observeDownloadError = TestUtils.consoleMessageObserved(msg => { 470 return ( 471 typeof msg.wrappedJSObject.arguments?.[0] == "string" && 472 msg.wrappedJSObject.arguments[0].includes("Could not download file:") 473 ); 474 }); 475 await client.emit("sync", { 476 data: payload, 477 }); 478 await observeDownloadError; 479 480 Assert.deepEqual( 481 await SERPDomainToCategoriesMap.get("example.com"), 482 [], 483 "Domain should not exist in store." 484 ); 485 486 Assert.equal( 487 SERPDomainToCategoriesMap.version, 488 null, 489 "Version should remain null." 490 ); 491 492 // Clean up. 493 await db.clear(); 494 await SERPDomainToCategoriesMap.uninit(true); 495 }); 496 497 add_task(async function test_mock_restart() { 498 info("Create record containing domain_category_mappings_2a.json attachment."); 499 let record2a = await mockRecordWithCachedAttachment(RECORDS.record2a); 500 await db.create(record2a); 501 502 info("Create record containing domain_category_mappings_2b.json attachment."); 503 let record2b = await mockRecordWithCachedAttachment(RECORDS.record2b); 504 await db.create(record2b); 505 506 info("Add data to Remote Settings DB."); 507 await db.importChanges({}, Date.now()); 508 509 info("Initialize search categorization mappings."); 510 let promise = waitForDomainToCategoriesUpdate(); 511 await SERPCategorization.init(); 512 await promise; 513 514 Assert.deepEqual( 515 await SERPDomainToCategoriesMap.get("example.com"), 516 [ 517 { 518 category: 1, 519 score: 80, 520 }, 521 ], 522 "Should have a record." 523 ); 524 525 Assert.equal( 526 SERPDomainToCategoriesMap.version, 527 2, 528 "Version should be the latest." 529 ); 530 531 info("Mock a restart by un-initializing the map."); 532 await SERPCategorization.uninit(); 533 promise = waitForDomainToCategoriesUpdate(); 534 await SERPCategorization.init(); 535 await promise; 536 537 Assert.deepEqual( 538 await SERPDomainToCategoriesMap.get("example.com"), 539 [ 540 { 541 category: 1, 542 score: 80, 543 }, 544 ], 545 "Should have a record." 546 ); 547 548 Assert.equal( 549 SERPDomainToCategoriesMap.version, 550 2, 551 "Version should be the latest." 552 ); 553 554 // Clean up. 555 await db.clear(); 556 await SERPDomainToCategoriesMap.uninit(true); 557 }); 558 559 add_task(async function update_record_from_non_matching_region() { 560 info("Create record containing domain_category_mappings_1a.json attachment."); 561 let record1a = await mockRecordWithCachedAttachment(RECORDS.record1a); 562 await db.create(record1a); 563 564 info("Add data to Remote Settings DB."); 565 await db.importChanges({}, Date.now()); 566 567 info("Initialize search categorization mappings."); 568 let promise = waitForDomainToCategoriesUpdate(); 569 await SERPDomainToCategoriesMap.init(); 570 await promise; 571 572 Assert.deepEqual( 573 await SERPDomainToCategoriesMap.get("example.com"), 574 [{ category: 1, score: 100 }], 575 "Return value from lookup of example.com should exist." 576 ); 577 578 info( 579 "Send update from Remote Settings with a record that doesn't match the home region." 580 ); 581 let record1c = await mockRecordWithCachedAttachment(RECORDS.record1c); 582 const payload = { 583 current: [record1a, record1c], 584 created: [record1c], 585 updated: [], 586 deleted: [], 587 }; 588 589 let observeNoChange = TestUtils.consoleMessageObserved(msg => { 590 return ( 591 typeof msg.wrappedJSObject.arguments?.[0] == "string" && 592 msg.wrappedJSObject.arguments[0].includes( 593 "Domain-to-category records had no changes that matched the region." 594 ) 595 ); 596 }); 597 await client.emit("sync", { data: payload }); 598 await observeNoChange; 599 600 Assert.deepEqual( 601 await SERPDomainToCategoriesMap.get("example.com"), 602 [{ category: 1, score: 100 }], 603 "Return value from lookup of example.com should still exist." 604 ); 605 606 Assert.deepEqual( 607 await SERPDomainToCategoriesMap.get("example.ca"), 608 [], 609 "Domain from non-home region should not exist." 610 ); 611 612 Assert.equal( 613 SERPDomainToCategoriesMap.version, 614 1, 615 "Version should be remain the same." 616 ); 617 618 // Clean up. 619 await db.clear(); 620 await SERPDomainToCategoriesMap.uninit(true); 621 }); 622 623 add_task(async function update_record_from_non_matching_region() { 624 info("Create record containing domain_category_mappings_1a.json attachment."); 625 let record1a = await mockRecordWithCachedAttachment(RECORDS.record1a); 626 await db.create(record1a); 627 628 info("Add data to Remote Settings DB."); 629 await db.importChanges({}, Date.now()); 630 631 info("Initialize search categorization mappings."); 632 let promise = waitForDomainToCategoriesUpdate(); 633 await SERPDomainToCategoriesMap.init(); 634 await promise; 635 636 Assert.deepEqual( 637 await SERPDomainToCategoriesMap.get("example.com"), 638 [{ category: 1, score: 100 }], 639 "Return value from lookup of example.com should exist." 640 ); 641 642 info( 643 "Send update from Remote Settings with a record that doesn't match the home region." 644 ); 645 let record1c = await mockRecordWithCachedAttachment(RECORDS.record1c); 646 const payload = { 647 current: [record1a, record1c], 648 created: [record1c], 649 updated: [], 650 deleted: [], 651 }; 652 653 let observeNoChange = TestUtils.consoleMessageObserved(msg => { 654 return ( 655 typeof msg.wrappedJSObject.arguments?.[0] == "string" && 656 msg.wrappedJSObject.arguments[0].includes( 657 "Domain-to-category records had no changes that matched the region." 658 ) 659 ); 660 }); 661 await client.emit("sync", { data: payload }); 662 await observeNoChange; 663 664 Assert.deepEqual( 665 await SERPDomainToCategoriesMap.get("example.com"), 666 [{ category: 1, score: 100 }], 667 "Return value from lookup of example.com should still exist." 668 ); 669 670 Assert.deepEqual( 671 await SERPDomainToCategoriesMap.get("example.ca"), 672 [], 673 "Domain from non-home region should not exist." 674 ); 675 676 Assert.equal( 677 SERPDomainToCategoriesMap.version, 678 1, 679 "Version should be remain the same." 680 ); 681 682 // Clean up. 683 await db.clear(); 684 await SERPDomainToCategoriesMap.uninit(true); 685 }); 686 687 add_task(async function update_() { 688 info("Create record containing domain_category_mappings_1a.json attachment."); 689 let record1a = await mockRecordWithCachedAttachment(RECORDS.record1a); 690 await db.create(record1a); 691 692 info("Add data to Remote Settings DB."); 693 await db.importChanges({}, Date.now()); 694 695 info("Initialize search categorization mappings."); 696 let promise = waitForDomainToCategoriesUpdate(); 697 await SERPDomainToCategoriesMap.init(); 698 await promise; 699 700 Assert.deepEqual( 701 await SERPDomainToCategoriesMap.get("example.com"), 702 [{ category: 1, score: 100 }], 703 "Return value from lookup of example.com should exist." 704 ); 705 706 // Re-init the Map to mimic a restart. 707 await SERPDomainToCategoriesMap.uninit(); 708 709 info("Change home region to one that doesn't match region of map."); 710 let originalHomeRegion = Region.home; 711 Region._setHomeRegion("DE"); 712 713 let observeDropStore = TestUtils.consoleMessageObserved(msg => { 714 return ( 715 typeof msg.wrappedJSObject.arguments?.[0] == "string" && 716 msg.wrappedJSObject.arguments[0].includes( 717 "Drop store because it no longer matches the home region." 718 ) 719 ); 720 }); 721 722 await SERPDomainToCategoriesMap.init(); 723 await observeDropStore; 724 Assert.deepEqual( 725 await SERPDomainToCategoriesMap.get("example.com"), 726 [], 727 "Return value from lookup of example.com should be empty." 728 ); 729 730 // Clean up. 731 await db.clear(); 732 Region._setHomeRegion(originalHomeRegion); 733 await SERPDomainToCategoriesMap.uninit(true); 734 });