Bug 667233 - Support scheme upgrades for searching logins. r=dolske
authorMatthew Noorenberghe <mozilla@noorenberghe.ca>
Sun, 29 May 2016 17:15:52 -0700
changeset 340330 ae1f10aaf97873ef33f37d35b060bcb5e6889fb9
parent 340329 ba10f978fe39da744b6fa8ea284b26928713b3da
child 340331 da82605a18d60cea51ae49d5bbaac1ef62a75cd4
push id1183
push userraliiev@mozilla.com
push dateMon, 05 Sep 2016 20:01:49 +0000
treeherdermozilla-release@3148731bed45 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdolske
bugs667233
milestone49.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 667233 - Support scheme upgrades for searching logins. r=dolske MozReview-Commit-ID: GAw0HIBgOFw
toolkit/components/passwordmgr/LoginHelper.jsm
toolkit/components/passwordmgr/LoginRecipes.jsm
toolkit/components/passwordmgr/nsLoginManager.js
toolkit/components/passwordmgr/storage-json.js
toolkit/components/passwordmgr/storage-mozStorage.js
toolkit/components/passwordmgr/test/unit/head.js
toolkit/components/passwordmgr/test/unit/test_isOriginMatching.js
toolkit/components/passwordmgr/test/unit/test_logins_search.js
toolkit/components/passwordmgr/test/unit/test_search_schemeUpgrades.js
toolkit/components/passwordmgr/test/unit/xpcshell.ini
--- a/toolkit/components/passwordmgr/LoginHelper.jsm
+++ b/toolkit/components/passwordmgr/LoginHelper.jsm
@@ -35,17 +35,17 @@ Cu.import("resource://gre/modules/XPCOMU
 this.LoginHelper = {
   /**
    * Warning: this only updates if a logger was created.
    */
   debug: Services.prefs.getBoolPref("signon.debug"),
 
   createLogger(aLogPrefix) {
     let getMaxLogLevel = () => {
-      return this.debug ? "debug" : "error";
+      return this.debug ? "debug" : "warn";
     };
 
     // Create a new instance of the ConsoleAPI so we can control the maxLogLevel with a pref.
     let ConsoleAPI = Cu.import("resource://gre/modules/Console.jsm", {}).ConsoleAPI;
     let consoleOptions = {
       maxLogLevel: getMaxLogLevel(),
       prefix: aLogPrefix,
     };
@@ -129,16 +129,89 @@ this.LoginHelper = {
     // eg host="foo (", realm="bar" --> "foo ( (bar)"
     // vs host="foo", realm=" (bar" --> "foo ( (bar)"
     if (aLogin.hostname.indexOf(" (") != -1) {
       throw new Error("bad parens in hostname");
     }
   },
 
   /**
+   * Returns a new XPCOM property bag with the provided properties.
+   *
+   * @param {Object} aProperties
+   *        Each property of this object is copied to the property bag.  This
+   *        parameter can be omitted to return an empty property bag.
+   *
+   * @return A new property bag, that is an instance of nsIWritablePropertyBag,
+   *         nsIWritablePropertyBag2, nsIPropertyBag, and nsIPropertyBag2.
+   */
+  newPropertyBag(aProperties) {
+    let propertyBag = Cc["@mozilla.org/hash-property-bag;1"]
+                      .createInstance(Ci.nsIWritablePropertyBag);
+    if (aProperties) {
+      for (let [name, value] of Iterator(aProperties)) {
+        propertyBag.setProperty(name, value);
+      }
+    }
+    return propertyBag.QueryInterface(Ci.nsIPropertyBag)
+                      .QueryInterface(Ci.nsIPropertyBag2)
+                      .QueryInterface(Ci.nsIWritablePropertyBag2);
+  },
+
+  /**
+   * Helper to avoid the `count` argument and property bags when calling
+   * Services.logins.searchLogins from JS.
+   *
+   * @param {Object} aSearchOptions - A regular JS object to copy to a property bag before searching
+   * @return {nsILoginInfo[]} - The result of calling searchLogins.
+   */
+  searchLoginsWithObject(aSearchOptions) {
+    return Services.logins.searchLogins({}, this.newPropertyBag(aSearchOptions));
+  },
+
+  /**
+   * @param {String} aLoginOrigin - An origin value from a stored login's
+   *                                hostname or formSubmitURL properties.
+   * @param {String} aSearchOrigin - The origin that was are looking to match
+   *                                 with aLoginOrigin. This would normally come
+   *                                 from a form or page that we are considering.
+   * @param {nsILoginFindOptions} aOptions - Options to affect whether the origin
+   *                                         from the login (aLoginOrigin) is a
+   *                                         match for the origin we're looking
+   *                                         for (aSearchOrigin).
+   */
+  isOriginMatching(aLoginOrigin, aSearchOrigin, aOptions = {
+    schemeUpgrades: false,
+  }) {
+    if (aLoginOrigin == aSearchOrigin) {
+      return true;
+    }
+
+    if (!aOptions) {
+      return false;
+    }
+
+    if (aOptions.schemeUpgrades) {
+      try {
+        let loginURI = Services.io.newURI(aLoginOrigin, null, null);
+        let searchURI = Services.io.newURI(aSearchOrigin, null, null);
+        if (loginURI.scheme == "http" && searchURI.scheme == "https" &&
+            loginURI.hostPort == searchURI.hostPort) {
+          return true;
+        }
+      } catch (ex) {
+        // newURI will throw for some values e.g. chrome://FirefoxAccounts
+        return false;
+      }
+    }
+
+    return false;
+  },
+
+  /**
    * Creates a new login object that results by modifying the given object with
    * the provided data.
    *
    * @param aOldStoredLogin
    *        Existing nsILoginInfo object to modify.
    * @param aNewLoginData
    *        The new login values, either as nsILoginInfo or nsIProperyBag.
    *
--- a/toolkit/components/passwordmgr/LoginRecipes.jsm
+++ b/toolkit/components/passwordmgr/LoginRecipes.jsm
@@ -245,17 +245,17 @@ var LoginRecipesContent = {
    * @return {HTMLElement|null}
    */
   queryLoginField(aParent, aSelector) {
     if (!aSelector) {
       return null;
     }
     let field = aParent.ownerDocument.querySelector(aSelector);
     if (!field) {
-      log.warn("Login field selector wasn't matched:", aSelector);
+      log.debug("Login field selector wasn't matched:", aSelector);
       return null;
     }
     if (!(field instanceof aParent.ownerDocument.defaultView.HTMLInputElement)) {
       log.warn("Login field isn't an <input> so ignoring it:", aSelector);
       return null;
     }
     return field;
   },
--- a/toolkit/components/passwordmgr/nsLoginManager.js
+++ b/toolkit/components/passwordmgr/nsLoginManager.js
@@ -374,16 +374,25 @@ LoginManager.prototype = {
    * Public wrapper around _searchLogins to convert the nsIPropertyBag to a
    * JavaScript object and decrypt the results.
    *
    * @return {nsILoginInfo[]} which are decrypted.
    */
   searchLogins(count, matchData) {
     log.debug("Searching for logins");
 
+    matchData.QueryInterface(Ci.nsIPropertyBag2);
+    if (!matchData.hasKey("hostname")) {
+      log.warn("searchLogins: A `hostname` is recommended");
+    }
+
+    if (!matchData.hasKey("formSubmitURL") && !matchData.hasKey("httpRealm")) {
+      log.warn("searchLogins: `formSubmitURL` or `httpRealm` is recommended");
+    }
+
     return this._storage.searchLogins(count, matchData);
   },
 
 
   /**
    * Search for the known logins for entries matching the specified criteria,
    * returns only the count.
    */
--- a/toolkit/components/passwordmgr/storage-json.js
+++ b/toolkit/components/passwordmgr/storage-json.js
@@ -239,74 +239,98 @@ this.LoginManagerStorage_json.prototype 
   /**
    * Public wrapper around _searchLogins to convert the nsIPropertyBag to a
    * JavaScript object and decrypt the results.
    *
    * @return {nsILoginInfo[]} which are decrypted.
    */
   searchLogins(count, matchData) {
     let realMatchData = {};
+    let options = {};
     // Convert nsIPropertyBag to normal JS object
     let propEnum = matchData.enumerator;
     while (propEnum.hasMoreElements()) {
       let prop = propEnum.getNext().QueryInterface(Ci.nsIProperty);
-      realMatchData[prop.name] = prop.value;
+      switch (prop.name) {
+        // Some property names aren't field names but are special options to affect the search.
+        case "schemeUpgrades": {
+          options[prop.name] = prop.value;
+          break;
+        }
+        default: {
+          realMatchData[prop.name] = prop.value;
+          break;
+        }
+      }
     }
 
-    let [logins, ids] = this._searchLogins(realMatchData);
+    let [logins, ids] = this._searchLogins(realMatchData, options);
 
     // Decrypt entries found for the caller.
     logins = this._decryptLogins(logins);
 
     count.value = logins.length; // needed for XPCOM
     return logins;
   },
 
   /**
    * Private method to perform arbitrary searches on any field. Decryption is
    * left to the caller.
    *
    * Returns [logins, ids] for logins that match the arguments, where logins
    * is an array of encrypted nsLoginInfo and ids is an array of associated
    * ids in the database.
    */
-  _searchLogins(matchData) {
+  _searchLogins(matchData, aOptions = {
+    schemeUpgrades: false,
+  }) {
     this._store.ensureDataReady();
 
     let conditions = [];
 
     function match(aLogin) {
       for (let field in matchData) {
-        let value = matchData[field];
+        let wantedValue = matchData[field];
         switch (field) {
-          // Historical compatibility requires this special case
           case "formSubmitURL":
-            if (value != null) {
-              if (aLogin.formSubmitURL != "" && aLogin.formSubmitURL != value) {
+            if (wantedValue != null) {
+              // Historical compatibility requires this special case
+              if (aLogin.formSubmitURL == "") {
+                break;
+              }
+              if (!LoginHelper.isOriginMatching(aLogin[field], wantedValue, aOptions)) {
                 return false;
               }
               break;
             }
+            // fall through
+          case "hostname":
+            if (wantedValue != null) { // needed for formSubmitURL fall through
+              if (!LoginHelper.isOriginMatching(aLogin[field], wantedValue, aOptions)) {
+                return false;
+              }
+              break;
+            }
+            // fall through
           // Normal cases.
-          case "hostname":
           case "httpRealm":
           case "id":
           case "usernameField":
           case "passwordField":
           case "encryptedUsername":
           case "encryptedPassword":
           case "guid":
           case "encType":
           case "timeCreated":
           case "timeLastUsed":
           case "timePasswordChanged":
           case "timesUsed":
-            if (value == null && aLogin[field]) {
+            if (wantedValue == null && aLogin[field]) {
               return false;
-            } else if (aLogin[field] != value) {
+            } else if (aLogin[field] != wantedValue) {
               return false;
             }
             break;
           // Fail if caller requests an unknown property.
           default:
             throw new Error("Unexpected field: " + field);
         }
       }
@@ -330,17 +354,17 @@ this.LoginManagerStorage_json.prototype 
         login.timeLastUsed = loginItem.timeLastUsed;
         login.timePasswordChanged = loginItem.timePasswordChanged;
         login.timesUsed = loginItem.timesUsed;
         foundLogins.push(login);
         foundIds.push(loginItem.id);
       }
     }
 
-    this.log("_searchLogins: returning", foundLogins.length, "logins");
+    this.log("_searchLogins: returning", foundLogins.length, "logins for", matchData);
     return [foundLogins, foundIds];
   },
 
   /**
    * Removes all logins from storage.
    *
    * Disabled hosts are kept, as one presumably doesn't want to erase those.
    */
--- a/toolkit/components/passwordmgr/storage-mozStorage.js
+++ b/toolkit/components/passwordmgr/storage-mozStorage.js
@@ -416,91 +416,120 @@ LoginManagerStorage_mozStorage.prototype
 
     this.log("_getAllLogins: returning " + logins.length + " logins.");
     if (count)
       count.value = logins.length; // needed for XPCOM
     return logins;
   },
 
 
-  /*
-   * searchLogins
-   *
+  /**
    * Public wrapper around _searchLogins to convert the nsIPropertyBag to a
    * JavaScript object and decrypt the results.
    *
-   * Returns an array of decrypted nsILoginInfo.
+   * @return {nsILoginInfo[]} which are decrypted.
    */
   searchLogins : function(count, matchData) {
     let realMatchData = {};
+    let options = {};
     // Convert nsIPropertyBag to normal JS object
     let propEnum = matchData.enumerator;
     while (propEnum.hasMoreElements()) {
       let prop = propEnum.getNext().QueryInterface(Ci.nsIProperty);
-      realMatchData[prop.name] = prop.value;
+      switch (prop.name) {
+        // Some property names aren't field names but are special options to affect the search.
+        case "schemeUpgrades": {
+          options[prop.name] = prop.value;
+          break;
+        }
+        default: {
+          realMatchData[prop.name] = prop.value;
+          break;
+        }
+      }
     }
 
-    let [logins, ids] = this._searchLogins(realMatchData);
+    let [logins, ids] = this._searchLogins(realMatchData, options);
 
     // Decrypt entries found for the caller.
     logins = this._decryptLogins(logins);
 
     count.value = logins.length; // needed for XPCOM
     return logins;
   },
 
 
-  /*
-   * _searchLogins
-   *
+  /**
    * Private method to perform arbitrary searches on any field. Decryption is
    * left to the caller.
    *
    * Returns [logins, ids] for logins that match the arguments, where logins
    * is an array of encrypted nsLoginInfo and ids is an array of associated
    * ids in the database.
    */
-  _searchLogins : function (matchData) {
+  _searchLogins : function (matchData, aOptions = {
+    schemeUpgrades: false,
+  }) {
     let conditions = [], params = {};
 
     for (let field in matchData) {
       let value = matchData[field];
+      let condition = "";
       switch (field) {
-        // Historical compatibility requires this special case
         case "formSubmitURL":
           if (value != null) {
-              conditions.push("formSubmitURL = :formSubmitURL OR formSubmitURL = ''");
-              params["formSubmitURL"] = value;
-              break;
+            // Historical compatibility requires this special case
+            condition = "formSubmitURL = '' OR ";
           }
+          // Fall through
+        case "hostname":
+          if (value != null) {
+            condition += `${field} = :${field}`;
+            params[field] = value;
+            let valueURI;
+            try {
+              if (aOptions.schemeUpgrades && (valueURI = Services.io.newURI(value, null, null)) &&
+                  valueURI.scheme == "https") {
+                condition += ` OR ${field} = :http${field}`;
+                params["http" + field] = "http://" + valueURI.hostPort;
+              }
+            } catch (ex) {
+              // newURI will throw for some values (e.g. chrome://FirefoxAccounts)
+              // but those URLs wouldn't support upgrades anyways.
+            }
+            break;
+          }
+          // Fall through
         // Normal cases.
-        case "hostname":
         case "httpRealm":
         case "id":
         case "usernameField":
         case "passwordField":
         case "encryptedUsername":
         case "encryptedPassword":
         case "guid":
         case "encType":
         case "timeCreated":
         case "timeLastUsed":
         case "timePasswordChanged":
         case "timesUsed":
           if (value == null) {
-              conditions.push(field + " isnull");
+            condition = field + " isnull";
           } else {
-              conditions.push(field + " = :" + field);
-              params[field] = value;
+            condition = field + " = :" + field;
+            params[field] = value;
           }
           break;
         // Fail if caller requests an unknown property.
         default:
           throw new Error("Unexpected field: " + field);
       }
+      if (condition) {
+        conditions.push(condition);
+      }
     }
 
     // Build query
     let query = "SELECT * FROM moz_logins";
     if (conditions.length) {
       conditions = conditions.map(c => "(" + c + ")");
       query += " WHERE " + conditions.join(" AND ");
     }
--- a/toolkit/components/passwordmgr/test/unit/head.js
+++ b/toolkit/components/passwordmgr/test/unit/head.js
@@ -1,23 +1,18 @@
-/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
-/* vim: set ts=2 et sw=2 tw=80: */
-/* Any copyright is dedicated to the Public Domain.
- * http://creativecommons.org/publicdomain/zero/1.0/ */
-
 /**
  * Provides infrastructure for automated login components tests.
  */
 
 "use strict";
 
 ////////////////////////////////////////////////////////////////////////////////
 //// Globals
 
-var { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
+let { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/LoginRecipes.jsm");
 Cu.import("resource://gre/modules/LoginHelper.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "DownloadPaths",
                                   "resource://gre/modules/DownloadPaths.jsm");
@@ -32,16 +27,17 @@ const LoginInfo =
       Components.Constructor("@mozilla.org/login-manager/loginInfo;1",
                              "nsILoginInfo", "init");
 
 // Import LoginTestUtils.jsm as LoginTestUtils.
 XPCOMUtils.defineLazyModuleGetter(this, "LoginTestUtils",
                                   "resource://testing-common/LoginTestUtils.jsm");
 LoginTestUtils.Assert = Assert;
 const TestData = LoginTestUtils.testData;
+const newPropertyBag = LoginHelper.newPropertyBag;
 
 /**
  * All the tests are implemented with add_task, this starts them automatically.
  */
 function run_test()
 {
   do_get_profile();
   run_next_test();
@@ -52,17 +48,17 @@ function run_test()
 
 // Some of these functions are already implemented in other parts of the source
 // tree, see bug 946708 about sharing more code.
 
 // While the previous test file should have deleted all the temporary files it
 // used, on Windows these might still be pending deletion on the physical file
 // system.  Thus, start from a new base number every time, to make a collision
 // with a file that is still pending deletion highly unlikely.
-var gFileCounter = Math.floor(Math.random() * 1000000);
+let gFileCounter = Math.floor(Math.random() * 1000000);
 
 /**
  * Returns a reference to a temporary file, that is guaranteed not to exist, and
  * to have never been created before.
  *
  * @param aLeafName
  *        Suggested leaf name for the file to be created.
  *
@@ -88,40 +84,16 @@ function getTempFile(aLeafName)
     if (file.exists()) {
       file.remove(false);
     }
   });
 
   return file;
 }
 
-/**
- * Returns a new XPCOM property bag with the provided properties.
- *
- * @param aProperties
- *        Each property of this object is copied to the property bag.  This
- *        parameter can be omitted to return an empty property bag.
- *
- * @return A new property bag, that is an instance of nsIWritablePropertyBag,
- *         nsIWritablePropertyBag2, nsIPropertyBag, and nsIPropertyBag2.
- */
-function newPropertyBag(aProperties)
-{
-  let propertyBag = Cc["@mozilla.org/hash-property-bag;1"]
-                      .createInstance(Ci.nsIWritablePropertyBag);
-  if (aProperties) {
-    for (let [name, value] of Iterator(aProperties)) {
-      propertyBag.setProperty(name, value);
-    }
-  }
-  return propertyBag.QueryInterface(Ci.nsIPropertyBag)
-                    .QueryInterface(Ci.nsIPropertyBag2)
-                    .QueryInterface(Ci.nsIWritablePropertyBag2);
-}
-
 ////////////////////////////////////////////////////////////////////////////////
 
 const RecipeHelpers = {
   initNewParent() {
     return (new LoginRecipesParent({ defaults: null })).initializationPromise;
   },
 };
 
new file mode 100644
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_isOriginMatching.js
@@ -0,0 +1,40 @@
+/*
+ * Test LoginHelper.isOriginMatching
+ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/LoginHelper.jsm");
+
+add_task(function test_isOriginMatching() {
+  let testcases = [
+    // Index 0 holds the expected return value followed by arguments to isOriginMatching.
+    [true, "http://example.com", "http://example.com"],
+    [true, "http://example.com:8080", "http://example.com:8080"],
+    [true, "https://example.com", "https://example.com"],
+    [true, "https://example.com:8443", "https://example.com:8443"],
+    [false, "http://example.com", "http://mozilla.org"],
+    [false, "http://example.com", "http://example.com:8080"],
+    [false, "https://example.com", "http://example.com"],
+    [false, "https://example.com", "https://mozilla.org"],
+    [false, "http://example.com", "http://sub.example.com"],
+    [false, "https://example.com", "https://sub.example.com"],
+    [false, "http://example.com", "https://example.com:8443"],
+    [false, "http://example.com:8080", "http://example.com:8081"],
+    [false, "http://example.com", ""],
+    [false, "", "http://example.com"],
+    [true, "http://example.com", "https://example.com", { schemeUpgrades: true }],
+    [true, "https://example.com", "https://example.com", { schemeUpgrades: true }],
+    [true, "http://example.com:8080", "http://example.com:8080", { schemeUpgrades: true }],
+    [true, "https://example.com:8443", "https://example.com:8443", { schemeUpgrades: true }],
+    [false, "https://example.com", "http://example.com", { schemeUpgrades: true }], // downgrade
+    [false, "http://example.com:8080", "https://example.com", { schemeUpgrades: true }], // port mismatch
+    [false, "http://example.com", "https://example.com:8443", { schemeUpgrades: true }], // port mismatch
+    [false, "http://sub.example.com", "http://example.com", { schemeUpgrades: true }],
+  ];
+  for (let tc of testcases) {
+    let expected = tc.shift();
+    Assert.strictEqual(LoginHelper.isOriginMatching(...tc), expected,
+                       "Check " + JSON.stringify(tc));
+  }
+});
--- a/toolkit/components/passwordmgr/test/unit/test_logins_search.js
+++ b/toolkit/components/passwordmgr/test/unit/test_logins_search.js
@@ -1,14 +1,9 @@
-/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
-/* vim: set ts=2 et sw=2 tw=80: */
-/* Any copyright is dedicated to the Public Domain.
- * http://creativecommons.org/publicdomain/zero/1.0/ */
-
-/**
+/*
  * Tests methods that find specific logins in the store (findLogins,
  * searchLogins, and countLogins).
  *
  * The getAllLogins method is not tested explicitly here, because it is used by
  * all tests to verify additions, removals and modifications to the login store.
  */
 
 "use strict";
new file mode 100644
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_search_schemeUpgrades.js
@@ -0,0 +1,184 @@
+/*
+ * Test Services.logins.searchLogins with the `schemeUpgrades` property.
+ */
+
+const HTTP3_ORIGIN = "http://www3.example.com";
+const HTTPS_ORIGIN = "https://www.example.com";
+const HTTP_ORIGIN = "http://www.example.com";
+
+/**
+ * Returns a list of new nsILoginInfo objects that are a subset of the test
+ * data, built to match the specified query.
+ *
+ * @param {Object} aQuery
+ *        Each property and value of this object restricts the search to those
+ *        entries from the test data that match the property exactly.
+ */
+function buildExpectedLogins(aQuery) {
+  return TestData.loginList().filter(
+    entry => Object.keys(aQuery).every(name => {
+      if (name == "schemeUpgrades") {
+        return true;
+      }
+      if (["hostname", "formSubmitURL"].includes(name)) {
+        return LoginHelper.isOriginMatching(entry[name], aQuery[name], {
+          schemeUpgrades: aQuery.schemeUpgrades,
+        });
+      }
+      return entry[name] === aQuery[name];
+    }));
+}
+
+/**
+ * Tests the searchLogins function.
+ *
+ * @param {Object} aQuery
+ *        Each property and value of this object is translated to an entry in
+ *        the nsIPropertyBag parameter of searchLogins.
+ * @param {Number} aExpectedCount
+ *        Number of logins from the test data that should be found.  The actual
+ *        list of logins is obtained using the buildExpectedLogins helper, and
+ *        this value is just used to verify that modifications to the test data
+ *        don't make the current test meaningless.
+ */
+function checkSearch(aQuery, aExpectedCount) {
+  do_print("Testing searchLogins for " + JSON.stringify(aQuery));
+
+  let expectedLogins = buildExpectedLogins(aQuery);
+  do_check_eq(expectedLogins.length, aExpectedCount);
+
+  let outCount = {};
+  let logins = Services.logins.searchLogins(outCount, newPropertyBag(aQuery));
+  do_check_eq(outCount.value, expectedLogins.length);
+  LoginTestUtils.assertLoginListsEqual(logins, expectedLogins);
+}
+
+/**
+ * Prepare data for the following tests.
+ */
+add_task(function test_initialize() {
+  for (let login of TestData.loginList()) {
+    Services.logins.addLogin(login);
+  }
+});
+
+/**
+ * Tests searchLogins with the `schemeUpgrades` property
+ */
+add_task(function test_search_schemeUpgrades_hostname() {
+  // Hostname-only
+  checkSearch({
+    hostname: HTTPS_ORIGIN,
+  }, 1);
+  checkSearch({
+    hostname: HTTPS_ORIGIN,
+    schemeUpgrades: false,
+  }, 1);
+  checkSearch({
+    hostname: HTTPS_ORIGIN,
+    schemeUpgrades: undefined,
+  }, 1);
+  checkSearch({
+    hostname: HTTPS_ORIGIN,
+    schemeUpgrades: true,
+  }, 2);
+});
+
+/**
+ * Same as above but replacing hostname with formSubmitURL.
+ */
+add_task(function test_search_schemeUpgrades_formSubmitURL() {
+  checkSearch({
+    formSubmitURL: HTTPS_ORIGIN,
+  }, 2);
+  checkSearch({
+    formSubmitURL: HTTPS_ORIGIN,
+    schemeUpgrades: false,
+  }, 2);
+  checkSearch({
+    formSubmitURL: HTTPS_ORIGIN,
+    schemeUpgrades: undefined,
+  }, 2);
+  checkSearch({
+    formSubmitURL: HTTPS_ORIGIN,
+    schemeUpgrades: true,
+  }, 4);
+});
+
+
+add_task(function test_search_schemeUpgrades_hostname_formSubmitURL() {
+  checkSearch({
+    formSubmitURL: HTTPS_ORIGIN,
+    hostname: HTTPS_ORIGIN,
+  }, 1);
+  checkSearch({
+    formSubmitURL: HTTPS_ORIGIN,
+    hostname: HTTPS_ORIGIN,
+    schemeUpgrades: false,
+  }, 1);
+  checkSearch({
+    formSubmitURL: HTTPS_ORIGIN,
+    hostname: HTTPS_ORIGIN,
+    schemeUpgrades: undefined,
+  }, 1);
+  checkSearch({
+    formSubmitURL: HTTPS_ORIGIN,
+    hostname: HTTPS_ORIGIN,
+    schemeUpgrades: true,
+  }, 2);
+  checkSearch({
+    formSubmitURL: HTTPS_ORIGIN,
+    hostname: HTTPS_ORIGIN,
+    schemeUpgrades: true,
+    usernameField: "form_field_username",
+  }, 2);
+  checkSearch({
+    formSubmitURL: HTTPS_ORIGIN,
+    hostname: HTTPS_ORIGIN,
+    passwordField: "form_field_password",
+    schemeUpgrades: true,
+    usernameField: "form_field_username",
+  }, 2);
+  checkSearch({
+    formSubmitURL: HTTPS_ORIGIN,
+    hostname: HTTPS_ORIGIN,
+    httpRealm: null,
+    passwordField: "form_field_password",
+    schemeUpgrades: true,
+    usernameField: "form_field_username",
+  }, 2);
+});
+
+/**
+ * HTTP submitting to HTTPS
+ */
+add_task(function test_http_to_https() {
+  checkSearch({
+    formSubmitURL: HTTPS_ORIGIN,
+    hostname: HTTP3_ORIGIN,
+    httpRealm: null,
+    schemeUpgrades: false,
+  }, 1);
+  checkSearch({
+    formSubmitURL: HTTPS_ORIGIN,
+    hostname: HTTP3_ORIGIN,
+    httpRealm: null,
+    schemeUpgrades: true,
+  }, 2);
+});
+
+/**
+ * schemeUpgrades shouldn't cause downgrades
+ */
+add_task(function test_search_schemeUpgrades_downgrade() {
+  checkSearch({
+    formSubmitURL: HTTP_ORIGIN,
+    hostname: HTTP_ORIGIN,
+  }, 1);
+  do_print("The same number should be found with schemeUpgrades since we're searching for HTTP");
+  checkSearch({
+    formSubmitURL: HTTP_ORIGIN,
+    hostname: HTTP_ORIGIN,
+    schemeUpgrades: true,
+  }, 1);
+});
--- a/toolkit/components/passwordmgr/test/unit/xpcshell.ini
+++ b/toolkit/components/passwordmgr/test/unit/xpcshell.ini
@@ -20,22 +20,24 @@ skip-if = true || os != "android" # Bug 
 [test_context_menu.js]
 skip-if = os == "android" # The context menu isn't used on Android.
 # LoginManagerContextMenu is only included for MOZ_BUILD_APP == 'browser'.
 run-if = buildapp == "browser"
 [test_disabled_hosts.js]
 [test_getFormFields.js]
 [test_getPasswordFields.js]
 [test_getPasswordOrigin.js]
+[test_isOriginMatching.js]
 [test_legacy_empty_formSubmitURL.js]
 [test_legacy_validation.js]
 [test_logins_change.js]
 [test_logins_decrypt_failure.js]
 skip-if = os == "android" # Bug 1171687: Needs fixing on Android
 [test_logins_metainfo.js]
 [test_logins_search.js]
 [test_notifications.js]
 [test_OSCrypto_win.js]
 skip-if = os != "win"
 [test_recipes_add.js]
 [test_recipes_content.js]
+[test_search_schemeUpgrades.js]
 [test_storage.js]
 [test_telemetry.js]