Bug 1324919 - Land logins API, r?aswan draft
authorBob Silverberg <bsilverberg@mozilla.com>
Fri, 20 Jan 2017 14:36:28 -0500
changeset 565413 8b3984b81693b9f4d1ec0d0b575609cd8078708a
parent 563687 05c212a94183838f12feebb2c3fd483a6eec18c2
child 624975 e5c824f998513745ab0043a255063f87fe91a28b
push id54851
push userbmo:bob.silverberg@gmail.com
push dateWed, 19 Apr 2017 20:44:30 +0000
reviewersaswan
bugs1324919
milestone55.0a1
Bug 1324919 - Land logins API, r?aswan MozReview-Commit-ID: 4f55C7NtzT0
browser/locales/en-US/chrome/browser/browser.properties
toolkit/components/extensions/ext-logins.js
toolkit/components/extensions/ext-toolkit.js
toolkit/components/extensions/jar.mn
toolkit/components/extensions/schemas/jar.mn
toolkit/components/extensions/schemas/logins.json
toolkit/components/extensions/test/xpcshell/test_ext_logins.js
toolkit/components/extensions/test/xpcshell/xpcshell.ini
--- a/browser/locales/en-US/chrome/browser/browser.properties
+++ b/browser/locales/en-US/chrome/browser/browser.properties
@@ -96,16 +96,17 @@ webextPerms.optionalPermsDeny.label=Deny
 webextPerms.optionalPermsDeny.accessKey=D
 
 webextPerms.description.bookmarks=Read and modify bookmarks
 webextPerms.description.clipboardRead=Get data from the clipboard
 webextPerms.description.clipboardWrite=Input data to the clipboard
 webextPerms.description.downloads=Download files and read and modify the browser’s download history
 webextPerms.description.geolocation=Access your location
 webextPerms.description.history=Access browsing history
