Bug 1257556 - Generalize Kinto blocklist client to addons/plugins/gfx. r=MattN
authorMathieu Leplatre <mathieu@mozilla.com>
Mon, 18 Apr 2016 11:38:25 +0200
changeset 331985 253a857a1dcc3984974681416568762451bd5a85
parent 331984 102f375bffd168e6c820b268c10a4051d69ca788
child 331986 95fc7c29de20dc3a4fe15c2847e7077342bdaaa1
push id6048
push userkmoir@mozilla.com
push dateMon, 06 Jun 2016 19:02:08 +0000
treeherdermozilla-beta@46d72a56c57d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMattN
bugs1257556
milestone48.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1257556 - Generalize Kinto blocklist client to addons/plugins/gfx. r=MattN MozReview-Commit-ID: DkiNF78QTId
browser/app/profile/firefox.js
mobile/android/app/mobile.js
services/common/KintoBlocklist.js
services/common/kinto-updater.js
services/common/tests/unit/test_kintoAddonPluginBlocklist.js
services/common/tests/unit/test_kintoCertBlocklist.js
services/common/tests/unit/test_kinto_updater.js
services/common/tests/unit/xpcshell.ini
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -63,16 +63,22 @@ pref("extensions.blocklist.detailsURL", 
 pref("extensions.blocklist.itemURL", "https://blocklist.addons.mozilla.org/%LOCALE%/%APP%/blocked/%blockID%");
 
 // Kinto blocklist preferences
 pref("services.kinto.base", "https://firefox.settings.services.mozilla.com/v1");
 pref("services.kinto.changes.path", "/buckets/monitor/collections/changes/records");
 pref("services.kinto.bucket", "blocklists");
 pref("services.kinto.onecrl.collection", "certificates");
 pref("services.kinto.onecrl.checked", 0);
+pref("services.kinto.addons.collection", "addons");
+pref("services.kinto.addons.checked", 0);
+pref("services.kinto.plugins.collection", "plugins");
+pref("services.kinto.plugins.checked", 0);
+pref("services.kinto.gfx.collection", "gfx");
+pref("services.kinto.gfx.checked", 0);
 
 // for now, let's keep kinto update out of the release channel
 #ifdef RELEASE_BUILD
 pref("services.kinto.update_enabled", false);
 #else
 pref("services.kinto.update_enabled", true);
 #endif
 
--- a/mobile/android/app/mobile.js
+++ b/mobile/android/app/mobile.js
@@ -240,16 +240,22 @@ pref("extensions.blocklist.url", "https:
 pref("extensions.blocklist.detailsURL", "https://www.mozilla.com/%LOCALE%/blocklist/");
 
 // Kinto blocklist preferences
 pref("services.kinto.base", "https://firefox.settings.services.mozilla.com/v1");
 pref("services.kinto.changes.path", "/buckets/monitor/collections/changes/records");
 pref("services.kinto.bucket", "blocklists");
 pref("services.kinto.onecrl.collection", "certificates");
 pref("services.kinto.onecrl.checked", 0);
+pref("services.kinto.addons.collection", "addons");
+pref("services.kinto.addons.checked", 0);
+pref("services.kinto.plugins.collection", "plugins");
+pref("services.kinto.plugins.checked", 0);
+pref("services.kinto.gfx.collection", "gfx");
+pref("services.kinto.gfx.checked", 0);
 
 // for now, let's keep kinto update out of the release channel (pending
 // collection signatures)
 #ifdef RELEASE_BUILD
 pref("services.kinto.update_enabled", false);
 #else
 pref("services.kinto.update_enabled", true);
 #endif
--- a/services/common/KintoBlocklist.js
+++ b/services/common/KintoBlocklist.js
@@ -1,115 +1,185 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
-this.EXPORTED_SYMBOLS = ["OneCRLClient"];
+this.EXPORTED_SYMBOLS = ["AddonBlocklistClient",
+                         "GfxBlocklistClient",
+                         "OneCRLBlocklistClient",
+                         "PluginBlocklistClient",
+                         "FILENAME_ADDONS_JSON",
+                         "FILENAME_GFX_JSON",
+                         "FILENAME_PLUGINS_JSON"];
 
 const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
 
-Cu.import("resource://services-common/kinto-offline-client.js");
+Cu.import("resource://gre/modules/Services.jsm");
+const { Task } = Cu.import("resource://gre/modules/Task.jsm");
+const { OS } = Cu.import("resource://gre/modules/osfile.jsm");
 
-Cu.import("resource://gre/modules/Services.jsm");
-Cu.import("resource://gre/modules/Task.jsm");
-Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+const { loadKinto } = Cu.import("resource://services-common/kinto-offline-client.js");
 
-XPCOMUtils.defineLazyServiceGetter(this, "uuidgen",
-                                   "@mozilla.org/uuid-generator;1",
-                                   "nsIUUIDGenerator");
+const PREF_KINTO_BASE                    = "services.kinto.base";
+const PREF_KINTO_BUCKET                  = "services.kinto.bucket";
+const PREF_KINTO_ONECRL_COLLECTION       = "services.kinto.onecrl.collection";
+const PREF_KINTO_ONECRL_CHECKED_SECONDS  = "services.kinto.onecrl.checked";
+const PREF_KINTO_ADDONS_COLLECTION       = "services.kinto.addons.collection";
+const PREF_KINTO_ADDONS_CHECKED_SECONDS  = "services.kinto.addons.checked";
+const PREF_KINTO_PLUGINS_COLLECTION      = "services.kinto.plugins.collection";
+const PREF_KINTO_PLUGINS_CHECKED_SECONDS = "services.kinto.plugins.checked";
+const PREF_KINTO_GFX_COLLECTION          = "services.kinto.gfx.collection";
+const PREF_KINTO_GFX_CHECKED_SECONDS     = "services.kinto.gfx.checked";
 
-const PREF_KINTO_BASE = "services.kinto.base";
-const PREF_KINTO_BUCKET = "services.kinto.bucket";
-const PREF_KINTO_ONECRL_COLLECTION = "services.kinto.onecrl.collection";
-const PREF_KINTO_ONECRL_CHECKED_SECONDS = "services.kinto.onecrl.checked";
+this.FILENAME_ADDONS_JSON  = "blocklist-addons.json";
+this.FILENAME_GFX_JSON     = "blocklist-gfx.json";
+this.FILENAME_PLUGINS_JSON = "blocklist-plugins.json";
 
-const RE_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
 
-// Kinto.js assumes version 4 UUIDs but allows you to specify custom
-// validators and generators. The tooling that generates records in the
-// certificates collection currently uses a version 1 UUID so we must
-// specify a validator that's less strict. We must also supply a generator
-// since Kinto.js does not allow one without the other.
-function makeIDSchema() {
-  return {
-    validate: RE_UUID.test.bind(RE_UUID),
-    generate: function() {
-      return uuidgen.generateUUID().toString();
-    }
-  };
-}
+/**
+ * Helper to instantiate a Kinto client based on preferences for remote server
+ * URL and bucket name. It uses the `FirefoxAdapter` which relies on SQLite to
+ * persist the local DB.
+ */
+function kintoClient() {
+  let base = Services.prefs.getCharPref(PREF_KINTO_BASE);
+  let bucket = Services.prefs.getCharPref(PREF_KINTO_BUCKET);
+
+  let Kinto = loadKinto();
 
-// A Kinto based client to keep the OneCRL certificate blocklist up to date.
-function CertBlocklistClient() {
-  // maybe sync the collection of certificates with remote data.
-  // lastModified - the lastModified date (on the server, milliseconds since
-  // epoch) of data in the remote collection
-  // serverTime - the time on the server (milliseconds since epoch)
-  // returns a promise which rejects on sync failure
-  this.maybeSync = function(lastModified, serverTime) {
-    let base = Services.prefs.getCharPref(PREF_KINTO_BASE);
-    let bucket = Services.prefs.getCharPref(PREF_KINTO_BUCKET);
+  let FirefoxAdapter = Kinto.adapters.FirefoxAdapter;
 
-    let Kinto = loadKinto();
+  let config = {
+    remote: base,
+    bucket: bucket,
+    adapter: FirefoxAdapter,
+  };
 
-    let FirefoxAdapter = Kinto.adapters.FirefoxAdapter;
+  return new Kinto(config);
+}
 
 
-    let certList = Cc["@mozilla.org/security/certblocklist;1"]
-                     .getService(Ci.nsICertBlocklist);
+class BlocklistClient {
 
-    // Future blocklist clients can extract the sync-if-stale logic. For
-    // now, since this is currently the only client, we'll do this here.
-    let config = {
-      remote: base,
-      bucket: bucket,
-      adapter: FirefoxAdapter,
-    };
+  constructor(collectionName, lastCheckTimePref, processCallback) {
+    this.collectionName = collectionName;
+    this.lastCheckTimePref = lastCheckTimePref;
+    this.processCallback = processCallback;
+  }
 
-    let db = new Kinto(config);
-    let collectionName = Services.prefs.getCharPref(PREF_KINTO_ONECRL_COLLECTION,
-                                                    "certificates");
-    let blocklist = db.collection(collectionName,
-                                  { idSchema: makeIDSchema() });
+  /**
+   * Synchronize from Kinto server, if necessary.
+   *
+   * @param {int}  lastModified the lastModified date (on the server) for
+                                the remote collection.
+   * @param {Date} serverTime   the current date return by the server.
+   * @return {Promise}          which rejects on sync or process failure.
+   */
+  maybeSync(lastModified, serverTime) {
+    let db = kintoClient();
+    let collection = db.collection(this.collectionName);
 
-    let updateLastCheck = function() {
-      let checkedServerTimeInSeconds = Math.round(serverTime / 1000);
-      Services.prefs.setIntPref(PREF_KINTO_ONECRL_CHECKED_SECONDS,
-                                checkedServerTimeInSeconds);
-    }
+    return Task.spawn((function* syncCollection() {
+      try {
+        yield collection.db.open();
 
-    return Task.spawn(function* () {
-      try {
-        yield blocklist.db.open();
-        let collectionLastModified = yield blocklist.db.getLastModified();
-        // if the data is up to date, there's no need to sync. We still need
+        let collectionLastModified = yield collection.db.getLastModified();
+        // If the data is up to date, there's no need to sync. We still need
         // to record the fact that a check happened.
         if (lastModified <= collectionLastModified) {
-          updateLastCheck();
+          this.updateLastCheck(serverTime);
           return;
         }
-        yield blocklist.sync();
-        let list = yield blocklist.list();
-        for (let item of list.data) {
-          if (item.issuerName && item.serialNumber) {
-            certList.revokeCertByIssuerAndSerial(item.issuerName,
-                                                 item.serialNumber);
-          } else if (item.subject && item.pubKeyHash) {
-            certList.revokeCertBySubjectAndPubKey(item.subject,
-                                                  item.pubKeyHash);
-          } else {
-            throw new Error("Cert blocklist record has incomplete data");
-          }
-        }
-        // We explicitly do not want to save entries or update the
-        // last-checked time if sync fails
-        certList.saveEntries();
-        updateLastCheck();
+        // Fetch changes from server.
+        yield collection.sync();
+        // Read local collection of records.
+        let list = yield collection.list();
+
+        yield this.processCallback(list.data);
+
+        // Track last update.
+        this.updateLastCheck(serverTime);
       } finally {
-        blocklist.db.close()
+        collection.db.close();
       }
-    });
+    }).bind(this));
+  }
+
+  /**
+   * Save last time server was checked in users prefs.
+   *
+   * @param {Date} serverTime   the current date return by server.
+   */
+  updateLastCheck(serverTime) {
+    let checkedServerTimeInSeconds = Math.round(serverTime / 1000);
+    Services.prefs.setIntPref(this.lastCheckTimePref, checkedServerTimeInSeconds);
   }
 }
 
-this.OneCRLClient = new CertBlocklistClient();
+/**
+ * Revoke the appropriate certificates based on the records from the blocklist.
+ *
+ * @param {Object} records   current records in the local db.
+ */
+function* updateCertBlocklist(records) {
+  let certList = Cc["@mozilla.org/security/certblocklist;1"]
+                   .getService(Ci.nsICertBlocklist);
+  for (let item of records) {
+    if (item.issuerName && item.serialNumber) {
+      certList.revokeCertByIssuerAndSerial(item.issuerName,
+                                           item.serialNumber);
+    } else if (item.subject && item.pubKeyHash) {
+      certList.revokeCertBySubjectAndPubKey(item.subject,
+                                            item.pubKeyHash);
+    } else {
+      throw new Error("Cert blocklist record has incomplete data");
+    }
+  }
+  certList.saveEntries();
+}
+
+/**
+ * Write list of records into JSON file, and notify nsBlocklistService.
+ *
+ * @param {String} filename  path relative to profile dir.
+ * @param {Object} records   current records in the local db.
+ */
+function* updateJSONBlocklist(filename, records) {
+  // Write JSON dump for synchronous load at startup.
+  const path = OS.Path.join(OS.Constants.Path.profileDir, filename);
+  const serialized = JSON.stringify({data: records}, null, 2);
+  try {
+    yield OS.File.writeAtomic(path, serialized, {tmpPath: path + ".tmp"});
+
+    // Notify change to `nsBlocklistService`
+    const eventData = {filename: filename};
+    Services.cpmm.sendAsyncMessage("Blocklist:reload-from-disk", eventData);
+  } catch(e) {
+    Cu.reportError(e);
+  }
+}
+
+
+this.OneCRLBlocklistClient = new BlocklistClient(
+  Services.prefs.getCharPref(PREF_KINTO_ONECRL_COLLECTION),
+  PREF_KINTO_ONECRL_CHECKED_SECONDS,
+  updateCertBlocklist
+);
+
+this.AddonBlocklistClient = new BlocklistClient(
+  Services.prefs.getCharPref(PREF_KINTO_ADDONS_COLLECTION),
+  PREF_KINTO_ADDONS_CHECKED_SECONDS,
+  updateJSONBlocklist.bind(undefined, FILENAME_ADDONS_JSON)
+);
+
+this.GfxBlocklistClient = new BlocklistClient(
+  Services.prefs.getCharPref(PREF_KINTO_GFX_COLLECTION),
+  PREF_KINTO_GFX_CHECKED_SECONDS,
+  updateJSONBlocklist.bind(undefined, FILENAME_GFX_JSON)
+);
+
+this.PluginBlocklistClient = new BlocklistClient(
+  Services.prefs.getCharPref(PREF_KINTO_PLUGINS_COLLECTION),
+  PREF_KINTO_PLUGINS_CHECKED_SECONDS,
+  updateJSONBlocklist.bind(undefined, FILENAME_PLUGINS_JSON)
+);
--- a/services/common/kinto-updater.js
+++ b/services/common/kinto-updater.js
@@ -4,33 +4,41 @@
 
 this.EXPORTED_SYMBOLS = ["checkVersions", "addTestKintoClient"];
 
 const { classes: Cc, Constructor: CC, interfaces: Ci, utils: Cu } = Components;
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
 Cu.importGlobalProperties(['fetch']);
+const BlocklistClients = Cu.import("resource://services-common/KintoBlocklist.js", {});
 
-const PREF_KINTO_CHANGES_PATH = "services.kinto.changes.path";
-const PREF_KINTO_BASE = "services.kinto.base";
-const PREF_KINTO_BUCKET = "services.kinto.bucket";
-const PREF_KINTO_LAST_UPDATE = "services.kinto.last_update_seconds";
-const PREF_KINTO_LAST_ETAG = "services.kinto.last_etag";
+const PREF_KINTO_CHANGES_PATH       = "services.kinto.changes.path";
+const PREF_KINTO_BASE               = "services.kinto.base";
+const PREF_KINTO_BUCKET             = "services.kinto.bucket";
+const PREF_KINTO_LAST_UPDATE        = "services.kinto.last_update_seconds";
+const PREF_KINTO_LAST_ETAG          = "services.kinto.last_etag";
 const PREF_KINTO_CLOCK_SKEW_SECONDS = "services.kinto.clock_skew_seconds";
-const PREF_KINTO_ONECRL_COLLECTION = "services.kinto.onecrl.collection";
+const PREF_KINTO_ONECRL_COLLECTION  = "services.kinto.onecrl.collection";
+
 
-const kintoClients = {
+const gBlocklistClients = {
+  [BlocklistClients.OneCRLBlocklistClient.collectionName]: BlocklistClients.OneCRLBlocklistClient,
+  [BlocklistClients.AddonBlocklistClient.collectionName]: BlocklistClients.AddonBlocklistClient,
+  [BlocklistClients.GfxBlocklistClient.collectionName]: BlocklistClients.GfxBlocklistClient,
+  [BlocklistClients.PluginBlocklistClient.collectionName]: BlocklistClients.PluginBlocklistClient
 };
 
+// Add a blocklist client for testing purposes. Do not use for any other purpose
+this.addTestKintoClient = (name, client) => { gBlocklistClients[name] = client; }
+
 // This is called by the ping mechanism.
 // returns a promise that rejects if something goes wrong
 this.checkVersions = function() {
-
-  return Task.spawn(function *() {
+  return Task.spawn(function* syncClients() {
     // Fetch a versionInfo object that looks like:
     // {"data":[
     //   {
     //     "host":"kinto-ota.dev.mozaws.net",
     //     "last_modified":1450717104423,
     //     "bucket":"blocklists",
     //     "collection":"certificates"
     //    }]}
@@ -73,24 +81,24 @@ this.checkVersions = function() {
     let firstError;
     for (let collectionInfo of versionInfo.data) {
       // Skip changes that don't concern configured blocklist bucket.
       if (collectionInfo.bucket != blocklistsBucket) {
         continue;
       }
 
       let collection = collectionInfo.collection;
-      let kintoClient = kintoClients[collection];
-      if (kintoClient && kintoClient.maybeSync) {
+      let client = gBlocklistClients[collection];
+      if (client && client.maybeSync) {
         let lastModified = 0;
         if (collectionInfo.last_modified) {
           lastModified = collectionInfo.last_modified;
         }
         try {
-          yield kintoClient.maybeSync(lastModified, serverTimeMillis);
+          yield client.maybeSync(lastModified, serverTimeMillis);
         } catch (e) {
           if (!firstError) {
             firstError = e;
           }
         }
       }
     }
     if (firstError) {
@@ -100,17 +108,8 @@ this.checkVersions = function() {
 
     // Save current Etag for next poll.
     if (response.headers.has("ETag")) {
       const currentEtag = response.headers.get("ETag");
       Services.prefs.setCharPref(PREF_KINTO_LAST_ETAG, currentEtag);
     }
   });
 };
-
-// Add a kintoClient for testing purposes. Do not use for any other purpose
-this.addTestKintoClient = function(name, kintoClient) {
-  kintoClients[name] = kintoClient;
-};
-
-// Add the various things that we know want updates
-const KintoBlocklist = Cu.import("resource://services-common/KintoCertificateBlocklist.js", {});
-kintoClients[Services.prefs.getCharPref(PREF_KINTO_ONECRL_COLLECTION)]  = KintoBlocklist.OneCRLClient;
new file mode 100644
--- /dev/null
+++ b/services/common/tests/unit/test_kintoAddonPluginBlocklist.js
@@ -0,0 +1,412 @@
+const { Constructor: CC } = Components;
+
+const KEY_PROFILEDIR = "ProfD";
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://testing-common/httpd.js");
+Cu.import("resource://gre/modules/Timer.jsm");
+const { FileUtils } = Cu.import("resource://gre/modules/FileUtils.jsm");
+const { OS } = Cu.import("resource://gre/modules/osfile.jsm");
+
+const { loadKinto } = Cu.import("resource://services-common/kinto-offline-client.js");
+const KintoBlocklist = Cu.import("resource://services-common/KintoBlocklist.js");
+
+const BinaryInputStream = CC("@mozilla.org/binaryinputstream;1",
+  "nsIBinaryInputStream", "setInputStream");
+
+const gBlocklistClients = [
+  {client: KintoBlocklist.AddonBlocklistClient, filename: KintoBlocklist.FILENAME_ADDONS_JSON, testData: ["i808","i720", "i539"]},
+  {client: KintoBlocklist.PluginBlocklistClient, filename: KintoBlocklist.FILENAME_PLUGINS_JSON, testData: ["p1044","p32","p28"]},
+  {client: KintoBlocklist.GfxBlocklistClient, filename: KintoBlocklist.FILENAME_GFX_JSON, testData: ["g204","g200","g36"]},
+];
+
+
+let server;
+let kintoClient;
+
+function kintoCollection(collectionName) {
+  if (!kintoClient) {
+    const Kinto = loadKinto();
+    const FirefoxAdapter = Kinto.adapters.FirefoxAdapter;
+    const config = {
+      // Set the remote to be some server that will cause test failure when
+      // hit since we should never hit the server directly, only via maybeSync()
+      remote: "https://firefox.settings.services.mozilla.com/v1/",
+      adapter: FirefoxAdapter,
+      bucket: "blocklists"
+    };
+    kintoClient = new Kinto(config);
+  }
+  return kintoClient.collection(collectionName);
+}
+
+function* readJSON(filepath) {
+  const binaryData = yield OS.File.read(filepath);
+  const textData = (new TextDecoder()).decode(binaryData);
+  return Promise.resolve(JSON.parse(textData));
+}
+
+function* clear_state() {
+  for (let {client} of gBlocklistClients) {
+    // Remove last server times.
+    Services.prefs.clearUserPref(client.lastCheckTimePref);
+
+    // Clear local DB.
+    const collection = kintoCollection(client.collectionName);
+    try {
+      yield collection.db.open();
+      yield collection.clear();
+    } finally {
+      yield collection.db.close();
+    }
+  }
+
+  // Remove profile data.
+  for (let {filename} of gBlocklistClients) {
+    const blocklist = FileUtils.getFile(KEY_PROFILEDIR, [filename]);
+    if (blocklist.exists()) {
+      blocklist.remove(true);
+    }
+  }
+}
+
+
+function run_test() {
+  // Set up an HTTP Server
+  server = new HttpServer();
+  server.start(-1);
+
+  // Point the blocklist clients to use this local HTTP server.
+  Services.prefs.setCharPref("services.kinto.base",
+                             `http://localhost:${server.identity.primaryPort}/v1`);
+
+  // Setup server fake responses.
+  function handleResponse(request, response) {
+    try {
+      const sample = getSampleResponse(request, server.identity.primaryPort);
+      if (!sample) {
+        do_throw(`unexpected ${request.method} request for ${request.path}?${request.queryString}`);
+      }
+
+      response.setStatusLine(null, sample.status.status,
+                             sample.status.statusText);
+      // send the headers
+      for (let headerLine of sample.sampleHeaders) {
+        let headerElements = headerLine.split(':');
+        response.setHeader(headerElements[0], headerElements[1].trimLeft());
+      }
+      response.setHeader("Date", (new Date()).toUTCString());
+
+      response.write(sample.responseBody);
+      response.finish();
+    } catch (e) {
+      do_print(e);
+    }
+  }
+  const configPath = "/v1/";
+  const addonsRecordsPath  = "/v1/buckets/blocklists/collections/addons/records";
+  const gfxRecordsPath     = "/v1/buckets/blocklists/collections/gfx/records";
+  const pluginsRecordsPath = "/v1/buckets/blocklists/collections/plugins/records";
+  server.registerPathHandler(configPath, handleResponse);
+  server.registerPathHandler(addonsRecordsPath, handleResponse);
+  server.registerPathHandler(gfxRecordsPath, handleResponse);
+  server.registerPathHandler(pluginsRecordsPath, handleResponse);
+
+
+  run_next_test();
+
+  do_register_cleanup(function() {
+    server.stop(() => { });
+  });
+}
+
+add_task(function* test_records_obtained_from_server_are_stored_in_db(){
+  for (let {client} of gBlocklistClients) {
+    // Test an empty db populates
+    let result = yield client.maybeSync(2000, Date.now());
+
+    // Open the collection, verify it's been populated:
+    // Our test data has a single record; it should be in the local collection
+    let collection = kintoCollection(client.collectionName);
+    yield collection.db.open();
+    let list = yield collection.list();
+    equal(list.data.length, 1);
+    yield collection.db.close();
+  }
+});
+add_task(clear_state);
+
+add_task(function* test_list_is_written_to_file_in_profile(){
+  for (let {client, filename, testData} of gBlocklistClients) {
+    const profFile = FileUtils.getFile(KEY_PROFILEDIR, [filename]);
+    strictEqual(profFile.exists(), false);
+
+    let result = yield client.maybeSync(2000, Date.now());
+
+    strictEqual(profFile.exists(), true);
+    const content = yield readJSON(profFile.path);
+    equal(content.data[0].blockID, testData[testData.length - 1]);
+  }
+});
+add_task(clear_state);
+
+add_task(function* test_current_server_time_is_saved_in_pref(){
+  for (let {client} of gBlocklistClients) {
+    const before = Services.prefs.getIntPref(client.lastCheckTimePref);
+    const serverTime = Date.now();
+    yield client.maybeSync(2000, serverTime);
+    const after = Services.prefs.getIntPref(client.lastCheckTimePref);
+    equal(after, Math.round(serverTime / 1000));
+  }
+});
+add_task(clear_state);
+
+add_task(function* test_update_json_file_when_addons_has_changes(){
+  for (let {client, filename, testData} of gBlocklistClients) {
+    yield client.maybeSync(2000, Date.now() - 1000);
+    const before = Services.prefs.getIntPref(client.lastCheckTimePref);
+    const profFile = FileUtils.getFile(KEY_PROFILEDIR, [filename]);
+    const fileLastModified = profFile.lastModifiedTime = profFile.lastModifiedTime - 1000;
+    const serverTime = Date.now();
+
+    yield client.maybeSync(3001, serverTime);
+
+    // File was updated.
+    notEqual(fileLastModified, profFile.lastModifiedTime);
+    const content = yield readJSON(profFile.path);
+    deepEqual(content.data.map((r) => r.blockID), testData);
+    // Server time was updated.
+    const after = Services.prefs.getIntPref(client.lastCheckTimePref);
+    equal(after, Math.round(serverTime / 1000));
+  }
+});
+add_task(clear_state);
+
+add_task(function* test_sends_reload_message_when_blocklist_has_changes(){
+  for (let {client, filename} of gBlocklistClients) {
+    let received = yield new Promise((resolve, reject) => {
+      Services.ppmm.addMessageListener("Blocklist:reload-from-disk", {
+        receiveMessage(aMsg) { resolve(aMsg) }
+      });
+
+      client.maybeSync(2000, Date.now() - 1000);
+    });
+
+    equal(received.data.filename, filename);
+  }
+});
+add_task(clear_state);
+
+add_task(function* test_do_nothing_when_blocklist_is_up_to_date(){
+  for (let {client, filename} of gBlocklistClients) {
+    yield client.maybeSync(2000, Date.now() - 1000);
+    const before = Services.prefs.getIntPref(client.lastCheckTimePref);
+    const profFile = FileUtils.getFile(KEY_PROFILEDIR, [filename]);
+    const fileLastModified = profFile.lastModifiedTime = profFile.lastModifiedTime - 1000;
+    const serverTime = Date.now();
+
+    yield client.maybeSync(3000, serverTime);
+
+    // File was not updated.
+    equal(fileLastModified, profFile.lastModifiedTime);
+    // Server time was updated.
+    const after = Services.prefs.getIntPref(client.lastCheckTimePref);
+    equal(after, Math.round(serverTime / 1000));
+  }
+});
+add_task(clear_state);
+
+
+
+// get a response for a given request from sample data
+function getSampleResponse(req, port) {
+  const responses = {
+    "OPTIONS": {
+      "sampleHeaders": [
+        "Access-Control-Allow-Headers: Content-Length,Expires,Backoff,Retry-After,Last-Modified,Total-Records,ETag,Pragma,Cache-Control,authorization,content-type,if-none-match,Alert,Next-Page",
+        "Access-Control-Allow-Methods: GET,HEAD,OPTIONS,POST,DELETE,OPTIONS",
+        "Access-Control-Allow-Origin: *",
+        "Content-Type: application/json; charset=UTF-8",
+        "Server: waitress"
+      ],
+      "status": {status: 200, statusText: "OK"},
+      "responseBody": "null"
+    },
+    "GET:/v1/?": {
+      "sampleHeaders": [
+        "Access-Control-Allow-Origin: *",
+        "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+        "Content-Type: application/json; charset=UTF-8",
+        "Server: waitress"
+      ],
+      "status": {status: 200, statusText: "OK"},
+      "responseBody": JSON.stringify({"settings":{"cliquet.batch_max_requests":25}, "url":`http://localhost:${port}/v1/`, "documentation":"https://kinto.readthedocs.org/", "version":"1.5.1", "commit":"cbc6f58", "hello":"kinto"})
+    },
+    "GET:/v1/buckets/blocklists/collections/addons/records?_sort=-last_modified": {
+      "sampleHeaders": [
+        "Access-Control-Allow-Origin: *",
+        "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+        "Content-Type: application/json; charset=UTF-8",
+        "Server: waitress",
+        "Etag: \"3000\""
+      ],
+      "status": {status: 200, statusText: "OK"},
+      "responseBody": JSON.stringify({"data":[{
+        "prefs": [],
+        "blockID": "i539",
+        "last_modified": 3000,
+        "versionRange": [{
+          "targetApplication": [],
+          "maxVersion": "*",
+          "minVersion": "0",
+          "severity": "1"
+        }],
+        "guid": "ScorpionSaver@jetpack",
+        "id": "9d500963-d80e-3a91-6e74-66f3811b99cc"
+      }]})
+    },
+    "GET:/v1/buckets/blocklists/collections/plugins/records?_sort=-last_modified": {
+      "sampleHeaders": [
+        "Access-Control-Allow-Origin: *",
+        "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+        "Content-Type: application/json; charset=UTF-8",
+        "Server: waitress",
+        "Etag: \"3000\""
+      ],
+      "status": {status: 200, statusText: "OK"},
+      "responseBody": JSON.stringify({"data":[{
+        "matchFilename": "NPFFAddOn.dll",
+        "blockID": "p28",
+        "id": "7b1e0b3c-e390-a817-11b6-a6887f65f56e",
+        "last_modified": 3000,
+        "versionRange": []
+      }]})
+    },
+    "GET:/v1/buckets/blocklists/collections/gfx/records?_sort=-last_modified": {
+      "sampleHeaders": [
+        "Access-Control-Allow-Origin: *",
+        "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+        "Content-Type: application/json; charset=UTF-8",
+        "Server: waitress",
+        "Etag: \"3000\""
+      ],
+      "status": {status: 200, statusText: "OK"},
+      "responseBody": JSON.stringify({"data":[{
+        "driverVersionComparator": "LESS_THAN_OR_EQUAL",
+        "driverVersion": "8.17.12.5896",
+        "vendor": "0x10de",
+        "blockID": "g36",
+        "feature": "DIRECT3D_9_LAYERS",
+        "devices": ["0x0a6c"],
+        "featureStatus": "BLOCKED_DRIVER_VERSION",
+        "last_modified": 3000,
+        "os": "WINNT 6.1",
+        "id": "3f947f16-37c2-4e96-d356-78b26363729b"
+      }]})
+    },
+    "GET:/v1/buckets/blocklists/collections/addons/records?_sort=-last_modified&_since=3000": {
+      "sampleHeaders": [
+        "Access-Control-Allow-Origin: *",
+        "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+        "Content-Type: application/json; charset=UTF-8",
+        "Server: waitress",
+        "Etag: \"4000\""
+      ],
+      "status": {status: 200, statusText: "OK"},
+      "responseBody": JSON.stringify({"data":[{
+        "prefs": [],
+        "blockID": "i808",
+        "last_modified": 4000,
+        "versionRange": [{
+          "targetApplication": [],
+          "maxVersion": "*",
+          "minVersion": "0",
+          "severity": "3"
+        }],
+        "guid": "{c96d1ae6-c4cf-4984-b110-f5f561b33b5a}",
+        "id": "9ccfac91-e463-c30c-f0bd-14143794a8dd"
+      }, {
+        "prefs": ["browser.startup.homepage"],
+        "blockID": "i720",
+        "last_modified": 3500,
+        "versionRange": [{
+          "targetApplication": [],
+          "maxVersion": "*",
+          "minVersion": "0",
+          "severity": "1"
+        }],
+        "guid": "FXqG@xeeR.net",
+        "id": "cf9b3129-a97e-dbd7-9525-a8575ac03c25"
+      }]})
+    },
+    "GET:/v1/buckets/blocklists/collections/plugins/records?_sort=-last_modified&_since=3000": {
+      "sampleHeaders": [
+        "Access-Control-Allow-Origin: *",
+        "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+        "Content-Type: application/json; charset=UTF-8",
+        "Server: waitress",
+        "Etag: \"4000\""
+      ],
+      "status": {status: 200, statusText: "OK"},
+      "responseBody": JSON.stringify({"data":[{
+        "infoURL": "https://get.adobe.com/flashplayer/",
+        "blockID": "p1044",
+        "matchFilename": "libflashplayer\\.so",
+        "last_modified": 4000,
+        "versionRange": [{
+          "targetApplication": [],
+          "minVersion": "11.2.202.509",
+          "maxVersion": "11.2.202.539",
+          "severity": "0",
+          "vulnerabilityStatus": "1"
+        }],
+        "os": "Linux",
+        "id": "aabad965-e556-ffe7-4191-074f5dee3df3"
+      }, {
+        "matchFilename": "npViewpoint.dll",
+        "blockID": "p32",
+        "id": "1f48af42-c508-b8ef-b8d5-609d48e4f6c9",
+        "last_modified": 3500,
+        "versionRange": [{
+          "targetApplication": [{
+            "minVersion": "3.0",
+            "guid": "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}",
+            "maxVersion": "*"
+          }]
+        }]
+      }]})
+    },
+    "GET:/v1/buckets/blocklists/collections/gfx/records?_sort=-last_modified&_since=3000": {
+      "sampleHeaders": [
+        "Access-Control-Allow-Origin: *",
+        "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+        "Content-Type: application/json; charset=UTF-8",
+        "Server: waitress",
+        "Etag: \"4000\""
+      ],
+      "status": {status: 200, statusText: "OK"},
+      "responseBody": JSON.stringify({"data":[{
+        "vendor": "0x8086",
+        "blockID": "g204",
+        "feature": "WEBGL_MSAA",
+        "devices": [],
+        "id": "c96bca82-e6bd-044d-14c4-9c1d67e9283a",
+        "last_modified": 4000,
+        "os": "Darwin 10",
+        "featureStatus": "BLOCKED_DEVICE"
+      }, {
+        "vendor": "0x10de",
+        "blockID": "g200",
+        "feature": "WEBGL_MSAA",
+        "devices": [],
+        "id": "c3a15ba9-e0e2-421f-e399-c995e5b8d14e",
+        "last_modified": 3500,
+        "os": "Darwin 11",
+        "featureStatus": "BLOCKED_DEVICE"
+      }]})
+    }
+  };
+  return responses[`${req.method}:${req.path}?${req.queryString}`] ||
+         responses[req.method];
+
+}
--- a/services/common/tests/unit/test_kintoCertBlocklist.js
+++ b/services/common/tests/unit/test_kintoCertBlocklist.js
@@ -1,21 +1,19 @@
-/* Any copyright is dedicated to the Public Domain.
-   http://creativecommons.org/publicdomain/zero/1.0/ */
-
 const { Constructor: CC } = Components;
 
-Cu.import("resource://services-common/KintoBlocklist.js");
-Cu.import("resource://services-common/kinto-offline-client.js")
 Cu.import("resource://testing-common/httpd.js");
 
+const { OneCRLBlocklistClient } = Cu.import("resource://services-common/KintoBlocklist.js");
+const { loadKinto } = Cu.import("resource://services-common/kinto-offline-client.js");
+
 const BinaryInputStream = CC("@mozilla.org/binaryinputstream;1",
   "nsIBinaryInputStream", "setInputStream");
 
-var server;
+let server;
 
 // set up what we need to make storage adapters
 const Kinto = loadKinto();
 const FirefoxAdapter = Kinto.adapters.FirefoxAdapter;
 const kintoFilename = "kinto.sqlite";
 
 let kintoClient;
 
@@ -43,86 +41,86 @@ add_task(function* test_something(){
   const recordsPath = "/v1/buckets/blocklists/collections/certificates/records";
 
   Services.prefs.setCharPref("services.kinto.base",
                              `http://localhost:${server.identity.primaryPort}/v1`);
 
   // register a handler
   function handleResponse (request, response) {
     try {
-      const sampled = getSampleResponse(request, server.identity.primaryPort);
-      if (!sampled) {
+      const sample = getSampleResponse(request, server.identity.primaryPort);
+      if (!sample) {
         do_throw(`unexpected ${request.method} request for ${request.path}?${request.queryString}`);
       }
 
-      response.setStatusLine(null, sampled.status.status,
-                             sampled.status.statusText);
+      response.setStatusLine(null, sample.status.status,
+                             sample.status.statusText);
       // send the headers
-      for (let headerLine of sampled.sampleHeaders) {
+      for (let headerLine of sample.sampleHeaders) {
         let headerElements = headerLine.split(':');
         response.setHeader(headerElements[0], headerElements[1].trimLeft());
       }
       response.setHeader("Date", (new Date()).toUTCString());
 
-      response.write(sampled.responseBody);
+      response.write(sample.responseBody);
     } catch (e) {
-      dump(`${e}\n`);
+      do_print(e);
     }
   }
   server.registerPathHandler(configPath, handleResponse);
   server.registerPathHandler(recordsPath, handleResponse);
 
   // Test an empty db populates
-  let result = yield OneCRLClient.maybeSync(2000, Date.now());
+  let result = yield OneCRLBlocklistClient.maybeSync(2000, Date.now());
 
   // Open the collection, verify it's been populated:
   // Our test data has a single record; it should be in the local collection
   let collection = do_get_kinto_collection("certificates");
   yield collection.db.open();
   let list = yield collection.list();
   do_check_eq(list.data.length, 1);
   yield collection.db.close();
 
   // Test the db is updated when we call again with a later lastModified value
-  result = yield OneCRLClient.maybeSync(4000, Date.now());
+  result = yield OneCRLBlocklistClient.maybeSync(4000, Date.now());
 
   // Open the collection, verify it's been updated:
   // Our test data now has two records; both should be in the local collection
   collection = do_get_kinto_collection("certificates");
   yield collection.db.open();
   list = yield collection.list();
   do_check_eq(list.data.length, 3);
   yield collection.db.close();
 
   // Try to maybeSync with the current lastModified value - no connection
   // should be attempted.
   // Clear the kinto base pref so any connections will cause a test failure
   Services.prefs.clearUserPref("services.kinto.base");
-  yield OneCRLClient.maybeSync(4000, Date.now());
+  yield OneCRLBlocklistClient.maybeSync(4000, Date.now());
 
   // Try again with a lastModified value at some point in the past
-  yield OneCRLClient.maybeSync(3000, Date.now());
+  yield OneCRLBlocklistClient.maybeSync(3000, Date.now());
 
   // Check the OneCRL check time pref is modified, even if the collection
   // hasn't changed
   Services.prefs.setIntPref("services.kinto.onecrl.checked", 0);
-  yield OneCRLClient.maybeSync(3000, Date.now());
+  yield OneCRLBlocklistClient.maybeSync(3000, Date.now());
   let newValue = Services.prefs.getIntPref("services.kinto.onecrl.checked");
   do_check_neq(newValue, 0);
 });
 
 function run_test() {
   // Set up an HTTP Server
   server = new HttpServer();
   server.start(-1);
 
   run_next_test();
 
   do_register_cleanup(function() {
-    server.stop(function() { });
+    server.stop(() => { });
   });
 }
 
 // get a response for a given request from sample data
 function getSampleResponse(req, port) {
   const responses = {
     "OPTIONS": {
       "sampleHeaders": [
--- a/services/common/tests/unit/test_kinto_updater.js
+++ b/services/common/tests/unit/test_kinto_updater.js
@@ -1,11 +1,8 @@
-/* Any copyright is dedicated to the Public Domain.
-   http://creativecommons.org/publicdomain/zero/1.0/ */
-
 Cu.import("resource://services-common/kinto-updater.js")
 Cu.import("resource://testing-common/httpd.js");
 
 var server;
 
 const PREF_KINTO_BASE = "services.kinto.base";
 const PREF_LAST_UPDATE = "services.kinto.last_update_seconds";
 const PREF_LAST_ETAG = "services.kinto.last_etag";
--- a/services/common/tests/unit/xpcshell.ini
+++ b/services/common/tests/unit/xpcshell.ini
@@ -6,16 +6,17 @@ skip-if = toolkit == 'gonk'
 support-files =
   test_storage_adapter/**
 
 # Test load modules first so syntax failures are caught early.
 [test_load_modules.js]
 
 [test_kinto.js]
 [test_kinto_updater.js]
+[test_kintoAddonPluginBlocklist.js]
 [test_kintoCertBlocklist.js]
 [test_storage_adapter.js]
 
 [test_utils_atob.js]
 [test_utils_convert_string.js]
 [test_utils_dateprefs.js]
 [test_utils_deepCopy.js]
 [test_utils_encodeBase32.js]