diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /services/settings/test/unit/test_remote_settings_poll.js | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r-- | services/settings/test/unit/test_remote_settings_poll.js | 1386 |
1 files changed, 1386 insertions, 0 deletions
diff --git a/services/settings/test/unit/test_remote_settings_poll.js b/services/settings/test/unit/test_remote_settings_poll.js new file mode 100644 index 0000000000..5afe5e41bd --- /dev/null +++ b/services/settings/test/unit/test_remote_settings_poll.js @@ -0,0 +1,1386 @@ +/* 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.import( + "resource://gre/modules/PushBroadcastService.jsm" +); +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.setCharPref( + 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(aSubject, aTopic, aData) { + 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.getCharPref(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(aSubject, aTopic, aData) { + Services.obs.removeObserver(this, e); + resolve(); + }, + }; + Services.obs.addObserver(changesPolledObserver, e); + remoteSettings.notify(null); + }); + + // Everything went fine. + Assert.equal(Services.prefs.getCharPref(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.setCharPref(PREF_LAST_ETAG, '"1100"'); + + // Ensure that the remote-settings:changes-poll-end notification is sent. + let notificationObserved = false; + const observer = { + observe(aSubject, aTopic, aData) { + 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("<html></html>"); + 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("<html></html>"); + 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("<html></html>"); + 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(aSubject, aTopic, aData) { + 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("<html></html>"); + 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.setCharPref(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, aData) { + 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(aSubject, aTopic, aData) { + 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.setCharPref( + 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.setCharPref(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); |