Backed out changeset eadf17764c12 (bug 1454970) for XPCShell Failure on services/settings/test/unit/test_remote_settings_poll.js. CLOSED TREE
authorDorel Luca <dluca@mozilla.com>
Tue, 05 Jun 2018 02:32:32 +0300
changeset 804126 cba6d551e43e6a96eee178b5342225368a4b3130
parent 804125 8d28a06d98e77c9f422273894e96b1f8d499ab78
child 804127 e5ac0c7a9fb49d3d011095283014bae7324361d0
push id112312
push userbmo:standard8@mozilla.com
push dateTue, 05 Jun 2018 16:07:56 +0000
bugs1454970
milestone62.0a1
backs outeadf17764c12fdc70a560a549e649ee4960b0128
Backed out changeset eadf17764c12 (bug 1454970) for XPCShell Failure on services/settings/test/unit/test_remote_settings_poll.js. CLOSED TREE
services/settings/remote-settings.js
services/settings/test/unit/test_remote_settings_poll.js
--- a/services/settings/remote-settings.js
+++ b/services/settings/remote-settings.js
@@ -7,17 +7,17 @@
 var EXPORTED_SYMBOLS = [
   "RemoteSettings",
   "jexlFilterFunc"
 ];
 
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm", {});
-Cu.importGlobalProperties(["fetch", "indexedDB"]);
+Cu.importGlobalProperties(["fetch"]);
 
 ChromeUtils.defineModuleGetter(this, "Kinto",
                                "resource://services-common/kinto-offline-client.js");
 ChromeUtils.defineModuleGetter(this, "KintoHttpClient",
                                "resource://services-common/kinto-http-client.js");
 ChromeUtils.defineModuleGetter(this, "CanonicalJSON",
                                "resource://gre/modules/CanonicalJSON.jsm");
 ChromeUtils.defineModuleGetter(this, "UptakeTelemetry",
@@ -173,31 +173,16 @@ async function fetchLatestChanges(url, l
     if (!isNaN(value)) {
       backoffSeconds = value;
     }
   }
 
   return {changes, currentEtag, serverTimeMillis, backoffSeconds};
 }
 
