/* import-globals-from ../../../common/tests/unit/head_helpers.js */ const { AppConstants } = ChromeUtils.importESModule( "resource://gre/modules/AppConstants.sys.mjs" ); const { setTimeout } = ChromeUtils.importESModule( "resource://gre/modules/Timer.sys.mjs" ); const { UptakeTelemetry, Policy } = ChromeUtils.importESModule( "resource://services-common/uptake-telemetry.sys.mjs" ); const { RemoteSettingsClient } = ChromeUtils.importESModule( "resource://services-settings/RemoteSettingsClient.sys.mjs" ); const { pushBroadcastService } = ChromeUtils.importESModule( "resource://gre/modules/PushBroadcastService.sys.mjs" ); const { SyncHistory } = ChromeUtils.importESModule( "resource://services-settings/SyncHistory.sys.mjs" ); const { RemoteSettings, remoteSettingsBroadcastHandler, BROADCAST_ID } = ChromeUtils.importESModule( "resource://services-settings/remote-settings.sys.mjs" ); const { Utils } = ChromeUtils.importESModule( "resource://services-settings/Utils.sys.mjs" ); const { TelemetryTestUtils } = ChromeUtils.importESModule( "resource://testing-common/TelemetryTestUtils.sys.mjs" ); const IS_ANDROID = AppConstants.platform == "android"; const PREF_SETTINGS_SERVER = "services.settings.server"; const PREF_SETTINGS_SERVER_BACKOFF = "services.settings.server.backoff"; const PREF_LAST_UPDATE = "services.settings.last_update_seconds"; const PREF_LAST_ETAG = "services.settings.last_etag"; const PREF_CLOCK_SKEW_SECONDS = "services.settings.clock_skew_seconds"; // Telemetry report result. const TELEMETRY_COMPONENT = "remotesettings"; const TELEMETRY_SOURCE_POLL = "settings-changes-monitoring"; const TELEMETRY_SOURCE_SYNC = "settings-sync"; const CHANGES_PATH = "/v1" + Utils.CHANGES_PATH; var server; async function clear_state() { // set up prefs so the kinto updater talks to the test server Services.prefs.setStringPref( PREF_SETTINGS_SERVER, `http://localhost:${server.identity.primaryPort}/v1` ); // set some initial values so we can check these are updated appropriately Services.prefs.setIntPref(PREF_LAST_UPDATE, 0); Services.prefs.setIntPref(PREF_CLOCK_SKEW_SECONDS, 0); Services.prefs.clearUserPref(PREF_LAST_ETAG); // Clear events snapshot. TelemetryTestUtils.assertEvents([], {}, { process: "dummy" }); // Clear sync history. await new SyncHistory("").clear(); } function serveChangesEntries(serverTime, entriesOrFunc) { return (request, response) => { response.setStatusLine(null, 200, "OK"); response.setHeader("Content-Type", "application/json; charset=UTF-8"); response.setHeader("Date", new Date(serverTime).toUTCString()); const entries = typeof entriesOrFunc == "function" ? entriesOrFunc() : entriesOrFunc; const latest = entries[0]?.last_modified ?? 42; if (entries.length) { response.setHeader("ETag", `"${latest}"`); } response.write(JSON.stringify({ timestamp: latest, changes: entries })); }; } function run_test() { // Set up an HTTP Server server = new HttpServer(); server.start(-1); // Pretend we are in nightly channel to make sure all telemetry events are sent. let oldGetChannel = Policy.getChannel; Policy.getChannel = () => "nightly"; run_next_test(); registerCleanupFunction(() => { Policy.getChannel = oldGetChannel; server.stop(() => {}); }); } add_task(clear_state); add_task(async function test_an_event_is_sent_on_start() { server.registerPathHandler(CHANGES_PATH, (request, response) => { response.write(JSON.stringify({ timestamp: 42, changes: [] })); response.setHeader("Content-Type", "application/json; charset=UTF-8"); response.setHeader("ETag", '"42"'); response.setHeader("Date", new Date().toUTCString()); response.setStatusLine(null, 200, "OK"); }); let notificationObserved = null; const observer = { observe(aSubject, aTopic, aData) { Services.obs.removeObserver(this, "remote-settings:changes-poll-start"); notificationObserved = JSON.parse(aData); }, }; Services.obs.addObserver(observer, "remote-settings:changes-poll-start"); await RemoteSettings.pollChanges({ expectedTimestamp: 13 }); Assert.equal( notificationObserved.expectedTimestamp, 13, "start notification should have been observed" ); }); add_task(clear_state); add_task(async function test_offline_is_reported_if_relevant() { const startSnapshot = getUptakeTelemetrySnapshot( TELEMETRY_COMPONENT, TELEMETRY_SOURCE_POLL ); const offlineBackup = Services.io.offline; try { Services.io.offline = true; await RemoteSettings.pollChanges(); const endSnapshot = getUptakeTelemetrySnapshot( TELEMETRY_COMPONENT, TELEMETRY_SOURCE_POLL ); const expectedIncrements = { [UptakeTelemetry.STATUS.NETWORK_OFFLINE_ERROR]: 1, }; checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements); } finally { Services.io.offline = offlineBackup; } }); add_task(clear_state); add_task(async function test_check_success() { const serverTime = 8000; server.registerPathHandler( CHANGES_PATH, serveChangesEntries(serverTime, [ { id: "330a0c5f-fadf-ff0b-40c8-4eb0d924ff6a", last_modified: 1100, host: "localhost", bucket: "some-other-bucket", collection: "test-collection", }, { id: "254cbb9e-6888-4d9f-8e60-58b74faa8778", last_modified: 1000, host: "localhost", bucket: "test-bucket", collection: "test-collection", }, ]) ); // add a test kinto client that will respond to lastModified information // for a collection called 'test-collection'. // Let's use a bucket that is not the default one (`test-bucket`). const c = RemoteSettings("test-collection", { bucketName: "test-bucket", }); let maybeSyncCalled = false; c.maybeSync = () => { maybeSyncCalled = true; }; // Ensure that the remote-settings:changes-poll-end notification works let notificationObserved = false; const observer = { observe() { Services.obs.removeObserver(this, "remote-settings:changes-poll-end"); notificationObserved = true; }, }; Services.obs.addObserver(observer, "remote-settings:changes-poll-end"); await RemoteSettings.pollChanges(); // It didn't fail, hence we are sure that the unknown collection ``some-other-bucket/test-collection`` // was ignored, otherwise it would have tried to reach the network. Assert.ok(maybeSyncCalled, "maybeSync was called"); Assert.ok(notificationObserved, "a notification should have been observed"); // Last timestamp was saved. An ETag header value is a quoted string. Assert.equal(Services.prefs.getStringPref(PREF_LAST_ETAG), '"1100"'); // check the last_update is updated Assert.equal(Services.prefs.getIntPref(PREF_LAST_UPDATE), serverTime / 1000); // ensure that we've accumulated the correct telemetry TelemetryTestUtils.assertEvents( [ [ "uptake.remotecontent.result", "uptake", "remotesettings", UptakeTelemetry.STATUS.SUCCESS, { source: TELEMETRY_SOURCE_POLL, trigger: "manual", }, ], [ "uptake.remotecontent.result", "uptake", "remotesettings", UptakeTelemetry.STATUS.SUCCESS, { source: TELEMETRY_SOURCE_SYNC, trigger: "manual", }, ], ], TELEMETRY_EVENTS_FILTERS ); }); add_task(clear_state); add_task(async function test_update_timer_interface() { const remoteSettings = Cc["@mozilla.org/services/settings;1"].getService( Ci.nsITimerCallback ); const serverTime = 8000; server.registerPathHandler( CHANGES_PATH, serveChangesEntries(serverTime, [ { id: "028261ad-16d4-40c2-a96a-66f72914d125", last_modified: 42, host: "localhost", bucket: "main", collection: "whatever-collection", }, ]) ); await new Promise(resolve => { const e = "remote-settings:changes-poll-end"; const changesPolledObserver = { observe() { Services.obs.removeObserver(this, e); resolve(); }, }; Services.obs.addObserver(changesPolledObserver, e); remoteSettings.notify(null); }); // Everything went fine. Assert.equal(Services.prefs.getStringPref(PREF_LAST_ETAG), '"42"'); Assert.equal(Services.prefs.getIntPref(PREF_LAST_UPDATE), serverTime / 1000); }); add_task(clear_state); add_task(async function test_check_up_to_date() { // Simulate a poll with up-to-date collection. const startSnapshot = getUptakeTelemetrySnapshot( TELEMETRY_COMPONENT, TELEMETRY_SOURCE_POLL ); const serverTime = 4000; server.registerPathHandler(CHANGES_PATH, serveChangesEntries(serverTime, [])); Services.prefs.setStringPref(PREF_LAST_ETAG, '"1100"'); // Ensure that the remote-settings:changes-poll-end notification is sent. let notificationObserved = false; const observer = { observe() { Services.obs.removeObserver(this, "remote-settings:changes-poll-end"); notificationObserved = true; }, }; Services.obs.addObserver(observer, "remote-settings:changes-poll-end"); // If server has no change, maybeSync() is not called. let maybeSyncCalled = false; const c = RemoteSettings("test-collection", { bucketName: "test-bucket", }); c.maybeSync = () => { maybeSyncCalled = true; }; await RemoteSettings.pollChanges(); Assert.ok(notificationObserved, "a notification should have been observed"); Assert.ok(!maybeSyncCalled, "maybeSync should not be called"); // Last update is overwritten Assert.equal(Services.prefs.getIntPref(PREF_LAST_UPDATE), serverTime / 1000); // ensure that we've accumulated the correct telemetry const endSnapshot = getUptakeTelemetrySnapshot( TELEMETRY_COMPONENT, TELEMETRY_SOURCE_POLL ); const expectedIncrements = { [UptakeTelemetry.STATUS.UP_TO_DATE]: 1, }; checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements); }); add_task(clear_state); add_task(async function test_expected_timestamp() { function withCacheBust(request, response) { const entries = [ { id: "695c2407-de79-4408-91c7-70720dd59d78", last_modified: 1100, host: "localhost", bucket: "main", collection: "with-cache-busting", }, ]; if ( request.queryString.includes(`_expected=${encodeURIComponent('"42"')}`) ) { response.write( JSON.stringify({ timestamp: 1110, changes: entries, }) ); } response.setHeader("Content-Type", "application/json; charset=UTF-8"); response.setHeader("ETag", '"1100"'); response.setHeader("Date", new Date().toUTCString()); response.setStatusLine(null, 200, "OK"); } server.registerPathHandler(CHANGES_PATH, withCacheBust); const c = RemoteSettings("with-cache-busting"); let maybeSyncCalled = false; c.maybeSync = () => { maybeSyncCalled = true; }; await RemoteSettings.pollChanges({ expectedTimestamp: '"42"' }); Assert.ok(maybeSyncCalled, "maybeSync was called"); }); add_task(clear_state); add_task(async function test_client_last_check_is_saved() { server.registerPathHandler(CHANGES_PATH, (request, response) => { response.write( JSON.stringify({ timestamp: 42, changes: [ { id: "695c2407-de79-4408-91c7-70720dd59d78", last_modified: 1100, host: "localhost", bucket: "main", collection: "models-recipes", }, ], }) ); response.setHeader("Content-Type", "application/json; charset=UTF-8"); response.setHeader("ETag", '"42"'); response.setHeader("Date", new Date().toUTCString()); response.setStatusLine(null, 200, "OK"); }); const c = RemoteSettings("models-recipes"); c.maybeSync = () => {}; equal( c.lastCheckTimePref, "services.settings.main.models-recipes.last_check" ); Services.prefs.setIntPref(c.lastCheckTimePref, 0); await RemoteSettings.pollChanges({ expectedTimestamp: '"42"' }); notEqual(Services.prefs.getIntPref(c.lastCheckTimePref), 0); }); add_task(clear_state); const TELEMETRY_EVENTS_FILTERS = { category: "uptake.remotecontent.result", method: "uptake", }; add_task(async function test_age_of_data_is_reported_in_uptake_status() { const serverTime = 1552323900000; const recordsTimestamp = serverTime - 3600 * 1000; server.registerPathHandler( CHANGES_PATH, serveChangesEntries(serverTime, [ { id: "b6ba7fab-a40a-4d03-a4af-6b627f3c5b36", last_modified: recordsTimestamp, host: "localhost", bucket: "main", collection: "some-entry", }, ]) ); await RemoteSettings.pollChanges(); TelemetryTestUtils.assertEvents( [ [ "uptake.remotecontent.result", "uptake", "remotesettings", UptakeTelemetry.STATUS.SUCCESS, { source: TELEMETRY_SOURCE_POLL, age: "3600", trigger: "manual", }, ], [ "uptake.remotecontent.result", "uptake", "remotesettings", UptakeTelemetry.STATUS.SUCCESS, { source: TELEMETRY_SOURCE_SYNC, duration: () => true, trigger: "manual", timestamp: `"${recordsTimestamp}"`, }, ], ], TELEMETRY_EVENTS_FILTERS ); }); add_task(clear_state); add_task( async function test_synchronization_duration_is_reported_in_uptake_status() { server.registerPathHandler( CHANGES_PATH, serveChangesEntries(10000, [ { id: "b6ba7fab-a40a-4d03-a4af-6b627f3c5b36", last_modified: 42, host: "localhost", bucket: "main", collection: "some-entry", }, ]) ); const c = RemoteSettings("some-entry"); // Simulate a synchronization that lasts 1 sec. // eslint-disable-next-line mozilla/no-arbitrary-setTimeout c.maybeSync = () => new Promise(resolve => setTimeout(resolve, 1000)); await RemoteSettings.pollChanges(); TelemetryTestUtils.assertEvents( [ [ "uptake.remotecontent.result", "uptake", "remotesettings", "success", { source: TELEMETRY_SOURCE_POLL, age: () => true, trigger: "manual", }, ], [ "uptake.remotecontent.result", "uptake", "remotesettings", "success", { source: TELEMETRY_SOURCE_SYNC, duration: v => v >= 1000, trigger: "manual", }, ], ], TELEMETRY_EVENTS_FILTERS ); } ); add_task(clear_state); add_task(async function test_success_with_partial_list() { function partialList(request, response) { const entries = [ { id: "028261ad-16d4-40c2-a96a-66f72914d125", last_modified: 43, host: "localhost", bucket: "main", collection: "cid-1", }, { id: "98a34576-bcd6-423f-abc2-1d290b776ed8", last_modified: 42, host: "localhost", bucket: "main", collection: "poll-test-collection", }, ]; if (request.queryString.includes(`_since=${encodeURIComponent('"42"')}`)) { response.write( JSON.stringify({ timestamp: 43, changes: entries.slice(0, 1), }) ); } else { response.write( JSON.stringify({ timestamp: 42, changes: entries, }) ); } response.setHeader("Content-Type", "application/json; charset=UTF-8"); response.setHeader("Date", new Date().toUTCString()); response.setStatusLine(null, 200, "OK"); } server.registerPathHandler(CHANGES_PATH, partialList); const c = RemoteSettings("poll-test-collection"); let maybeSyncCount = 0; c.maybeSync = () => { maybeSyncCount++; }; await RemoteSettings.pollChanges(); await RemoteSettings.pollChanges(); // On the second call, the server does not mention the poll-test-collection // and maybeSync() is not called. Assert.equal(maybeSyncCount, 1, "maybeSync should not be called twice"); }); add_task(clear_state); add_task(async function test_full_polling() { server.registerPathHandler( CHANGES_PATH, serveChangesEntries(10000, [ { id: "b6ba7fab-a40a-4d03-a4af-6b627f3c5b36", last_modified: 42, host: "localhost", bucket: "main", collection: "poll-test-collection", }, ]) ); const c = RemoteSettings("poll-test-collection"); let maybeSyncCount = 0; c.maybeSync = () => { maybeSyncCount++; }; await RemoteSettings.pollChanges(); await RemoteSettings.pollChanges({ full: true }); // Since the second call is full, clients are called Assert.equal(maybeSyncCount, 2, "maybeSync should be called twice"); }); add_task(clear_state); add_task(async function test_server_bad_json() { const startSnapshot = getUptakeTelemetrySnapshot( TELEMETRY_COMPONENT, TELEMETRY_SOURCE_POLL ); function simulateBadJSON(request, response) { response.setHeader("Content-Type", "application/json; charset=UTF-8"); response.write(""); response.setStatusLine(null, 200, "OK"); } server.registerPathHandler(CHANGES_PATH, simulateBadJSON); let error; try { await RemoteSettings.pollChanges(); } catch (e) { error = e; } Assert.ok(/JSON.parse: unexpected character/.test(error.message)); const endSnapshot = getUptakeTelemetrySnapshot( TELEMETRY_COMPONENT, TELEMETRY_SOURCE_POLL ); const expectedIncrements = { [UptakeTelemetry.STATUS.PARSE_ERROR]: 1, }; checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements); }); add_task(clear_state); add_task(async function test_server_bad_content_type() { const startSnapshot = getUptakeTelemetrySnapshot( TELEMETRY_COMPONENT, TELEMETRY_SOURCE_POLL ); function simulateBadContentType(request, response) { response.setHeader("Content-Type", "text/html"); response.write(""); response.setStatusLine(null, 200, "OK"); } server.registerPathHandler(CHANGES_PATH, simulateBadContentType); let error; try { await RemoteSettings.pollChanges(); } catch (e) { error = e; } Assert.ok(/Unexpected content-type/.test(error.message)); const endSnapshot = getUptakeTelemetrySnapshot( TELEMETRY_COMPONENT, TELEMETRY_SOURCE_POLL ); const expectedIncrements = { [UptakeTelemetry.STATUS.CONTENT_ERROR]: 1, }; checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements); }); add_task(clear_state); add_task(async function test_server_404_response() { function simulateDummy404(request, response) { response.setHeader("Content-Type", "text/html; charset=UTF-8"); response.write(""); response.setStatusLine(null, 404, "OK"); } server.registerPathHandler(CHANGES_PATH, simulateDummy404); await RemoteSettings.pollChanges(); // Does not fail when running from tests. }); add_task(clear_state); add_task(async function test_server_error() { const startSnapshot = getUptakeTelemetrySnapshot( TELEMETRY_COMPONENT, TELEMETRY_SOURCE_POLL ); // Simulate a server error. function simulateErrorResponse(request, response) { response.setHeader("Date", new Date(3000).toUTCString()); response.setHeader("Content-Type", "application/json; charset=UTF-8"); response.write( JSON.stringify({ code: 503, errno: 999, error: "Service Unavailable", }) ); response.setStatusLine(null, 503, "Service Unavailable"); } server.registerPathHandler(CHANGES_PATH, simulateErrorResponse); let notificationObserved = false; const observer = { observe() { Services.obs.removeObserver(this, "remote-settings:changes-poll-end"); notificationObserved = true; }, }; Services.obs.addObserver(observer, "remote-settings:changes-poll-end"); Services.prefs.setIntPref(PREF_LAST_UPDATE, 42); // pollChanges() fails with adequate error and no notification. let error; try { await RemoteSettings.pollChanges(); } catch (e) { error = e; } Assert.ok( !notificationObserved, "a notification should not have been observed" ); Assert.ok(/Polling for changes failed/.test(error.message)); // When an error occurs, last update was not overwritten. Assert.equal(Services.prefs.getIntPref(PREF_LAST_UPDATE), 42); // ensure that we've accumulated the correct telemetry const endSnapshot = getUptakeTelemetrySnapshot( TELEMETRY_COMPONENT, TELEMETRY_SOURCE_POLL ); const expectedIncrements = { [UptakeTelemetry.STATUS.SERVER_ERROR]: 1, }; checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements); }); add_task(clear_state); add_task(async function test_server_error_5xx() { const startSnapshot = getUptakeTelemetrySnapshot( TELEMETRY_COMPONENT, TELEMETRY_SOURCE_POLL ); function simulateErrorResponse(request, response) { response.setHeader("Date", new Date(3000).toUTCString()); response.setHeader("Content-Type", "text/html; charset=UTF-8"); response.write(""); response.setStatusLine(null, 504, "Gateway Timeout"); } server.registerPathHandler(CHANGES_PATH, simulateErrorResponse); try { await RemoteSettings.pollChanges(); } catch (e) {} const endSnapshot = getUptakeTelemetrySnapshot( TELEMETRY_COMPONENT, TELEMETRY_SOURCE_POLL ); const expectedIncrements = { [UptakeTelemetry.STATUS.SERVER_ERROR]: 1, }; checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements); }); add_task(clear_state); add_task(async function test_server_error_4xx() { function simulateErrorResponse(request, response) { response.setHeader("Date", new Date(3000).toUTCString()); response.setHeader("Content-Type", "application/json; charset=UTF-8"); if (request.queryString.includes(`_since=${encodeURIComponent('"abc"')}`)) { response.setStatusLine(null, 400, "Bad Request"); response.write(JSON.stringify({})); } else { response.setStatusLine(null, 200, "OK"); response.write(JSON.stringify({ changes: [] })); } } server.registerPathHandler(CHANGES_PATH, simulateErrorResponse); Services.prefs.setStringPref(PREF_LAST_ETAG, '"abc"'); let error; try { await RemoteSettings.pollChanges(); } catch (e) { error = e; } Assert.ok(error.message.includes("400 Bad Request"), "Polling failed"); Assert.ok( !Services.prefs.prefHasUserValue(PREF_LAST_ETAG), "Last ETag pref was cleared" ); await RemoteSettings.pollChanges(); // Does not raise. }); add_task(clear_state); add_task(async function test_client_error() { const startSnapshot = getUptakeTelemetrySnapshot( TELEMETRY_COMPONENT, TELEMETRY_SOURCE_SYNC ); const collectionDetails = { id: "b6ba7fab-a40a-4d03-a4af-6b627f3c5b36", last_modified: 42, host: "localhost", bucket: "main", collection: "some-entry", }; server.registerPathHandler( CHANGES_PATH, serveChangesEntries(10000, [collectionDetails]) ); const c = RemoteSettings("some-entry"); c.maybeSync = () => { throw new RemoteSettingsClient.CorruptedDataError("main/some-entry"); }; let notificationsObserved = []; const observer = { observe(aSubject, aTopic) { Services.obs.removeObserver(this, aTopic); notificationsObserved.push([aTopic, aSubject.wrappedJSObject]); }, }; Services.obs.addObserver(observer, "remote-settings:changes-poll-end"); Services.obs.addObserver(observer, "remote-settings:sync-error"); Services.prefs.setIntPref(PREF_LAST_ETAG, 42); // pollChanges() fails with adequate error and a sync-error notification. let error; try { await RemoteSettings.pollChanges(); } catch (e) { error = e; } Assert.equal( notificationsObserved.length, 1, "only the error notification should not have been observed" ); console.log(notificationsObserved); let [topicObserved, subjectObserved] = notificationsObserved[0]; Assert.equal(topicObserved, "remote-settings:sync-error"); Assert.ok( subjectObserved.error instanceof RemoteSettingsClient.CorruptedDataError, `original error is provided (got ${subjectObserved.error})` ); Assert.deepEqual( subjectObserved.error.details, collectionDetails, "information about collection is provided" ); Assert.ok(/Corrupted/.test(error.message), "original client error is thrown"); // When an error occurs, last etag was not overwritten. Assert.equal(Services.prefs.getIntPref(PREF_LAST_ETAG), 42); // ensure that we've accumulated the correct telemetry const endSnapshot = getUptakeTelemetrySnapshot( TELEMETRY_COMPONENT, TELEMETRY_SOURCE_SYNC ); const expectedIncrements = { [UptakeTelemetry.STATUS.SYNC_ERROR]: 1, }; checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements); }); add_task(clear_state); add_task(async function test_sync_success_is_stored_in_history() { const collectionDetails = { last_modified: 444, bucket: "main", collection: "desktop-manager", }; server.registerPathHandler( CHANGES_PATH, serveChangesEntries(10000, [collectionDetails]) ); const c = RemoteSettings("desktop-manager"); c.maybeSync = () => {}; try { await RemoteSettings.pollChanges({ expectedTimestamp: 555 }); } catch (e) {} const { history } = await RemoteSettings.inspect(); Assert.deepEqual(history, { [TELEMETRY_SOURCE_SYNC]: [ { timestamp: 444, status: "success", infos: {}, datetime: new Date(444), }, ], }); }); add_task(clear_state); add_task(async function test_sync_error_is_stored_in_history() { const collectionDetails = { last_modified: 1337, bucket: "main", collection: "desktop-manager", }; server.registerPathHandler( CHANGES_PATH, serveChangesEntries(10000, [collectionDetails]) ); const c = RemoteSettings("desktop-manager"); c.maybeSync = () => { throw new RemoteSettingsClient.MissingSignatureError( "main/desktop-manager" ); }; try { await RemoteSettings.pollChanges({ expectedTimestamp: 123456 }); } catch (e) {} const { history } = await RemoteSettings.inspect(); Assert.deepEqual(history, { [TELEMETRY_SOURCE_SYNC]: [ { timestamp: 1337, status: "sync_error", infos: { expectedTimestamp: 123456, errorName: "MissingSignatureError", }, datetime: new Date(1337), }, ], }); }); add_task(clear_state); add_task( async function test_sync_broken_signal_is_sent_on_consistent_failure() { const startSnapshot = getUptakeTelemetrySnapshot( TELEMETRY_COMPONENT, TELEMETRY_SOURCE_POLL ); // Wait for the "sync-broken-error" notification. let notificationObserved = false; const observer = { observe() { notificationObserved = true; }, }; Services.obs.addObserver(observer, "remote-settings:broken-sync-error"); // Register a client with a failing sync method. const c = RemoteSettings("desktop-manager"); c.maybeSync = () => { throw new RemoteSettingsClient.InvalidSignatureError( "main/desktop-manager" ); }; // Simulate a response whose ETag gets incremented on each call // (in order to generate several history entries, indexed by timestamp). let timestamp = 1337; server.registerPathHandler( CHANGES_PATH, serveChangesEntries(10000, () => { return [ { last_modified: ++timestamp, bucket: "main", collection: "desktop-manager", }, ]; }) ); // Now obtain several failures in a row (less than threshold). for (var i = 0; i < 9; i++) { try { await RemoteSettings.pollChanges(); } catch (e) {} } Assert.ok(!notificationObserved, "Not notified yet"); // Fail again once. Will now notify. try { await RemoteSettings.pollChanges(); } catch (e) {} Assert.ok(notificationObserved, "Broken sync notified"); // Uptake event to notify broken sync is sent. const endSnapshot = getUptakeTelemetrySnapshot( TELEMETRY_COMPONENT, TELEMETRY_SOURCE_SYNC ); const expectedIncrements = { [UptakeTelemetry.STATUS.SYNC_ERROR]: 10, [UptakeTelemetry.STATUS.SYNC_BROKEN_ERROR]: 1, }; checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements); // Synchronize successfully. notificationObserved = false; const failingSync = c.maybeSync; c.maybeSync = () => {}; await RemoteSettings.pollChanges(); const { history } = await RemoteSettings.inspect(); Assert.equal( history[TELEMETRY_SOURCE_SYNC][0].status, UptakeTelemetry.STATUS.SUCCESS, "Last sync is success" ); Assert.ok(!notificationObserved, "Not notified after success"); // Now fail again. Broken sync isn't notified, we need several in a row. c.maybeSync = failingSync; try { await RemoteSettings.pollChanges(); } catch (e) {} Assert.ok(!notificationObserved, "Not notified on single error"); Services.obs.removeObserver(observer, "remote-settings:broken-sync-error"); } ); add_task(clear_state); add_task(async function test_check_clockskew_is_updated() { const serverTime = 2000; function serverResponse(request, response) { response.setHeader("Content-Type", "application/json; charset=UTF-8"); response.setHeader("Date", new Date(serverTime).toUTCString()); response.write(JSON.stringify({ timestamp: 42, changes: [] })); response.setStatusLine(null, 200, "OK"); } server.registerPathHandler(CHANGES_PATH, serverResponse); let startTime = Date.now(); await RemoteSettings.pollChanges(); // How does the clock difference look? let endTime = Date.now(); let clockDifference = Services.prefs.getIntPref(PREF_CLOCK_SKEW_SECONDS); // we previously set the serverTime to 2 (seconds past epoch) Assert.ok( clockDifference <= endTime / 1000 && clockDifference >= Math.floor(startTime / 1000) - serverTime / 1000 ); // check negative clock skew times // set to a time in the future server.registerPathHandler( CHANGES_PATH, serveChangesEntries(Date.now() + 10000, []) ); await RemoteSettings.pollChanges(); clockDifference = Services.prefs.getIntPref(PREF_CLOCK_SKEW_SECONDS); // we previously set the serverTime to Date.now() + 10000 ms past epoch Assert.ok(clockDifference <= 0 && clockDifference >= -10); }); add_task(clear_state); add_task(async function test_check_clockskew_takes_age_into_account() { const currentTime = Date.now(); const skewSeconds = 5; const ageCDNSeconds = 3600; const serverTime = currentTime - skewSeconds * 1000 - ageCDNSeconds * 1000; function serverResponse(request, response) { response.setHeader("Content-Type", "application/json; charset=UTF-8"); response.setHeader("Date", new Date(serverTime).toUTCString()); response.setHeader("Age", `${ageCDNSeconds}`); response.write(JSON.stringify({ timestamp: 42, changes: [] })); response.setStatusLine(null, 200, "OK"); } server.registerPathHandler(CHANGES_PATH, serverResponse); await RemoteSettings.pollChanges(); const clockSkew = Services.prefs.getIntPref(PREF_CLOCK_SKEW_SECONDS); Assert.ok(clockSkew >= skewSeconds, `clockSkew is ${clockSkew}`); }); add_task(clear_state); add_task(async function test_backoff() { const startSnapshot = getUptakeTelemetrySnapshot( TELEMETRY_COMPONENT, TELEMETRY_SOURCE_POLL ); function simulateBackoffResponse(request, response) { response.setHeader("Content-Type", "application/json; charset=UTF-8"); response.setHeader("Backoff", "10"); response.write(JSON.stringify({ timestamp: 42, changes: [] })); response.setStatusLine(null, 200, "OK"); } server.registerPathHandler(CHANGES_PATH, simulateBackoffResponse); // First will work. await RemoteSettings.pollChanges(); // Second will fail because we haven't waited. try { await RemoteSettings.pollChanges(); // The previous line should have thrown an error. Assert.ok(false); } catch (e) { Assert.ok( /Server is asking clients to back off; retry in \d+s./.test(e.message) ); } // Once backoff time has expired, polling for changes can start again. server.registerPathHandler( CHANGES_PATH, serveChangesEntries(12000, [ { id: "6a733d4a-601e-11e8-837a-0f85257529a1", last_modified: 1300, host: "localhost", bucket: "some-bucket", collection: "some-collection", }, ]) ); Services.prefs.setStringPref( PREF_SETTINGS_SERVER_BACKOFF, `${Date.now() - 1000}` ); await RemoteSettings.pollChanges(); // Backoff tracking preference was cleared. Assert.ok(!Services.prefs.prefHasUserValue(PREF_SETTINGS_SERVER_BACKOFF)); // Ensure that we've accumulated the correct telemetry const endSnapshot = getUptakeTelemetrySnapshot( TELEMETRY_COMPONENT, TELEMETRY_SOURCE_POLL ); const expectedIncrements = { [UptakeTelemetry.STATUS.SUCCESS]: 1, [UptakeTelemetry.STATUS.UP_TO_DATE]: 1, [UptakeTelemetry.STATUS.BACKOFF]: 1, }; checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements); }); add_task(clear_state); add_task(async function test_network_error() { const startSnapshot = getUptakeTelemetrySnapshot( TELEMETRY_COMPONENT, TELEMETRY_SOURCE_POLL ); // Simulate a network error (to check telemetry report). Services.prefs.setStringPref(PREF_SETTINGS_SERVER, "http://localhost:42/v1"); try { await RemoteSettings.pollChanges(); } catch (e) {} // ensure that we've accumulated the correct telemetry const endSnapshot = getUptakeTelemetrySnapshot( TELEMETRY_COMPONENT, TELEMETRY_SOURCE_POLL ); const expectedIncrements = { [UptakeTelemetry.STATUS.NETWORK_ERROR]: 1, }; checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements); }); add_task(clear_state); add_task(async function test_syncs_clients_with_local_database() { server.registerPathHandler( CHANGES_PATH, serveChangesEntries(42000, [ { id: "d4a14f44-601f-11e8-8b8a-030f3dc5b844", last_modified: 10000, host: "localhost", bucket: "main", collection: "some-unknown", }, { id: "39f57e4e-6023-11e8-8b74-77c8dedfb389", last_modified: 9000, host: "localhost", bucket: "blocklists", collection: "addons", }, { id: "9a594c1a-601f-11e8-9c8a-33b2239d9113", last_modified: 8000, host: "localhost", bucket: "main", collection: "recipes", }, ]) ); // This simulates what remote-settings would do when initializing a local database. // We don't want to instantiate a client using the RemoteSettings() API // since we want to test «unknown» clients that have a local database. new RemoteSettingsClient("addons", { bucketName: "blocklists", }).db.importChanges({}, 42); new RemoteSettingsClient("recipes").db.importChanges({}, 43); let error; try { await RemoteSettings.pollChanges(); Assert.ok(false, "pollChange() should throw when pulling recipes"); } catch (e) { error = e; } // The `main/some-unknown` should be skipped because it has no local database. // The `blocklists/addons` should be skipped because it is not the main bucket. // The `recipes` has a local database, and should cause a network error because the test // does not setup the server to receive the requests of `maybeSync()`. Assert.ok(/HTTP 404/.test(error.message), "server will return 404 on sync"); Assert.equal(error.details.collection, "recipes"); }); add_task(clear_state); add_task(async function test_syncs_clients_with_local_dump() { if (IS_ANDROID) { // Skip test: we don't ship remote settings dumps on Android (see package-manifest). return; } server.registerPathHandler( CHANGES_PATH, serveChangesEntries(42000, [ { id: "d4a14f44-601f-11e8-8b8a-030f3dc5b844", last_modified: 10000, host: "localhost", bucket: "main", collection: "some-unknown", }, { id: "39f57e4e-6023-11e8-8b74-77c8dedfb389", last_modified: 9000, host: "localhost", bucket: "blocklists", collection: "addons", }, { id: "9a594c1a-601f-11e8-9c8a-33b2239d9113", last_modified: 8000, host: "localhost", bucket: "main", collection: "example", }, ]) ); let error; try { await RemoteSettings.pollChanges(); } catch (e) { error = e; } // The `main/some-unknown` should be skipped because it has no dump. // The `blocklists/addons` should be skipped because it is not the main bucket. // The `example` has a dump, and should cause a network error because the test // does not setup the server to receive the requests of `maybeSync()`. Assert.ok(/HTTP 404/.test(error.message), "server will return 404 on sync"); Assert.equal(error.details.collection, "example"); }); add_task(clear_state); add_task(async function test_adding_client_resets_polling() { function serve200(request, response) { const entries = [ { id: "aa71e6cc-9f37-447a-b6e0-c025e8eabd03", last_modified: 42, host: "localhost", bucket: "main", collection: "a-collection", }, ]; if (request.queryString.includes("_since")) { response.write( JSON.stringify({ timestamp: 42, changes: [], }) ); } else { response.write( JSON.stringify({ timestamp: 42, changes: entries, }) ); } response.setStatusLine(null, 200, "OK"); response.setHeader("Content-Type", "application/json; charset=UTF-8"); response.setHeader("Date", new Date().toUTCString()); } server.registerPathHandler(CHANGES_PATH, serve200); // Poll once, without any client for "a-collection" await RemoteSettings.pollChanges(); // Register a new client. let maybeSyncCalled = false; const c = RemoteSettings("a-collection"); c.maybeSync = () => { maybeSyncCalled = true; }; // Poll again. await RemoteSettings.pollChanges(); // The new client was called, even if the server data didn't change. Assert.ok(maybeSyncCalled); // Poll again. This time maybeSync() won't be called. maybeSyncCalled = false; await RemoteSettings.pollChanges(); Assert.ok(!maybeSyncCalled); }); add_task(clear_state); add_task( async function test_broadcast_handler_passes_version_and_trigger_values() { // The polling will use the broadcast version as cache busting query param. let passedQueryString; function serveCacheBusted(request, response) { passedQueryString = request.queryString; const entries = [ { id: "b6ba7fab-a40a-4d03-a4af-6b627f3c5b36", last_modified: 42, host: "localhost", bucket: "main", collection: "from-broadcast", }, ]; response.write( JSON.stringify({ changes: entries, timestamp: 42, }) ); response.setHeader("ETag", '"42"'); response.setStatusLine(null, 200, "OK"); response.setHeader("Content-Type", "application/json; charset=UTF-8"); response.setHeader("Date", new Date().toUTCString()); } server.registerPathHandler(CHANGES_PATH, serveCacheBusted); let passedTrigger; const c = RemoteSettings("from-broadcast"); c.maybeSync = (last_modified, { trigger }) => { passedTrigger = trigger; }; const version = "1337"; let context = { phase: pushBroadcastService.PHASES.HELLO }; await remoteSettingsBroadcastHandler.receivedBroadcastMessage( version, BROADCAST_ID, context ); Assert.equal(passedTrigger, "startup"); Assert.equal(passedQueryString, `_expected=${version}`); clear_state(); context = { phase: pushBroadcastService.PHASES.REGISTER }; await remoteSettingsBroadcastHandler.receivedBroadcastMessage( version, BROADCAST_ID, context ); Assert.equal(passedTrigger, "startup"); clear_state(); context = { phase: pushBroadcastService.PHASES.BROADCAST }; await remoteSettingsBroadcastHandler.receivedBroadcastMessage( version, BROADCAST_ID, context ); Assert.equal(passedTrigger, "broadcast"); } ); add_task(clear_state);