+webextPerms.description.logins=Read and modify stored logins
 # LOCALIZATION NOTE (webextPerms.description.nativeMessaging)
 # %S will be replaced with the name of the application
 webextPerms.description.nativeMessaging=Exchange messages with programs other than %S
 webextPerms.description.notifications=Display notifications to you
 webextPerms.description.privacy=Read and modify privacy settings
 webextPerms.description.sessions=Access recently closed tabs
 webextPerms.description.tabs=Access browser tabs
 webextPerms.description.topSites=Access browsing history
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/ext-logins.js
@@ -0,0 +1,220 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper",
+                                  "resource://gre/modules/LoginHelper.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+                                  "resource://gre/modules/Services.jsm");
+
+var {
+  ExtensionError,
+} = ExtensionUtils;
+
+const LoginInfo = Components.Constructor("@mozilla.org/login-manager/loginInfo;1",
+                                         "nsILoginInfo", "init");
+
+const FIELDS = {
+  formActionOrigin: "formSubmitURL",
+  origin: "hostname",
+  httpRealm: "httpRealm",
+  username: "username",
+  password: "password",
+  id: "guid",
+};
+
+/**
+ * Converts an nsILoginInfo object into a LoginItem API object.
+ *
+ * @param {nsILoginInfo} info An an nsILoginInfo object.
+ *
+ * @returns {Object} A LoginItem API object.
+*/
+function convert(info) {
+  info.QueryInterface(Ci.nsILoginMetaInfo);
+  let obj = {};
+  for (let field of Object.keys(FIELDS)) {
+    obj[field] = info[FIELDS[field]];
+  }
+  return obj;
+}
+
+/**
+ * Checks whether an nsILoginInfo object matches some search criteria.
+ *
+ * @param {nsILoginInfo} info  An an nsILoginInfo object.
+ * @param {Object} criteria An object with one property per search criterion.
+ *
+ * @returns {boolean} Does the nsILoginInfo object match all of the criteria?
+*/
+function match(info, criteria) {
+  return Object.keys(criteria).every(field => criteria[field] == null ||
+                                              criteria[field] == "" ||
+                                              criteria[field] == info[FIELDS[field]]);
+}
+
+/**
+ * Checks whether an hostname is accessible by the current extension.
+ *
+ * @param {BaseContext} context
+ * @param {string} hostname A hostname to check.
+ *
+ * @returns {boolean} Does the extension have access to the hostname?
+*/
+function accessible(context, hostname) {
+  let url;
+  try {
+    url = Services.io.newURI(hostname);
+  } catch (ex) {
+    Cu.reportError(ex);
+    return false;
+  }
+
+  if (url.scheme == "addon") {
+    return (url.path == context.extension.id);
+  }
+  if (url.scheme == "moz-extension") {
+    return (url.host == context.extension.id
+            || url.host == context.extension.uuid);
+  }
+  return (context.extension.whiteListedHosts.matches(url));
+}
+
+/**
+ * Validates a LoginItem to ensure that its origin, formActionOrigin and
+ * httpRealm are valid, including checking that they are accessible to
+ * the extension in question.
+ *
+ * @param {BaseContext} context
+ * @param {Object} loginItem A LoginItem API object.
+ *
+ * @returns {string} An origin that conforms to the expectations of the
+ *                   logins API.
+*/
+function validateOrigin(context, loginItem) {
+  let origin = loginItem.origin;
+
+  function check(field) {
+    if (!loginItem[field]) {
+      return;
+    }
+    let uri;
+    try {
+      uri = Services.io.newURI(loginItem[field]);
+    } catch (err) {
+      return `Cannot parse ${field} as a URL.`;
+    }
+
+    if (origin) {
+      if (uri.prePath != origin) {
+        return `Origin does not match ${field}.`;
+      }
+    } else {
+      origin = uri.prePath;
+    }
+  }
+
+  for (let field of ["formActionOrigin", "httpRealm"]) {
+    let checkMsg = check(field);
+    if (checkMsg) {
+      throw new ExtensionError(checkMsg);
+    }
+  }
+
+  if (!origin) {
+    throw new ExtensionError("Must specify origin, formActionOrigin, or httpRealm.");
+  }
+
+  if (!accessible(context, origin)) {
+    throw new ExtensionError(`Permission denied for ${origin}.`);
+  }
+
+  return origin;
+}
+
+this.logins = class extends ExtensionAPI {
+  getAPI(context) {
+    return {
+      logins: {
+        search(query) {
+          let logins = Services.logins.findLogins({}, query.origin,
+                                                  query.formActionOrigin,
+                                                  query.httpRealm)
+              .filter(login => accessible(context, login.hostname))
+              .filter(login => match(login, query))
+              .map(convert);
+
+          return Promise.resolve(logins);
+        },
+
+        create(loginData) {
+          let origin = validateOrigin(context, loginData);
+          let login = new LoginInfo(origin, loginData.formActionOrigin, loginData.httpRealm,
+                                    loginData.username, loginData.password, "", "");
+
+          try {
+            Services.logins.addLogin(login);
+          } catch (err) {
+            return Promise.reject({message: err.message});
+          }
+          return Promise.resolve();
+        },
+
+        update(id, loginData) {
+          let origin;
+          // We only need to validate the new origin if any of the applicable
+          // properties were passed.
+          if (loginData.origin || loginData.formActionOrigin || loginData.httpRealm) {
+            origin = validateOrigin(context, loginData);
+          }
+
+          let oldLogin = LoginHelper.searchLoginsWithObject({guid: id})[0];
+          if (!oldLogin) {
+            return Promise.reject(
+              {message: `No login was found that matches the id: ${id}.`});
+          }
+
+          // Convert the login so we can validate it (for accessibility).
+          let oldLoginItem = convert(oldLogin);
+          let oldOrigin = validateOrigin(context, oldLoginItem);
+
+          let newLoginData = {};
+          for (let prop of Object.keys(oldLoginItem)) {
+            newLoginData[prop] = loginData[prop] === null
+                                 ? oldLoginItem[prop] : loginData[prop];
+          }
+
+          let newLoginInfo = new LoginInfo(
+            origin || oldOrigin, newLoginData.formActionOrigin, newLoginData.httpRealm,
+            newLoginData.username, newLoginData.password, "", "");
+
+          try {
+            Services.logins.modifyLogin(oldLogin, newLoginInfo);
+          } catch (err) {
+            return Promise.reject({message: err.message});
+          }
+          return Promise.resolve();
+        },
+
+        remove(id) {
+          let oldLogin = LoginHelper.searchLoginsWithObject({guid: id})[0];
+          if (!oldLogin) {
+            return Promise.reject(
+              {message: `No login was found that matches the id: ${id}.`});
+          }
+
+          if (!accessible(context, oldLogin.hostname)) {
+            throw new ExtensionError(`Permission denied for ${oldLogin.hostname}.`);
+          }
+
+          try {
+            Services.logins.removeLogin(oldLogin);
+          } catch (err) {
+            return Promise.reject({message: err.message});
+          }
+          return Promise.resolve();
+        },
+      },
+    };
+  }
+};
--- a/toolkit/components/extensions/ext-toolkit.js
+++ b/toolkit/components/extensions/ext-toolkit.js
@@ -125,16 +125,24 @@ extensions.registerModules({
   idle: {
     url: "chrome://extensions/content/ext-idle.js",
     schema: "chrome://extensions/content/schemas/idle.json",
     scopes: ["addon_parent"],
     paths: [
       ["idle"],
     ],
   },
+  logins: {
+    url: "chrome://extensions/content/ext-logins.js",
+    schema: "chrome://extensions/content/schemas/logins.json",
+    scopes: ["addon_parent"],
+    paths: [
+      ["logins"],
+    ],
+  },
   management: {
     url: "chrome://extensions/content/ext-management.js",
     schema: "chrome://extensions/content/schemas/management.json",
     scopes: ["addon_parent"],
     paths: [
       ["management"],
     ],
   },
--- a/toolkit/components/extensions/jar.mn
+++ b/toolkit/components/extensions/jar.mn
@@ -10,16 +10,17 @@ toolkit.jar:
     content/extensions/ext-browser-content.js
     content/extensions/ext-contextualIdentities.js
     content/extensions/ext-cookies.js
     content/extensions/ext-downloads.js
     content/extensions/ext-extension.js
     content/extensions/ext-geolocation.js
     content/extensions/ext-i18n.js
     content/extensions/ext-idle.js
+    content/extensions/ext-logins.js
     content/extensions/ext-management.js
     content/extensions/ext-notifications.js
     content/extensions/ext-permissions.js
     content/extensions/ext-privacy.js
     content/extensions/ext-protocolHandlers.js
     content/extensions/ext-proxy.js
     content/extensions/ext-runtime.js
     content/extensions/ext-storage.js
--- a/toolkit/components/extensions/schemas/jar.mn
+++ b/toolkit/components/extensions/schemas/jar.mn
@@ -13,16 +13,17 @@ toolkit.jar:
     content/extensions/schemas/extension.json
     content/extensions/schemas/extension_types.json
     content/extensions/schemas/extension_protocol_handlers.json
     content/extensions/schemas/i18n.json
 #ifndef ANDROID
     content/extensions/schemas/identity.json
 #endif
     content/extensions/schemas/idle.json
+    content/extensions/schemas/logins.json
     content/extensions/schemas/management.json
     content/extensions/schemas/manifest.json
     content/extensions/schemas/native_host_manifest.json
     content/extensions/schemas/notifications.json
     content/extensions/schemas/permissions.json
     content/extensions/schemas/proxy.json
     content/extensions/schemas/privacy.json
     content/extensions/schemas/runtime.json
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/schemas/logins.json
@@ -0,0 +1,204 @@
+/* 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/. */
+
+[
+  {
+    "namespace": "manifest",
+    "types": [
+      {
+        "$extend": "OptionalPermission",
+        "choices": [{
+          "type": "string",
+          "enum": [
+            "logins"
+          ]
+        }]
+      }
+    ]
+  },
+  {
+    "namespace": "logins",
+    "description": "Access saved logins and passwords.",
+    "permissions": ["logins"],
+    "types": [
+      {
+        "id": "LoginItem",
+        "type": "object",
+        "properties": {
+          "id": {
+            "type": "string",
+            "description": "An id that uniquely identifies the login object."
+          },
+          "username": {
+            "type": "string",
+            "description": "The username."
+          },
+          "password": {
+            "type": "string",
+            "description": "The password."
+          },
+          "origin": {
+            "type": "string",
+            "optional": true,
+            "description": "The origin for the web service which requires the credential. You should omit anything after the hostname and (optional) port."
+          },
+          "httpRealm": {
+            "type": "string",
+            "optional": true,
+            "description": "The WWW-Authenticate response header sent by the server may include a \"httpRealm\" field as detailed in RFC 2617. If it does, this property contains the value for the \"httpRealm\" field. Otherwise, it is omitted.\nThe httpRealm is displayed in Firefox's Password Manager, under \"Site\", in brackets after the URL."
+          },
+          "formActionOrigin": {
+            "type": "string",
+            "optional": true,
+            "description": "The origin to which an HTML form for the credentials is submitted."
+          }
+        }
+      },
+      {
+        "id": "CreateLoginData",
+        "type": "object",
+        "properties": {
+          "username": {
+            "type": "string",
+            "description": "The username."
+          },
+          "password": {
+            "type": "string",
+            "description": "The password."
+          },
+          "origin": {
+            "type": "string",
+            "optional": true,
+            "description": "The origin for the web service which requires the credential. You should omit anything after the hostname and (optional) port."
+          },
+          "httpRealm": {
+            "type": "string",
+            "optional": true,
+            "description": "The WWW-Authenticate response header sent by the server may include a \"httpRealm\" field as detailed in RFC 2617. If it does, this property contains the value for the \"httpRealm\" field. Otherwise, it is omitted.\nThe httpRealm is displayed in Firefox's Password Manager, under \"Site\", in brackets after the URL."
+          },
+          "formActionOrigin": {
+            "type": "string",
+            "optional": true,
+            "description": "The origin to which an HTML form for the credentials is submitted."
+          }
+        }
+      },
+      {
+        "id": "UpdateLoginData",
+        "type": "object",
+        "properties": {
+          "username": {
+            "type": "string",
+            "optional": true,
+            "description": "The username."
+          },
+          "password": {
+            "type": "string",
+            "optional": true,
+            "description": "The password."
+          },
+          "origin": {
+            "type": "string",
+            "optional": true,
+            "description": "The origin for the web service which requires the credential. You should omit anything after the hostname and (optional) port."
+          },
+          "httpRealm": {
+            "type": "string",
+            "optional": true,
+            "description": "The WWW-Authenticate response header sent by the server may include a \"httpRealm\" field as detailed in RFC 2617. If it does, this property contains the value for the \"httpRealm\" field. Otherwise, it is omitted.\nThe httpRealm is displayed in Firefox's Password Manager, under \"Site\", in brackets after the URL."
+          },
+          "formActionOrigin": {
+            "type": "string",
+            "optional": true,
+            "description": "The origin to which an HTML form for the credentials is submitted."
+          }
+        }
+      },
+      {
+        "id": "LoginQuery",
+        "type": "object",
+        "description": "Equivalent to LoginItem but with all properties marked as optional.  Instances of this object are passed the the search method.",
+        "properties": {
+          "username": {
+            "type": "string",
+            "optional": true
+          },
+          "password": {
+            "type": "string",
+            "optional": true
+          },
+          "origin": {
+            "type": "string",
+            "optional": true,
+            "default": ""
+          },
+          "httpRealm": {
+            "type": "string",
+            "optional": true,
+            "default": ""
+          },
+          "formActionOrigin": {
+            "type": "string",
+            "optional": true,
+            "default": ""
+          }
+        }
+      }
+    ],
+    "functions": [
+      {
+        "name": "search",
+        "type": "function",
+        "async": true,
+        "description": "Search for matching logins.  Returns a Promise that resolves to a list of matching LoginItem objects.  Only items that the current extension has access to (either because they belong to the extension or because the extension has a host permission for the origin of the item) are returned.",
+        "parameters": [
+          {
+            "name": "query",
+            "$ref": "LoginQuery"
+          }
+        ]
+      },
+      {
+        "name": "create",
+        "type": "function",
+        "async": true,
+        "description": "Create a login.  The extension must have permission for the given origin.",
+        "parameters": [
+          {
+            "name": "loginData",
+            "$ref": "CreateLoginData"
+          }
+        ]
+      },
+      {
+        "name": "update",
+        "type": "function",
+        "async": true,
+        "description": "Update an existing login, identified by its id.  The extension must have permission for the given origin.",
+        "parameters": [
+          {
+            "name": "id",
+            "type": "string"
+          },
+          {
+            "name": "loginData",
+            "$ref": "UpdateLoginData"
+          }
+        ]
+      },
+      {
+        "name": "remove",
+        "type": "function",
+        "async": true,
+        "description": "Remove a saved login.",
+        "parameters": [
+          {
+            "name": "id",
+            "type": "string"
+          }
+        ]
+      }
+    ]
+  }
+]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_logins.js
@@ -0,0 +1,265 @@
+"use strict";
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+function checkLogin(expected, actual) {
+  for (let prop of Object.keys(expected)) {
+    equal(actual[prop], expected[prop], `Expected value for ${prop} for found login.`);
+  }
+}
+
+add_task(async function test_logins() {
+  function background() {
+    browser.test.onMessage.addListener(function(msg, args) {
+      Promise.resolve().then(() => browser.logins[msg](...args))
+        .then(results => {
+          browser.test.sendMessage(`${msg}.done`, {results});
+        }, err => {
+          browser.test.sendMessage(`${msg}.done`, {errmsg: err.message});
+        });
+    });
+    browser.test.sendMessage("ready");
+  }
+
+  function run(ext, cmd, ...args) {
+    let promise = ext.awaitMessage(`${cmd}.done`);
+    ext.sendMessage(cmd, args);
+    return promise;
+  }
+
+  let privilegedExtension = ExtensionTestUtils.loadExtension({
+    background,
+    manifest: {
+      permissions: ["logins", "https://*.mozilla.com/"],
+    },
+  });
+
+  let unprivilegedExtension = ExtensionTestUtils.loadExtension({
+    background,
+    manifest: {
+      permissions: ["logins"],
+    },
+  });
+
+  await privilegedExtension.startup();
+  await unprivilegedExtension.startup();
+  await privilegedExtension.awaitMessage("ready");
+  await unprivilegedExtension.awaitMessage("ready");
+
+  // Initially, we shouldn't see anything.
+  let response = await run(privilegedExtension, "search", {});
+  equal(response.results.length, 0, "No logins found.");
+  response = await run(unprivilegedExtension, "search", {});
+  equal(response.results.length, 0, "No logins found.");
+
+  // Add a login record via nsILoginManager.
+  let login1 = {
+    formActionOrigin: "https://test.mozilla.com/somepage",
+    origin: "https://test.mozilla.com",
+    username: "user",
+    password: "password",
+  };
+
+  let info = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo);
+  info.formSubmitURL = login1.formActionOrigin;
+  info.hostname = login1.origin;
+  info.username = login1.username;
+  info.password = login1.password;
+  info.usernameField = "usernameField";
+  info.passwordField = "passwordField";
+  Services.logins.addLogin(info);
+
+  // Find the generated id and add it to the login1 object.
+  let newLogin = Services.logins.findLogins({}, login1.origin, "", "")[0];
+  ok(newLogin, "Login created and found via nsILoginManager.");
+  newLogin.QueryInterface(Ci.nsILoginMetaInfo);
+  login1.id = newLogin.guid;
+
+  // The unprivileged extension should not be able to see it.
+  response = await run(unprivilegedExtension, "search", {});
+  equal(response.results.length, 0, "No logins found.");
+
+  // The privileged extension should be able to see it.
+  response = await run(privilegedExtension, "search", {});
+  equal(response.results.length, 1, "One login found.");
+  checkLogin(login1, response.results[0]);
+
+  // Add another login, this time via the API.
+  let login2 = {
+    formActionOrigin: "https://test.mozilla.com/somepage",
+    origin: "https://test.mozilla.com",
+    username: "username2",
+    password: "password2",
+  };
+
+  // Test that unprivileged extension cannot create a record for an external site.
+  response = await run(unprivilegedExtension, "create", login2);
+  equal(response.errmsg, `Permission denied for ${login2.origin}.`,
+        "Trying to create a login for an inaccessible origin generates an error.");
+
+  // Test that create() works for an extension with host permission.
+  response = await run(privilegedExtension, "create", login2);
+  equal(response.errmsg, undefined, "create() succeeded.");
+  response = await run(privilegedExtension, "search", {username: login2.username});
+  equal(response.results.length, 1, "One record found.");
+  checkLogin(login2, response.results[0]);
+
+  // Check that we don't see login2 when we search for login1.
+  response = await run(privilegedExtension, "search", {username: login1.username});
+  equal(response.results.length, 1, "One login found.");
+  checkLogin(login1, response.results[0]);
+
+  // Add another login, with a different origin.
+  let login3 = {
+    formActionOrigin: "https://test3.mozilla.com/somepage",
+    origin: "https://test3.mozilla.com",
+    username: "username3",
+    password: "password3",
+  };
+  response = await run(privilegedExtension, "create", login3);
+  equal(response.errmsg, undefined, "create() succeeded.");
+  response = await run(privilegedExtension, "search", {origin: login3.origin});
+  equal(response.results.length, 1, "One record found.");
+  checkLogin(login3, response.results[0]);
+  // Add the id to login3.
+  login3.id = response.results[0].id;
+
+  // This should find two records.
+  response = await run(privilegedExtension, "search", {origin: login1.origin});
+  equal(response.results.length, 2, "Two logins found.");
+
+  // Test each field and some combinations.
+  let queries = [
+    {formActionOrigin: login3.formActionOrigin},
+    {origin: login3.origin},
+    {username: login3.username},
+    {password: login3.password},
+    {formActionOrigin: login3.formActionOrigin, origin: login3.origin},
+    {formActionOrigin: login3.formActionOrigin, origin: login3.origin,
+     username: login3.username},
+    {formActionOrigin: login3.formActionOrigin, origin: login3.origin,
+     username: login3.username, password: login3.password},
+  ];
+
+  for (let query of queries) {
+    response = await run(privilegedExtension, "search", query);
+    equal(response.results.length, 1, "One login found.");
+    checkLogin(login3, response.results[0]);
+  }
+
+  // Test that create() fails with a duplicate login.
+  response = await run(privilegedExtension, "create", login2);
+  ok(response.errmsg.includes("This login already exists."),
+     "Trying to create a duplicate login generates an error.");
+
+  let badLogin = {
+    formActionOrigin: "https://test1.mozilla.com/testpage",
+    origin: "https://test2.mozilla.com",
+    httpRealm: null,
+    username: "baduser",
+    password: "badpassword",
+  };
+
+  response = await run(privilegedExtension, "create", badLogin);
+  equal(response.errmsg, "Origin does not match formActionOrigin.",
+        "Trying to create a record with a mismatched formActionOrigin and origin generates an error.");
+
+  badLogin.formActionOrigin = "not_a_valid_url";
+  response = await run(privilegedExtension, "create", badLogin);
+  equal(response.errmsg, "Cannot parse formActionOrigin as a URL.",
+        "Trying to create a record with an invalid formActionOrigin generates an error.");
+
+  // Test that an unprivileged extension cannot update a record for an external site.
+  response = await run(unprivilegedExtension, "update", login3.id,
+                       {username: "this should fail"});
+  equal(response.errmsg, `Permission denied for ${login3.origin}.`,
+        "Trying to update a login for an inaccessible origin generates an error.");
+
+  async function testUpdate(oldLogin, loginData) {
+    // Update the login.
+    response = await run(privilegedExtension, "update", oldLogin.id, loginData);
+    equal(response.errmsg, undefined, "update() succeeded.");
+
+    // Check that the changes are in the database.
+    response = await run(privilegedExtension, "search", loginData);
+    equal(response.results.length, 1, "One record found.");
+    let foundLogin = response.results[0];
+    for (let prop of Object.keys(foundLogin)) {
+      if (loginData[prop]) {
+        equal(foundLogin[prop], loginData[prop], `The ${prop} was updated.`);
+      } else {
+        equal(foundLogin[prop], oldLogin[prop], `The ${prop} was retained from the old login.`);
+      }
+    }
+    return foundLogin;
+  }
+
+  let loginData = {password: "new password"};
+  login3 = await testUpdate(login3, loginData);
+
+  loginData = {username: "new username", password: "another new password"};
+  login3 = await testUpdate(login3, loginData);
+
+  loginData = {
+    username: "still another username",
+    password: "still another password",
+    origin: "https://test4.mozilla.com",
+    formActionOrigin: "https://test4.mozilla.com/somepage",
+  };
+  login3 = await testUpdate(login3, loginData);
+
+  // Test that an extension cannot update a record to an inaccessible host.
+  let badOrigin = "http://example.com";
+  response = await run(privilegedExtension, "update", login3.id,
+                       {origin: badOrigin});
+  equal(response.errmsg, `Permission denied for ${badOrigin}.`,
+        "Trying to update a login to an inaccessible origin generates an error.");
+
+  response = await run(privilegedExtension, "update", "not a valid id", loginData);
+  equal(response.errmsg, `No login was found that matches the id: not a valid id.`,
+        "Trying to update a record without a findable id generates an error.");
+
+  loginData = {
+    origin: "https://test5.mozilla.com",
+    formActionOrigin: "https://test6.mozilla.com/somepage",
+  };
+  response = await run(privilegedExtension, "update", login3.id, loginData);
+  equal(response.errmsg, "Origin does not match formActionOrigin.",
+        "Trying to update a record with a mismatched formActionOrigin and origin generates an error.");
+
+  loginData = {formActionOrigin: "not_a_valid_url"};
+  response = await run(privilegedExtension, "update", login3.id, loginData);
+  equal(response.errmsg, "Cannot parse formActionOrigin as a URL.",
+        "Trying to update a record with an invalid formActionOrigin generates an error.");
+
+  // Make sure the update didn't change any other records.
+  response = await run(privilegedExtension, "search", {username: login1.username});
+  equal(response.results.length, 1, "One login found.");
+  checkLogin(login1, response.results[0]);
+
+  let id = "not a valid id";
+  response = await run(privilegedExtension, "remove", id);
+  equal(response.errmsg, `No login was found that matches the id: ${id}.`,
+        "Trying to remove a record without a found id generates an error.");
+
+  id = login3.id;
+  // Unprivileged extension should not be able to remove the record.
+  response = await run(unprivilegedExtension, "remove", id);
+  equal(response.errmsg, `Permission denied for ${login3.origin}.`,
+        "Trying to remove an inaccessible record generates an error.");
+  response = await run(privilegedExtension, "search", {username: login3.username});
+  equal(response.results.length, 1, "One record found.");
+
+  // Extension with privileges should be able to remove it.
+  response = await run(privilegedExtension, "remove", id);
+  equal(response.errmsg, undefined, "remove() succeeded.");
+  response = await run(privilegedExtension, "search", {username: login3.username});
+  equal(response.results.length, 0, "No records found.");
+
+  // Make sure that other records remain in place.
+  response = await run(privilegedExtension, "search", {origin: login1.origin});
+  equal(response.results.length, 2, "Two login found.");
+
+  await privilegedExtension.unload();
+  await unprivilegedExtension.unload();
+});
--- a/toolkit/components/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini
@@ -38,16 +38,17 @@ skip-if = os == "android"
 [test_ext_experiments.js]
 skip-if = release_or_beta
 [test_ext_extension.js]
 [test_ext_extensionPreferencesManager.js]
 [test_ext_extensionSettingsStore.js]
 [test_ext_idle.js]
 [test_ext_json_parser.js]
 [test_ext_localStorage.js]
+[test_ext_logins.js]
 [test_ext_management.js]
 [test_ext_management_uninstall_self.js]
 [test_ext_manifest_content_security_policy.js]
 [test_ext_manifest_incognito.js]
 [test_ext_manifest_minimum_chrome_version.js]
 [test_ext_manifest_themes.js]
 [test_ext_onmessage_removelistener.js]
 skip-if = true # This test no longer tests what it is meant to test.