test_NewTabAttributionService.js (16840B)
1 /* Any copyright is dedicated to the Public Domain. 2 https://creativecommons.org/publicdomain/zero/1.0/ */ 3 4 "use strict"; 5 6 ChromeUtils.defineESModuleGetters(this, { 7 NewTabAttributionServiceClass: 8 "resource://newtab/lib/NewTabAttributionService.sys.mjs", 9 ObliviousHTTP: "resource://gre/modules/ObliviousHTTP.sys.mjs", 10 AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs", 11 sinon: "resource://testing-common/Sinon.sys.mjs", 12 }); 13 14 const { HttpServer } = ChromeUtils.importESModule( 15 "resource://testing-common/httpd.sys.mjs" 16 ); 17 18 const BinaryInputStream = Components.Constructor( 19 "@mozilla.org/binaryinputstream;1", 20 "nsIBinaryInputStream", 21 "setInputStream" 22 ); 23 24 const PREF_LEADER = "toolkit.telemetry.dap.leader.url"; 25 const PREF_HELPER = "toolkit.telemetry.dap.helper.url"; 26 const TASK_ID = "DSZGMFh26hBYXNaKvhL_N4AHA3P5lDn19on1vFPBxJM"; 27 const MAX_CONVERSIONS = 2; 28 const DAY_IN_MILLI = 1000 * 60 * 60 * 24; 29 const LOOKBACK_DAYS = 1; 30 const MAX_LOOKBACK_DAYS = 30; 31 const HISTOGRAM_SIZE = 5; 32 33 class MockDateProvider { 34 constructor() { 35 this._now = Date.now(); 36 } 37 38 now() { 39 return this._now; 40 } 41 42 add(interval_ms) { 43 this._now += interval_ms; 44 } 45 } 46 47 class MockDAPSender { 48 constructor() { 49 this.receivedMeasurements = []; 50 } 51 52 async sendDAPMeasurement(task, measurement, options) { 53 this.receivedMeasurements.push({ 54 task, 55 measurement, 56 options, 57 }); 58 } 59 } 60 61 class MockServer { 62 constructor() { 63 this.receivedReports = []; 64 65 const server = new HttpServer(); 66 67 server.registerPrefixHandler( 68 "/leader_endpoint/tasks/", 69 this.uploadHandler.bind(this) 70 ); 71 72 this._server = server; 73 } 74 75 start() { 76 this._server.start(-1); 77 78 this.orig_leader = Services.prefs.getStringPref(PREF_LEADER); 79 this.orig_helper = Services.prefs.getStringPref(PREF_HELPER); 80 81 const i = this._server.identity; 82 const serverAddr = `${i.primaryScheme}://${i.primaryHost}:${i.primaryPort}`; 83 Services.prefs.setStringPref(PREF_LEADER, `${serverAddr}/leader_endpoint`); 84 Services.prefs.setStringPref(PREF_HELPER, `${serverAddr}/helper_endpoint`); 85 } 86 87 async stop() { 88 Services.prefs.setStringPref(PREF_LEADER, this.orig_leader); 89 Services.prefs.setStringPref(PREF_HELPER, this.orig_helper); 90 91 await this._server.stop(); 92 } 93 94 uploadHandler(request, response) { 95 let body = new BinaryInputStream(request.bodyInputStream); 96 97 this.receivedReports.push({ 98 contentType: request.getHeader("Content-Type"), 99 size: body.available(), 100 }); 101 102 response.setStatusLine(request.httpVersion, 200); 103 } 104 } 105 106 let globalSandbox; 107 108 add_setup(async function () { 109 do_get_profile(); 110 Services.prefs.setStringPref( 111 "browser.newtabpage.activity-stream.unifiedAds.endpoint", 112 "https://test.example.com" 113 ); 114 Services.prefs.setStringPref( 115 "browser.newtabpage.activity-stream.discoverystream.ohttp.configURL", 116 "https://test.example.com/config" 117 ); 118 Services.prefs.setStringPref( 119 "browser.newtabpage.activity-stream.discoverystream.ohttp.relayURL", 120 "https://test.example.com/relay" 121 ); 122 123 globalSandbox = sinon.createSandbox(); 124 globalSandbox.stub(ObliviousHTTP, "getOHTTPConfig").resolves({}); 125 globalSandbox.stub(ObliviousHTTP, "ohttpRequest").resolves({ 126 status: 200, 127 json: () => { 128 return Promise.resolve({ 129 task_id: TASK_ID, 130 vdaf: "histogram", 131 bits: 1, 132 length: HISTOGRAM_SIZE, 133 time_precision: 60, 134 default_measurement: 0, 135 }); 136 }, 137 }); 138 139 const mockStore = { 140 getState: () => ({ 141 Prefs: { 142 values: { 143 trainhopConfig: { 144 attribution: {}, 145 }, 146 }, 147 }, 148 }), 149 }; 150 151 globalSandbox.stub(AboutNewTab, "activityStream").value({ 152 store: mockStore, 153 }); 154 }); 155 156 registerCleanupFunction(() => { 157 Services.prefs.clearUserPref( 158 "browser.newtabpage.activity-stream.unifiedAds.endpoint" 159 ); 160 Services.prefs.clearUserPref( 161 "browser.newtabpage.activity-stream.discoverystream.ohttp.configURL" 162 ); 163 Services.prefs.clearUserPref( 164 "browser.newtabpage.activity-stream.discoverystream.ohttp.relayURL" 165 ); 166 167 globalSandbox.restore(); 168 }); 169 170 add_task(async function testSuccessfulConversion() { 171 const mockSender = new MockDAPSender(); 172 const privateAttribution = new NewTabAttributionServiceClass({ 173 dapSender: mockSender, 174 }); 175 176 const partnerIdentifier = "partner_identifier"; 177 const index = 1; 178 179 await privateAttribution.onAttributionEvent("view", { 180 partner_id: partnerIdentifier, 181 index, 182 }); 183 184 await privateAttribution.onAttributionEvent("click", { 185 partner_id: partnerIdentifier, 186 index, 187 }); 188 189 await privateAttribution.onAttributionConversion( 190 partnerIdentifier, 191 LOOKBACK_DAYS, 192 "view" 193 ); 194 195 const receivedMeasurement = mockSender.receivedMeasurements.pop(); 196 Assert.deepEqual(receivedMeasurement.task, { 197 task_id: TASK_ID, 198 id: TASK_ID, 199 vdaf: "histogram", 200 bits: 1, 201 length: HISTOGRAM_SIZE, 202 time_precision: 60, 203 default_measurement: 0, 204 }); 205 Assert.equal(receivedMeasurement.measurement, index); 206 Assert.ok(receivedMeasurement.options.ohttp_hpke); 207 Assert.equal(receivedMeasurement.options.ohttp_hpke.length, 41); 208 Assert.equal( 209 receivedMeasurement.options.ohttp_relay, 210 Services.prefs.getStringPref("dap.ohttp.relayURL") 211 ); 212 Assert.equal(mockSender.receivedMeasurements.length, 0); 213 }); 214 215 add_task(async function testZeroIndex() { 216 const mockSender = new MockDAPSender(); 217 const privateAttribution = new NewTabAttributionServiceClass({ 218 dapSender: mockSender, 219 }); 220 221 const partnerIdentifier = "partner_identifier_zero"; 222 const index = 0; 223 224 await privateAttribution.onAttributionEvent("view", { 225 partner_id: partnerIdentifier, 226 index, 227 }); 228 229 await privateAttribution.onAttributionConversion( 230 partnerIdentifier, 231 LOOKBACK_DAYS, 232 "view" 233 ); 234 235 const receivedMeasurement = mockSender.receivedMeasurements.pop(); 236 Assert.equal(receivedMeasurement.measurement, index); 237 Assert.equal(mockSender.receivedMeasurements.length, 0); 238 }); 239 240 add_task(async function testConversionWithoutImpression() { 241 const mockSender = new MockDAPSender(); 242 const privateAttribution = new NewTabAttributionServiceClass({ 243 dapSender: mockSender, 244 }); 245 246 const partnerIdentifier = "partner_identifier_no_impression"; 247 248 await privateAttribution.onAttributionConversion( 249 partnerIdentifier, 250 LOOKBACK_DAYS, 251 "view" 252 ); 253 254 const receivedMeasurement = mockSender.receivedMeasurements.pop(); 255 Assert.deepEqual(receivedMeasurement.task, { 256 task_id: TASK_ID, 257 id: TASK_ID, 258 vdaf: "histogram", 259 bits: 1, 260 length: HISTOGRAM_SIZE, 261 time_precision: 60, 262 default_measurement: 0, 263 }); 264 Assert.equal(receivedMeasurement.measurement, 0); 265 Assert.equal(mockSender.receivedMeasurements.length, 0); 266 }); 267 268 add_task(async function testConversionWithInvalidLookbackDays() { 269 const mockSender = new MockDAPSender(); 270 const privateAttribution = new NewTabAttributionServiceClass({ 271 dapSender: mockSender, 272 }); 273 274 const partnerIdentifier = "partner_identifier"; 275 const index = 1; 276 277 await privateAttribution.onAttributionEvent("view", { 278 partner_id: partnerIdentifier, 279 index, 280 }); 281 282 await privateAttribution.onAttributionConversion( 283 partnerIdentifier, 284 MAX_LOOKBACK_DAYS + 1, 285 "view" 286 ); 287 288 Assert.equal(mockSender.receivedMeasurements.length, 0); 289 }); 290 291 add_task(async function testSelectionByLastView() { 292 const mockSender = new MockDAPSender(); 293 const mockDateProvider = new MockDateProvider(); 294 const privateAttribution = new NewTabAttributionServiceClass({ 295 dapSender: mockSender, 296 dateProvider: mockDateProvider, 297 }); 298 299 const partnerIdentifier = "partner_identifier_last_view"; 300 const selectedViewIndex = 1; 301 const ignoredViewIndex = 2; 302 const clickIndex = 3; 303 304 // View event that will be ignored, as a more recent view will exist 305 await privateAttribution.onAttributionEvent("view", { 306 partner_id: partnerIdentifier, 307 index: ignoredViewIndex, 308 }); 309 310 // step forward time 311 mockDateProvider.add(10); 312 313 // View event that will be selected, as no more recent view exists 314 await privateAttribution.onAttributionEvent("view", { 315 partner_id: partnerIdentifier, 316 index: selectedViewIndex, 317 }); 318 319 // step forward time 320 mockDateProvider.add(10); 321 322 // Click event that will be ignored because the match type is "view" 323 await privateAttribution.onAttributionEvent("click", { 324 partner_id: partnerIdentifier, 325 index: clickIndex, 326 }); 327 328 // Conversion filtering for "view" finds the view event 329 await privateAttribution.onAttributionConversion( 330 partnerIdentifier, 331 LOOKBACK_DAYS, 332 "view" 333 ); 334 335 let receivedMeasurement = mockSender.receivedMeasurements.pop(); 336 Assert.deepEqual(receivedMeasurement.measurement, selectedViewIndex); 337 Assert.equal(mockSender.receivedMeasurements.length, 0); 338 }); 339 340 add_task(async function testSelectionByLastClick() { 341 const mockSender = new MockDAPSender(); 342 const mockDateProvider = new MockDateProvider(); 343 const privateAttribution = new NewTabAttributionServiceClass({ 344 dapSender: mockSender, 345 dateProvider: mockDateProvider, 346 }); 347 348 const partnerIdentifier = "partner_identifier_last_click"; 349 const viewIndex = 1; 350 const ignoredClickIndex = 2; 351 const selectedClickIndex = 3; 352 353 // Click event that will be ignored, as a more recent click will exist 354 await privateAttribution.onAttributionEvent("click", { 355 partner_id: partnerIdentifier, 356 index: ignoredClickIndex, 357 }); 358 359 // step forward time 360 mockDateProvider.add(10); 361 362 // Click event that will be selected, as no more recent click exists 363 await privateAttribution.onAttributionEvent("click", { 364 partner_id: partnerIdentifier, 365 index: selectedClickIndex, 366 }); 367 368 // step forward time 369 mockDateProvider.add(10); 370 371 // View event that will be ignored because the match type is "click" 372 await privateAttribution.onAttributionEvent("view", { 373 partner_id: partnerIdentifier, 374 index: viewIndex, 375 }); 376 377 // Conversion filtering for "click" finds the click event 378 await privateAttribution.onAttributionConversion( 379 partnerIdentifier, 380 LOOKBACK_DAYS, 381 "click" 382 ); 383 384 let receivedMeasurement = mockSender.receivedMeasurements.pop(); 385 Assert.deepEqual(receivedMeasurement.measurement, selectedClickIndex); 386 Assert.equal(mockSender.receivedMeasurements.length, 0); 387 }); 388 389 add_task(async function testSelectionByLastTouch() { 390 const mockSender = new MockDAPSender(); 391 const mockDateProvider = new MockDateProvider(); 392 const privateAttribution = new NewTabAttributionServiceClass({ 393 dapSender: mockSender, 394 dateProvider: mockDateProvider, 395 }); 396 397 const partnerIdentifier = "partner_identifier_last_touch"; 398 const viewIndex = 1; 399 const clickIndex = 2; 400 401 // Click at clickIndex 402 await privateAttribution.onAttributionEvent("click", { 403 partner_id: partnerIdentifier, 404 index: clickIndex, 405 }); 406 407 // step forward time so the view event occurs most recently 408 mockDateProvider.add(10); 409 410 // View at viewIndex 411 await privateAttribution.onAttributionEvent("view", { 412 partner_id: partnerIdentifier, 413 index: viewIndex, 414 }); 415 416 // Conversion filtering for "default" finds the view event 417 await privateAttribution.onAttributionConversion( 418 partnerIdentifier, 419 LOOKBACK_DAYS, 420 "default" 421 ); 422 423 let receivedMeasurement = mockSender.receivedMeasurements.pop(); 424 Assert.deepEqual(receivedMeasurement.measurement, viewIndex); 425 Assert.equal(mockSender.receivedMeasurements.length, 0); 426 }); 427 428 add_task(async function testSelectionByPartnerId() { 429 const mockSender = new MockDAPSender(); 430 const mockDateProvider = new MockDateProvider(); 431 const privateAttribution = new NewTabAttributionServiceClass({ 432 dapSender: mockSender, 433 dateProvider: mockDateProvider, 434 }); 435 436 const partnerIdentifier1 = "partner_identifier_1"; 437 const partnerIdentifier2 = "partner_identifier_2"; 438 const partner1Index = 1; 439 const partner2Index = 2; 440 441 // view event associated with partner 1 442 await privateAttribution.onAttributionEvent("view", { 443 partner_id: partnerIdentifier1, 444 index: partner1Index, 445 }); 446 447 // step forward time so the partner 2 event occurs most recently 448 mockDateProvider.add(10); 449 450 // view event associated with partner 2 451 await privateAttribution.onAttributionEvent("view", { 452 partner_id: partnerIdentifier2, 453 index: partner2Index, 454 }); 455 456 // Conversion filtering for "default" finds the correct view event 457 await privateAttribution.onAttributionConversion( 458 partnerIdentifier1, 459 LOOKBACK_DAYS, 460 "default" 461 ); 462 463 let receivedMeasurement = mockSender.receivedMeasurements.pop(); 464 Assert.deepEqual(receivedMeasurement.measurement, partner1Index); 465 Assert.equal(mockSender.receivedMeasurements.length, 0); 466 }); 467 468 add_task(async function testExpiredImpressions() { 469 const mockSender = new MockDAPSender(); 470 const mockDateProvider = new MockDateProvider(); 471 const privateAttribution = new NewTabAttributionServiceClass({ 472 dapSender: mockSender, 473 dateProvider: mockDateProvider, 474 }); 475 476 const partnerIdentifier = "partner_identifier"; 477 const index = 1; 478 const defaultMeasurement = 0; 479 480 // Register impression 481 await privateAttribution.onAttributionEvent("view", { 482 partner_id: partnerIdentifier, 483 index, 484 }); 485 486 // Fast-forward time by LOOKBACK_DAYS days + 1 ms 487 mockDateProvider.add(LOOKBACK_DAYS * DAY_IN_MILLI + 1); 488 489 await privateAttribution.onAttributionConversion( 490 partnerIdentifier, 491 LOOKBACK_DAYS, 492 "view" 493 ); 494 495 const receivedMeasurement = mockSender.receivedMeasurements.pop(); 496 Assert.deepEqual(receivedMeasurement.measurement, defaultMeasurement); 497 Assert.equal(mockSender.receivedMeasurements.length, 0); 498 }); 499 500 add_task(async function testConversionBudget() { 501 const mockSender = new MockDAPSender(); 502 const privateAttribution = new NewTabAttributionServiceClass({ 503 dapSender: mockSender, 504 }); 505 506 const partnerIdentifier = "partner_identifier_budget"; 507 const index = 1; 508 const defaultMeasurement = 0; 509 510 await privateAttribution.onAttributionEvent("view", { 511 partner_id: partnerIdentifier, 512 index, 513 }); 514 515 // Measurements uploaded for conversions up to MAX_CONVERSIONS 516 for (let i = 0; i < MAX_CONVERSIONS; i++) { 517 await privateAttribution.onAttributionConversion( 518 partnerIdentifier, 519 LOOKBACK_DAYS, 520 "view" 521 ); 522 523 const receivedMeasurement = mockSender.receivedMeasurements.pop(); 524 Assert.deepEqual(receivedMeasurement.measurement, index); 525 Assert.equal(mockSender.receivedMeasurements.length, 0); 526 } 527 528 // default report uploaded on subsequent conversions 529 await privateAttribution.onAttributionConversion( 530 partnerIdentifier, 531 LOOKBACK_DAYS, 532 "view" 533 ); 534 535 const receivedMeasurement = mockSender.receivedMeasurements.pop(); 536 Assert.deepEqual(receivedMeasurement.measurement, defaultMeasurement); 537 Assert.equal(mockSender.receivedMeasurements.length, 0); 538 }); 539 540 add_task(async function testHistogramSize() { 541 const mockSender = new MockDAPSender(); 542 const privateAttribution = new NewTabAttributionServiceClass({ 543 dapSender: mockSender, 544 }); 545 546 const partnerIdentifier = "partner_identifier_bad_settings"; 547 const defaultMeasurement = 0; 548 // Zero-based index equal to histogram size is out of bounds 549 const index = HISTOGRAM_SIZE; 550 551 await privateAttribution.onAttributionEvent("view", { 552 partner_id: partnerIdentifier, 553 index, 554 }); 555 556 await privateAttribution.onAttributionConversion( 557 partnerIdentifier, 558 LOOKBACK_DAYS, 559 "view" 560 ); 561 562 const receivedMeasurement = mockSender.receivedMeasurements.pop(); 563 Assert.deepEqual(receivedMeasurement.measurement, defaultMeasurement); 564 Assert.equal(mockSender.receivedMeasurements.length, 0); 565 }); 566 567 add_task(async function testWithRealDAPSender() { 568 // Omit mocking DAP telemetry sender in this test to defend against mock 569 // sender getting out of sync 570 Services.prefs.setStringPref("dap.ohttp.hpke", ""); 571 Services.prefs.setStringPref("dap.ohttp.relayURL", ""); 572 const mockServer = new MockServer(); 573 mockServer.start(); 574 575 const privateAttribution = new NewTabAttributionServiceClass(); 576 577 const partnerIdentifier = "partner_identifier_real_dap"; 578 const index = 1; 579 580 await privateAttribution.onAttributionEvent("view", { 581 partner_id: partnerIdentifier, 582 index, 583 }); 584 585 await privateAttribution.onAttributionConversion( 586 partnerIdentifier, 587 LOOKBACK_DAYS, 588 "view" 589 ); 590 591 await mockServer.stop(); 592 593 Assert.equal(mockServer.receivedReports.length, 1); 594 595 const expectedReport = { 596 contentType: "application/dap-report", 597 size: 502, 598 }; 599 600 const receivedReport = mockServer.receivedReports.pop(); 601 Assert.deepEqual(receivedReport, expectedReport); 602 603 Services.prefs.clearUserPref("dap.ohttp.hpke"); 604 Services.prefs.clearUserPref("dap.ohttp.relayURL"); 605 });