--- a/modules/libpref/init/all.js
+++ b/modules/libpref/init/all.js
@@ -4243,16 +4243,17 @@ pref("signon.rememberSignons.visibilityT
pref("signon.rememberSignons.visibilityToggle", false);
#endif
pref("signon.autofillForms", true);
pref("signon.autologin.proxy", false);
pref("signon.storeWhenAutocompleteOff", true);
pref("signon.ui.experimental", false);
pref("signon.debug", false);
pref("signon.recipes.path", "chrome://passwordmgr/content/recipes.json");
+pref("signon.schemeUpgrades", false);
// Satchel (Form Manager) prefs
pref("browser.formfill.debug", false);
pref("browser.formfill.enable", true);
pref("browser.formfill.expire_days", 180);
pref("browser.formfill.saveHttpsForms", true);
pref("browser.formfill.agedWeight", 2);
pref("browser.formfill.bucketSize", 1);
--- a/toolkit/components/passwordmgr/LoginHelper.jsm
+++ b/toolkit/components/passwordmgr/LoginHelper.jsm
@@ -29,19 +29,20 @@ Cu.import("resource://gre/modules/XPCOMU
////////////////////////////////////////////////////////////////////////////////
//// LoginHelper
/**
* Contains functions shared by different Login Manager components.
*/
this.LoginHelper = {
/**
- * Warning: this only updates if a logger was created.
+ * Warning: these only update if a logger was created.
*/
debug: Services.prefs.getBoolPref("signon.debug"),
+ schemeUpgrades: Services.prefs.getBoolPref("signon.schemeUpgrades"),
createLogger(aLogPrefix) {
let getMaxLogLevel = () => {
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;
@@ -49,16 +50,17 @@ this.LoginHelper = {
maxLogLevel: getMaxLogLevel(),
prefix: aLogPrefix,
};
let logger = new ConsoleAPI(consoleOptions);
// Watch for pref changes and update this.debug and the maxLogLevel for created loggers
Services.prefs.addObserver("signon.", () => {
this.debug = Services.prefs.getBoolPref("signon.debug");
+ this.schemeUpgrades = Services.prefs.getBoolPref("signon.schemeUpgrades");
logger.maxLogLevel = getMaxLogLevel();
}, false);
return logger;
},
/**
* Due to the way the signons2.txt file is formatted, we need to make
@@ -328,48 +330,141 @@ this.LoginHelper = {
// Throws if there are bogus values.
this.checkLoginValues(newLogin);
return newLogin;
},
/**
- * Removes duplicates from a list of logins.
+ * Removes duplicates from a list of logins while preserving the sort order.
*
* @param {nsILoginInfo[]} logins
* A list of logins we want to deduplicate.
- *
- * @param {string[] = ["username", "password"]} uniqueKeys
+ * @param {string[]} [uniqueKeys = ["username", "password"]]
* A list of login attributes to use as unique keys for the deduplication.
+ * @param {string[]} [resolveBy = ["timeLastUsed"]]
+ * Ordered array of keyword strings used to decide which of the
+ * duplicates should be used. "scheme" would prefer the login that has
+ * a scheme matching `preferredOrigin`'s if there are two logins with
+ * the same `uniqueKeys`. The default preference to distinguish two
+ * logins is `timeLastUsed`. If there is no preference between two
+ * logins, the first one found wins.
+ * @param {string} [preferredOrigin = undefined]
+ * String representing the origin to use for preferring one login over
+ * another when they are dupes. This is used with "scheme" for
+ * `resolveBy` so the scheme from this origin will be preferred.
*
* @returns {nsILoginInfo[]} list of unique logins.
*/
- dedupeLogins(logins, uniqueKeys = ["username", "password"]) {
+ dedupeLogins(logins, uniqueKeys = ["username", "password"],
+ resolveBy = ["timeLastUsed"],
+ preferredOrigin = undefined) {
const KEY_DELIMITER = ":";
+ if (!preferredOrigin && resolveBy.includes("scheme")) {
+ throw new Error("dedupeLogins: `preferredOrigin` is required in order to "+
+ "prefer schemes which match it.");
+ }
+
+ let preferredOriginScheme;
+ if (preferredOrigin) {
+ try {
+ preferredOriginScheme = Services.io.newURI(preferredOrigin, null, null).scheme;
+ } catch (ex) {
+ // Handle strings that aren't valid URIs e.g. chrome://FirefoxAccounts
+ }
+ }
+
+ if (!preferredOriginScheme && resolveBy.includes("scheme")) {
+ throw new Error("dedupeLogins: Deduping with a scheme preference but couldn't " +
+ "get the preferred origin scheme.");
+ }
+
+ // We use a Map to easily lookup logins by their unique keys.
+ let loginsByKeys = new Map();
+
// Generate a unique key string from a login.
function getKey(login, uniqueKeys) {
return uniqueKeys.reduce((prev, key) => prev + KEY_DELIMITER + login[key], "");
}
- // We use a Map to easily lookup logins by their unique keys.
- let loginsByKeys = new Map();
+ /**
+ * @return {bool} whether `login` is preferred over its duplicate (considering `uniqueKeys`)
+ * `existingLogin`.
+ *
+ * `resolveBy` is a sorted array so we can return true the first time `login` is preferred
+ * over the existingLogin.
+ */
+ function isLoginPreferred(existingLogin, login) {
+ if (!resolveBy || resolveBy.length == 0) {
+ // If there is no preference, prefer the existing login.
+ return false;
+ }
+
+ for (let preference of resolveBy) {
+ switch (preference) {
+ case "scheme": {
+ if (!preferredOriginScheme) {
+ break;
+ }
+
+ try {
+ // Only `hostname` is currently considered
+ let existingLoginURI = Services.io.newURI(existingLogin.hostname, null, null);
+ let loginURI = Services.io.newURI(login.hostname, null, null);
+ // If the schemes of the two logins are the same or neither match the
+ // preferredOriginScheme then we have no preference and look at the next resolveBy.
+ if (loginURI.scheme == existingLoginURI.scheme ||
+ (loginURI.scheme != preferredOriginScheme &&
+ existingLoginURI.scheme != preferredOriginScheme)) {
+ break;
+ }
+
+ return loginURI.scheme == preferredOriginScheme;
+ } catch (ex) {
+ // Some URLs aren't valid nsIURI (e.g. chrome://FirefoxAccounts)
+ log.debug("dedupeLogins/shouldReplaceExisting: Error comparing schemes:",
+ existingLogin.hostname, login.hostname,
+ "preferredOrigin:", preferredOrigin, ex);
+ }
+ break;
+ }
+ case "timeLastUsed":
+ case "timePasswordChanged": {
+ // If we find a more recent login for the same key, replace the existing one.
+ let loginDate = login.QueryInterface(Ci.nsILoginMetaInfo)[preference];
+ let storedLoginDate = existingLogin.QueryInterface(Ci.nsILoginMetaInfo)[preference];
+ if (loginDate == storedLoginDate) {
+ break;
+ }
+
+ return loginDate > storedLoginDate;
+ }
+ default: {
+ throw new Error("dedupeLogins: Invalid resolveBy preference: " + preference);
+ }
+ }
+ }
+
+ return false;
+ }
+
for (let login of logins) {
let key = getKey(login, uniqueKeys);
- // If we find a more recently used login for the same key, replace the existing one.
+
if (loginsByKeys.has(key)) {
- let loginDate = login.QueryInterface(Ci.nsILoginMetaInfo).timeLastUsed;
- let storedLoginDate = loginsByKeys.get(key).QueryInterface(Ci.nsILoginMetaInfo).timeLastUsed;
- if (loginDate < storedLoginDate) {
+ if (!isLoginPreferred(loginsByKeys.get(key), login)) {
+ // If there is no preference for the new login, use the existing one.
continue;
}
}
loginsByKeys.set(key, login);
}
+
// Return the map values in the form of an array.
return [...loginsByKeys.values()];
},
/**
* Open the password manager window.
*
* @param {Window} window
@@ -486,40 +581,40 @@ this.LoginHelper = {
return logins.map(this.loginToVanillaObject);
},
/**
* Same as above, but for a single login.
*/
loginToVanillaObject(login) {
let obj = {};
- for (let i in login) {
+ for (let i in login.QueryInterface(Ci.nsILoginMetaInfo)) {
if (typeof login[i] !== 'function') {
obj[i] = login[i];
}
}
- login.QueryInterface(Ci.nsILoginMetaInfo);
- obj.guid = login.guid;
return obj;
},
/**
* Convert an object received from IPC into an nsILoginInfo (with guid).
*/
vanillaObjectToLogin(login) {
- var formLogin = Cc["@mozilla.org/login-manager/loginInfo;1"].
- createInstance(Ci.nsILoginInfo);
+ let formLogin = Cc["@mozilla.org/login-manager/loginInfo;1"].
+ createInstance(Ci.nsILoginInfo);
formLogin.init(login.hostname, login.formSubmitURL,
login.httpRealm, login.username,
login.password, login.usernameField,
login.passwordField);
formLogin.QueryInterface(Ci.nsILoginMetaInfo);
- formLogin.guid = login.guid;
+ for (let prop of ["guid", "timeCreated", "timeLastUsed", "timePasswordChanged", "timesUsed"]) {
+ formLogin[prop] = login[prop];
+ }
return formLogin;
},
/**
* As above, but for an array of objects.
*/
vanillaObjectsToLogins(logins) {
return logins.map(this.vanillaObjectToLogin);
@@ -547,8 +642,13 @@ this.LoginHelper = {
} catch (e) {}
}
for (let file of toDeletes) {
File.remove(file);
}
}
};
+
+XPCOMUtils.defineLazyGetter(this, "log", () => {
+ let logger = LoginHelper.createLogger("LoginHelper");
+ return logger;
+});
--- a/toolkit/components/passwordmgr/LoginManagerContent.jsm
+++ b/toolkit/components/passwordmgr/LoginManagerContent.jsm
@@ -224,16 +224,19 @@ var LoginManagerContent = {
* @param {Object} options
* @param {boolean} options.showMasterPassword - whether to show a master password prompt
*/
_getLoginDataFromParent: function(form, options) {
let doc = form.ownerDocument;
let win = doc.defaultView;
let formOrigin = LoginUtils._getPasswordOrigin(doc.documentURI);
+ if (!formOrigin) {
+ return Promise.reject("_getLoginDataFromParent: A form origin is required");
+ }
let actionOrigin = LoginUtils._getActionOrigin(form);
let messageManager = messageManagerFromWindow(win);
// XXX Weak??
let requestData = { form: form };
let messageData = { formOrigin: formOrigin,
actionOrigin: actionOrigin,
--- a/toolkit/components/passwordmgr/LoginManagerContextMenu.jsm
+++ b/toolkit/components/passwordmgr/LoginManagerContextMenu.jsm
@@ -5,16 +5,18 @@
"use strict";
this.EXPORTED_SYMBOLS = ["LoginManagerContextMenu"];
const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper",
+ "resource://gre/modules/LoginHelper.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "LoginManagerParent",
"resource://gre/modules/LoginManagerParent.jsm");
/*
* Password manager object for the browser contextual menu.
*/
var LoginManagerContextMenu = {
dateAndTimeFormatter: new Intl.DateTimeFormat(undefined,
@@ -88,17 +90,26 @@ var LoginManagerContextMenu = {
* @param {nsIURI} documentURI
* URI object with the hostname of the logins we want to find.
* This isn't the same as the browser's top-level document URI
* when subframes are involved.
*
* @returns {nsILoginInfo[]} a login list
*/
_findLogins(documentURI) {
- let logins = Services.logins.findLogins({}, documentURI.prePath, "", "");
+ let searchParams = {
+ hostname: documentURI.prePath,
+ schemeUpgrades: LoginHelper.schemeUpgrades,
+ };
+ let logins = LoginHelper.searchLoginsWithObject(searchParams);
+ let resolveBy = [
+ "scheme",
+ "timePasswordChanged",
+ ];
+ logins = LoginHelper.dedupeLogins(logins, ["username", "password"], resolveBy, documentURI.prePath);
// Sort logins in alphabetical order and by date.
logins.sort((loginA, loginB) => {
// Sort alphabetically
let result = loginA.username.localeCompare(loginB.username);
if (result) {
// Forces empty logins to be at the end
if (!loginA.username) {
--- a/toolkit/components/passwordmgr/LoginManagerParent.jsm
+++ b/toolkit/components/passwordmgr/LoginManagerParent.jsm
@@ -52,17 +52,16 @@ var LoginManagerParent = {
XPCOMUtils.defineLazyGetter(this, "recipeParentPromise", () => {
const { LoginRecipesParent } = Cu.import("resource://gre/modules/LoginRecipes.jsm", {});
this._recipeManager = new LoginRecipesParent({
defaults: Services.prefs.getComplexValue("signon.recipes.path", Ci.nsISupportsString).data,
});
return this._recipeManager.initializationPromise;
});
-
},
receiveMessage: function (msg) {
let data = msg.data;
switch (msg.name) {
case "RemoteLogins:findLogins": {
// TODO Verify msg.target's principals against the formOrigin?
this.sendLoginDataToChild(data.options.showMasterPassword,
@@ -202,17 +201,27 @@ var LoginManagerParent = {
// never return). We should guarantee that at least one of these
// will fire.
// See bug XXX.
Services.obs.addObserver(observer, "passwordmgr-crypto-login", false);
Services.obs.addObserver(observer, "passwordmgr-crypto-loginCanceled", false);
return;
}
- var logins = Services.logins.findLogins({}, formOrigin, actionOrigin, null);
+ let logins = LoginHelper.searchLoginsWithObject({
+ formSubmitURL: actionOrigin,
+ hostname: formOrigin,
+ schemeUpgrades: LoginHelper.schemeUpgrades,
+ });
+ let resolveBy = [
+ "scheme",
+ "timePasswordChanged",
+ ];
+ logins = LoginHelper.dedupeLogins(logins, ["username"], resolveBy, formOrigin);
+ log("sendLoginDataToChild:", logins.length, "deduped logins");
// Convert the array of nsILoginInfo to vanilla JS objects since nsILoginInfo
// doesn't support structured cloning.
var jsLogins = LoginHelper.loginsToVanillaObjects(logins);
target.sendAsyncMessage("RemoteLogins:loginsFound", {
requestId: requestId,
logins: jsLogins,
recipes,
});
@@ -233,17 +242,26 @@ var LoginManagerParent = {
// We have a list of results for a shorter search string, so just
// filter them further based on the new search string.
logins = LoginHelper.vanillaObjectsToLogins(previousResult.logins);
} else {
log("Creating new autocomplete search result.");
// Grab the logins from the database.
- logins = Services.logins.findLogins({}, formOrigin, actionOrigin, null);
+ logins = LoginHelper.searchLoginsWithObject({
+ formSubmitURL: actionOrigin,
+ hostname: formOrigin,
+ schemeUpgrades: LoginHelper.schemeUpgrades,
+ });
+ let resolveBy = [
+ "scheme",
+ "timePasswordChanged",
+ ];
+ logins = LoginHelper.dedupeLogins(logins, ["username"], resolveBy, formOrigin);
}
let matchingLogins = logins.filter(function(fullMatch) {
let match = fullMatch.username;
// Remove results that are too short, or have different prefix.
// Also don't offer empty usernames as possible results.
return match && match.toLowerCase().startsWith(searchStringLower);
@@ -305,17 +323,21 @@ var LoginManagerParent = {
var formLogin = Cc["@mozilla.org/login-manager/loginInfo;1"].
createInstance(Ci.nsILoginInfo);
formLogin.init(hostname, formSubmitURL, null,
(usernameField ? usernameField.value : ""),
newPasswordField.value,
(usernameField ? usernameField.name : ""),
newPasswordField.name);
- let logins = Services.logins.findLogins({}, hostname, formSubmitURL, null);
+ let logins = LoginHelper.searchLoginsWithObject({
+ formSubmitURL,
+ hostname,
+ schemeUpgrades: LoginHelper.schemeUpgrades,
+ });
// If we didn't find a username field, but seem to be changing a
// password, allow the user to select from a list of applicable
// logins to update the password for.
if (!usernameField && oldPasswordField && logins.length > 0) {
var prompter = getPrompter();
if (logins.length == 1) {
@@ -458,27 +480,32 @@ var LoginManagerParent = {
if (!state.anchorDeferredTask) {
state.anchorDeferredTask = new DeferredTask(
() => this.updateLoginAnchor(browser),
ANCHOR_DELAY_MS
);
}
state.anchorDeferredTask.arm();
},
+
updateLoginAnchor: Task.async(function* (browser) {
// Copy the state to use for this execution of the task. These will not
// change during this execution of the asynchronous function, but in case a
// change happens in the state, the function will be retriggered.
let { loginFormOrigin, loginFormPresent } = this.stateForBrowser(browser);
yield Services.logins.initializationPromise;
// Check if there are form logins for the site, ignoring formSubmitURL.
let hasLogins = loginFormOrigin &&
- Services.logins.countLogins(loginFormOrigin, "", null) > 0;
+ LoginHelper.searchLoginsWithObject({
+ formSubmitURL: "",
+ hostname: loginFormOrigin,
+ schemeUpgrades: LoginHelper.schemeUpgrades,
+ }).length > 0;
// Once this preference is removed, this version of the fill doorhanger
// should be enabled for Desktop only, and not for Android or B2G.
if (!Services.prefs.getBoolPref("signon.ui.experimental")) {
return;
}
let showLoginAnchor = loginFormPresent || hasLogins;
--- a/toolkit/components/passwordmgr/nsLoginManagerPrompter.js
+++ b/toolkit/components/passwordmgr/nsLoginManagerPrompter.js
@@ -531,17 +531,17 @@ LoginManagerPrompter.prototype = {
// notification bar that was displayed. Conveniently, the user will
// be prompted for authentication again, which brings us here.
this._removeLoginNotifications();
var [hostname, httpRealm] = this._getAuthTarget(aChannel, aAuthInfo);
// Looks for existing logins to prefill the prompt with.
var foundLogins = this._pwmgr.findLogins({},
- hostname, null, httpRealm);
+ hostname, null, httpRealm);
this.log("found " + foundLogins.length + " matching logins.");
// XXX Can't select from multiple accounts yet. (bug 227632)
if (foundLogins.length > 0) {
selectedLogin = foundLogins[0];
this._SetAuthInfo(aAuthInfo, selectedLogin.username,
selectedLogin.password);
@@ -716,16 +716,17 @@ LoginManagerPrompter.prototype = {
},
/*
* promptToSavePassword
*
*/
promptToSavePassword : function (aLogin) {
+ this.log("promptToSavePassword");
var notifyObj = this._getPopupNote() || this._getNotifyBox();
if (notifyObj)
this._showSaveLoginNotification(notifyObj, aLogin);
else
this._showSaveLoginDialog(aLogin);
},
@@ -1170,16 +1171,17 @@ LoginManagerPrompter.prototype = {
* password fields.
*
* @param {nsILoginInfo} aOldLogin
* The old login we may want to update.
* @param {nsILoginInfo} aNewLogin
* The new login from the page form.
*/
promptToChangePassword(aOldLogin, aNewLogin) {
+ this.log("promptToChangePassword");
let notifyObj = this._getPopupNote() || this._getNotifyBox();
if (notifyObj) {
this._showChangeLoginNotification(notifyObj, aOldLogin,
aNewLogin);
} else {
this._showChangeLoginDialog(aOldLogin, aNewLogin);
}
@@ -1297,16 +1299,17 @@ LoginManagerPrompter.prototype = {
*
* Note: The caller doesn't know the username for aNewLogin, so this
* function fills in .username and .usernameField with the values
* from the login selected by the user.
*
* Note; XPCOM stupidity: |count| is just |logins.length|.
*/
promptToChangePasswordWithUsernames : function (logins, count, aNewLogin) {
+ this.log("promptToChangePasswordWithUsernames with count:", count);
const buttonFlags = Ci.nsIPrompt.STD_YES_NO_BUTTONS;
var usernames = logins.map(l => l.username);
var dialogText = this._getLocalizedString("userSelectText");
var dialogTitle = this._getLocalizedString("passwordChangeTitle");
var selectedIndex = { value: null };
// If user selects ok, outparam.value is set to the index
--- a/toolkit/components/passwordmgr/test/browser/browser_context_menu.js
+++ b/toolkit/components/passwordmgr/test/browser/browser_context_menu.js
@@ -11,73 +11,122 @@ const MULTIPLE_FORMS_PAGE_PATH = "/brows
/**
* Initialize logins needed for the tests and disable autofill
* for login forms for easier testing of manual fill.
*/
add_task(function* test_initialize() {
Services.prefs.setBoolPref("signon.autofillForms", false);
registerCleanupFunction(() => {
Services.prefs.clearUserPref("signon.autofillForms");
- Services.logins.removeAllLogins();
+ Services.prefs.clearUserPref("signon.schemeUpgrades");
});
for (let login of loginList()) {
Services.logins.addLogin(login);
}
});
/**
* Check if the context menu is populated with the right
* menuitems for the target password input field.
*/
-add_task(function* test_context_menu_populate_password() {
+add_task(function* test_context_menu_populate_password_noSchemeUpgrades() {
+ Services.prefs.setBoolPref("signon.schemeUpgrades", false);
yield BrowserTestUtils.withNewTab({
gBrowser,
url: TEST_HOSTNAME + MULTIPLE_FORMS_PAGE_PATH,
}, function* (browser) {
let passwordInput = browser.contentWindow.document.getElementById("test-password-1");
yield openPasswordContextMenu(browser, passwordInput);
// Check the content of the password manager popup
let popupMenu = document.getElementById("fill-login-popup");
- checkMenu(popupMenu);
+ checkMenu(popupMenu, 2);
+
+ let contextMenu = document.getElementById("contentAreaContextMenu");
+ contextMenu.hidePopup();
+ });
+});
+
+/**
+ * Check if the context menu is populated with the right
+ * menuitems for the target password input field.
+ */
+add_task(function* test_context_menu_populate_password_schemeUpgrades() {
+ Services.prefs.setBoolPref("signon.schemeUpgrades", true);
+ yield BrowserTestUtils.withNewTab({
+ gBrowser,
+ url: TEST_HOSTNAME + MULTIPLE_FORMS_PAGE_PATH,
+ }, function* (browser) {
+ let passwordInput = browser.contentWindow.document.getElementById("test-password-1");
+
+ yield openPasswordContextMenu(browser, passwordInput);
+
+ // Check the content of the password manager popup
+ let popupMenu = document.getElementById("fill-login-popup");
+ checkMenu(popupMenu, 3);
let contextMenu = document.getElementById("contentAreaContextMenu");
contextMenu.hidePopup();
});
});
/**
* Check if the context menu is populated with the right menuitems
* for the target username field with a password field present.
*/
-add_task(function* test_context_menu_populate_username_with_password() {
+add_task(function* test_context_menu_populate_username_with_password_noSchemeUpgrades() {
+ Services.prefs.setBoolPref("signon.schemeUpgrades", false);
yield BrowserTestUtils.withNewTab({
gBrowser,
url: TEST_HOSTNAME + "/browser/toolkit/components/" +
"passwordmgr/test/browser/multiple_forms.html",
}, function* (browser) {
let passwordInput = browser.contentWindow.document.getElementById("test-username-2");
yield openPasswordContextMenu(browser, passwordInput);
// Check the content of the password manager popup
let popupMenu = document.getElementById("fill-login-popup");
- checkMenu(popupMenu);
+ checkMenu(popupMenu, 2);
+
+ let contextMenu = document.getElementById("contentAreaContextMenu");
+ contextMenu.hidePopup();
+ });
+});
+/**
+ * Check if the context menu is populated with the right menuitems
+ * for the target username field with a password field present.
+ */
+add_task(function* test_context_menu_populate_username_with_password_schemeUpgrades() {
+ Services.prefs.setBoolPref("signon.schemeUpgrades", true);
+ yield BrowserTestUtils.withNewTab({
+ gBrowser,
+ url: TEST_HOSTNAME + "/browser/toolkit/components/" +
+ "passwordmgr/test/browser/multiple_forms.html",
+ }, function* (browser) {
+ let passwordInput = browser.contentWindow.document.getElementById("test-username-2");
+
+ yield openPasswordContextMenu(browser, passwordInput);
+
+ // Check the content of the password manager popup
+ let popupMenu = document.getElementById("fill-login-popup");
+ checkMenu(popupMenu, 3);
let contextMenu = document.getElementById("contentAreaContextMenu");
contextMenu.hidePopup();
});
});
/**
* Check if the password field is correctly filled when one
* login menuitem is clicked.
*/
add_task(function* test_context_menu_password_fill() {
+ Services.prefs.setBoolPref("signon.schemeUpgrades", true);
yield BrowserTestUtils.withNewTab({
gBrowser,
url: TEST_HOSTNAME + MULTIPLE_FORMS_PAGE_PATH,
}, function* (browser) {
let testForms = browser.contentWindow.document.getElementsByClassName("test-form");
for (let form of testForms) {
let usernameInputList = form.querySelectorAll("input[type='password']");
@@ -106,28 +155,30 @@ add_task(function* test_context_menu_pas
if (contextMenu.state != "open") {
continue;
}
// The only field affected by the password fill
// should be the target password field itself.
let unchangedFields = form.querySelectorAll('input:not(#' + passwordField.id + ')');
- yield assertContextMenuFill(form, null, passwordField, unchangedFields);
+ yield assertContextMenuFill(form, null, passwordField, unchangedFields, 1);
+ Assert.equal(passwordField.value, "password1", "Check upgraded login was actually used");
contextMenu.hidePopup();
}
}
});
});
/**
* Check if the form is correctly filled when one
* username context menu login menuitem is clicked.
*/
add_task(function* test_context_menu_username_login_fill() {
+ Services.prefs.setBoolPref("signon.schemeUpgrades", true);
yield BrowserTestUtils.withNewTab({
gBrowser,
url: TEST_HOSTNAME + MULTIPLE_FORMS_PAGE_PATH,
}, function* (browser) {
let testForms = browser.contentWindow.document.getElementsByClassName("test-form");
for (let form of testForms) {
let usernameInputList = form.querySelectorAll("input[type='text']");
@@ -163,27 +214,31 @@ add_task(function* test_context_menu_use
return true;
});
if (contextMenu.state != "open") {
continue;
}
// We shouldn't change any field that's not the target username field or the first password field
let unchangedFields = form.querySelectorAll('input:not(#' + usernameField.id + '):not(#' + passwordField.id + ')');
- yield assertContextMenuFill(form, usernameField, passwordField, unchangedFields);
+ yield assertContextMenuFill(form, usernameField, passwordField, unchangedFields, 1);
+ if (!passwordField.hasAttribute("expectedFail")) {
+ Assert.equal(passwordField.value, "password1", "Check upgraded login was actually used");
+ }
contextMenu.hidePopup();
}
}
});
});
/**
* Check if the password field is correctly filled when it's in an iframe.
*/
add_task(function* test_context_menu_iframe_fill() {
+ Services.prefs.setBoolPref("signon.schemeUpgrades", true);
yield BrowserTestUtils.withNewTab({
gBrowser,
url: TEST_HOSTNAME + MULTIPLE_FORMS_PAGE_PATH,
}, function* (browser) {
let iframe = browser.contentWindow.document.getElementById("test-iframe");
let passwordInput = iframe.contentDocument.getElementById("form-basic-password");
let contextMenuShownPromise = BrowserTestUtils.waitForEvent(window, "popupshown");
@@ -257,34 +312,34 @@ function* openPasswordContextMenu(browse
let popupShownPromise = BrowserTestUtils.waitForEvent(popupHeader, "popupshown");
EventUtils.synthesizeMouseAtCenter(popupHeader, {});
yield popupShownPromise;
}
/**
* Verify that only the expected form fields are filled.
*/
-function* assertContextMenuFill(form, usernameField, passwordField, unchangedFields){
+function* assertContextMenuFill(form, usernameField, passwordField, unchangedFields, loginIndex){
let popupMenu = document.getElementById("fill-login-popup");
// Store the value of fields that should remain unchanged.
if (unchangedFields.length) {
for (let field of unchangedFields) {
field.setAttribute("original-value", field.value);
}
}
- // Execute the default command of the first login menuitem found at the context menu.
- let firstLoginItem = popupMenu.getElementsByClassName("context-login-item")[0];
- firstLoginItem.doCommand();
+ // Execute the default command of the specified login menuitem found in the context menu.
+ let loginItem = popupMenu.getElementsByClassName("context-login-item")[loginIndex];
+ loginItem.doCommand();
yield BrowserTestUtils.waitForEvent(form, "input", "Username input value changed");
// Find the used login by it's username (Use only unique usernames in this test).
- let login = getLoginFromUsername(firstLoginItem.label);
+ let login = getLoginFromUsername(loginItem.label);
// If we have an username field, check if it's correctly filled
if (usernameField && usernameField.getAttribute("expectedFail") == null) {
Assert.equal(login.username, usernameField.value, "Username filled and correct.");
}
// If we have a password field, check if it's correctly filled
if (passwordField && passwordField.getAttribute("expectedFail") == null) {
@@ -301,60 +356,75 @@ function* assertContextMenuFill(form, us
}
return true;
}, "Other fields were not changed.");
}
}
/**
* Check if every login that matches the page hostname are available at the context menu.
+ * @param {Element} contextMenu
+ * @param {Number} expectedCount - Number of logins expected in the context menu. Used to ensure
+* we continue testing something useful.
*/
-function checkMenu(contextMenu) {
- let logins = loginList().filter(login => login.hostname == TEST_HOSTNAME);
+function checkMenu(contextMenu, expectedCount) {
+ let logins = loginList().filter(login => {
+ return LoginHelper.isOriginMatching(login.hostname, TEST_HOSTNAME, {
+ schemeUpgrades: Services.prefs.getBoolPref("signon.schemeUpgrades"),
+ });
+ });
// Make an array of menuitems for easier comparison.
let menuitems = [...contextMenu.getElementsByClassName("context-login-item")];
- Assert.equal(menuitems.length, logins.length, "Same amount of menu items and expected logins.");
+ Assert.equal(menuitems.length, expectedCount, "Expected number of menu items");
Assert.ok(logins.every(l => menuitems.some(m => l.username == m.label)), "Every login have an item at the menu.");
}
/**
* Search for a login by it's username.
*
* Only unique login/hostname combinations should be used at this test.
*/
function getLoginFromUsername(username) {
return loginList().find(login => login.username == username);
}
/**
* List of logins used for the test.
*
* We should only use unique usernames in this test,
- * because we need to search logins by username.
+ * because we need to search logins by username. There is one duplicate u+p combo
+ * in order to test de-duping in the menu.
*/
function loginList() {
return [
LoginTestUtils.testData.formLogin({
hostname: "https://example.com",
formSubmitURL: "https://example.com",
username: "username",
password: "password",
}),
+ // Same as above but HTTP in order to test de-duping.
+ LoginTestUtils.testData.formLogin({
+ hostname: "http://example.com",
+ formSubmitURL: "http://example.com",
+ username: "username",
+ password: "password",
+ }),
LoginTestUtils.testData.formLogin({
hostname: "http://example.com",
formSubmitURL: "http://example.com",
username: "username1",
password: "password1",
}),
LoginTestUtils.testData.formLogin({
hostname: "https://example.com",
formSubmitURL: "https://example.com",
username: "username2",
password: "password2",
}),
LoginTestUtils.testData.formLogin({
- hostname: "https://example.org",
- formSubmitURL: "https://example.org",
+ hostname: "http://example.org",
+ formSubmitURL: "http://example.org",
username: "username-cross-origin",
password: "password-cross-origin",
}),
];
}
--- a/toolkit/components/passwordmgr/test/mochitest/mochitest.ini
+++ b/toolkit/components/passwordmgr/test/mochitest/mochitest.ini
@@ -1,20 +1,24 @@
[DEFAULT]
skip-if = buildapp == 'mulet' || buildapp == 'b2g'
support-files =
+ ../../../prompts/test/chromeScript.js
+ ../../../prompts/test/prompt_common.js
../../../satchel/test/parent_utils.js
../../../satchel/test/satchel_common.js
../authenticate.sjs
+ ../browser/form_basic.html
+ ../browser/form_cross_origin_secure_action.html
+ ../notification_common.js
../pwmgr_common.js
- ../notification_common.js
auth2/authenticate.sjs
- ../../../prompts/test/prompt_common.js
- ../../../prompts/test/chromeScript.js
+[test_autocomplete_https_upgrade.html]
+[test_autofill_https_upgrade.html]
[test_autofill_password-only.html]
[test_basic_form.html]
[test_basic_form_0pw.html]
[test_basic_form_1pw.html]
[test_basic_form_1pw_2.html]
[test_basic_form_2pw_1.html]
[test_basic_form_2pw_2.html]
[test_basic_form_3pw_1.html]
copy from toolkit/components/passwordmgr/test/mochitest/test_case_differences.html
copy to toolkit/components/passwordmgr/test/mochitest/test_autocomplete_https_upgrade.html
--- a/toolkit/components/passwordmgr/test/mochitest/test_case_differences.html
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_https_upgrade.html
@@ -1,102 +1,116 @@
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
- <title>Test autocomplete due to multiple matching logins</title>
+ <title>Test autocomplete on an HTTPS page using upgraded HTTP logins</title>
<script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
<script type="text/javascript" src="/tests/SimpleTest/EventUtils.js"></script>
<script type="text/javascript" src="/tests/SimpleTest/SpawnTask.js"></script>
<script type="text/javascript" src="satchel_common.js"></script>
<script type="text/javascript" src="pwmgr_common.js"></script>
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
</head>
<body>
-Login Manager test: autocomplete due to multiple matching logins
+<script>
+const chromeScript = runChecksAfterCommonInit(false);
-<script>
-runChecksAfterCommonInit(false);
-
-SpecialPowers.loadChromeScript(function addLogins() {
+runInParent(function addLogins() {
const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
Cu.import("resource://gre/modules/Services.jsm");
// Create some logins just for this form, since we'll be deleting them.
- var nsLoginInfo = Components.Constructor("@mozilla.org/login-manager/loginInfo;1",
+ let nsLoginInfo = Components.Constructor("@mozilla.org/login-manager/loginInfo;1",
Ci.nsILoginInfo, "init");
- var login0 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null,
+ // We have two actual HTTPS to avoid autofill before the schemeUpgrades pref flips to true.
+ let login0 = new nsLoginInfo("https://example.org", "https://example.org", null,
"name", "pass", "uname", "pword");
- var login1 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null,
- "Name", "Pass", "uname", "pword");
+ let login1 = new nsLoginInfo("https://example.org", "https://example.org", null,
+ "name1", "pass1", "uname", "pword");
- var login2 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null,
- "USER", "PASS", "uname", "pword");
+ // Same as above but HTTP instead of HTTPS (to test de-duping)
+ let login2 = new nsLoginInfo("http://example.org", "http://example.org", null,
+ "name1", "passHTTP", "uname", "pword");
+
+ // Different HTTP login to upgrade with secure formSubmitURL
+ let login3 = new nsLoginInfo("http://example.org", "https://example.org", null,
+ "name2", "passHTTPtoHTTPS", "uname", "pword");
try {
Services.logins.addLogin(login0);
Services.logins.addLogin(login1);
Services.logins.addLogin(login2);
+ Services.logins.addLogin(login3);
} catch (e) {
assert.ok(false, "addLogin threw: " + e);
}
});
</script>
<p id="display"></p>
<!-- we presumably can't hide the content for this test. -->
<div id="content">
-
- <!-- form1 tests multiple matching logins -->
- <form id="form1" action="http://autocomplete:8888/formtest.js" onsubmit="return false;">
- <input type="text" name="uname">
- <input type="password" name="pword">
- <button type="submit">Submit</button>
- </form>
-
+ <iframe src="https://example.org/tests/toolkit/components/passwordmgr/test/mochitest/form_basic.html"></iframe>
</div>
<pre id="test">
<script class="testbody" type="text/javascript">
-
-/** Test for Login Manager: autocomplete due to multiple matching logins **/
+const shiftModifier = SpecialPowers.Ci.nsIDOMEvent.SHIFT_MASK;
-var uname = $_(1, "uname");
-var pword = $_(1, "pword");
+let iframe = SpecialPowers.wrap(document.getElementsByTagName("iframe")[0]);
+let iframeDoc;
+let uname;
+let pword;
// Restore the form to the default state.
function restoreForm() {
- uname.value = "";
- pword.value = "";
- uname.focus();
+ pword.focus();
+ uname.value = "";
+ pword.value = "";
+ uname.focus();
}
// Check for expected username/password in form.
function checkACForm(expectedUsername, expectedPassword) {
- var formID = uname.parentNode.id;
+ let formID = uname.parentNode.id;
is(uname.value, expectedUsername, "Checking " + formID + " username");
is(pword.value, expectedPassword, "Checking " + formID + " password");
}
+add_task(function* setup() {
+ yield SpecialPowers.pushPrefEnv({"set": [["signon.schemeUpgrades", true]]});
+
+ yield new Promise(resolve => {
+ iframe.addEventListener("load", function onLoad() {
+ iframe.removeEventListener("load", onLoad);
+ resolve();
+ });
+ });
+
+ iframeDoc = iframe.contentDocument;
+ uname = iframeDoc.getElementById("form-basic-username");
+ pword = iframeDoc.getElementById("form-basic-password");
+});
+
add_task(function* test_empty_first_entry() {
- /* test 1 */
// Make sure initial form is empty.
checkACForm("", "");
// Trigger autocomplete popup
restoreForm();
let popupState = yield getPopupState();
is(popupState.open, false, "Check popup is initially closed");
let shownPromise = promiseACShown();
doKey("down");
let results = yield shownPromise;
popupState = yield getPopupState();
is(popupState.selectedIndex, -1, "Check no entries are selected");
- checkArrayValues(results, ["name", "Name", "USER"], "initial");
+ checkArrayValues(results, ["name", "name1", "name2"], "initial");
// Check first entry
let index0Promise = notifySelectedIndex(0);
doKey("down");
yield index0Promise;
checkACForm("", ""); // value shouldn't update
doKey("return"); // not "enter"!
yield promiseFormsProcessed();
@@ -107,41 +121,98 @@ add_task(function* test_empty_second_ent
restoreForm();
let shownPromise = promiseACShown();
doKey("down"); // open
yield shownPromise;
doKey("down"); // first
doKey("down"); // second
doKey("return"); // not "enter"!
yield promiseFormsProcessed();
- checkACForm("Name", "Pass");
+ checkACForm("name1", "pass1");
});
-add_task(function* test_empty_third_entry() {
+add_task(function* test_search() {
restoreForm();
let shownPromise = promiseACShown();
+ // We need to blur for the autocomplete controller to notice the forced value below.
+ uname.blur();
+ uname.value = "name";
+ uname.focus();
+ sendChar("1");
doKey("down"); // open
- yield shownPromise;
+ let results = yield shownPromise;
+ checkArrayValues(results, ["name1"], "check result deduping for 'name1'");
doKey("down"); // first
- doKey("down"); // second
- doKey("down"); // third
- doKey("return");
+ doKey("return"); // not "enter"!
yield promiseFormsProcessed();
- checkACForm("USER", "PASS");
+ checkACForm("name1", "pass1");
+
+ let popupState = yield getPopupState();
+ is(popupState.open, false, "Check popup is now closed");
});
-add_task(function* test_preserve_matching_username_case() {
+add_task(function* test_delete_first_entry() {
restoreForm();
- uname.value = "user";
+ uname.focus();
let shownPromise = promiseACShown();
- doKey("down"); // open
+ doKey("down");
yield shownPromise;
- // Check that we don't clobber user-entered text when tabbing away
- // (even with no autocomplete entry selected)
- doKey("tab");
- yield promiseFormsProcessed();
- checkACForm("user", "PASS");
+ let index0Promise = notifySelectedIndex(0);
+ doKey("down");
+ yield index0Promise;
+
+ let deletionPromise = promiseStorageChanged(["removeLogin"]);
+ // On OS X, shift-backspace and shift-delete work, just delete does not.
+ // On Win/Linux, shift-backspace does not work, delete and shift-delete do.
+ doKey("delete", shiftModifier);
+ yield deletionPromise;
+ checkACForm("", "");
+
+ let results = yield notifyMenuChanged(2, "name1");
+
+ checkArrayValues(results, ["name1", "name2"], "two should remain after deleting the first");
+ let popupState = yield getPopupState();
+ is(popupState.open, true, "Check popup stays open after deleting");
+ doKey("escape");
+ popupState = yield getPopupState();
+ is(popupState.open, false, "Check popup closed upon ESC");
});
+
+add_task(function* test_delete_duplicate_entry() {
+ restoreForm();
+ uname.focus();
+ let shownPromise = promiseACShown();
+ doKey("down");
+ yield shownPromise;
+
+ let index0Promise = notifySelectedIndex(0);
+ doKey("down");
+ yield index0Promise;
+
+ let deletionPromise = promiseStorageChanged(["removeLogin"]);
+ // On OS X, shift-backspace and shift-delete work, just delete does not.
+ // On Win/Linux, shift-backspace does not work, delete and shift-delete do.
+ doKey("delete", shiftModifier);
+ yield deletionPromise;
+ checkACForm("", "");
+
+ is(LoginManager.countLogins("http://example.org", "http://example.org", null), 1,
+ "Check that the HTTP login remains");
+ is(LoginManager.countLogins("https://example.org", "https://example.org", null), 0,
+ "Check that the HTTPS login was deleted");
+
+ // Two menu items should remain as the HTTPS login should have been deleted but
+ // the HTTP would remain.
+ let results = yield notifyMenuChanged(1, "name2");
+
+ checkArrayValues(results, ["name2"], "one should remain after deleting the HTTPS name1");
+ let popupState = yield getPopupState();
+ is(popupState.open, true, "Check popup stays open after deleting");
+ doKey("escape");
+ popupState = yield getPopupState();
+ is(popupState.open, false, "Check popup closed upon ESC");
+});
+
</script>
</pre>
</body>
</html>
new file mode 100644
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autofill_https_upgrade.html
@@ -0,0 +1,117 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test autocomplete on an HTTPS page using upgraded HTTP logins</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<script>
+const MISSING_ACTION_PATH = TESTS_DIR + "mochitest/form_basic.html";
+const CROSS_ORIGIN_SECURE_PATH = TESTS_DIR + "mochitest/form_cross_origin_secure_action.html";
+
+const chromeScript = runChecksAfterCommonInit(false);
+
+let nsLoginInfo = SpecialPowers.wrap(SpecialPowers.Components).Constructor("@mozilla.org/login-manager/loginInfo;1",
+SpecialPowers.Ci.nsILoginInfo,
+"init");
+</script>
+<p id="display"></p>
+
+<!-- we presumably can't hide the content for this test. -->
+<div id="content">
+ <iframe></iframe>
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+let iframe = SpecialPowers.wrap(document.getElementsByTagName("iframe")[0]);
+
+// Check for expected username/password in form.
+function checkACForm(expectedUsername, expectedPassword) {
+ let iframeDoc = iframe.contentDocument;
+ let uname = iframeDoc.getElementById("form-basic-username");
+ let pword = iframeDoc.getElementById("form-basic-password");
+ let formID = uname.parentNode.id;
+ is(uname.value, expectedUsername, "Checking " + formID + " username");
+ is(pword.value, expectedPassword, "Checking " + formID + " password");
+}
+function* prepareLoginsAndProcessForm(url, logins = []) {
+ LoginManager.removeAllLogins();
+
+ let dates = Date.now();
+ for (let login of logins) {
+ SpecialPowers.do_QueryInterface(login, SpecialPowers.Ci.nsILoginMetaInfo);
+ // Force all dates to be the same so they don't affect things like deduping.
+ login.timeCreated = login.timePasswordChanged = login.timeLastUsed = dates;
+ LoginManager.addLogin(login);
+ }
+
+ iframe.src = url;
+ yield promiseFormsProcessed();
+}
+
+add_task(function* setup() {
+ yield SpecialPowers.pushPrefEnv({"set": [["signon.schemeUpgrades", true]]});
+})
+
+add_task(function* test_simpleNoDupesNoAction() {
+ yield prepareLoginsAndProcessForm("https://example.com" + MISSING_ACTION_PATH, [
+ new nsLoginInfo("http://example.com", "http://example.com", null,
+ "name2", "pass2", "uname", "pword"),
+ ]);
+
+ checkACForm("name2", "pass2");
+});
+
+add_task(function* test_simpleNoDupesUpgradeOriginAndAction() {
+ yield prepareLoginsAndProcessForm("https://example.com" + CROSS_ORIGIN_SECURE_PATH, [
+ new nsLoginInfo("http://example.com", "http://another.domain", null,
+ "name2", "pass2", "uname", "pword"),
+ ]);
+
+ checkACForm("name2", "pass2");
+});
+
+add_task(function* test_simpleNoDupesUpgradeOriginOnly() {
+ yield prepareLoginsAndProcessForm("https://example.com" + CROSS_ORIGIN_SECURE_PATH, [
+ new nsLoginInfo("http://example.com", "https://another.domain", null,
+ "name2", "pass2", "uname", "pword"),
+ ]);
+
+ checkACForm("name2", "pass2");
+});
+
+add_task(function* test_simpleNoDupesUpgradeActionOnly() {
+ yield prepareLoginsAndProcessForm("https://example.com" + CROSS_ORIGIN_SECURE_PATH, [
+ new nsLoginInfo("https://example.com", "http://another.domain", null,
+ "name2", "pass2", "uname", "pword"),
+ ]);
+
+ checkACForm("name2", "pass2");
+});
+
+add_task(function* test_dedupe() {
+ yield prepareLoginsAndProcessForm("https://example.com" + MISSING_ACTION_PATH, [
+ new nsLoginInfo("https://example.com", "https://example.com", null,
+ "name1", "passHTTPStoHTTPS", "uname", "pword"),
+ new nsLoginInfo("http://example.com", "http://example.com", null,
+ "name1", "passHTTPtoHTTP", "uname", "pword"),
+ new nsLoginInfo("http://example.com", "https://example.com", null,
+ "name1", "passHTTPtoHTTPS", "uname", "pword"),
+ new nsLoginInfo("https://example.com", "http://example.com", null,
+ "name1", "passHTTPStoHTTP", "uname", "pword"),
+ ]);
+
+ checkACForm("name1", "passHTTPStoHTTPS");
+});
+
+</script>
+</pre>
+</body>
+</html>
--- a/toolkit/components/passwordmgr/test/mochitest/test_basic_form_2pw_2.html
+++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_2pw_2.html
@@ -51,17 +51,17 @@ function startTest() {
// Called by each form's onsubmit handler.
function checkSubmit(formNum) {
numSubmittedForms++;
// End the test at the last form.
if (formNum == 999) {
is(numSubmittedForms, 999, "Ensuring all forms submitted for testing.");
- var numEndingLogins = countLogins();
+ var numEndingLogins = LoginManager.countLogins("", "", "");
ok(numEndingLogins > 0, "counting logins at end");
is(numStartingLogins, numEndingLogins + 222, "counting logins at end");
SimpleTest.finish();
return false; // return false to cancel current form submission
}
--- a/toolkit/components/passwordmgr/test/mochitest/test_basic_form_autocomplete.html
+++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_autocomplete.html
@@ -214,26 +214,29 @@ add_task(function* setup() {
add_task(function* test_form1_initial_empty() {
yield SimpleTest.promiseFocus(window);
// Make sure initial form is empty.
checkACForm("", "");
let popupState = yield getPopupState();
is(popupState.open, false, "Check popup is initially closed");
- is(popupState.selectedIndex, -1, "Check no entries are selected");
});
add_task(function* test_form1_first_entry() {
yield SimpleTest.promiseFocus(window);
// Trigger autocomplete popup
restoreForm();
let shownPromise = promiseACShown();
doKey("down"); // open
yield shownPromise;
+
+ let popupState = yield getPopupState();
+ is(popupState.selectedIndex, -1, "Check no entries are selected upon opening");
+
doKey("down"); // first
checkACForm("", ""); // value shouldn't update just by selecting
doKey("return"); // not "enter"!
yield promiseFormsProcessed();
checkACForm("tempuser1", "temppass1");
});
add_task(function* test_form1_second_entry() {
@@ -423,27 +426,27 @@ add_task(function* test_form1_delete() {
// XXX tried sending character "t" before/during dropdown to test
// filtering, but had no luck. Seemed like the character was getting lost.
// Setting uname.value didn't seem to work either. This works with a human
// driver, so I'm not sure what's up.
// Delete the first entry (of 4), "tempuser1"
doKey("down");
var numLogins;
- numLogins = countLogins(chromeScript, "http://mochi.test:8888", "http://autocomplete:8888", null);
+ numLogins = LoginManager.countLogins("http://mochi.test:8888", "http://autocomplete:8888", null);
is(numLogins, 5, "Correct number of logins before deleting one");
var deletionPromise = promiseStorageChanged(["removeLogin"]);
// On OS X, shift-backspace and shift-delete work, just delete does not.
// On Win/Linux, shift-backspace does not work, delete and shift-delete do.
doKey("delete", shiftModifier);
yield deletionPromise;
checkACForm("", "");
- numLogins = countLogins(chromeScript, "http://mochi.test:8888", "http://autocomplete:8888", null);
+ numLogins = LoginManager.countLogins("http://mochi.test:8888", "http://autocomplete:8888", null);
is(numLogins, 4, "Correct number of logins after deleting one");
notifyMenuChanged(4);
doKey("return");
yield promiseFormsProcessed();
checkACForm("testuser2", "testpass2");
});
add_task(function* test_form1_first_after_deletion() {
@@ -465,17 +468,17 @@ add_task(function* test_form1_delete_sec
doKey("down"); // open
yield shownPromise;
// Delete the second entry (of 3), "testuser3"
doKey("down");
doKey("down");
doKey("delete", shiftModifier);
checkACForm("", "");
- numLogins = countLogins(chromeScript, "http://mochi.test:8888", "http://autocomplete:8888", null);
+ numLogins = LoginManager.countLogins("http://mochi.test:8888", "http://autocomplete:8888", null);
is(numLogins, 3, "Correct number of logins after deleting one");
doKey("return");
yield promiseFormsProcessed();
checkACForm("zzzuser4", "zzzpass4");
});
add_task(function* test_form1_first_after_deletion2() {
restoreForm();
@@ -497,17 +500,17 @@ add_task(function* test_form1_delete_las
yield shownPromise;
/* test 54 */
// Delete the last entry (of 2), "zzzuser4"
doKey("down");
doKey("down");
doKey("delete", shiftModifier);
checkACForm("", "");
- numLogins = countLogins(chromeScript, "http://mochi.test:8888", "http://autocomplete:8888", null);
+ numLogins = LoginManager.countLogins("http://mochi.test:8888", "http://autocomplete:8888", null);
is(numLogins, 2, "Correct number of logins after deleting one");
doKey("return");
yield promiseFormsProcessed();
checkACForm("testuser2", "testpass2");
});
add_task(function* test_form1_first_after_3_deletions() {
restoreForm();
@@ -529,17 +532,17 @@ add_task(function* test_form1_check_only
yield shownPromise;
/* test 56 */
// Delete the only remaining entry, "testuser2"
doKey("down");
doKey("delete", shiftModifier);
//doKey("return");
checkACForm("", "");
- numLogins = countLogins(chromeScript, "http://mochi.test:8888", "http://autocomplete:8888", null);
+ numLogins = LoginManager.countLogins("http://mochi.test:8888", "http://autocomplete:8888", null);
is(numLogins, 1, "Correct number of logins after deleting one");
// remove the login that's not shown in the list.
setupScript.sendSyncMessage("removeLogin", "login0");
});
/* Tests for single-user forms for ignoring autocomplete=off */
add_task(function* test_form2() {
--- a/toolkit/components/passwordmgr/test/pwmgr_common.js
+++ b/toolkit/components/passwordmgr/test/pwmgr_common.js
@@ -1,8 +1,10 @@
+const TESTS_DIR = "/tests/toolkit/components/passwordmgr/test/";
+
/**
* Returns the element with the specified |name| attribute.
*/
function $_(formNum, name) {
var form = document.getElementById("form" + formNum);
if (!form) {
logWarning("$_ couldn't find requested form " + formNum);
return null;
@@ -270,17 +272,17 @@ function getRecipeParent() {
*/
function promiseFormsProcessed(expectedCount = 1) {
var processedCount = 0;
return new Promise((resolve, reject) => {
function onProcessedForm(subject, topic, data) {
processedCount++;
if (processedCount == expectedCount) {
SpecialPowers.removeObserver(onProcessedForm, "passwordmgr-processed-form");
- resolve(subject, data);
+ resolve(SpecialPowers.Cu.waiveXrays(subject), data);
}
}
SpecialPowers.addObserver(onProcessedForm, "passwordmgr-processed-form", false);
});
}
function loadRecipes(recipes) {
info("Loading recipes");
@@ -313,20 +315,16 @@ function promiseStorageChanged(expectedC
chromeScript.removeMessageListener("storageChanged", onStorageChanged);
resolve();
}
}
chromeScript.addMessageListener("storageChanged", onStorageChanged);
});
}
-function countLogins(chromeScript, formOrigin, submitOrigin, httpRealm) {
- return chromeScript.sendSyncMessage("countLogins", {formOrigin, submitOrigin, httpRealm})[0][0];
-}
-
/**
* Run a function synchronously in the parent process and destroy it in the test cleanup function.
* @param {Function|String} aFunctionOrURL - either a function that will be stringified and run
* or the URL to a JS file.
* @return {Object} - the return value of loadChromeScript providing message-related methods.
* @see loadChromeScript in specialpowersAPI.js
*/
function runInParent(aFunctionOrURL) {
@@ -356,16 +354,17 @@ function runChecksAfterCommonInit(aFunct
// Code to run when loaded as a chrome script in tests via loadChromeScript
if (this.addMessageListener) {
const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
var SpecialPowers = { Cc, Ci, Cr, Cu, };
var ok, is;
// Ignore ok/is in commonInit since they aren't defined in a chrome script.
ok = is = () => {}; // eslint-disable-line no-native-reassign
+ Cu.import("resource://gre/modules/LoginHelper.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/Task.jsm");
function onStorageChanged(subject, topic, data) {
sendAsyncMessage("storageChanged", {
topic,
data,
});
@@ -386,18 +385,27 @@ if (this.addMessageListener) {
addMessageListener("resetRecipes", Task.async(function* resetRecipes() {
let { LoginManagerParent } = Cu.import("resource://gre/modules/LoginManagerParent.jsm", {});
let recipeParent = yield LoginManagerParent.recipeParentPromise;
yield recipeParent.reset();
sendAsyncMessage("recipesReset");
}));
- addMessageListener("countLogins", ({formOrigin, submitOrigin, httpRealm}) => {
- return Services.logins.countLogins(formOrigin, submitOrigin, httpRealm);
+ addMessageListener("proxyLoginManager", msg => {
+ // Recreate nsILoginInfo objects from vanilla JS objects.
+ let recreatedArgs = msg.args.map((arg, index) => {
+ if (msg.loginInfoIndices.includes(index)) {
+ return LoginHelper.vanillaObjectToLogin(arg);
+ }
+
+ return arg;
+ });
+
+ return Services.logins[msg.methodName](...recreatedArgs);
});
var globalMM = Cc["@mozilla.org/globalmessagemanager;1"].getService(Ci.nsIMessageListenerManager);
globalMM.addMessageListener("RemoteLogins:onFormSubmit", function onFormSubmit(message) {
sendAsyncMessage("formSubmissionProcessed", message.data, message.objects);
});
} else {
// Code to only run in the mochitest pages (not in the chrome script).
@@ -417,9 +425,38 @@ if (this.addMessageListener) {
getService(Ci.nsIHttpAuthManager);
authMgr.clearAll();
if (LoginManagerParent._recipeManager) {
LoginManagerParent._recipeManager.reset();
}
});
});
+
+
+ let { LoginHelper } = SpecialPowers.Cu.import("resource://gre/modules/LoginHelper.jsm", {});
+ /**
+ * Proxy for Services.logins (nsILoginManager).
+ * Only supports arguments which support structured clone plus {nsILoginInfo}
+ * Assumes properties are methods.
+ */
+ this.LoginManager = new Proxy({}, {
+ get(target, prop, receiver) {
+ return (...args) => {
+ let loginInfoIndices = [];
+ let cloneableArgs = args.map((val, index) => {
+ if (SpecialPowers.call_Instanceof(val, SpecialPowers.Ci.nsILoginInfo)) {
+ loginInfoIndices.push(index);
+ return LoginHelper.loginToVanillaObject(val);
+ }
+
+ return val;
+ });
+
+ return chromeScript.sendSyncMessage("proxyLoginManager", {
+ args: cloneableArgs,
+ loginInfoIndices,
+ methodName: prop,
+ })[0][0];
+ };
+ },
+ });
}
new file mode 100644
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_dedupeLogins.js
@@ -0,0 +1,292 @@
+/*
+ * Test LoginHelper.dedupeLogins
+ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/LoginHelper.jsm");
+
+const DOMAIN1_HTTP_TO_HTTP_U1_P1 = TestData.formLogin({
+ timePasswordChanged: 3000,
+ timeLastUsed: 2000,
+});
+const DOMAIN1_HTTP_TO_HTTP_U1_P2 = TestData.formLogin({
+ password: "password two",
+});
+const DOMAIN1_HTTP_TO_HTTP_U2_P2 = TestData.formLogin({
+ password: "password two",
+ username: "username two",
+});
+const DOMAIN1_HTTPS_TO_HTTPS_U1_P1 = TestData.formLogin({
+ formSubmitURL: "http://www.example.com",
+ hostname: "https://www3.example.com",
+ timePasswordChanged: 4000,
+ timeLastUsed: 1000,
+});
+const DOMAIN1_HTTPS_TO_EMPTY_U1_P1 = TestData.formLogin({
+ formSubmitURL: "",
+ hostname: "https://www3.example.com",
+});
+const DOMAIN1_HTTPS_TO_EMPTYU_P1 = TestData.formLogin({
+ hostname: "https://www3.example.com",
+ username: "",
+});
+const DOMAIN1_HTTP_AUTH = TestData.authLogin({
+ hostname: "http://www3.example.com",
+});
+const DOMAIN1_HTTPS_AUTH = TestData.authLogin({
+ hostname: "https://www3.example.com",
+});
+
+
+add_task(function test_dedupeLogins() {
+ // [description, expectedOutput, dedupe arg. 0, dedupe arg 1, ...]
+ let testcases = [
+ [
+ "exact dupes",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ [], // force no resolveBy logic to test behavior of preferring the first..
+ ],
+ [
+ "default uniqueKeys is un + pw",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P2],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P2],
+ undefined,
+ [],
+ ],
+ [
+ "same usernames, different passwords, dedupe username only",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P2],
+ ["username"],
+ [],
+ ],
+ [
+ "same un+pw, different scheme",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ undefined,
+ [],
+ ],
+ [
+ "same un+pw, different scheme, reverse order",
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ [],
+ ],
+ [
+ "same un+pw, different scheme, include hostname",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ ["hostname", "username", "password"],
+ [],
+ ],
+ [
+ "empty username is not deduped with non-empty",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_EMPTYU_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_EMPTYU_P1],
+ undefined,
+ [],
+ ],
+ [
+ "empty username is deduped with same passwords",
+ [DOMAIN1_HTTPS_TO_EMPTYU_P1],
+ [DOMAIN1_HTTPS_TO_EMPTYU_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ ["password"],
+ [],
+ ],
+ [
+ "mix of form and HTTP auth",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTP_AUTH],
+ undefined,
+ [],
+ ],
+ ];
+
+ for (let tc of testcases) {
+ let description = tc.shift();
+ let expected = tc.shift();
+ let actual = LoginHelper.dedupeLogins(...tc);
+ Assert.strictEqual(actual.length, expected.length, `Check: ${description}`);
+ for (let [i, login] of expected.entries()) {
+ Assert.strictEqual(actual[i], login, `Check index ${i}`);
+ }
+ }
+});
+
+
+add_task(function* test_dedupeLogins_resolveBy() {
+ Assert.ok(DOMAIN1_HTTP_TO_HTTP_U1_P1.timeLastUsed > DOMAIN1_HTTPS_TO_HTTPS_U1_P1.timeLastUsed,
+ "Sanity check timeLastUsed difference");
+ Assert.ok(DOMAIN1_HTTP_TO_HTTP_U1_P1.timePasswordChanged < DOMAIN1_HTTPS_TO_HTTPS_U1_P1.timePasswordChanged,
+ "Sanity check timePasswordChanged difference");
+
+ let testcases = [
+ [
+ "default resolveBy is timeLastUsed",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ ],
+ [
+ "default resolveBy is timeLastUsed, reversed input",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ ],
+ [
+ "resolveBy timeLastUsed + timePasswordChanged",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ ["timeLastUsed", "timePasswordChanged"],
+ ],
+ [
+ "resolveBy timeLastUsed + timePasswordChanged, reversed input",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ undefined,
+ ["timeLastUsed", "timePasswordChanged"],
+ ],
+ [
+ "resolveBy timePasswordChanged",
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ ["timePasswordChanged"],
+ ],
+ [
+ "resolveBy timePasswordChanged, reversed",
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ undefined,
+ ["timePasswordChanged"],
+ ],
+ [
+ "resolveBy timePasswordChanged + timeLastUsed",
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ ["timePasswordChanged", "timeLastUsed"],
+ ],
+ [
+ "resolveBy timePasswordChanged + timeLastUsed, reversed",
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ undefined,
+ ["timePasswordChanged", "timeLastUsed"],
+ ],
+ [
+ "resolveBy scheme + timePasswordChanged, prefer HTTP",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ ["scheme", "timePasswordChanged"],
+ DOMAIN1_HTTP_TO_HTTP_U1_P1.hostname,
+ ],
+ [
+ "resolveBy scheme + timePasswordChanged, prefer HTTP, reversed input",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ undefined,
+ ["scheme", "timePasswordChanged"],
+ DOMAIN1_HTTP_TO_HTTP_U1_P1.hostname,
+ ],
+ [
+ "resolveBy scheme + timePasswordChanged, prefer HTTPS",
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ ["scheme", "timePasswordChanged"],
+ DOMAIN1_HTTPS_TO_HTTPS_U1_P1.hostname,
+ ],
+ [
+ "resolveBy scheme + timePasswordChanged, prefer HTTPS, reversed input",
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ undefined,
+ ["scheme", "timePasswordChanged"],
+ DOMAIN1_HTTPS_TO_HTTPS_U1_P1.hostname,
+ ],
+ [
+ "resolveBy scheme HTTP auth",
+ [DOMAIN1_HTTPS_AUTH],
+ [DOMAIN1_HTTP_AUTH, DOMAIN1_HTTPS_AUTH],
+ undefined,
+ ["scheme"],
+ DOMAIN1_HTTPS_AUTH.hostname,
+ ],
+ [
+ "resolveBy scheme HTTP auth, reversed input",
+ [DOMAIN1_HTTPS_AUTH],
+ [DOMAIN1_HTTPS_AUTH, DOMAIN1_HTTP_AUTH],
+ undefined,
+ ["scheme"],
+ DOMAIN1_HTTPS_AUTH.hostname,
+ ],
+ [
+ "resolveBy scheme, empty form submit URL",
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTPS_TO_EMPTY_U1_P1],
+ undefined,
+ ["scheme"],
+ DOMAIN1_HTTPS_TO_HTTPS_U1_P1.hostname,
+ ],
+ ];
+
+ for (let tc of testcases) {
+ let description = tc.shift();
+ let expected = tc.shift();
+ let actual = LoginHelper.dedupeLogins(...tc);
+ Assert.strictEqual(actual.length, expected.length, `Check: ${description}`);
+ for (let [i, login] of expected.entries()) {
+ Assert.strictEqual(actual[i], login, `Check index ${i}`);
+ }
+ }
+
+});
+
+add_task(function* test_dedupeLogins_preferredOriginMissing() {
+ let testcases = [
+ [
+ "resolveBy scheme + timePasswordChanged, missing preferredOrigin",
+ /preferredOrigin/,
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ ["scheme", "timePasswordChanged"],
+ ],
+ [
+ "resolveBy timePasswordChanged + scheme, missing preferredOrigin",
+ /preferredOrigin/,
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ ["timePasswordChanged", "scheme"],
+ ],
+ [
+ "resolveBy scheme + timePasswordChanged, empty preferredOrigin",
+ /preferredOrigin/,
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ ["scheme", "timePasswordChanged"],
+ "",
+ ],
+ [
+ "resolveBy scheme + timePasswordChanged, invalid preferredOrigin",
+ /preferred origin/,
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ ["scheme", "timePasswordChanged"],
+ "example.com",
+ ],
+ ];
+
+ for (let tc of testcases) {
+ let description = tc.shift();
+ let expectedException = tc.shift();
+ Assert.throws(() => {
+ LoginHelper.dedupeLogins(...tc);
+ }, expectedException, `Check: ${description}`);
+ }
+});
--- a/toolkit/components/passwordmgr/test/unit/xpcshell.ini
+++ b/toolkit/components/passwordmgr/test/unit/xpcshell.ini
@@ -16,16 +16,17 @@ skip-if = os == "android"
[test_storage_mozStorage.js]
skip-if = true || os != "android" # Bug 1171687: Needs fixing on Android
# The following tests apply to any storage back-end.
[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_dedupeLogins.js]
[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]