-/**
- * Load the the JSON file distributed with the release for this collection.
- */
-async function loadDumpFile(filename) {
-  // Replace OS specific path separator by / for URI.
-  const { components: folderFile } = OS.Path.split(filename);
-  const fileURI = `resource://app/defaults/settings/${folderFile.join("/")}`;
-  const response = await fetch(fileURI);
-  if (!response.ok) {
-    throw new Error(`Could not read from '${fileURI}'`);
-  }
-  // Will be rejected if JSON is invalid.
-  return response.json();
-}
-
 
 class RemoteSettingsClient {
 
   constructor(collectionName, { bucketName, signerName, filterFunc = jexlFilterFunc, lastCheckTimePref }) {
     this.collectionName = collectionName;
     this.bucketName = bucketName;
     this.signerName = signerName;
     this.filterFunc = filterFunc;
@@ -285,18 +270,18 @@ class RemoteSettingsClient {
     const { filters = {}, order } = options;
     const c = await this.openCollection();
 
     const timestamp = await c.db.getLastModified();
     // If the local database was never synchronized, then we attempt to load
     // a packaged JSON dump.
     if (timestamp == null) {
       try {
-        const { data } = await loadDumpFile(this.filename);
-        await c.loadDump(data);
+         const { data } = await this._loadDumpFile();
+         await c.loadDump(data);
       } catch (e) {
         // Report but return an empty list since there will be no data anyway.
         Cu.reportError(e);
         return [];
       }
     }
 
     const { data } = await c.list({ filters, order });
@@ -336,17 +321,17 @@ class RemoteSettingsClient {
       let collectionLastModified = await collection.db.getLastModified();
 
       // If there is no data currently in the collection, attempt to import
       // initial data from the application defaults.
       // This allows to avoid synchronizing the whole collection content on
       // cold start.
       if (!collectionLastModified && loadDump) {
         try {
-          const initialData = await loadDumpFile(this.filename);
+          const initialData = await this._loadDumpFile();
           await collection.loadDump(initialData.data);
           collectionLastModified = await collection.db.getLastModified();
         } catch (e) {
           // Report but go-on.
           Cu.reportError(e);
         }
       }
 
@@ -472,16 +457,31 @@ class RemoteSettingsClient {
       if (reportStatus === null) {
         reportStatus = UptakeTelemetry.STATUS.SUCCESS;
       }
       // Report success/error status to Telemetry.
       UptakeTelemetry.report(this.identifier, reportStatus);
     }
   }
 
+  /**
+   * Load the the JSON file distributed with the release for this collection.
+   */
+  async _loadDumpFile() {
+    // Replace OS specific path separator by / for URI.
+    const { components: folderFile } = OS.Path.split(this.filename);
+    const fileURI = `resource://app/defaults/settings/${folderFile.join("/")}`;
+    const response = await fetch(fileURI);
+    if (!response.ok) {
+      throw new Error(`Could not read from '${fileURI}'`);
+    }
+    // Will be rejected if JSON is invalid.
+    return response.json();
+  }
+
   async _validateCollectionSignature(remote, payload, collection, options = {}) {
     const {ignoreLocal} = options;
     // this is a content-signature field from an autograph response.
     const signaturePayload = await fetchCollectionMetadata(remote, collection);
     if (!signaturePayload) {
       throw new Error(MISSING_SIGNATURE);
     }
     const {x5u, signature} = signaturePayload;
@@ -534,60 +534,16 @@ class RemoteSettingsClient {
     }
     const environment = cacheProxy(ClientEnvironment);
     const dataPromises = data.map(e => this.filterFunc(e, environment));
     const results = await Promise.all(dataPromises);
     return results.filter(v => !!v);
   }
 }
 
-/**
- * Check if an IndexedDB database exists for the specified bucket and collection.
- *
- * @param {String} bucket
- * @param {String} collection
- * @return {bool} Whether it exists or not.
- */
-async function databaseExists(bucket, collection) {
-  // The dbname is chosen by kinto.js from the bucket and collection names.
-  // https://github.com/Kinto/kinto.js/blob/41aa1526e/src/collection.js#L231
-  const dbname = `${bucket}/${collection}`;
-  try {
-    await new Promise((resolve, reject) => {
-      const request = indexedDB.open(dbname, 1);
-      request.onupgradeneeded = event => {
-        event.target.transaction.abort();
-        reject(event.target.error);
-      };
-      request.onerror = event => reject(event.target.error);
-      request.onsuccess = event => resolve(event.target.result);
-    });
-    return true;
-  } catch (e) {
-    return false;
-  }
-}
-
-/**
- * Check if we ship a JSON dump for the specified bucket and collection.
- *
- * @param {String} bucket
- * @param {String} collection
- * @return {bool} Whether it is present or not.
- */
-async function hasLocalDump(bucket, collection) {
-  const filename = OS.Path.join(bucket, `${collection}.json`);
-  try {
-    await loadDumpFile(filename);
-    return true;
-  } catch (e) {
-    return false;
-  }
-}
-
 
 function remoteSettingsFunction() {
   const _clients = new Map();
 
   // If not explicitly specified, use the default bucket name and signer.
   const mainBucket = Services.prefs.getCharPref(PREF_SETTINGS_DEFAULT_BUCKET);
   const defaultSigner = Services.prefs.getCharPref(PREF_SETTINGS_DEFAULT_SIGNER);
 
@@ -671,54 +627,30 @@ function remoteSettingsFunction() {
     Services.prefs.setIntPref(PREF_SETTINGS_CLOCK_SKEW_SECONDS, clockDifference);
     Services.prefs.setIntPref(PREF_SETTINGS_LAST_UPDATE, serverTimeMillis / 1000);
 
     const loadDump = Services.prefs.getBoolPref(PREF_SETTINGS_LOAD_DUMP, true);
     // Iterate through the collections version info and initiate a synchronization
     // on the related remote settings client.
     let firstError;
     for (const change of changes) {
-      const {bucket: bucketName, collection, last_modified: lastModified} = change;
-      const key = `${bucketName}/${collection}`;
-
-      let client;
-      // Check if a client was registered for this bucket/collection. Potentially
-      // with some specific options like bucket, signer, etc.
-      if (_clients.has(key)) {
-        client = _clients.get(key);
-        // If the bucket name was changed manually on the client instance and does not
-        // match, it should be skipped.
-        if (client.bucketName != bucketName) {
-          continue;
-        }
-
-      // There was no client registered for this bucket/collection, but it's the main bucket,
-      // therefore we can instantiate a client with the default options.
-      // So if we have a local database or if we ship a JSON dump, then it means that
-      // this client is known but it was not registered yet (eg. calling module not "imported" yet).
-      } else if (bucketName == mainBucket && (await databaseExists(bucketName, collection) ||
-                                              await hasLocalDump(bucketName, collection))) {
-        client = new RemoteSettingsClient(collection, {bucketName, signerName: defaultSigner});
-
-      // We are not able to synchronize data for clients in specific buckets since we cannot
-      // guess which `signerName` has to be used for example.
-      // And we don't want to synchronize data for collections in the main bucket that are
-      // completely unknown (ie. no database and no JSON dump).
-      } else {
+      const {bucket, collection, last_modified: lastModified} = change;
+      const key = `${bucket}/${collection}`;
+      if (!_clients.has(key)) {
         continue;
       }
-
-      // Start synchronization! It will be a no-op if the specified `lastModified` equals
-      // the one in the local database.
+      const client = _clients.get(key);
+      if (client.bucketName != bucket) {
+        continue;
+      }
       try {
         await client.maybeSync(lastModified, serverTimeMillis, {loadDump});
       } catch (e) {
         if (!firstError) {
           firstError = e;
-          firstError.details = change;
         }
       }
     }
     if (firstError) {
       // cause the promise to reject by throwing the first observed error
       throw firstError;
     }
 
--- a/services/settings/test/unit/test_remote_settings_poll.js
+++ b/services/settings/test/unit/test_remote_settings_poll.js
@@ -1,396 +1,239 @@
 /* import-globals-from ../../../common/tests/unit/head_helpers.js */
 
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://testing-common/httpd.js");
 
 const { UptakeTelemetry } = ChromeUtils.import("resource://services-common/uptake-telemetry.js", {});
 const { RemoteSettings } = ChromeUtils.import("resource://services-settings/remote-settings.js", {});
-const { Kinto } = ChromeUtils.import("resource://services-common/kinto-offline-client.js", {});
 
 var server;
 
 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_HISTOGRAM_KEY = "settings-changes-monitoring";
-const CHANGES_PATH = "/v1/buckets/monitor/collections/changes/records";
+
+// Check to ensure maybeSync is called with correct values when a changes
+// document contains information on when a collection was last modified
+add_task(async function test_check_maybeSync() {
+  const changesPath = "/v1/buckets/monitor/collections/changes/records";
+
+  // register a handler
+  function handleResponse(serverTimeMillis, request, response) {
+    try {
+      const sampled = getSampleResponse(request, server.identity.primaryPort);
+      if (!sampled) {
+        do_throw(`unexpected ${request.method} request for ${request.path}?${request.queryString}`);
+      }
 
+      response.setStatusLine(null, sampled.status.status,
+                             sampled.status.statusText);
+      // send the headers
+      for (let headerLine of sampled.sampleHeaders) {
+        let headerElements = headerLine.split(":");
+        response.setHeader(headerElements[0], headerElements[1].trimLeft());
+      }
 
-async function clear_state() {
+      // set the server date
+      response.setHeader("Date", (new Date(serverTimeMillis)).toUTCString());
+
+      response.write(sampled.responseBody);
+    } catch (e) {
+      dump(`${e}\n`);
+    }
+  }
+
+  server.registerPathHandler(changesPath, handleResponse.bind(null, 2000));
+
   // 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);
-}
+
+
+  let startTime = Date.now();
+
+  // ensure we get the maybeSync call
+  // add a test kinto client that will respond to lastModified information
+  // for a collection called 'test-collection'
+  const c = RemoteSettings("test-collection", {
+    bucketName: "test-bucket",
+  });
+  c.maybeSync = () => {};
+
+  const startHistogram = getUptakeTelemetrySnapshot(TELEMETRY_HISTOGRAM_KEY);
+
+  let notificationObserved = false;
+
+  // Ensure that the remote-settings-changes-polled notification works
+  let certblockObserver = {
+    observe(aSubject, aTopic, aData) {
+      Services.obs.removeObserver(this, "remote-settings-changes-polled");
+      notificationObserved = true;
+    }
+  };
+
+  Services.obs.addObserver(certblockObserver, "remote-settings-changes-polled");
+
+  await RemoteSettings.pollChanges();
 
-function serveChangesEntries(serverTime, entries) {
-  return (request, response) => {
-    response.setStatusLine(null, 200, "OK");
+  Assert.ok(notificationObserved, "a notification should have been observed");
+
+  // check the last_update is updated
+  Assert.equal(Services.prefs.getIntPref(PREF_LAST_UPDATE), 2);
+
+  // 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) - 2);
+  // Last timestamp was saved. An ETag header value is a quoted string.
+  let lastEtag = Services.prefs.getCharPref(PREF_LAST_ETAG);
+  Assert.equal(lastEtag, "\"1100\"");
+
+  // Simulate a poll with up-to-date collection.
+  Services.prefs.setIntPref(PREF_LAST_UPDATE, 0);
+  // If server has no change, a 304 is received, maybeSync() is not called.
+  c.maybeSync = () => { throw new Error("Should not be called"); };
+  await RemoteSettings.pollChanges();
+  // Last update is overwritten
+  Assert.equal(Services.prefs.getIntPref(PREF_LAST_UPDATE), 2);
+
+
+  // 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.setHeader("Date", (new Date(serverTime)).toUTCString());
-    if (entries.length) {
-      response.setHeader("ETag", `"${entries[0].last_modified}"`);
-    }
-    response.write(JSON.stringify({"data": entries}));
+    response.write(JSON.stringify({
+      code: 503,
+      errno: 999,
+      error: "Service Unavailable",
+    }));
+    response.setStatusLine(null, 503, "Service Unavailable");
+  }
+  server.registerPathHandler(changesPath, simulateErrorResponse);
+
+  // pollChanges() fails with adequate error and no notification.
+  let error;
+  notificationObserved = false;
+  Services.obs.addObserver(certblockObserver, "remote-settings-changes-polled");
+  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 (see Date header above).
+  Assert.equal(Services.prefs.getIntPref(PREF_LAST_UPDATE), 2);
+
+  // check negative clock skew times
+
+  // set to a time in the future
+  server.registerPathHandler(changesPath, handleResponse.bind(null, 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);
+
+  //
+  // Backoff
+  //
+  function simulateBackoffResponse(request, response) {
+    response.setHeader("Content-Type", "application/json; charset=UTF-8");
+    response.setHeader("Backoff", "10");
+    response.write(JSON.stringify({data: []}));
+    response.setStatusLine(null, 200, "OK");
+  }
+  server.registerPathHandler(changesPath, 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(changesPath, handleResponse.bind(null, 2000));
+  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));
+
+
+  // Simulate a network error (to check telemetry report).
+  Services.prefs.setCharPref(PREF_SETTINGS_SERVER, "http://localhost:42/v1");
+  try {
+    await RemoteSettings.pollChanges();
+  } catch (e) {}
+
+  const endHistogram = getUptakeTelemetrySnapshot(TELEMETRY_HISTOGRAM_KEY);
+  // ensure that we've accumulated the correct telemetry
+  const expectedIncrements = {
+    [UptakeTelemetry.STATUS.UP_TO_DATE]: 4,
+    [UptakeTelemetry.STATUS.SUCCESS]: 1,
+    [UptakeTelemetry.STATUS.BACKOFF]: 1,
+    [UptakeTelemetry.STATUS.SERVER_ERROR]: 1,
+    [UptakeTelemetry.STATUS.NETWORK_ERROR]: 1,
+    [UptakeTelemetry.STATUS.UNKNOWN_ERROR]: 0,
   };
-}
+  checkUptakeTelemetry(startHistogram, endHistogram, expectedIncrements);
+});
 
 function run_test() {
   // Set up an HTTP Server
   server = new HttpServer();
   server.start(-1);
 
   run_next_test();
 
   registerCleanupFunction(function() {
     server.stop(function() { });
   });
 }
 
-add_task(clear_state);
-
-add_task(async function test_check_success() {
-  const startHistogram = getUptakeTelemetrySnapshot(TELEMETRY_HISTOGRAM_KEY);
-  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 maybeSyncCalled = false;
-  const c = RemoteSettings("test-collection", {
-    bucketName: "test-bucket",
-  });
-  c.maybeSync = () => { maybeSyncCalled = true; };
-
-  // Ensure that the remote-settings-changes-polled notification works
-  let notificationObserved = false;
-  const observer = {
-    observe(aSubject, aTopic, aData) {
-      Services.obs.removeObserver(this, "remote-settings-changes-polled");
-      notificationObserved = true;
-    }
-  };
-  Services.obs.addObserver(observer, "remote-settings-changes-polled");
-
-  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
-  const endHistogram = getUptakeTelemetrySnapshot(TELEMETRY_HISTOGRAM_KEY);
-  const expectedIncrements = {
-    [UptakeTelemetry.STATUS.SUCCESS]: 1,
-  };
-  checkUptakeTelemetry(startHistogram, endHistogram, expectedIncrements);
-});
-add_task(clear_state);
-
-
-add_task(async function test_check_up_to_date() {
-  // Simulate a poll with up-to-date collection.
-  const startHistogram = getUptakeTelemetrySnapshot(TELEMETRY_HISTOGRAM_KEY);
-
-  const serverTime = 4000;
-  function server304(request, response) {
-    if (request.hasHeader("if-none-match") && request.getHeader("if-none-match") == "\"1100\"") {
-      response.setHeader("Date", (new Date(serverTime)).toUTCString());
-      response.setStatusLine(null, 304, "Service Not Modified");
-    }
-  }
-  server.registerPathHandler(CHANGES_PATH, server304);
-
-  Services.prefs.setCharPref(PREF_LAST_ETAG, '"1100"');
-
-  // Ensure that the remote-settings-changes-polled notification is sent.
-  let notificationObserved = false;
-  const observer = {
-    observe(aSubject, aTopic, aData) {
-      Services.obs.removeObserver(this, "remote-settings-changes-polled");
-      notificationObserved = true;
-    }
-  };
-  Services.obs.addObserver(observer, "remote-settings-changes-polled");
-
-  // If server has no change, a 304 is received, 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 endHistogram = getUptakeTelemetrySnapshot(TELEMETRY_HISTOGRAM_KEY);
-  const expectedIncrements = {
-    [UptakeTelemetry.STATUS.UP_TO_DATE]: 1,
-  };
-  checkUptakeTelemetry(startHistogram, endHistogram, expectedIncrements);
-});
-add_task(clear_state);
-
-
-add_task(async function test_server_error() {
-  const startHistogram = getUptakeTelemetrySnapshot(TELEMETRY_HISTOGRAM_KEY);
-
-  // 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-polled");
-      notificationObserved = true;
+// get a response for a given request from sample data
+function getSampleResponse(req, port) {
+  const responses = {
+    "GET:/v1/buckets/monitor/collections/changes/records?": {
+      "sampleHeaders": [
+        "Content-Type: application/json; charset=UTF-8",
+        "ETag: \"1100\""
+      ],
+      "status": {status: 200, statusText: "OK"},
+      "responseBody": JSON.stringify({"data": [{
+        "host": "localhost",
+        "last_modified": 1100,
+        "bucket": "test-bucket-aurora",
+        "id": "330a0c5f-fadf-ff0b-40c8-4eb0d924ff6a",
+        "collection": "test-collection"
+      }, {
+        "host": "localhost",
+        "last_modified": 1000,
+        "bucket": "test-bucket",
+        "id": "254cbb9e-6888-4d9f-8e60-58b74faa8778",
+        "collection": "test-collection"
+      }]})
     }
   };
-  Services.obs.addObserver(observer, "remote-settings-changes-polled");
-  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 endHistogram = getUptakeTelemetrySnapshot(TELEMETRY_HISTOGRAM_KEY);
-  const expectedIncrements = {
-    [UptakeTelemetry.STATUS.SERVER_ERROR]: 1,
-  };
-  checkUptakeTelemetry(startHistogram, endHistogram, expectedIncrements);
-});
-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({data: []}));
-    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_backoff() {
-  const startHistogram = getUptakeTelemetrySnapshot(TELEMETRY_HISTOGRAM_KEY);
-
-  function simulateBackoffResponse(request, response) {
-    response.setHeader("Content-Type", "application/json; charset=UTF-8");
-    response.setHeader("Backoff", "10");
-    response.write(JSON.stringify({data: []}));
-    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));
+  if (req.hasHeader("if-none-match") && req.getHeader("if-none-match", "") == "\"1100\"")
+    return {sampleHeaders: [], status: {status: 304, statusText: "Not Modified"}, responseBody: ""};
 
-  // Ensure that we've accumulated the correct telemetry
-  const endHistogram = getUptakeTelemetrySnapshot(TELEMETRY_HISTOGRAM_KEY);
-  const expectedIncrements = {
-    [UptakeTelemetry.STATUS.SUCCESS]: 1,
-    [UptakeTelemetry.STATUS.UP_TO_DATE]: 1,
-    [UptakeTelemetry.STATUS.BACKOFF]: 1,
-  };
-  checkUptakeTelemetry(startHistogram, endHistogram, expectedIncrements);
-});
-add_task(clear_state);
-
-
-add_task(async function test_network_error() {
-  const startHistogram = getUptakeTelemetrySnapshot(TELEMETRY_HISTOGRAM_KEY);
-
-  // 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 endHistogram = getUptakeTelemetrySnapshot(TELEMETRY_HISTOGRAM_KEY);
-  const expectedIncrements = {
-    [UptakeTelemetry.STATUS.NETWORK_ERROR]: 1,
-  };
-  checkUptakeTelemetry(startHistogram, endHistogram, 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.
-  await (new Kinto.adapters.IDB("blocklists/addons")).saveLastModified(42);
-  await (new Kinto.adapters.IDB("main/recipes")).saveLastModified(43);
-
-  let error;
-  try {
-    await RemoteSettings.pollChanges();
-  } 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() {
-  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: "tippytop"
-  }]));
-
-  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 `tippytop` 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, "tippytop");
-});
-add_task(clear_state);
+  return responses[`${req.method}:${req.path}?${req.queryString}`] ||
+         responses[req.method];
+}