test_remote_settings_poll.js (39908B)
1 const { pushBroadcastService } = ChromeUtils.importESModule( 2 "resource://gre/modules/PushBroadcastService.sys.mjs" 3 ); 4 5 const { remoteSettingsBroadcastHandler, BROADCAST_ID } = 6 ChromeUtils.importESModule( 7 "resource://services-settings/remote-settings.sys.mjs" 8 ); 9 10 const IS_ANDROID = AppConstants.platform == "android"; 11 12 const PREF_SETTINGS_SERVER = "services.settings.server"; 13 const PREF_SETTINGS_SERVER_BACKOFF = "services.settings.server.backoff"; 14 const PREF_LAST_UPDATE = "services.settings.last_update_seconds"; 15 const PREF_LAST_ETAG = "services.settings.last_etag"; 16 const PREF_CLOCK_SKEW_SECONDS = "services.settings.clock_skew_seconds"; 17 18 // Telemetry report result. 19 const TELEMETRY_COMPONENT = "remotesettings"; 20 const TELEMETRY_SOURCE_POLL = "settings-changes-monitoring"; 21 const TELEMETRY_SOURCE_SYNC = "settings-sync"; 22 const CHANGES_PATH = "/v1" + Utils.CHANGES_PATH; 23 24 var server; 25 26 async function clear_state() { 27 // set up prefs so the kinto updater talks to the test server 28 Services.prefs.setStringPref( 29 PREF_SETTINGS_SERVER, 30 `http://localhost:${server.identity.primaryPort}/v1` 31 ); 32 33 // set some initial values so we can check these are updated appropriately 34 Services.prefs.setIntPref(PREF_LAST_UPDATE, 0); 35 Services.prefs.setIntPref(PREF_CLOCK_SKEW_SECONDS, 0); 36 Services.prefs.clearUserPref(PREF_LAST_ETAG); 37 38 // Clear events snapshot. 39 TelemetryTestUtils.assertEvents([], {}, { process: "dummy" }); 40 41 // Clear sync history. 42 await new SyncHistory("").clear(); 43 } 44 45 function serveChangesEntries(serverTime, entriesOrFunc) { 46 return (request, response) => { 47 response.setStatusLine(null, 200, "OK"); 48 response.setHeader("Content-Type", "application/json; charset=UTF-8"); 49 response.setHeader("Date", new Date(serverTime).toUTCString()); 50 const entries = 51 typeof entriesOrFunc == "function" ? entriesOrFunc() : entriesOrFunc; 52 const latest = entries[0]?.last_modified ?? 42; 53 if (entries.length) { 54 response.setHeader("ETag", `"${latest}"`); 55 } 56 response.write(JSON.stringify({ timestamp: latest, changes: entries })); 57 }; 58 } 59 60 add_setup(() => { 61 // Set up an HTTP Server 62 server = new HttpServer(); 63 server.start(-1); 64 65 registerCleanupFunction(() => { 66 server.stop(() => {}); 67 }); 68 }); 69 70 add_task(clear_state); 71 72 add_task(async function test_an_event_is_sent_on_start() { 73 server.registerPathHandler(CHANGES_PATH, (request, response) => { 74 response.write(JSON.stringify({ timestamp: 42, changes: [] })); 75 response.setHeader("Content-Type", "application/json; charset=UTF-8"); 76 response.setHeader("ETag", '"42"'); 77 response.setHeader("Date", new Date().toUTCString()); 78 response.setStatusLine(null, 200, "OK"); 79 }); 80 let notificationObserved = null; 81 const observer = { 82 observe(aSubject, aTopic, aData) { 83 Services.obs.removeObserver(this, "remote-settings:changes-poll-start"); 84 notificationObserved = JSON.parse(aData); 85 }, 86 }; 87 Services.obs.addObserver(observer, "remote-settings:changes-poll-start"); 88 89 await RemoteSettings.pollChanges({ expectedTimestamp: 13 }); 90 91 Assert.equal( 92 notificationObserved.expectedTimestamp, 93 13, 94 "start notification should have been observed" 95 ); 96 }); 97 add_task(clear_state); 98 99 add_task(async function test_offline_is_reported_if_relevant() { 100 const startSnapshot = getUptakeTelemetrySnapshot( 101 TELEMETRY_COMPONENT, 102 TELEMETRY_SOURCE_POLL 103 ); 104 const offlineBackup = Services.io.offline; 105 try { 106 Services.io.offline = true; 107 108 await RemoteSettings.pollChanges(); 109 110 const endSnapshot = getUptakeTelemetrySnapshot( 111 TELEMETRY_COMPONENT, 112 TELEMETRY_SOURCE_POLL 113 ); 114 const expectedIncrements = { 115 [UptakeTelemetry.STATUS.NETWORK_OFFLINE_ERROR]: 1, 116 }; 117 checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements); 118 } finally { 119 Services.io.offline = offlineBackup; 120 } 121 }); 122 add_task(clear_state); 123 124 add_task(async function test_check_success() { 125 const serverTime = 8000; 126 127 server.registerPathHandler( 128 CHANGES_PATH, 129 serveChangesEntries(serverTime, [ 130 { 131 id: "330a0c5f-fadf-ff0b-40c8-4eb0d924ff6a", 132 last_modified: 1100, 133 host: "localhost", 134 bucket: "some-other-bucket", 135 collection: "test-collection", 136 }, 137 { 138 id: "254cbb9e-6888-4d9f-8e60-58b74faa8778", 139 last_modified: 1000, 140 host: "localhost", 141 bucket: "test-bucket", 142 collection: "test-collection", 143 }, 144 ]) 145 ); 146 147 // add a test kinto client that will respond to lastModified information 148 // for a collection called 'test-collection'. 149 // Let's use a bucket that is not the default one (`test-bucket`). 150 const c = RemoteSettings("test-collection", { 151 bucketName: "test-bucket", 152 }); 153 let maybeSyncCalled = false; 154 c.maybeSync = () => { 155 maybeSyncCalled = true; 156 }; 157 158 // Ensure that the remote-settings:changes-poll-end notification works 159 let notificationObserved = false; 160 const observer = { 161 observe() { 162 Services.obs.removeObserver(this, "remote-settings:changes-poll-end"); 163 notificationObserved = true; 164 }, 165 }; 166 Services.obs.addObserver(observer, "remote-settings:changes-poll-end"); 167 168 await RemoteSettings.pollChanges(); 169 170 // It didn't fail, hence we are sure that the unknown collection ``some-other-bucket/test-collection`` 171 // was ignored, otherwise it would have tried to reach the network. 172 173 Assert.ok(maybeSyncCalled, "maybeSync was called"); 174 Assert.ok(notificationObserved, "a notification should have been observed"); 175 // Last timestamp was saved. An ETag header value is a quoted string. 176 Assert.equal(Services.prefs.getStringPref(PREF_LAST_ETAG), '"1100"'); 177 // check the last_update is updated 178 Assert.equal(Services.prefs.getIntPref(PREF_LAST_UPDATE), serverTime / 1000); 179 180 // ensure that we've accumulated the correct telemetry 181 TelemetryTestUtils.assertEvents( 182 [ 183 [ 184 "uptake.remotecontent.result", 185 "uptake", 186 "remotesettings", 187 UptakeTelemetry.STATUS.SUCCESS, 188 { 189 source: TELEMETRY_SOURCE_POLL, 190 trigger: "manual", 191 }, 192 ], 193 [ 194 "uptake.remotecontent.result", 195 "uptake", 196 "remotesettings", 197 UptakeTelemetry.STATUS.SUCCESS, 198 { 199 source: TELEMETRY_SOURCE_SYNC, 200 trigger: "manual", 201 }, 202 ], 203 ], 204 TELEMETRY_EVENTS_FILTERS 205 ); 206 }); 207 add_task(clear_state); 208 209 add_task(async function test_update_timer_interface() { 210 const remoteSettings = Cc["@mozilla.org/services/settings;1"].getService( 211 Ci.nsITimerCallback 212 ); 213 214 const serverTime = 8000; 215 server.registerPathHandler( 216 CHANGES_PATH, 217 serveChangesEntries(serverTime, [ 218 { 219 id: "028261ad-16d4-40c2-a96a-66f72914d125", 220 last_modified: 42, 221 host: "localhost", 222 bucket: "main", 223 collection: "whatever-collection", 224 }, 225 ]) 226 ); 227 228 await new Promise(resolve => { 229 const e = "remote-settings:changes-poll-end"; 230 const changesPolledObserver = { 231 observe() { 232 Services.obs.removeObserver(this, e); 233 resolve(); 234 }, 235 }; 236 Services.obs.addObserver(changesPolledObserver, e); 237 remoteSettings.notify(null); 238 }); 239 240 // Everything went fine. 241 Assert.equal(Services.prefs.getStringPref(PREF_LAST_ETAG), '"42"'); 242 Assert.equal(Services.prefs.getIntPref(PREF_LAST_UPDATE), serverTime / 1000); 243 }); 244 add_task(clear_state); 245 246 add_task(async function test_check_up_to_date() { 247 // Simulate a poll with up-to-date collection. 248 const startSnapshot = getUptakeTelemetrySnapshot( 249 TELEMETRY_COMPONENT, 250 TELEMETRY_SOURCE_POLL 251 ); 252 253 const serverTime = 4000; 254 server.registerPathHandler(CHANGES_PATH, serveChangesEntries(serverTime, [])); 255 256 Services.prefs.setStringPref(PREF_LAST_ETAG, '"1100"'); 257 258 // Ensure that the remote-settings:changes-poll-end notification is sent. 259 let notificationObserved = false; 260 const observer = { 261 observe() { 262 Services.obs.removeObserver(this, "remote-settings:changes-poll-end"); 263 notificationObserved = true; 264 }, 265 }; 266 Services.obs.addObserver(observer, "remote-settings:changes-poll-end"); 267 268 // If server has no change, maybeSync() is not called. 269 let maybeSyncCalled = false; 270 const c = RemoteSettings("test-collection", { 271 bucketName: "test-bucket", 272 }); 273 c.maybeSync = () => { 274 maybeSyncCalled = true; 275 }; 276 277 await RemoteSettings.pollChanges(); 278 279 Assert.ok(notificationObserved, "a notification should have been observed"); 280 Assert.ok(!maybeSyncCalled, "maybeSync should not be called"); 281 // Last update is overwritten 282 Assert.equal(Services.prefs.getIntPref(PREF_LAST_UPDATE), serverTime / 1000); 283 284 // ensure that we've accumulated the correct telemetry 285 const endSnapshot = getUptakeTelemetrySnapshot( 286 TELEMETRY_COMPONENT, 287 TELEMETRY_SOURCE_POLL 288 ); 289 const expectedIncrements = { 290 [UptakeTelemetry.STATUS.UP_TO_DATE]: 1, 291 }; 292 checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements); 293 }); 294 add_task(clear_state); 295 296 add_task(async function test_expected_timestamp() { 297 function withCacheBust(request, response) { 298 const entries = [ 299 { 300 id: "695c2407-de79-4408-91c7-70720dd59d78", 301 last_modified: 1100, 302 host: "localhost", 303 bucket: "main", 304 collection: "with-cache-busting", 305 }, 306 ]; 307 if ( 308 request.queryString.includes(`_expected=${encodeURIComponent('"42"')}`) 309 ) { 310 response.write( 311 JSON.stringify({ 312 timestamp: 1110, 313 changes: entries, 314 }) 315 ); 316 } 317 response.setHeader("Content-Type", "application/json; charset=UTF-8"); 318 response.setHeader("ETag", '"1100"'); 319 response.setHeader("Date", new Date().toUTCString()); 320 response.setStatusLine(null, 200, "OK"); 321 } 322 server.registerPathHandler(CHANGES_PATH, withCacheBust); 323 324 const c = RemoteSettings("with-cache-busting"); 325 let maybeSyncCalled = false; 326 c.maybeSync = () => { 327 maybeSyncCalled = true; 328 }; 329 330 await RemoteSettings.pollChanges({ expectedTimestamp: '"42"' }); 331 332 Assert.ok(maybeSyncCalled, "maybeSync was called"); 333 }); 334 add_task(clear_state); 335 336 add_task(async function test_client_last_check_is_saved() { 337 server.registerPathHandler(CHANGES_PATH, (request, response) => { 338 response.write( 339 JSON.stringify({ 340 timestamp: 42, 341 changes: [ 342 { 343 id: "695c2407-de79-4408-91c7-70720dd59d78", 344 last_modified: 1100, 345 host: "localhost", 346 bucket: "main", 347 collection: "models-recipes", 348 }, 349 ], 350 }) 351 ); 352 response.setHeader("Content-Type", "application/json; charset=UTF-8"); 353 response.setHeader("ETag", '"42"'); 354 response.setHeader("Date", new Date().toUTCString()); 355 response.setStatusLine(null, 200, "OK"); 356 }); 357 358 const c = RemoteSettings("models-recipes"); 359 c.maybeSync = () => {}; 360 361 equal( 362 c.lastCheckTimePref, 363 "services.settings.main.models-recipes.last_check" 364 ); 365 Services.prefs.setIntPref(c.lastCheckTimePref, 0); 366 367 await RemoteSettings.pollChanges({ expectedTimestamp: '"42"' }); 368 369 notEqual(Services.prefs.getIntPref(c.lastCheckTimePref), 0); 370 }); 371 add_task(clear_state); 372 373 const TELEMETRY_EVENTS_FILTERS = { 374 category: "uptake.remotecontent.result", 375 method: "uptake", 376 }; 377 add_task(async function test_age_of_data_is_reported_in_uptake_status() { 378 const serverTime = 1552323900000; 379 const recordsTimestamp = serverTime - 3600 * 1000; 380 server.registerPathHandler( 381 CHANGES_PATH, 382 serveChangesEntries(serverTime, [ 383 { 384 id: "b6ba7fab-a40a-4d03-a4af-6b627f3c5b36", 385 last_modified: recordsTimestamp, 386 host: "localhost", 387 bucket: "main", 388 collection: "some-entry", 389 }, 390 ]) 391 ); 392 393 await RemoteSettings.pollChanges(); 394 395 TelemetryTestUtils.assertEvents( 396 [ 397 [ 398 "uptake.remotecontent.result", 399 "uptake", 400 "remotesettings", 401 UptakeTelemetry.STATUS.SUCCESS, 402 { 403 source: TELEMETRY_SOURCE_POLL, 404 age: "3600", 405 trigger: "manual", 406 }, 407 ], 408 [ 409 "uptake.remotecontent.result", 410 "uptake", 411 "remotesettings", 412 UptakeTelemetry.STATUS.SUCCESS, 413 { 414 source: TELEMETRY_SOURCE_SYNC, 415 duration: () => true, 416 trigger: "manual", 417 timestamp: `"${recordsTimestamp}"`, 418 }, 419 ], 420 ], 421 TELEMETRY_EVENTS_FILTERS 422 ); 423 }); 424 add_task(clear_state); 425 426 add_task( 427 async function test_synchronization_duration_is_reported_in_uptake_status() { 428 server.registerPathHandler( 429 CHANGES_PATH, 430 serveChangesEntries(10000, [ 431 { 432 id: "b6ba7fab-a40a-4d03-a4af-6b627f3c5b36", 433 last_modified: 42, 434 host: "localhost", 435 bucket: "main", 436 collection: "some-entry", 437 }, 438 ]) 439 ); 440 const c = RemoteSettings("some-entry"); 441 // Simulate a synchronization that lasts 1 sec. 442 // eslint-disable-next-line mozilla/no-arbitrary-setTimeout 443 c.maybeSync = () => new Promise(resolve => setTimeout(resolve, 1000)); 444 445 await RemoteSettings.pollChanges(); 446 447 TelemetryTestUtils.assertEvents( 448 [ 449 [ 450 "uptake.remotecontent.result", 451 "uptake", 452 "remotesettings", 453 "success", 454 { 455 source: TELEMETRY_SOURCE_POLL, 456 age: () => true, 457 trigger: "manual", 458 }, 459 ], 460 [ 461 "uptake.remotecontent.result", 462 "uptake", 463 "remotesettings", 464 "success", 465 { 466 source: TELEMETRY_SOURCE_SYNC, 467 duration: v => v >= 1000, 468 trigger: "manual", 469 }, 470 ], 471 ], 472 TELEMETRY_EVENTS_FILTERS 473 ); 474 } 475 ); 476 add_task(clear_state); 477 478 add_task(async function test_success_with_partial_list() { 479 function partialList(request, response) { 480 const entries = [ 481 { 482 id: "028261ad-16d4-40c2-a96a-66f72914d125", 483 last_modified: 43, 484 host: "localhost", 485 bucket: "main", 486 collection: "cid-1", 487 }, 488 { 489 id: "98a34576-bcd6-423f-abc2-1d290b776ed8", 490 last_modified: 42, 491 host: "localhost", 492 bucket: "main", 493 collection: "poll-test-collection", 494 }, 495 ]; 496 if (request.queryString.includes(`_since=${encodeURIComponent('"42"')}`)) { 497 response.write( 498 JSON.stringify({ 499 timestamp: 43, 500 changes: entries.slice(0, 1), 501 }) 502 ); 503 } else { 504 response.write( 505 JSON.stringify({ 506 timestamp: 42, 507 changes: entries, 508 }) 509 ); 510 } 511 response.setHeader("Content-Type", "application/json; charset=UTF-8"); 512 response.setHeader("Date", new Date().toUTCString()); 513 response.setStatusLine(null, 200, "OK"); 514 } 515 server.registerPathHandler(CHANGES_PATH, partialList); 516 517 const c = RemoteSettings("poll-test-collection"); 518 let maybeSyncCount = 0; 519 c.maybeSync = () => { 520 maybeSyncCount++; 521 }; 522 523 await RemoteSettings.pollChanges(); 524 await RemoteSettings.pollChanges(); 525 526 // On the second call, the server does not mention the poll-test-collection 527 // and maybeSync() is not called. 528 Assert.equal(maybeSyncCount, 1, "maybeSync should not be called twice"); 529 }); 530 add_task(clear_state); 531 532 add_task(async function test_full_polling() { 533 server.registerPathHandler( 534 CHANGES_PATH, 535 serveChangesEntries(10000, [ 536 { 537 id: "b6ba7fab-a40a-4d03-a4af-6b627f3c5b36", 538 last_modified: 42, 539 host: "localhost", 540 bucket: "main", 541 collection: "poll-test-collection", 542 }, 543 ]) 544 ); 545 546 const c = RemoteSettings("poll-test-collection"); 547 let maybeSyncCount = 0; 548 c.maybeSync = () => { 549 maybeSyncCount++; 550 }; 551 552 await RemoteSettings.pollChanges(); 553 await RemoteSettings.pollChanges({ full: true }); 554 555 // Since the second call is full, clients are called 556 Assert.equal(maybeSyncCount, 2, "maybeSync should be called twice"); 557 }); 558 add_task(clear_state); 559 560 add_task(async function test_server_bad_json() { 561 const startSnapshot = getUptakeTelemetrySnapshot( 562 TELEMETRY_COMPONENT, 563 TELEMETRY_SOURCE_POLL 564 ); 565 566 function simulateBadJSON(request, response) { 567 response.setHeader("Content-Type", "application/json; charset=UTF-8"); 568 response.write("<html></html>"); 569 response.setStatusLine(null, 200, "OK"); 570 } 571 server.registerPathHandler(CHANGES_PATH, simulateBadJSON); 572 573 let error; 574 try { 575 await RemoteSettings.pollChanges(); 576 } catch (e) { 577 error = e; 578 } 579 Assert.ok(/JSON.parse: unexpected character/.test(error.message)); 580 581 const endSnapshot = getUptakeTelemetrySnapshot( 582 TELEMETRY_COMPONENT, 583 TELEMETRY_SOURCE_POLL 584 ); 585 const expectedIncrements = { 586 [UptakeTelemetry.STATUS.PARSE_ERROR]: 1, 587 }; 588 checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements); 589 }); 590 add_task(clear_state); 591 592 add_task(async function test_server_bad_content_type() { 593 const startSnapshot = getUptakeTelemetrySnapshot( 594 TELEMETRY_COMPONENT, 595 TELEMETRY_SOURCE_POLL 596 ); 597 598 function simulateBadContentType(request, response) { 599 response.setHeader("Content-Type", "text/html"); 600 response.write("<html></html>"); 601 response.setStatusLine(null, 200, "OK"); 602 } 603 server.registerPathHandler(CHANGES_PATH, simulateBadContentType); 604 605 let error; 606 try { 607 await RemoteSettings.pollChanges(); 608 } catch (e) { 609 error = e; 610 } 611 Assert.ok(/Unexpected content-type/.test(error.message)); 612 613 const endSnapshot = getUptakeTelemetrySnapshot( 614 TELEMETRY_COMPONENT, 615 TELEMETRY_SOURCE_POLL 616 ); 617 const expectedIncrements = { 618 [UptakeTelemetry.STATUS.CONTENT_ERROR]: 1, 619 }; 620 checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements); 621 }); 622 add_task(clear_state); 623 624 add_task(async function test_server_404_response() { 625 function simulateDummy404(request, response) { 626 response.setHeader("Content-Type", "text/html; charset=UTF-8"); 627 response.write("<html></html>"); 628 response.setStatusLine(null, 404, "OK"); 629 } 630 server.registerPathHandler(CHANGES_PATH, simulateDummy404); 631 632 await RemoteSettings.pollChanges(); // Does not fail when running from tests. 633 }); 634 add_task(clear_state); 635 636 add_task(async function test_server_error() { 637 const startSnapshot = getUptakeTelemetrySnapshot( 638 TELEMETRY_COMPONENT, 639 TELEMETRY_SOURCE_POLL 640 ); 641 642 // Simulate a server error. 643 function simulateErrorResponse(request, response) { 644 response.setHeader("Date", new Date(3000).toUTCString()); 645 response.setHeader("Content-Type", "application/json; charset=UTF-8"); 646 response.write( 647 JSON.stringify({ 648 code: 503, 649 errno: 999, 650 error: "Service Unavailable", 651 }) 652 ); 653 response.setStatusLine(null, 503, "Service Unavailable"); 654 } 655 server.registerPathHandler(CHANGES_PATH, simulateErrorResponse); 656 657 let notificationObserved = false; 658 const observer = { 659 observe() { 660 Services.obs.removeObserver(this, "remote-settings:changes-poll-end"); 661 notificationObserved = true; 662 }, 663 }; 664 Services.obs.addObserver(observer, "remote-settings:changes-poll-end"); 665 Services.prefs.setIntPref(PREF_LAST_UPDATE, 42); 666 667 // pollChanges() fails with adequate error and no notification. 668 let error; 669 try { 670 await RemoteSettings.pollChanges(); 671 } catch (e) { 672 error = e; 673 } 674 675 Assert.ok( 676 !notificationObserved, 677 "a notification should not have been observed" 678 ); 679 Assert.ok(/Polling for changes failed/.test(error.message)); 680 // When an error occurs, last update was not overwritten. 681 Assert.equal(Services.prefs.getIntPref(PREF_LAST_UPDATE), 42); 682 // ensure that we've accumulated the correct telemetry 683 const endSnapshot = getUptakeTelemetrySnapshot( 684 TELEMETRY_COMPONENT, 685 TELEMETRY_SOURCE_POLL 686 ); 687 const expectedIncrements = { 688 [UptakeTelemetry.STATUS.SERVER_ERROR]: 1, 689 }; 690 checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements); 691 }); 692 add_task(clear_state); 693 694 add_task(async function test_server_error_5xx() { 695 const startSnapshot = getUptakeTelemetrySnapshot( 696 TELEMETRY_COMPONENT, 697 TELEMETRY_SOURCE_POLL 698 ); 699 700 function simulateErrorResponse(request, response) { 701 response.setHeader("Date", new Date(3000).toUTCString()); 702 response.setHeader("Content-Type", "text/html; charset=UTF-8"); 703 response.write("<html></html>"); 704 response.setStatusLine(null, 504, "Gateway Timeout"); 705 } 706 server.registerPathHandler(CHANGES_PATH, simulateErrorResponse); 707 708 try { 709 await RemoteSettings.pollChanges(); 710 } catch (e) {} 711 712 const endSnapshot = getUptakeTelemetrySnapshot( 713 TELEMETRY_COMPONENT, 714 TELEMETRY_SOURCE_POLL 715 ); 716 const expectedIncrements = { 717 [UptakeTelemetry.STATUS.SERVER_ERROR]: 1, 718 }; 719 checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements); 720 }); 721 add_task(clear_state); 722 723 add_task(async function test_server_error_4xx() { 724 function simulateErrorResponse(request, response) { 725 response.setHeader("Date", new Date(3000).toUTCString()); 726 response.setHeader("Content-Type", "application/json; charset=UTF-8"); 727 if (request.queryString.includes(`_since=${encodeURIComponent('"abc"')}`)) { 728 response.setStatusLine(null, 400, "Bad Request"); 729 response.write(JSON.stringify({})); 730 } else { 731 response.setStatusLine(null, 200, "OK"); 732 response.write(JSON.stringify({ changes: [] })); 733 } 734 } 735 server.registerPathHandler(CHANGES_PATH, simulateErrorResponse); 736 737 Services.prefs.setStringPref(PREF_LAST_ETAG, '"abc"'); 738 739 let error; 740 try { 741 await RemoteSettings.pollChanges(); 742 } catch (e) { 743 error = e; 744 } 745 746 Assert.ok(error.message.includes("400 Bad Request"), "Polling failed"); 747 Assert.ok( 748 !Services.prefs.prefHasUserValue(PREF_LAST_ETAG), 749 "Last ETag pref was cleared" 750 ); 751 752 await RemoteSettings.pollChanges(); // Does not raise. 753 }); 754 add_task(clear_state); 755 756 add_task(async function test_client_error() { 757 const startSnapshot = getUptakeTelemetrySnapshot( 758 TELEMETRY_COMPONENT, 759 TELEMETRY_SOURCE_SYNC 760 ); 761 762 const collectionDetails = { 763 id: "b6ba7fab-a40a-4d03-a4af-6b627f3c5b36", 764 last_modified: 42, 765 host: "localhost", 766 bucket: "main", 767 collection: "some-entry", 768 }; 769 server.registerPathHandler( 770 CHANGES_PATH, 771 serveChangesEntries(10000, [collectionDetails]) 772 ); 773 const c = RemoteSettings("some-entry"); 774 c.maybeSync = () => { 775 throw new RemoteSettingsClient.CorruptedDataError("main/some-entry"); 776 }; 777 778 let notificationsObserved = []; 779 const observer = { 780 observe(aSubject, aTopic) { 781 Services.obs.removeObserver(this, aTopic); 782 notificationsObserved.push([aTopic, aSubject.wrappedJSObject]); 783 }, 784 }; 785 Services.obs.addObserver(observer, "remote-settings:changes-poll-end"); 786 Services.obs.addObserver(observer, "remote-settings:sync-error"); 787 Services.prefs.setIntPref(PREF_LAST_ETAG, 42); 788 789 // pollChanges() fails with adequate error and a sync-error notification. 790 let error; 791 try { 792 await RemoteSettings.pollChanges(); 793 } catch (e) { 794 error = e; 795 } 796 797 Assert.equal( 798 notificationsObserved.length, 799 1, 800 "only the error notification should not have been observed" 801 ); 802 console.log(notificationsObserved); 803 let [topicObserved, subjectObserved] = notificationsObserved[0]; 804 Assert.equal(topicObserved, "remote-settings:sync-error"); 805 Assert.ok( 806 subjectObserved.error instanceof RemoteSettingsClient.CorruptedDataError, 807 `original error is provided (got ${subjectObserved.error})` 808 ); 809 Assert.deepEqual( 810 subjectObserved.error.details, 811 collectionDetails, 812 "information about collection is provided" 813 ); 814 815 Assert.ok(/Corrupted/.test(error.message), "original client error is thrown"); 816 // When an error occurs, last etag was not overwritten. 817 Assert.equal(Services.prefs.getIntPref(PREF_LAST_ETAG), 42); 818 // ensure that we've accumulated the correct telemetry 819 const endSnapshot = getUptakeTelemetrySnapshot( 820 TELEMETRY_COMPONENT, 821 TELEMETRY_SOURCE_SYNC 822 ); 823 const expectedIncrements = { 824 [UptakeTelemetry.STATUS.SYNC_ERROR]: 1, 825 }; 826 checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements); 827 }); 828 add_task(clear_state); 829 830 add_task(async function test_sync_success_is_stored_in_history() { 831 const collectionDetails = { 832 last_modified: 444, 833 bucket: "main", 834 collection: "desktop-manager", 835 }; 836 server.registerPathHandler( 837 CHANGES_PATH, 838 serveChangesEntries(10000, [collectionDetails]) 839 ); 840 const c = RemoteSettings("desktop-manager"); 841 c.maybeSync = () => {}; 842 try { 843 await RemoteSettings.pollChanges({ expectedTimestamp: 555 }); 844 } catch (e) {} 845 846 const { history } = await RemoteSettings.inspect(); 847 848 Assert.deepEqual(history, { 849 [TELEMETRY_SOURCE_SYNC]: [ 850 { 851 timestamp: 444, 852 status: "success", 853 infos: {}, 854 datetime: new Date(444), 855 }, 856 ], 857 }); 858 }); 859 add_task(clear_state); 860 861 add_task(async function test_sync_error_is_stored_in_history() { 862 const collectionDetails = { 863 last_modified: 1337, 864 bucket: "main", 865 collection: "desktop-manager", 866 }; 867 server.registerPathHandler( 868 CHANGES_PATH, 869 serveChangesEntries(10000, [collectionDetails]) 870 ); 871 const c = RemoteSettings("desktop-manager"); 872 c.maybeSync = () => { 873 throw new RemoteSettingsClient.MissingSignatureError( 874 "main/desktop-manager" 875 ); 876 }; 877 try { 878 await RemoteSettings.pollChanges({ expectedTimestamp: 123456 }); 879 } catch (e) {} 880 881 const { history } = await RemoteSettings.inspect(); 882 883 Assert.deepEqual(history, { 884 [TELEMETRY_SOURCE_SYNC]: [ 885 { 886 timestamp: 1337, 887 status: "sync_error", 888 infos: { 889 expectedTimestamp: 123456, 890 errorName: "MissingSignatureError", 891 }, 892 datetime: new Date(1337), 893 }, 894 ], 895 }); 896 }); 897 add_task(clear_state); 898 899 add_task( 900 async function test_sync_broken_signal_is_sent_on_consistent_failure() { 901 const startSnapshot = getUptakeTelemetrySnapshot( 902 TELEMETRY_COMPONENT, 903 TELEMETRY_SOURCE_POLL 904 ); 905 // Wait for the "sync-broken-error" notification. 906 let notificationObserved = false; 907 const observer = { 908 observe() { 909 notificationObserved = true; 910 }, 911 }; 912 Services.obs.addObserver(observer, "remote-settings:broken-sync-error"); 913 // Register a client with a failing sync method. 914 const c = RemoteSettings("desktop-manager"); 915 c.maybeSync = () => { 916 throw new RemoteSettingsClient.InvalidSignatureError( 917 "main/desktop-manager" 918 ); 919 }; 920 // Simulate a response whose ETag gets incremented on each call 921 // (in order to generate several history entries, indexed by timestamp). 922 let timestamp = 1337; 923 server.registerPathHandler( 924 CHANGES_PATH, 925 serveChangesEntries(10000, () => { 926 return [ 927 { 928 last_modified: ++timestamp, 929 bucket: "main", 930 collection: "desktop-manager", 931 }, 932 ]; 933 }) 934 ); 935 936 // Now obtain several failures in a row (less than threshold). 937 for (var i = 0; i < 9; i++) { 938 try { 939 await RemoteSettings.pollChanges(); 940 } catch (e) {} 941 } 942 Assert.ok(!notificationObserved, "Not notified yet"); 943 944 // Fail again once. Will now notify. 945 try { 946 await RemoteSettings.pollChanges(); 947 } catch (e) {} 948 Assert.ok(notificationObserved, "Broken sync notified"); 949 // Uptake event to notify broken sync is sent. 950 const endSnapshot = getUptakeTelemetrySnapshot( 951 TELEMETRY_COMPONENT, 952 TELEMETRY_SOURCE_SYNC 953 ); 954 const expectedIncrements = { 955 [UptakeTelemetry.STATUS.SYNC_ERROR]: 10, 956 [UptakeTelemetry.STATUS.SYNC_BROKEN_ERROR]: 1, 957 }; 958 checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements); 959 960 // Synchronize successfully. 961 notificationObserved = false; 962 const failingSync = c.maybeSync; 963 c.maybeSync = () => {}; 964 await RemoteSettings.pollChanges(); 965 966 const { history } = await RemoteSettings.inspect(); 967 Assert.equal( 968 history[TELEMETRY_SOURCE_SYNC][0].status, 969 UptakeTelemetry.STATUS.SUCCESS, 970 "Last sync is success" 971 ); 972 Assert.ok(!notificationObserved, "Not notified after success"); 973 974 // Now fail again. Broken sync isn't notified, we need several in a row. 975 c.maybeSync = failingSync; 976 try { 977 await RemoteSettings.pollChanges(); 978 } catch (e) {} 979 Assert.ok(!notificationObserved, "Not notified on single error"); 980 Services.obs.removeObserver(observer, "remote-settings:broken-sync-error"); 981 } 982 ); 983 add_task(clear_state); 984 985 add_task(async function test_check_clockskew_is_updated() { 986 const serverTime = 2000; 987 988 function serverResponse(request, response) { 989 response.setHeader("Content-Type", "application/json; charset=UTF-8"); 990 response.setHeader("Date", new Date(serverTime).toUTCString()); 991 response.write(JSON.stringify({ timestamp: 42, changes: [] })); 992 response.setStatusLine(null, 200, "OK"); 993 } 994 server.registerPathHandler(CHANGES_PATH, serverResponse); 995 996 let startTime = Date.now(); 997 998 await RemoteSettings.pollChanges(); 999 1000 // How does the clock difference look? 1001 let endTime = Date.now(); 1002 let clockDifference = Services.prefs.getIntPref(PREF_CLOCK_SKEW_SECONDS); 1003 // we previously set the serverTime to 2 (seconds past epoch) 1004 Assert.ok( 1005 clockDifference <= endTime / 1000 && 1006 clockDifference >= Math.floor(startTime / 1000) - serverTime / 1000 1007 ); 1008 1009 // check negative clock skew times 1010 // set to a time in the future 1011 server.registerPathHandler( 1012 CHANGES_PATH, 1013 serveChangesEntries(Date.now() + 10000, []) 1014 ); 1015 1016 await RemoteSettings.pollChanges(); 1017 1018 clockDifference = Services.prefs.getIntPref(PREF_CLOCK_SKEW_SECONDS); 1019 // we previously set the serverTime to Date.now() + 10000 ms past epoch 1020 Assert.ok(clockDifference <= 0 && clockDifference >= -10); 1021 }); 1022 add_task(clear_state); 1023 1024 add_task(async function test_check_clockskew_takes_age_into_account() { 1025 const currentTime = Date.now(); 1026 const skewSeconds = 5; 1027 const ageCDNSeconds = 3600; 1028 const serverTime = currentTime - skewSeconds * 1000 - ageCDNSeconds * 1000; 1029 1030 function serverResponse(request, response) { 1031 response.setHeader("Content-Type", "application/json; charset=UTF-8"); 1032 response.setHeader("Date", new Date(serverTime).toUTCString()); 1033 response.setHeader("Age", `${ageCDNSeconds}`); 1034 response.write(JSON.stringify({ timestamp: 42, changes: [] })); 1035 response.setStatusLine(null, 200, "OK"); 1036 } 1037 server.registerPathHandler(CHANGES_PATH, serverResponse); 1038 1039 await RemoteSettings.pollChanges(); 1040 1041 const clockSkew = Services.prefs.getIntPref(PREF_CLOCK_SKEW_SECONDS); 1042 Assert.greaterOrEqual(clockSkew, skewSeconds, `clockSkew is ${clockSkew}`); 1043 }); 1044 add_task(clear_state); 1045 1046 add_task(async function test_backoff() { 1047 const startSnapshot = getUptakeTelemetrySnapshot( 1048 TELEMETRY_COMPONENT, 1049 TELEMETRY_SOURCE_POLL 1050 ); 1051 1052 function simulateBackoffResponse(request, response) { 1053 response.setHeader("Content-Type", "application/json; charset=UTF-8"); 1054 response.setHeader("Backoff", "10"); 1055 response.write(JSON.stringify({ timestamp: 42, changes: [] })); 1056 response.setStatusLine(null, 200, "OK"); 1057 } 1058 server.registerPathHandler(CHANGES_PATH, simulateBackoffResponse); 1059 1060 // First will work. 1061 await RemoteSettings.pollChanges(); 1062 // Second will fail because we haven't waited. 1063 try { 1064 await RemoteSettings.pollChanges(); 1065 // The previous line should have thrown an error. 1066 Assert.ok(false); 1067 } catch (e) { 1068 Assert.ok( 1069 /Server is asking clients to back off; retry in \d+s./.test(e.message) 1070 ); 1071 } 1072 1073 // Once backoff time has expired, polling for changes can start again. 1074 server.registerPathHandler( 1075 CHANGES_PATH, 1076 serveChangesEntries(12000, [ 1077 { 1078 id: "6a733d4a-601e-11e8-837a-0f85257529a1", 1079 last_modified: 1300, 1080 host: "localhost", 1081 bucket: "some-bucket", 1082 collection: "some-collection", 1083 }, 1084 ]) 1085 ); 1086 Services.prefs.setStringPref( 1087 PREF_SETTINGS_SERVER_BACKOFF, 1088 `${Date.now() - 1000}` 1089 ); 1090 1091 await RemoteSettings.pollChanges(); 1092 1093 // Backoff tracking preference was cleared. 1094 Assert.ok(!Services.prefs.prefHasUserValue(PREF_SETTINGS_SERVER_BACKOFF)); 1095 1096 // Ensure that we've accumulated the correct telemetry 1097 const endSnapshot = getUptakeTelemetrySnapshot( 1098 TELEMETRY_COMPONENT, 1099 TELEMETRY_SOURCE_POLL 1100 ); 1101 const expectedIncrements = { 1102 [UptakeTelemetry.STATUS.SUCCESS]: 1, 1103 [UptakeTelemetry.STATUS.UP_TO_DATE]: 1, 1104 [UptakeTelemetry.STATUS.BACKOFF]: 1, 1105 }; 1106 checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements); 1107 }); 1108 add_task(clear_state); 1109 1110 add_task(async function test_network_error() { 1111 const startSnapshot = getUptakeTelemetrySnapshot( 1112 TELEMETRY_COMPONENT, 1113 TELEMETRY_SOURCE_POLL 1114 ); 1115 1116 // Simulate a network error (to check telemetry report). 1117 Services.prefs.setStringPref(PREF_SETTINGS_SERVER, "http://localhost:42/v1"); 1118 try { 1119 await RemoteSettings.pollChanges(); 1120 } catch (e) {} 1121 1122 // ensure that we've accumulated the correct telemetry 1123 const endSnapshot = getUptakeTelemetrySnapshot( 1124 TELEMETRY_COMPONENT, 1125 TELEMETRY_SOURCE_POLL 1126 ); 1127 const expectedIncrements = { 1128 [UptakeTelemetry.STATUS.NETWORK_ERROR]: 1, 1129 }; 1130 checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements); 1131 }); 1132 add_task(clear_state); 1133 1134 add_task(async function test_syncs_clients_with_local_database() { 1135 server.registerPathHandler( 1136 CHANGES_PATH, 1137 serveChangesEntries(42000, [ 1138 { 1139 id: "d4a14f44-601f-11e8-8b8a-030f3dc5b844", 1140 last_modified: 10000, 1141 host: "localhost", 1142 bucket: "main", 1143 collection: "some-unknown", 1144 }, 1145 { 1146 id: "39f57e4e-6023-11e8-8b74-77c8dedfb389", 1147 last_modified: 9000, 1148 host: "localhost", 1149 bucket: "blocklists", 1150 collection: "addons", 1151 }, 1152 { 1153 id: "9a594c1a-601f-11e8-9c8a-33b2239d9113", 1154 last_modified: 8000, 1155 host: "localhost", 1156 bucket: "main", 1157 collection: "recipes", 1158 }, 1159 ]) 1160 ); 1161 1162 // This simulates what remote-settings would do when initializing a local database. 1163 // We don't want to instantiate a client using the RemoteSettings() API 1164 // since we want to test «unknown» clients that have a local database. 1165 new RemoteSettingsClient("addons", { 1166 bucketName: "blocklists", 1167 }).db.importChanges({}, 42); 1168 new RemoteSettingsClient("recipes").db.importChanges({}, 43); 1169 1170 let error; 1171 try { 1172 await RemoteSettings.pollChanges(); 1173 Assert.ok(false, "pollChange() should throw when pulling recipes"); 1174 } catch (e) { 1175 error = e; 1176 } 1177 1178 // The `main/some-unknown` should be skipped because it has no local database. 1179 // The `blocklists/addons` should be skipped because it is not the main bucket. 1180 // The `recipes` has a local database, and should cause a network error because the test 1181 // does not setup the server to receive the requests of `maybeSync()`. 1182 Assert.ok(/HTTP 404/.test(error.message), "server will return 404 on sync"); 1183 Assert.equal(error.details.collection, "recipes"); 1184 }); 1185 add_task(clear_state); 1186 1187 add_task(async function test_syncs_clients_with_local_dump() { 1188 if (IS_ANDROID) { 1189 // Skip test: we don't ship remote settings dumps on Android (see package-manifest). 1190 return; 1191 } 1192 server.registerPathHandler( 1193 CHANGES_PATH, 1194 serveChangesEntries(42000, [ 1195 { 1196 id: "d4a14f44-601f-11e8-8b8a-030f3dc5b844", 1197 last_modified: 10000, 1198 host: "localhost", 1199 bucket: "main", 1200 collection: "some-unknown", 1201 }, 1202 { 1203 id: "39f57e4e-6023-11e8-8b74-77c8dedfb389", 1204 last_modified: 9000, 1205 host: "localhost", 1206 bucket: "blocklists", 1207 collection: "addons", 1208 }, 1209 { 1210 id: "9a594c1a-601f-11e8-9c8a-33b2239d9113", 1211 last_modified: 8000, 1212 host: "localhost", 1213 bucket: "main", 1214 collection: "example", 1215 }, 1216 ]) 1217 ); 1218 1219 let error; 1220 try { 1221 await RemoteSettings.pollChanges(); 1222 } catch (e) { 1223 error = e; 1224 } 1225 1226 // The `main/some-unknown` should be skipped because it has no dump. 1227 // The `blocklists/addons` should be skipped because it is not the main bucket. 1228 // The `example` has a dump, and should cause a network error because the test 1229 // does not setup the server to receive the requests of `maybeSync()`. 1230 Assert.ok(/HTTP 404/.test(error.message), "server will return 404 on sync"); 1231 Assert.equal(error.details.collection, "example"); 1232 }); 1233 add_task(clear_state); 1234 1235 add_task(async function test_adding_client_resets_polling() { 1236 function serve200(request, response) { 1237 const entries = [ 1238 { 1239 id: "aa71e6cc-9f37-447a-b6e0-c025e8eabd03", 1240 last_modified: 42, 1241 host: "localhost", 1242 bucket: "main", 1243 collection: "a-collection", 1244 }, 1245 ]; 1246 if (request.queryString.includes("_since")) { 1247 response.write( 1248 JSON.stringify({ 1249 timestamp: 42, 1250 changes: [], 1251 }) 1252 ); 1253 } else { 1254 response.write( 1255 JSON.stringify({ 1256 timestamp: 42, 1257 changes: entries, 1258 }) 1259 ); 1260 } 1261 response.setStatusLine(null, 200, "OK"); 1262 response.setHeader("Content-Type", "application/json; charset=UTF-8"); 1263 response.setHeader("Date", new Date().toUTCString()); 1264 } 1265 server.registerPathHandler(CHANGES_PATH, serve200); 1266 1267 // Poll once, without any client for "a-collection" 1268 await RemoteSettings.pollChanges(); 1269 1270 // Register a new client. 1271 let maybeSyncCalled = false; 1272 const c = RemoteSettings("a-collection"); 1273 c.maybeSync = () => { 1274 maybeSyncCalled = true; 1275 }; 1276 1277 // Poll again. 1278 await RemoteSettings.pollChanges(); 1279 1280 // The new client was called, even if the server data didn't change. 1281 Assert.ok(maybeSyncCalled); 1282 1283 // Poll again. This time maybeSync() won't be called. 1284 maybeSyncCalled = false; 1285 await RemoteSettings.pollChanges(); 1286 Assert.ok(!maybeSyncCalled); 1287 }); 1288 add_task(clear_state); 1289 1290 add_task( 1291 async function test_broadcast_handler_passes_version_and_trigger_values() { 1292 // The polling will use the broadcast version as cache busting query param. 1293 let passedQueryString; 1294 function serveCacheBusted(request, response) { 1295 passedQueryString = request.queryString; 1296 const entries = [ 1297 { 1298 id: "b6ba7fab-a40a-4d03-a4af-6b627f3c5b36", 1299 last_modified: 42, 1300 host: "localhost", 1301 bucket: "main", 1302 collection: "from-broadcast", 1303 }, 1304 ]; 1305 response.write( 1306 JSON.stringify({ 1307 changes: entries, 1308 timestamp: 42, 1309 }) 1310 ); 1311 response.setHeader("ETag", '"42"'); 1312 response.setStatusLine(null, 200, "OK"); 1313 response.setHeader("Content-Type", "application/json; charset=UTF-8"); 1314 response.setHeader("Date", new Date().toUTCString()); 1315 } 1316 server.registerPathHandler(CHANGES_PATH, serveCacheBusted); 1317 1318 let passedTrigger; 1319 const c = RemoteSettings("from-broadcast"); 1320 c.maybeSync = (last_modified, { trigger }) => { 1321 passedTrigger = trigger; 1322 }; 1323 1324 const version = "1337"; 1325 1326 let context = { phase: pushBroadcastService.PHASES.HELLO }; 1327 await remoteSettingsBroadcastHandler.receivedBroadcastMessage( 1328 version, 1329 BROADCAST_ID, 1330 context 1331 ); 1332 Assert.equal(passedTrigger, "startup"); 1333 Assert.equal(passedQueryString, `_expected=${version}`); 1334 1335 clear_state(); 1336 1337 context = { phase: pushBroadcastService.PHASES.REGISTER }; 1338 await remoteSettingsBroadcastHandler.receivedBroadcastMessage( 1339 version, 1340 BROADCAST_ID, 1341 context 1342 ); 1343 Assert.equal(passedTrigger, "startup"); 1344 1345 clear_state(); 1346 1347 context = { phase: pushBroadcastService.PHASES.BROADCAST }; 1348 await remoteSettingsBroadcastHandler.receivedBroadcastMessage( 1349 version, 1350 BROADCAST_ID, 1351 context 1352 ); 1353 Assert.equal(passedTrigger, "broadcast"); 1354 } 1355 ); 1356 add_task(clear_state);