Bug 667233 - Support scheme upgrades for searching logins. r=dolske draft
authorMatthew Noorenberghe <mozilla@noorenberghe.ca>
Thu, 12 May 2016 15:06:06 -0700
changeset 372320 e98756cfe27fae24271a5d1d7573bdb0600dfb87
parent 372319 6cc8c4380ae868c038c3580a4d0104c8696977cc
child 372321 477f5a544038fef47a5b7a1e8a2465e5e6e7be4e
child 372662 4a3e1f23ec6e47a21cd24ceb2c44f5df0de72136
push id19506
push usermozilla@noorenberghe.ca
push dateSat, 28 May 2016 01:35:56 +0000
reviewersdolske
bugs667233
milestone49.0a1
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]