diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | 5f8de423f190bbb79a62f804151bc24824fa32d8 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /toolkit/components/passwordmgr | |
parent | 49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff) | |
download | UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip |
Add m-esr52 at 52.6.0
Diffstat (limited to 'toolkit/components/passwordmgr')
193 files changed, 31386 insertions, 0 deletions
diff --git a/toolkit/components/passwordmgr/.eslintrc.js b/toolkit/components/passwordmgr/.eslintrc.js new file mode 100644 index 000000000..188f7eeff --- /dev/null +++ b/toolkit/components/passwordmgr/.eslintrc.js @@ -0,0 +1,36 @@ +"use strict"; + +module.exports = { // eslint-disable-line no-undef + "extends": "../../.eslintrc.js", + "rules": { + // Require spacing around => + "arrow-spacing": "error", + + // No newline before open brace for a block + "brace-style": ["error", "1tbs", {"allowSingleLine": true}], + + // No space before always a space after a comma + "comma-spacing": ["error", {"before": false, "after": true}], + + // Commas at the end of the line not the start + "comma-style": "error", + + // Use [] instead of Array() + "no-array-constructor": "error", + + // Use {} instead of new Object() + "no-new-object": "error", + + // No using undeclared variables + "no-undef": "error", + + // Don't allow unused local variables unless they match the pattern + "no-unused-vars": ["error", {"args": "none", "vars": "local", "varsIgnorePattern": "^(ids|ignored|unused)$"}], + + // Always require semicolon at end of statement + "semi": ["error", "always"], + + // Require spaces around operators + "space-infix-ops": "error", + } +}; diff --git a/toolkit/components/passwordmgr/InsecurePasswordUtils.jsm b/toolkit/components/passwordmgr/InsecurePasswordUtils.jsm new file mode 100644 index 000000000..5351e45b2 --- /dev/null +++ b/toolkit/components/passwordmgr/InsecurePasswordUtils.jsm @@ -0,0 +1,150 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +this.EXPORTED_SYMBOLS = [ "InsecurePasswordUtils" ]; + +const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; +const STRINGS_URI = "chrome://global/locale/security/security.properties"; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "devtools", + "resource://devtools/shared/Loader.jsm"); +XPCOMUtils.defineLazyServiceGetter(this, "gContentSecurityManager", + "@mozilla.org/contentsecuritymanager;1", + "nsIContentSecurityManager"); +XPCOMUtils.defineLazyServiceGetter(this, "gScriptSecurityManager", + "@mozilla.org/scriptsecuritymanager;1", + "nsIScriptSecurityManager"); +XPCOMUtils.defineLazyGetter(this, "WebConsoleUtils", () => { + return this.devtools.require("devtools/server/actors/utils/webconsole-utils").Utils; +}); + +/* + * A module that provides utility functions for form security. + * + * Note: + * This module uses isSecureContextIfOpenerIgnored instead of isSecureContext. + * + * We don't want to expose JavaScript APIs in a non-Secure Context even if + * the context is only insecure because the windows has an insecure opener. + * Doing so prevents sites from implementing postMessage workarounds to enable + * an insecure opener to gain access to Secure Context-only APIs. However, + * in the case of form fields such as password fields we don't need to worry + * about whether the opener is secure or not. In fact to flag a password + * field as insecure in such circumstances would unnecessarily confuse our + * users. + */ +this.InsecurePasswordUtils = { + _formRootsWarned: new WeakMap(), + _sendWebConsoleMessage(messageTag, domDoc) { + let windowId = WebConsoleUtils.getInnerWindowId(domDoc.defaultView); + let category = "Insecure Password Field"; + // All web console messages are warnings for now. + let flag = Ci.nsIScriptError.warningFlag; + let bundle = Services.strings.createBundle(STRINGS_URI); + let message = bundle.GetStringFromName(messageTag); + let consoleMsg = Cc["@mozilla.org/scripterror;1"].createInstance(Ci.nsIScriptError); + consoleMsg.initWithWindowID(message, domDoc.location.href, 0, 0, 0, flag, category, windowId); + + Services.console.logMessage(consoleMsg); + }, + + /** + * Gets the security state of the passed form. + * + * @param {FormLike} aForm A form-like object. @See {FormLikeFactory} + * + * @returns {Object} An object with the following boolean values: + * isFormSubmitHTTP: if the submit action is an http:// URL + * isFormSubmitSecure: if the submit action URL is secure, + * either because it is HTTPS or because its origin is considered trustworthy + */ + _checkFormSecurity(aForm) { + let isFormSubmitHTTP = false, isFormSubmitSecure = false; + if (aForm.rootElement instanceof Ci.nsIDOMHTMLFormElement) { + let uri = Services.io.newURI(aForm.rootElement.action || aForm.rootElement.baseURI, + null, null); + let principal = gScriptSecurityManager.getCodebasePrincipal(uri); + + if (uri.schemeIs("http")) { + isFormSubmitHTTP = true; + if (gContentSecurityManager.isOriginPotentiallyTrustworthy(principal)) { + isFormSubmitSecure = true; + } + } else { + isFormSubmitSecure = true; + } + } + + return { isFormSubmitHTTP, isFormSubmitSecure }; + }, + + /** + * Checks if there are insecure password fields present on the form's document + * i.e. passwords inside forms with http action, inside iframes with http src, + * or on insecure web pages. + * + * @param {FormLike} aForm A form-like object. @See {LoginFormFactory} + * @return {boolean} whether the form is secure + */ + isFormSecure(aForm) { + // Ignores window.opener, see top level documentation. + let isSafePage = aForm.ownerDocument.defaultView.isSecureContextIfOpenerIgnored; + let { isFormSubmitSecure, isFormSubmitHTTP } = this._checkFormSecurity(aForm); + + return isSafePage && (isFormSubmitSecure || !isFormSubmitHTTP); + }, + + /** + * Report insecure password fields in a form to the web console to warn developers. + * + * @param {FormLike} aForm A form-like object. @See {FormLikeFactory} + */ + reportInsecurePasswords(aForm) { + if (this._formRootsWarned.has(aForm.rootElement) || + this._formRootsWarned.get(aForm.rootElement)) { + return; + } + + let domDoc = aForm.ownerDocument; + // Ignores window.opener, see top level documentation. + let isSafePage = domDoc.defaultView.isSecureContextIfOpenerIgnored; + + let { isFormSubmitHTTP, isFormSubmitSecure } = this._checkFormSecurity(aForm); + + if (!isSafePage) { + if (domDoc.defaultView == domDoc.defaultView.parent) { + this._sendWebConsoleMessage("InsecurePasswordsPresentOnPage", domDoc); + } else { + this._sendWebConsoleMessage("InsecurePasswordsPresentOnIframe", domDoc); + } + this._formRootsWarned.set(aForm.rootElement, true); + } else if (isFormSubmitHTTP && !isFormSubmitSecure) { + this._sendWebConsoleMessage("InsecureFormActionPasswordsPresent", domDoc); + this._formRootsWarned.set(aForm.rootElement, true); + } + + // The safety of a password field determined by the form action and the page protocol + let passwordSafety; + if (isSafePage) { + if (isFormSubmitSecure) { + passwordSafety = 0; + } else if (isFormSubmitHTTP) { + passwordSafety = 1; + } else { + passwordSafety = 2; + } + } else if (isFormSubmitSecure) { + passwordSafety = 3; + } else if (isFormSubmitHTTP) { + passwordSafety = 4; + } else { + passwordSafety = 5; + } + + Services.telemetry.getHistogramById("PWMGR_LOGIN_PAGE_SAFETY").add(passwordSafety); + }, +}; diff --git a/toolkit/components/passwordmgr/LoginHelper.jsm b/toolkit/components/passwordmgr/LoginHelper.jsm new file mode 100644 index 000000000..e0c4d872b --- /dev/null +++ b/toolkit/components/passwordmgr/LoginHelper.jsm @@ -0,0 +1,725 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Contains functions shared by different Login Manager components. + * + * This JavaScript module exists in order to share code between the different + * XPCOM components that constitute the Login Manager, including implementations + * of nsILoginManager and nsILoginManagerStorage. + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = [ + "LoginHelper", +]; + +// Globals + +const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +// LoginHelper + +/** + * Contains functions shared by different Login Manager components. + */ +this.LoginHelper = { + /** + * Warning: these only update if a logger was created. + */ + debug: Services.prefs.getBoolPref("signon.debug"), + formlessCaptureEnabled: Services.prefs.getBoolPref("signon.formlessCapture.enabled"), + schemeUpgrades: Services.prefs.getBoolPref("signon.schemeUpgrades"), + insecureAutofill: Services.prefs.getBoolPref("signon.autofillForms.http"), + showInsecureFieldWarning: Services.prefs.getBoolPref("security.insecure_field_warning.contextual.enabled"), + + 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; + let consoleOptions = { + 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.formlessCaptureEnabled = Services.prefs.getBoolPref("signon.formlessCapture.enabled"); + this.schemeUpgrades = Services.prefs.getBoolPref("signon.schemeUpgrades"); + this.insecureAutofill = Services.prefs.getBoolPref("signon.autofillForms.http"); + logger.maxLogLevel = getMaxLogLevel(); + }, false); + + Services.prefs.addObserver("security.insecure_field_warning.", () => { + this.showInsecureFieldWarning = Services.prefs.getBoolPref("security.insecure_field_warning.contextual.enabled"); + }, false); + + return logger; + }, + + /** + * Due to the way the signons2.txt file is formatted, we need to make + * sure certain field values or characters do not cause the file to + * be parsed incorrectly. Reject hostnames that we can't store correctly. + * + * @throws String with English message in case validation failed. + */ + checkHostnameValue(aHostname) { + // Nulls are invalid, as they don't round-trip well. Newlines are also + // invalid for any field stored as plaintext, and a hostname made of a + // single dot cannot be stored in the legacy format. + if (aHostname == "." || + aHostname.indexOf("\r") != -1 || + aHostname.indexOf("\n") != -1 || + aHostname.indexOf("\0") != -1) { + throw new Error("Invalid hostname"); + } + }, + + /** + * Due to the way the signons2.txt file is formatted, we need to make + * sure certain field values or characters do not cause the file to + * be parsed incorrectly. Reject logins that we can't store correctly. + * + * @throws String with English message in case validation failed. + */ + checkLoginValues(aLogin) { + function badCharacterPresent(l, c) { + return ((l.formSubmitURL && l.formSubmitURL.indexOf(c) != -1) || + (l.httpRealm && l.httpRealm.indexOf(c) != -1) || + l.hostname.indexOf(c) != -1 || + l.usernameField.indexOf(c) != -1 || + l.passwordField.indexOf(c) != -1); + } + + // Nulls are invalid, as they don't round-trip well. + // Mostly not a formatting problem, although ".\0" can be quirky. + if (badCharacterPresent(aLogin, "\0")) { + throw new Error("login values can't contain nulls"); + } + + // In theory these nulls should just be rolled up into the encrypted + // values, but nsISecretDecoderRing doesn't use nsStrings, so the + // nulls cause truncation. Check for them here just to avoid + // unexpected round-trip surprises. + if (aLogin.username.indexOf("\0") != -1 || + aLogin.password.indexOf("\0") != -1) { + throw new Error("login values can't contain nulls"); + } + + // Newlines are invalid for any field stored as plaintext. + if (badCharacterPresent(aLogin, "\r") || + badCharacterPresent(aLogin, "\n")) { + throw new Error("login values can't contain newlines"); + } + + // A line with just a "." can have special meaning. + if (aLogin.usernameField == "." || + aLogin.formSubmitURL == ".") { + throw new Error("login values can't be periods"); + } + + // A hostname with "\ \(" won't roundtrip. + // 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 Object.entries(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; + }, + + doLoginsMatch(aLogin1, aLogin2, { + ignorePassword = false, + ignoreSchemes = false, + }) { + if (aLogin1.httpRealm != aLogin2.httpRealm || + aLogin1.username != aLogin2.username) + return false; + + if (!ignorePassword && aLogin1.password != aLogin2.password) + return false; + + if (ignoreSchemes) { + let hostname1URI = Services.io.newURI(aLogin1.hostname, null, null); + let hostname2URI = Services.io.newURI(aLogin2.hostname, null, null); + if (hostname1URI.hostPort != hostname2URI.hostPort) + return false; + + if (aLogin1.formSubmitURL != "" && aLogin2.formSubmitURL != "" && + Services.io.newURI(aLogin1.formSubmitURL, null, null).hostPort != + Services.io.newURI(aLogin2.formSubmitURL, null, null).hostPort) + return false; + } else { + if (aLogin1.hostname != aLogin2.hostname) + return false; + + // If either formSubmitURL is blank (but not null), then match. + if (aLogin1.formSubmitURL != "" && aLogin2.formSubmitURL != "" && + aLogin1.formSubmitURL != aLogin2.formSubmitURL) + return false; + } + + // The .usernameField and .passwordField values are ignored. + + return true; + }, + + /** + * 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. + * + * @return The newly created nsILoginInfo object. + * + * @throws String with English message in case validation failed. + */ + buildModifiedLogin(aOldStoredLogin, aNewLoginData) { + function bagHasProperty(aPropName) { + try { + aNewLoginData.getProperty(aPropName); + return true; + } catch (ex) { } + return false; + } + + aOldStoredLogin.QueryInterface(Ci.nsILoginMetaInfo); + + let newLogin; + if (aNewLoginData instanceof Ci.nsILoginInfo) { + // Clone the existing login to get its nsILoginMetaInfo, then init it + // with the replacement nsILoginInfo data from the new login. + newLogin = aOldStoredLogin.clone(); + newLogin.init(aNewLoginData.hostname, + aNewLoginData.formSubmitURL, aNewLoginData.httpRealm, + aNewLoginData.username, aNewLoginData.password, + aNewLoginData.usernameField, aNewLoginData.passwordField); + newLogin.QueryInterface(Ci.nsILoginMetaInfo); + + // Automatically update metainfo when password is changed. + if (newLogin.password != aOldStoredLogin.password) { + newLogin.timePasswordChanged = Date.now(); + } + } else if (aNewLoginData instanceof Ci.nsIPropertyBag) { + // Clone the existing login, along with all its properties. + newLogin = aOldStoredLogin.clone(); + newLogin.QueryInterface(Ci.nsILoginMetaInfo); + + // Automatically update metainfo when password is changed. + // (Done before the main property updates, lest the caller be + // explicitly updating both .password and .timePasswordChanged) + if (bagHasProperty("password")) { + let newPassword = aNewLoginData.getProperty("password"); + if (newPassword != aOldStoredLogin.password) { + newLogin.timePasswordChanged = Date.now(); + } + } + + let propEnum = aNewLoginData.enumerator; + while (propEnum.hasMoreElements()) { + let prop = propEnum.getNext().QueryInterface(Ci.nsIProperty); + switch (prop.name) { + // nsILoginInfo + case "hostname": + case "httpRealm": + case "formSubmitURL": + case "username": + case "password": + case "usernameField": + case "passwordField": + // nsILoginMetaInfo + case "guid": + case "timeCreated": + case "timeLastUsed": + case "timePasswordChanged": + case "timesUsed": + newLogin[prop.name] = prop.value; + break; + + // Fake property, allows easy incrementing. + case "timesUsedIncrement": + newLogin.timesUsed += prop.value; + break; + + // Fail if caller requests setting an unknown property. + default: + throw new Error("Unexpected propertybag item: " + prop.name); + } + } + } else { + throw new Error("newLoginData needs an expected interface!"); + } + + // Sanity check the login + if (newLogin.hostname == null || newLogin.hostname.length == 0) { + throw new Error("Can't add a login with a null or empty hostname."); + } + + // For logins w/o a username, set to "", not null. + if (newLogin.username == null) { + throw new Error("Can't add a login with a null username."); + } + + if (newLogin.password == null || newLogin.password.length == 0) { + throw new Error("Can't add a login with a null or empty password."); + } + + if (newLogin.formSubmitURL || newLogin.formSubmitURL == "") { + // We have a form submit URL. Can't have a HTTP realm. + if (newLogin.httpRealm != null) { + throw new Error("Can't add a login with both a httpRealm and formSubmitURL."); + } + } else if (newLogin.httpRealm) { + // We have a HTTP realm. Can't have a form submit URL. + if (newLogin.formSubmitURL != null) { + throw new Error("Can't add a login with both a httpRealm and formSubmitURL."); + } + } else { + // Need one or the other! + throw new Error("Can't add a login without a httpRealm or formSubmitURL."); + } + + // Throws if there are bogus values. + this.checkLoginValues(newLogin); + + return newLogin; + }, + + /** + * 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[]} [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"], + 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")) { + log.warn("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], ""); + } + + /** + * @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 (loginsByKeys.has(key)) { + 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 + * the window from where we want to open the dialog + * + * @param {string} [filterString=""] + * the filterString parameter to pass to the login manager dialog + */ + openPasswordManager(window, filterString = "") { + let win = Services.wm.getMostRecentWindow("Toolkit:PasswordManager"); + if (win) { + win.setFilter(filterString); + win.focus(); + } else { + window.openDialog("chrome://passwordmgr/content/passwordManager.xul", + "Toolkit:PasswordManager", "", + {filterString : filterString}); + } + }, + + /** + * Checks if a field type is username compatible. + * + * @param {Element} element + * the field we want to check. + * + * @returns {Boolean} true if the field type is one + * of the username types. + */ + isUsernameFieldType(element) { + if (!(element instanceof Ci.nsIDOMHTMLInputElement)) + return false; + + let fieldType = (element.hasAttribute("type") ? + element.getAttribute("type").toLowerCase() : + element.type); + if (fieldType == "text" || + fieldType == "email" || + fieldType == "url" || + fieldType == "tel" || + fieldType == "number") { + return true; + } + return false; + }, + + /** + * Add the login to the password manager if a similar one doesn't already exist. Merge it + * otherwise with the similar existing ones. + * @param {Object} loginData - the data about the login that needs to be added. + * @returns {nsILoginInfo} the newly added login, or null if no login was added. + * Note that we will also return null if an existing login + * was modified. + */ + maybeImportLogin(loginData) { + // create a new login + let login = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo); + login.init(loginData.hostname, + loginData.formSubmitURL || (typeof(loginData.httpRealm) == "string" ? null : ""), + typeof(loginData.httpRealm) == "string" ? loginData.httpRealm : null, + loginData.username, + loginData.password, + loginData.usernameElement || "", + loginData.passwordElement || ""); + + login.QueryInterface(Ci.nsILoginMetaInfo); + login.timeCreated = loginData.timeCreated; + login.timeLastUsed = loginData.timeLastUsed || loginData.timeCreated; + login.timePasswordChanged = loginData.timePasswordChanged || loginData.timeCreated; + login.timesUsed = loginData.timesUsed || 1; + // While here we're passing formSubmitURL and httpRealm, they could be empty/null and get + // ignored in that case, leading to multiple logins for the same username. + let existingLogins = Services.logins.findLogins({}, login.hostname, + login.formSubmitURL, + login.httpRealm); + // Check for an existing login that matches *including* the password. + // If such a login exists, we do not need to add a new login. + if (existingLogins.some(l => login.matches(l, false /* ignorePassword */))) { + return null; + } + // Now check for a login with the same username, where it may be that we have an + // updated password. + let foundMatchingLogin = false; + for (let existingLogin of existingLogins) { + if (login.username == existingLogin.username) { + foundMatchingLogin = true; + existingLogin.QueryInterface(Ci.nsILoginMetaInfo); + if (login.password != existingLogin.password & + login.timePasswordChanged > existingLogin.timePasswordChanged) { + // if a login with the same username and different password already exists and it's older + // than the current one, update its password and timestamp. + let propBag = Cc["@mozilla.org/hash-property-bag;1"]. + createInstance(Ci.nsIWritablePropertyBag); + propBag.setProperty("password", login.password); + propBag.setProperty("timePasswordChanged", login.timePasswordChanged); + Services.logins.modifyLogin(existingLogin, propBag); + } + } + } + // if the new login is an update or is older than an exiting login, don't add it. + if (foundMatchingLogin) { + return null; + } + return Services.logins.addLogin(login); + }, + + /** + * Convert an array of nsILoginInfo to vanilla JS objects suitable for + * sending over IPC. + * + * NB: All members of nsILoginInfo and nsILoginMetaInfo are strings. + */ + loginsToVanillaObjects(logins) { + return logins.map(this.loginToVanillaObject); + }, + + /** + * Same as above, but for a single login. + */ + loginToVanillaObject(login) { + let obj = {}; + for (let i in login.QueryInterface(Ci.nsILoginMetaInfo)) { + if (typeof login[i] !== 'function') { + obj[i] = login[i]; + } + } + + return obj; + }, + + /** + * Convert an object received from IPC into an nsILoginInfo (with guid). + */ + vanillaObjectToLogin(login) { + 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); + 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); + }, + + removeLegacySignonFiles() { + const {Constants, Path, File} = Cu.import("resource://gre/modules/osfile.jsm").OS; + + const profileDir = Constants.Path.profileDir; + const defaultSignonFilePrefs = new Map([ + ["signon.SignonFileName", "signons.txt"], + ["signon.SignonFileName2", "signons2.txt"], + ["signon.SignonFileName3", "signons3.txt"] + ]); + const toDeletes = new Set(); + + for (let [pref, val] of defaultSignonFilePrefs.entries()) { + toDeletes.add(Path.join(profileDir, val)); + + try { + let signonFile = Services.prefs.getCharPref(pref); + + toDeletes.add(Path.join(profileDir, signonFile)); + Services.prefs.clearUserPref(pref); + } catch (e) {} + } + + for (let file of toDeletes) { + File.remove(file); + } + }, + + /** + * Returns true if the user has a master password set and false otherwise. + */ + isMasterPasswordSet() { + let secmodDB = Cc["@mozilla.org/security/pkcs11moduledb;1"]. + getService(Ci.nsIPKCS11ModuleDB); + let slot = secmodDB.findSlotByName(""); + if (!slot) { + return false; + } + let hasMP = slot.status != Ci.nsIPKCS11Slot.SLOT_UNINITIALIZED && + slot.status != Ci.nsIPKCS11Slot.SLOT_READY; + return hasMP; + }, + + /** + * Send a notification when stored data is changed. + */ + notifyStorageChanged(changeType, data) { + let dataObject = data; + // Can't pass a raw JS string or array though notifyObservers(). :-( + if (Array.isArray(data)) { + dataObject = Cc["@mozilla.org/array;1"]. + createInstance(Ci.nsIMutableArray); + for (let i = 0; i < data.length; i++) { + dataObject.appendElement(data[i], false); + } + } else if (typeof(data) == "string") { + dataObject = Cc["@mozilla.org/supports-string;1"]. + createInstance(Ci.nsISupportsString); + dataObject.data = data; + } + Services.obs.notifyObservers(dataObject, "passwordmgr-storage-changed", changeType); + } +}; + +XPCOMUtils.defineLazyGetter(this, "log", () => { + let logger = LoginHelper.createLogger("LoginHelper"); + return logger; +}); diff --git a/toolkit/components/passwordmgr/LoginImport.jsm b/toolkit/components/passwordmgr/LoginImport.jsm new file mode 100644 index 000000000..a1d5c988a --- /dev/null +++ b/toolkit/components/passwordmgr/LoginImport.jsm @@ -0,0 +1,173 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Provides an object that has a method to import login-related data from the + * previous SQLite storage format. + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = [ + "LoginImport", +]; + +// Globals + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; +const Cr = Components.results; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Sqlite", + "resource://gre/modules/Sqlite.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); + +// LoginImport + +/** + * Provides an object that has a method to import login-related data from the + * previous SQLite storage format. + * + * @param aStore + * LoginStore object where imported data will be added. + * @param aPath + * String containing the file path of the SQLite login database. + */ +this.LoginImport = function (aStore, aPath) { + this.store = aStore; + this.path = aPath; +}; + +this.LoginImport.prototype = { + /** + * LoginStore object where imported data will be added. + */ + store: null, + + /** + * String containing the file path of the SQLite login database. + */ + path: null, + + /** + * Imports login-related data from the previous SQLite storage format. + */ + import: Task.async(function* () { + // We currently migrate data directly from the database to the JSON store at + // first run, then we set a preference to prevent repeating the import. + // Thus, merging with existing data is not a use case we support. This + // restriction might be removed to support re-importing passwords set by an + // old version by flipping the import preference and restarting. + if (this.store.data.logins.length > 0 || + this.store.data.disabledHosts.length > 0) { + throw new Error("Unable to import saved passwords because some data " + + "has already been imported or saved."); + } + + // When a timestamp is not specified, we will use the same reference time. + let referenceTimeMs = Date.now(); + + let connection = yield Sqlite.openConnection({ path: this.path }); + try { + let schemaVersion = yield connection.getSchemaVersion(); + + // We support importing database schema versions from 3 onwards. + // Version 3 was implemented in bug 316084 (Firefox 3.6, March 2009). + // Version 4 was implemented in bug 465636 (Firefox 4, March 2010). + // Version 5 was implemented in bug 718817 (Firefox 13, February 2012). + if (schemaVersion < 3) { + throw new Error("Unable to import saved passwords because " + + "the existing profile is too old."); + } + + let rows = yield connection.execute("SELECT * FROM moz_logins"); + for (let row of rows) { + try { + let hostname = row.getResultByName("hostname"); + let httpRealm = row.getResultByName("httpRealm"); + let formSubmitURL = row.getResultByName("formSubmitURL"); + let usernameField = row.getResultByName("usernameField"); + let passwordField = row.getResultByName("passwordField"); + let encryptedUsername = row.getResultByName("encryptedUsername"); + let encryptedPassword = row.getResultByName("encryptedPassword"); + + // The "guid" field was introduced in schema version 2, and the + // "enctype" field was introduced in schema version 3. We don't + // support upgrading from older versions of the database. + let guid = row.getResultByName("guid"); + let encType = row.getResultByName("encType"); + + // The time and count fields were introduced in schema version 4. + let timeCreated = null; + let timeLastUsed = null; + let timePasswordChanged = null; + let timesUsed = null; + try { + timeCreated = row.getResultByName("timeCreated"); + timeLastUsed = row.getResultByName("timeLastUsed"); + timePasswordChanged = row.getResultByName("timePasswordChanged"); + timesUsed = row.getResultByName("timesUsed"); + } catch (ex) { } + + // These columns may be null either because they were not present in + // the database or because the record was created on a new schema + // version by an old application version. + if (!timeCreated) { + timeCreated = referenceTimeMs; + } + if (!timeLastUsed) { + timeLastUsed = referenceTimeMs; + } + if (!timePasswordChanged) { + timePasswordChanged = referenceTimeMs; + } + if (!timesUsed) { + timesUsed = 1; + } + + this.store.data.logins.push({ + id: this.store.data.nextId++, + hostname: hostname, + httpRealm: httpRealm, + formSubmitURL: formSubmitURL, + usernameField: usernameField, + passwordField: passwordField, + encryptedUsername: encryptedUsername, + encryptedPassword: encryptedPassword, + guid: guid, + encType: encType, + timeCreated: timeCreated, + timeLastUsed: timeLastUsed, + timePasswordChanged: timePasswordChanged, + timesUsed: timesUsed, + }); + } catch (ex) { + Cu.reportError("Error importing login: " + ex); + } + } + + rows = yield connection.execute("SELECT * FROM moz_disabledHosts"); + for (let row of rows) { + try { + let hostname = row.getResultByName("hostname"); + + this.store.data.disabledHosts.push(hostname); + } catch (ex) { + Cu.reportError("Error importing disabled host: " + ex); + } + } + } finally { + yield connection.close(); + } + }), +}; diff --git a/toolkit/components/passwordmgr/LoginManagerContent.jsm b/toolkit/components/passwordmgr/LoginManagerContent.jsm new file mode 100644 index 000000000..60805530d --- /dev/null +++ b/toolkit/components/passwordmgr/LoginManagerContent.jsm @@ -0,0 +1,1619 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = [ "LoginManagerContent", + "LoginFormFactory", + "UserAutoCompleteResult" ]; + +const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; +const PASSWORD_INPUT_ADDED_COALESCING_THRESHOLD_MS = 1; +const AUTOCOMPLETE_AFTER_CONTEXTMENU_THRESHOLD_MS = 250; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/PrivateBrowsingUtils.jsm"); +Cu.import("resource://gre/modules/InsecurePasswordUtils.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://gre/modules/Timer.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "DeferredTask", "resource://gre/modules/DeferredTask.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "FormLikeFactory", + "resource://gre/modules/FormLikeFactory.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "LoginRecipesContent", + "resource://gre/modules/LoginRecipes.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper", + "resource://gre/modules/LoginHelper.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "InsecurePasswordUtils", + "resource://gre/modules/InsecurePasswordUtils.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "gNetUtil", + "@mozilla.org/network/util;1", + "nsINetUtil"); + +XPCOMUtils.defineLazyGetter(this, "log", () => { + let logger = LoginHelper.createLogger("LoginManagerContent"); + return logger.log.bind(logger); +}); + +// These mirror signon.* prefs. +var gEnabled, gAutofillForms, gStoreWhenAutocompleteOff; +var gLastContextMenuEventTimeStamp = Number.NEGATIVE_INFINITY; + +var observer = { + QueryInterface : XPCOMUtils.generateQI([Ci.nsIObserver, + Ci.nsIFormSubmitObserver, + Ci.nsIWebProgressListener, + Ci.nsIDOMEventListener, + Ci.nsISupportsWeakReference]), + + // nsIFormSubmitObserver + notify(formElement, aWindow, actionURI) { + log("observer notified for form submission."); + + // We're invoked before the content's |onsubmit| handlers, so we + // can grab form data before it might be modified (see bug 257781). + + try { + let formLike = LoginFormFactory.createFromForm(formElement); + LoginManagerContent._onFormSubmit(formLike); + } catch (e) { + log("Caught error in onFormSubmit(", e.lineNumber, "):", e.message); + Cu.reportError(e); + } + + return true; // Always return true, or form submit will be canceled. + }, + + onPrefChange() { + gEnabled = Services.prefs.getBoolPref("signon.rememberSignons"); + gAutofillForms = Services.prefs.getBoolPref("signon.autofillForms"); + gStoreWhenAutocompleteOff = Services.prefs.getBoolPref("signon.storeWhenAutocompleteOff"); + }, + + // nsIWebProgressListener + onLocationChange(aWebProgress, aRequest, aLocation, aFlags) { + // Only handle pushState/replaceState here. + if (!(aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) || + !(aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_PUSHSTATE)) { + return; + } + + log("onLocationChange handled:", aLocation.spec, aWebProgress.DOMWindow.document); + + LoginManagerContent._onNavigation(aWebProgress.DOMWindow.document); + }, + + onStateChange(aWebProgress, aRequest, aState, aStatus) { + if (!(aState & Ci.nsIWebProgressListener.STATE_START)) { + return; + } + + // We only care about when a page triggered a load, not the user. For example: + // clicking refresh/back/forward, typing a URL and hitting enter, and loading a bookmark aren't + // likely to be when a user wants to save a login. + let channel = aRequest.QueryInterface(Ci.nsIChannel); + let triggeringPrincipal = channel.loadInfo.triggeringPrincipal; + if (triggeringPrincipal.isNullPrincipal || + triggeringPrincipal.equals(Services.scriptSecurityManager.getSystemPrincipal())) { + return; + } + + // Don't handle history navigation, reload, or pushState not triggered via chrome UI. + // e.g. history.go(-1), location.reload(), history.replaceState() + if (!(aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_NORMAL)) { + log("onStateChange: loadType isn't LOAD_CMD_NORMAL:", aWebProgress.loadType); + return; + } + + log("onStateChange handled:", channel); + LoginManagerContent._onNavigation(aWebProgress.DOMWindow.document); + }, + + handleEvent(aEvent) { + if (!aEvent.isTrusted) { + return; + } + + if (!gEnabled) { + return; + } + + switch (aEvent.type) { + // Only used for username fields. + case "focus": { + LoginManagerContent._onUsernameFocus(aEvent); + break; + } + + case "contextmenu": { + gLastContextMenuEventTimeStamp = Date.now(); + break; + } + + default: { + throw new Error("Unexpected event"); + } + } + }, +}; + +Services.obs.addObserver(observer, "earlyformsubmit", false); +var prefBranch = Services.prefs.getBranch("signon."); +prefBranch.addObserver("", observer.onPrefChange, false); + +observer.onPrefChange(); // read initial values + + +function messageManagerFromWindow(win) { + return win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell) + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIContentFrameMessageManager); +} + +// This object maps to the "child" process (even in the single-process case). +var LoginManagerContent = { + + __formFillService : null, // FormFillController, for username autocompleting + get _formFillService() { + if (!this.__formFillService) + this.__formFillService = + Cc["@mozilla.org/satchel/form-fill-controller;1"]. + getService(Ci.nsIFormFillController); + return this.__formFillService; + }, + + _getRandomId() { + return Cc["@mozilla.org/uuid-generator;1"] + .getService(Ci.nsIUUIDGenerator).generateUUID().toString(); + }, + + _messages: [ "RemoteLogins:loginsFound", + "RemoteLogins:loginsAutoCompleted" ], + + /** + * WeakMap of the root element of a FormLike to the FormLike representing its fields. + * + * This is used to be able to lookup an existing FormLike for a given root element since multiple + * calls to LoginFormFactory won't give the exact same object. When batching fills we don't always + * want to use the most recent list of elements for a FormLike since we may end up doing multiple + * fills for the same set of elements when a field gets added between arming and running the + * DeferredTask. + * + * @type {WeakMap} + */ + _formLikeByRootElement: new WeakMap(), + + /** + * WeakMap of the root element of a WeakMap to the DeferredTask to fill its fields. + * + * This is used to be able to throttle fills for a FormLike since onDOMInputPasswordAdded gets + * dispatched for each password field added to a document but we only want to fill once per + * FormLike when multiple fields are added at once. + * + * @type {WeakMap} + */ + _deferredPasswordAddedTasksByRootElement: new WeakMap(), + + // Map from form login requests to information about that request. + _requests: new Map(), + + // Number of outstanding requests to each manager. + _managers: new Map(), + + _takeRequest(msg) { + let data = msg.data; + let request = this._requests.get(data.requestId); + + this._requests.delete(data.requestId); + + let count = this._managers.get(msg.target); + if (--count === 0) { + this._managers.delete(msg.target); + + for (let message of this._messages) + msg.target.removeMessageListener(message, this); + } else { + this._managers.set(msg.target, count); + } + + return request; + }, + + _sendRequest(messageManager, requestData, + name, messageData) { + let count; + if (!(count = this._managers.get(messageManager))) { + this._managers.set(messageManager, 1); + + for (let message of this._messages) + messageManager.addMessageListener(message, this); + } else { + this._managers.set(messageManager, ++count); + } + + let requestId = this._getRandomId(); + messageData.requestId = requestId; + + messageManager.sendAsyncMessage(name, messageData); + + let deferred = Promise.defer(); + requestData.promise = deferred; + this._requests.set(requestId, requestData); + return deferred.promise; + }, + + receiveMessage(msg, window) { + if (msg.name == "RemoteLogins:fillForm") { + this.fillForm({ + topDocument: window.document, + loginFormOrigin: msg.data.loginFormOrigin, + loginsFound: LoginHelper.vanillaObjectsToLogins(msg.data.logins), + recipes: msg.data.recipes, + inputElement: msg.objects.inputElement, + }); + return; + } + + let request = this._takeRequest(msg); + switch (msg.name) { + case "RemoteLogins:loginsFound": { + let loginsFound = LoginHelper.vanillaObjectsToLogins(msg.data.logins); + request.promise.resolve({ + form: request.form, + loginsFound: loginsFound, + recipes: msg.data.recipes, + }); + break; + } + + case "RemoteLogins:loginsAutoCompleted": { + let loginsFound = + LoginHelper.vanillaObjectsToLogins(msg.data.logins); + // If we're in the parent process, don't pass a message manager so our + // autocomplete result objects know they can remove the login from the + // login manager directly. + let messageManager = + (Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_CONTENT) ? + msg.target : undefined; + request.promise.resolve({ logins: loginsFound, messageManager }); + break; + } + } + }, + + /** + * Get relevant logins and recipes from the parent + * + * @param {HTMLFormElement} form - form to get login data for + * @param {Object} options + * @param {boolean} options.showMasterPassword - whether to show a master password prompt + */ + _getLoginDataFromParent(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, + options: options }; + + return this._sendRequest(messageManager, requestData, + "RemoteLogins:findLogins", + messageData); + }, + + _autoCompleteSearchAsync(aSearchString, aPreviousResult, + aElement, aRect) { + let doc = aElement.ownerDocument; + let form = LoginFormFactory.createFromField(aElement); + let win = doc.defaultView; + + let formOrigin = LoginUtils._getPasswordOrigin(doc.documentURI); + let actionOrigin = LoginUtils._getActionOrigin(form); + + let messageManager = messageManagerFromWindow(win); + + let remote = (Services.appinfo.processType === + Services.appinfo.PROCESS_TYPE_CONTENT); + + let previousResult = aPreviousResult ? + { searchString: aPreviousResult.searchString, + logins: LoginHelper.loginsToVanillaObjects(aPreviousResult.logins) } : + null; + + let requestData = {}; + let messageData = { formOrigin: formOrigin, + actionOrigin: actionOrigin, + searchString: aSearchString, + previousResult: previousResult, + rect: aRect, + isSecure: InsecurePasswordUtils.isFormSecure(form), + isPasswordField: aElement.type == "password", + remote: remote }; + + return this._sendRequest(messageManager, requestData, + "RemoteLogins:autoCompleteLogins", + messageData); + }, + + setupProgressListener(window) { + if (!LoginHelper.formlessCaptureEnabled) { + return; + } + + try { + let webProgress = window.QueryInterface(Ci.nsIInterfaceRequestor). + getInterface(Ci.nsIWebNavigation). + QueryInterface(Ci.nsIDocShell). + QueryInterface(Ci.nsIInterfaceRequestor). + getInterface(Ci.nsIWebProgress); + webProgress.addProgressListener(observer, + Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT | + Ci.nsIWebProgress.NOTIFY_LOCATION); + } catch (ex) { + // Ignore NS_ERROR_FAILURE if the progress listener was already added + } + }, + + onDOMFormHasPassword(event, window) { + if (!event.isTrusted) { + return; + } + + let form = event.target; + let formLike = LoginFormFactory.createFromForm(form); + log("onDOMFormHasPassword:", form, formLike); + this._fetchLoginsFromParentAndFillForm(formLike, window); + }, + + onDOMInputPasswordAdded(event, window) { + if (!event.isTrusted) { + return; + } + + let pwField = event.target; + if (pwField.form) { + // Fill is handled by onDOMFormHasPassword which is already throttled. + return; + } + + // Only setup the listener for formless inputs. + // Capture within a <form> but without a submit event is bug 1287202. + this.setupProgressListener(window); + + let formLike = LoginFormFactory.createFromField(pwField); + log("onDOMInputPasswordAdded:", pwField, formLike); + + let deferredTask = this._deferredPasswordAddedTasksByRootElement.get(formLike.rootElement); + if (!deferredTask) { + log("Creating a DeferredTask to call _fetchLoginsFromParentAndFillForm soon"); + this._formLikeByRootElement.set(formLike.rootElement, formLike); + + deferredTask = new DeferredTask(function* deferredInputProcessing() { + // Get the updated formLike instead of the one at the time of creating the DeferredTask via + // a closure since it could be stale since FormLike.elements isn't live. + let formLike2 = this._formLikeByRootElement.get(formLike.rootElement); + log("Running deferred processing of onDOMInputPasswordAdded", formLike2); + this._deferredPasswordAddedTasksByRootElement.delete(formLike2.rootElement); + this._fetchLoginsFromParentAndFillForm(formLike2, window); + }.bind(this), PASSWORD_INPUT_ADDED_COALESCING_THRESHOLD_MS); + + this._deferredPasswordAddedTasksByRootElement.set(formLike.rootElement, deferredTask); + } + + if (deferredTask.isArmed) { + log("DeferredTask is already armed so just updating the FormLike"); + // We update the FormLike so it (most important .elements) is fresh when the task eventually + // runs since changes to the elements could affect our field heuristics. + this._formLikeByRootElement.set(formLike.rootElement, formLike); + } else if (window.document.readyState == "complete") { + log("Arming the DeferredTask we just created since document.readyState == 'complete'"); + deferredTask.arm(); + } else { + window.addEventListener("DOMContentLoaded", function armPasswordAddedTask() { + window.removeEventListener("DOMContentLoaded", armPasswordAddedTask); + log("Arming the onDOMInputPasswordAdded DeferredTask due to DOMContentLoaded"); + deferredTask.arm(); + }); + } + }, + + /** + * Fetch logins from the parent for a given form and then attempt to fill it. + * + * @param {FormLike} form to fetch the logins for then try autofill. + * @param {Window} window + */ + _fetchLoginsFromParentAndFillForm(form, window) { + this._detectInsecureFormLikes(window); + + let messageManager = messageManagerFromWindow(window); + messageManager.sendAsyncMessage("LoginStats:LoginEncountered"); + + if (!gEnabled) { + return; + } + + this._getLoginDataFromParent(form, { showMasterPassword: true }) + .then(this.loginsFound.bind(this)) + .then(null, Cu.reportError); + }, + + onPageShow(event, window) { + this._detectInsecureFormLikes(window); + }, + + /** + * Maps all DOM content documents in this content process, including those in + * frames, to the current state used by the Login Manager. + */ + loginFormStateByDocument: new WeakMap(), + + /** + * Retrieves a reference to the state object associated with the given + * document. This is initialized to an object with default values. + */ + stateForDocument(document) { + let loginFormState = this.loginFormStateByDocument.get(document); + if (!loginFormState) { + loginFormState = { + /** + * Keeps track of filled fields and values. + */ + fillsByRootElement: new WeakMap(), + loginFormRootElements: new Set(), + }; + this.loginFormStateByDocument.set(document, loginFormState); + } + return loginFormState; + }, + + /** + * Compute whether there is an insecure login form on any frame of the current page, and + * notify the parent process. This is used to control whether insecure password UI appears. + */ + _detectInsecureFormLikes(topWindow) { + log("_detectInsecureFormLikes", topWindow.location.href); + + // Returns true if this window or any subframes have insecure login forms. + let hasInsecureLoginForms = (thisWindow) => { + let doc = thisWindow.document; + let hasLoginForm = this.stateForDocument(doc).loginFormRootElements.size > 0; + // Ignores window.opener, because it's not relevant for indicating + // form security. See InsecurePasswordUtils docs for details. + return (hasLoginForm && !thisWindow.isSecureContextIfOpenerIgnored) || + Array.some(thisWindow.frames, + frame => hasInsecureLoginForms(frame)); + }; + + let messageManager = messageManagerFromWindow(topWindow); + messageManager.sendAsyncMessage("RemoteLogins:insecureLoginFormPresent", { + hasInsecureLoginForms: hasInsecureLoginForms(topWindow), + }); + }, + + /** + * Perform a password fill upon user request coming from the parent process. + * The fill will be in the form previously identified during page navigation. + * + * @param An object with the following properties: + * { + * topDocument: + * DOM document currently associated to the the top-level window + * for which the fill is requested. This may be different from the + * document that originally caused the login UI to be displayed. + * loginFormOrigin: + * String with the origin for which the login UI was displayed. + * This must match the origin of the form used for the fill. + * loginsFound: + * Array containing the login to fill. While other messages may + * have more logins, for this use case this is expected to have + * exactly one element. The origin of the login may be different + * from the origin of the form used for the fill. + * recipes: + * Fill recipes transmitted together with the original message. + * inputElement: + * Username or password input element from the form we want to fill. + * } + */ + fillForm({ topDocument, loginFormOrigin, loginsFound, recipes, inputElement }) { + if (!inputElement) { + log("fillForm: No input element specified"); + return; + } + if (LoginUtils._getPasswordOrigin(topDocument.documentURI) != loginFormOrigin) { + if (!inputElement || + LoginUtils._getPasswordOrigin(inputElement.ownerDocument.documentURI) != loginFormOrigin) { + log("fillForm: The requested origin doesn't match the one form the", + "document. This may mean we navigated to a document from a different", + "site before we had a chance to indicate this change in the user", + "interface."); + return; + } + } + + let clobberUsername = true; + let options = { + inputElement, + }; + + let form = LoginFormFactory.createFromField(inputElement); + if (inputElement.type == "password") { + clobberUsername = false; + } + this._fillForm(form, true, clobberUsername, true, true, loginsFound, recipes, options); + }, + + loginsFound({ form, loginsFound, recipes }) { + let doc = form.ownerDocument; + let autofillForm = gAutofillForms && !PrivateBrowsingUtils.isContentWindowPrivate(doc.defaultView); + + this._fillForm(form, autofillForm, false, false, false, loginsFound, recipes); + }, + + /** + * Focus event handler for username fields to decide whether to show autocomplete. + * @param {FocusEvent} event + */ + _onUsernameFocus(event) { + let focusedField = event.target; + if (!focusedField.mozIsTextField(true) || focusedField.readOnly) { + return; + } + + if (this._isLoginAlreadyFilled(focusedField)) { + log("_onUsernameFocus: Already filled"); + return; + } + + /* + * A `focus` event is fired before a `contextmenu` event if a user right-clicks into an + * unfocused field. In that case we don't want to show both autocomplete and a context menu + * overlapping so we spin the event loop to see if a `contextmenu` event is coming next. If no + * `contextmenu` event was seen and the focused field is still focused by the form fill + * controller then show the autocomplete popup. + */ + let timestamp = Date.now(); + setTimeout(function maybeOpenAutocompleteAfterFocus() { + // Even though the `focus` event happens first, its .timeStamp is greater in + // testing and I don't want to rely on that so the absolute value is used. + let timeDiff = Math.abs(gLastContextMenuEventTimeStamp - timestamp); + if (timeDiff < AUTOCOMPLETE_AFTER_CONTEXTMENU_THRESHOLD_MS) { + log("Not opening autocomplete after focus since a context menu was opened within", + timeDiff, "ms"); + return; + } + + if (this._formFillService.focusedInput == focusedField) { + log("maybeOpenAutocompleteAfterFocus: Opening the autocomplete popup. Time diff:", timeDiff); + this._formFillService.showPopup(); + } else { + log("maybeOpenAutocompleteAfterFocus: FormFillController has a different focused input"); + } + }.bind(this), 0); + }, + + /** + * Listens for DOMAutoComplete and blur events on an input field. + */ + onUsernameInput(event) { + if (!event.isTrusted) + return; + + if (!gEnabled) + return; + + var acInputField = event.target; + + // This is probably a bit over-conservatative. + if (!(acInputField.ownerDocument instanceof Ci.nsIDOMHTMLDocument)) + return; + + if (!LoginHelper.isUsernameFieldType(acInputField)) + return; + + var acForm = LoginFormFactory.createFromField(acInputField); + if (!acForm) + return; + + // If the username is blank, bail out now -- we don't want + // fillForm() to try filling in a login without a username + // to filter on (bug 471906). + if (!acInputField.value) + return; + + log("onUsernameInput from", event.type); + + let doc = acForm.ownerDocument; + let messageManager = messageManagerFromWindow(doc.defaultView); + let recipes = messageManager.sendSyncMessage("RemoteLogins:findRecipes", { + formOrigin: LoginUtils._getPasswordOrigin(doc.documentURI), + })[0]; + + // Make sure the username field fillForm will use is the + // same field as the autocomplete was activated on. + var [usernameField, passwordField, ignored] = + this._getFormFields(acForm, false, recipes); + if (usernameField == acInputField && passwordField) { + this._getLoginDataFromParent(acForm, { showMasterPassword: false }) + .then(({ form, loginsFound, recipes }) => { + this._fillForm(form, true, false, true, true, loginsFound, recipes); + }) + .then(null, Cu.reportError); + } else { + // Ignore the event, it's for some input we don't care about. + } + }, + + /** + * @param {FormLike} form - the FormLike to look for password fields in. + * @param {bool} [skipEmptyFields=false] - Whether to ignore password fields with no value. + * Used at capture time since saving empty values isn't + * useful. + * @return {Array|null} Array of password field elements for the specified form. + * If no pw fields are found, or if more than 3 are found, then null + * is returned. + */ + _getPasswordFields(form, skipEmptyFields = false) { + // Locate the password fields in the form. + let pwFields = []; + for (let i = 0; i < form.elements.length; i++) { + let element = form.elements[i]; + if (!(element instanceof Ci.nsIDOMHTMLInputElement) || + element.type != "password") { + continue; + } + + if (skipEmptyFields && !element.value.trim()) { + continue; + } + + pwFields[pwFields.length] = { + index : i, + element : element + }; + } + + // If too few or too many fields, bail out. + if (pwFields.length == 0) { + log("(form ignored -- no password fields.)"); + return null; + } else if (pwFields.length > 3) { + log("(form ignored -- too many password fields. [ got ", pwFields.length, "])"); + return null; + } + + return pwFields; + }, + + /** + * Returns the username and password fields found in the form. + * Can handle complex forms by trying to figure out what the + * relevant fields are. + * + * @param {FormLike} form + * @param {bool} isSubmission + * @param {Set} recipes + * @return {Array} [usernameField, newPasswordField, oldPasswordField] + * + * usernameField may be null. + * newPasswordField will always be non-null. + * oldPasswordField may be null. If null, newPasswordField is just + * "theLoginField". If not null, the form is apparently a + * change-password field, with oldPasswordField containing the password + * that is being changed. + * + * Note that even though we can create a FormLike from a text field, + * this method will only return a non-null usernameField if the + * FormLike has a password field. + */ + _getFormFields(form, isSubmission, recipes) { + var usernameField = null; + var pwFields = null; + var fieldOverrideRecipe = LoginRecipesContent.getFieldOverrides(recipes, form); + if (fieldOverrideRecipe) { + var pwOverrideField = LoginRecipesContent.queryLoginField( + form, + fieldOverrideRecipe.passwordSelector + ); + if (pwOverrideField) { + // The field from the password override may be in a different FormLike. + let formLike = LoginFormFactory.createFromField(pwOverrideField); + pwFields = [{ + index : [...formLike.elements].indexOf(pwOverrideField), + element : pwOverrideField, + }]; + } + + var usernameOverrideField = LoginRecipesContent.queryLoginField( + form, + fieldOverrideRecipe.usernameSelector + ); + if (usernameOverrideField) { + usernameField = usernameOverrideField; + } + } + + if (!pwFields) { + // Locate the password field(s) in the form. Up to 3 supported. + // If there's no password field, there's nothing for us to do. + pwFields = this._getPasswordFields(form, isSubmission); + } + + if (!pwFields) { + return [null, null, null]; + } + + if (!usernameField) { + // Locate the username field in the form by searching backwards + // from the first password field, assume the first text field is the + // username. We might not find a username field if the user is + // already logged in to the site. + for (var i = pwFields[0].index - 1; i >= 0; i--) { + var element = form.elements[i]; + if (!LoginHelper.isUsernameFieldType(element)) { + continue; + } + + if (fieldOverrideRecipe && fieldOverrideRecipe.notUsernameSelector && + element.matches(fieldOverrideRecipe.notUsernameSelector)) { + continue; + } + + usernameField = element; + break; + } + } + + if (!usernameField) + log("(form -- no username field found)"); + else + log("Username field ", usernameField, "has name/value:", + usernameField.name, "/", usernameField.value); + + // If we're not submitting a form (it's a page load), there are no + // password field values for us to use for identifying fields. So, + // just assume the first password field is the one to be filled in. + if (!isSubmission || pwFields.length == 1) { + var passwordField = pwFields[0].element; + log("Password field", passwordField, "has name: ", passwordField.name); + return [usernameField, passwordField, null]; + } + + + // Try to figure out WTF is in the form based on the password values. + var oldPasswordField, newPasswordField; + var pw1 = pwFields[0].element.value; + var pw2 = pwFields[1].element.value; + var pw3 = (pwFields[2] ? pwFields[2].element.value : null); + + if (pwFields.length == 3) { + // Look for two identical passwords, that's the new password + + if (pw1 == pw2 && pw2 == pw3) { + // All 3 passwords the same? Weird! Treat as if 1 pw field. + newPasswordField = pwFields[0].element; + oldPasswordField = null; + } else if (pw1 == pw2) { + newPasswordField = pwFields[0].element; + oldPasswordField = pwFields[2].element; + } else if (pw2 == pw3) { + oldPasswordField = pwFields[0].element; + newPasswordField = pwFields[2].element; + } else if (pw1 == pw3) { + // A bit odd, but could make sense with the right page layout. + newPasswordField = pwFields[0].element; + oldPasswordField = pwFields[1].element; + } else { + // We can't tell which of the 3 passwords should be saved. + log("(form ignored -- all 3 pw fields differ)"); + return [null, null, null]; + } + } else if (pw1 == pw2) { + // pwFields.length == 2 + // Treat as if 1 pw field + newPasswordField = pwFields[0].element; + oldPasswordField = null; + } else { + // Just assume that the 2nd password is the new password + oldPasswordField = pwFields[0].element; + newPasswordField = pwFields[1].element; + } + + log("Password field (new) id/name is: ", newPasswordField.id, " / ", newPasswordField.name); + if (oldPasswordField) { + log("Password field (old) id/name is: ", oldPasswordField.id, " / ", oldPasswordField.name); + } else { + log("Password field (old):", oldPasswordField); + } + return [usernameField, newPasswordField, oldPasswordField]; + }, + + + /** + * @return true if the page requests autocomplete be disabled for the + * specified element. + */ + _isAutocompleteDisabled(element) { + return element && element.autocomplete == "off"; + }, + + /** + * Trigger capture on any relevant FormLikes due to a navigation alone (not + * necessarily due to an actual form submission). This method is used to + * capture logins for cases where form submit events are not used. + * + * To avoid multiple notifications for the same FormLike, this currently + * avoids capturing when dealing with a real <form> which are ideally already + * using a submit event. + * + * @param {Document} document being navigated + */ + _onNavigation(aDocument) { + let state = this.stateForDocument(aDocument); + let loginFormRootElements = state.loginFormRootElements; + log("_onNavigation: state:", state, "loginFormRootElements size:", loginFormRootElements.size, + "document:", aDocument); + + for (let formRoot of state.loginFormRootElements) { + if (formRoot instanceof Ci.nsIDOMHTMLFormElement) { + // For now only perform capture upon navigation for FormLike's without + // a <form> to avoid capture from both an earlyformsubmit and + // navigation for the same "form". + log("Ignoring navigation for the form root to avoid multiple prompts " + + "since it was for a real <form>"); + continue; + } + let formLike = this._formLikeByRootElement.get(formRoot); + this._onFormSubmit(formLike); + } + }, + + /** + * Called by our observer when notified of a form submission. + * [Note that this happens before any DOM onsubmit handlers are invoked.] + * Looks for a password change in the submitted form, so we can update + * our stored password. + * + * @param {FormLike} form + */ + _onFormSubmit(form) { + log("_onFormSubmit", form); + var doc = form.ownerDocument; + var win = doc.defaultView; + + if (PrivateBrowsingUtils.isContentWindowPrivate(win)) { + // We won't do anything in private browsing mode anyway, + // so there's no need to perform further checks. + log("(form submission ignored in private browsing mode)"); + return; + } + + // If password saving is disabled (globally or for host), bail out now. + if (!gEnabled) + return; + + var hostname = LoginUtils._getPasswordOrigin(doc.documentURI); + if (!hostname) { + log("(form submission ignored -- invalid hostname)"); + return; + } + + let formSubmitURL = LoginUtils._getActionOrigin(form); + let messageManager = messageManagerFromWindow(win); + + let recipes = messageManager.sendSyncMessage("RemoteLogins:findRecipes", { + formOrigin: hostname, + })[0]; + + // Get the appropriate fields from the form. + var [usernameField, newPasswordField, oldPasswordField] = + this._getFormFields(form, true, recipes); + + // Need at least 1 valid password field to do anything. + if (newPasswordField == null) + return; + + // Check for autocomplete=off attribute. We don't use it to prevent + // autofilling (for existing logins), but won't save logins when it's + // present and the storeWhenAutocompleteOff pref is false. + // XXX spin out a bug that we don't update timeLastUsed in this case? + if ((this._isAutocompleteDisabled(form) || + this._isAutocompleteDisabled(usernameField) || + this._isAutocompleteDisabled(newPasswordField) || + this._isAutocompleteDisabled(oldPasswordField)) && + !gStoreWhenAutocompleteOff) { + log("(form submission ignored -- autocomplete=off found)"); + return; + } + + // Don't try to send DOM nodes over IPC. + let mockUsername = usernameField ? + { name: usernameField.name, + value: usernameField.value } : + null; + let mockPassword = { name: newPasswordField.name, + value: newPasswordField.value }; + let mockOldPassword = oldPasswordField ? + { name: oldPasswordField.name, + value: oldPasswordField.value } : + null; + + // Make sure to pass the opener's top in case it was in a frame. + let openerTopWindow = win.opener ? win.opener.top : null; + + messageManager.sendAsyncMessage("RemoteLogins:onFormSubmit", + { hostname: hostname, + formSubmitURL: formSubmitURL, + usernameField: mockUsername, + newPasswordField: mockPassword, + oldPasswordField: mockOldPassword }, + { openerTopWindow }); + }, + + /** + * Attempt to find the username and password fields in a form, and fill them + * in using the provided logins and recipes. + * + * @param {LoginForm} form + * @param {bool} autofillForm denotes if we should fill the form in automatically + * @param {bool} clobberUsername controls if an existing username can be overwritten. + * If this is false and an inputElement of type password + * is also passed, the username field will be ignored. + * If this is false and no inputElement is passed, if the username + * field value is not found in foundLogins, it will not fill the password. + * @param {bool} clobberPassword controls if an existing password value can be + * overwritten + * @param {bool} userTriggered is an indication of whether this filling was triggered by + * the user + * @param {nsILoginInfo[]} foundLogins is an array of nsILoginInfo that could be used for the form + * @param {Set} recipes that could be used to affect how the form is filled + * @param {Object} [options = {}] is a list of options for this method. + - [inputElement] is an optional target input element we want to fill + */ + _fillForm(form, autofillForm, clobberUsername, clobberPassword, + userTriggered, foundLogins, recipes, {inputElement} = {}) { + if (form instanceof Ci.nsIDOMHTMLFormElement) { + throw new Error("_fillForm should only be called with FormLike objects"); + } + + log("_fillForm", form.elements); + let ignoreAutocomplete = true; + // Will be set to one of AUTOFILL_RESULT in the `try` block. + let autofillResult = -1; + const AUTOFILL_RESULT = { + FILLED: 0, + NO_PASSWORD_FIELD: 1, + PASSWORD_DISABLED_READONLY: 2, + NO_LOGINS_FIT: 3, + NO_SAVED_LOGINS: 4, + EXISTING_PASSWORD: 5, + EXISTING_USERNAME: 6, + MULTIPLE_LOGINS: 7, + NO_AUTOFILL_FORMS: 8, + AUTOCOMPLETE_OFF: 9, + INSECURE: 10, + }; + + try { + // Nothing to do if we have no matching logins available, + // and there isn't a need to show the insecure form warning. + if (foundLogins.length == 0 && + (InsecurePasswordUtils.isFormSecure(form) || + !LoginHelper.showInsecureFieldWarning)) { + // We don't log() here since this is a very common case. + autofillResult = AUTOFILL_RESULT.NO_SAVED_LOGINS; + return; + } + + // Heuristically determine what the user/pass fields are + // We do this before checking to see if logins are stored, + // so that the user isn't prompted for a master password + // without need. + var [usernameField, passwordField, ignored] = + this._getFormFields(form, false, recipes); + + // If we have a password inputElement parameter and it's not + // the same as the one heuristically found, use the parameter + // one instead. + if (inputElement) { + if (inputElement.type == "password") { + passwordField = inputElement; + if (!clobberUsername) { + usernameField = null; + } + } else if (LoginHelper.isUsernameFieldType(inputElement)) { + usernameField = inputElement; + } else { + throw new Error("Unexpected input element type."); + } + } + + // Need a valid password field to do anything. + if (passwordField == null) { + log("not filling form, no password field found"); + autofillResult = AUTOFILL_RESULT.NO_PASSWORD_FIELD; + return; + } + + // If the password field is disabled or read-only, there's nothing to do. + if (passwordField.disabled || passwordField.readOnly) { + log("not filling form, password field disabled or read-only"); + autofillResult = AUTOFILL_RESULT.PASSWORD_DISABLED_READONLY; + return; + } + + // Attach autocomplete stuff to the username field, if we have + // one. This is normally used to select from multiple accounts, + // but even with one account we should refill if the user edits. + // We would also need this attached to show the insecure login + // warning, regardless of saved login. + if (usernameField) { + this._formFillService.markAsLoginManagerField(usernameField); + } + + // Nothing to do if we have no matching logins available. + // Only insecure pages reach this block and logs the same + // telemetry flag. + if (foundLogins.length == 0) { + // We don't log() here since this is a very common case. + autofillResult = AUTOFILL_RESULT.NO_SAVED_LOGINS; + return; + } + + // Prevent autofilling insecure forms. + if (!userTriggered && !LoginHelper.insecureAutofill && + !InsecurePasswordUtils.isFormSecure(form)) { + log("not filling form since it's insecure"); + autofillResult = AUTOFILL_RESULT.INSECURE; + return; + } + + var isAutocompleteOff = false; + if (this._isAutocompleteDisabled(form) || + this._isAutocompleteDisabled(usernameField) || + this._isAutocompleteDisabled(passwordField)) { + isAutocompleteOff = true; + } + + // Discard logins which have username/password values that don't + // fit into the fields (as specified by the maxlength attribute). + // The user couldn't enter these values anyway, and it helps + // with sites that have an extra PIN to be entered (bug 391514) + var maxUsernameLen = Number.MAX_VALUE; + var maxPasswordLen = Number.MAX_VALUE; + + // If attribute wasn't set, default is -1. + if (usernameField && usernameField.maxLength >= 0) + maxUsernameLen = usernameField.maxLength; + if (passwordField.maxLength >= 0) + maxPasswordLen = passwordField.maxLength; + + var logins = foundLogins.filter(function (l) { + var fit = (l.username.length <= maxUsernameLen && + l.password.length <= maxPasswordLen); + if (!fit) + log("Ignored", l.username, "login: won't fit"); + + return fit; + }, this); + + if (logins.length == 0) { + log("form not filled, none of the logins fit in the field"); + autofillResult = AUTOFILL_RESULT.NO_LOGINS_FIT; + return; + } + + // Don't clobber an existing password. + if (passwordField.value && !clobberPassword) { + log("form not filled, the password field was already filled"); + autofillResult = AUTOFILL_RESULT.EXISTING_PASSWORD; + return; + } + + // Select a login to use for filling in the form. + var selectedLogin; + if (!clobberUsername && usernameField && (usernameField.value || + usernameField.disabled || + usernameField.readOnly)) { + // If username was specified in the field, it's disabled or it's readOnly, only fill in the + // password if we find a matching login. + var username = usernameField.value.toLowerCase(); + + let matchingLogins = logins.filter(l => + l.username.toLowerCase() == username); + if (matchingLogins.length == 0) { + log("Password not filled. None of the stored logins match the username already present."); + autofillResult = AUTOFILL_RESULT.EXISTING_USERNAME; + return; + } + + // If there are multiple, and one matches case, use it + for (let l of matchingLogins) { + if (l.username == usernameField.value) { + selectedLogin = l; + } + } + // Otherwise just use the first + if (!selectedLogin) { + selectedLogin = matchingLogins[0]; + } + } else if (logins.length == 1) { + selectedLogin = logins[0]; + } else { + // We have multiple logins. Handle a special case here, for sites + // which have a normal user+pass login *and* a password-only login + // (eg, a PIN). Prefer the login that matches the type of the form + // (user+pass or pass-only) when there's exactly one that matches. + let matchingLogins; + if (usernameField) + matchingLogins = logins.filter(l => l.username); + else + matchingLogins = logins.filter(l => !l.username); + + if (matchingLogins.length != 1) { + log("Multiple logins for form, so not filling any."); + autofillResult = AUTOFILL_RESULT.MULTIPLE_LOGINS; + return; + } + + selectedLogin = matchingLogins[0]; + } + + // We will always have a selectedLogin at this point. + + if (!autofillForm) { + log("autofillForms=false but form can be filled"); + autofillResult = AUTOFILL_RESULT.NO_AUTOFILL_FORMS; + return; + } + + if (isAutocompleteOff && !ignoreAutocomplete) { + log("Not filling the login because we're respecting autocomplete=off"); + autofillResult = AUTOFILL_RESULT.AUTOCOMPLETE_OFF; + return; + } + + // Fill the form + + if (usernameField) { + // Don't modify the username field if it's disabled or readOnly so we preserve its case. + let disabledOrReadOnly = usernameField.disabled || usernameField.readOnly; + + let userNameDiffers = selectedLogin.username != usernameField.value; + // Don't replace the username if it differs only in case, and the user triggered + // this autocomplete. We assume that if it was user-triggered the entered text + // is desired. + let userEnteredDifferentCase = userTriggered && userNameDiffers && + usernameField.value.toLowerCase() == selectedLogin.username.toLowerCase(); + + if (!disabledOrReadOnly && !userEnteredDifferentCase && userNameDiffers) { + usernameField.setUserInput(selectedLogin.username); + } + } + + let doc = form.ownerDocument; + if (passwordField.value != selectedLogin.password) { + passwordField.setUserInput(selectedLogin.password); + let autoFilledLogin = { + guid: selectedLogin.QueryInterface(Ci.nsILoginMetaInfo).guid, + username: selectedLogin.username, + usernameField: usernameField ? Cu.getWeakReference(usernameField) : null, + password: selectedLogin.password, + passwordField: Cu.getWeakReference(passwordField), + }; + log("Saving autoFilledLogin", autoFilledLogin.guid, "for", form.rootElement); + this.stateForDocument(doc).fillsByRootElement.set(form.rootElement, autoFilledLogin); + } + + log("_fillForm succeeded"); + autofillResult = AUTOFILL_RESULT.FILLED; + + let win = doc.defaultView; + let messageManager = messageManagerFromWindow(win); + messageManager.sendAsyncMessage("LoginStats:LoginFillSuccessful"); + } finally { + if (autofillResult == -1) { + // eslint-disable-next-line no-unsafe-finally + throw new Error("_fillForm: autofillResult must be specified"); + } + + if (!userTriggered) { + // Ignore fills as a result of user action for this probe. + Services.telemetry.getHistogramById("PWMGR_FORM_AUTOFILL_RESULT").add(autofillResult); + + if (usernameField) { + let focusedElement = this._formFillService.focusedInput; + if (usernameField == focusedElement && + autofillResult !== AUTOFILL_RESULT.FILLED) { + log("_fillForm: Opening username autocomplete popup since the form wasn't autofilled"); + this._formFillService.showPopup(); + } + } + } + + if (usernameField) { + log("_fillForm: Attaching event listeners to usernameField"); + usernameField.addEventListener("focus", observer); + usernameField.addEventListener("contextmenu", observer); + } + + Services.obs.notifyObservers(form.rootElement, "passwordmgr-processed-form", null); + } + }, + + /** + * Given a field, determine whether that field was last filled as a username + * field AND whether the username is still filled in with the username AND + * whether the associated password field has the matching password. + * + * @note This could possibly be unified with getFieldContext but they have + * slightly different use cases. getFieldContext looks up recipes whereas this + * method doesn't need to since it's only returning a boolean based upon the + * recipes used for the last fill (in _fillForm). + * + * @param {HTMLInputElement} aUsernameField element contained in a FormLike + * cached in _formLikeByRootElement. + * @returns {Boolean} whether the username and password fields still have the + * last-filled values, if previously filled. + */ + _isLoginAlreadyFilled(aUsernameField) { + let formLikeRoot = FormLikeFactory.findRootForField(aUsernameField); + // Look for the existing FormLike. + let existingFormLike = this._formLikeByRootElement.get(formLikeRoot); + if (!existingFormLike) { + throw new Error("_isLoginAlreadyFilled called with a username field with " + + "no rootElement FormLike"); + } + + log("_isLoginAlreadyFilled: existingFormLike", existingFormLike); + let filledLogin = this.stateForDocument(aUsernameField.ownerDocument).fillsByRootElement.get(formLikeRoot); + if (!filledLogin) { + return false; + } + + // Unpack the weak references. + let autoFilledUsernameField = filledLogin.usernameField ? filledLogin.usernameField.get() : null; + let autoFilledPasswordField = filledLogin.passwordField.get(); + + // Check username and password values match what was filled. + if (!autoFilledUsernameField || + autoFilledUsernameField != aUsernameField || + autoFilledUsernameField.value != filledLogin.username || + !autoFilledPasswordField || + autoFilledPasswordField.value != filledLogin.password) { + return false; + } + + return true; + }, + + /** + * Verify if a field is a valid login form field and + * returns some information about it's FormLike. + * + * @param {Element} aField + * A form field we want to verify. + * + * @returns {Object} an object with information about the + * FormLike username and password field + * or null if the passed field is invalid. + */ + getFieldContext(aField) { + // If the element is not a proper form field, return null. + if (!(aField instanceof Ci.nsIDOMHTMLInputElement) || + (aField.type != "password" && !LoginHelper.isUsernameFieldType(aField)) || + !aField.ownerDocument) { + return null; + } + let form = LoginFormFactory.createFromField(aField); + + let doc = aField.ownerDocument; + let messageManager = messageManagerFromWindow(doc.defaultView); + let recipes = messageManager.sendSyncMessage("RemoteLogins:findRecipes", { + formOrigin: LoginUtils._getPasswordOrigin(doc.documentURI), + })[0]; + + let [usernameField, newPasswordField] = + this._getFormFields(form, false, recipes); + + // If we are not verifying a password field, we want + // to use aField as the username field. + if (aField.type != "password") { + usernameField = aField; + } + + return { + usernameField: { + found: !!usernameField, + disabled: usernameField && (usernameField.disabled || usernameField.readOnly), + }, + passwordField: { + found: !!newPasswordField, + disabled: newPasswordField && (newPasswordField.disabled || newPasswordField.readOnly), + }, + }; + }, +}; + +var LoginUtils = { + /** + * Get the parts of the URL we want for identification. + * Strip out things like the userPass portion + */ + _getPasswordOrigin(uriString, allowJS) { + var realm = ""; + try { + var uri = Services.io.newURI(uriString, null, null); + + if (allowJS && uri.scheme == "javascript") + return "javascript:"; + + // Build this manually instead of using prePath to avoid including the userPass portion. + realm = uri.scheme + "://" + uri.hostPort; + } catch (e) { + // bug 159484 - disallow url types that don't support a hostPort. + // (although we handle "javascript:..." as a special case above.) + log("Couldn't parse origin for", uriString, e); + realm = null; + } + + return realm; + }, + + _getActionOrigin(form) { + var uriString = form.action; + + // A blank or missing action submits to where it came from. + if (uriString == "") + uriString = form.baseURI; // ala bug 297761 + + return this._getPasswordOrigin(uriString, true); + }, +}; + +// nsIAutoCompleteResult implementation +function UserAutoCompleteResult(aSearchString, matchingLogins, {isSecure, messageManager, isPasswordField}) { + function loginSort(a, b) { + var userA = a.username.toLowerCase(); + var userB = b.username.toLowerCase(); + + if (userA < userB) + return -1; + + if (userA > userB) + return 1; + + return 0; + } + + function findDuplicates(loginList) { + let seen = new Set(); + let duplicates = new Set(); + for (let login of loginList) { + if (seen.has(login.username)) { + duplicates.add(login.username); + } + seen.add(login.username); + } + return duplicates; + } + + this._showInsecureFieldWarning = (!isSecure && LoginHelper.showInsecureFieldWarning) ? 1 : 0; + this.searchString = aSearchString; + this.logins = matchingLogins.sort(loginSort); + this.matchCount = matchingLogins.length + this._showInsecureFieldWarning; + this._messageManager = messageManager; + this._stringBundle = Services.strings.createBundle("chrome://passwordmgr/locale/passwordmgr.properties"); + this._dateAndTimeFormatter = new Intl.DateTimeFormat(undefined, + { day: "numeric", month: "short", year: "numeric" }); + + this._isPasswordField = isPasswordField; + + this._duplicateUsernames = findDuplicates(matchingLogins); + + if (this.matchCount > 0) { + this.searchResult = Ci.nsIAutoCompleteResult.RESULT_SUCCESS; + this.defaultIndex = 0; + } +} + +UserAutoCompleteResult.prototype = { + QueryInterface : XPCOMUtils.generateQI([Ci.nsIAutoCompleteResult, + Ci.nsISupportsWeakReference]), + + // private + logins : null, + + // Allow autoCompleteSearch to get at the JS object so it can + // modify some readonly properties for internal use. + get wrappedJSObject() { + return this; + }, + + // Interfaces from idl... + searchString : null, + searchResult : Ci.nsIAutoCompleteResult.RESULT_NOMATCH, + defaultIndex : -1, + errorDescription : "", + matchCount : 0, + + getValueAt(index) { + if (index < 0 || index >= this.matchCount) { + throw new Error("Index out of range."); + } + + if (this._showInsecureFieldWarning && index === 0) { + return ""; + } + + let selectedLogin = this.logins[index - this._showInsecureFieldWarning]; + + return this._isPasswordField ? selectedLogin.password : selectedLogin.username; + }, + + getLabelAt(index) { + if (index < 0 || index >= this.matchCount) { + throw new Error("Index out of range."); + } + + if (this._showInsecureFieldWarning && index === 0) { + return this._stringBundle.GetStringFromName("insecureFieldWarningDescription") + " " + + this._stringBundle.GetStringFromName("insecureFieldWarningLearnMore"); + } + + let that = this; + + function getLocalizedString(key, formatArgs) { + if (formatArgs) { + return that._stringBundle.formatStringFromName(key, formatArgs, formatArgs.length); + } + return that._stringBundle.GetStringFromName(key); + } + + let login = this.logins[index - this._showInsecureFieldWarning]; + let username = login.username; + // If login is empty or duplicated we want to append a modification date to it. + if (!username || this._duplicateUsernames.has(username)) { + if (!username) { + username = getLocalizedString("noUsername"); + } + let meta = login.QueryInterface(Ci.nsILoginMetaInfo); + let time = this._dateAndTimeFormatter.format(new Date(meta.timePasswordChanged)); + username = getLocalizedString("loginHostAge", [username, time]); + } + + return username; + }, + + getCommentAt(index) { + return ""; + }, + + getStyleAt(index) { + if (index == 0 && this._showInsecureFieldWarning) { + return "insecureWarning"; + } + + return "login"; + }, + + getImageAt(index) { + return ""; + }, + + getFinalCompleteValueAt(index) { + return this.getValueAt(index); + }, + + removeValueAt(index, removeFromDB) { + if (index < 0 || index >= this.matchCount) { + throw new Error("Index out of range."); + } + + if (this._showInsecureFieldWarning && index === 0) { + // Ignore the warning message item. + return; + } + if (this._showInsecureFieldWarning) { + index--; + } + + var [removedLogin] = this.logins.splice(index, 1); + + this.matchCount--; + if (this.defaultIndex > this.logins.length) + this.defaultIndex--; + + if (removeFromDB) { + if (this._messageManager) { + let vanilla = LoginHelper.loginToVanillaObject(removedLogin); + this._messageManager.sendAsyncMessage("RemoteLogins:removeLogin", + { login: vanilla }); + } else { + Services.logins.removeLogin(removedLogin); + } + } + } +}; + +/** + * A factory to generate FormLike objects that represent a set of login fields + * which aren't necessarily marked up with a <form> element. + */ +var LoginFormFactory = { + /** + * Create a LoginForm object from a <form>. + * + * @param {HTMLFormElement} aForm + * @return {LoginForm} + * @throws Error if aForm isn't an HTMLFormElement + */ + createFromForm(aForm) { + let formLike = FormLikeFactory.createFromForm(aForm); + formLike.action = LoginUtils._getActionOrigin(aForm); + + let state = LoginManagerContent.stateForDocument(formLike.ownerDocument); + state.loginFormRootElements.add(formLike.rootElement); + log("adding", formLike.rootElement, "to loginFormRootElements for", formLike.ownerDocument); + + LoginManagerContent._formLikeByRootElement.set(formLike.rootElement, formLike); + return formLike; + }, + + /** + * Create a LoginForm object from a password or username field. + * + * If the field is in a <form>, construct the LoginForm from the form. + * Otherwise, create a LoginForm with a rootElement (wrapper) according to + * heuristics. Currently all <input> not in a <form> are one LoginForm but this + * shouldn't be relied upon as the heuristics may change to detect multiple + * "forms" (e.g. registration and login) on one page with a <form>. + * + * Note that two LoginForms created from the same field won't return the same LoginForm object. + * Use the `rootElement` property on the LoginForm as a key instead. + * + * @param {HTMLInputElement} aField - a password or username field in a document + * @return {LoginForm} + * @throws Error if aField isn't a password or username field in a document + */ + createFromField(aField) { + if (!(aField instanceof Ci.nsIDOMHTMLInputElement) || + (aField.type != "password" && !LoginHelper.isUsernameFieldType(aField)) || + !aField.ownerDocument) { + throw new Error("createFromField requires a password or username field in a document"); + } + + if (aField.form) { + return this.createFromForm(aField.form); + } + + let formLike = FormLikeFactory.createFromField(aField); + formLike.action = LoginUtils._getPasswordOrigin(aField.ownerDocument.baseURI); + log("Created non-form FormLike for rootElement:", aField.ownerDocument.documentElement); + + let state = LoginManagerContent.stateForDocument(formLike.ownerDocument); + state.loginFormRootElements.add(formLike.rootElement); + log("adding", formLike.rootElement, "to loginFormRootElements for", formLike.ownerDocument); + + + LoginManagerContent._formLikeByRootElement.set(formLike.rootElement, formLike); + + return formLike; + }, +}; diff --git a/toolkit/components/passwordmgr/LoginManagerContextMenu.jsm b/toolkit/components/passwordmgr/LoginManagerContextMenu.jsm new file mode 100644 index 000000000..5c88687bf --- /dev/null +++ b/toolkit/components/passwordmgr/LoginManagerContextMenu.jsm @@ -0,0 +1,199 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["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 = { + /** + * Look for login items and add them to the contextual menu. + * + * @param {HTMLInputElement} inputElement + * The target input element of the context menu click. + * @param {xul:browser} browser + * The browser for the document the context menu was open on. + * @param {nsIURI} documentURI + * The URI of the document that the context menu was activated from. + * This isn't the same as the browser's top-level document URI + * when subframes are involved. + * @returns {DocumentFragment} a document fragment with all the login items. + */ + addLoginsToMenu(inputElement, browser, documentURI) { + let foundLogins = this._findLogins(documentURI); + + if (!foundLogins.length) { + return null; + } + + let fragment = browser.ownerDocument.createDocumentFragment(); + let duplicateUsernames = this._findDuplicates(foundLogins); + for (let login of foundLogins) { + let item = fragment.ownerDocument.createElement("menuitem"); + + let username = login.username; + // If login is empty or duplicated we want to append a modification date to it. + if (!username || duplicateUsernames.has(username)) { + if (!username) { + username = this._getLocalizedString("noUsername"); + } + let meta = login.QueryInterface(Ci.nsILoginMetaInfo); + let time = this.dateAndTimeFormatter.format(new Date(meta.timePasswordChanged)); + username = this._getLocalizedString("loginHostAge", [username, time]); + } + item.setAttribute("label", username); + item.setAttribute("class", "context-login-item"); + + // login is bound so we can keep the reference to each object. + item.addEventListener("command", function(login, event) { + this._fillTargetField(login, inputElement, browser, documentURI); + }.bind(this, login)); + + fragment.appendChild(item); + } + + return fragment; + }, + + /** + * Undoes the work of addLoginsToMenu for the same menu. + * + * @param {Document} + * The context menu owner document. + */ + clearLoginsFromMenu(document) { + let loginItems = document.getElementsByClassName("context-login-item"); + while (loginItems.item(0)) { + loginItems.item(0).remove(); + } + }, + + /** + * Find logins for the current URI. + * + * @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 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) { + return 1; + } + if (!loginB.username) { + return -1; + } + return result; + } + + // Same username logins are sorted by last change date + let metaA = loginA.QueryInterface(Ci.nsILoginMetaInfo); + let metaB = loginB.QueryInterface(Ci.nsILoginMetaInfo); + return metaB.timePasswordChanged - metaA.timePasswordChanged; + }); + + return logins; + }, + + /** + * Find duplicate usernames in a login list. + * + * @param {nsILoginInfo[]} loginList + * A list of logins we want to look for duplicate usernames. + * + * @returns {Set} a set with the duplicate usernames. + */ + _findDuplicates(loginList) { + let seen = new Set(); + let duplicates = new Set(); + for (let login of loginList) { + if (seen.has(login.username)) { + duplicates.add(login.username); + } + seen.add(login.username); + } + return duplicates; + }, + + /** + * @param {nsILoginInfo} login + * The login we want to fill the form with. + * @param {Element} inputElement + * The target input element we want to fill. + * @param {xul:browser} browser + * The target tab browser. + * @param {nsIURI} documentURI + * URI of the document owning the form we want to fill. + * This isn't the same as the browser's top-level + * document URI when subframes are involved. + */ + _fillTargetField(login, inputElement, browser, documentURI) { + LoginManagerParent.fillForm({ + browser: browser, + loginFormOrigin: documentURI.prePath, + login: login, + inputElement: inputElement, + }).catch(Cu.reportError); + }, + + /** + * @param {string} key + * The localized string key + * @param {string[]} formatArgs + * An array of formatting argument string + * + * @returns {string} the localized string for the specified key, + * formatted with arguments if required. + */ + _getLocalizedString(key, formatArgs) { + if (formatArgs) { + return this._stringBundle.formatStringFromName(key, formatArgs, formatArgs.length); + } + return this._stringBundle.GetStringFromName(key); + }, +}; + +XPCOMUtils.defineLazyGetter(LoginManagerContextMenu, "_stringBundle", function() { + return Services.strings. + createBundle("chrome://passwordmgr/locale/passwordmgr.properties"); +}); + +XPCOMUtils.defineLazyGetter(LoginManagerContextMenu, "dateAndTimeFormatter", function() { + return new Intl.DateTimeFormat(undefined, { + day: "numeric", + month: "short", + year: "numeric", + }); +}); diff --git a/toolkit/components/passwordmgr/LoginManagerParent.jsm b/toolkit/components/passwordmgr/LoginManagerParent.jsm new file mode 100644 index 000000000..e472fb61c --- /dev/null +++ b/toolkit/components/passwordmgr/LoginManagerParent.jsm @@ -0,0 +1,511 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; + +Cu.importGlobalProperties(["URL"]); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "UserAutoCompleteResult", + "resource://gre/modules/LoginManagerContent.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "AutoCompletePopup", + "resource://gre/modules/AutoCompletePopup.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "DeferredTask", + "resource://gre/modules/DeferredTask.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper", + "resource://gre/modules/LoginHelper.jsm"); + +XPCOMUtils.defineLazyGetter(this, "log", () => { + let logger = LoginHelper.createLogger("LoginManagerParent"); + return logger.log.bind(logger); +}); + +this.EXPORTED_SYMBOLS = [ "LoginManagerParent" ]; + +var LoginManagerParent = { + /** + * Reference to the default LoginRecipesParent (instead of the initialization promise) for + * synchronous access. This is a temporary hack and new consumers should yield on + * recipeParentPromise instead. + * + * @type LoginRecipesParent + * @deprecated + */ + _recipeManager: null, + + // Tracks the last time the user cancelled the master password prompt, + // to avoid spamming master password prompts on autocomplete searches. + _lastMPLoginCancelled: Math.NEGATIVE_INFINITY, + + _searchAndDedupeLogins: function (formOrigin, actionOrigin) { + let logins; + try { + logins = LoginHelper.searchLoginsWithObject({ + hostname: formOrigin, + formSubmitURL: actionOrigin, + schemeUpgrades: LoginHelper.schemeUpgrades, + }); + } catch (e) { + // Record the last time the user cancelled the MP prompt + // to avoid spamming them with MP prompts for autocomplete. + if (e.result == Cr.NS_ERROR_ABORT) { + log("User cancelled master password prompt."); + this._lastMPLoginCancelled = Date.now(); + return []; + } + throw e; + } + + // Dedupe so the length checks below still make sense with scheme upgrades. + let resolveBy = [ + "scheme", + "timePasswordChanged", + ]; + return LoginHelper.dedupeLogins(logins, ["username"], resolveBy, formOrigin); + }, + + init: function() { + let mm = Cc["@mozilla.org/globalmessagemanager;1"] + .getService(Ci.nsIMessageListenerManager); + mm.addMessageListener("RemoteLogins:findLogins", this); + mm.addMessageListener("RemoteLogins:findRecipes", this); + mm.addMessageListener("RemoteLogins:onFormSubmit", this); + mm.addMessageListener("RemoteLogins:autoCompleteLogins", this); + mm.addMessageListener("RemoteLogins:removeLogin", this); + mm.addMessageListener("RemoteLogins:insecureLoginFormPresent", this); + + 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, + data.formOrigin, + data.actionOrigin, + data.requestId, + msg.target.messageManager); + break; + } + + case "RemoteLogins:findRecipes": { + let formHost = (new URL(data.formOrigin)).host; + return this._recipeManager.getRecipesForHost(formHost); + } + + case "RemoteLogins:onFormSubmit": { + // TODO Verify msg.target's principals against the formOrigin? + this.onFormSubmit(data.hostname, + data.formSubmitURL, + data.usernameField, + data.newPasswordField, + data.oldPasswordField, + msg.objects.openerTopWindow, + msg.target); + break; + } + + case "RemoteLogins:insecureLoginFormPresent": { + this.setHasInsecureLoginForms(msg.target, data.hasInsecureLoginForms); + break; + } + + case "RemoteLogins:autoCompleteLogins": { + this.doAutocompleteSearch(data, msg.target); + break; + } + + case "RemoteLogins:removeLogin": { + let login = LoginHelper.vanillaObjectToLogin(data.login); + AutoCompletePopup.removeLogin(login); + break; + } + } + + return undefined; + }, + + /** + * Trigger a login form fill and send relevant data (e.g. logins and recipes) + * to the child process (LoginManagerContent). + */ + fillForm: Task.async(function* ({ browser, loginFormOrigin, login, inputElement }) { + let recipes = []; + if (loginFormOrigin) { + let formHost; + try { + formHost = (new URL(loginFormOrigin)).host; + let recipeManager = yield this.recipeParentPromise; + recipes = recipeManager.getRecipesForHost(formHost); + } catch (ex) { + // Some schemes e.g. chrome aren't supported by URL + } + } + + // Convert the array of nsILoginInfo to vanilla JS objects since nsILoginInfo + // doesn't support structured cloning. + let jsLogins = [LoginHelper.loginToVanillaObject(login)]; + + let objects = inputElement ? {inputElement} : null; + browser.messageManager.sendAsyncMessage("RemoteLogins:fillForm", { + loginFormOrigin, + logins: jsLogins, + recipes, + }, objects); + }), + + /** + * Send relevant data (e.g. logins and recipes) to the child process (LoginManagerContent). + */ + sendLoginDataToChild: Task.async(function*(showMasterPassword, formOrigin, actionOrigin, + requestId, target) { + let recipes = []; + if (formOrigin) { + let formHost; + try { + formHost = (new URL(formOrigin)).host; + let recipeManager = yield this.recipeParentPromise; + recipes = recipeManager.getRecipesForHost(formHost); + } catch (ex) { + // Some schemes e.g. chrome aren't supported by URL + } + } + + if (!showMasterPassword && !Services.logins.isLoggedIn) { + try { + target.sendAsyncMessage("RemoteLogins:loginsFound", { + requestId: requestId, + logins: [], + recipes, + }); + } catch (e) { + log("error sending message to target", e); + } + return; + } + + // If we're currently displaying a master password prompt, defer + // processing this form until the user handles the prompt. + if (Services.logins.uiBusy) { + log("deferring sendLoginDataToChild for", formOrigin); + let self = this; + let observer = { + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver, + Ci.nsISupportsWeakReference]), + + observe: function (subject, topic, data) { + log("Got deferred sendLoginDataToChild notification:", topic); + // Only run observer once. + Services.obs.removeObserver(this, "passwordmgr-crypto-login"); + Services.obs.removeObserver(this, "passwordmgr-crypto-loginCanceled"); + if (topic == "passwordmgr-crypto-loginCanceled") { + target.sendAsyncMessage("RemoteLogins:loginsFound", { + requestId: requestId, + logins: [], + recipes, + }); + return; + } + + self.sendLoginDataToChild(showMasterPassword, formOrigin, actionOrigin, + requestId, target); + }, + }; + + // Possible leak: it's possible that neither of these notifications + // will fire, and if that happens, we'll leak the observer (and + // 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; + } + + let logins = this._searchAndDedupeLogins(formOrigin, actionOrigin); + + 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, + }); + }), + + doAutocompleteSearch: function({ formOrigin, actionOrigin, + searchString, previousResult, + rect, requestId, isSecure, isPasswordField, + remote }, target) { + // Note: previousResult is a regular object, not an + // nsIAutoCompleteResult. + + // Cancel if we unsuccessfully prompted for the master password too recently. + if (!Services.logins.isLoggedIn) { + let timeDiff = Date.now() - this._lastMPLoginCancelled; + if (timeDiff < this._repromptTimeout) { + log("Not searching logins for autocomplete since the master password " + + `prompt was last cancelled ${Math.round(timeDiff / 1000)} seconds ago.`); + // Send an empty array to make LoginManagerContent clear the + // outstanding request it has temporarily saved. + target.messageManager.sendAsyncMessage("RemoteLogins:loginsAutoCompleted", { + requestId, + logins: [], + }); + return; + } + } + + let searchStringLower = searchString.toLowerCase(); + let logins; + if (previousResult && + searchStringLower.startsWith(previousResult.searchString.toLowerCase())) { + log("Using previous autocomplete result"); + + // 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."); + + logins = this._searchAndDedupeLogins(formOrigin, actionOrigin); + } + + 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 except + // for password field. + if (isPasswordField) { + return true; + } + return match && match.toLowerCase().startsWith(searchStringLower); + }); + + // XXX In the E10S case, we're responsible for showing our own + // autocomplete popup here because the autocomplete protocol hasn't + // been e10s-ized yet. In the non-e10s case, our caller is responsible + // for showing the autocomplete popup (via the regular + // nsAutoCompleteController). + if (remote) { + let results = new UserAutoCompleteResult(searchString, matchingLogins, {isSecure}); + AutoCompletePopup.showPopupWithResults({ browser: target.ownerDocument.defaultView, rect, results }); + } + + // Convert the array of nsILoginInfo to vanilla JS objects since nsILoginInfo + // doesn't support structured cloning. + var jsLogins = LoginHelper.loginsToVanillaObjects(matchingLogins); + target.messageManager.sendAsyncMessage("RemoteLogins:loginsAutoCompleted", { + requestId: requestId, + logins: jsLogins, + }); + }, + + onFormSubmit: function(hostname, formSubmitURL, + usernameField, newPasswordField, + oldPasswordField, openerTopWindow, + target) { + function getPrompter() { + var prompterSvc = Cc["@mozilla.org/login-manager/prompter;1"]. + createInstance(Ci.nsILoginManagerPrompter); + prompterSvc.init(target.ownerDocument.defaultView); + prompterSvc.browser = target; + prompterSvc.opener = openerTopWindow; + return prompterSvc; + } + + function recordLoginUse(login) { + // Update the lastUsed timestamp and increment the use count. + let propBag = Cc["@mozilla.org/hash-property-bag;1"]. + createInstance(Ci.nsIWritablePropertyBag); + propBag.setProperty("timeLastUsed", Date.now()); + propBag.setProperty("timesUsedIncrement", 1); + Services.logins.modifyLogin(login, propBag); + } + + if (!Services.logins.getLoginSavingEnabled(hostname)) { + log("(form submission ignored -- saving is disabled for:", hostname, ")"); + return; + } + + 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); + + // Below here we have one login per hostPort + action + username with the + // matching scheme being preferred. + let logins = this._searchAndDedupeLogins(hostname, formSubmitURL); + + // 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) { + var oldLogin = logins[0]; + + if (oldLogin.password == formLogin.password) { + recordLoginUse(oldLogin); + log("(Not prompting to save/change since we have no username and the " + + "only saved password matches the new password)"); + return; + } + + formLogin.username = oldLogin.username; + formLogin.usernameField = oldLogin.usernameField; + + prompter.promptToChangePassword(oldLogin, formLogin); + } else { + // Note: It's possible that that we already have the correct u+p saved + // but since we don't have the username, we don't know if the user is + // changing a second account to the new password so we ask anyways. + + prompter.promptToChangePasswordWithUsernames( + logins, logins.length, formLogin); + } + + return; + } + + + var existingLogin = null; + // Look for an existing login that matches the form login. + for (let login of logins) { + let same; + + // If one login has a username but the other doesn't, ignore + // the username when comparing and only match if they have the + // same password. Otherwise, compare the logins and match even + // if the passwords differ. + if (!login.username && formLogin.username) { + var restoreMe = formLogin.username; + formLogin.username = ""; + same = LoginHelper.doLoginsMatch(formLogin, login, { + ignorePassword: false, + ignoreSchemes: LoginHelper.schemeUpgrades, + }); + formLogin.username = restoreMe; + } else if (!formLogin.username && login.username) { + formLogin.username = login.username; + same = LoginHelper.doLoginsMatch(formLogin, login, { + ignorePassword: false, + ignoreSchemes: LoginHelper.schemeUpgrades, + }); + formLogin.username = ""; // we know it's always blank. + } else { + same = LoginHelper.doLoginsMatch(formLogin, login, { + ignorePassword: true, + ignoreSchemes: LoginHelper.schemeUpgrades, + }); + } + + if (same) { + existingLogin = login; + break; + } + } + + if (existingLogin) { + log("Found an existing login matching this form submission"); + + // Change password if needed. + if (existingLogin.password != formLogin.password) { + log("...passwords differ, prompting to change."); + prompter = getPrompter(); + prompter.promptToChangePassword(existingLogin, formLogin); + } else if (!existingLogin.username && formLogin.username) { + log("...empty username update, prompting to change."); + prompter = getPrompter(); + prompter.promptToChangePassword(existingLogin, formLogin); + } else { + recordLoginUse(existingLogin); + } + + return; + } + + + // Prompt user to save login (via dialog or notification bar) + prompter = getPrompter(); + prompter.promptToSavePassword(formLogin); + }, + + /** + * Maps all the <browser> elements for tabs in the parent process to the + * current state used to display tab-specific UI. + * + * This mapping is not updated in case a web page is moved to a different + * chrome window by the swapDocShells method. In this case, it is possible + * that a UI update just requested for the login fill doorhanger and then + * delayed by a few hundred milliseconds will be lost. Later requests would + * use the new browser reference instead. + * + * Given that the case above is rare, and it would not cause any origin + * mismatch at the time of filling because the origin is checked later in the + * content process, this case is left unhandled. + */ + loginFormStateByBrowser: new WeakMap(), + + /** + * Retrieves a reference to the state object associated with the given + * browser. This is initialized to an empty object. + */ + stateForBrowser(browser) { + let loginFormState = this.loginFormStateByBrowser.get(browser); + if (!loginFormState) { + loginFormState = {}; + this.loginFormStateByBrowser.set(browser, loginFormState); + } + return loginFormState; + }, + + /** + * Returns true if the page currently loaded in the given browser element has + * insecure login forms. This state may be updated asynchronously, in which + * case a custom event named InsecureLoginFormsStateChange will be dispatched + * on the browser element. + */ + hasInsecureLoginForms(browser) { + return !!this.stateForBrowser(browser).hasInsecureLoginForms; + }, + + /** + * Called to indicate whether an insecure password field is present so + * insecure password UI can know when to show. + */ + setHasInsecureLoginForms(browser, hasInsecureLoginForms) { + let state = this.stateForBrowser(browser); + + // Update the data to use to the latest known values. Since messages are + // processed in order, this will always be the latest version to use. + state.hasInsecureLoginForms = hasInsecureLoginForms; + + // Report the insecure login form state immediately. + browser.dispatchEvent(new browser.ownerDocument.defaultView + .CustomEvent("InsecureLoginFormsStateChange")); + }, +}; + +XPCOMUtils.defineLazyPreferenceGetter(LoginManagerParent, "_repromptTimeout", + "signon.masterPasswordReprompt.timeout_ms", 900000); // 15 Minutes diff --git a/toolkit/components/passwordmgr/LoginRecipes.jsm b/toolkit/components/passwordmgr/LoginRecipes.jsm new file mode 100644 index 000000000..4a8124bbc --- /dev/null +++ b/toolkit/components/passwordmgr/LoginRecipes.jsm @@ -0,0 +1,260 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["LoginRecipesContent", "LoginRecipesParent"]; + +const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; +const REQUIRED_KEYS = ["hosts"]; +const OPTIONAL_KEYS = ["description", "notUsernameSelector", "passwordSelector", "pathRegex", "usernameSelector"]; +const SUPPORTED_KEYS = REQUIRED_KEYS.concat(OPTIONAL_KEYS); + +Cu.importGlobalProperties(["URL"]); + +Cu.import("resource://gre/modules/NetUtil.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper", + "resource://gre/modules/LoginHelper.jsm"); + +XPCOMUtils.defineLazyGetter(this, "log", () => LoginHelper.createLogger("LoginRecipes")); + +/** + * Create an instance of the object to manage recipes in the parent process. + * Consumers should wait until {@link initializationPromise} resolves before + * calling methods on the object. + * + * @constructor + * @param {String} [aOptions.defaults=null] the URI to load the recipes from. + * If it's null, nothing is loaded. + * +*/ +function LoginRecipesParent(aOptions = { defaults: null }) { + if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) { + throw new Error("LoginRecipesParent should only be used from the main process"); + } + this._defaults = aOptions.defaults; + this.reset(); +} + +LoginRecipesParent.prototype = { + /** + * Promise resolved with an instance of itself when the module is ready. + * + * @type {Promise} + */ + initializationPromise: null, + + /** + * @type {bool} Whether default recipes were loaded at construction time. + */ + _defaults: null, + + /** + * @type {Map} Map of hosts (including non-default port numbers) to Sets of recipes. + * e.g. "example.com:8080" => Set({...}) + */ + _recipesByHost: null, + + /** + * @param {Object} aRecipes an object containing recipes to load for use. The object + * should be compatible with JSON (e.g. no RegExp). + * @return {Promise} resolving when the recipes are loaded + */ + load(aRecipes) { + let recipeErrors = 0; + for (let rawRecipe of aRecipes.siteRecipes) { + try { + rawRecipe.pathRegex = rawRecipe.pathRegex ? new RegExp(rawRecipe.pathRegex) : undefined; + this.add(rawRecipe); + } catch (ex) { + recipeErrors++; + log.error("Error loading recipe", rawRecipe, ex); + } + } + + if (recipeErrors) { + return Promise.reject(`There were ${recipeErrors} recipe error(s)`); + } + + return Promise.resolve(); + }, + + /** + * Reset the set of recipes to the ones from the time of construction. + */ + reset() { + log.debug("Resetting recipes with defaults:", this._defaults); + this._recipesByHost = new Map(); + + if (this._defaults) { + let channel = NetUtil.newChannel({uri: NetUtil.newURI(this._defaults, "UTF-8"), + loadUsingSystemPrincipal: true}); + channel.contentType = "application/json"; + + try { + this.initializationPromise = new Promise(function(resolve) { + NetUtil.asyncFetch(channel, function (stream, result) { + if (!Components.isSuccessCode(result)) { + throw new Error("Error fetching recipe file:" + result); + } + let count = stream.available(); + let data = NetUtil.readInputStreamToString(stream, count, { charset: "UTF-8" }); + resolve(JSON.parse(data)); + }); + }).then(recipes => { + return this.load(recipes); + }).then(resolve => { + return this; + }); + } catch (e) { + throw new Error("Error reading recipe file:" + e); + } + } else { + this.initializationPromise = Promise.resolve(this); + } + }, + + /** + * Validate the recipe is sane and then add it to the set of recipes. + * + * @param {Object} recipe + */ + add(recipe) { + log.debug("Adding recipe:", recipe); + let recipeKeys = Object.keys(recipe); + let unknownKeys = recipeKeys.filter(key => SUPPORTED_KEYS.indexOf(key) == -1); + if (unknownKeys.length > 0) { + throw new Error("The following recipe keys aren't supported: " + unknownKeys.join(", ")); + } + + let missingRequiredKeys = REQUIRED_KEYS.filter(key => recipeKeys.indexOf(key) == -1); + if (missingRequiredKeys.length > 0) { + throw new Error("The following required recipe keys are missing: " + missingRequiredKeys.join(", ")); + } + + if (!Array.isArray(recipe.hosts)) { + throw new Error("'hosts' must be a array"); + } + + if (!recipe.hosts.length) { + throw new Error("'hosts' must be a non-empty array"); + } + + if (recipe.pathRegex && recipe.pathRegex.constructor.name != "RegExp") { + throw new Error("'pathRegex' must be a regular expression"); + } + + const OPTIONAL_STRING_PROPS = ["description", "passwordSelector", "usernameSelector"]; + for (let prop of OPTIONAL_STRING_PROPS) { + if (recipe[prop] && typeof(recipe[prop]) != "string") { + throw new Error(`'${prop}' must be a string`); + } + } + + // Add the recipe to the map for each host + for (let host of recipe.hosts) { + if (!this._recipesByHost.has(host)) { + this._recipesByHost.set(host, new Set()); + } + this._recipesByHost.get(host).add(recipe); + } + }, + + /** + * Currently only exact host matches are returned but this will eventually handle parent domains. + * + * @param {String} aHost (e.g. example.com:8080 [non-default port] or sub.example.com) + * @return {Set} of recipes that apply to the host ordered by host priority + */ + getRecipesForHost(aHost) { + let hostRecipes = this._recipesByHost.get(aHost); + if (!hostRecipes) { + return new Set(); + } + + return hostRecipes; + }, +}; + + +var LoginRecipesContent = { + /** + * @param {Set} aRecipes - Possible recipes that could apply to the form + * @param {FormLike} aForm - We use a form instead of just a URL so we can later apply + * tests to the page contents. + * @return {Set} a subset of recipes that apply to the form with the order preserved + */ + _filterRecipesForForm(aRecipes, aForm) { + let formDocURL = aForm.ownerDocument.location; + let hostRecipes = aRecipes; + let recipes = new Set(); + log.debug("_filterRecipesForForm", aRecipes); + if (!hostRecipes) { + return recipes; + } + + for (let hostRecipe of hostRecipes) { + if (hostRecipe.pathRegex && !hostRecipe.pathRegex.test(formDocURL.pathname)) { + continue; + } + recipes.add(hostRecipe); + } + + return recipes; + }, + + /** + * Given a set of recipes that apply to the host, choose the one most applicable for + * overriding login fields in the form. + * + * @param {Set} aRecipes The set of recipes to consider for the form + * @param {FormLike} aForm The form where login fields exist. + * @return {Object} The recipe that is most applicable for the form. + */ + getFieldOverrides(aRecipes, aForm) { + let recipes = this._filterRecipesForForm(aRecipes, aForm); + log.debug("getFieldOverrides: filtered recipes:", recipes); + if (!recipes.size) { + return null; + } + + let chosenRecipe = null; + // Find the first (most-specific recipe that involves field overrides). + for (let recipe of recipes) { + if (!recipe.usernameSelector && !recipe.passwordSelector && + !recipe.notUsernameSelector) { + continue; + } + + chosenRecipe = recipe; + break; + } + + return chosenRecipe; + }, + + /** + * @param {HTMLElement} aParent the element to query for the selector from. + * @param {CSSSelector} aSelector the CSS selector to query for the login field. + * @return {HTMLElement|null} + */ + queryLoginField(aParent, aSelector) { + if (!aSelector) { + return null; + } + let field = aParent.ownerDocument.querySelector(aSelector); + if (!field) { + 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; + }, +}; diff --git a/toolkit/components/passwordmgr/LoginStore.jsm b/toolkit/components/passwordmgr/LoginStore.jsm new file mode 100644 index 000000000..9fa6e7dff --- /dev/null +++ b/toolkit/components/passwordmgr/LoginStore.jsm @@ -0,0 +1,136 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Handles serialization of the data and persistence into a file. + * + * The file is stored in JSON format, without indentation, using UTF-8 encoding. + * With indentation applied, the file would look like this: + * + * { + * "logins": [ + * { + * "id": 2, + * "hostname": "http://www.example.com", + * "httpRealm": null, + * "formSubmitURL": "http://www.example.com/submit-url", + * "usernameField": "username_field", + * "passwordField": "password_field", + * "encryptedUsername": "...", + * "encryptedPassword": "...", + * "guid": "...", + * "encType": 1, + * "timeCreated": 1262304000000, + * "timeLastUsed": 1262304000000, + * "timePasswordChanged": 1262476800000, + * "timesUsed": 1 + * }, + * { + * "id": 4, + * (...) + * } + * ], + * "disabledHosts": [ + * "http://www.example.org", + * "http://www.example.net" + * ], + * "nextId": 10, + * "version": 1 + * } + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = [ + "LoginStore", +]; + +// Globals + +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, "JSONFile", + "resource://gre/modules/JSONFile.jsm"); + +/** + * Current data version assigned by the code that last touched the data. + * + * This number should be updated only when it is important to understand whether + * an old version of the code has touched the data, for example to execute an + * update logic. In most cases, this number should not be changed, in + * particular when no special one-time update logic is needed. + * + * For example, this number should NOT be changed when a new optional field is + * added to a login entry. + */ +const kDataVersion = 2; + +// The permission type we store in the permission manager. +const PERMISSION_SAVE_LOGINS = "login-saving"; + +// LoginStore + +/** + * Inherits from JSONFile and handles serialization of login-related data and + * persistence into a file. + * + * @param aPath + * String containing the file path where data should be saved. + */ +function LoginStore(aPath) { + JSONFile.call(this, { + path: aPath, + dataPostProcessor: this._dataPostProcessor.bind(this) + }); +} + +LoginStore.prototype = Object.create(JSONFile.prototype); +LoginStore.prototype.constructor = LoginStore; + +/** + * Synchronously work on the data just loaded into memory. + */ +LoginStore.prototype._dataPostProcessor = function(data) { + if (data.nextId === undefined) { + data.nextId = 1; + } + + // Create any arrays that are not present in the saved file. + if (!data.logins) { + data.logins = []; + } + + // Stub needed for login imports before data has been migrated. + if (!data.disabledHosts) { + data.disabledHosts = []; + } + + if (data.version === 1) { + this._migrateDisabledHosts(data); + } + + // Indicate that the current version of the code has touched the file. + data.version = kDataVersion; + + return data; +}; + +/** + * Migrates disabled hosts to the permission manager. + */ +LoginStore.prototype._migrateDisabledHosts = function (data) { + for (let host of data.disabledHosts) { + try { + let uri = Services.io.newURI(host, null, null); + Services.perms.add(uri, PERMISSION_SAVE_LOGINS, Services.perms.DENY_ACTION); + } catch (e) { + Cu.reportError(e); + } + } + + delete data.disabledHosts; +}; diff --git a/toolkit/components/passwordmgr/OSCrypto.jsm b/toolkit/components/passwordmgr/OSCrypto.jsm new file mode 100644 index 000000000..04254f66f --- /dev/null +++ b/toolkit/components/passwordmgr/OSCrypto.jsm @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Common front for various implementations of OSCrypto + */ + +"use strict"; + +Components.utils.import("resource://gre/modules/AppConstants.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); + +this.EXPORTED_SYMBOLS = ["OSCrypto"]; + +var OSCrypto = {}; + +if (AppConstants.platform == "win") { + Services.scriptloader.loadSubScript("resource://gre/modules/OSCrypto_win.js", this); +} else { + throw new Error("OSCrypto.jsm isn't supported on this platform"); +} diff --git a/toolkit/components/passwordmgr/OSCrypto_win.js b/toolkit/components/passwordmgr/OSCrypto_win.js new file mode 100644 index 000000000..0f52f4269 --- /dev/null +++ b/toolkit/components/passwordmgr/OSCrypto_win.js @@ -0,0 +1,245 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "ctypes", "resource://gre/modules/ctypes.jsm"); + +const FLAGS_NOT_SET = 0; + +const wintypes = { + BOOL: ctypes.bool, + BYTE: ctypes.uint8_t, + DWORD: ctypes.uint32_t, + PBYTE: ctypes.unsigned_char.ptr, + PCHAR: ctypes.char.ptr, + PDWORD: ctypes.uint32_t.ptr, + PVOID: ctypes.voidptr_t, + WORD: ctypes.uint16_t, +}; + +function OSCrypto() { + this._structs = {}; + this._functions = new Map(); + this._libs = new Map(); + this._structs.DATA_BLOB = new ctypes.StructType("DATA_BLOB", + [ + {cbData: wintypes.DWORD}, + {pbData: wintypes.PVOID} + ]); + + try { + + this._libs.set("crypt32", ctypes.open("Crypt32")); + this._libs.set("kernel32", ctypes.open("Kernel32")); + + this._functions.set("CryptProtectData", + this._libs.get("crypt32").declare("CryptProtectData", + ctypes.winapi_abi, + wintypes.DWORD, + this._structs.DATA_BLOB.ptr, + wintypes.PVOID, + wintypes.PVOID, + wintypes.PVOID, + wintypes.PVOID, + wintypes.DWORD, + this._structs.DATA_BLOB.ptr)); + this._functions.set("CryptUnprotectData", + this._libs.get("crypt32").declare("CryptUnprotectData", + ctypes.winapi_abi, + wintypes.DWORD, + this._structs.DATA_BLOB.ptr, + wintypes.PVOID, + wintypes.PVOID, + wintypes.PVOID, + wintypes.PVOID, + wintypes.DWORD, + this._structs.DATA_BLOB.ptr)); + this._functions.set("LocalFree", + this._libs.get("kernel32").declare("LocalFree", + ctypes.winapi_abi, + wintypes.DWORD, + wintypes.PVOID)); + } catch (ex) { + Cu.reportError(ex); + this.finalize(); + throw ex; + } +} +OSCrypto.prototype = { + /** + * Convert an array containing only two bytes unsigned numbers to a string. + * @param {number[]} arr - the array that needs to be converted. + * @returns {string} the string representation of the array. + */ + arrayToString(arr) { + let str = ""; + for (let i = 0; i < arr.length; i++) { + str += String.fromCharCode(arr[i]); + } + return str; + }, + + /** + * Convert a string to an array. + * @param {string} str - the string that needs to be converted. + * @returns {number[]} the array representation of the string. + */ + stringToArray(str) { + let arr = []; + for (let i = 0; i < str.length; i++) { + arr.push(str.charCodeAt(i)); + } + return arr; + }, + + /** + * Calculate the hash value used by IE as the name of the registry value where login details are + * stored. + * @param {string} data - the string value that needs to be hashed. + * @returns {string} the hash value of the string. + */ + getIELoginHash(data) { + // return the two-digit hexadecimal code for a byte + function toHexString(charCode) { + return ("00" + charCode.toString(16)).slice(-2); + } + + // the data needs to be encoded in null terminated UTF-16 + data += "\0"; + let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]. + createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-16"; + // result is an out parameter, + // result.value will contain the array length + let result = {}; + // dataArray is an array of bytes + let dataArray = converter.convertToByteArray(data, result); + // calculation of SHA1 hash value + let cryptoHash = Cc["@mozilla.org/security/hash;1"]. + createInstance(Ci.nsICryptoHash); + cryptoHash.init(cryptoHash.SHA1); + cryptoHash.update(dataArray, dataArray.length); + let hash = cryptoHash.finish(false); + + let tail = 0; // variable to calculate value for the last 2 bytes + // convert to a character string in hexadecimal notation + for (let c of hash) { + tail += c.charCodeAt(0); + } + hash += String.fromCharCode(tail % 256); + + // convert the binary hash data to a hex string. + let hashStr = Array.from(hash, (c, i) => toHexString(hash.charCodeAt(i))).join(""); + return hashStr.toUpperCase(); + }, + + /** + * Decrypt a string using the windows CryptUnprotectData API. + * @param {string} data - the encrypted string that needs to be decrypted. + * @param {?string} entropy - the entropy value of the decryption (could be null). Its value must + * be the same as the one used when the data was encrypted. + * @returns {string} the decryption of the string. + */ + decryptData(data, entropy = null) { + let array = this.stringToArray(data); + let decryptedData = ""; + let encryptedData = wintypes.BYTE.array(array.length)(array); + let inData = new this._structs.DATA_BLOB(encryptedData.length, encryptedData); + let outData = new this._structs.DATA_BLOB(); + let entropyParam; + if (entropy) { + let entropyArray = this.stringToArray(entropy); + entropyArray.push(0); + let entropyData = wintypes.WORD.array(entropyArray.length)(entropyArray); + let optionalEntropy = new this._structs.DATA_BLOB(entropyData.length * 2, + entropyData); + entropyParam = optionalEntropy.address(); + } else { + entropyParam = null; + } + + let status = this._functions.get("CryptUnprotectData")(inData.address(), null, + entropyParam, + null, null, FLAGS_NOT_SET, + outData.address()); + if (status === 0) { + throw new Error("decryptData failed: " + status); + } + + // convert byte array to JS string. + let len = outData.cbData; + let decrypted = ctypes.cast(outData.pbData, + wintypes.BYTE.array(len).ptr).contents; + for (let i = 0; i < decrypted.length; i++) { + decryptedData += String.fromCharCode(decrypted[i]); + } + + this._functions.get("LocalFree")(outData.pbData); + return decryptedData; + }, + + /** + * Encrypt a string using the windows CryptProtectData API. + * @param {string} data - the string that is going to be encrypted. + * @param {?string} entropy - the entropy value of the encryption (could be null). Its value must + * be the same as the one that is going to be used for the decryption. + * @returns {string} the encrypted string. + */ + encryptData(data, entropy = null) { + let encryptedData = ""; + let decryptedData = wintypes.BYTE.array(data.length)(this.stringToArray(data)); + + let inData = new this._structs.DATA_BLOB(data.length, decryptedData); + let outData = new this._structs.DATA_BLOB(); + let entropyParam; + if (!entropy) { + entropyParam = null; + } else { + let entropyArray = this.stringToArray(entropy); + entropyArray.push(0); + let entropyData = wintypes.WORD.array(entropyArray.length)(entropyArray); + let optionalEntropy = new this._structs.DATA_BLOB(entropyData.length * 2, + entropyData); + entropyParam = optionalEntropy.address(); + } + + let status = this._functions.get("CryptProtectData")(inData.address(), null, + entropyParam, + null, null, FLAGS_NOT_SET, + outData.address()); + if (status === 0) { + throw new Error("encryptData failed: " + status); + } + + // convert byte array to JS string. + let len = outData.cbData; + let encrypted = ctypes.cast(outData.pbData, + wintypes.BYTE.array(len).ptr).contents; + encryptedData = this.arrayToString(encrypted); + this._functions.get("LocalFree")(outData.pbData); + return encryptedData; + }, + + /** + * Must be invoked once after last use of any of the provided helpers. + */ + finalize() { + this._structs = {}; + this._functions.clear(); + for (let lib of this._libs.values()) { + try { + lib.close(); + } catch (ex) { + Cu.reportError(ex); + } + } + this._libs.clear(); + }, +}; diff --git a/toolkit/components/passwordmgr/content/passwordManager.js b/toolkit/components/passwordmgr/content/passwordManager.js new file mode 100644 index 000000000..333dc1d24 --- /dev/null +++ b/toolkit/components/passwordmgr/content/passwordManager.js @@ -0,0 +1,728 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** * =================== SAVED SIGNONS CODE =================== ***/ +const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; + +Cu.import("resource://gre/modules/AppConstants.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "DeferredTask", + "resource://gre/modules/DeferredTask.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm"); + +let kSignonBundle; + +// Default value for signon table sorting +let lastSignonSortColumn = "hostname"; +let lastSignonSortAscending = true; + +let showingPasswords = false; + +// password-manager lists +let signons = []; +let deletedSignons = []; + +// Elements that would be used frequently +let filterField; +let togglePasswordsButton; +let signonsIntro; +let removeButton; +let removeAllButton; +let signonsTree; + +let signonReloadDisplay = { + observe: function(subject, topic, data) { + if (topic == "passwordmgr-storage-changed") { + switch (data) { + case "addLogin": + case "modifyLogin": + case "removeLogin": + case "removeAllLogins": + if (!signonsTree) { + return; + } + signons.length = 0; + LoadSignons(); + // apply the filter if needed + if (filterField && filterField.value != "") { + FilterPasswords(); + } + break; + } + Services.obs.notifyObservers(null, "passwordmgr-dialog-updated", null); + } + } +}; + +// Formatter for localization. +let dateFormatter = new Intl.DateTimeFormat(undefined, + { day: "numeric", month: "short", year: "numeric" }); +let dateAndTimeFormatter = new Intl.DateTimeFormat(undefined, + { day: "numeric", month: "short", year: "numeric", + hour: "numeric", minute: "numeric" }); + +function Startup() { + // be prepared to reload the display if anything changes + Services.obs.addObserver(signonReloadDisplay, "passwordmgr-storage-changed", false); + + signonsTree = document.getElementById("signonsTree"); + kSignonBundle = document.getElementById("signonBundle"); + filterField = document.getElementById("filter"); + togglePasswordsButton = document.getElementById("togglePasswords"); + signonsIntro = document.getElementById("signonsIntro"); + removeButton = document.getElementById("removeSignon"); + removeAllButton = document.getElementById("removeAllSignons"); + + togglePasswordsButton.label = kSignonBundle.getString("showPasswords"); + togglePasswordsButton.accessKey = kSignonBundle.getString("showPasswordsAccessKey"); + signonsIntro.textContent = kSignonBundle.getString("loginsDescriptionAll"); + document.getElementsByTagName("treecols")[0].addEventListener("click", (event) => { + let { target, button } = event; + let sortField = target.getAttribute("data-field-name"); + + if (target.nodeName != "treecol" || button != 0 || !sortField) { + return; + } + + SignonColumnSort(sortField); + Services.telemetry.getKeyedHistogramById("PWMGR_MANAGE_SORTED").add(sortField); + }); + + LoadSignons(); + + // filter the table if requested by caller + if (window.arguments && + window.arguments[0] && + window.arguments[0].filterString) { + setFilter(window.arguments[0].filterString); + Services.telemetry.getHistogramById("PWMGR_MANAGE_OPENED").add(1); + } else { + Services.telemetry.getHistogramById("PWMGR_MANAGE_OPENED").add(0); + } + + FocusFilterBox(); +} + +function Shutdown() { + Services.obs.removeObserver(signonReloadDisplay, "passwordmgr-storage-changed"); +} + +function setFilter(aFilterString) { + filterField.value = aFilterString; + FilterPasswords(); +} + +let signonsTreeView = { + // Keep track of which favicons we've fetched or started fetching. + // Maps a login origin to a favicon URL. + _faviconMap: new Map(), + _filterSet: [], + // Coalesce invalidations to avoid repeated flickering. + _invalidateTask: new DeferredTask(() => { + signonsTree.treeBoxObject.invalidateColumn(signonsTree.columns.siteCol); + }, 10), + _lastSelectedRanges: [], + selection: null, + + rowCount: 0, + setTree(tree) {}, + getImageSrc(row, column) { + if (column.element.getAttribute("id") !== "siteCol") { + return ""; + } + + const signon = this._filterSet.length ? this._filterSet[row] : signons[row]; + + // We already have the favicon URL or we started to fetch (value is null). + if (this._faviconMap.has(signon.hostname)) { + return this._faviconMap.get(signon.hostname); + } + + // Record the fact that we already starting fetching a favicon for this + // origin in order to avoid multiple requests for the same origin. + this._faviconMap.set(signon.hostname, null); + + PlacesUtils.promiseFaviconLinkUrl(signon.hostname) + .then(faviconURI => { + this._faviconMap.set(signon.hostname, faviconURI.spec); + this._invalidateTask.arm(); + }).catch(Cu.reportError); + + return ""; + }, + getProgressMode(row, column) {}, + getCellValue(row, column) {}, + getCellText(row, column) { + let time; + let signon = this._filterSet.length ? this._filterSet[row] : signons[row]; + switch (column.id) { + case "siteCol": + return signon.httpRealm ? + (signon.hostname + " (" + signon.httpRealm + ")") : + signon.hostname; + case "userCol": + return signon.username || ""; + case "passwordCol": + return signon.password || ""; + case "timeCreatedCol": + time = new Date(signon.timeCreated); + return dateFormatter.format(time); + case "timeLastUsedCol": + time = new Date(signon.timeLastUsed); + return dateAndTimeFormatter.format(time); + case "timePasswordChangedCol": + time = new Date(signon.timePasswordChanged); + return dateFormatter.format(time); + case "timesUsedCol": + return signon.timesUsed; + default: + return ""; + } + }, + isEditable(row, col) { + if (col.id == "userCol" || col.id == "passwordCol") { + return true; + } + return false; + }, + isSeparator(index) { return false; }, + isSorted() { return false; }, + isContainer(index) { return false; }, + cycleHeader(column) {}, + getRowProperties(row) { return ""; }, + getColumnProperties(column) { return ""; }, + getCellProperties(row, column) { + if (column.element.getAttribute("id") == "siteCol") + return "ltr"; + + return ""; + }, + setCellText(row, col, value) { + // If there is a filter, _filterSet needs to be used, otherwise signons is used. + let table = signonsTreeView._filterSet.length ? signonsTreeView._filterSet : signons; + function _editLogin(field) { + if (value == table[row][field]) { + return; + } + let existingLogin = table[row].clone(); + table[row][field] = value; + table[row].timePasswordChanged = Date.now(); + Services.logins.modifyLogin(existingLogin, table[row]); + signonsTree.treeBoxObject.invalidateRow(row); + } + + if (col.id == "userCol") { + _editLogin("username"); + + } else if (col.id == "passwordCol") { + if (!value) { + return; + } + _editLogin("password"); + } + }, +}; + +function SortTree(column, ascending) { + let table = signonsTreeView._filterSet.length ? signonsTreeView._filterSet : signons; + // remember which item was selected so we can restore it after the sort + let selections = GetTreeSelections(); + let selectedNumber = selections.length ? table[selections[0]].number : -1; + + function compareFunc(a, b) { + let valA, valB; + switch (column) { + case "hostname": + let realmA = a.httpRealm; + let realmB = b.httpRealm; + realmA = realmA == null ? "" : realmA.toLowerCase(); + realmB = realmB == null ? "" : realmB.toLowerCase(); + + valA = a[column].toLowerCase() + realmA; + valB = b[column].toLowerCase() + realmB; + break; + case "username": + case "password": + valA = a[column].toLowerCase(); + valB = b[column].toLowerCase(); + break; + + default: + valA = a[column]; + valB = b[column]; + } + + if (valA < valB) + return -1; + if (valA > valB) + return 1; + return 0; + } + + // do the sort + table.sort(compareFunc); + if (!ascending) { + table.reverse(); + } + + // restore the selection + let selectedRow = -1; + if (selectedNumber >= 0 && false) { + for (let s = 0; s < table.length; s++) { + if (table[s].number == selectedNumber) { + // update selection + // note: we need to deselect before reselecting in order to trigger ...Selected() + signonsTree.view.selection.select(-1); + signonsTree.view.selection.select(s); + selectedRow = s; + break; + } + } + } + + // display the results + signonsTree.treeBoxObject.invalidate(); + if (selectedRow >= 0) { + signonsTree.treeBoxObject.ensureRowIsVisible(selectedRow); + } +} + +function LoadSignons() { + // loads signons into table + try { + signons = Services.logins.getAllLogins(); + } catch (e) { + signons = []; + } + signons.forEach(login => login.QueryInterface(Ci.nsILoginMetaInfo)); + signonsTreeView.rowCount = signons.length; + + // sort and display the table + signonsTree.view = signonsTreeView; + // The sort column didn't change. SortTree (called by + // SignonColumnSort) assumes we want to toggle the sort + // direction but here we don't so we have to trick it + lastSignonSortAscending = !lastSignonSortAscending; + SignonColumnSort(lastSignonSortColumn); + + // disable "remove all signons" button if there are no signons + if (signons.length == 0) { + removeAllButton.setAttribute("disabled", "true"); + togglePasswordsButton.setAttribute("disabled", "true"); + } else { + removeAllButton.removeAttribute("disabled"); + togglePasswordsButton.removeAttribute("disabled"); + } + + return true; +} + +function GetTreeSelections() { + let selections = []; + let select = signonsTree.view.selection; + if (select) { + let count = select.getRangeCount(); + let min = {}; + let max = {}; + for (let i = 0; i < count; i++) { + select.getRangeAt(i, min, max); + for (let k = min.value; k <= max.value; k++) { + if (k != -1) { + selections[selections.length] = k; + } + } + } + } + return selections; +} + +function SignonSelected() { + let selections = GetTreeSelections(); + if (selections.length) { + removeButton.removeAttribute("disabled"); + } else { + removeButton.setAttribute("disabled", true); + } +} + +function DeleteSignon() { + let filterSet = signonsTreeView._filterSet; + let syncNeeded = (filterSet.length != 0); + let tree = signonsTree; + let view = signonsTreeView; + let table = filterSet.length ? filterSet : signons; + + // Turn off tree selection notifications during the deletion + tree.view.selection.selectEventsSuppressed = true; + + // remove selected items from list (by setting them to null) and place in deleted list + let selections = GetTreeSelections(); + for (let s = selections.length - 1; s >= 0; s--) { + let i = selections[s]; + deletedSignons.push(table[i]); + table[i] = null; + } + + // collapse list by removing all the null entries + for (let j = 0; j < table.length; j++) { + if (table[j] == null) { + let k = j; + while ((k < table.length) && (table[k] == null)) { + k++; + } + table.splice(j, k - j); + view.rowCount -= k - j; + tree.treeBoxObject.rowCountChanged(j, j - k); + } + } + + // update selection and/or buttons + if (table.length) { + // update selection + let nextSelection = (selections[0] < table.length) ? selections[0] : table.length - 1; + tree.view.selection.select(nextSelection); + tree.treeBoxObject.ensureRowIsVisible(nextSelection); + } else { + // disable buttons + removeButton.setAttribute("disabled", "true"); + removeAllButton.setAttribute("disabled", "true"); + } + tree.view.selection.selectEventsSuppressed = false; + FinalizeSignonDeletions(syncNeeded); +} + +function DeleteAllSignons() { + let prompter = Cc["@mozilla.org/embedcomp/prompt-service;1"] + .getService(Ci.nsIPromptService); + + // Confirm the user wants to remove all passwords + let dummy = { value: false }; + if (prompter.confirmEx(window, + kSignonBundle.getString("removeAllPasswordsTitle"), + kSignonBundle.getString("removeAllPasswordsPrompt"), + prompter.STD_YES_NO_BUTTONS + prompter.BUTTON_POS_1_DEFAULT, + null, null, null, null, dummy) == 1) // 1 == "No" button + return; + + let filterSet = signonsTreeView._filterSet; + let syncNeeded = (filterSet.length != 0); + let view = signonsTreeView; + let table = filterSet.length ? filterSet : signons; + + // remove all items from table and place in deleted table + for (let i = 0; i < table.length; i++) { + deletedSignons.push(table[i]); + } + table.length = 0; + + // clear out selections + view.selection.select(-1); + + // update the tree view and notify the tree + view.rowCount = 0; + + let box = signonsTree.treeBoxObject; + box.rowCountChanged(0, -deletedSignons.length); + box.invalidate(); + + // disable buttons + removeButton.setAttribute("disabled", "true"); + removeAllButton.setAttribute("disabled", "true"); + FinalizeSignonDeletions(syncNeeded); + Services.telemetry.getHistogramById("PWMGR_MANAGE_DELETED_ALL").add(1); +} + +function TogglePasswordVisible() { + if (showingPasswords || masterPasswordLogin(AskUserShowPasswords)) { + showingPasswords = !showingPasswords; + togglePasswordsButton.label = kSignonBundle.getString(showingPasswords ? "hidePasswords" : "showPasswords"); + togglePasswordsButton.accessKey = kSignonBundle.getString(showingPasswords ? "hidePasswordsAccessKey" : "showPasswordsAccessKey"); + document.getElementById("passwordCol").hidden = !showingPasswords; + FilterPasswords(); + } + + // Notify observers that the password visibility toggling is + // completed. (Mostly useful for tests) + Services.obs.notifyObservers(null, "passwordmgr-password-toggle-complete", null); + Services.telemetry.getHistogramById("PWMGR_MANAGE_VISIBILITY_TOGGLED").add(showingPasswords); +} + +function AskUserShowPasswords() { + let prompter = Cc["@mozilla.org/embedcomp/prompt-service;1"].getService(Ci.nsIPromptService); + let dummy = { value: false }; + + // Confirm the user wants to display passwords + return prompter.confirmEx(window, + null, + kSignonBundle.getString("noMasterPasswordPrompt"), prompter.STD_YES_NO_BUTTONS, + null, null, null, null, dummy) == 0; // 0=="Yes" button +} + +function FinalizeSignonDeletions(syncNeeded) { + for (let s = 0; s < deletedSignons.length; s++) { + Services.logins.removeLogin(deletedSignons[s]); + Services.telemetry.getHistogramById("PWMGR_MANAGE_DELETED").add(1); + } + // If the deletion has been performed in a filtered view, reflect the deletion in the unfiltered table. + // See bug 405389. + if (syncNeeded) { + try { + signons = Services.logins.getAllLogins(); + } catch (e) { + signons = []; + } + } + deletedSignons.length = 0; +} + +function HandleSignonKeyPress(e) { + // If editing is currently performed, don't do anything. + if (signonsTree.getAttribute("editing")) { + return; + } + if (e.keyCode == KeyboardEvent.DOM_VK_DELETE || + (AppConstants.platform == "macosx" && + e.keyCode == KeyboardEvent.DOM_VK_BACK_SPACE)) { + DeleteSignon(); + } +} + +function getColumnByName(column) { + switch (column) { + case "hostname": + return document.getElementById("siteCol"); + case "username": + return document.getElementById("userCol"); + case "password": + return document.getElementById("passwordCol"); + case "timeCreated": + return document.getElementById("timeCreatedCol"); + case "timeLastUsed": + return document.getElementById("timeLastUsedCol"); + case "timePasswordChanged": + return document.getElementById("timePasswordChangedCol"); + case "timesUsed": + return document.getElementById("timesUsedCol"); + } + return undefined; +} + +function SignonColumnSort(column) { + let sortedCol = getColumnByName(column); + let lastSortedCol = getColumnByName(lastSignonSortColumn); + + // clear out the sortDirection attribute on the old column + lastSortedCol.removeAttribute("sortDirection"); + + // determine if sort is to be ascending or descending + lastSignonSortAscending = (column == lastSignonSortColumn) ? !lastSignonSortAscending : true; + + // sort + lastSignonSortColumn = column; + SortTree(lastSignonSortColumn, lastSignonSortAscending); + + // set the sortDirection attribute to get the styling going + // first we need to get the right element + sortedCol.setAttribute("sortDirection", lastSignonSortAscending ? + "ascending" : "descending"); +} + +function SignonClearFilter() { + let singleSelection = (signonsTreeView.selection.count == 1); + + // Clear the Tree Display + signonsTreeView.rowCount = 0; + signonsTree.treeBoxObject.rowCountChanged(0, -signonsTreeView._filterSet.length); + signonsTreeView._filterSet = []; + + // Just reload the list to make sure deletions are respected + LoadSignons(); + + // Restore selection + if (singleSelection) { + signonsTreeView.selection.clearSelection(); + for (let i = 0; i < signonsTreeView._lastSelectedRanges.length; ++i) { + let range = signonsTreeView._lastSelectedRanges[i]; + signonsTreeView.selection.rangedSelect(range.min, range.max, true); + } + } else { + signonsTreeView.selection.select(0); + } + signonsTreeView._lastSelectedRanges = []; + + signonsIntro.textContent = kSignonBundle.getString("loginsDescriptionAll"); +} + +function FocusFilterBox() { + if (filterField.getAttribute("focused") != "true") { + filterField.focus(); + } +} + +function SignonMatchesFilter(aSignon, aFilterValue) { + if (aSignon.hostname.toLowerCase().indexOf(aFilterValue) != -1) + return true; + if (aSignon.username && + aSignon.username.toLowerCase().indexOf(aFilterValue) != -1) + return true; + if (aSignon.httpRealm && + aSignon.httpRealm.toLowerCase().indexOf(aFilterValue) != -1) + return true; + if (showingPasswords && aSignon.password && + aSignon.password.toLowerCase().indexOf(aFilterValue) != -1) + return true; + + return false; +} + +function _filterPasswords(aFilterValue, view) { + aFilterValue = aFilterValue.toLowerCase(); + return signons.filter(s => SignonMatchesFilter(s, aFilterValue)); +} + +function SignonSaveState() { + // Save selection + let seln = signonsTreeView.selection; + signonsTreeView._lastSelectedRanges = []; + let rangeCount = seln.getRangeCount(); + for (let i = 0; i < rangeCount; ++i) { + let min = {}; let max = {}; + seln.getRangeAt(i, min, max); + signonsTreeView._lastSelectedRanges.push({ min: min.value, max: max.value }); + } +} + +function FilterPasswords() { + if (filterField.value == "") { + SignonClearFilter(); + return; + } + + let newFilterSet = _filterPasswords(filterField.value, signonsTreeView); + if (!signonsTreeView._filterSet.length) { + // Save Display Info for the Non-Filtered mode when we first + // enter Filtered mode. + SignonSaveState(); + } + signonsTreeView._filterSet = newFilterSet; + + // Clear the display + let oldRowCount = signonsTreeView.rowCount; + signonsTreeView.rowCount = 0; + signonsTree.treeBoxObject.rowCountChanged(0, -oldRowCount); + // Set up the filtered display + signonsTreeView.rowCount = signonsTreeView._filterSet.length; + signonsTree.treeBoxObject.rowCountChanged(0, signonsTreeView.rowCount); + + // if the view is not empty then select the first item + if (signonsTreeView.rowCount > 0) + signonsTreeView.selection.select(0); + + signonsIntro.textContent = kSignonBundle.getString("loginsDescriptionFiltered"); +} + +function CopyPassword() { + // Don't copy passwords if we aren't already showing the passwords & a master + // password hasn't been entered. + if (!showingPasswords && !masterPasswordLogin()) + return; + // Copy selected signon's password to clipboard + let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"]. + getService(Ci.nsIClipboardHelper); + let row = signonsTree.currentIndex; + let password = signonsTreeView.getCellText(row, {id : "passwordCol" }); + clipboard.copyString(password); + Services.telemetry.getHistogramById("PWMGR_MANAGE_COPIED_PASSWORD").add(1); +} + +function CopyUsername() { + // Copy selected signon's username to clipboard + let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"]. + getService(Ci.nsIClipboardHelper); + let row = signonsTree.currentIndex; + let username = signonsTreeView.getCellText(row, {id : "userCol" }); + clipboard.copyString(username); + Services.telemetry.getHistogramById("PWMGR_MANAGE_COPIED_USERNAME").add(1); +} + +function EditCellInSelectedRow(columnName) { + let row = signonsTree.currentIndex; + let columnElement = getColumnByName(columnName); + signonsTree.startEditing(row, signonsTree.columns.getColumnFor(columnElement)); +} + +function UpdateContextMenu() { + let singleSelection = (signonsTreeView.selection.count == 1); + let menuItems = new Map(); + let menupopup = document.getElementById("signonsTreeContextMenu"); + for (let menuItem of menupopup.querySelectorAll("menuitem")) { + menuItems.set(menuItem.id, menuItem); + } + + if (!singleSelection) { + for (let menuItem of menuItems.values()) { + menuItem.setAttribute("disabled", "true"); + } + return; + } + + let selectedRow = signonsTree.currentIndex; + + // Disable "Copy Username" if the username is empty. + if (signonsTreeView.getCellText(selectedRow, { id: "userCol" }) != "") { + menuItems.get("context-copyusername").removeAttribute("disabled"); + } else { + menuItems.get("context-copyusername").setAttribute("disabled", "true"); + } + + menuItems.get("context-editusername").removeAttribute("disabled"); + menuItems.get("context-copypassword").removeAttribute("disabled"); + + // Disable "Edit Password" if the password column isn't showing. + if (!document.getElementById("passwordCol").hidden) { + menuItems.get("context-editpassword").removeAttribute("disabled"); + } else { + menuItems.get("context-editpassword").setAttribute("disabled", "true"); + } +} + +function masterPasswordLogin(noPasswordCallback) { + // This doesn't harm if passwords are not encrypted + let tokendb = Cc["@mozilla.org/security/pk11tokendb;1"] + .createInstance(Ci.nsIPK11TokenDB); + let token = tokendb.getInternalKeyToken(); + + // If there is no master password, still give the user a chance to opt-out of displaying passwords + if (token.checkPassword("")) + return noPasswordCallback ? noPasswordCallback() : true; + + // So there's a master password. But since checkPassword didn't succeed, we're logged out (per nsIPK11Token.idl). + try { + // Relogin and ask for the master password. + token.login(true); // 'true' means always prompt for token password. User will be prompted until + // clicking 'Cancel' or entering the correct password. + } catch (e) { + // An exception will be thrown if the user cancels the login prompt dialog. + // User is also logged out of Software Security Device. + } + + return token.isLoggedIn(); +} + +function escapeKeyHandler() { + // If editing is currently performed, don't do anything. + if (signonsTree.getAttribute("editing")) { + return; + } + window.close(); +} + +function OpenMigrator() { + const { MigrationUtils } = Cu.import("resource:///modules/MigrationUtils.jsm", {}); + // We pass in the type of source we're using for use in telemetry: + MigrationUtils.showMigrationWizard(window, [MigrationUtils.MIGRATION_ENTRYPOINT_PASSWORDS]); +} diff --git a/toolkit/components/passwordmgr/content/passwordManager.xul b/toolkit/components/passwordmgr/content/passwordManager.xul new file mode 100644 index 000000000..d248283b6 --- /dev/null +++ b/toolkit/components/passwordmgr/content/passwordManager.xul @@ -0,0 +1,134 @@ +<?xml version="1.0"?> <!-- -*- Mode: SGML; indent-tabs-mode: nil -*- --> +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +<?xml-stylesheet href="chrome://global/skin/" type="text/css"?> +<?xml-stylesheet href="chrome://global/skin/passwordmgr.css" type="text/css"?> + +<!DOCTYPE dialog SYSTEM "chrome://passwordmgr/locale/passwordManager.dtd" > + +<window id="SignonViewerDialog" + windowtype="Toolkit:PasswordManager" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + onload="Startup();" + onunload="Shutdown();" + title="&savedLogins.title;" + style="width: 45em;" + persist="width height screenX screenY"> + + <script type="application/javascript" src="chrome://passwordmgr/content/passwordManager.js"/> + + <stringbundle id="signonBundle" + src="chrome://passwordmgr/locale/passwordmgr.properties"/> + + <keyset> + <key keycode="VK_ESCAPE" oncommand="escapeKeyHandler();"/> + <key key="&windowClose.key;" modifiers="accel" oncommand="escapeKeyHandler();"/> + <key key="&focusSearch1.key;" modifiers="accel" oncommand="FocusFilterBox();"/> + <key key="&focusSearch2.key;" modifiers="accel" oncommand="FocusFilterBox();"/> + </keyset> + + <popupset id="signonsTreeContextSet"> + <menupopup id="signonsTreeContextMenu" + onpopupshowing="UpdateContextMenu()"> + <menuitem id="context-copyusername" + label="©UsernameCmd.label;" + accesskey="©UsernameCmd.accesskey;" + oncommand="CopyUsername()"/> + <menuitem id="context-editusername" + label="&editUsernameCmd.label;" + accesskey="&editUsernameCmd.accesskey;" + oncommand="EditCellInSelectedRow('username')"/> + <menuseparator/> + <menuitem id="context-copypassword" + label="©PasswordCmd.label;" + accesskey="©PasswordCmd.accesskey;" + oncommand="CopyPassword()"/> + <menuitem id="context-editpassword" + label="&editPasswordCmd.label;" + accesskey="&editPasswordCmd.accesskey;" + oncommand="EditCellInSelectedRow('password')"/> + </menupopup> + </popupset> + + <!-- saved signons --> + <vbox id="savedsignons" class="contentPane" flex="1"> + <!-- filter --> + <hbox align="center"> + <label accesskey="&filter.accesskey;" control="filter">&filter.label;</label> + <textbox id="filter" flex="1" type="search" + aria-controls="signonsTree" + oncommand="FilterPasswords();"/> + </hbox> + + <label control="signonsTree" id="signonsIntro"/> + <separator class="thin"/> + <tree id="signonsTree" flex="1" + width="750" + style="height: 20em;" + onkeypress="HandleSignonKeyPress(event)" + onselect="SignonSelected();" + editable="true" + context="signonsTreeContextMenu"> + <treecols> + <treecol id="siteCol" label="&treehead.site.label;" flex="40" + data-field-name="hostname" persist="width" + ignoreincolumnpicker="true" + sortDirection="ascending"/> + <splitter class="tree-splitter"/> + <treecol id="userCol" label="&treehead.username.label;" flex="25" + ignoreincolumnpicker="true" + data-field-name="username" persist="width"/> + <splitter class="tree-splitter"/> + <treecol id="passwordCol" label="&treehead.password.label;" flex="15" + ignoreincolumnpicker="true" + data-field-name="password" persist="width" + hidden="true"/> + <splitter class="tree-splitter"/> + <treecol id="timeCreatedCol" label="&treehead.timeCreated.label;" flex="10" + data-field-name="timeCreated" persist="width hidden" + hidden="true"/> + <splitter class="tree-splitter"/> + <treecol id="timeLastUsedCol" label="&treehead.timeLastUsed.label;" flex="20" + data-field-name="timeLastUsed" persist="width hidden" + hidden="true"/> + <splitter class="tree-splitter"/> + <treecol id="timePasswordChangedCol" label="&treehead.timePasswordChanged.label;" flex="10" + data-field-name="timePasswordChanged" persist="width hidden"/> + <splitter class="tree-splitter"/> + <treecol id="timesUsedCol" label="&treehead.timesUsed.label;" flex="1" + data-field-name="timesUsed" persist="width hidden" + hidden="true"/> + <splitter class="tree-splitter"/> + </treecols> + <treechildren/> + </tree> + <separator class="thin"/> + <hbox id="SignonViewerButtons"> + <button id="removeSignon" disabled="true" icon="remove" + label="&remove.label;" accesskey="&remove.accesskey;" + oncommand="DeleteSignon();"/> + <button id="removeAllSignons" icon="clear" + label="&removeall.label;" accesskey="&removeall.accesskey;" + oncommand="DeleteAllSignons();"/> + <spacer flex="1"/> +#if defined(MOZ_BUILD_APP_IS_BROWSER) && defined(XP_WIN) + <button accesskey="&import.accesskey;" + label="&import.label;" + oncommand="OpenMigrator();"/> +#endif + <button id="togglePasswords" + oncommand="TogglePasswordVisible();"/> + </hbox> + </vbox> + <hbox align="end"> + <hbox class="actionButtons" flex="1"> + <spacer flex="1"/> +#ifndef XP_MACOSX + <button oncommand="close();" icon="close" + label="&closebutton.label;" accesskey="&closebutton.accesskey;"/> +#endif + </hbox> + </hbox> +</window> diff --git a/toolkit/components/passwordmgr/content/recipes.json b/toolkit/components/passwordmgr/content/recipes.json new file mode 100644 index 000000000..fc747219b --- /dev/null +++ b/toolkit/components/passwordmgr/content/recipes.json @@ -0,0 +1,31 @@ +{ + "siteRecipes": [ + { + "description": "okta uses a hidden password field to disable filling", + "hosts": ["mozilla.okta.com"], + "passwordSelector": "#pass-signin" + }, + { + "description": "anthem uses a hidden password and username field to disable filling", + "hosts": ["www.anthem.com"], + "passwordSelector": "#LoginContent_txtLoginPass" + }, + { + "description": "An ephemeral password-shim field is incorrectly selected as the username field.", + "hosts": ["www.discover.com"], + "usernameSelector": "#login-account" + }, + { + "description": "Tibia uses type=password for its username field and puts the email address before the password field during registration", + "hosts": ["secure.tibia.com"], + "usernameSelector": "#accountname, input[name='loginname']", + "passwordSelector": "#password1, input[name='loginpassword']", + "pathRegex": "^\/account\/" + }, + { + "description": "Username field will be incorrectly captured in the change password form (bug 1243722)", + "hosts": ["www.facebook.com"], + "notUsernameSelector": "#password_strength" + } + ] +} diff --git a/toolkit/components/passwordmgr/crypto-SDR.js b/toolkit/components/passwordmgr/crypto-SDR.js new file mode 100644 index 000000000..b0916eb29 --- /dev/null +++ b/toolkit/components/passwordmgr/crypto-SDR.js @@ -0,0 +1,207 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper", + "resource://gre/modules/LoginHelper.jsm"); + +function LoginManagerCrypto_SDR() { + this.init(); +} + +LoginManagerCrypto_SDR.prototype = { + + classID : Components.ID("{dc6c2976-0f73-4f1f-b9ff-3d72b4e28309}"), + QueryInterface : XPCOMUtils.generateQI([Ci.nsILoginManagerCrypto]), + + __sdrSlot : null, // PKCS#11 slot being used by the SDR. + get _sdrSlot() { + if (!this.__sdrSlot) { + let modules = Cc["@mozilla.org/security/pkcs11moduledb;1"]. + getService(Ci.nsIPKCS11ModuleDB); + this.__sdrSlot = modules.findSlotByName(""); + } + return this.__sdrSlot; + }, + + __decoderRing : null, // nsSecretDecoderRing service + get _decoderRing() { + if (!this.__decoderRing) + this.__decoderRing = Cc["@mozilla.org/security/sdr;1"]. + getService(Ci.nsISecretDecoderRing); + return this.__decoderRing; + }, + + __utfConverter : null, // UCS2 <--> UTF8 string conversion + get _utfConverter() { + if (!this.__utfConverter) { + this.__utfConverter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]. + createInstance(Ci.nsIScriptableUnicodeConverter); + this.__utfConverter.charset = "UTF-8"; + } + return this.__utfConverter; + }, + + _utfConverterReset : function() { + this.__utfConverter = null; + }, + + _uiBusy : false, + + + init : function () { + // Check to see if the internal PKCS#11 token has been initialized. + // If not, set a blank password. + let tokenDB = Cc["@mozilla.org/security/pk11tokendb;1"]. + getService(Ci.nsIPK11TokenDB); + + let token = tokenDB.getInternalKeyToken(); + if (token.needsUserInit) { + this.log("Initializing key3.db with default blank password."); + token.initPassword(""); + } + }, + + + /* + * encrypt + * + * Encrypts the specified string, using the SecretDecoderRing. + * + * Returns the encrypted string, or throws an exception if there was a + * problem. + */ + encrypt : function (plainText) { + let cipherText = null; + + let wasLoggedIn = this.isLoggedIn; + let canceledMP = false; + + this._uiBusy = true; + try { + let plainOctet = this._utfConverter.ConvertFromUnicode(plainText); + plainOctet += this._utfConverter.Finish(); + cipherText = this._decoderRing.encryptString(plainOctet); + } catch (e) { + this.log("Failed to encrypt string. (" + e.name + ")"); + // If the user clicks Cancel, we get NS_ERROR_FAILURE. + // (unlike decrypting, which gets NS_ERROR_NOT_AVAILABLE). + if (e.result == Cr.NS_ERROR_FAILURE) { + canceledMP = true; + throw Components.Exception("User canceled master password entry", Cr.NS_ERROR_ABORT); + } else { + throw Components.Exception("Couldn't encrypt string", Cr.NS_ERROR_FAILURE); + } + } finally { + this._uiBusy = false; + // If we triggered a master password prompt, notify observers. + if (!wasLoggedIn && this.isLoggedIn) + this._notifyObservers("passwordmgr-crypto-login"); + else if (canceledMP) + this._notifyObservers("passwordmgr-crypto-loginCanceled"); + } + return cipherText; + }, + + + /* + * decrypt + * + * Decrypts the specified string, using the SecretDecoderRing. + * + * Returns the decrypted string, or throws an exception if there was a + * problem. + */ + decrypt : function (cipherText) { + let plainText = null; + + let wasLoggedIn = this.isLoggedIn; + let canceledMP = false; + + this._uiBusy = true; + try { + let plainOctet; + plainOctet = this._decoderRing.decryptString(cipherText); + plainText = this._utfConverter.ConvertToUnicode(plainOctet); + } catch (e) { + this.log("Failed to decrypt string: " + cipherText + + " (" + e.name + ")"); + + // In the unlikely event the converter threw, reset it. + this._utfConverterReset(); + + // If the user clicks Cancel, we get NS_ERROR_NOT_AVAILABLE. + // If the cipherText is bad / wrong key, we get NS_ERROR_FAILURE + // Wrong passwords are handled by the decoderRing reprompting; + // we get no notification. + if (e.result == Cr.NS_ERROR_NOT_AVAILABLE) { + canceledMP = true; + throw Components.Exception("User canceled master password entry", Cr.NS_ERROR_ABORT); + } else { + throw Components.Exception("Couldn't decrypt string", Cr.NS_ERROR_FAILURE); + } + } finally { + this._uiBusy = false; + // If we triggered a master password prompt, notify observers. + if (!wasLoggedIn && this.isLoggedIn) + this._notifyObservers("passwordmgr-crypto-login"); + else if (canceledMP) + this._notifyObservers("passwordmgr-crypto-loginCanceled"); + } + + return plainText; + }, + + + /* + * uiBusy + */ + get uiBusy() { + return this._uiBusy; + }, + + + /* + * isLoggedIn + */ + get isLoggedIn() { + let status = this._sdrSlot.status; + this.log("SDR slot status is " + status); + if (status == Ci.nsIPKCS11Slot.SLOT_READY || + status == Ci.nsIPKCS11Slot.SLOT_LOGGED_IN) + return true; + if (status == Ci.nsIPKCS11Slot.SLOT_NOT_LOGGED_IN) + return false; + throw Components.Exception("unexpected slot status: " + status, Cr.NS_ERROR_FAILURE); + }, + + + /* + * defaultEncType + */ + get defaultEncType() { + return Ci.nsILoginManagerCrypto.ENCTYPE_SDR; + }, + + + /* + * _notifyObservers + */ + _notifyObservers : function(topic) { + this.log("Prompted for a master password, notifying for " + topic); + Services.obs.notifyObservers(null, topic, null); + }, +}; // end of nsLoginManagerCrypto_SDR implementation + +XPCOMUtils.defineLazyGetter(this.LoginManagerCrypto_SDR.prototype, "log", () => { + let logger = LoginHelper.createLogger("Login crypto"); + return logger.log.bind(logger); +}); + +var component = [LoginManagerCrypto_SDR]; +this.NSGetFactory = XPCOMUtils.generateNSGetFactory(component); diff --git a/toolkit/components/passwordmgr/jar.mn b/toolkit/components/passwordmgr/jar.mn new file mode 100644 index 000000000..9fa574e49 --- /dev/null +++ b/toolkit/components/passwordmgr/jar.mn @@ -0,0 +1,9 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +toolkit.jar: +% content passwordmgr %content/passwordmgr/ +* content/passwordmgr/passwordManager.xul (content/passwordManager.xul) + content/passwordmgr/passwordManager.js (content/passwordManager.js) + content/passwordmgr/recipes.json (content/recipes.json) diff --git a/toolkit/components/passwordmgr/moz.build b/toolkit/components/passwordmgr/moz.build new file mode 100644 index 000000000..72c8c70a4 --- /dev/null +++ b/toolkit/components/passwordmgr/moz.build @@ -0,0 +1,78 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +if CONFIG['MOZ_BUILD_APP'] == 'browser': + DEFINES['MOZ_BUILD_APP_IS_BROWSER'] = True + +MOCHITEST_MANIFESTS += ['test/mochitest.ini', 'test/mochitest/mochitest.ini'] +MOCHITEST_CHROME_MANIFESTS += ['test/chrome/chrome.ini'] +BROWSER_CHROME_MANIFESTS += ['test/browser/browser.ini'] +XPCSHELL_TESTS_MANIFESTS += ['test/unit/xpcshell.ini'] + +TESTING_JS_MODULES += [ + # Make this file available from the "resource:" URI of the test environment. + 'test/browser/form_basic.html', + 'test/LoginTestUtils.jsm', +] + +XPIDL_SOURCES += [ + 'nsILoginInfo.idl', + 'nsILoginManager.idl', + 'nsILoginManagerCrypto.idl', + 'nsILoginManagerPrompter.idl', + 'nsILoginManagerStorage.idl', + 'nsILoginMetaInfo.idl', +] + +XPIDL_MODULE = 'loginmgr' + +EXTRA_COMPONENTS += [ + 'crypto-SDR.js', + 'nsLoginInfo.js', + 'nsLoginManager.js', + 'nsLoginManagerPrompter.js', +] + +EXTRA_PP_COMPONENTS += [ + 'passwordmgr.manifest', +] + +EXTRA_JS_MODULES += [ + 'InsecurePasswordUtils.jsm', + 'LoginHelper.jsm', + 'LoginManagerContent.jsm', + 'LoginManagerParent.jsm', + 'LoginRecipes.jsm', + 'OSCrypto.jsm', +] + +if CONFIG['OS_TARGET'] == 'Android': + EXTRA_COMPONENTS += [ + 'storage-mozStorage.js', + ] +else: + EXTRA_COMPONENTS += [ + 'storage-json.js', + ] + EXTRA_JS_MODULES += [ + 'LoginImport.jsm', + 'LoginStore.jsm', + ] + +if CONFIG['OS_TARGET'] == 'WINNT': + EXTRA_JS_MODULES += [ + 'OSCrypto_win.js', + ] + +if CONFIG['MOZ_BUILD_APP'] == 'browser': + EXTRA_JS_MODULES += [ + 'LoginManagerContextMenu.jsm', + ] + +JAR_MANIFESTS += ['jar.mn'] + +with Files('**'): + BUG_COMPONENT = ('Toolkit', 'Password Manager') diff --git a/toolkit/components/passwordmgr/nsILoginInfo.idl b/toolkit/components/passwordmgr/nsILoginInfo.idl new file mode 100644 index 000000000..7dce9033d --- /dev/null +++ b/toolkit/components/passwordmgr/nsILoginInfo.idl @@ -0,0 +1,120 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + + +#include "nsISupports.idl" + +[scriptable, uuid(c41b7dff-6b9b-42fe-b78d-113051facb05)] + +/** + * An object containing information for a login stored by the + * password manager. + */ +interface nsILoginInfo : nsISupports { + /** + * The hostname the login applies to. + * + * The hostname should be formatted as an URL. For example, + * "https://site.com", "http://site.com:1234", "ftp://ftp.site.com". + */ + attribute AString hostname; + + /** + * The URL a form-based login was submitted to. + * + * For logins obtained from HTML forms, this field is the |action| + * attribute from the |form| element, with the path removed. For + * example "http://www.site.com". [Forms with no |action| attribute + * default to submitting to their origin URL, so we store that.] + * + * For logins obtained from a HTTP or FTP protocol authentication, + * this field is NULL. + */ + attribute AString formSubmitURL; + + /** + * The HTTP Realm a login was requested for. + * + * When an HTTP server sends a 401 result, the WWW-Authenticate + * header includes a realm to identify the "protection space." See + * RFC2617. If the response sent has a missing or blank realm, the + * hostname is used instead. + * + * For logins obtained from HTML forms, this field is NULL. + */ + attribute AString httpRealm; + + /** + * The username for the login. + */ + attribute AString username; + + /** + * The |name| attribute for the username input field. + * + * For logins obtained from a HTTP or FTP protocol authentication, + * this field is an empty string. + */ + attribute AString usernameField; + + /** + * The password for the login. + */ + attribute AString password; + + /** + * The |name| attribute for the password input field. + * + * For logins obtained from a HTTP or FTP protocol authentication, + * this field is an empty string. + */ + attribute AString passwordField; + + /** + * Initialize a newly created nsLoginInfo object. + * + * The arguments are the fields for the new object. + */ + void init(in AString aHostname, + in AString aFormSubmitURL, in AString aHttpRealm, + in AString aUsername, in AString aPassword, + in AString aUsernameField, in AString aPasswordField); + + /** + * Test for strict equality with another nsILoginInfo object. + * + * @param aLoginInfo + * The other object to test. + */ + boolean equals(in nsILoginInfo aLoginInfo); + + /** + * Test for loose equivalency with another nsILoginInfo object. The + * passwordField and usernameField values are ignored, and the password + * values may be optionally ignored. If one login's formSubmitURL is an + * empty string (but not null), it will be treated as a wildcard. [The + * blank value indicates the login was stored before bug 360493 was fixed.] + * + * @param aLoginInfo + * The other object to test. + * @param ignorePassword + * If true, ignore the password when checking for match. + */ + boolean matches(in nsILoginInfo aLoginInfo, in boolean ignorePassword); + + /** + * Create an identical copy of the login, duplicating all of the login's + * nsILoginInfo and nsILoginMetaInfo properties. + * + * This allows code to be forwards-compatible, when additional properties + * are added to nsILoginMetaInfo (or nsILoginInfo) in the future. + */ + nsILoginInfo clone(); +}; + +%{C++ + +#define NS_LOGININFO_CONTRACTID "@mozilla.org/login-manager/loginInfo;1" + +%} diff --git a/toolkit/components/passwordmgr/nsILoginManager.idl b/toolkit/components/passwordmgr/nsILoginManager.idl new file mode 100644 index 000000000..30b5a0449 --- /dev/null +++ b/toolkit/components/passwordmgr/nsILoginManager.idl @@ -0,0 +1,262 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + + +#include "nsISupports.idl" + +interface nsIURI; +interface nsILoginInfo; +interface nsIAutoCompleteResult; +interface nsIFormAutoCompleteObserver; +interface nsIDOMHTMLInputElement; +interface nsIDOMHTMLFormElement; +interface nsIPropertyBag; + +[scriptable, uuid(38c7f6af-7df9-49c7-b558-2776b24e6cc1)] +interface nsILoginManager : nsISupports { + /** + * This promise is resolved when initialization is complete, and is rejected + * in case initialization failed. This includes the initial loading of the + * login data as well as any migration from previous versions. + * + * Calling any method of nsILoginManager before this promise is resolved + * might trigger the synchronous initialization fallback. + */ + readonly attribute jsval initializationPromise; + + + /** + * Store a new login in the login manager. + * + * @param aLogin + * The login to be added. + * @return a clone of the login info with the guid set (even if it was not provided) + * + * Default values for the login's nsILoginMetaInfo properties will be + * created. However, if the caller specifies non-default values, they will + * be used instead. + */ + nsILoginInfo addLogin(in nsILoginInfo aLogin); + + + /** + * Remove a login from the login manager. + * + * @param aLogin + * The login to be removed. + * + * The specified login must exactly match a stored login. However, the + * values of any nsILoginMetaInfo properties are ignored. + */ + void removeLogin(in nsILoginInfo aLogin); + + + /** + * Modify an existing login in the login manager. + * + * @param oldLogin + * The login to be modified. + * @param newLoginData + * The new login values (either a nsILoginInfo or nsIProperyBag) + * + * If newLoginData is a nsILoginInfo, all of the old login's nsILoginInfo + * properties are changed to the values from newLoginData (but the old + * login's nsILoginMetaInfo properties are unmodified). + * + * If newLoginData is a nsIPropertyBag, only the specified properties + * will be changed. The nsILoginMetaInfo properties of oldLogin can be + * changed in this manner. + * + * If the propertybag contains an item named "timesUsedIncrement", the + * login's timesUsed property will be incremented by the item's value. + */ + void modifyLogin(in nsILoginInfo oldLogin, in nsISupports newLoginData); + + + /** + * Remove all logins known to login manager. + * + * The browser sanitization feature allows the user to clear any stored + * passwords. This interface allows that to be done without getting each + * login first (which might require knowing the master password). + * + */ + void removeAllLogins(); + + + /** + * Fetch all logins in the login manager. An array is always returned; + * if there are no logins the array is empty. + * + * @param count + * The number of elements in the array. JS callers can simply use + * the array's .length property and omit this param. + * @param logins + * An array of nsILoginInfo objects. + * + * NOTE: This can be called from JS as: + * var logins = pwmgr.getAllLogins(); + * (|logins| is an array). + */ + void getAllLogins([optional] out unsigned long count, + [retval, array, size_is(count)] out nsILoginInfo logins); + + + /** + * Obtain a list of all hosts for which password saving is disabled. + * + * @param count + * The number of elements in the array. JS callers can simply use + * the array's .length property and omit this param. + * @param hostnames + * An array of hostname strings, in origin URL format without a + * pathname. For example: "https://www.site.com". + * + * NOTE: This can be called from JS as: + * var logins = pwmgr.getDisabledAllLogins(); + */ + void getAllDisabledHosts([optional] out unsigned long count, + [retval, array, size_is(count)] out wstring hostnames); + + + /** + * Check to see if saving logins has been disabled for a host. + * + * @param aHost + * The hostname to check. This argument should be in the origin + * URL format, without a pathname. For example: "http://foo.com". + */ + boolean getLoginSavingEnabled(in AString aHost); + + + /** + * Disable (or enable) storing logins for the specified host. When + * disabled, the login manager will not prompt to store logins for + * that host. Existing logins are not affected. + * + * @param aHost + * The hostname to set. This argument should be in the origin + * URL format, without a pathname. For example: "http://foo.com". + * @param isEnabled + * Specify if saving logins should be enabled (true) or + * disabled (false) + */ + void setLoginSavingEnabled(in AString aHost, in boolean isEnabled); + + + /** + * Search for logins matching the specified criteria. Called when looking + * for logins that might be applicable to a form or authentication request. + * + * @param count + * The number of elements in the array. JS callers can simply use + * the array's .length property, and supply an dummy object for + * this out param. For example: |findLogins({}, hostname, ...)| + * @param aHostname + * The hostname to restrict searches to, in URL format. For + * example: "http://www.site.com". + * To find logins for a given nsIURI, you would typically pass in + * its prePath. + * @param aActionURL + * For form logins, this argument should be the URL to which the + * form will be submitted. For protocol logins, specify null. + * An empty string ("") will match any value (except null). + * @param aHttpRealm + * For protocol logins, this argument should be the HTTP Realm + * for which the login applies. This is obtained from the + * WWW-Authenticate header. See RFC2617. For form logins, + * specify null. + * An empty string ("") will match any value (except null). + * @param logins + * An array of nsILoginInfo objects. + * + * NOTE: This can be called from JS as: + * var logins = pwmgr.findLogins({}, hostname, ...); + * + */ + void findLogins(out unsigned long count, in AString aHostname, + in AString aActionURL, in AString aHttpRealm, + [retval, array, size_is(count)] out nsILoginInfo logins); + + + /** + * Search for logins matching the specified criteria, as with + * findLogins(). This interface only returns the number of matching + * logins (and not the logins themselves), which allows a caller to + * check for logins without causing the user to be prompted for a master + * password to decrypt the logins. + * + * @param aHostname + * The hostname to restrict searches to. Specify an empty string + * to match all hosts. A null value will not match any logins, and + * will thus always return a count of 0. + * @param aActionURL + * The URL to which a form login will be submitted. To match any + * form login, specify an empty string. To not match any form + * login, specify null. + * @param aHttpRealm + * The HTTP Realm for which the login applies. To match logins for + * any realm, specify an empty string. To not match logins for any + * realm, specify null. + */ + unsigned long countLogins(in AString aHostname, in AString aActionURL, + in AString aHttpRealm); + + + /** + * Generate results for a userfield autocomplete menu. + * + * NOTE: This interface is provided for use only by the FormFillController, + * which calls it directly. This isn't really ideal, it should + * probably be callback registered through the FFC. + */ + void autoCompleteSearchAsync(in AString aSearchString, + in nsIAutoCompleteResult aPreviousResult, + in nsIDOMHTMLInputElement aElement, + in nsIFormAutoCompleteObserver aListener); + + /** + * Stop a previously-started async search. + */ + void stopSearch(); + + /** + * Search for logins in the login manager. An array is always returned; + * if there are no logins the array is empty. + * + * @param count + * The number of elements in the array. JS callers can simply use + * the array's .length property, and supply an dummy object for + * this out param. For example: |searchLogins({}, matchData)| + * @param matchData + * The data used to search. This does not follow the same + * requirements as findLogins for those fields. Wildcard matches are + * simply not specified. + * @param logins + * An array of nsILoginInfo objects. + * + * NOTE: This can be called from JS as: + * var logins = pwmgr.searchLogins({}, matchData); + * (|logins| is an array). + */ + void searchLogins(out unsigned long count, in nsIPropertyBag matchData, + [retval, array, size_is(count)] out nsILoginInfo logins); + + /** + * True when a master password prompt is being displayed. + */ + readonly attribute boolean uiBusy; + + /** + * True when the master password has already been entered, and so a caller + * can ask for decrypted logins without triggering a prompt. + */ + readonly attribute boolean isLoggedIn; +}; + +%{C++ + +#define NS_LOGINMANAGER_CONTRACTID "@mozilla.org/login-manager;1" + +%} diff --git a/toolkit/components/passwordmgr/nsILoginManagerCrypto.idl b/toolkit/components/passwordmgr/nsILoginManagerCrypto.idl new file mode 100644 index 000000000..8af36a258 --- /dev/null +++ b/toolkit/components/passwordmgr/nsILoginManagerCrypto.idl @@ -0,0 +1,67 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + + +#include "nsISupports.idl" + +[scriptable, uuid(2030770e-542e-40cd-8061-cd9d4ad4227f)] + +interface nsILoginManagerCrypto : nsISupports { + + const unsigned long ENCTYPE_BASE64 = 0; // obsolete + const unsigned long ENCTYPE_SDR = 1; + + /** + * encrypt + * + * @param plainText + * The string to be encrypted. + * + * Encrypts the specified string, returning the ciphertext value. + * + * NOTE: The current implemention of this inferface simply uses NSS/PSM's + * "Secret Decoder Ring" service. It is not recommended for general + * purpose encryption/decryption. + * + * Can throw if the user cancels entry of their master password. + */ + AString encrypt(in AString plainText); + + /** + * decrypt + * + * @param cipherText + * The string to be decrypted. + * + * Decrypts the specified string, returning the plaintext value. + * + * Can throw if the user cancels entry of their master password, or if the + * cipherText value can not be successfully decrypted (eg, if it was + * encrypted with some other key). + */ + AString decrypt(in AString cipherText); + + /** + * uiBusy + * + * True when a master password prompt is being displayed. + */ + readonly attribute boolean uiBusy; + + /** + * isLoggedIn + * + * Current login state of the token used for encryption. If the user is + * not logged in, performing a crypto operation will result in a master + * password prompt. + */ + readonly attribute boolean isLoggedIn; + + /** + * defaultEncType + * + * Default encryption type used by an implementation of this interface. + */ + readonly attribute unsigned long defaultEncType; +}; diff --git a/toolkit/components/passwordmgr/nsILoginManagerPrompter.idl b/toolkit/components/passwordmgr/nsILoginManagerPrompter.idl new file mode 100644 index 000000000..c673154d1 --- /dev/null +++ b/toolkit/components/passwordmgr/nsILoginManagerPrompter.idl @@ -0,0 +1,94 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + + +#include "nsISupports.idl" + +interface nsILoginInfo; +interface nsIDOMElement; +interface nsIDOMWindow; + +[scriptable, uuid(425f73b9-b2db-4e8a-88c5-9ac2512934ce)] +interface nsILoginManagerPrompter : nsISupports { + /** + * Initialize the prompter. Must be called before using other interfaces. + * + * @param aWindow + * The window in which the user is doing some login-related action that's + * resulting in a need to prompt them for something. The prompt + * will be associated with this window (or, if a notification bar + * is being used, topmost opener in some cases). + * + * aWindow can be null if there is no associated window, e.g. in a JSM + * or Sandbox. In this case there will be no checkbox to save the login + * since the window is needed to know if this is a private context. + * + * If this window is a content window, the corresponding window and browser + * elements will be calculated. If this window is a chrome window, the + * corresponding browser element needs to be set using setBrowser. + */ + void init(in nsIDOMWindow aWindow); + + /** + * The browser this prompter is being created for. + * This is required if the init function received a chrome window as argument. + */ + attribute nsIDOMElement browser; + + /** + * The opener that was used to open the window passed to init. + * The opener can be used to determine in which window the prompt + * should be shown. Must be a content window that is not a frame window, + * make sure to pass the top window using e.g. window.top. + */ + attribute nsIDOMWindow opener; + + /** + * Ask the user if they want to save a login (Yes, Never, Not Now) + * + * @param aLogin + * The login to be saved. + */ + void promptToSavePassword(in nsILoginInfo aLogin); + + /** + * Ask the user if they want to change a login's password or username. + * If the user consents, modifyLogin() will be called. + * + * @param aOldLogin + * The existing login (with the old password). + * @param aNewLogin + * The new login. + */ + void promptToChangePassword(in nsILoginInfo aOldLogin, + in nsILoginInfo aNewLogin); + + /** + * Ask the user if they want to change the password for one of + * multiple logins, when the caller can't determine exactly which + * login should be changed. If the user consents, modifyLogin() will + * be called. + * + * @param logins + * An array of existing logins. + * @param count + * (length of the array) + * @param aNewLogin + * The new login. + * + * Note: Because the caller does not know the username of the login + * to be changed, aNewLogin.username and aNewLogin.usernameField + * will be set (using the user's selection) before modifyLogin() + * is called. + */ + void promptToChangePasswordWithUsernames( + [array, size_is(count)] in nsILoginInfo logins, + in uint32_t count, + in nsILoginInfo aNewLogin); +}; +%{C++ + +#define NS_LOGINMANAGERPROMPTER_CONTRACTID "@mozilla.org/login-manager/prompter/;1" + +%} diff --git a/toolkit/components/passwordmgr/nsILoginManagerStorage.idl b/toolkit/components/passwordmgr/nsILoginManagerStorage.idl new file mode 100644 index 000000000..4ad3dbfe9 --- /dev/null +++ b/toolkit/components/passwordmgr/nsILoginManagerStorage.idl @@ -0,0 +1,211 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + + +#include "nsISupports.idl" + +interface nsIFile; +interface nsILoginInfo; +interface nsIPropertyBag; + +[scriptable, uuid(5df81a93-25e6-4b45-a696-089479e15c7d)] + +/* + * NOTE: This interface is intended to be implemented by modules + * providing storage mechanisms for the login manager. + * Other code should use the login manager's interfaces + * (nsILoginManager), and should not call storage modules + * directly. + */ +interface nsILoginManagerStorage : nsISupports { + /** + * Initialize the component. + * + * At present, other methods of this interface may be called before the + * returned promise is resolved or rejected. + * + * @return {Promise} + * @resolves When initialization is complete. + * @rejects JavaScript exception. + */ + jsval initialize(); + + + /** + * Ensures that all data has been written to disk and all files are closed. + * + * At present, this method is called by regression tests only. Finalization + * on shutdown is done by observers within the component. + * + * @return {Promise} + * @resolves When finalization is complete. + * @rejects JavaScript exception. + */ + jsval terminate(); + + + /** + * Store a new login in the storage module. + * + * @param aLogin + * The login to be added. + * @return a clone of the login info with the guid set (even if it was not provided). + * + * Default values for the login's nsILoginMetaInfo properties will be + * created. However, if the caller specifies non-default values, they will + * be used instead. + */ + nsILoginInfo addLogin(in nsILoginInfo aLogin); + + + /** + * Remove a login from the storage module. + * + * @param aLogin + * The login to be removed. + * + * The specified login must exactly match a stored login. However, the + * values of any nsILoginMetaInfo properties are ignored. + */ + void removeLogin(in nsILoginInfo aLogin); + + + /** + * Modify an existing login in the storage module. + * + * @param oldLogin + * The login to be modified. + * @param newLoginData + * The new login values (either a nsILoginInfo or nsIProperyBag) + * + * If newLoginData is a nsILoginInfo, all of the old login's nsILoginInfo + * properties are changed to the values from newLoginData (but the old + * login's nsILoginMetaInfo properties are unmodified). + * + * If newLoginData is a nsIPropertyBag, only the specified properties + * will be changed. The nsILoginMetaInfo properties of oldLogin can be + * changed in this manner. + * + * If the propertybag contains an item named "timesUsedIncrement", the + * login's timesUsed property will be incremented by the item's value. + */ + void modifyLogin(in nsILoginInfo oldLogin, in nsISupports newLoginData); + + + /** + * Remove all stored logins. + * + * The browser sanitization feature allows the user to clear any stored + * passwords. This interface allows that to be done without getting each + * login first (which might require knowing the master password). + * + */ + void removeAllLogins(); + + + /** + * Fetch all logins in the login manager. An array is always returned; + * if there are no logins the array is empty. + * + * @param count + * The number of elements in the array. JS callers can simply use + * the array's .length property and omit this param. + * @param logins + * An array of nsILoginInfo objects. + * + * NOTE: This can be called from JS as: + * var logins = pwmgr.getAllLogins(); + * (|logins| is an array). + */ + void getAllLogins([optional] out unsigned long count, + [retval, array, size_is(count)] out nsILoginInfo logins); + + + /** + * Search for logins in the login manager. An array is always returned; + * if there are no logins the array is empty. + * + * @param count + * The number of elements in the array. JS callers can simply use + * the array's .length property, and supply an dummy object for + * this out param. For example: |searchLogins({}, matchData)| + * @param matchData + * The data used to search. This does not follow the same + * requirements as findLogins for those fields. Wildcard matches are + * simply not specified. + * @param logins + * An array of nsILoginInfo objects. + * + * NOTE: This can be called from JS as: + * var logins = pwmgr.searchLogins({}, matchData); + * (|logins| is an array). + */ + void searchLogins(out unsigned long count, in nsIPropertyBag matchData, + [retval, array, size_is(count)] out nsILoginInfo logins); + + + /** + * Search for logins matching the specified criteria. Called when looking + * for logins that might be applicable to a form or authentication request. + * + * @param count + * The number of elements in the array. JS callers can simply use + * the array's .length property, and supply an dummy object for + * this out param. For example: |findLogins({}, hostname, ...)| + * @param aHostname + * The hostname to restrict searches to, in URL format. For + * example: "http://www.site.com". + * @param aActionURL + * For form logins, this argument should be the URL to which the + * form will be submitted. For protocol logins, specify null. + * @param aHttpRealm + * For protocol logins, this argument should be the HTTP Realm + * for which the login applies. This is obtained from the + * WWW-Authenticate header. See RFC2617. For form logins, + * specify null. + * @param logins + * An array of nsILoginInfo objects. + * + * NOTE: This can be called from JS as: + * var logins = pwmgr.findLogins({}, hostname, ...); + * + */ + void findLogins(out unsigned long count, in AString aHostname, + in AString aActionURL, in AString aHttpRealm, + [retval, array, size_is(count)] out nsILoginInfo logins); + + + /** + * Search for logins matching the specified criteria, as with + * findLogins(). This interface only returns the number of matching + * logins (and not the logins themselves), which allows a caller to + * check for logins without causing the user to be prompted for a master + * password to decrypt the logins. + * + * @param aHostname + * The hostname to restrict searches to. Specify an empty string + * to match all hosts. A null value will not match any logins, and + * will thus always return a count of 0. + * @param aActionURL + * The URL to which a form login will be submitted. To match any + * form login, specify an empty string. To not match any form + * login, specify null. + * @param aHttpRealm + * The HTTP Realm for which the login applies. To match logins for + * any realm, specify an empty string. To not match logins for any + * realm, specify null. + */ + unsigned long countLogins(in AString aHostname, in AString aActionURL, + in AString aHttpRealm); + /** + * True when a master password prompt is being shown. + */ + readonly attribute boolean uiBusy; + + /** + * True when the master password has already been entered, and so a caller + * can ask for decrypted logins without triggering a prompt. + */ + readonly attribute boolean isLoggedIn; +}; diff --git a/toolkit/components/passwordmgr/nsILoginMetaInfo.idl b/toolkit/components/passwordmgr/nsILoginMetaInfo.idl new file mode 100644 index 000000000..92d8f2bc8 --- /dev/null +++ b/toolkit/components/passwordmgr/nsILoginMetaInfo.idl @@ -0,0 +1,55 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + + +#include "nsISupports.idl" + +[scriptable, uuid(20d8eb40-c494-497f-b2a6-aaa32f807ebd)] + +/** + * An object containing metainfo for a login stored by the login manager. + * + * Code using login manager can generally ignore this interface. When adding + * logins, default value will be created. When modifying logins, these + * properties will be unchanged unless a change is explicitly requested [by + * using modifyLogin() with a nsIPropertyBag]. When deleting a login or + * comparing logins, these properties are ignored. + */ +interface nsILoginMetaInfo : nsISupports { + /** + * The GUID to uniquely identify the login. This can be any arbitrary + * string, but a format as created by nsIUUIDGenerator is recommended. + * For example, "{d4e1a1f6-5ea0-40ee-bff5-da57982f21cf}" + * + * addLogin will generate a random value unless a value is provided. + * + * addLogin and modifyLogin will throw if the GUID already exists. + */ + attribute AString guid; + + /** + * The time, in Unix Epoch milliseconds, when the login was first created. + */ + attribute unsigned long long timeCreated; + + /** + * The time, in Unix Epoch milliseconds, when the login was last submitted + * in a form or used to begin an HTTP auth session. + */ + attribute unsigned long long timeLastUsed; + + /** + * The time, in Unix Epoch milliseconds, when the login was last modified. + * + * Contrary to what the name may suggest, this attribute takes into account + * not only the password but also the username attribute. + */ + attribute unsigned long long timePasswordChanged; + + /** + * The number of times the login was submitted in a form or used to begin + * an HTTP auth session. + */ + attribute unsigned long timesUsed; +}; diff --git a/toolkit/components/passwordmgr/nsLoginInfo.js b/toolkit/components/passwordmgr/nsLoginInfo.js new file mode 100644 index 000000000..d6ea86446 --- /dev/null +++ b/toolkit/components/passwordmgr/nsLoginInfo.js @@ -0,0 +1,93 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper", + "resource://gre/modules/LoginHelper.jsm"); + + +function nsLoginInfo() {} + +nsLoginInfo.prototype = { + + classID : Components.ID("{0f2f347c-1e4f-40cc-8efd-792dea70a85e}"), + QueryInterface: XPCOMUtils.generateQI([Ci.nsILoginInfo, Ci.nsILoginMetaInfo]), + + // + // nsILoginInfo interfaces... + // + + hostname : null, + formSubmitURL : null, + httpRealm : null, + username : null, + password : null, + usernameField : null, + passwordField : null, + + init : function (aHostname, aFormSubmitURL, aHttpRealm, + aUsername, aPassword, + aUsernameField, aPasswordField) { + this.hostname = aHostname; + this.formSubmitURL = aFormSubmitURL; + this.httpRealm = aHttpRealm; + this.username = aUsername; + this.password = aPassword; + this.usernameField = aUsernameField; + this.passwordField = aPasswordField; + }, + + matches(aLogin, ignorePassword) { + return LoginHelper.doLoginsMatch(this, aLogin, { + ignorePassword, + }); + }, + + equals : function (aLogin) { + if (this.hostname != aLogin.hostname || + this.formSubmitURL != aLogin.formSubmitURL || + this.httpRealm != aLogin.httpRealm || + this.username != aLogin.username || + this.password != aLogin.password || + this.usernameField != aLogin.usernameField || + this.passwordField != aLogin.passwordField) + return false; + + return true; + }, + + clone : function() { + let clone = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + clone.init(this.hostname, this.formSubmitURL, this.httpRealm, + this.username, this.password, + this.usernameField, this.passwordField); + + // Copy nsILoginMetaInfo props + clone.QueryInterface(Ci.nsILoginMetaInfo); + clone.guid = this.guid; + clone.timeCreated = this.timeCreated; + clone.timeLastUsed = this.timeLastUsed; + clone.timePasswordChanged = this.timePasswordChanged; + clone.timesUsed = this.timesUsed; + + return clone; + }, + + // + // nsILoginMetaInfo interfaces... + // + + guid : null, + timeCreated : null, + timeLastUsed : null, + timePasswordChanged : null, + timesUsed : null + +}; // end of nsLoginInfo implementation + +this.NSGetFactory = XPCOMUtils.generateNSGetFactory([nsLoginInfo]); diff --git a/toolkit/components/passwordmgr/nsLoginManager.js b/toolkit/components/passwordmgr/nsLoginManager.js new file mode 100644 index 000000000..514351fa5 --- /dev/null +++ b/toolkit/components/passwordmgr/nsLoginManager.js @@ -0,0 +1,541 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; + +const PERMISSION_SAVE_LOGINS = "login-saving"; + +Cu.import("resource://gre/modules/AppConstants.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Timer.jsm"); +Cu.import("resource://gre/modules/LoginManagerContent.jsm"); /* global UserAutoCompleteResult */ + +XPCOMUtils.defineLazyModuleGetter(this, "Promise", + "resource://gre/modules/Promise.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "BrowserUtils", + "resource://gre/modules/BrowserUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper", + "resource://gre/modules/LoginHelper.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "LoginFormFactory", + "resource://gre/modules/LoginManagerContent.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "InsecurePasswordUtils", + "resource://gre/modules/InsecurePasswordUtils.jsm"); + +XPCOMUtils.defineLazyGetter(this, "log", () => { + let logger = LoginHelper.createLogger("nsLoginManager"); + return logger; +}); + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +function LoginManager() { + this.init(); +} + +LoginManager.prototype = { + + classID: Components.ID("{cb9e0de8-3598-4ed7-857b-827f011ad5d8}"), + QueryInterface: XPCOMUtils.generateQI([Ci.nsILoginManager, + Ci.nsISupportsWeakReference, + Ci.nsIInterfaceRequestor]), + getInterface(aIID) { + if (aIID.equals(Ci.mozIStorageConnection) && this._storage) { + let ir = this._storage.QueryInterface(Ci.nsIInterfaceRequestor); + return ir.getInterface(aIID); + } + + if (aIID.equals(Ci.nsIVariant)) { + // Allows unwrapping the JavaScript object for regression tests. + return this; + } + + throw new Components.Exception("Interface not available", Cr.NS_ERROR_NO_INTERFACE); + }, + + + /* ---------- private members ---------- */ + + + __formFillService: null, // FormFillController, for username autocompleting + get _formFillService() { + if (!this.__formFillService) { + this.__formFillService = Cc["@mozilla.org/satchel/form-fill-controller;1"]. + getService(Ci.nsIFormFillController); + } + return this.__formFillService; + }, + + + _storage: null, // Storage component which contains the saved logins + _prefBranch: null, // Preferences service + _remember: true, // mirrors signon.rememberSignons preference + + + /** + * Initialize the Login Manager. Automatically called when service + * is created. + * + * Note: Service created in /browser/base/content/browser.js, + * delayedStartup() + */ + init() { + + // Cache references to current |this| in utility objects + this._observer._pwmgr = this; + + // Preferences. Add observer so we get notified of changes. + this._prefBranch = Services.prefs.getBranch("signon."); + this._prefBranch.addObserver("rememberSignons", this._observer, false); + + this._remember = this._prefBranch.getBoolPref("rememberSignons"); + this._autoCompleteLookupPromise = null; + + // Form submit observer checks forms for new logins and pw changes. + Services.obs.addObserver(this._observer, "xpcom-shutdown", false); + + if (Services.appinfo.processType === + Services.appinfo.PROCESS_TYPE_DEFAULT) { + Services.obs.addObserver(this._observer, "passwordmgr-storage-replace", + false); + + // Initialize storage so that asynchronous data loading can start. + this._initStorage(); + } + + Services.obs.addObserver(this._observer, "gather-telemetry", false); + }, + + + _initStorage() { + let contractID; + if (AppConstants.platform == "android") { + contractID = "@mozilla.org/login-manager/storage/mozStorage;1"; + } else { + contractID = "@mozilla.org/login-manager/storage/json;1"; + } + try { + let catMan = Cc["@mozilla.org/categorymanager;1"]. + getService(Ci.nsICategoryManager); + contractID = catMan.getCategoryEntry("login-manager-storage", + "nsILoginManagerStorage"); + log.debug("Found alternate nsILoginManagerStorage with contract ID:", contractID); + } catch (e) { + log.debug("No alternate nsILoginManagerStorage registered"); + } + + this._storage = Cc[contractID]. + createInstance(Ci.nsILoginManagerStorage); + this.initializationPromise = this._storage.initialize(); + }, + + + /* ---------- Utility objects ---------- */ + + + /** + * Internal utility object, implements the nsIObserver interface. + * Used to receive notification for: form submission, preference changes. + */ + _observer: { + _pwmgr: null, + + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver, + Ci.nsISupportsWeakReference]), + + // nsIObserver + observe(subject, topic, data) { + if (topic == "nsPref:changed") { + var prefName = data; + log.debug("got change to", prefName, "preference"); + + if (prefName == "rememberSignons") { + this._pwmgr._remember = + this._pwmgr._prefBranch.getBoolPref("rememberSignons"); + } else { + log.debug("Oops! Pref not handled, change ignored."); + } + } else if (topic == "xpcom-shutdown") { + delete this._pwmgr.__formFillService; + delete this._pwmgr._storage; + delete this._pwmgr._prefBranch; + this._pwmgr = null; + } else if (topic == "passwordmgr-storage-replace") { + Task.spawn(function* () { + yield this._pwmgr._storage.terminate(); + this._pwmgr._initStorage(); + yield this._pwmgr.initializationPromise; + Services.obs.notifyObservers(null, + "passwordmgr-storage-replace-complete", null); + }.bind(this)); + } else if (topic == "gather-telemetry") { + // When testing, the "data" parameter is a string containing the + // reference time in milliseconds for time-based statistics. + this._pwmgr._gatherTelemetry(data ? parseInt(data) + : new Date().getTime()); + } else { + log.debug("Oops! Unexpected notification:", topic); + } + } + }, + + /** + * Collects statistics about the current logins and settings. The telemetry + * histograms used here are not accumulated, but are reset each time this + * function is called, since it can be called multiple times in a session. + * + * This function might also not be called at all in the current session. + * + * @param referenceTimeMs + * Current time used to calculate time-based statistics, expressed as + * the number of milliseconds since January 1, 1970, 00:00:00 UTC. + * This is set to a fake value during unit testing. + */ + _gatherTelemetry(referenceTimeMs) { + function clearAndGetHistogram(histogramId) { + let histogram = Services.telemetry.getHistogramById(histogramId); + histogram.clear(); + return histogram; + } + + clearAndGetHistogram("PWMGR_BLOCKLIST_NUM_SITES").add( + this.getAllDisabledHosts({}).length + ); + clearAndGetHistogram("PWMGR_NUM_SAVED_PASSWORDS").add( + this.countLogins("", "", "") + ); + clearAndGetHistogram("PWMGR_NUM_HTTPAUTH_PASSWORDS").add( + this.countLogins("", null, "") + ); + + // This is a boolean histogram, and not a flag, because we don't want to + // record any value if _gatherTelemetry is not called. + clearAndGetHistogram("PWMGR_SAVING_ENABLED").add(this._remember); + + // Don't try to get logins if MP is enabled, since we don't want to show a MP prompt. + if (!this.isLoggedIn) { + return; + } + + let logins = this.getAllLogins({}); + + let usernamePresentHistogram = clearAndGetHistogram("PWMGR_USERNAME_PRESENT"); + let loginLastUsedDaysHistogram = clearAndGetHistogram("PWMGR_LOGIN_LAST_USED_DAYS"); + + let hostnameCount = new Map(); + for (let login of logins) { + usernamePresentHistogram.add(!!login.username); + + let hostname = login.hostname; + hostnameCount.set(hostname, (hostnameCount.get(hostname) || 0 ) + 1); + + login.QueryInterface(Ci.nsILoginMetaInfo); + let timeLastUsedAgeMs = referenceTimeMs - login.timeLastUsed; + if (timeLastUsedAgeMs > 0) { + loginLastUsedDaysHistogram.add( + Math.floor(timeLastUsedAgeMs / MS_PER_DAY) + ); + } + } + + let passwordsCountHistogram = clearAndGetHistogram("PWMGR_NUM_PASSWORDS_PER_HOSTNAME"); + for (let count of hostnameCount.values()) { + passwordsCountHistogram.add(count); + } + }, + + + + + + /* ---------- Primary Public interfaces ---------- */ + + + + + /** + * @type Promise + * This promise is resolved when initialization is complete, and is rejected + * in case the asynchronous part of initialization failed. + */ + initializationPromise: null, + + + /** + * Add a new login to login storage. + */ + addLogin(login) { + // Sanity check the login + if (login.hostname == null || login.hostname.length == 0) { + throw new Error("Can't add a login with a null or empty hostname."); + } + + // For logins w/o a username, set to "", not null. + if (login.username == null) { + throw new Error("Can't add a login with a null username."); + } + + if (login.password == null || login.password.length == 0) { + throw new Error("Can't add a login with a null or empty password."); + } + + if (login.formSubmitURL || login.formSubmitURL == "") { + // We have a form submit URL. Can't have a HTTP realm. + if (login.httpRealm != null) { + throw new Error("Can't add a login with both a httpRealm and formSubmitURL."); + } + } else if (login.httpRealm) { + // We have a HTTP realm. Can't have a form submit URL. + if (login.formSubmitURL != null) { + throw new Error("Can't add a login with both a httpRealm and formSubmitURL."); + } + } else { + // Need one or the other! + throw new Error("Can't add a login without a httpRealm or formSubmitURL."); + } + + + // Look for an existing entry. + var logins = this.findLogins({}, login.hostname, login.formSubmitURL, + login.httpRealm); + + if (logins.some(l => login.matches(l, true))) { + throw new Error("This login already exists."); + } + + log.debug("Adding login"); + return this._storage.addLogin(login); + }, + + /** + * Remove the specified login from the stored logins. + */ + removeLogin(login) { + log.debug("Removing login"); + return this._storage.removeLogin(login); + }, + + + /** + * Change the specified login to match the new login. + */ + modifyLogin(oldLogin, newLogin) { + log.debug("Modifying login"); + return this._storage.modifyLogin(oldLogin, newLogin); + }, + + + /** + * Get a dump of all stored logins. Used by the login manager UI. + * + * @param count - only needed for XPCOM. + * @return {nsILoginInfo[]} - If there are no logins, the array is empty. + */ + getAllLogins(count) { + log.debug("Getting a list of all logins"); + return this._storage.getAllLogins(count); + }, + + + /** + * Remove all stored logins. + */ + removeAllLogins() { + log.debug("Removing all logins"); + this._storage.removeAllLogins(); + }, + + /** + * Get a list of all origins for which logins are disabled. + * + * @param {Number} count - only needed for XPCOM. + * + * @return {String[]} of disabled origins. If there are no disabled origins, + * the array is empty. + */ + getAllDisabledHosts(count) { + log.debug("Getting a list of all disabled origins"); + + let disabledHosts = []; + let enumerator = Services.perms.enumerator; + + while (enumerator.hasMoreElements()) { + let perm = enumerator.getNext(); + if (perm.type == PERMISSION_SAVE_LOGINS && perm.capability == Services.perms.DENY_ACTION) { + disabledHosts.push(perm.principal.URI.prePath); + } + } + + if (count) + count.value = disabledHosts.length; // needed for XPCOM + + log.debug("getAllDisabledHosts: returning", disabledHosts.length, "disabled hosts."); + return disabledHosts; + }, + + + /** + * Search for the known logins for entries matching the specified criteria. + */ + findLogins(count, origin, formActionOrigin, httpRealm) { + log.debug("Searching for logins matching origin:", origin, + "formActionOrigin:", formActionOrigin, "httpRealm:", httpRealm); + + return this._storage.findLogins(count, origin, formActionOrigin, + httpRealm); + }, + + + /** + * 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("guid")) { + 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. + */ + countLogins(origin, formActionOrigin, httpRealm) { + log.debug("Counting logins matching origin:", origin, + "formActionOrigin:", formActionOrigin, "httpRealm:", httpRealm); + + return this._storage.countLogins(origin, formActionOrigin, httpRealm); + }, + + + get uiBusy() { + return this._storage.uiBusy; + }, + + + get isLoggedIn() { + return this._storage.isLoggedIn; + }, + + + /** + * Check to see if user has disabled saving logins for the origin. + */ + getLoginSavingEnabled(origin) { + log.debug("Checking if logins to", origin, "can be saved."); + if (!this._remember) { + return false; + } + + let uri = Services.io.newURI(origin, null, null); + return Services.perms.testPermission(uri, PERMISSION_SAVE_LOGINS) != Services.perms.DENY_ACTION; + }, + + + /** + * Enable or disable storing logins for the specified origin. + */ + setLoginSavingEnabled(origin, enabled) { + // Throws if there are bogus values. + LoginHelper.checkHostnameValue(origin); + + let uri = Services.io.newURI(origin, null, null); + if (enabled) { + Services.perms.remove(uri, PERMISSION_SAVE_LOGINS); + } else { + Services.perms.add(uri, PERMISSION_SAVE_LOGINS, Services.perms.DENY_ACTION); + } + + log.debug("Login saving for", origin, "now enabled?", enabled); + LoginHelper.notifyStorageChanged(enabled ? "hostSavingEnabled" : "hostSavingDisabled", origin); + }, + + /** + * Yuck. This is called directly by satchel: + * nsFormFillController::StartSearch() + * [toolkit/components/satchel/nsFormFillController.cpp] + * + * We really ought to have a simple way for code to register an + * auto-complete provider, and not have satchel calling pwmgr directly. + */ + autoCompleteSearchAsync(aSearchString, aPreviousResult, + aElement, aCallback) { + // aPreviousResult is an nsIAutoCompleteResult, aElement is + // nsIDOMHTMLInputElement + + let form = LoginFormFactory.createFromField(aElement); + let isSecure = InsecurePasswordUtils.isFormSecure(form); + let isPasswordField = aElement.type == "password"; + + let completeSearch = (autoCompleteLookupPromise, { logins, messageManager }) => { + // If the search was canceled before we got our + // results, don't bother reporting them. + if (this._autoCompleteLookupPromise !== autoCompleteLookupPromise) { + return; + } + + this._autoCompleteLookupPromise = null; + let results = new UserAutoCompleteResult(aSearchString, logins, { + messageManager, + isSecure, + isPasswordField, + }); + aCallback.onSearchCompletion(results); + }; + + if (isPasswordField && aSearchString) { + // Return empty result on password fields with password already filled. + let acLookupPromise = this._autoCompleteLookupPromise = Promise.resolve({ logins: [] }); + acLookupPromise.then(completeSearch.bind(this, acLookupPromise)); + return; + } + + if (!this._remember) { + let acLookupPromise = this._autoCompleteLookupPromise = Promise.resolve({ logins: [] }); + acLookupPromise.then(completeSearch.bind(this, acLookupPromise)); + return; + } + + log.debug("AutoCompleteSearch invoked. Search is:", aSearchString); + + let previousResult; + if (aPreviousResult) { + previousResult = { searchString: aPreviousResult.searchString, + logins: aPreviousResult.wrappedJSObject.logins }; + } else { + previousResult = null; + } + + let rect = BrowserUtils.getElementBoundingScreenRect(aElement); + let acLookupPromise = this._autoCompleteLookupPromise = + LoginManagerContent._autoCompleteSearchAsync(aSearchString, previousResult, + aElement, rect); + acLookupPromise.then(completeSearch.bind(this, acLookupPromise)) + .then(null, Cu.reportError); + }, + + stopSearch() { + this._autoCompleteLookupPromise = null; + }, +}; // end of LoginManager implementation + +this.NSGetFactory = XPCOMUtils.generateNSGetFactory([LoginManager]); diff --git a/toolkit/components/passwordmgr/nsLoginManagerPrompter.js b/toolkit/components/passwordmgr/nsLoginManagerPrompter.js new file mode 100644 index 000000000..b66489234 --- /dev/null +++ b/toolkit/components/passwordmgr/nsLoginManagerPrompter.js @@ -0,0 +1,1701 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/PrivateBrowsingUtils.jsm"); +const { PromptUtils } = Cu.import("resource://gre/modules/SharedPromptUtils.jsm", {}); + +XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper", + "resource://gre/modules/LoginHelper.jsm"); + +const LoginInfo = + Components.Constructor("@mozilla.org/login-manager/loginInfo;1", + "nsILoginInfo", "init"); + +const BRAND_BUNDLE = "chrome://branding/locale/brand.properties"; + +/** + * Constants for password prompt telemetry. + * Mirrored in mobile/android/components/LoginManagerPrompter.js */ +const PROMPT_DISPLAYED = 0; + +const PROMPT_ADD_OR_UPDATE = 1; +const PROMPT_NOTNOW = 2; +const PROMPT_NEVER = 3; + +/** + * Implements nsIPromptFactory + * + * Invoked by [toolkit/components/prompts/src/nsPrompter.js] + */ +function LoginManagerPromptFactory() { + Services.obs.addObserver(this, "quit-application-granted", true); + Services.obs.addObserver(this, "passwordmgr-crypto-login", true); + Services.obs.addObserver(this, "passwordmgr-crypto-loginCanceled", true); +} + +LoginManagerPromptFactory.prototype = { + + classID : Components.ID("{749e62f4-60ae-4569-a8a2-de78b649660e}"), + QueryInterface : XPCOMUtils.generateQI([Ci.nsIPromptFactory, Ci.nsIObserver, Ci.nsISupportsWeakReference]), + + _asyncPrompts : {}, + _asyncPromptInProgress : false, + + observe : function (subject, topic, data) { + this.log("Observed: " + topic); + if (topic == "quit-application-granted") { + this._cancelPendingPrompts(); + } else if (topic == "passwordmgr-crypto-login") { + // Start processing the deferred prompters. + this._doAsyncPrompt(); + } else if (topic == "passwordmgr-crypto-loginCanceled") { + // User canceled a Master Password prompt, so go ahead and cancel + // all pending auth prompts to avoid nagging over and over. + this._cancelPendingPrompts(); + } + }, + + getPrompt : function (aWindow, aIID) { + var prompt = new LoginManagerPrompter().QueryInterface(aIID); + prompt.init(aWindow, this); + return prompt; + }, + + _doAsyncPrompt : function() { + if (this._asyncPromptInProgress) { + this.log("_doAsyncPrompt bypassed, already in progress"); + return; + } + + // Find the first prompt key we have in the queue + var hashKey = null; + for (hashKey in this._asyncPrompts) + break; + + if (!hashKey) { + this.log("_doAsyncPrompt:run bypassed, no prompts in the queue"); + return; + } + + // If login manger has logins for this host, defer prompting if we're + // already waiting on a master password entry. + var prompt = this._asyncPrompts[hashKey]; + var prompter = prompt.prompter; + var [hostname, httpRealm] = prompter._getAuthTarget(prompt.channel, prompt.authInfo); + var hasLogins = (prompter._pwmgr.countLogins(hostname, null, httpRealm) > 0); + if (!hasLogins && LoginHelper.schemeUpgrades && hostname.startsWith("https://")) { + let httpHostname = hostname.replace(/^https:\/\//, "http://"); + hasLogins = (prompter._pwmgr.countLogins(httpHostname, null, httpRealm) > 0); + } + if (hasLogins && prompter._pwmgr.uiBusy) { + this.log("_doAsyncPrompt:run bypassed, master password UI busy"); + return; + } + + // Allow only a limited number of authentication dialogs when they are all + // canceled by the user. + var cancelationCounter = (prompter._browser && prompter._browser.canceledAuthenticationPromptCounter) || { count: 0, id: 0 }; + if (prompt.channel) { + var httpChannel = prompt.channel.QueryInterface(Ci.nsIHttpChannel); + if (httpChannel) { + var windowId = httpChannel.topLevelContentWindowId; + if (windowId != cancelationCounter.id) { + // window has been reloaded or navigated, reset the counter + cancelationCounter = { count: 0, id: windowId }; + } + } + } + + var self = this; + + var runnable = { + cancel: false, + run : function() { + var ok = false; + if (!this.cancel) { + try { + self.log("_doAsyncPrompt:run - performing the prompt for '" + hashKey + "'"); + ok = prompter.promptAuth(prompt.channel, + prompt.level, + prompt.authInfo); + } catch (e) { + if (e instanceof Components.Exception && + e.result == Cr.NS_ERROR_NOT_AVAILABLE) { + self.log("_doAsyncPrompt:run bypassed, UI is not available in this context"); + } else { + Components.utils.reportError("LoginManagerPrompter: " + + "_doAsyncPrompt:run: " + e + "\n"); + } + } + + delete self._asyncPrompts[hashKey]; + prompt.inProgress = false; + self._asyncPromptInProgress = false; + + if (ok) { + cancelationCounter.count = 0; + } else { + cancelationCounter.count++; + } + if (prompter._browser) { + prompter._browser.canceledAuthenticationPromptCounter = cancelationCounter; + } + } + + for (var consumer of prompt.consumers) { + if (!consumer.callback) + // Not having a callback means that consumer didn't provide it + // or canceled the notification + continue; + + self.log("Calling back to " + consumer.callback + " ok=" + ok); + try { + if (ok) { + consumer.callback.onAuthAvailable(consumer.context, prompt.authInfo); + } else { + consumer.callback.onAuthCancelled(consumer.context, !this.cancel); + } + } catch (e) { /* Throw away exceptions caused by callback */ } + } + self._doAsyncPrompt(); + } + }; + + var cancelDialogLimit = Services.prefs.getIntPref("prompts.authentication_dialog_abuse_limit"); + + this.log("cancelationCounter =", cancelationCounter); + if (cancelDialogLimit && cancelationCounter.count >= cancelDialogLimit) { + this.log("Blocking auth dialog, due to exceeding dialog bloat limit"); + delete this._asyncPrompts[hashKey]; + + // just make the runnable cancel all consumers + runnable.cancel = true; + } else { + this._asyncPromptInProgress = true; + prompt.inProgress = true; + } + + Services.tm.mainThread.dispatch(runnable, Ci.nsIThread.DISPATCH_NORMAL); + this.log("_doAsyncPrompt:run dispatched"); + }, + + + _cancelPendingPrompts : function() { + this.log("Canceling all pending prompts..."); + var asyncPrompts = this._asyncPrompts; + this.__proto__._asyncPrompts = {}; + + for (var hashKey in asyncPrompts) { + let prompt = asyncPrompts[hashKey]; + // Watch out! If this prompt is currently prompting, let it handle + // notifying the callbacks of success/failure, since it's already + // asking the user for input. Reusing a callback can be crashy. + if (prompt.inProgress) { + this.log("skipping a prompt in progress"); + continue; + } + + for (var consumer of prompt.consumers) { + if (!consumer.callback) + continue; + + this.log("Canceling async auth prompt callback " + consumer.callback); + try { + consumer.callback.onAuthCancelled(consumer.context, true); + } catch (e) { /* Just ignore exceptions from the callback */ } + } + } + }, +}; // end of LoginManagerPromptFactory implementation + +XPCOMUtils.defineLazyGetter(this.LoginManagerPromptFactory.prototype, "log", () => { + let logger = LoginHelper.createLogger("Login PromptFactory"); + return logger.log.bind(logger); +}); + + + + +/* ==================== LoginManagerPrompter ==================== */ + + + + +/** + * Implements interfaces for prompting the user to enter/save/change auth info. + * + * nsIAuthPrompt: Used by SeaMonkey, Thunderbird, but not Firefox. + * + * nsIAuthPrompt2: Is invoked by a channel for protocol-based authentication + * (eg HTTP Authenticate, FTP login). + * + * nsILoginManagerPrompter: Used by Login Manager for saving/changing logins + * found in HTML forms. + */ +function LoginManagerPrompter() {} + +LoginManagerPrompter.prototype = { + + classID : Components.ID("{8aa66d77-1bbb-45a6-991e-b8f47751c291}"), + QueryInterface : XPCOMUtils.generateQI([Ci.nsIAuthPrompt, + Ci.nsIAuthPrompt2, + Ci.nsILoginManagerPrompter]), + + _factory : null, + _chromeWindow : null, + _browser : null, + _opener : null, + + __pwmgr : null, // Password Manager service + get _pwmgr() { + if (!this.__pwmgr) + this.__pwmgr = Cc["@mozilla.org/login-manager;1"]. + getService(Ci.nsILoginManager); + return this.__pwmgr; + }, + + __promptService : null, // Prompt service for user interaction + get _promptService() { + if (!this.__promptService) + this.__promptService = + Cc["@mozilla.org/embedcomp/prompt-service;1"]. + getService(Ci.nsIPromptService2); + return this.__promptService; + }, + + + __strBundle : null, // String bundle for L10N + get _strBundle() { + if (!this.__strBundle) { + var bunService = Cc["@mozilla.org/intl/stringbundle;1"]. + getService(Ci.nsIStringBundleService); + this.__strBundle = bunService.createBundle( + "chrome://passwordmgr/locale/passwordmgr.properties"); + if (!this.__strBundle) + throw new Error("String bundle for Login Manager not present!"); + } + + return this.__strBundle; + }, + + + __ellipsis : null, + get _ellipsis() { + if (!this.__ellipsis) { + this.__ellipsis = "\u2026"; + try { + this.__ellipsis = Services.prefs.getComplexValue( + "intl.ellipsis", Ci.nsIPrefLocalizedString).data; + } catch (e) { } + } + return this.__ellipsis; + }, + + + // Whether we are in private browsing mode + get _inPrivateBrowsing() { + if (this._chromeWindow) { + return PrivateBrowsingUtils.isWindowPrivate(this._chromeWindow); + } + // If we don't that we're in private browsing mode if the caller did + // not provide a window. The callers which really care about this + // will indeed pass down a window to us, and for those who don't, + // we can just assume that we don't want to save the entered login + // information. + this.log("We have no chromeWindow so assume we're in a private context"); + return true; + }, + + + + + /* ---------- nsIAuthPrompt prompts ---------- */ + + + /** + * Wrapper around the prompt service prompt. Saving random fields here + * doesn't really make sense and therefore isn't implemented. + */ + prompt : function (aDialogTitle, aText, aPasswordRealm, + aSavePassword, aDefaultText, aResult) { + if (aSavePassword != Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER) + throw new Components.Exception("prompt only supports SAVE_PASSWORD_NEVER", + Cr.NS_ERROR_NOT_IMPLEMENTED); + + this.log("===== prompt() called ====="); + + if (aDefaultText) { + aResult.value = aDefaultText; + } + + return this._promptService.prompt(this._chromeWindow, + aDialogTitle, aText, aResult, null, {}); + }, + + + /** + * Looks up a username and password in the database. Will prompt the user + * with a dialog, even if a username and password are found. + */ + promptUsernameAndPassword : function (aDialogTitle, aText, aPasswordRealm, + aSavePassword, aUsername, aPassword) { + this.log("===== promptUsernameAndPassword() called ====="); + + if (aSavePassword == Ci.nsIAuthPrompt.SAVE_PASSWORD_FOR_SESSION) + throw new Components.Exception("promptUsernameAndPassword doesn't support SAVE_PASSWORD_FOR_SESSION", + Cr.NS_ERROR_NOT_IMPLEMENTED); + + var selectedLogin = null; + var checkBox = { value : false }; + var checkBoxLabel = null; + var [hostname, realm, unused] = this._getRealmInfo(aPasswordRealm); + + // If hostname is null, we can't save this login. + if (hostname) { + var canRememberLogin; + if (this._inPrivateBrowsing) + canRememberLogin = false; + else + canRememberLogin = (aSavePassword == + Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY) && + this._pwmgr.getLoginSavingEnabled(hostname); + + // if checkBoxLabel is null, the checkbox won't be shown at all. + if (canRememberLogin) + checkBoxLabel = this._getLocalizedString("rememberPassword"); + + // Look for existing logins. + var foundLogins = this._pwmgr.findLogins({}, hostname, null, + realm); + + // XXX Like the original code, we can't deal with multiple + // account selection. (bug 227632) + if (foundLogins.length > 0) { + selectedLogin = foundLogins[0]; + + // If the caller provided a username, try to use it. If they + // provided only a password, this will try to find a password-only + // login (or return null if none exists). + if (aUsername.value) + selectedLogin = this._repickSelectedLogin(foundLogins, + aUsername.value); + + if (selectedLogin) { + checkBox.value = true; + aUsername.value = selectedLogin.username; + // If the caller provided a password, prefer it. + if (!aPassword.value) + aPassword.value = selectedLogin.password; + } + } + } + + var ok = this._promptService.promptUsernameAndPassword(this._chromeWindow, + aDialogTitle, aText, aUsername, aPassword, + checkBoxLabel, checkBox); + + if (!ok || !checkBox.value || !hostname) + return ok; + + if (!aPassword.value) { + this.log("No password entered, so won't offer to save."); + return ok; + } + + // XXX We can't prompt with multiple logins yet (bug 227632), so + // the entered login might correspond to an existing login + // other than the one we originally selected. + selectedLogin = this._repickSelectedLogin(foundLogins, aUsername.value); + + // If we didn't find an existing login, or if the username + // changed, save as a new login. + let newLogin = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + newLogin.init(hostname, null, realm, + aUsername.value, aPassword.value, "", ""); + if (!selectedLogin) { + // add as new + this.log("New login seen for " + realm); + this._pwmgr.addLogin(newLogin); + } else if (aPassword.value != selectedLogin.password) { + // update password + this.log("Updating password for " + realm); + this._updateLogin(selectedLogin, newLogin); + } else { + this.log("Login unchanged, no further action needed."); + this._updateLogin(selectedLogin); + } + + return ok; + }, + + + /** + * If a password is found in the database for the password realm, it is + * returned straight away without displaying a dialog. + * + * If a password is not found in the database, the user will be prompted + * with a dialog with a text field and ok/cancel buttons. If the user + * allows it, then the password will be saved in the database. + */ + promptPassword : function (aDialogTitle, aText, aPasswordRealm, + aSavePassword, aPassword) { + this.log("===== promptPassword called() ====="); + + if (aSavePassword == Ci.nsIAuthPrompt.SAVE_PASSWORD_FOR_SESSION) + throw new Components.Exception("promptPassword doesn't support SAVE_PASSWORD_FOR_SESSION", + Cr.NS_ERROR_NOT_IMPLEMENTED); + + var checkBox = { value : false }; + var checkBoxLabel = null; + var [hostname, realm, username] = this._getRealmInfo(aPasswordRealm); + + username = decodeURIComponent(username); + + // If hostname is null, we can't save this login. + if (hostname && !this._inPrivateBrowsing) { + var canRememberLogin = (aSavePassword == + Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY) && + this._pwmgr.getLoginSavingEnabled(hostname); + + // if checkBoxLabel is null, the checkbox won't be shown at all. + if (canRememberLogin) + checkBoxLabel = this._getLocalizedString("rememberPassword"); + + if (!aPassword.value) { + // Look for existing logins. + var foundLogins = this._pwmgr.findLogins({}, hostname, null, + realm); + + // XXX Like the original code, we can't deal with multiple + // account selection (bug 227632). We can deal with finding the + // account based on the supplied username - but in this case we'll + // just return the first match. + for (var i = 0; i < foundLogins.length; ++i) { + if (foundLogins[i].username == username) { + aPassword.value = foundLogins[i].password; + // wallet returned straight away, so this mimics that code + return true; + } + } + } + } + + var ok = this._promptService.promptPassword(this._chromeWindow, aDialogTitle, + aText, aPassword, + checkBoxLabel, checkBox); + + if (ok && checkBox.value && hostname && aPassword.value) { + var newLogin = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + newLogin.init(hostname, null, realm, username, + aPassword.value, "", ""); + + this.log("New login seen for " + realm); + + this._pwmgr.addLogin(newLogin); + } + + return ok; + }, + + /* ---------- nsIAuthPrompt helpers ---------- */ + + + /** + * Given aRealmString, such as "http://user@example.com/foo", returns an + * array of: + * - the formatted hostname + * - the realm (hostname + path) + * - the username, if present + * + * If aRealmString is in the format produced by NS_GetAuthKey for HTTP[S] + * channels, e.g. "example.com:80 (httprealm)", null is returned for all + * arguments to let callers know the login can't be saved because we don't + * know whether it's http or https. + */ + _getRealmInfo : function (aRealmString) { + var httpRealm = /^.+ \(.+\)$/; + if (httpRealm.test(aRealmString)) + return [null, null, null]; + + var uri = Services.io.newURI(aRealmString, null, null); + var pathname = ""; + + if (uri.path != "/") + pathname = uri.path; + + var formattedHostname = this._getFormattedHostname(uri); + + return [formattedHostname, formattedHostname + pathname, uri.username]; + }, + + /* ---------- nsIAuthPrompt2 prompts ---------- */ + + + + + /** + * Implementation of nsIAuthPrompt2. + * + * @param {nsIChannel} aChannel + * @param {int} aLevel + * @param {nsIAuthInformation} aAuthInfo + */ + promptAuth : function (aChannel, aLevel, aAuthInfo) { + var selectedLogin = null; + var checkbox = { value : false }; + var checkboxLabel = null; + var epicfail = false; + var canAutologin = false; + var notifyObj; + var foundLogins; + + try { + this.log("===== promptAuth called ====="); + + // If the user submits a login but it fails, we need to remove the + // 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. + foundLogins = LoginHelper.searchLoginsWithObject({ + hostname, + httpRealm, + schemeUpgrades: LoginHelper.schemeUpgrades, + }); + this.log("found", foundLogins.length, "matching logins."); + let resolveBy = [ + "scheme", + "timePasswordChanged", + ]; + foundLogins = LoginHelper.dedupeLogins(foundLogins, ["username"], resolveBy, hostname); + this.log(foundLogins.length, "matching logins remain after deduping"); + + // XXX Can't select from multiple accounts yet. (bug 227632) + if (foundLogins.length > 0) { + selectedLogin = foundLogins[0]; + this._SetAuthInfo(aAuthInfo, selectedLogin.username, + selectedLogin.password); + + // Allow automatic proxy login + if (aAuthInfo.flags & Ci.nsIAuthInformation.AUTH_PROXY && + !(aAuthInfo.flags & Ci.nsIAuthInformation.PREVIOUS_FAILED) && + Services.prefs.getBoolPref("signon.autologin.proxy") && + !this._inPrivateBrowsing) { + + this.log("Autologin enabled, skipping auth prompt."); + canAutologin = true; + } + + checkbox.value = true; + } + + var canRememberLogin = this._pwmgr.getLoginSavingEnabled(hostname); + if (this._inPrivateBrowsing) + canRememberLogin = false; + + // if checkboxLabel is null, the checkbox won't be shown at all. + notifyObj = this._getPopupNote() || this._getNotifyBox(); + if (canRememberLogin && !notifyObj) + checkboxLabel = this._getLocalizedString("rememberPassword"); + } catch (e) { + // Ignore any errors and display the prompt anyway. + epicfail = true; + Components.utils.reportError("LoginManagerPrompter: " + + "Epic fail in promptAuth: " + e + "\n"); + } + + var ok = canAutologin; + if (!ok) { + if (this._chromeWindow) + PromptUtils.fireDialogEvent(this._chromeWindow, "DOMWillOpenModalDialog", this._browser); + ok = this._promptService.promptAuth(this._chromeWindow, + aChannel, aLevel, aAuthInfo, + checkboxLabel, checkbox); + } + + // If there's a notification box, use it to allow the user to + // determine if the login should be saved. If there isn't a + // notification box, only save the login if the user set the + // checkbox to do so. + var rememberLogin = notifyObj ? canRememberLogin : checkbox.value; + if (!ok || !rememberLogin || epicfail) + return ok; + + try { + var [username, password] = this._GetAuthInfo(aAuthInfo); + + if (!password) { + this.log("No password entered, so won't offer to save."); + return ok; + } + + // XXX We can't prompt with multiple logins yet (bug 227632), so + // the entered login might correspond to an existing login + // other than the one we originally selected. + selectedLogin = this._repickSelectedLogin(foundLogins, username); + + // If we didn't find an existing login, or if the username + // changed, save as a new login. + let newLogin = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + newLogin.init(hostname, null, httpRealm, + username, password, "", ""); + if (!selectedLogin) { + this.log("New login seen for " + username + + " @ " + hostname + " (" + httpRealm + ")"); + + if (notifyObj) + this._showSaveLoginNotification(notifyObj, newLogin); + else + this._pwmgr.addLogin(newLogin); + } else if (password != selectedLogin.password) { + this.log("Updating password for " + username + + " @ " + hostname + " (" + httpRealm + ")"); + if (notifyObj) + this._showChangeLoginNotification(notifyObj, + selectedLogin, newLogin); + else + this._updateLogin(selectedLogin, newLogin); + } else { + this.log("Login unchanged, no further action needed."); + this._updateLogin(selectedLogin); + } + } catch (e) { + Components.utils.reportError("LoginManagerPrompter: " + + "Fail2 in promptAuth: " + e + "\n"); + } + + return ok; + }, + + asyncPromptAuth : function (aChannel, aCallback, aContext, aLevel, aAuthInfo) { + var cancelable = null; + + try { + this.log("===== asyncPromptAuth called ====="); + + // If the user submits a login but it fails, we need to remove the + // notification bar that was displayed. Conveniently, the user will + // be prompted for authentication again, which brings us here. + this._removeLoginNotifications(); + + cancelable = this._newAsyncPromptConsumer(aCallback, aContext); + + var [hostname, httpRealm] = this._getAuthTarget(aChannel, aAuthInfo); + + var hashKey = aLevel + "|" + hostname + "|" + httpRealm; + this.log("Async prompt key = " + hashKey); + var asyncPrompt = this._factory._asyncPrompts[hashKey]; + if (asyncPrompt) { + this.log("Prompt bound to an existing one in the queue, callback = " + aCallback); + asyncPrompt.consumers.push(cancelable); + return cancelable; + } + + this.log("Adding new prompt to the queue, callback = " + aCallback); + asyncPrompt = { + consumers: [cancelable], + channel: aChannel, + authInfo: aAuthInfo, + level: aLevel, + inProgress : false, + prompter: this + }; + + this._factory._asyncPrompts[hashKey] = asyncPrompt; + this._factory._doAsyncPrompt(); + } catch (e) { + Components.utils.reportError("LoginManagerPrompter: " + + "asyncPromptAuth: " + e + "\nFalling back to promptAuth\n"); + // Fail the prompt operation to let the consumer fall back + // to synchronous promptAuth method + throw e; + } + + return cancelable; + }, + + + + + /* ---------- nsILoginManagerPrompter prompts ---------- */ + + + init : function (aWindow = null, aFactory = null) { + if (!aWindow) { + // There may be no applicable window e.g. in a Sandbox or JSM. + this._chromeWindow = null; + this._browser = null; + } else if (aWindow instanceof Ci.nsIDOMChromeWindow) { + this._chromeWindow = aWindow; + // needs to be set explicitly using setBrowser + this._browser = null; + } else { + let {win, browser} = this._getChromeWindow(aWindow); + this._chromeWindow = win; + this._browser = browser; + } + this._opener = null; + this._factory = aFactory || null; + + this.log("===== initialized ====="); + }, + + set browser(aBrowser) { + this._browser = aBrowser; + }, + + set opener(aOpener) { + this._opener = aOpener; + }, + + promptToSavePassword : function (aLogin) { + this.log("promptToSavePassword"); + var notifyObj = this._getPopupNote() || this._getNotifyBox(); + if (notifyObj) + this._showSaveLoginNotification(notifyObj, aLogin); + else + this._showSaveLoginDialog(aLogin); + }, + + /** + * Displays a notification bar. + */ + _showLoginNotification : function (aNotifyBox, aName, aText, aButtons) { + var oldBar = aNotifyBox.getNotificationWithValue(aName); + const priority = aNotifyBox.PRIORITY_INFO_MEDIUM; + + this.log("Adding new " + aName + " notification bar"); + var newBar = aNotifyBox.appendNotification( + aText, aName, "", + priority, aButtons); + + // The page we're going to hasn't loaded yet, so we want to persist + // across the first location change. + newBar.persistence++; + + // Sites like Gmail perform a funky redirect dance before you end up + // at the post-authentication page. I don't see a good way to + // heuristically determine when to ignore such location changes, so + // we'll try ignoring location changes based on a time interval. + newBar.timeout = Date.now() + 20000; // 20 seconds + + if (oldBar) { + this.log("(...and removing old " + aName + " notification bar)"); + aNotifyBox.removeNotification(oldBar); + } + }, + + /** + * Displays the PopupNotifications.jsm doorhanger for password save or change. + * + * @param {nsILoginInfo} login + * Login to save or change. For changes, this login should contain the + * new password. + * @param {string} type + * This is "password-save" or "password-change" depending on the + * original notification type. This is used for telemetry and tests. + */ + _showLoginCaptureDoorhanger(login, type) { + let { browser } = this._getNotifyWindow(); + + let saveMsgNames = { + prompt: login.username === "" ? "rememberLoginMsgNoUser" + : "rememberLoginMsg", + buttonLabel: "rememberLoginButtonText", + buttonAccessKey: "rememberLoginButtonAccessKey", + }; + + let changeMsgNames = { + prompt: login.username === "" ? "updateLoginMsgNoUser" + : "updateLoginMsg", + buttonLabel: "updateLoginButtonText", + buttonAccessKey: "updateLoginButtonAccessKey", + }; + + let initialMsgNames = type == "password-save" ? saveMsgNames + : changeMsgNames; + + let brandBundle = Services.strings.createBundle(BRAND_BUNDLE); + let brandShortName = brandBundle.GetStringFromName("brandShortName"); + let promptMsg = type == "password-save" ? this._getLocalizedString(saveMsgNames.prompt, [brandShortName]) + : this._getLocalizedString(changeMsgNames.prompt); + + let histogramName = type == "password-save" ? "PWMGR_PROMPT_REMEMBER_ACTION" + : "PWMGR_PROMPT_UPDATE_ACTION"; + let histogram = Services.telemetry.getHistogramById(histogramName); + histogram.add(PROMPT_DISPLAYED); + + let chromeDoc = browser.ownerDocument; + + let currentNotification; + + let updateButtonStatus = (element) => { + let mainActionButton = chromeDoc.getAnonymousElementByAttribute(element.button, "anonid", "button"); + // Disable the main button inside the menu-button if the password field is empty. + if (login.password.length == 0) { + mainActionButton.setAttribute("disabled", true); + chromeDoc.getElementById("password-notification-password") + .classList.add("popup-notification-invalid-input"); + } else { + mainActionButton.removeAttribute("disabled"); + chromeDoc.getElementById("password-notification-password") + .classList.remove("popup-notification-invalid-input"); + } + }; + + let updateButtonLabel = () => { + let foundLogins = LoginHelper.searchLoginsWithObject({ + formSubmitURL: login.formSubmitURL, + hostname: login.hostname, + httpRealm: login.httpRealm, + schemeUpgrades: LoginHelper.schemeUpgrades, + }); + + let logins = this._filterUpdatableLogins(login, foundLogins); + let msgNames = (logins.length == 0) ? saveMsgNames : changeMsgNames; + + // Update the label based on whether this will be a new login or not. + let label = this._getLocalizedString(msgNames.buttonLabel); + let accessKey = this._getLocalizedString(msgNames.buttonAccessKey); + + // Update the labels for the next time the panel is opened. + currentNotification.mainAction.label = label; + currentNotification.mainAction.accessKey = accessKey; + + // Update the labels in real time if the notification is displayed. + let element = [...currentNotification.owner.panel.childNodes] + .find(n => n.notification == currentNotification); + if (element) { + element.setAttribute("buttonlabel", label); + element.setAttribute("buttonaccesskey", accessKey); + updateButtonStatus(element); + } + }; + + let writeDataToUI = () => { + // setAttribute is used since the <textbox> binding may not be attached yet. + chromeDoc.getElementById("password-notification-username") + .setAttribute("placeholder", usernamePlaceholder); + chromeDoc.getElementById("password-notification-username") + .setAttribute("value", login.username); + + let toggleCheckbox = chromeDoc.getElementById("password-notification-visibilityToggle"); + toggleCheckbox.removeAttribute("checked"); + let passwordField = chromeDoc.getElementById("password-notification-password"); + // Ensure the type is reset so the field is masked. + passwordField.setAttribute("type", "password"); + passwordField.setAttribute("value", login.password); + updateButtonLabel(); + }; + + let readDataFromUI = () => { + login.username = + chromeDoc.getElementById("password-notification-username").value; + login.password = + chromeDoc.getElementById("password-notification-password").value; + }; + + let onInput = () => { + readDataFromUI(); + updateButtonLabel(); + }; + + let onVisibilityToggle = (commandEvent) => { + let passwordField = chromeDoc.getElementById("password-notification-password"); + // Gets the caret position before changing the type of the textbox + let selectionStart = passwordField.selectionStart; + let selectionEnd = passwordField.selectionEnd; + passwordField.setAttribute("type", commandEvent.target.checked ? "" : "password"); + if (!passwordField.hasAttribute("focused")) { + return; + } + passwordField.selectionStart = selectionStart; + passwordField.selectionEnd = selectionEnd; + }; + + let persistData = () => { + let foundLogins = LoginHelper.searchLoginsWithObject({ + formSubmitURL: login.formSubmitURL, + hostname: login.hostname, + httpRealm: login.httpRealm, + schemeUpgrades: LoginHelper.schemeUpgrades, + }); + + let logins = this._filterUpdatableLogins(login, foundLogins); + + if (logins.length == 0) { + // The original login we have been provided with might have its own + // metadata, but we don't want it propagated to the newly created one. + Services.logins.addLogin(new LoginInfo(login.hostname, + login.formSubmitURL, + login.httpRealm, + login.username, + login.password, + login.usernameField, + login.passwordField)); + } else if (logins.length == 1) { + if (logins[0].password == login.password && + logins[0].username == login.username) { + // We only want to touch the login's use count and last used time. + this._updateLogin(logins[0]); + } else { + this._updateLogin(logins[0], login); + } + } else { + Cu.reportError("Unexpected match of multiple logins."); + } + }; + + // The main action is the "Remember" or "Update" button. + let mainAction = { + label: this._getLocalizedString(initialMsgNames.buttonLabel), + accessKey: this._getLocalizedString(initialMsgNames.buttonAccessKey), + callback: () => { + histogram.add(PROMPT_ADD_OR_UPDATE); + if (histogramName == "PWMGR_PROMPT_REMEMBER_ACTION") { + Services.obs.notifyObservers(null, 'LoginStats:NewSavedPassword', null); + } + readDataFromUI(); + persistData(); + browser.focus(); + } + }; + + // Include a "Never for this site" button when saving a new password. + let secondaryActions = type == "password-save" ? [{ + label: this._getLocalizedString("notifyBarNeverRememberButtonText"), + accessKey: this._getLocalizedString("notifyBarNeverRememberButtonAccessKey"), + callback: () => { + histogram.add(PROMPT_NEVER); + Services.logins.setLoginSavingEnabled(login.hostname, false); + browser.focus(); + } + }] : null; + + let usernamePlaceholder = this._getLocalizedString("noUsernamePlaceholder"); + let togglePasswordLabel = this._getLocalizedString("togglePasswordLabel"); + let togglePasswordAccessKey = this._getLocalizedString("togglePasswordAccessKey"); + + this._getPopupNote().show( + browser, + "password", + promptMsg, + "password-notification-icon", + mainAction, + secondaryActions, + { + timeout: Date.now() + 10000, + displayURI: Services.io.newURI(login.hostname, null, null), + persistWhileVisible: true, + passwordNotificationType: type, + eventCallback: function (topic) { + switch (topic) { + case "showing": + currentNotification = this; + chromeDoc.getElementById("password-notification-password") + .removeAttribute("focused"); + chromeDoc.getElementById("password-notification-username") + .removeAttribute("focused"); + chromeDoc.getElementById("password-notification-username") + .addEventListener("input", onInput); + chromeDoc.getElementById("password-notification-password") + .addEventListener("input", onInput); + let toggleBtn = chromeDoc.getElementById("password-notification-visibilityToggle"); + + if (Services.prefs.getBoolPref("signon.rememberSignons.visibilityToggle")) { + toggleBtn.addEventListener("command", onVisibilityToggle); + toggleBtn.setAttribute("label", togglePasswordLabel); + toggleBtn.setAttribute("accesskey", togglePasswordAccessKey); + toggleBtn.setAttribute("hidden", LoginHelper.isMasterPasswordSet()); + } + if (this.wasDismissed) { + chromeDoc.getElementById("password-notification-visibilityToggle") + .setAttribute("hidden", true); + } + break; + case "shown": + writeDataToUI(); + break; + case "dismissed": + this.wasDismissed = true; + readDataFromUI(); + // Fall through. + case "removed": + currentNotification = null; + chromeDoc.getElementById("password-notification-username") + .removeEventListener("input", onInput); + chromeDoc.getElementById("password-notification-password") + .removeEventListener("input", onInput); + chromeDoc.getElementById("password-notification-visibilityToggle") + .removeEventListener("command", onVisibilityToggle); + break; + } + return false; + }, + } + ); + }, + + /** + * Displays a notification bar or a popup notification, to allow the user + * to save the specified login. This allows the user to see the results of + * their login, and only save a login which they know worked. + * + * @param aNotifyObj + * A notification box or a popup notification. + * @param aLogin + * The login captured from the form. + */ + _showSaveLoginNotification : function (aNotifyObj, aLogin) { + // Ugh. We can't use the strings from the popup window, because they + // have the access key marked in the string (eg "Mo&zilla"), along + // with some weird rules for handling access keys that do not occur + // in the string, for L10N. See commonDialog.js's setLabelForNode(). + var neverButtonText = + this._getLocalizedString("notifyBarNeverRememberButtonText"); + var neverButtonAccessKey = + this._getLocalizedString("notifyBarNeverRememberButtonAccessKey"); + var rememberButtonText = + this._getLocalizedString("notifyBarRememberPasswordButtonText"); + var rememberButtonAccessKey = + this._getLocalizedString("notifyBarRememberPasswordButtonAccessKey"); + + var displayHost = this._getShortDisplayHost(aLogin.hostname); + var notificationText = this._getLocalizedString( + "rememberPasswordMsgNoUsername", + [displayHost]); + + // The callbacks in |buttons| have a closure to access the variables + // in scope here; set one to |this._pwmgr| so we can get back to pwmgr + // without a getService() call. + var pwmgr = this._pwmgr; + + // Notification is a PopupNotification + if (aNotifyObj == this._getPopupNote()) { + this._showLoginCaptureDoorhanger(aLogin, "password-save"); + } else { + var notNowButtonText = + this._getLocalizedString("notifyBarNotNowButtonText"); + var notNowButtonAccessKey = + this._getLocalizedString("notifyBarNotNowButtonAccessKey"); + var buttons = [ + // "Remember" button + { + label: rememberButtonText, + accessKey: rememberButtonAccessKey, + popup: null, + callback: function(aNotifyObj, aButton) { + pwmgr.addLogin(aLogin); + } + }, + + // "Never for this site" button + { + label: neverButtonText, + accessKey: neverButtonAccessKey, + popup: null, + callback: function(aNotifyObj, aButton) { + pwmgr.setLoginSavingEnabled(aLogin.hostname, false); + } + }, + + // "Not now" button + { + label: notNowButtonText, + accessKey: notNowButtonAccessKey, + popup: null, + callback: function() { /* NOP */ } + } + ]; + + this._showLoginNotification(aNotifyObj, "password-save", + notificationText, buttons); + } + + Services.obs.notifyObservers(aLogin, "passwordmgr-prompt-save", null); + }, + + _removeLoginNotifications : function () { + var popupNote = this._getPopupNote(); + if (popupNote) + popupNote = popupNote.getNotification("password"); + if (popupNote) + popupNote.remove(); + + var notifyBox = this._getNotifyBox(); + if (notifyBox) { + var oldBar = notifyBox.getNotificationWithValue("password-save"); + if (oldBar) { + this.log("Removing save-password notification bar."); + notifyBox.removeNotification(oldBar); + } + + oldBar = notifyBox.getNotificationWithValue("password-change"); + if (oldBar) { + this.log("Removing change-password notification bar."); + notifyBox.removeNotification(oldBar); + } + } + }, + + + /** + * Called when we detect a new login in a form submission, + * asks the user what to do. + */ + _showSaveLoginDialog : function (aLogin) { + const buttonFlags = Ci.nsIPrompt.BUTTON_POS_1_DEFAULT + + (Ci.nsIPrompt.BUTTON_TITLE_IS_STRING * Ci.nsIPrompt.BUTTON_POS_0) + + (Ci.nsIPrompt.BUTTON_TITLE_IS_STRING * Ci.nsIPrompt.BUTTON_POS_1) + + (Ci.nsIPrompt.BUTTON_TITLE_IS_STRING * Ci.nsIPrompt.BUTTON_POS_2); + + var displayHost = this._getShortDisplayHost(aLogin.hostname); + + var dialogText; + if (aLogin.username) { + var displayUser = this._sanitizeUsername(aLogin.username); + dialogText = this._getLocalizedString( + "rememberPasswordMsg", + [displayUser, displayHost]); + } else { + dialogText = this._getLocalizedString( + "rememberPasswordMsgNoUsername", + [displayHost]); + + } + var dialogTitle = this._getLocalizedString( + "savePasswordTitle"); + var neverButtonText = this._getLocalizedString( + "neverForSiteButtonText"); + var rememberButtonText = this._getLocalizedString( + "rememberButtonText"); + var notNowButtonText = this._getLocalizedString( + "notNowButtonText"); + + this.log("Prompting user to save/ignore login"); + var userChoice = this._promptService.confirmEx(this._chromeWindow, + dialogTitle, dialogText, + buttonFlags, rememberButtonText, + notNowButtonText, neverButtonText, + null, {}); + // Returns: + // 0 - Save the login + // 1 - Ignore the login this time + // 2 - Never save logins for this site + if (userChoice == 2) { + this.log("Disabling " + aLogin.hostname + " logins by request."); + this._pwmgr.setLoginSavingEnabled(aLogin.hostname, false); + } else if (userChoice == 0) { + this.log("Saving login for " + aLogin.hostname); + this._pwmgr.addLogin(aLogin); + } else { + // userChoice == 1 --> just ignore the login. + this.log("Ignoring login."); + } + + Services.obs.notifyObservers(aLogin, "passwordmgr-prompt-save", null); + }, + + + /** + * Called when we think we detect a password or username change for + * an existing login, when the form being submitted contains multiple + * 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); + } + }, + + /** + * Shows the Change Password notification bar or popup notification. + * + * @param aNotifyObj + * A notification box or a popup notification. + * + * @param aOldLogin + * The stored login we want to update. + * + * @param aNewLogin + * The login object with the changes we want to make. + */ + _showChangeLoginNotification(aNotifyObj, aOldLogin, aNewLogin) { + var changeButtonText = + this._getLocalizedString("notifyBarUpdateButtonText"); + var changeButtonAccessKey = + this._getLocalizedString("notifyBarUpdateButtonAccessKey"); + + // We reuse the existing message, even if it expects a username, until we + // switch to the final terminology in bug 1144856. + var displayHost = this._getShortDisplayHost(aOldLogin.hostname); + var notificationText = this._getLocalizedString("updatePasswordMsg", + [displayHost]); + + // The callbacks in |buttons| have a closure to access the variables + // in scope here; set one to |this._pwmgr| so we can get back to pwmgr + // without a getService() call. + var self = this; + + // Notification is a PopupNotification + if (aNotifyObj == this._getPopupNote()) { + aOldLogin.hostname = aNewLogin.hostname; + aOldLogin.formSubmitURL = aNewLogin.formSubmitURL; + aOldLogin.password = aNewLogin.password; + aOldLogin.username = aNewLogin.username; + this._showLoginCaptureDoorhanger(aOldLogin, "password-change"); + } else { + var dontChangeButtonText = + this._getLocalizedString("notifyBarDontChangeButtonText"); + var dontChangeButtonAccessKey = + this._getLocalizedString("notifyBarDontChangeButtonAccessKey"); + var buttons = [ + // "Yes" button + { + label: changeButtonText, + accessKey: changeButtonAccessKey, + popup: null, + callback: function(aNotifyObj, aButton) { + self._updateLogin(aOldLogin, aNewLogin); + } + }, + + // "No" button + { + label: dontChangeButtonText, + accessKey: dontChangeButtonAccessKey, + popup: null, + callback: function(aNotifyObj, aButton) { + // do nothing + } + } + ]; + + this._showLoginNotification(aNotifyObj, "password-change", + notificationText, buttons); + } + + let oldGUID = aOldLogin.QueryInterface(Ci.nsILoginMetaInfo).guid; + Services.obs.notifyObservers(aNewLogin, "passwordmgr-prompt-change", oldGUID); + }, + + + /** + * Shows the Change Password dialog. + */ + _showChangeLoginDialog(aOldLogin, aNewLogin) { + const buttonFlags = Ci.nsIPrompt.STD_YES_NO_BUTTONS; + + var dialogText; + if (aOldLogin.username) + dialogText = this._getLocalizedString( + "updatePasswordMsg", + [aOldLogin.username]); + else + dialogText = this._getLocalizedString( + "updatePasswordMsgNoUser"); + + var dialogTitle = this._getLocalizedString( + "passwordChangeTitle"); + + // returns 0 for yes, 1 for no. + var ok = !this._promptService.confirmEx(this._chromeWindow, + dialogTitle, dialogText, buttonFlags, + null, null, null, + null, {}); + if (ok) { + this.log("Updating password for user " + aOldLogin.username); + this._updateLogin(aOldLogin, aNewLogin); + } + + let oldGUID = aOldLogin.QueryInterface(Ci.nsILoginMetaInfo).guid; + Services.obs.notifyObservers(aNewLogin, "passwordmgr-prompt-change", oldGUID); + }, + + + /** + * Called when we detect a password change in a form submission, but we + * don't know which existing login (username) it's for. Asks the user + * to select a username and confirm the password change. + * + * 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); + + 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 + // of the selected username. + var ok = this._promptService.select(this._chromeWindow, + dialogTitle, dialogText, + usernames.length, usernames, + selectedIndex); + if (ok) { + // Now that we know which login to use, modify its password. + var selectedLogin = logins[selectedIndex.value]; + this.log("Updating password for user " + selectedLogin.username); + var newLoginWithUsername = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + newLoginWithUsername.init(aNewLogin.hostname, + aNewLogin.formSubmitURL, aNewLogin.httpRealm, + selectedLogin.username, aNewLogin.password, + selectedLogin.userNameField, aNewLogin.passwordField); + this._updateLogin(selectedLogin, newLoginWithUsername); + } + }, + + + + + /* ---------- Internal Methods ---------- */ + + + + + _updateLogin(login, aNewLogin = null) { + var now = Date.now(); + var propBag = Cc["@mozilla.org/hash-property-bag;1"]. + createInstance(Ci.nsIWritablePropertyBag); + if (aNewLogin) { + propBag.setProperty("formSubmitURL", aNewLogin.formSubmitURL); + propBag.setProperty("hostname", aNewLogin.hostname); + propBag.setProperty("password", aNewLogin.password); + propBag.setProperty("username", aNewLogin.username); + // Explicitly set the password change time here (even though it would + // be changed automatically), to ensure that it's exactly the same + // value as timeLastUsed. + propBag.setProperty("timePasswordChanged", now); + } + propBag.setProperty("timeLastUsed", now); + propBag.setProperty("timesUsedIncrement", 1); + this._pwmgr.modifyLogin(login, propBag); + }, + + /** + * Given a content DOM window, returns the chrome window and browser it's in. + */ + _getChromeWindow: function (aWindow) { + let windows = Services.wm.getEnumerator(null); + while (windows.hasMoreElements()) { + let win = windows.getNext(); + let browser = win.gBrowser.getBrowserForContentWindow(aWindow); + if (browser) { + return { win, browser }; + } + } + return null; + }, + + _getNotifyWindow: function () { + // Some sites pop up a temporary login window, which disappears + // upon submission of credentials. We want to put the notification + // bar in the opener window if this seems to be happening. + if (this._opener) { + let chromeDoc = this._chromeWindow.document.documentElement; + + // Check to see if the current window was opened with chrome + // disabled, and if so use the opener window. But if the window + // has been used to visit other pages (ie, has a history), + // assume it'll stick around and *don't* use the opener. + if (chromeDoc.getAttribute("chromehidden") && !this._browser.canGoBack) { + this.log("Using opener window for notification bar."); + return this._getChromeWindow(this._opener); + } + } + + return { win: this._chromeWindow, browser: this._browser }; + }, + + /** + * Returns the popup notification to this prompter, + * or null if there isn't one available. + */ + _getPopupNote : function () { + let popupNote = null; + + try { + let { win: notifyWin } = this._getNotifyWindow(); + + // .wrappedJSObject needed here -- see bug 422974 comment 5. + popupNote = notifyWin.wrappedJSObject.PopupNotifications; + } catch (e) { + this.log("Popup notifications not available on window"); + } + + return popupNote; + }, + + + /** + * Returns the notification box to this prompter, or null if there isn't + * a notification box available. + */ + _getNotifyBox : function () { + let notifyBox = null; + + try { + let { win: notifyWin } = this._getNotifyWindow(); + + // .wrappedJSObject needed here -- see bug 422974 comment 5. + notifyBox = notifyWin.wrappedJSObject.getNotificationBox(notifyWin); + } catch (e) { + this.log("Notification bars not available on window"); + } + + return notifyBox; + }, + + + /** + * The user might enter a login that isn't the one we prefilled, but + * is the same as some other existing login. So, pick a login with a + * matching username, or return null. + */ + _repickSelectedLogin : function (foundLogins, username) { + for (var i = 0; i < foundLogins.length; i++) + if (foundLogins[i].username == username) + return foundLogins[i]; + return null; + }, + + + /** + * Can be called as: + * _getLocalizedString("key1"); + * _getLocalizedString("key2", ["arg1"]); + * _getLocalizedString("key3", ["arg1", "arg2"]); + * (etc) + * + * Returns the localized string for the specified key, + * formatted if required. + * + */ + _getLocalizedString : function (key, formatArgs) { + if (formatArgs) + return this._strBundle.formatStringFromName( + key, formatArgs, formatArgs.length); + return this._strBundle.GetStringFromName(key); + }, + + + /** + * Sanitizes the specified username, by stripping quotes and truncating if + * it's too long. This helps prevent an evil site from messing with the + * "save password?" prompt too much. + */ + _sanitizeUsername : function (username) { + if (username.length > 30) { + username = username.substring(0, 30); + username += this._ellipsis; + } + return username.replace(/['"]/g, ""); + }, + + + /** + * The aURI parameter may either be a string uri, or an nsIURI instance. + * + * Returns the hostname to use in a nsILoginInfo object (for example, + * "http://example.com"). + */ + _getFormattedHostname : function (aURI) { + let uri; + if (aURI instanceof Ci.nsIURI) { + uri = aURI; + } else { + uri = Services.io.newURI(aURI, null, null); + } + + return uri.scheme + "://" + uri.hostPort; + }, + + + /** + * Converts a login's hostname field (a URL) to a short string for + * prompting purposes. Eg, "http://foo.com" --> "foo.com", or + * "ftp://www.site.co.uk" --> "site.co.uk". + */ + _getShortDisplayHost: function (aURIString) { + var displayHost; + + var eTLDService = Cc["@mozilla.org/network/effective-tld-service;1"]. + getService(Ci.nsIEffectiveTLDService); + var idnService = Cc["@mozilla.org/network/idn-service;1"]. + getService(Ci.nsIIDNService); + try { + var uri = Services.io.newURI(aURIString, null, null); + var baseDomain = eTLDService.getBaseDomain(uri); + displayHost = idnService.convertToDisplayIDN(baseDomain, {}); + } catch (e) { + this.log("_getShortDisplayHost couldn't process " + aURIString); + } + + if (!displayHost) + displayHost = aURIString; + + return displayHost; + }, + + + /** + * Returns the hostname and realm for which authentication is being + * requested, in the format expected to be used with nsILoginInfo. + */ + _getAuthTarget : function (aChannel, aAuthInfo) { + var hostname, realm; + + // If our proxy is demanding authentication, don't use the + // channel's actual destination. + if (aAuthInfo.flags & Ci.nsIAuthInformation.AUTH_PROXY) { + this.log("getAuthTarget is for proxy auth"); + if (!(aChannel instanceof Ci.nsIProxiedChannel)) + throw new Error("proxy auth needs nsIProxiedChannel"); + + var info = aChannel.proxyInfo; + if (!info) + throw new Error("proxy auth needs nsIProxyInfo"); + + // Proxies don't have a scheme, but we'll use "moz-proxy://" + // so that it's more obvious what the login is for. + var idnService = Cc["@mozilla.org/network/idn-service;1"]. + getService(Ci.nsIIDNService); + hostname = "moz-proxy://" + + idnService.convertUTF8toACE(info.host) + + ":" + info.port; + realm = aAuthInfo.realm; + if (!realm) + realm = hostname; + + return [hostname, realm]; + } + + hostname = this._getFormattedHostname(aChannel.URI); + + // If a HTTP WWW-Authenticate header specified a realm, that value + // will be available here. If it wasn't set or wasn't HTTP, we'll use + // the formatted hostname instead. + realm = aAuthInfo.realm; + if (!realm) + realm = hostname; + + return [hostname, realm]; + }, + + + /** + * Returns [username, password] as extracted from aAuthInfo (which + * holds this info after having prompted the user). + * + * If the authentication was for a Windows domain, we'll prepend the + * return username with the domain. (eg, "domain\user") + */ + _GetAuthInfo : function (aAuthInfo) { + var username, password; + + var flags = aAuthInfo.flags; + if (flags & Ci.nsIAuthInformation.NEED_DOMAIN && aAuthInfo.domain) + username = aAuthInfo.domain + "\\" + aAuthInfo.username; + else + username = aAuthInfo.username; + + password = aAuthInfo.password; + + return [username, password]; + }, + + + /** + * Given a username (possibly in DOMAIN\user form) and password, parses the + * domain out of the username if necessary and sets domain, username and + * password on the auth information object. + */ + _SetAuthInfo : function (aAuthInfo, username, password) { + var flags = aAuthInfo.flags; + if (flags & Ci.nsIAuthInformation.NEED_DOMAIN) { + // Domain is separated from username by a backslash + var idx = username.indexOf("\\"); + if (idx == -1) { + aAuthInfo.username = username; + } else { + aAuthInfo.domain = username.substring(0, idx); + aAuthInfo.username = username.substring(idx + 1); + } + } else { + aAuthInfo.username = username; + } + aAuthInfo.password = password; + }, + + _newAsyncPromptConsumer : function(aCallback, aContext) { + return { + QueryInterface: XPCOMUtils.generateQI([Ci.nsICancelable]), + callback: aCallback, + context: aContext, + cancel: function() { + this.callback.onAuthCancelled(this.context, false); + this.callback = null; + this.context = null; + } + }; + }, + + /** + * This function looks for existing logins that can be updated + * to match a submitted login, instead of creating a new one. + * + * Given a login and a loginList, it filters the login list + * to find every login with either the same username as aLogin + * or with the same password as aLogin and an empty username + * so the user can add a username. + * + * @param {nsILoginInfo} aLogin + * login to use as filter. + * @param {nsILoginInfo[]} aLoginList + * Array of logins to filter. + * @returns {nsILoginInfo[]} the filtered array of logins. + */ + _filterUpdatableLogins(aLogin, aLoginList) { + return aLoginList.filter(l => l.username == aLogin.username || + (l.password == aLogin.password && + !l.username)); + }, + +}; // end of LoginManagerPrompter implementation + +XPCOMUtils.defineLazyGetter(this.LoginManagerPrompter.prototype, "log", () => { + let logger = LoginHelper.createLogger("LoginManagerPrompter"); + return logger.log.bind(logger); +}); + +var component = [LoginManagerPromptFactory, LoginManagerPrompter]; +this.NSGetFactory = XPCOMUtils.generateNSGetFactory(component); diff --git a/toolkit/components/passwordmgr/passwordmgr.manifest b/toolkit/components/passwordmgr/passwordmgr.manifest new file mode 100644 index 000000000..72e9ccffb --- /dev/null +++ b/toolkit/components/passwordmgr/passwordmgr.manifest @@ -0,0 +1,17 @@ +component {cb9e0de8-3598-4ed7-857b-827f011ad5d8} nsLoginManager.js +contract @mozilla.org/login-manager;1 {cb9e0de8-3598-4ed7-857b-827f011ad5d8} +component {749e62f4-60ae-4569-a8a2-de78b649660e} nsLoginManagerPrompter.js +contract @mozilla.org/passwordmanager/authpromptfactory;1 {749e62f4-60ae-4569-a8a2-de78b649660e} +component {8aa66d77-1bbb-45a6-991e-b8f47751c291} nsLoginManagerPrompter.js +contract @mozilla.org/login-manager/prompter;1 {8aa66d77-1bbb-45a6-991e-b8f47751c291} +component {0f2f347c-1e4f-40cc-8efd-792dea70a85e} nsLoginInfo.js +contract @mozilla.org/login-manager/loginInfo;1 {0f2f347c-1e4f-40cc-8efd-792dea70a85e} +#ifdef ANDROID +component {8c2023b9-175c-477e-9761-44ae7b549756} storage-mozStorage.js +contract @mozilla.org/login-manager/storage/mozStorage;1 {8c2023b9-175c-477e-9761-44ae7b549756} +#else +component {c00c432d-a0c9-46d7-bef6-9c45b4d07341} storage-json.js +contract @mozilla.org/login-manager/storage/json;1 {c00c432d-a0c9-46d7-bef6-9c45b4d07341} +#endif +component {dc6c2976-0f73-4f1f-b9ff-3d72b4e28309} crypto-SDR.js +contract @mozilla.org/login-manager/crypto/SDR;1 {dc6c2976-0f73-4f1f-b9ff-3d72b4e28309}
\ No newline at end of file diff --git a/toolkit/components/passwordmgr/storage-json.js b/toolkit/components/passwordmgr/storage-json.js new file mode 100644 index 000000000..20834d45b --- /dev/null +++ b/toolkit/components/passwordmgr/storage-json.js @@ -0,0 +1,514 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* + * nsILoginManagerStorage implementation for the JSON back-end. + */ + +"use strict"; + +const { 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/Task.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper", + "resource://gre/modules/LoginHelper.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "LoginImport", + "resource://gre/modules/LoginImport.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "LoginStore", + "resource://gre/modules/LoginStore.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "gUUIDGenerator", + "@mozilla.org/uuid-generator;1", + "nsIUUIDGenerator"); + +this.LoginManagerStorage_json = function () {}; + +this.LoginManagerStorage_json.prototype = { + classID: Components.ID("{c00c432d-a0c9-46d7-bef6-9c45b4d07341}"), + QueryInterface: XPCOMUtils.generateQI([Ci.nsILoginManagerStorage]), + + __crypto: null, // nsILoginManagerCrypto service + get _crypto() { + if (!this.__crypto) + this.__crypto = Cc["@mozilla.org/login-manager/crypto/SDR;1"]. + getService(Ci.nsILoginManagerCrypto); + return this.__crypto; + }, + + initialize() { + try { + // Force initialization of the crypto module. + // See bug 717490 comment 17. + this._crypto; + + // Set the reference to LoginStore synchronously. + let jsonPath = OS.Path.join(OS.Constants.Path.profileDir, + "logins.json"); + this._store = new LoginStore(jsonPath); + + return Task.spawn(function* () { + // Load the data asynchronously. + this.log("Opening database at", this._store.path); + yield this._store.load(); + + // The import from previous versions operates the first time + // that this built-in storage back-end is used. This may be + // later than expected, in case add-ons have registered an + // alternate storage that disabled the default one. + try { + if (Services.prefs.getBoolPref("signon.importedFromSqlite")) { + return; + } + } catch (ex) { + // If the preference does not exist, we need to import. + } + + // Import only happens asynchronously. + let sqlitePath = OS.Path.join(OS.Constants.Path.profileDir, + "signons.sqlite"); + if (yield OS.File.exists(sqlitePath)) { + let loginImport = new LoginImport(this._store, sqlitePath); + // Failures during import, for example due to a corrupt + // file or a schema version that is too old, will not + // prevent us from marking the operation as completed. + // At the next startup, we will not try the import again. + yield loginImport.import().catch(Cu.reportError); + this._store.saveSoon(); + } + + // We won't attempt import again on next startup. + Services.prefs.setBoolPref("signon.importedFromSqlite", true); + }.bind(this)).catch(Cu.reportError); + } catch (e) { + this.log("Initialization failed:", e); + throw new Error("Initialization failed"); + } + }, + + /** + * Internal method used by regression tests only. It is called before + * replacing this storage module with a new instance. + */ + terminate() { + this._store._saver.disarm(); + return this._store._save(); + }, + + addLogin(login) { + this._store.ensureDataReady(); + + // Throws if there are bogus values. + LoginHelper.checkLoginValues(login); + + let [encUsername, encPassword, encType] = this._encryptLogin(login); + + // Clone the login, so we don't modify the caller's object. + let loginClone = login.clone(); + + // Initialize the nsILoginMetaInfo fields, unless the caller gave us values + loginClone.QueryInterface(Ci.nsILoginMetaInfo); + if (loginClone.guid) { + if (!this._isGuidUnique(loginClone.guid)) + throw new Error("specified GUID already exists"); + } else { + loginClone.guid = gUUIDGenerator.generateUUID().toString(); + } + + // Set timestamps + let currentTime = Date.now(); + if (!loginClone.timeCreated) + loginClone.timeCreated = currentTime; + if (!loginClone.timeLastUsed) + loginClone.timeLastUsed = currentTime; + if (!loginClone.timePasswordChanged) + loginClone.timePasswordChanged = currentTime; + if (!loginClone.timesUsed) + loginClone.timesUsed = 1; + + this._store.data.logins.push({ + id: this._store.data.nextId++, + hostname: loginClone.hostname, + httpRealm: loginClone.httpRealm, + formSubmitURL: loginClone.formSubmitURL, + usernameField: loginClone.usernameField, + passwordField: loginClone.passwordField, + encryptedUsername: encUsername, + encryptedPassword: encPassword, + guid: loginClone.guid, + encType: encType, + timeCreated: loginClone.timeCreated, + timeLastUsed: loginClone.timeLastUsed, + timePasswordChanged: loginClone.timePasswordChanged, + timesUsed: loginClone.timesUsed + }); + this._store.saveSoon(); + + // Send a notification that a login was added. + LoginHelper.notifyStorageChanged("addLogin", loginClone); + return loginClone; + }, + + removeLogin(login) { + this._store.ensureDataReady(); + + let [idToDelete, storedLogin] = this._getIdForLogin(login); + if (!idToDelete) + throw new Error("No matching logins"); + + let foundIndex = this._store.data.logins.findIndex(l => l.id == idToDelete); + if (foundIndex != -1) { + this._store.data.logins.splice(foundIndex, 1); + this._store.saveSoon(); + } + + LoginHelper.notifyStorageChanged("removeLogin", storedLogin); + }, + + modifyLogin(oldLogin, newLoginData) { + this._store.ensureDataReady(); + + let [idToModify, oldStoredLogin] = this._getIdForLogin(oldLogin); + if (!idToModify) + throw new Error("No matching logins"); + + let newLogin = LoginHelper.buildModifiedLogin(oldStoredLogin, newLoginData); + + // Check if the new GUID is duplicate. + if (newLogin.guid != oldStoredLogin.guid && + !this._isGuidUnique(newLogin.guid)) { + throw new Error("specified GUID already exists"); + } + + // Look for an existing entry in case key properties changed. + if (!newLogin.matches(oldLogin, true)) { + let logins = this.findLogins({}, newLogin.hostname, + newLogin.formSubmitURL, + newLogin.httpRealm); + + if (logins.some(login => newLogin.matches(login, true))) + throw new Error("This login already exists."); + } + + // Get the encrypted value of the username and password. + let [encUsername, encPassword, encType] = this._encryptLogin(newLogin); + + for (let loginItem of this._store.data.logins) { + if (loginItem.id == idToModify) { + loginItem.hostname = newLogin.hostname; + loginItem.httpRealm = newLogin.httpRealm; + loginItem.formSubmitURL = newLogin.formSubmitURL; + loginItem.usernameField = newLogin.usernameField; + loginItem.passwordField = newLogin.passwordField; + loginItem.encryptedUsername = encUsername; + loginItem.encryptedPassword = encPassword; + loginItem.guid = newLogin.guid; + loginItem.encType = encType; + loginItem.timeCreated = newLogin.timeCreated; + loginItem.timeLastUsed = newLogin.timeLastUsed; + loginItem.timePasswordChanged = newLogin.timePasswordChanged; + loginItem.timesUsed = newLogin.timesUsed; + this._store.saveSoon(); + break; + } + } + + LoginHelper.notifyStorageChanged("modifyLogin", [oldStoredLogin, newLogin]); + }, + + /** + * @return {nsILoginInfo[]} + */ + getAllLogins(count) { + let [logins, ids] = this._searchLogins({}); + + // decrypt entries for caller. + logins = this._decryptLogins(logins); + + this.log("_getAllLogins: returning", logins.length, "logins."); + if (count) + count.value = logins.length; // needed for XPCOM + return logins; + }, + + /** + * 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); + 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, 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, aOptions = { + schemeUpgrades: false, + }) { + this._store.ensureDataReady(); + + function match(aLogin) { + for (let field in matchData) { + let wantedValue = matchData[field]; + switch (field) { + case "formSubmitURL": + 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 "httpRealm": + case "id": + case "usernameField": + case "passwordField": + case "encryptedUsername": + case "encryptedPassword": + case "guid": + case "encType": + case "timeCreated": + case "timeLastUsed": + case "timePasswordChanged": + case "timesUsed": + if (wantedValue == null && aLogin[field]) { + return false; + } else if (aLogin[field] != wantedValue) { + return false; + } + break; + // Fail if caller requests an unknown property. + default: + throw new Error("Unexpected field: " + field); + } + } + return true; + } + + let foundLogins = [], foundIds = []; + for (let loginItem of this._store.data.logins) { + if (match(loginItem)) { + // Create the new nsLoginInfo object, push to array + let login = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + login.init(loginItem.hostname, loginItem.formSubmitURL, + loginItem.httpRealm, loginItem.encryptedUsername, + loginItem.encryptedPassword, loginItem.usernameField, + loginItem.passwordField); + // set nsILoginMetaInfo values + login.QueryInterface(Ci.nsILoginMetaInfo); + login.guid = loginItem.guid; + login.timeCreated = loginItem.timeCreated; + 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 for", matchData, + "with options", aOptions); + return [foundLogins, foundIds]; + }, + + /** + * Removes all logins from storage. + */ + removeAllLogins() { + this._store.ensureDataReady(); + + this.log("Removing all logins"); + this._store.data.logins = []; + this._store.saveSoon(); + + LoginHelper.notifyStorageChanged("removeAllLogins", null); + }, + + findLogins(count, hostname, formSubmitURL, httpRealm) { + let loginData = { + hostname: hostname, + formSubmitURL: formSubmitURL, + httpRealm: httpRealm + }; + let matchData = { }; + for (let field of ["hostname", "formSubmitURL", "httpRealm"]) + if (loginData[field] != '') + matchData[field] = loginData[field]; + let [logins, ids] = this._searchLogins(matchData); + + // Decrypt entries found for the caller. + logins = this._decryptLogins(logins); + + this.log("_findLogins: returning", logins.length, "logins"); + count.value = logins.length; // needed for XPCOM + return logins; + }, + + countLogins(hostname, formSubmitURL, httpRealm) { + let loginData = { + hostname: hostname, + formSubmitURL: formSubmitURL, + httpRealm: httpRealm + }; + let matchData = { }; + for (let field of ["hostname", "formSubmitURL", "httpRealm"]) + if (loginData[field] != '') + matchData[field] = loginData[field]; + let [logins, ids] = this._searchLogins(matchData); + + this.log("_countLogins: counted logins:", logins.length); + return logins.length; + }, + + get uiBusy() { + return this._crypto.uiBusy; + }, + + get isLoggedIn() { + return this._crypto.isLoggedIn; + }, + + /** + * Returns an array with two items: [id, login]. If the login was not + * found, both items will be null. The returned login contains the actual + * stored login (useful for looking at the actual nsILoginMetaInfo values). + */ + _getIdForLogin(login) { + let matchData = { }; + for (let field of ["hostname", "formSubmitURL", "httpRealm"]) + if (login[field] != '') + matchData[field] = login[field]; + let [logins, ids] = this._searchLogins(matchData); + + let id = null; + let foundLogin = null; + + // The specified login isn't encrypted, so we need to ensure + // the logins we're comparing with are decrypted. We decrypt one entry + // at a time, lest _decryptLogins return fewer entries and screw up + // indices between the two. + for (let i = 0; i < logins.length; i++) { + let [decryptedLogin] = this._decryptLogins([logins[i]]); + + if (!decryptedLogin || !decryptedLogin.equals(login)) + continue; + + // We've found a match, set id and break + foundLogin = decryptedLogin; + id = ids[i]; + break; + } + + return [id, foundLogin]; + }, + + /** + * Checks to see if the specified GUID already exists. + */ + _isGuidUnique(guid) { + this._store.ensureDataReady(); + + return this._store.data.logins.every(l => l.guid != guid); + }, + + /** + * Returns the encrypted username, password, and encrypton type for the specified + * login. Can throw if the user cancels a master password entry. + */ + _encryptLogin(login) { + let encUsername = this._crypto.encrypt(login.username); + let encPassword = this._crypto.encrypt(login.password); + let encType = this._crypto.defaultEncType; + + return [encUsername, encPassword, encType]; + }, + + /** + * Decrypts username and password fields in the provided array of + * logins. + * + * The entries specified by the array will be decrypted, if possible. + * An array of successfully decrypted logins will be returned. The return + * value should be given to external callers (since still-encrypted + * entries are useless), whereas internal callers generally don't want + * to lose unencrypted entries (eg, because the user clicked Cancel + * instead of entering their master password) + */ + _decryptLogins(logins) { + let result = []; + + for (let login of logins) { + try { + login.username = this._crypto.decrypt(login.username); + login.password = this._crypto.decrypt(login.password); + } catch (e) { + // If decryption failed (corrupt entry?), just skip it. + // Rethrow other errors (like canceling entry of a master pw) + if (e.result == Cr.NS_ERROR_FAILURE) + continue; + throw e; + } + result.push(login); + } + + return result; + }, +}; + +XPCOMUtils.defineLazyGetter(this.LoginManagerStorage_json.prototype, "log", () => { + let logger = LoginHelper.createLogger("Login storage"); + return logger.log.bind(logger); +}); + +this.NSGetFactory = XPCOMUtils.generateNSGetFactory([LoginManagerStorage_json]); diff --git a/toolkit/components/passwordmgr/storage-mozStorage.js b/toolkit/components/passwordmgr/storage-mozStorage.js new file mode 100644 index 000000000..7fc9e57fd --- /dev/null +++ b/toolkit/components/passwordmgr/storage-mozStorage.js @@ -0,0 +1,1262 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; +const DB_VERSION = 6; // The database schema version +const PERMISSION_SAVE_LOGINS = "login-saving"; + +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/Promise.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper", + "resource://gre/modules/LoginHelper.jsm"); + +/** + * Object that manages a database transaction properly so consumers don't have + * to worry about it throwing. + * + * @param aDatabase + * The mozIStorageConnection to start a transaction on. + */ +function Transaction(aDatabase) { + this._db = aDatabase; + + this._hasTransaction = false; + try { + this._db.beginTransaction(); + this._hasTransaction = true; + } catch (e) { /* om nom nom exceptions */ } +} + +Transaction.prototype = { + commit : function() { + if (this._hasTransaction) + this._db.commitTransaction(); + }, + + rollback : function() { + if (this._hasTransaction) + this._db.rollbackTransaction(); + }, +}; + + +function LoginManagerStorage_mozStorage() { } + +LoginManagerStorage_mozStorage.prototype = { + + classID : Components.ID("{8c2023b9-175c-477e-9761-44ae7b549756}"), + QueryInterface : XPCOMUtils.generateQI([Ci.nsILoginManagerStorage, + Ci.nsIInterfaceRequestor]), + getInterface : function(aIID) { + if (aIID.equals(Ci.nsIVariant)) { + // Allows unwrapping the JavaScript object for regression tests. + return this; + } + + if (aIID.equals(Ci.mozIStorageConnection)) { + return this._dbConnection; + } + + throw new Components.Exception("Interface not available", Cr.NS_ERROR_NO_INTERFACE); + }, + + __crypto : null, // nsILoginManagerCrypto service + get _crypto() { + if (!this.__crypto) + this.__crypto = Cc["@mozilla.org/login-manager/crypto/SDR;1"]. + getService(Ci.nsILoginManagerCrypto); + return this.__crypto; + }, + + __profileDir: null, // nsIFile for the user's profile dir + get _profileDir() { + if (!this.__profileDir) + this.__profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + return this.__profileDir; + }, + + __storageService: null, // Storage service for using mozStorage + get _storageService() { + if (!this.__storageService) + this.__storageService = Cc["@mozilla.org/storage/service;1"]. + getService(Ci.mozIStorageService); + return this.__storageService; + }, + + __uuidService: null, + get _uuidService() { + if (!this.__uuidService) + this.__uuidService = Cc["@mozilla.org/uuid-generator;1"]. + getService(Ci.nsIUUIDGenerator); + return this.__uuidService; + }, + + + // The current database schema. + _dbSchema: { + tables: { + moz_logins: "id INTEGER PRIMARY KEY," + + "hostname TEXT NOT NULL," + + "httpRealm TEXT," + + "formSubmitURL TEXT," + + "usernameField TEXT NOT NULL," + + "passwordField TEXT NOT NULL," + + "encryptedUsername TEXT NOT NULL," + + "encryptedPassword TEXT NOT NULL," + + "guid TEXT," + + "encType INTEGER," + + "timeCreated INTEGER," + + "timeLastUsed INTEGER," + + "timePasswordChanged INTEGER," + + "timesUsed INTEGER", + // Changes must be reflected in this._dbAreExpectedColumnsPresent(), + // this._searchLogins(), and this.modifyLogin(). + + moz_disabledHosts: "id INTEGER PRIMARY KEY," + + "hostname TEXT UNIQUE ON CONFLICT REPLACE", + + moz_deleted_logins: "id INTEGER PRIMARY KEY," + + "guid TEXT," + + "timeDeleted INTEGER", + }, + indices: { + moz_logins_hostname_index: { + table: "moz_logins", + columns: ["hostname"] + }, + moz_logins_hostname_formSubmitURL_index: { + table: "moz_logins", + columns: ["hostname", "formSubmitURL"] + }, + moz_logins_hostname_httpRealm_index: { + table: "moz_logins", + columns: ["hostname", "httpRealm"] + }, + moz_logins_guid_index: { + table: "moz_logins", + columns: ["guid"] + }, + moz_logins_encType_index: { + table: "moz_logins", + columns: ["encType"] + } + } + }, + _dbConnection : null, // The database connection + _dbStmts : null, // Database statements for memoization + + _signonsFile : null, // nsIFile for "signons.sqlite" + + + /* + * Internal method used by regression tests only. It overrides the default + * database location. + */ + initWithFile : function(aDBFile) { + if (aDBFile) + this._signonsFile = aDBFile; + + this.initialize(); + }, + + + initialize : function () { + this._dbStmts = {}; + + let isFirstRun; + try { + // Force initialization of the crypto module. + // See bug 717490 comment 17. + this._crypto; + + // If initWithFile is calling us, _signonsFile may already be set. + if (!this._signonsFile) { + // Initialize signons.sqlite + this._signonsFile = this._profileDir.clone(); + this._signonsFile.append("signons.sqlite"); + } + this.log("Opening database at " + this._signonsFile.path); + + // Initialize the database (create, migrate as necessary) + isFirstRun = this._dbInit(); + + this._initialized = true; + + return Promise.resolve(); + } catch (e) { + this.log("Initialization failed: " + e); + // If the import fails on first run, we want to delete the db + if (isFirstRun && e == "Import failed") + this._dbCleanup(false); + throw new Error("Initialization failed"); + } + }, + + + /** + * Internal method used by regression tests only. It is called before + * replacing this storage module with a new instance. + */ + terminate : function () { + return Promise.resolve(); + }, + + + addLogin : function (login) { + // Throws if there are bogus values. + LoginHelper.checkLoginValues(login); + + let [encUsername, encPassword, encType] = this._encryptLogin(login); + + // Clone the login, so we don't modify the caller's object. + let loginClone = login.clone(); + + // Initialize the nsILoginMetaInfo fields, unless the caller gave us values + loginClone.QueryInterface(Ci.nsILoginMetaInfo); + if (loginClone.guid) { + if (!this._isGuidUnique(loginClone.guid)) + throw new Error("specified GUID already exists"); + } else { + loginClone.guid = this._uuidService.generateUUID().toString(); + } + + // Set timestamps + let currentTime = Date.now(); + if (!loginClone.timeCreated) + loginClone.timeCreated = currentTime; + if (!loginClone.timeLastUsed) + loginClone.timeLastUsed = currentTime; + if (!loginClone.timePasswordChanged) + loginClone.timePasswordChanged = currentTime; + if (!loginClone.timesUsed) + loginClone.timesUsed = 1; + + let query = + "INSERT INTO moz_logins " + + "(hostname, httpRealm, formSubmitURL, usernameField, " + + "passwordField, encryptedUsername, encryptedPassword, " + + "guid, encType, timeCreated, timeLastUsed, timePasswordChanged, " + + "timesUsed) " + + "VALUES (:hostname, :httpRealm, :formSubmitURL, :usernameField, " + + ":passwordField, :encryptedUsername, :encryptedPassword, " + + ":guid, :encType, :timeCreated, :timeLastUsed, " + + ":timePasswordChanged, :timesUsed)"; + + let params = { + hostname: loginClone.hostname, + httpRealm: loginClone.httpRealm, + formSubmitURL: loginClone.formSubmitURL, + usernameField: loginClone.usernameField, + passwordField: loginClone.passwordField, + encryptedUsername: encUsername, + encryptedPassword: encPassword, + guid: loginClone.guid, + encType: encType, + timeCreated: loginClone.timeCreated, + timeLastUsed: loginClone.timeLastUsed, + timePasswordChanged: loginClone.timePasswordChanged, + timesUsed: loginClone.timesUsed + }; + + let stmt; + try { + stmt = this._dbCreateStatement(query, params); + stmt.execute(); + } catch (e) { + this.log("addLogin failed: " + e.name + " : " + e.message); + throw new Error("Couldn't write to database, login not added."); + } finally { + if (stmt) { + stmt.reset(); + } + } + + // Send a notification that a login was added. + LoginHelper.notifyStorageChanged("addLogin", loginClone); + return loginClone; + }, + + + removeLogin : function (login) { + let [idToDelete, storedLogin] = this._getIdForLogin(login); + if (!idToDelete) + throw new Error("No matching logins"); + + // Execute the statement & remove from DB + let query = "DELETE FROM moz_logins WHERE id = :id"; + let params = { id: idToDelete }; + let stmt; + let transaction = new Transaction(this._dbConnection); + try { + stmt = this._dbCreateStatement(query, params); + stmt.execute(); + this.storeDeletedLogin(storedLogin); + transaction.commit(); + } catch (e) { + this.log("_removeLogin failed: " + e.name + " : " + e.message); + transaction.rollback(); + throw new Error("Couldn't write to database, login not removed."); + } finally { + if (stmt) { + stmt.reset(); + } + } + LoginHelper.notifyStorageChanged("removeLogin", storedLogin); + }, + + modifyLogin : function (oldLogin, newLoginData) { + let [idToModify, oldStoredLogin] = this._getIdForLogin(oldLogin); + if (!idToModify) + throw new Error("No matching logins"); + + let newLogin = LoginHelper.buildModifiedLogin(oldStoredLogin, newLoginData); + + // Check if the new GUID is duplicate. + if (newLogin.guid != oldStoredLogin.guid && + !this._isGuidUnique(newLogin.guid)) { + throw new Error("specified GUID already exists"); + } + + // Look for an existing entry in case key properties changed. + if (!newLogin.matches(oldLogin, true)) { + let logins = this.findLogins({}, newLogin.hostname, + newLogin.formSubmitURL, + newLogin.httpRealm); + + if (logins.some(login => newLogin.matches(login, true))) + throw new Error("This login already exists."); + } + + // Get the encrypted value of the username and password. + let [encUsername, encPassword, encType] = this._encryptLogin(newLogin); + + let query = + "UPDATE moz_logins " + + "SET hostname = :hostname, " + + "httpRealm = :httpRealm, " + + "formSubmitURL = :formSubmitURL, " + + "usernameField = :usernameField, " + + "passwordField = :passwordField, " + + "encryptedUsername = :encryptedUsername, " + + "encryptedPassword = :encryptedPassword, " + + "guid = :guid, " + + "encType = :encType, " + + "timeCreated = :timeCreated, " + + "timeLastUsed = :timeLastUsed, " + + "timePasswordChanged = :timePasswordChanged, " + + "timesUsed = :timesUsed " + + "WHERE id = :id"; + + let params = { + id: idToModify, + hostname: newLogin.hostname, + httpRealm: newLogin.httpRealm, + formSubmitURL: newLogin.formSubmitURL, + usernameField: newLogin.usernameField, + passwordField: newLogin.passwordField, + encryptedUsername: encUsername, + encryptedPassword: encPassword, + guid: newLogin.guid, + encType: encType, + timeCreated: newLogin.timeCreated, + timeLastUsed: newLogin.timeLastUsed, + timePasswordChanged: newLogin.timePasswordChanged, + timesUsed: newLogin.timesUsed + }; + + let stmt; + try { + stmt = this._dbCreateStatement(query, params); + stmt.execute(); + } catch (e) { + this.log("modifyLogin failed: " + e.name + " : " + e.message); + throw new Error("Couldn't write to database, login not modified."); + } finally { + if (stmt) { + stmt.reset(); + } + } + + LoginHelper.notifyStorageChanged("modifyLogin", [oldStoredLogin, newLogin]); + }, + + + /** + * Returns an array of nsILoginInfo. + */ + getAllLogins : function (count) { + let [logins, ids] = this._searchLogins({}); + + // decrypt entries for caller. + logins = this._decryptLogins(logins); + + this.log("_getAllLogins: returning " + logins.length + " logins."); + if (count) + count.value = logins.length; // needed for XPCOM + return logins; + }, + + + /** + * Public wrapper around _searchLogins to convert the nsIPropertyBag to a + * JavaScript object and decrypt the results. + * + * @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); + 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, 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 : function (matchData, aOptions = { + schemeUpgrades: false, + }) { + let conditions = [], params = {}; + + for (let field in matchData) { + let value = matchData[field]; + let condition = ""; + switch (field) { + case "formSubmitURL": + if (value != null) { + // 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 "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) { + condition = field + " isnull"; + } else { + 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 "); + } + + let stmt; + let logins = [], ids = []; + try { + stmt = this._dbCreateStatement(query, params); + // We can't execute as usual here, since we're iterating over rows + while (stmt.executeStep()) { + // Create the new nsLoginInfo object, push to array + let login = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + login.init(stmt.row.hostname, stmt.row.formSubmitURL, + stmt.row.httpRealm, stmt.row.encryptedUsername, + stmt.row.encryptedPassword, stmt.row.usernameField, + stmt.row.passwordField); + // set nsILoginMetaInfo values + login.QueryInterface(Ci.nsILoginMetaInfo); + login.guid = stmt.row.guid; + login.timeCreated = stmt.row.timeCreated; + login.timeLastUsed = stmt.row.timeLastUsed; + login.timePasswordChanged = stmt.row.timePasswordChanged; + login.timesUsed = stmt.row.timesUsed; + logins.push(login); + ids.push(stmt.row.id); + } + } catch (e) { + this.log("_searchLogins failed: " + e.name + " : " + e.message); + } finally { + if (stmt) { + stmt.reset(); + } + } + + this.log("_searchLogins: returning " + logins.length + " logins"); + return [logins, ids]; + }, + + /** + * Moves a login to the deleted logins table + */ + storeDeletedLogin : function(aLogin) { + let stmt = null; + try { + this.log("Storing " + aLogin.guid + " in deleted passwords\n"); + let query = "INSERT INTO moz_deleted_logins (guid, timeDeleted) VALUES (:guid, :timeDeleted)"; + let params = { guid: aLogin.guid, + timeDeleted: Date.now() }; + let stmt = this._dbCreateStatement(query, params); + stmt.execute(); + } catch (ex) { + throw ex; + } finally { + if (stmt) + stmt.reset(); + } + }, + + + /** + * Removes all logins from storage. + */ + removeAllLogins : function () { + this.log("Removing all logins"); + let query; + let stmt; + let transaction = new Transaction(this._dbConnection); + + // Disabled hosts kept, as one presumably doesn't want to erase those. + // TODO: Add these items to the deleted items table once we've sorted + // out the issues from bug 756701 + query = "DELETE FROM moz_logins"; + try { + stmt = this._dbCreateStatement(query); + stmt.execute(); + transaction.commit(); + } catch (e) { + this.log("_removeAllLogins failed: " + e.name + " : " + e.message); + transaction.rollback(); + throw new Error("Couldn't write to database"); + } finally { + if (stmt) { + stmt.reset(); + } + } + + LoginHelper.notifyStorageChanged("removeAllLogins", null); + }, + + + findLogins : function (count, hostname, formSubmitURL, httpRealm) { + let loginData = { + hostname: hostname, + formSubmitURL: formSubmitURL, + httpRealm: httpRealm + }; + let matchData = { }; + for (let field of ["hostname", "formSubmitURL", "httpRealm"]) + if (loginData[field] != '') + matchData[field] = loginData[field]; + let [logins, ids] = this._searchLogins(matchData); + + // Decrypt entries found for the caller. + logins = this._decryptLogins(logins); + + this.log("_findLogins: returning " + logins.length + " logins"); + count.value = logins.length; // needed for XPCOM + return logins; + }, + + + countLogins : function (hostname, formSubmitURL, httpRealm) { + + let _countLoginsHelper = (hostname, formSubmitURL, httpRealm) => { + // Do checks for null and empty strings, adjust conditions and params + let [conditions, params] = + this._buildConditionsAndParams(hostname, formSubmitURL, httpRealm); + + let query = "SELECT COUNT(1) AS numLogins FROM moz_logins"; + if (conditions.length) { + conditions = conditions.map(c => "(" + c + ")"); + query += " WHERE " + conditions.join(" AND "); + } + + let stmt, numLogins; + try { + stmt = this._dbCreateStatement(query, params); + stmt.executeStep(); + numLogins = stmt.row.numLogins; + } catch (e) { + this.log("_countLogins failed: " + e.name + " : " + e.message); + } finally { + if (stmt) { + stmt.reset(); + } + } + return numLogins; + }; + + let resultLogins = _countLoginsHelper(hostname, formSubmitURL, httpRealm); + this.log("_countLogins: counted logins: " + resultLogins); + return resultLogins; + }, + + + get uiBusy() { + return this._crypto.uiBusy; + }, + + + get isLoggedIn() { + return this._crypto.isLoggedIn; + }, + + + /** + * Returns an array with two items: [id, login]. If the login was not + * found, both items will be null. The returned login contains the actual + * stored login (useful for looking at the actual nsILoginMetaInfo values). + */ + _getIdForLogin : function (login) { + let matchData = { }; + for (let field of ["hostname", "formSubmitURL", "httpRealm"]) + if (login[field] != '') + matchData[field] = login[field]; + let [logins, ids] = this._searchLogins(matchData); + + let id = null; + let foundLogin = null; + + // The specified login isn't encrypted, so we need to ensure + // the logins we're comparing with are decrypted. We decrypt one entry + // at a time, lest _decryptLogins return fewer entries and screw up + // indices between the two. + for (let i = 0; i < logins.length; i++) { + let [decryptedLogin] = this._decryptLogins([logins[i]]); + + if (!decryptedLogin || !decryptedLogin.equals(login)) + continue; + + // We've found a match, set id and break + foundLogin = decryptedLogin; + id = ids[i]; + break; + } + + return [id, foundLogin]; + }, + + + /** + * Adjusts the WHERE conditions and parameters for statements prior to the + * statement being created. This fixes the cases where nulls are involved + * and the empty string is supposed to be a wildcard match + */ + _buildConditionsAndParams : function (hostname, formSubmitURL, httpRealm) { + let conditions = [], params = {}; + + if (hostname == null) { + conditions.push("hostname isnull"); + } else if (hostname != '') { + conditions.push("hostname = :hostname"); + params["hostname"] = hostname; + } + + if (formSubmitURL == null) { + conditions.push("formSubmitURL isnull"); + } else if (formSubmitURL != '') { + conditions.push("formSubmitURL = :formSubmitURL OR formSubmitURL = ''"); + params["formSubmitURL"] = formSubmitURL; + } + + if (httpRealm == null) { + conditions.push("httpRealm isnull"); + } else if (httpRealm != '') { + conditions.push("httpRealm = :httpRealm"); + params["httpRealm"] = httpRealm; + } + + return [conditions, params]; + }, + + + /** + * Checks to see if the specified GUID already exists. + */ + _isGuidUnique : function (guid) { + let query = "SELECT COUNT(1) AS numLogins FROM moz_logins WHERE guid = :guid"; + let params = { guid: guid }; + + let stmt, numLogins; + try { + stmt = this._dbCreateStatement(query, params); + stmt.executeStep(); + numLogins = stmt.row.numLogins; + } catch (e) { + this.log("_isGuidUnique failed: " + e.name + " : " + e.message); + } finally { + if (stmt) { + stmt.reset(); + } + } + + return (numLogins == 0); + }, + + + /** + * Returns the encrypted username, password, and encrypton type for the specified + * login. Can throw if the user cancels a master password entry. + */ + _encryptLogin : function (login) { + let encUsername = this._crypto.encrypt(login.username); + let encPassword = this._crypto.encrypt(login.password); + let encType = this._crypto.defaultEncType; + + return [encUsername, encPassword, encType]; + }, + + + /** + * Decrypts username and password fields in the provided array of + * logins. + * + * The entries specified by the array will be decrypted, if possible. + * An array of successfully decrypted logins will be returned. The return + * value should be given to external callers (since still-encrypted + * entries are useless), whereas internal callers generally don't want + * to lose unencrypted entries (eg, because the user clicked Cancel + * instead of entering their master password) + */ + _decryptLogins : function (logins) { + let result = []; + + for (let login of logins) { + try { + login.username = this._crypto.decrypt(login.username); + login.password = this._crypto.decrypt(login.password); + } catch (e) { + // If decryption failed (corrupt entry?), just skip it. + // Rethrow other errors (like canceling entry of a master pw) + if (e.result == Cr.NS_ERROR_FAILURE) + continue; + throw e; + } + result.push(login); + } + + return result; + }, + + + // Database Creation & Access + + /** + * Creates a statement, wraps it, and then does parameter replacement + * Returns the wrapped statement for execution. Will use memoization + * so that statements can be reused. + */ + _dbCreateStatement : function (query, params) { + let wrappedStmt = this._dbStmts[query]; + // Memoize the statements + if (!wrappedStmt) { + this.log("Creating new statement for query: " + query); + wrappedStmt = this._dbConnection.createStatement(query); + this._dbStmts[query] = wrappedStmt; + } + // Replace parameters, must be done 1 at a time + if (params) + for (let i in params) + wrappedStmt.params[i] = params[i]; + return wrappedStmt; + }, + + + /** + * Attempts to initialize the database. This creates the file if it doesn't + * exist, performs any migrations, etc. Return if this is the first run. + */ + _dbInit : function () { + this.log("Initializing Database"); + let isFirstRun = false; + try { + this._dbConnection = this._storageService.openDatabase(this._signonsFile); + // Get the version of the schema in the file. It will be 0 if the + // database has not been created yet. + let version = this._dbConnection.schemaVersion; + if (version == 0) { + this._dbCreate(); + isFirstRun = true; + } else if (version != DB_VERSION) { + this._dbMigrate(version); + } + } catch (e) { + if (e.result == Cr.NS_ERROR_FILE_CORRUPTED) { + // Database is corrupted, so we backup the database, then throw + // causing initialization to fail and a new db to be created next use + this._dbCleanup(true); + } + throw e; + } + + Services.obs.addObserver(this, "profile-before-change", false); + return isFirstRun; + }, + + observe: function (subject, topic, data) { + switch (topic) { + case "profile-before-change": + Services.obs.removeObserver(this, "profile-before-change"); + this._dbClose(); + break; + } + }, + + _dbCreate: function () { + this.log("Creating Database"); + this._dbCreateSchema(); + this._dbConnection.schemaVersion = DB_VERSION; + }, + + + _dbCreateSchema : function () { + this._dbCreateTables(); + this._dbCreateIndices(); + }, + + + _dbCreateTables : function () { + this.log("Creating Tables"); + for (let name in this._dbSchema.tables) + this._dbConnection.createTable(name, this._dbSchema.tables[name]); + }, + + + _dbCreateIndices : function () { + this.log("Creating Indices"); + for (let name in this._dbSchema.indices) { + let index = this._dbSchema.indices[name]; + let statement = "CREATE INDEX IF NOT EXISTS " + name + " ON " + index.table + + "(" + index.columns.join(", ") + ")"; + this._dbConnection.executeSimpleSQL(statement); + } + }, + + + _dbMigrate : function (oldVersion) { + this.log("Attempting to migrate from version " + oldVersion); + + if (oldVersion > DB_VERSION) { + this.log("Downgrading to version " + DB_VERSION); + // User's DB is newer. Sanity check that our expected columns are + // present, and if so mark the lower version and merrily continue + // on. If the columns are borked, something is wrong so blow away + // the DB and start from scratch. [Future incompatible upgrades + // should swtich to a different table or file.] + + if (!this._dbAreExpectedColumnsPresent()) + throw Components.Exception("DB is missing expected columns", + Cr.NS_ERROR_FILE_CORRUPTED); + + // Change the stored version to the current version. If the user + // runs the newer code again, it will see the lower version number + // and re-upgrade (to fixup any entries the old code added). + this._dbConnection.schemaVersion = DB_VERSION; + return; + } + + // Upgrade to newer version... + + let transaction = new Transaction(this._dbConnection); + + try { + for (let v = oldVersion + 1; v <= DB_VERSION; v++) { + this.log("Upgrading to version " + v + "..."); + let migrateFunction = "_dbMigrateToVersion" + v; + this[migrateFunction](); + } + } catch (e) { + this.log("Migration failed: " + e); + transaction.rollback(); + throw e; + } + + this._dbConnection.schemaVersion = DB_VERSION; + transaction.commit(); + this.log("DB migration completed."); + }, + + + /** + * Version 2 adds a GUID column. Existing logins are assigned a random GUID. + */ + _dbMigrateToVersion2 : function () { + // Check to see if GUID column already exists, add if needed + let query; + if (!this._dbColumnExists("guid")) { + query = "ALTER TABLE moz_logins ADD COLUMN guid TEXT"; + this._dbConnection.executeSimpleSQL(query); + + query = "CREATE INDEX IF NOT EXISTS moz_logins_guid_index ON moz_logins (guid)"; + this._dbConnection.executeSimpleSQL(query); + } + + // Get a list of IDs for existing logins + let ids = []; + query = "SELECT id FROM moz_logins WHERE guid isnull"; + let stmt; + try { + stmt = this._dbCreateStatement(query); + while (stmt.executeStep()) + ids.push(stmt.row.id); + } catch (e) { + this.log("Failed getting IDs: " + e); + throw e; + } finally { + if (stmt) { + stmt.reset(); + } + } + + // Generate a GUID for each login and update the DB. + query = "UPDATE moz_logins SET guid = :guid WHERE id = :id"; + for (let id of ids) { + let params = { + id: id, + guid: this._uuidService.generateUUID().toString() + }; + + try { + stmt = this._dbCreateStatement(query, params); + stmt.execute(); + } catch (e) { + this.log("Failed setting GUID: " + e); + throw e; + } finally { + if (stmt) { + stmt.reset(); + } + } + } + }, + + + /** + * Version 3 adds a encType column. + */ + _dbMigrateToVersion3 : function () { + // Check to see if encType column already exists, add if needed + let query; + if (!this._dbColumnExists("encType")) { + query = "ALTER TABLE moz_logins ADD COLUMN encType INTEGER"; + this._dbConnection.executeSimpleSQL(query); + + query = "CREATE INDEX IF NOT EXISTS " + + "moz_logins_encType_index ON moz_logins (encType)"; + this._dbConnection.executeSimpleSQL(query); + } + + // Get a list of existing logins + let logins = []; + let stmt; + query = "SELECT id, encryptedUsername, encryptedPassword " + + "FROM moz_logins WHERE encType isnull"; + try { + stmt = this._dbCreateStatement(query); + while (stmt.executeStep()) { + let params = { id: stmt.row.id }; + // We will tag base64 logins correctly, but no longer support their use. + if (stmt.row.encryptedUsername.charAt(0) == '~' || + stmt.row.encryptedPassword.charAt(0) == '~') + params.encType = Ci.nsILoginManagerCrypto.ENCTYPE_BASE64; + else + params.encType = Ci.nsILoginManagerCrypto.ENCTYPE_SDR; + logins.push(params); + } + } catch (e) { + this.log("Failed getting logins: " + e); + throw e; + } finally { + if (stmt) { + stmt.reset(); + } + } + + // Determine encryption type for each login and update the DB. + query = "UPDATE moz_logins SET encType = :encType WHERE id = :id"; + for (let params of logins) { + try { + stmt = this._dbCreateStatement(query, params); + stmt.execute(); + } catch (e) { + this.log("Failed setting encType: " + e); + throw e; + } finally { + if (stmt) { + stmt.reset(); + } + } + } + }, + + + /** + * Version 4 adds timeCreated, timeLastUsed, timePasswordChanged, + * and timesUsed columns + */ + _dbMigrateToVersion4 : function () { + let query; + // Add the new columns, if needed. + for (let column of ["timeCreated", "timeLastUsed", "timePasswordChanged", "timesUsed"]) { + if (!this._dbColumnExists(column)) { + query = "ALTER TABLE moz_logins ADD COLUMN " + column + " INTEGER"; + this._dbConnection.executeSimpleSQL(query); + } + } + + // Get a list of IDs for existing logins. + let ids = []; + let stmt; + query = "SELECT id FROM moz_logins WHERE timeCreated isnull OR " + + "timeLastUsed isnull OR timePasswordChanged isnull OR timesUsed isnull"; + try { + stmt = this._dbCreateStatement(query); + while (stmt.executeStep()) + ids.push(stmt.row.id); + } catch (e) { + this.log("Failed getting IDs: " + e); + throw e; + } finally { + if (stmt) { + stmt.reset(); + } + } + + // Initialize logins with current time. + query = "UPDATE moz_logins SET timeCreated = :initTime, timeLastUsed = :initTime, " + + "timePasswordChanged = :initTime, timesUsed = 1 WHERE id = :id"; + let params = { + id: null, + initTime: Date.now() + }; + for (let id of ids) { + params.id = id; + try { + stmt = this._dbCreateStatement(query, params); + stmt.execute(); + } catch (e) { + this.log("Failed setting timestamps: " + e); + throw e; + } finally { + if (stmt) { + stmt.reset(); + } + } + } + }, + + + /** + * Version 5 adds the moz_deleted_logins table + */ + _dbMigrateToVersion5 : function () { + if (!this._dbConnection.tableExists("moz_deleted_logins")) { + this._dbConnection.createTable("moz_deleted_logins", this._dbSchema.tables.moz_deleted_logins); + } + }, + + /** + * Version 6 migrates all the hosts from + * moz_disabledHosts to the permission manager. + */ + _dbMigrateToVersion6 : function () { + let disabledHosts = []; + let query = "SELECT hostname FROM moz_disabledHosts"; + let stmt; + + try { + stmt = this._dbCreateStatement(query); + + while (stmt.executeStep()) { + disabledHosts.push(stmt.row.hostname); + } + + for (let host of disabledHosts) { + try { + let uri = Services.io.newURI(host, null, null); + Services.perms.add(uri, PERMISSION_SAVE_LOGINS, Services.perms.DENY_ACTION); + } catch (e) { + Cu.reportError(e); + } + } + } catch (e) { + this.log(`_dbMigrateToVersion6 failed: ${e.name} : ${e.message}`); + } finally { + if (stmt) { + stmt.reset(); + } + } + + query = "DELETE FROM moz_disabledHosts"; + this._dbConnection.executeSimpleSQL(query); + }, + + /** + * Sanity check to ensure that the columns this version of the code expects + * are present in the DB we're using. + */ + _dbAreExpectedColumnsPresent : function () { + let query = "SELECT " + + "id, " + + "hostname, " + + "httpRealm, " + + "formSubmitURL, " + + "usernameField, " + + "passwordField, " + + "encryptedUsername, " + + "encryptedPassword, " + + "guid, " + + "encType, " + + "timeCreated, " + + "timeLastUsed, " + + "timePasswordChanged, " + + "timesUsed " + + "FROM moz_logins"; + try { + let stmt = this._dbConnection.createStatement(query); + // (no need to execute statement, if it compiled we're good) + stmt.finalize(); + } catch (e) { + return false; + } + + query = "SELECT " + + "id, " + + "hostname " + + "FROM moz_disabledHosts"; + try { + let stmt = this._dbConnection.createStatement(query); + // (no need to execute statement, if it compiled we're good) + stmt.finalize(); + } catch (e) { + return false; + } + + this.log("verified that expected columns are present in DB."); + return true; + }, + + + /** + * Checks to see if the named column already exists. + */ + _dbColumnExists : function (columnName) { + let query = "SELECT " + columnName + " FROM moz_logins"; + try { + let stmt = this._dbConnection.createStatement(query); + // (no need to execute statement, if it compiled we're good) + stmt.finalize(); + return true; + } catch (e) { + return false; + } + }, + + _dbClose : function () { + this.log("Closing the DB connection."); + // Finalize all statements to free memory, avoid errors later + for (let query in this._dbStmts) { + let stmt = this._dbStmts[query]; + stmt.finalize(); + } + this._dbStmts = {}; + + if (this._dbConnection !== null) { + try { + this._dbConnection.close(); + } catch (e) { + Components.utils.reportError(e); + } + } + this._dbConnection = null; + }, + + /** + * Called when database creation fails. Finalizes database statements, + * closes the database connection, deletes the database file. + */ + _dbCleanup : function (backup) { + this.log("Cleaning up DB file - close & remove & backup=" + backup); + + // Create backup file + if (backup) { + let backupFile = this._signonsFile.leafName + ".corrupt"; + this._storageService.backupDatabaseFile(this._signonsFile, backupFile); + } + + this._dbClose(); + this._signonsFile.remove(false); + } + +}; // end of nsLoginManagerStorage_mozStorage implementation + +XPCOMUtils.defineLazyGetter(this.LoginManagerStorage_mozStorage.prototype, "log", () => { + let logger = LoginHelper.createLogger("Login storage"); + return logger.log.bind(logger); +}); + +var component = [LoginManagerStorage_mozStorage]; +this.NSGetFactory = XPCOMUtils.generateNSGetFactory(component); diff --git a/toolkit/components/passwordmgr/test/.eslintrc.js b/toolkit/components/passwordmgr/test/.eslintrc.js new file mode 100644 index 000000000..ca626f31c --- /dev/null +++ b/toolkit/components/passwordmgr/test/.eslintrc.js @@ -0,0 +1,13 @@ +"use strict"; + +module.exports = { // eslint-disable-line no-undef + "extends": [ + "../../../../testing/mochitest/mochitest.eslintrc.js", + "../../../../testing/mochitest/chrome.eslintrc.js" + ], + "rules": { + "brace-style": "off", + "no-undef": "off", + "no-unused-vars": "off", + }, +}; diff --git a/toolkit/components/passwordmgr/test/LoginTestUtils.jsm b/toolkit/components/passwordmgr/test/LoginTestUtils.jsm new file mode 100644 index 000000000..2fd8a31a3 --- /dev/null +++ b/toolkit/components/passwordmgr/test/LoginTestUtils.jsm @@ -0,0 +1,295 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Shared functions generally available for testing login components. + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = [ + "LoginTestUtils", +]; + +const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +Cu.import("resource://testing-common/TestUtils.jsm"); + +const LoginInfo = + Components.Constructor("@mozilla.org/login-manager/loginInfo;1", + "nsILoginInfo", "init"); + +// For now, we need consumers to provide a reference to Assert.jsm. +var Assert = null; + +this.LoginTestUtils = { + set Assert(assert) { + Assert = assert; // eslint-disable-line no-native-reassign + }, + + /** + * Forces the storage module to save all data, and the Login Manager service + * to replace the storage module with a newly initialized instance. + */ + * reloadData() { + Services.obs.notifyObservers(null, "passwordmgr-storage-replace", null); + yield TestUtils.topicObserved("passwordmgr-storage-replace-complete"); + }, + + /** + * Erases all the data stored by the Login Manager service. + */ + clearData() { + Services.logins.removeAllLogins(); + for (let hostname of Services.logins.getAllDisabledHosts()) { + Services.logins.setLoginSavingEnabled(hostname, true); + } + }, + + /** + * Checks that the currently stored list of nsILoginInfo matches the provided + * array. The comparison uses the "equals" method of nsILoginInfo, that does + * not include nsILoginMetaInfo properties in the test. + */ + checkLogins(expectedLogins) { + this.assertLoginListsEqual(Services.logins.getAllLogins(), expectedLogins); + }, + + /** + * Checks that the two provided arrays of nsILoginInfo have the same length, + * and every login in "expected" is also found in "actual". The comparison + * uses the "equals" method of nsILoginInfo, that does not include + * nsILoginMetaInfo properties in the test. + */ + assertLoginListsEqual(actual, expected) { + Assert.equal(expected.length, actual.length); + Assert.ok(expected.every(e => actual.some(a => a.equals(e)))); + }, + + /** + * Checks that the two provided arrays of strings contain the same values, + * maybe in a different order, case-sensitively. + */ + assertDisabledHostsEqual(actual, expected) { + Assert.deepEqual(actual.sort(), expected.sort()); + }, + + /** + * Checks whether the given time, expressed as the number of milliseconds + * since January 1, 1970, 00:00:00 UTC, falls within 30 seconds of now. + */ + assertTimeIsAboutNow(timeMs) { + Assert.ok(Math.abs(timeMs - Date.now()) < 30000); + }, +}; + +/** + * This object contains functions that return new instances of nsILoginInfo for + * every call. The returned instances can be compared using their "equals" or + * "matches" methods, or modified for the needs of the specific test being run. + * + * Any modification to the test data requires updating the tests accordingly, in + * particular the search tests. + */ +this.LoginTestUtils.testData = { + /** + * Returns a new nsILoginInfo for use with form submits. + * + * @param modifications + * Each property of this object replaces the property of the same name + * in the returned nsILoginInfo or nsILoginMetaInfo. + */ + formLogin(modifications) { + let loginInfo = new LoginInfo("http://www3.example.com", + "http://www.example.com", null, + "the username", "the password", + "form_field_username", "form_field_password"); + loginInfo.QueryInterface(Ci.nsILoginMetaInfo); + if (modifications) { + for (let [name, value] of Object.entries(modifications)) { + loginInfo[name] = value; + } + } + return loginInfo; + }, + + /** + * Returns a new nsILoginInfo for use with HTTP authentication. + * + * @param modifications + * Each property of this object replaces the property of the same name + * in the returned nsILoginInfo or nsILoginMetaInfo. + */ + authLogin(modifications) { + let loginInfo = new LoginInfo("http://www.example.org", null, + "The HTTP Realm", "the username", + "the password", "", ""); + loginInfo.QueryInterface(Ci.nsILoginMetaInfo); + if (modifications) { + for (let [name, value] of Object.entries(modifications)) { + loginInfo[name] = value; + } + } + return loginInfo; + }, + + /** + * Returns an array of typical nsILoginInfo that could be stored in the + * database. + */ + loginList() { + return [ + // --- Examples of form logins (subdomains of example.com) --- + + // Simple form login with named fields for username and password. + new LoginInfo("http://www.example.com", "http://www.example.com", null, + "the username", "the password for www.example.com", + "form_field_username", "form_field_password"), + + // Different schemes are treated as completely different sites. + new LoginInfo("https://www.example.com", "https://www.example.com", null, + "the username", "the password for https", + "form_field_username", "form_field_password"), + + // Subdomains are treated as completely different sites. + new LoginInfo("https://example.com", "https://example.com", null, + "the username", "the password for example.com", + "form_field_username", "form_field_password"), + + // Forms found on the same host, but with different hostnames in the + // "action" attribute, are handled independently. + new LoginInfo("http://www3.example.com", "http://www.example.com", null, + "the username", "the password", + "form_field_username", "form_field_password"), + new LoginInfo("http://www3.example.com", "https://www.example.com", null, + "the username", "the password", + "form_field_username", "form_field_password"), + new LoginInfo("http://www3.example.com", "http://example.com", null, + "the username", "the password", + "form_field_username", "form_field_password"), + + // It is not possible to store multiple passwords for the same username, + // however multiple passwords can be stored when the usernames differ. + // An empty username is a valid case and different from the others. + new LoginInfo("http://www4.example.com", "http://www4.example.com", null, + "username one", "password one", + "form_field_username", "form_field_password"), + new LoginInfo("http://www4.example.com", "http://www4.example.com", null, + "username two", "password two", + "form_field_username", "form_field_password"), + new LoginInfo("http://www4.example.com", "http://www4.example.com", null, + "", "password three", + "form_field_username", "form_field_password"), + + // Username and passwords fields in forms may have no "name" attribute. + new LoginInfo("http://www5.example.com", "http://www5.example.com", null, + "multi username", "multi password", "", ""), + + // Forms with PIN-type authentication will typically have no username. + new LoginInfo("http://www6.example.com", "http://www6.example.com", null, + "", "12345", "", "form_field_password"), + + // --- Examples of authentication logins (subdomains of example.org) --- + + // Simple HTTP authentication login. + new LoginInfo("http://www.example.org", null, "The HTTP Realm", + "the username", "the password", "", ""), + + // Simple FTP authentication login. + new LoginInfo("ftp://ftp.example.org", null, "ftp://ftp.example.org", + "the username", "the password", "", ""), + + // Multiple HTTP authentication logins can be stored for different realms. + new LoginInfo("http://www2.example.org", null, "The HTTP Realm", + "the username", "the password", "", ""), + new LoginInfo("http://www2.example.org", null, "The HTTP Realm Other", + "the username other", "the password other", "", ""), + + // --- Both form and authentication logins (example.net) --- + + new LoginInfo("http://example.net", "http://example.net", null, + "the username", "the password", + "form_field_username", "form_field_password"), + new LoginInfo("http://example.net", "http://www.example.net", null, + "the username", "the password", + "form_field_username", "form_field_password"), + new LoginInfo("http://example.net", "http://www.example.net", null, + "username two", "the password", + "form_field_username", "form_field_password"), + new LoginInfo("http://example.net", null, "The HTTP Realm", + "the username", "the password", "", ""), + new LoginInfo("http://example.net", null, "The HTTP Realm Other", + "username two", "the password", "", ""), + new LoginInfo("ftp://example.net", null, "ftp://example.net", + "the username", "the password", "", ""), + + // --- Examples of logins added by extensions (chrome scheme) --- + + new LoginInfo("chrome://example_extension", null, "Example Login One", + "the username", "the password one", "", ""), + new LoginInfo("chrome://example_extension", null, "Example Login Two", + "the username", "the password two", "", ""), + ]; + }, +}; + +this.LoginTestUtils.recipes = { + getRecipeParent() { + let { LoginManagerParent } = Cu.import("resource://gre/modules/LoginManagerParent.jsm", {}); + if (!LoginManagerParent.recipeParentPromise) { + return null; + } + return LoginManagerParent.recipeParentPromise.then((recipeParent) => { + return recipeParent; + }); + }, +}; + +this.LoginTestUtils.masterPassword = { + masterPassword: "omgsecret!", + + _set(enable) { + let oldPW, newPW; + if (enable) { + oldPW = ""; + newPW = this.masterPassword; + } else { + oldPW = this.masterPassword; + newPW = ""; + } + + let secmodDB = Cc["@mozilla.org/security/pkcs11moduledb;1"] + .getService(Ci.nsIPKCS11ModuleDB); + let slot = secmodDB.findSlotByName(""); + if (!slot) { + throw new Error("Can't find slot"); + } + + // Set master password. Note that this does not log you in, so the next + // invocation of pwmgr can trigger a MP prompt. + let pk11db = Cc["@mozilla.org/security/pk11tokendb;1"] + .getService(Ci.nsIPK11TokenDB); + let token = pk11db.findTokenByName(""); + if (slot.status == Ci.nsIPKCS11Slot.SLOT_UNINITIALIZED) { + dump("MP initialized to " + newPW + "\n"); + token.initPassword(newPW); + } else { + token.checkPassword(oldPW); + dump("MP change from " + oldPW + " to " + newPW + "\n"); + token.changePassword(oldPW, newPW); + } + }, + + enable() { + this._set(true); + }, + + disable() { + this._set(false); + }, +}; diff --git a/toolkit/components/passwordmgr/test/authenticate.sjs b/toolkit/components/passwordmgr/test/authenticate.sjs new file mode 100644 index 000000000..42edc3220 --- /dev/null +++ b/toolkit/components/passwordmgr/test/authenticate.sjs @@ -0,0 +1,228 @@ +function handleRequest(request, response) +{ + try { + reallyHandleRequest(request, response); + } catch (e) { + response.setStatusLine("1.0", 200, "AlmostOK"); + response.write("Error handling request: " + e); + } +} + + +function reallyHandleRequest(request, response) { + var match; + var requestAuth = true, requestProxyAuth = true; + + // Allow the caller to drive how authentication is processed via the query. + // Eg, http://localhost:8888/authenticate.sjs?user=foo&realm=bar + // The extra ? allows the user/pass/realm checks to succeed if the name is + // at the beginning of the query string. + var query = "?" + request.queryString; + + var expected_user = "", expected_pass = "", realm = "mochitest"; + var proxy_expected_user = "", proxy_expected_pass = "", proxy_realm = "mochi-proxy"; + var huge = false, plugin = false, anonymous = false, formauth = false; + var authHeaderCount = 1; + // user=xxx + match = /[^_]user=([^&]*)/.exec(query); + if (match) + expected_user = match[1]; + + // pass=xxx + match = /[^_]pass=([^&]*)/.exec(query); + if (match) + expected_pass = match[1]; + + // realm=xxx + match = /[^_]realm=([^&]*)/.exec(query); + if (match) + realm = match[1]; + + // proxy_user=xxx + match = /proxy_user=([^&]*)/.exec(query); + if (match) + proxy_expected_user = match[1]; + + // proxy_pass=xxx + match = /proxy_pass=([^&]*)/.exec(query); + if (match) + proxy_expected_pass = match[1]; + + // proxy_realm=xxx + match = /proxy_realm=([^&]*)/.exec(query); + if (match) + proxy_realm = match[1]; + + // huge=1 + match = /huge=1/.exec(query); + if (match) + huge = true; + + // plugin=1 + match = /plugin=1/.exec(query); + if (match) + plugin = true; + + // multiple=1 + match = /multiple=([^&]*)/.exec(query); + if (match) + authHeaderCount = match[1]+0; + + // anonymous=1 + match = /anonymous=1/.exec(query); + if (match) + anonymous = true; + + // formauth=1 + match = /formauth=1/.exec(query); + if (match) + formauth = true; + + // Look for an authentication header, if any, in the request. + // + // EG: Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== + // + // This test only supports Basic auth. The value sent by the client is + // "username:password", obscured with base64 encoding. + + var actual_user = "", actual_pass = "", authHeader, authPresent = false; + if (request.hasHeader("Authorization")) { + authPresent = true; + authHeader = request.getHeader("Authorization"); + match = /Basic (.+)/.exec(authHeader); + if (match.length != 2) + throw new Error("Couldn't parse auth header: " + authHeader); + + var userpass = base64ToString(match[1]); // no atob() :-( + match = /(.*):(.*)/.exec(userpass); + if (match.length != 3) + throw new Error("Couldn't decode auth header: " + userpass); + actual_user = match[1]; + actual_pass = match[2]; + } + + var proxy_actual_user = "", proxy_actual_pass = ""; + if (request.hasHeader("Proxy-Authorization")) { + authHeader = request.getHeader("Proxy-Authorization"); + match = /Basic (.+)/.exec(authHeader); + if (match.length != 2) + throw new Error("Couldn't parse auth header: " + authHeader); + + var userpass = base64ToString(match[1]); // no atob() :-( + match = /(.*):(.*)/.exec(userpass); + if (match.length != 3) + throw new Error("Couldn't decode auth header: " + userpass); + proxy_actual_user = match[1]; + proxy_actual_pass = match[2]; + } + + // Don't request authentication if the credentials we got were what we + // expected. + if (expected_user == actual_user && + expected_pass == actual_pass) { + requestAuth = false; + } + if (proxy_expected_user == proxy_actual_user && + proxy_expected_pass == proxy_actual_pass) { + requestProxyAuth = false; + } + + if (anonymous) { + if (authPresent) { + response.setStatusLine("1.0", 400, "Unexpected authorization header found"); + } else { + response.setStatusLine("1.0", 200, "Authorization header not found"); + } + } else { + if (requestProxyAuth) { + response.setStatusLine("1.0", 407, "Proxy authentication required"); + for (i = 0; i < authHeaderCount; ++i) + response.setHeader("Proxy-Authenticate", "basic realm=\"" + proxy_realm + "\"", true); + } else if (requestAuth) { + if (formauth && authPresent) + response.setStatusLine("1.0", 403, "Form authentication required"); + else + response.setStatusLine("1.0", 401, "Authentication required"); + for (i = 0; i < authHeaderCount; ++i) + response.setHeader("WWW-Authenticate", "basic realm=\"" + realm + "\"", true); + } else { + response.setStatusLine("1.0", 200, "OK"); + } + } + + response.setHeader("Content-Type", "application/xhtml+xml", false); + response.write("<html xmlns='http://www.w3.org/1999/xhtml'>"); + response.write("<p>Login: <span id='ok'>" + (requestAuth ? "FAIL" : "PASS") + "</span></p>\n"); + response.write("<p>Proxy: <span id='proxy'>" + (requestProxyAuth ? "FAIL" : "PASS") + "</span></p>\n"); + response.write("<p>Auth: <span id='auth'>" + authHeader + "</span></p>\n"); + response.write("<p>User: <span id='user'>" + actual_user + "</span></p>\n"); + response.write("<p>Pass: <span id='pass'>" + actual_pass + "</span></p>\n"); + + if (huge) { + response.write("<div style='display: none'>"); + for (i = 0; i < 100000; i++) { + response.write("123456789\n"); + } + response.write("</div>"); + response.write("<span id='footnote'>This is a footnote after the huge content fill</span>"); + } + + if (plugin) { + response.write("<embed id='embedtest' style='width: 400px; height: 100px;' " + + "type='application/x-test'></embed>\n"); + } + + response.write("</html>"); +} + + +// base64 decoder +// +// Yoinked from extensions/xml-rpc/src/nsXmlRpcClient.js because btoa() +// doesn't seem to exist. :-( +/* Convert Base64 data to a string */ +const toBinaryTable = [ + -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, + -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, + -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,62, -1,-1,-1,63, + 52,53,54,55, 56,57,58,59, 60,61,-1,-1, -1, 0,-1,-1, + -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11,12,13,14, + 15,16,17,18, 19,20,21,22, 23,24,25,-1, -1,-1,-1,-1, + -1,26,27,28, 29,30,31,32, 33,34,35,36, 37,38,39,40, + 41,42,43,44, 45,46,47,48, 49,50,51,-1, -1,-1,-1,-1 +]; +const base64Pad = '='; + +function base64ToString(data) { + + var result = ''; + var leftbits = 0; // number of bits decoded, but yet to be appended + var leftdata = 0; // bits decoded, but yet to be appended + + // Convert one by one. + for (var i = 0; i < data.length; i++) { + var c = toBinaryTable[data.charCodeAt(i) & 0x7f]; + var padding = (data[i] == base64Pad); + // Skip illegal characters and whitespace + if (c == -1) continue; + + // Collect data into leftdata, update bitcount + leftdata = (leftdata << 6) | c; + leftbits += 6; + + // If we have 8 or more bits, append 8 bits to the result + if (leftbits >= 8) { + leftbits -= 8; + // Append if not padding. + if (!padding) + result += String.fromCharCode((leftdata >> leftbits) & 0xff); + leftdata &= (1 << leftbits) - 1; + } + } + + // If there are any bits left, the base64 string was corrupted + if (leftbits) + throw Components.Exception('Corrupted base64 string'); + + return result; +} diff --git a/toolkit/components/passwordmgr/test/blank.html b/toolkit/components/passwordmgr/test/blank.html new file mode 100644 index 000000000..81ddc2235 --- /dev/null +++ b/toolkit/components/passwordmgr/test/blank.html @@ -0,0 +1,8 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + </head> + <body> + </body> +</html> diff --git a/toolkit/components/passwordmgr/test/browser/.eslintrc.js b/toolkit/components/passwordmgr/test/browser/.eslintrc.js new file mode 100644 index 000000000..7c8021192 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + "extends": [ + "../../../../../testing/mochitest/browser.eslintrc.js" + ] +}; diff --git a/toolkit/components/passwordmgr/test/browser/authenticate.sjs b/toolkit/components/passwordmgr/test/browser/authenticate.sjs new file mode 100644 index 000000000..fe2d2423c --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/authenticate.sjs @@ -0,0 +1,110 @@ +function handleRequest(request, response) +{ + var match; + var requestAuth = true; + + // Allow the caller to drive how authentication is processed via the query. + // Eg, http://localhost:8888/authenticate.sjs?user=foo&realm=bar + // The extra ? allows the user/pass/realm checks to succeed if the name is + // at the beginning of the query string. + var query = "?" + request.queryString; + + var expected_user = "test", expected_pass = "testpass", realm = "mochitest"; + + // Look for an authentication header, if any, in the request. + // + // EG: Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== + // + // This test only supports Basic auth. The value sent by the client is + // "username:password", obscured with base64 encoding. + + var actual_user = "", actual_pass = "", authHeader, authPresent = false; + if (request.hasHeader("Authorization")) { + authPresent = true; + authHeader = request.getHeader("Authorization"); + match = /Basic (.+)/.exec(authHeader); + if (match.length != 2) + throw new Error("Couldn't parse auth header: " + authHeader); + + var userpass = base64ToString(match[1]); // no atob() :-( + match = /(.*):(.*)/.exec(userpass); + if (match.length != 3) + throw new Error("Couldn't decode auth header: " + userpass); + actual_user = match[1]; + actual_pass = match[2]; + } + + // Don't request authentication if the credentials we got were what we + // expected. + if (expected_user == actual_user && + expected_pass == actual_pass) { + requestAuth = false; + } + + if (requestAuth) { + response.setStatusLine("1.0", 401, "Authentication required"); + response.setHeader("WWW-Authenticate", "basic realm=\"" + realm + "\"", true); + } else { + response.setStatusLine("1.0", 200, "OK"); + } + + response.setHeader("Content-Type", "application/xhtml+xml", false); + response.write("<html xmlns='http://www.w3.org/1999/xhtml'>"); + response.write("<p>Login: <span id='ok'>" + (requestAuth ? "FAIL" : "PASS") + "</span></p>\n"); + response.write("<p>Auth: <span id='auth'>" + authHeader + "</span></p>\n"); + response.write("<p>User: <span id='user'>" + actual_user + "</span></p>\n"); + response.write("<p>Pass: <span id='pass'>" + actual_pass + "</span></p>\n"); + response.write("</html>"); +} + + +// base64 decoder +// +// Yoinked from extensions/xml-rpc/src/nsXmlRpcClient.js because btoa() +// doesn't seem to exist. :-( +/* Convert Base64 data to a string */ +const toBinaryTable = [ + -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, + -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, + -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,62, -1,-1,-1,63, + 52,53,54,55, 56,57,58,59, 60,61,-1,-1, -1, 0,-1,-1, + -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11,12,13,14, + 15,16,17,18, 19,20,21,22, 23,24,25,-1, -1,-1,-1,-1, + -1,26,27,28, 29,30,31,32, 33,34,35,36, 37,38,39,40, + 41,42,43,44, 45,46,47,48, 49,50,51,-1, -1,-1,-1,-1 +]; +const base64Pad = '='; + +function base64ToString(data) { + + var result = ''; + var leftbits = 0; // number of bits decoded, but yet to be appended + var leftdata = 0; // bits decoded, but yet to be appended + + // Convert one by one. + for (var i = 0; i < data.length; i++) { + var c = toBinaryTable[data.charCodeAt(i) & 0x7f]; + var padding = (data[i] == base64Pad); + // Skip illegal characters and whitespace + if (c == -1) continue; + + // Collect data into leftdata, update bitcount + leftdata = (leftdata << 6) | c; + leftbits += 6; + + // If we have 8 or more bits, append 8 bits to the result + if (leftbits >= 8) { + leftbits -= 8; + // Append if not padding. + if (!padding) + result += String.fromCharCode((leftdata >> leftbits) & 0xff); + leftdata &= (1 << leftbits) - 1; + } + } + + // If there are any bits left, the base64 string was corrupted + if (leftbits) + throw Components.Exception('Corrupted base64 string'); + + return result; +} diff --git a/toolkit/components/passwordmgr/test/browser/browser.ini b/toolkit/components/passwordmgr/test/browser/browser.ini new file mode 100644 index 000000000..b17591436 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser.ini @@ -0,0 +1,72 @@ +[DEFAULT] +support-files = + ../formsubmit.sjs + authenticate.sjs + form_basic.html + form_basic_iframe.html + formless_basic.html + form_same_origin_action.html + form_cross_origin_secure_action.html + head.js + insecure_test.html + insecure_test_subframe.html + multiple_forms.html + streamConverter_content.sjs + +[browser_autocomplete_insecure_warning.js] +support-files = + form_cross_origin_insecure_action.html +[browser_capture_doorhanger.js] +support-files = + subtst_notifications_1.html + subtst_notifications_2.html + subtst_notifications_2pw_0un.html + subtst_notifications_2pw_1un_1text.html + subtst_notifications_3.html + subtst_notifications_4.html + subtst_notifications_5.html + subtst_notifications_6.html + subtst_notifications_8.html + subtst_notifications_9.html + subtst_notifications_10.html + subtst_notifications_change_p.html +[browser_capture_doorhanger_httpsUpgrade.js] +support-files = + subtst_notifications_1.html + subtst_notifications_8.html +[browser_capture_doorhanger_window_open.js] +support-files = + subtst_notifications_11.html + subtst_notifications_11_popup.html +skip-if = os == "linux" # Bug 1312981, bug 1313136 +[browser_context_menu_autocomplete_interaction.js] +[browser_username_select_dialog.js] +support-files = + subtst_notifications_change_p.html +[browser_DOMFormHasPassword.js] +[browser_DOMInputPasswordAdded.js] +[browser_exceptions_dialog.js] +[browser_formless_submit_chrome.js] +[browser_hasInsecureLoginForms.js] +[browser_hasInsecureLoginForms_streamConverter.js] +[browser_http_autofill.js] +[browser_insecurePasswordConsoleWarning.js] +support-files = + form_cross_origin_insecure_action.html +[browser_master_password_autocomplete.js] +[browser_notifications.js] +[browser_notifications_username.js] +[browser_notifications_password.js] +[browser_notifications_2.js] +skip-if = os == "linux" # Bug 1272849 Main action button disabled state intermittent +[browser_passwordmgr_editing.js] +skip-if = os == "linux" +[browser_context_menu.js] +[browser_context_menu_iframe.js] +[browser_passwordmgr_contextmenu.js] +subsuite = clipboard +[browser_passwordmgr_fields.js] +[browser_passwordmgr_observers.js] +[browser_passwordmgr_sort.js] +[browser_passwordmgr_switchtab.js] +[browser_passwordmgrdlg.js] diff --git a/toolkit/components/passwordmgr/test/browser/browser_DOMFormHasPassword.js b/toolkit/components/passwordmgr/test/browser/browser_DOMFormHasPassword.js new file mode 100644 index 000000000..80a0dd903 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_DOMFormHasPassword.js @@ -0,0 +1,94 @@ +const ids = { + INPUT_ID: "input1", + FORM1_ID: "form1", + FORM2_ID: "form2", + CHANGE_INPUT_ID: "input2", +}; + +function task(contentIds) { + let resolve; + let promise = new Promise(r => { resolve = r; }); + + function unexpectedContentEvent(evt) { + ok(false, "Received a " + evt.type + " event on content"); + } + + var gDoc = null; + + addEventListener("load", tabLoad, true); + + function tabLoad() { + if (content.location.href == "about:blank") + return; + removeEventListener("load", tabLoad, true); + + gDoc = content.document; + gDoc.addEventListener("DOMFormHasPassword", unexpectedContentEvent, false); + gDoc.defaultView.setTimeout(test_inputAdd, 0); + } + + function test_inputAdd() { + addEventListener("DOMFormHasPassword", test_inputAddHandler, false); + let input = gDoc.createElementNS("http://www.w3.org/1999/xhtml", "input"); + input.setAttribute("type", "password"); + input.setAttribute("id", contentIds.INPUT_ID); + input.setAttribute("data-test", "unique-attribute"); + gDoc.getElementById(contentIds.FORM1_ID).appendChild(input); + } + + function test_inputAddHandler(evt) { + removeEventListener(evt.type, test_inputAddHandler, false); + is(evt.target.id, contentIds.FORM1_ID, + evt.type + " event targets correct form element (added password element)"); + gDoc.defaultView.setTimeout(test_inputChangeForm, 0); + } + + function test_inputChangeForm() { + addEventListener("DOMFormHasPassword", test_inputChangeFormHandler, false); + let input = gDoc.getElementById(contentIds.INPUT_ID); + input.setAttribute("form", contentIds.FORM2_ID); + } + + function test_inputChangeFormHandler(evt) { + removeEventListener(evt.type, test_inputChangeFormHandler, false); + is(evt.target.id, contentIds.FORM2_ID, + evt.type + " event targets correct form element (changed form)"); + gDoc.defaultView.setTimeout(test_inputChangesType, 0); + } + + function test_inputChangesType() { + addEventListener("DOMFormHasPassword", test_inputChangesTypeHandler, false); + let input = gDoc.getElementById(contentIds.CHANGE_INPUT_ID); + input.setAttribute("type", "password"); + } + + function test_inputChangesTypeHandler(evt) { + removeEventListener(evt.type, test_inputChangesTypeHandler, false); + is(evt.target.id, contentIds.FORM1_ID, + evt.type + " event targets correct form element (changed type)"); + gDoc.defaultView.setTimeout(finish, 0); + } + + function finish() { + gDoc.removeEventListener("DOMFormHasPassword", unexpectedContentEvent, false); + resolve(); + } + + return promise; +} + +add_task(function* () { + let tab = gBrowser.selectedTab = gBrowser.addTab(); + + let promise = ContentTask.spawn(tab.linkedBrowser, ids, task); + tab.linkedBrowser.loadURI("data:text/html;charset=utf-8," + + "<html><body>" + + "<form id='" + ids.FORM1_ID + "'>" + + "<input id='" + ids.CHANGE_INPUT_ID + "'></form>" + + "<form id='" + ids.FORM2_ID + "'></form>" + + "</body></html>"); + yield promise; + + ok(true, "Test completed"); + gBrowser.removeCurrentTab(); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_DOMInputPasswordAdded.js b/toolkit/components/passwordmgr/test/browser/browser_DOMInputPasswordAdded.js new file mode 100644 index 000000000..f54892e19 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_DOMInputPasswordAdded.js @@ -0,0 +1,99 @@ +const consts = { + HTML_NS: "http://www.w3.org/1999/xhtml", + + INPUT_ID: "input1", + FORM1_ID: "form1", + FORM2_ID: "form2", + CHANGE_INPUT_ID: "input2", + BODY_INPUT_ID: "input3", +}; + +function task(contentConsts) { + let resolve; + let promise = new Promise(r => { resolve = r; }); + + function unexpectedContentEvent(evt) { + Assert.ok(false, "Received a " + evt.type + " event on content"); + } + + var gDoc = null; + + addEventListener("load", tabLoad, true); + + function tabLoad() { + removeEventListener("load", tabLoad, true); + gDoc = content.document; + // These events shouldn't escape to content. + gDoc.addEventListener("DOMInputPasswordAdded", unexpectedContentEvent, false); + gDoc.defaultView.setTimeout(test_inputAdd, 0); + } + + function test_inputAdd() { + addEventListener("DOMInputPasswordAdded", test_inputAddHandler, false); + let input = gDoc.createElementNS(contentConsts.HTML_NS, "input"); + input.setAttribute("type", "password"); + input.setAttribute("id", contentConsts.INPUT_ID); + input.setAttribute("data-test", "unique-attribute"); + gDoc.getElementById(contentConsts.FORM1_ID).appendChild(input); + info("Done appending the input element"); + } + + function test_inputAddHandler(evt) { + removeEventListener(evt.type, test_inputAddHandler, false); + Assert.equal(evt.target.id, contentConsts.INPUT_ID, + evt.type + " event targets correct input element (added password element)"); + gDoc.defaultView.setTimeout(test_inputAddOutsideForm, 0); + } + + function test_inputAddOutsideForm() { + addEventListener("DOMInputPasswordAdded", test_inputAddOutsideFormHandler, false); + let input = gDoc.createElementNS(contentConsts.HTML_NS, "input"); + input.setAttribute("type", "password"); + input.setAttribute("id", contentConsts.BODY_INPUT_ID); + input.setAttribute("data-test", "unique-attribute"); + gDoc.body.appendChild(input); + info("Done appending the input element to the body"); + } + + function test_inputAddOutsideFormHandler(evt) { + removeEventListener(evt.type, test_inputAddOutsideFormHandler, false); + Assert.equal(evt.target.id, contentConsts.BODY_INPUT_ID, + evt.type + " event targets correct input element (added password element outside form)"); + gDoc.defaultView.setTimeout(test_inputChangesType, 0); + } + + function test_inputChangesType() { + addEventListener("DOMInputPasswordAdded", test_inputChangesTypeHandler, false); + let input = gDoc.getElementById(contentConsts.CHANGE_INPUT_ID); + input.setAttribute("type", "password"); + } + + function test_inputChangesTypeHandler(evt) { + removeEventListener(evt.type, test_inputChangesTypeHandler, false); + Assert.equal(evt.target.id, contentConsts.CHANGE_INPUT_ID, + evt.type + " event targets correct input element (changed type)"); + gDoc.defaultView.setTimeout(completeTest, 0); + } + + function completeTest() { + Assert.ok(true, "Test completed"); + gDoc.removeEventListener("DOMInputPasswordAdded", unexpectedContentEvent, false); + resolve(); + } + + return promise; +} + +add_task(function* () { + let tab = gBrowser.selectedTab = gBrowser.addTab(); + let promise = ContentTask.spawn(tab.linkedBrowser, consts, task); + tab.linkedBrowser.loadURI("data:text/html;charset=utf-8," + + "<html><body>" + + "<form id='" + consts.FORM1_ID + "'>" + + "<input id='" + consts.CHANGE_INPUT_ID + "'></form>" + + "<form id='" + consts.FORM2_ID + "'></form>" + + "</body></html>"); + yield promise; + gBrowser.removeCurrentTab(); +}); + diff --git a/toolkit/components/passwordmgr/test/browser/browser_autocomplete_insecure_warning.js b/toolkit/components/passwordmgr/test/browser/browser_autocomplete_insecure_warning.js new file mode 100644 index 000000000..6aa8e5cf7 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_autocomplete_insecure_warning.js @@ -0,0 +1,41 @@ +"use strict"; + +const EXPECTED_SUPPORT_URL = Services.urlFormatter.formatURLPref("app.support.baseURL") + + "insecure-password"; + +add_task(function* test_clickInsecureFieldWarning() { + let url = "https://example.com" + DIRECTORY_PATH + "form_cross_origin_insecure_action.html"; + + yield BrowserTestUtils.withNewTab({ + gBrowser, + url, + }, function*(browser) { + let popup = document.getElementById("PopupAutoComplete"); + ok(popup, "Got popup"); + + let promiseShown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + + yield SimpleTest.promiseFocus(browser); + info("content window focused"); + + // Focus the username field to open the popup. + yield ContentTask.spawn(browser, null, function openAutocomplete() { + content.document.getElementById("form-basic-username").focus(); + }); + + yield promiseShown; + ok(promiseShown, "autocomplete shown"); + + let warningItem = document.getAnonymousElementByAttribute(popup, "type", "insecureWarning"); + ok(warningItem, "Got warning richlistitem"); + + yield BrowserTestUtils.waitForCondition(() => !warningItem.collapsed, "Wait for warning to show"); + + info("Clicking on warning"); + let supportTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, EXPECTED_SUPPORT_URL); + EventUtils.synthesizeMouseAtCenter(warningItem, {}); + let supportTab = yield supportTabPromise; + ok(supportTab, "Support tab opened"); + yield BrowserTestUtils.removeTab(supportTab); + }); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_capture_doorhanger.js b/toolkit/components/passwordmgr/test/browser/browser_capture_doorhanger.js new file mode 100644 index 000000000..b6bfdbf50 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_capture_doorhanger.js @@ -0,0 +1,600 @@ +/* + * Test capture popup notifications + */ + +const BRAND_BUNDLE = Services.strings.createBundle("chrome://branding/locale/brand.properties"); +const BRAND_SHORT_NAME = BRAND_BUNDLE.GetStringFromName("brandShortName"); + +let nsLoginInfo = new Components.Constructor("@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, "init"); +let login1 = new nsLoginInfo("http://example.com", "http://example.com", null, + "notifyu1", "notifyp1", "user", "pass"); +let login2 = new nsLoginInfo("http://example.com", "http://example.com", null, + "", "notifyp1", "", "pass"); +let login1B = new nsLoginInfo("http://example.com", "http://example.com", null, + "notifyu1B", "notifyp1B", "user", "pass"); +let login2B = new nsLoginInfo("http://example.com", "http://example.com", null, + "", "notifyp1B", "", "pass"); + +requestLongerTimeout(2); + +add_task(function* setup() { + // Load recipes for this test. + let recipeParent = yield LoginManagerParent.recipeParentPromise; + yield recipeParent.load({ + siteRecipes: [{ + hosts: ["example.org"], + usernameSelector: "#user", + passwordSelector: "#pass", + }], + }); +}); + +add_task(function* test_remember_opens() { + yield testSubmittingLoginForm("subtst_notifications_1.html", function*(fieldValues) { + is(fieldValues.username, "notifyu1", "Checking submitted username"); + is(fieldValues.password, "notifyp1", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-save"); + ok(notif, "got notification popup"); + notif.remove(); + }); +}); + +add_task(function* test_clickNever() { + yield testSubmittingLoginForm("subtst_notifications_1.html", function*(fieldValues) { + is(fieldValues.username, "notifyu1", "Checking submitted username"); + is(fieldValues.password, "notifyp1", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-save"); + ok(notif, "got notification popup"); + is(true, Services.logins.getLoginSavingEnabled("http://example.com"), + "Checking for login saving enabled"); + + yield* checkDoorhangerUsernamePassword("notifyu1", "notifyp1"); + clickDoorhangerButton(notif, NEVER_BUTTON); + }); + + is(Services.logins.getAllLogins().length, 0, "Should not have any logins yet"); + + info("Make sure Never took effect"); + yield testSubmittingLoginForm("subtst_notifications_1.html", function*(fieldValues) { + is(fieldValues.username, "notifyu1", "Checking submitted username"); + is(fieldValues.password, "notifyp1", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-save"); + ok(!notif, "checking for no notification popup"); + is(false, Services.logins.getLoginSavingEnabled("http://example.com"), + "Checking for login saving disabled"); + Services.logins.setLoginSavingEnabled("http://example.com", true); + }); + + is(Services.logins.getAllLogins().length, 0, "Should not have any logins yet"); +}); + +add_task(function* test_clickRemember() { + yield testSubmittingLoginForm("subtst_notifications_1.html", function*(fieldValues) { + is(fieldValues.username, "notifyu1", "Checking submitted username"); + is(fieldValues.password, "notifyp1", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-save"); + ok(notif, "got notification popup"); + + is(Services.logins.getAllLogins().length, 0, "Should not have any logins yet"); + + yield* checkDoorhangerUsernamePassword("notifyu1", "notifyp1"); + clickDoorhangerButton(notif, REMEMBER_BUTTON); + }); + + let logins = Services.logins.getAllLogins(); + is(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + is(login.username, "notifyu1", "Check the username used on the new entry"); + is(login.password, "notifyp1", "Check the password used on the new entry"); + is(login.timesUsed, 1, "Check times used on new entry"); + + info("Make sure Remember took effect and we don't prompt for an existing login"); + yield testSubmittingLoginForm("subtst_notifications_1.html", function*(fieldValues) { + is(fieldValues.username, "notifyu1", "Checking submitted username"); + is(fieldValues.password, "notifyp1", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-save"); + ok(!notif, "checking for no notification popup"); + }); + + logins = Services.logins.getAllLogins(); + is(logins.length, 1, "Should only have 1 login"); + login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + is(login.username, "notifyu1", "Check the username used"); + is(login.password, "notifyp1", "Check the password used"); + is(login.timesUsed, 2, "Check times used incremented"); + + checkOnlyLoginWasUsedTwice({ justChanged: false }); + + // remove that login + Services.logins.removeLogin(login1); +}); + +/* signons.rememberSignons pref tests... */ + +add_task(function* test_rememberSignonsFalse() { + info("Make sure we don't prompt with rememberSignons=false"); + Services.prefs.setBoolPref("signon.rememberSignons", false); + + yield testSubmittingLoginForm("subtst_notifications_1.html", function*(fieldValues) { + is(fieldValues.username, "notifyu1", "Checking submitted username"); + is(fieldValues.password, "notifyp1", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-save"); + ok(!notif, "checking for no notification popup"); + }); + + is(Services.logins.getAllLogins().length, 0, "Should not have any logins yet"); +}); + +add_task(function* test_rememberSignonsTrue() { + info("Make sure we prompt with rememberSignons=true"); + Services.prefs.setBoolPref("signon.rememberSignons", true); + + yield testSubmittingLoginForm("subtst_notifications_1.html", function*(fieldValues) { + is(fieldValues.username, "notifyu1", "Checking submitted username"); + is(fieldValues.password, "notifyp1", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-save"); + ok(notif, "got notification popup"); + notif.remove(); + }); + + is(Services.logins.getAllLogins().length, 0, "Should not have any logins yet"); +}); + +/* autocomplete=off tests... */ + +add_task(function* test_autocompleteOffUsername() { + info("Check for notification popup when autocomplete=off present on username"); + + yield testSubmittingLoginForm("subtst_notifications_2.html", function*(fieldValues) { + is(fieldValues.username, "notifyu1", "Checking submitted username"); + is(fieldValues.password, "notifyp1", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-save"); + ok(notif, "checking for notification popup"); + notif.remove(); + }); + + is(Services.logins.getAllLogins().length, 0, "Should not have any logins yet"); +}); + +add_task(function* test_autocompleteOffPassword() { + info("Check for notification popup when autocomplete=off present on password"); + + yield testSubmittingLoginForm("subtst_notifications_3.html", function*(fieldValues) { + is(fieldValues.username, "notifyu1", "Checking submitted username"); + is(fieldValues.password, "notifyp1", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-save"); + ok(notif, "checking for notification popup"); + notif.remove(); + }); + + is(Services.logins.getAllLogins().length, 0, "Should not have any logins yet"); +}); + +add_task(function* test_autocompleteOffForm() { + info("Check for notification popup when autocomplete=off present on form"); + + yield testSubmittingLoginForm("subtst_notifications_4.html", function*(fieldValues) { + is(fieldValues.username, "notifyu1", "Checking submitted username"); + is(fieldValues.password, "notifyp1", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-save"); + ok(notif, "checking for notification popup"); + notif.remove(); + }); + + is(Services.logins.getAllLogins().length, 0, "Should not have any logins yet"); +}); + + +add_task(function* test_noPasswordField() { + info("Check for no notification popup when no password field present"); + + yield testSubmittingLoginForm("subtst_notifications_5.html", function*(fieldValues) { + is(fieldValues.username, "notifyu1", "Checking submitted username"); + is(fieldValues.password, "null", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-save"); + ok(!notif, "checking for no notification popup"); + }); + + is(Services.logins.getAllLogins().length, 0, "Should not have any logins yet"); +}); + +add_task(function* test_pwOnlyLoginMatchesForm() { + info("Check for update popup when existing pw-only login matches form."); + Services.logins.addLogin(login2); + + yield testSubmittingLoginForm("subtst_notifications_1.html", function*(fieldValues) { + is(fieldValues.username, "notifyu1", "Checking submitted username"); + is(fieldValues.password, "notifyp1", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-change"); + ok(notif, "checking for notification popup"); + notif.remove(); + }); + + let logins = Services.logins.getAllLogins(); + is(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + is(login.username, "", "Check the username"); + is(login.password, "notifyp1", "Check the password"); + is(login.timesUsed, 1, "Check times used"); + + Services.logins.removeLogin(login2); +}); + +add_task(function* test_pwOnlyFormMatchesLogin() { + info("Check for no notification popup when pw-only form matches existing login."); + Services.logins.addLogin(login1); + + yield testSubmittingLoginForm("subtst_notifications_6.html", function*(fieldValues) { + is(fieldValues.username, "null", "Checking submitted username"); + is(fieldValues.password, "notifyp1", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-save"); + ok(!notif, "checking for no notification popup"); + }); + + let logins = Services.logins.getAllLogins(); + is(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + is(login.username, "notifyu1", "Check the username"); + is(login.password, "notifyp1", "Check the password"); + is(login.timesUsed, 2, "Check times used"); + + Services.logins.removeLogin(login1); +}); + +add_task(function* test_pwOnlyFormDoesntMatchExisting() { + info("Check for notification popup when pw-only form doesn't match existing login."); + Services.logins.addLogin(login1B); + + yield testSubmittingLoginForm("subtst_notifications_6.html", function*(fieldValues) { + is(fieldValues.username, "null", "Checking submitted username"); + is(fieldValues.password, "notifyp1", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-save"); + ok(notif, "got notification popup"); + notif.remove(); + }); + + let logins = Services.logins.getAllLogins(); + is(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + is(login.username, "notifyu1B", "Check the username unchanged"); + is(login.password, "notifyp1B", "Check the password unchanged"); + is(login.timesUsed, 1, "Check times used"); + + Services.logins.removeLogin(login1B); +}); + +add_task(function* test_changeUPLoginOnUPForm_dont() { + info("Check for change-password popup, u+p login on u+p form. (not changed)"); + Services.logins.addLogin(login1); + + yield testSubmittingLoginForm("subtst_notifications_8.html", function*(fieldValues) { + is(fieldValues.username, "notifyu1", "Checking submitted username"); + is(fieldValues.password, "pass2", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-change"); + ok(notif, "got notification popup"); + + yield* checkDoorhangerUsernamePassword("notifyu1", "pass2"); + clickDoorhangerButton(notif, DONT_CHANGE_BUTTON); + }); + + let logins = Services.logins.getAllLogins(); + is(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + is(login.username, "notifyu1", "Check the username unchanged"); + is(login.password, "notifyp1", "Check the password unchanged"); + is(login.timesUsed, 1, "Check times used"); + + Services.logins.removeLogin(login1); +}); + +add_task(function* test_changeUPLoginOnUPForm_change() { + info("Check for change-password popup, u+p login on u+p form."); + Services.logins.addLogin(login1); + + yield testSubmittingLoginForm("subtst_notifications_8.html", function*(fieldValues) { + is(fieldValues.username, "notifyu1", "Checking submitted username"); + is(fieldValues.password, "pass2", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-change"); + ok(notif, "got notification popup"); + + yield* checkDoorhangerUsernamePassword("notifyu1", "pass2"); + clickDoorhangerButton(notif, CHANGE_BUTTON); + + ok(!getCaptureDoorhanger("password-change"), "popup should be gone"); + }); + + let logins = Services.logins.getAllLogins(); + is(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + is(login.username, "notifyu1", "Check the username unchanged"); + is(login.password, "pass2", "Check the password changed"); + is(login.timesUsed, 2, "Check times used"); + + checkOnlyLoginWasUsedTwice({ justChanged: true }); + + // cleanup + login1.password = "pass2"; + Services.logins.removeLogin(login1); + login1.password = "notifyp1"; +}); + +add_task(function* test_changePLoginOnUPForm() { + info("Check for change-password popup, p-only login on u+p form."); + Services.logins.addLogin(login2); + + yield testSubmittingLoginForm("subtst_notifications_9.html", function*(fieldValues) { + is(fieldValues.username, "", "Checking submitted username"); + is(fieldValues.password, "pass2", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-change"); + ok(notif, "got notification popup"); + + yield* checkDoorhangerUsernamePassword("", "pass2"); + clickDoorhangerButton(notif, CHANGE_BUTTON); + + ok(!getCaptureDoorhanger("password-change"), "popup should be gone"); + }); + + let logins = Services.logins.getAllLogins(); + is(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + is(login.username, "", "Check the username unchanged"); + is(login.password, "pass2", "Check the password changed"); + is(login.timesUsed, 2, "Check times used"); + + // no cleanup -- saved password to be used in the next test. +}); + +add_task(function* test_changePLoginOnPForm() { + info("Check for change-password popup, p-only login on p-only form."); + + yield testSubmittingLoginForm("subtst_notifications_10.html", function*(fieldValues) { + is(fieldValues.username, "null", "Checking submitted username"); + is(fieldValues.password, "notifyp1", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-change"); + ok(notif, "got notification popup"); + + yield* checkDoorhangerUsernamePassword("", "notifyp1"); + clickDoorhangerButton(notif, CHANGE_BUTTON); + + ok(!getCaptureDoorhanger("password-change"), "popup should be gone"); + }); + + let logins = Services.logins.getAllLogins(); + is(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + is(login.username, "", "Check the username unchanged"); + is(login.password, "notifyp1", "Check the password changed"); + is(login.timesUsed, 3, "Check times used"); + + Services.logins.removeLogin(login2); +}); + +add_task(function* test_checkUPSaveText() { + info("Check text on a user+pass notification popup"); + + yield testSubmittingLoginForm("subtst_notifications_1.html", function*(fieldValues) { + is(fieldValues.username, "notifyu1", "Checking submitted username"); + is(fieldValues.password, "notifyp1", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-save"); + ok(notif, "got notification popup"); + // Check the text, which comes from the localized saveLoginText string. + let notificationText = notif.message; + let expectedText = "Would you like " + BRAND_SHORT_NAME + " to remember this login?"; + is(expectedText, notificationText, "Checking text: " + notificationText); + notif.remove(); + }); + + is(Services.logins.getAllLogins().length, 0, "Should not have any logins yet"); +}); + +add_task(function* test_checkPSaveText() { + info("Check text on a pass-only notification popup"); + + yield testSubmittingLoginForm("subtst_notifications_6.html", function*(fieldValues) { + is(fieldValues.username, "null", "Checking submitted username"); + is(fieldValues.password, "notifyp1", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-save"); + ok(notif, "got notification popup"); + // Check the text, which comes from the localized saveLoginTextNoUser string. + let notificationText = notif.message; + let expectedText = "Would you like " + BRAND_SHORT_NAME + " to remember this password?"; + is(expectedText, notificationText, "Checking text: " + notificationText); + notif.remove(); + }); + + is(Services.logins.getAllLogins().length, 0, "Should not have any logins yet"); +}); + +add_task(function* test_capture2pw0un() { + info("Check for notification popup when a form with 2 password fields (no username) " + + "is submitted and there are no saved logins."); + + yield testSubmittingLoginForm("subtst_notifications_2pw_0un.html", function*(fieldValues) { + is(fieldValues.username, "null", "Checking submitted username"); + is(fieldValues.password, "notifyp1", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-save"); + ok(notif, "got notification popup"); + notif.remove(); + }); + + is(Services.logins.getAllLogins().length, 0, "Should not have any logins yet"); +}); + +add_task(function* test_change2pw0unExistingDifferentUP() { + info("Check for notification popup when a form with 2 password fields (no username) " + + "is submitted and there is a saved login with a username and different password."); + + Services.logins.addLogin(login1B); + + yield testSubmittingLoginForm("subtst_notifications_2pw_0un.html", function*(fieldValues) { + is(fieldValues.username, "null", "Checking submitted username"); + is(fieldValues.password, "notifyp1", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-change"); + ok(notif, "got notification popup"); + notif.remove(); + }); + + let logins = Services.logins.getAllLogins(); + is(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + is(login.username, "notifyu1B", "Check the username unchanged"); + is(login.password, "notifyp1B", "Check the password unchanged"); + is(login.timesUsed, 1, "Check times used"); + + Services.logins.removeLogin(login1B); +}); + +add_task(function* test_change2pw0unExistingDifferentP() { + info("Check for notification popup when a form with 2 password fields (no username) " + + "is submitted and there is a saved login with no username and different password."); + + Services.logins.addLogin(login2B); + + yield testSubmittingLoginForm("subtst_notifications_2pw_0un.html", function*(fieldValues) { + is(fieldValues.username, "null", "Checking submitted username"); + is(fieldValues.password, "notifyp1", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-change"); + ok(notif, "got notification popup"); + notif.remove(); + }); + + let logins = Services.logins.getAllLogins(); + is(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + is(login.username, "", "Check the username unchanged"); + is(login.password, "notifyp1B", "Check the password unchanged"); + is(login.timesUsed, 1, "Check times used"); + + Services.logins.removeLogin(login2B); +}); + +add_task(function* test_change2pw0unExistingWithSameP() { + info("Check for no notification popup when a form with 2 password fields (no username) " + + "is submitted and there is a saved login with a username and the same password."); + + Services.logins.addLogin(login2); + + yield testSubmittingLoginForm("subtst_notifications_2pw_0un.html", function*(fieldValues) { + is(fieldValues.username, "null", "Checking submitted username"); + is(fieldValues.password, "notifyp1", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-change"); + ok(!notif, "checking for no notification popup"); + }); + + let logins = Services.logins.getAllLogins(); + is(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + is(login.username, "", "Check the username unchanged"); + is(login.password, "notifyp1", "Check the password unchanged"); + is(login.timesUsed, 2, "Check times used incremented"); + + checkOnlyLoginWasUsedTwice({ justChanged: false }); + + Services.logins.removeLogin(login2); +}); + +add_task(function* test_changeUPLoginOnPUpdateForm() { + info("Check for change-password popup, u+p login on password update form."); + Services.logins.addLogin(login1); + + yield testSubmittingLoginForm("subtst_notifications_change_p.html", function*(fieldValues) { + is(fieldValues.username, "null", "Checking submitted username"); + is(fieldValues.password, "pass2", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-change"); + ok(notif, "got notification popup"); + + yield* checkDoorhangerUsernamePassword("notifyu1", "pass2"); + clickDoorhangerButton(notif, CHANGE_BUTTON); + + ok(!getCaptureDoorhanger("password-change"), "popup should be gone"); + }); + + let logins = Services.logins.getAllLogins(); + is(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + is(login.username, "notifyu1", "Check the username unchanged"); + is(login.password, "pass2", "Check the password changed"); + is(login.timesUsed, 2, "Check times used"); + + checkOnlyLoginWasUsedTwice({ justChanged: true }); + + // cleanup + login1.password = "pass2"; + Services.logins.removeLogin(login1); + login1.password = "notifyp1"; +}); + +add_task(function* test_recipeCaptureFields_NewLogin() { + info("Check that we capture the proper fields when a field recipe is in use."); + + yield testSubmittingLoginForm("subtst_notifications_2pw_1un_1text.html", function*(fieldValues) { + is(fieldValues.username, "notifyu1", "Checking submitted username"); + is(fieldValues.password, "notifyp1", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-save"); + ok(notif, "got notification popup"); + + // Sanity check, no logins should exist yet. + let logins = Services.logins.getAllLogins(); + is(logins.length, 0, "Should not have any logins yet"); + + yield* checkDoorhangerUsernamePassword("notifyu1", "notifyp1"); + clickDoorhangerButton(notif, REMEMBER_BUTTON); + + }, "http://example.org"); // The recipe is for example.org + + let logins = Services.logins.getAllLogins(); + is(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + is(login.username, "notifyu1", "Check the username unchanged"); + is(login.password, "notifyp1", "Check the password unchanged"); + is(login.timesUsed, 1, "Check times used"); +}); + +add_task(function* test_recipeCaptureFields_ExistingLogin() { + info("Check that we capture the proper fields when a field recipe is in use " + + "and there is a matching login"); + + yield testSubmittingLoginForm("subtst_notifications_2pw_1un_1text.html", function*(fieldValues) { + is(fieldValues.username, "notifyu1", "Checking submitted username"); + is(fieldValues.password, "notifyp1", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-save"); + ok(!notif, "checking for no notification popup"); + }, "http://example.org"); + + checkOnlyLoginWasUsedTwice({ justChanged: false }); + let logins = Services.logins.getAllLogins(); + is(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + is(login.username, "notifyu1", "Check the username unchanged"); + is(login.password, "notifyp1", "Check the password unchanged"); + is(login.timesUsed, 2, "Check times used incremented"); + + Services.logins.removeAllLogins(); +}); + +add_task(function* test_noShowPasswordOnDismissal() { + info("Check for no Show Password field when the doorhanger is dismissed"); + + yield testSubmittingLoginForm("subtst_notifications_1.html", function*(fieldValues) { + info("Opening popup"); + let notif = getCaptureDoorhanger("password-save"); + let { panel } = PopupNotifications; + + info("Hiding popup."); + let promiseHidden = BrowserTestUtils.waitForEvent(panel, "popuphidden"); + panel.hidePopup(); + yield promiseHidden; + + info("Clicking on anchor to reshow popup."); + let promiseShown = BrowserTestUtils.waitForEvent(panel, "popupshown"); + notif.anchorElement.click(); + yield promiseShown; + + let passwordVisiblityToggle = panel.querySelector("#password-notification-visibilityToggle"); + is(passwordVisiblityToggle.hidden, true, "Check that the Show Password field is Hidden"); + }); +}); + +// TODO: +// * existing login test, form has different password --> change password, no save prompt diff --git a/toolkit/components/passwordmgr/test/browser/browser_capture_doorhanger_httpsUpgrade.js b/toolkit/components/passwordmgr/test/browser/browser_capture_doorhanger_httpsUpgrade.js new file mode 100644 index 000000000..9be0aa631 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_capture_doorhanger_httpsUpgrade.js @@ -0,0 +1,123 @@ +/* + * Test capture popup notifications with HTTPS upgrades + */ + +let nsLoginInfo = new Components.Constructor("@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, "init"); +let login1 = new nsLoginInfo("http://example.com", "http://example.com", null, + "notifyu1", "notifyp1", "user", "pass"); +let login1HTTPS = new nsLoginInfo("https://example.com", "https://example.com", null, + "notifyu1", "notifyp1", "user", "pass"); + +add_task(function* test_httpsUpgradeCaptureFields_noChange() { + info("Check that we don't prompt to remember when capturing an upgraded login with no change"); + Services.logins.addLogin(login1); + // Sanity check the HTTP login exists. + let logins = Services.logins.getAllLogins(); + is(logins.length, 1, "Should have the HTTP login"); + + yield testSubmittingLoginForm("subtst_notifications_1.html", function*(fieldValues) { + is(fieldValues.username, "notifyu1", "Checking submitted username"); + is(fieldValues.password, "notifyp1", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-save"); + ok(!notif, "checking for no notification popup"); + }, "https://example.com"); // This is HTTPS whereas the saved login is HTTP + + logins = Services.logins.getAllLogins(); + is(logins.length, 1, "Should only have 1 login still"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + is(login.hostname, "http://example.com", "Check the hostname is unchanged"); + is(login.username, "notifyu1", "Check the username is unchanged"); + is(login.password, "notifyp1", "Check the password is unchanged"); + is(login.timesUsed, 2, "Check times used increased"); + + Services.logins.removeLogin(login1); +}); + +add_task(function* test_httpsUpgradeCaptureFields_changePW() { + info("Check that we prompt to change when capturing an upgraded login with a new PW"); + Services.logins.addLogin(login1); + // Sanity check the HTTP login exists. + let logins = Services.logins.getAllLogins(); + is(logins.length, 1, "Should have the HTTP login"); + + yield testSubmittingLoginForm("subtst_notifications_8.html", function*(fieldValues) { + is(fieldValues.username, "notifyu1", "Checking submitted username"); + is(fieldValues.password, "pass2", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-change"); + ok(notif, "checking for a change popup"); + + yield* checkDoorhangerUsernamePassword("notifyu1", "pass2"); + clickDoorhangerButton(notif, CHANGE_BUTTON); + + ok(!getCaptureDoorhanger("password-change"), "popup should be gone"); + }, "https://example.com"); // This is HTTPS whereas the saved login is HTTP + + checkOnlyLoginWasUsedTwice({ justChanged: true }); + logins = Services.logins.getAllLogins(); + is(logins.length, 1, "Should only have 1 login still"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + is(login.hostname, "https://example.com", "Check the hostname is upgraded"); + is(login.formSubmitURL, "https://example.com", "Check the formSubmitURL is upgraded"); + is(login.username, "notifyu1", "Check the username is unchanged"); + is(login.password, "pass2", "Check the password changed"); + is(login.timesUsed, 2, "Check times used increased"); + + Services.logins.removeAllLogins(); +}); + +add_task(function* test_httpsUpgradeCaptureFields_captureMatchingHTTP() { + info("Capture a new HTTP login which matches a stored HTTPS one."); + Services.logins.addLogin(login1HTTPS); + + yield testSubmittingLoginForm("subtst_notifications_1.html", function*(fieldValues) { + is(fieldValues.username, "notifyu1", "Checking submitted username"); + is(fieldValues.password, "notifyp1", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-save"); + ok(notif, "got notification popup"); + + is(Services.logins.getAllLogins().length, 1, "Should only have the HTTPS login"); + + yield* checkDoorhangerUsernamePassword("notifyu1", "notifyp1"); + clickDoorhangerButton(notif, REMEMBER_BUTTON); + }); + + let logins = Services.logins.getAllLogins(); + is(logins.length, 2, "Should have both HTTP and HTTPS logins"); + for (let login of logins) { + login = login.QueryInterface(Ci.nsILoginMetaInfo); + is(login.username, "notifyu1", "Check the username used on the new entry"); + is(login.password, "notifyp1", "Check the password used on the new entry"); + is(login.timesUsed, 1, "Check times used on entry"); + } + + info("Make sure Remember took effect and we don't prompt for an existing HTTP login"); + yield testSubmittingLoginForm("subtst_notifications_1.html", function*(fieldValues) { + is(fieldValues.username, "notifyu1", "Checking submitted username"); + is(fieldValues.password, "notifyp1", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-save"); + ok(!notif, "checking for no notification popup"); + }); + + logins = Services.logins.getAllLogins(); + is(logins.length, 2, "Should have both HTTP and HTTPS still"); + + let httpsLogins = LoginHelper.searchLoginsWithObject({ + hostname: "https://example.com", + }); + is(httpsLogins.length, 1, "Check https logins count"); + let httpsLogin = httpsLogins[0].QueryInterface(Ci.nsILoginMetaInfo); + ok(httpsLogin.equals(login1HTTPS), "Check HTTPS login didn't change"); + is(httpsLogin.timesUsed, 1, "Check times used"); + + let httpLogins = LoginHelper.searchLoginsWithObject({ + hostname: "http://example.com", + }); + is(httpLogins.length, 1, "Check http logins count"); + let httpLogin = httpLogins[0].QueryInterface(Ci.nsILoginMetaInfo); + ok(httpLogin.equals(login1), "Check HTTP login is as expected"); + is(httpLogin.timesUsed, 2, "Check times used increased"); + + Services.logins.removeLogin(login1); + Services.logins.removeLogin(login1HTTPS); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_capture_doorhanger_window_open.js b/toolkit/components/passwordmgr/test/browser/browser_capture_doorhanger_window_open.js new file mode 100644 index 000000000..1bcfec5eb --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_capture_doorhanger_window_open.js @@ -0,0 +1,144 @@ +/* + * Test capture popup notifications in content opened by window.open + */ + +let nsLoginInfo = new Components.Constructor("@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, "init"); +let login1 = new nsLoginInfo("http://mochi.test:8888", "http://mochi.test:8888", null, + "notifyu1", "notifyp1", "user", "pass"); +let login2 = new nsLoginInfo("http://mochi.test:8888", "http://mochi.test:8888", null, + "notifyu2", "notifyp2", "user", "pass"); + + +function withTestTabUntilStorageChange(aPageFile, aTaskFn) { + function storageChangedObserved(subject, data) { + // Watch for actions triggered from a doorhanger (not cleanup tasks with removeLogin) + if (data == "removeLogin") { + return false; + } + return true; + } + + let storageChangedPromised = TestUtils.topicObserved("passwordmgr-storage-changed", + storageChangedObserved); + return BrowserTestUtils.withNewTab({ + gBrowser, + url: "http://mochi.test:8888" + DIRECTORY_PATH + aPageFile, + }, function*(browser) { + ok(true, "loaded " + aPageFile); + info("running test case task"); + yield* aTaskFn(); + info("waiting for storage change"); + yield storageChangedPromised; + }); +} + +add_task(function* setup() { + yield SimpleTest.promiseFocus(window); +}); + +add_task(function* test_saveChromeHiddenAutoClose() { + let notifShownPromise = BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown"); + // query arguments are: username, password, features, auto-close (delimited by '|') + let url = "subtst_notifications_11.html?notifyu1|notifyp1|" + + "menubar=no,toolbar=no,location=no|autoclose"; + yield withTestTabUntilStorageChange(url, function*() { + info("waiting for popupshown"); + yield notifShownPromise; + // the popup closes and the doorhanger should appear in the opener + let popup = getCaptureDoorhanger("password-save"); + ok(popup, "got notification popup"); + yield* checkDoorhangerUsernamePassword("notifyu1", "notifyp1"); + // Sanity check, no logins should exist yet. + let logins = Services.logins.getAllLogins(); + is(logins.length, 0, "Should not have any logins yet"); + + clickDoorhangerButton(popup, REMEMBER_BUTTON); + }); + // Check result of clicking Remember + let logins = Services.logins.getAllLogins(); + is(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + is(login.timesUsed, 1, "Check times used on new entry"); + is(login.username, "notifyu1", "Check the username used on the new entry"); + is(login.password, "notifyp1", "Check the password used on the new entry"); +}); + +add_task(function* test_changeChromeHiddenAutoClose() { + let notifShownPromise = BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown"); + let url = "subtst_notifications_11.html?notifyu1|pass2|menubar=no,toolbar=no,location=no|autoclose"; + yield withTestTabUntilStorageChange(url, function*() { + info("waiting for popupshown"); + yield notifShownPromise; + let popup = getCaptureDoorhanger("password-change"); + ok(popup, "got notification popup"); + yield* checkDoorhangerUsernamePassword("notifyu1", "pass2"); + clickDoorhangerButton(popup, CHANGE_BUTTON); + }); + + // Check to make sure we updated the password, timestamps and use count for + // the login being changed with this form. + let logins = Services.logins.getAllLogins(); + is(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + is(login.username, "notifyu1", "Check the username"); + is(login.password, "pass2", "Check password changed"); + is(login.timesUsed, 2, "check .timesUsed incremented on change"); + ok(login.timeCreated < login.timeLastUsed, "timeLastUsed bumped"); + ok(login.timeLastUsed == login.timePasswordChanged, "timeUsed == timeChanged"); + + login1.password = "pass2"; + Services.logins.removeLogin(login1); + login1.password = "notifyp1"; +}); + +add_task(function* test_saveChromeVisibleSameWindow() { + // This test actually opens a new tab in the same window with default browser settings. + let url = "subtst_notifications_11.html?notifyu2|notifyp2||"; + let notifShownPromise = BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown"); + yield withTestTabUntilStorageChange(url, function*() { + yield notifShownPromise; + let popup = getCaptureDoorhanger("password-save"); + ok(popup, "got notification popup"); + yield* checkDoorhangerUsernamePassword("notifyu2", "notifyp2"); + clickDoorhangerButton(popup, REMEMBER_BUTTON); + yield BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); + + // Check result of clicking Remember + let logins = Services.logins.getAllLogins(); + is(logins.length, 1, "Should only have 1 login now"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + is(login.username, "notifyu2", "Check the username used on the new entry"); + is(login.password, "notifyp2", "Check the password used on the new entry"); + is(login.timesUsed, 1, "Check times used on new entry"); +}); + +add_task(function* test_changeChromeVisibleSameWindow() { + let url = "subtst_notifications_11.html?notifyu2|pass2||"; + let notifShownPromise = BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown"); + yield withTestTabUntilStorageChange(url, function*() { + yield notifShownPromise; + let popup = getCaptureDoorhanger("password-change"); + ok(popup, "got notification popup"); + yield* checkDoorhangerUsernamePassword("notifyu2", "pass2"); + clickDoorhangerButton(popup, CHANGE_BUTTON); + yield BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); + + // Check to make sure we updated the password, timestamps and use count for + // the login being changed with this form. + let logins = Services.logins.getAllLogins(); + is(logins.length, 1, "Should have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + is(login.username, "notifyu2", "Check the username"); + is(login.password, "pass2", "Check password changed"); + is(login.timesUsed, 2, "check .timesUsed incremented on change"); + ok(login.timeCreated < login.timeLastUsed, "timeLastUsed bumped"); + ok(login.timeLastUsed == login.timePasswordChanged, "timeUsed == timeChanged"); + + // cleanup + login2.password = "pass2"; + Services.logins.removeLogin(login2); + login2.password = "notifyp2"; +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_context_menu.js b/toolkit/components/passwordmgr/test/browser/browser_context_menu.js new file mode 100644 index 000000000..6cfcaa7c2 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_context_menu.js @@ -0,0 +1,432 @@ +/* + * Test the password manager context menu. + */ + +/* eslint no-shadow:"off" */ + +"use strict"; + +// The hostname for the test URIs. +const TEST_HOSTNAME = "https://example.com"; +const MULTIPLE_FORMS_PAGE_PATH = "/browser/toolkit/components/passwordmgr/test/browser/multiple_forms.html"; + +const CONTEXT_MENU = document.getElementById("contentAreaContextMenu"); +const POPUP_HEADER = document.getElementById("fill-login"); + +/** + * 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.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_noSchemeUpgrades() { + Services.prefs.setBoolPref("signon.schemeUpgrades", false); + yield BrowserTestUtils.withNewTab({ + gBrowser, + url: TEST_HOSTNAME + MULTIPLE_FORMS_PAGE_PATH, + }, function* (browser) { + yield openPasswordContextMenu(browser, "#test-password-1"); + + // Check the content of the password manager popup + let popupMenu = document.getElementById("fill-login-popup"); + checkMenu(popupMenu, 2); + + CONTEXT_MENU.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) { + yield openPasswordContextMenu(browser, "#test-password-1"); + + // Check the content of the password manager popup + let popupMenu = document.getElementById("fill-login-popup"); + checkMenu(popupMenu, 3); + + CONTEXT_MENU.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_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) { + yield openPasswordContextMenu(browser, "#test-username-2"); + + // Check the content of the password manager popup + let popupMenu = document.getElementById("fill-login-popup"); + checkMenu(popupMenu, 2); + + CONTEXT_MENU.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) { + yield openPasswordContextMenu(browser, "#test-username-2"); + + // Check the content of the password manager popup + let popupMenu = document.getElementById("fill-login-popup"); + checkMenu(popupMenu, 3); + + CONTEXT_MENU.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 formDescriptions = yield ContentTask.spawn(browser, {}, function*() { + let forms = Array.from(content.document.getElementsByClassName("test-form")); + return forms.map((f) => f.getAttribute("description")); + }); + + for (let description of formDescriptions) { + info("Testing form: " + description); + + let passwordInputIds = yield ContentTask.spawn(browser, {description}, function*({description}) { + let formElement = content.document.querySelector(`[description="${description}"]`); + let passwords = Array.from(formElement.querySelectorAll("input[type='password']")); + return passwords.map((p) => p.id); + }); + + for (let inputId of passwordInputIds) { + info("Testing password field: " + inputId); + + // Synthesize a right mouse click over the username input element. + yield openPasswordContextMenu(browser, "#" + inputId, function*() { + let inputDisabled = yield ContentTask + .spawn(browser, {inputId}, function*({inputId}) { + let input = content.document.getElementById(inputId); + return input.disabled || input.readOnly; + }); + + // If the password field is disabled or read-only, we want to see + // the disabled Fill Password popup header. + if (inputDisabled) { + Assert.ok(!POPUP_HEADER.hidden, "Popup menu is not hidden."); + Assert.ok(POPUP_HEADER.disabled, "Popup menu is disabled."); + CONTEXT_MENU.hidePopup(); + } + + return !inputDisabled; + }); + + if (CONTEXT_MENU.state != "open") { + continue; + } + + // The only field affected by the password fill + // should be the target password field itself. + yield assertContextMenuFill(browser, description, null, inputId, 1); + yield ContentTask.spawn(browser, {inputId}, function*({inputId}) { + let passwordField = content.document.getElementById(inputId); + Assert.equal(passwordField.value, "password1", "Check upgraded login was actually used"); + }); + + CONTEXT_MENU.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 formDescriptions = yield ContentTask.spawn(browser, {}, function*() { + let forms = Array.from(content.document.getElementsByClassName("test-form")); + return forms.map((f) => f.getAttribute("description")); + }); + + for (let description of formDescriptions) { + info("Testing form: " + description); + let usernameInputIds = yield ContentTask + .spawn(browser, {description}, function*({description}) { + let formElement = content.document.querySelector(`[description="${description}"]`); + let inputs = Array.from(formElement.querySelectorAll("input[type='text']")); + return inputs.map((p) => p.id); + }); + + for (let inputId of usernameInputIds) { + info("Testing username field: " + inputId); + + // Synthesize a right mouse click over the username input element. + yield openPasswordContextMenu(browser, "#" + inputId, function*() { + let headerHidden = POPUP_HEADER.hidden; + let headerDisabled = POPUP_HEADER.disabled; + + let data = {description, inputId, headerHidden, headerDisabled}; + let shouldContinue = yield ContentTask.spawn(browser, data, function*(data) { + let {description, inputId, headerHidden, headerDisabled} = data; + let formElement = content.document.querySelector(`[description="${description}"]`); + let usernameField = content.document.getElementById(inputId); + // We always want to check if the first password field is filled, + // since this is the current behavior from the _fillForm function. + let passwordField = formElement.querySelector("input[type='password']"); + + // If we don't want to see the actual popup menu, + // check if the popup is hidden or disabled. + if (!passwordField || usernameField.disabled || usernameField.readOnly || + passwordField.disabled || passwordField.readOnly) { + if (!passwordField) { + Assert.ok(headerHidden, "Popup menu is hidden."); + } else { + Assert.ok(!headerHidden, "Popup menu is not hidden."); + Assert.ok(headerDisabled, "Popup menu is disabled."); + } + return false; + } + return true; + }); + + if (!shouldContinue) { + CONTEXT_MENU.hidePopup(); + } + + return shouldContinue; + }); + + if (CONTEXT_MENU.state != "open") { + continue; + } + + let passwordFieldId = yield ContentTask + .spawn(browser, {description}, function*({description}) { + let formElement = content.document.querySelector(`[description="${description}"]`); + return formElement.querySelector("input[type='password']").id; + }); + + // We shouldn't change any field that's not the target username field or the first password field + yield assertContextMenuFill(browser, description, inputId, passwordFieldId, 1); + + yield ContentTask.spawn(browser, {passwordFieldId}, function*({passwordFieldId}) { + let passwordField = content.document.getElementById(passwordFieldId); + if (!passwordField.hasAttribute("expectedFail")) { + Assert.equal(passwordField.value, "password1", "Check upgraded login was actually used"); + } + }); + + CONTEXT_MENU.hidePopup(); + } + } + }); +}); + +/** + * Synthesize mouse clicks to open the password manager context menu popup + * for a target password input element. + * + * assertCallback should return true if we should continue or else false. + */ +function* openPasswordContextMenu(browser, passwordInput, assertCallback = null) { + // Synthesize a right mouse click over the password input element. + let contextMenuShownPromise = BrowserTestUtils.waitForEvent(CONTEXT_MENU, "popupshown"); + let eventDetails = {type: "contextmenu", button: 2}; + BrowserTestUtils.synthesizeMouseAtCenter(passwordInput, eventDetails, browser); + yield contextMenuShownPromise; + + if (assertCallback) { + let shouldContinue = yield assertCallback(); + if (!shouldContinue) { + return; + } + } + + // Synthesize a mouse click over the fill login menu header. + let popupShownPromise = BrowserTestUtils.waitForEvent(POPUP_HEADER, "popupshown"); + EventUtils.synthesizeMouseAtCenter(POPUP_HEADER, {}); + yield popupShownPromise; +} + +/** + * Verify that only the expected form fields are filled. + */ +function* assertContextMenuFill(browser, formId, usernameFieldId, passwordFieldId, loginIndex) { + let popupMenu = document.getElementById("fill-login-popup"); + let unchangedSelector = `[description="${formId}"] input:not(#${passwordFieldId})`; + + if (usernameFieldId) { + unchangedSelector += `:not(#${usernameFieldId})`; + } + + yield ContentTask.spawn(browser, {unchangedSelector}, function*({unchangedSelector}) { + let unchangedFields = content.document.querySelectorAll(unchangedSelector); + + // 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 specified login menuitem found in the context menu. + let loginItem = popupMenu.getElementsByClassName("context-login-item")[loginIndex]; + + // Find the used login by it's username (Use only unique usernames in this test). + let {username, password} = getLoginFromUsername(loginItem.label); + + let data = {username, password, usernameFieldId, passwordFieldId, formId, unchangedSelector}; + let continuePromise = ContentTask.spawn(browser, data, function*(data) { + let {username, password, usernameFieldId, passwordFieldId, formId, unchangedSelector} = data; + let form = content.document.querySelector(`[description="${formId}"]`); + yield ContentTaskUtils.waitForEvent(form, "input", "Username input value changed"); + + if (usernameFieldId) { + let usernameField = content.document.getElementById(usernameFieldId); + + // If we have an username field, check if it's correctly filled + if (usernameField.getAttribute("expectedFail") == null) { + Assert.equal(username, usernameField.value, "Username filled and correct."); + } + } + + if (passwordFieldId) { + let passwordField = content.document.getElementById(passwordFieldId); + + // If we have a password field, check if it's correctly filled + if (passwordField && passwordField.getAttribute("expectedFail") == null) { + Assert.equal(password, passwordField.value, "Password filled and correct."); + } + } + + let unchangedFields = content.document.querySelectorAll(unchangedSelector); + + // Check that all fields that should not change have the same value as before. + if (unchangedFields.length) { + Assert.ok(() => { + for (let field of unchangedFields) { + if (field.value != field.getAttribute("original-value")) { + return false; + } + } + return true; + }, "Other fields were not changed."); + } + }); + + loginItem.doCommand(); + + return continuePromise; +} + +/** + * 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, 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 = [...CONTEXT_MENU.getElementsByClassName("context-login-item")]; + 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. 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: "http://example.org", + formSubmitURL: "http://example.org", + username: "username-cross-origin", + password: "password-cross-origin", + }), + ]; +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_context_menu_autocomplete_interaction.js b/toolkit/components/passwordmgr/test/browser/browser_context_menu_autocomplete_interaction.js new file mode 100644 index 000000000..1b37e3f79 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_context_menu_autocomplete_interaction.js @@ -0,0 +1,99 @@ +/* + * Test the password manager context menu interaction with autocomplete. + */ + +"use strict"; + +const TEST_HOSTNAME = "https://example.com"; +const BASIC_FORM_PAGE_PATH = DIRECTORY_PATH + "form_basic.html"; + +var gUnexpectedIsTODO = false; + +/** + * Initialize logins needed for the tests and disable autofill + * for login forms for easier testing of manual fill. + */ +add_task(function* test_initialize() { + let autocompletePopup = document.getElementById("PopupAutoComplete"); + Services.prefs.setBoolPref("signon.autofillForms", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("signon.autofillForms"); + autocompletePopup.removeEventListener("popupshowing", autocompleteUnexpectedPopupShowing); + }); + for (let login of loginList()) { + Services.logins.addLogin(login); + } + autocompletePopup.addEventListener("popupshowing", autocompleteUnexpectedPopupShowing); +}); + +add_task(function* test_context_menu_username() { + yield BrowserTestUtils.withNewTab({ + gBrowser, + url: TEST_HOSTNAME + BASIC_FORM_PAGE_PATH, + }, function* (browser) { + yield openContextMenu(browser, "#form-basic-username"); + + let contextMenu = document.getElementById("contentAreaContextMenu"); + Assert.equal(contextMenu.state, "open", "Context menu opened"); + contextMenu.hidePopup(); + }); +}); + +add_task(function* test_context_menu_password() { + gUnexpectedIsTODO = true; + yield BrowserTestUtils.withNewTab({ + gBrowser, + url: TEST_HOSTNAME + BASIC_FORM_PAGE_PATH, + }, function* (browser) { + yield openContextMenu(browser, "#form-basic-password"); + + let contextMenu = document.getElementById("contentAreaContextMenu"); + Assert.equal(contextMenu.state, "open", "Context menu opened"); + contextMenu.hidePopup(); + }); +}); + +function autocompleteUnexpectedPopupShowing(event) { + if (gUnexpectedIsTODO) { + todo(false, "Autocomplete shouldn't appear"); + } else { + Assert.ok(false, "Autocomplete shouldn't appear"); + } + event.target.hidePopup(); +} + +/** + * Synthesize mouse clicks to open the context menu popup + * for a target login input element. + */ +function* openContextMenu(browser, loginInput) { + // First synthesize a mousedown. We need this to get the focus event with the "contextmenu" event. + let eventDetails1 = {type: "mousedown", button: 2}; + BrowserTestUtils.synthesizeMouseAtCenter(loginInput, eventDetails1, browser); + + // Then synthesize the contextmenu click over the input element. + let contextMenuShownPromise = BrowserTestUtils.waitForEvent(window, "popupshown"); + let eventDetails = {type: "contextmenu", button: 2}; + BrowserTestUtils.synthesizeMouseAtCenter(loginInput, eventDetails, browser); + yield contextMenuShownPromise; + + // Wait to see which popups are shown. + yield new Promise(resolve => setTimeout(resolve, 1000)); +} + +function loginList() { + return [ + LoginTestUtils.testData.formLogin({ + hostname: "https://example.com", + formSubmitURL: "https://example.com", + username: "username", + password: "password", + }), + LoginTestUtils.testData.formLogin({ + hostname: "https://example.com", + formSubmitURL: "https://example.com", + username: "username2", + password: "password2", + }), + ]; +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_context_menu_iframe.js b/toolkit/components/passwordmgr/test/browser/browser_context_menu_iframe.js new file mode 100644 index 000000000..c5219789d --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_context_menu_iframe.js @@ -0,0 +1,144 @@ +/* + * Test the password manager context menu. + */ + +"use strict"; + +const TEST_HOSTNAME = "https://example.com"; + +// Test with a page that only has a form within an iframe, not in the top-level document +const IFRAME_PAGE_PATH = "/browser/toolkit/components/passwordmgr/test/browser/form_basic_iframe.html"; + +/** + * 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.prefs.clearUserPref("signon.schemeUpgrades"); + }); + for (let login of loginList()) { + Services.logins.addLogin(login); + } +}); + +/** + * 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 + IFRAME_PAGE_PATH + }, function* (browser) { + function getPasswordInput() { + let frame = content.document.getElementById("test-iframe"); + return frame.contentDocument.getElementById("form-basic-password"); + } + + let contextMenuShownPromise = BrowserTestUtils.waitForEvent(window, "popupshown"); + let eventDetails = {type: "contextmenu", button: 2}; + + // To click at the right point we have to take into account the iframe offset. + // Synthesize a right mouse click over the password input element. + BrowserTestUtils.synthesizeMouseAtCenter(getPasswordInput, eventDetails, browser); + yield contextMenuShownPromise; + + // Synthesize a mouse click over the fill login menu header. + let popupHeader = document.getElementById("fill-login"); + let popupShownPromise = BrowserTestUtils.waitForEvent(popupHeader, "popupshown"); + EventUtils.synthesizeMouseAtCenter(popupHeader, {}); + yield popupShownPromise; + + let popupMenu = document.getElementById("fill-login-popup"); + + // Stores the original value of username + function promiseFrameInputValue(name) { + return ContentTask.spawn(browser, name, function(inputname) { + let iframe = content.document.getElementById("test-iframe"); + let input = iframe.contentDocument.getElementById(inputname); + return input.value; + }); + } + let usernameOriginalValue = yield promiseFrameInputValue("form-basic-username"); + + // Execute the command of the first login menuitem found at the context menu. + let passwordChangedPromise = ContentTask.spawn(browser, null, function* () { + let frame = content.document.getElementById("test-iframe"); + let passwordInput = frame.contentDocument.getElementById("form-basic-password"); + yield ContentTaskUtils.waitForEvent(passwordInput, "input"); + }); + + let firstLoginItem = popupMenu.getElementsByClassName("context-login-item")[0]; + firstLoginItem.doCommand(); + + yield passwordChangedPromise; + + // Find the used login by it's username. + let login = getLoginFromUsername(firstLoginItem.label); + let passwordValue = yield promiseFrameInputValue("form-basic-password"); + is(login.password, passwordValue, "Password filled and correct."); + + let usernameNewValue = yield promiseFrameInputValue("form-basic-username"); + is(usernameOriginalValue, + usernameNewValue, + "Username value was not changed."); + + let contextMenu = document.getElementById("contentAreaContextMenu"); + contextMenu.hidePopup(); + }); +}); + +/** + * 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. 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: "http://example.org", + formSubmitURL: "http://example.org", + username: "username-cross-origin", + password: "password-cross-origin", + }), + ]; +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_exceptions_dialog.js b/toolkit/components/passwordmgr/test/browser/browser_exceptions_dialog.js new file mode 100644 index 000000000..09fbe0eea --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_exceptions_dialog.js @@ -0,0 +1,56 @@ + +"use strict"; + +const LOGIN_HOST = "http://example.com"; + +function openExceptionsDialog() { + return window.openDialog( + "chrome://browser/content/preferences/permissions.xul", + "Toolkit:PasswordManagerExceptions", "", + { + blockVisible: true, + sessionVisible: false, + allowVisible: false, + hideStatusColumn: true, + prefilledHost: "", + permissionType: "login-saving" + } + ); +} + +function countDisabledHosts(dialog) { + let doc = dialog.document; + let rejectsTree = doc.getElementById("permissionsTree"); + + return rejectsTree.view.rowCount; +} + +function promiseStorageChanged(expectedData) { + function observer(subject, data) { + return data == expectedData && subject.QueryInterface(Ci.nsISupportsString).data == LOGIN_HOST; + } + + return TestUtils.topicObserved("passwordmgr-storage-changed", observer); +} + +add_task(function* test_disable() { + let dialog = openExceptionsDialog(); + let promiseChanged = promiseStorageChanged("hostSavingDisabled"); + + yield BrowserTestUtils.waitForEvent(dialog, "load"); + Services.logins.setLoginSavingEnabled(LOGIN_HOST, false); + yield promiseChanged; + is(countDisabledHosts(dialog), 1, "Verify disabled host added"); + yield BrowserTestUtils.closeWindow(dialog); +}); + +add_task(function* test_enable() { + let dialog = openExceptionsDialog(); + let promiseChanged = promiseStorageChanged("hostSavingEnabled"); + + yield BrowserTestUtils.waitForEvent(dialog, "load"); + Services.logins.setLoginSavingEnabled(LOGIN_HOST, true); + yield promiseChanged; + is(countDisabledHosts(dialog), 0, "Verify disabled host removed"); + yield BrowserTestUtils.closeWindow(dialog); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_formless_submit_chrome.js b/toolkit/components/passwordmgr/test/browser/browser_formless_submit_chrome.js new file mode 100644 index 000000000..c6d9ce50a --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_formless_submit_chrome.js @@ -0,0 +1,126 @@ +/* + * Test that browser chrome UI interactions don't trigger a capture doorhanger. + */ + +"use strict"; + +function* fillTestPage(aBrowser) { + yield ContentTask.spawn(aBrowser, null, function*() { + content.document.getElementById("form-basic-username").value = "my_username"; + content.document.getElementById("form-basic-password").value = "my_password"; + }); + info("fields filled"); +} + +function* withTestPage(aTaskFn) { + return BrowserTestUtils.withNewTab({ + gBrowser, + url: "https://example.com" + DIRECTORY_PATH + "formless_basic.html", + }, function*(aBrowser) { + info("tab opened"); + yield fillTestPage(aBrowser); + yield* aTaskFn(aBrowser); + + // Give a chance for the doorhanger to appear + yield new Promise(resolve => SimpleTest.executeSoon(resolve)); + ok(!getCaptureDoorhanger("any"), "No doorhanger should be present"); + }); +} + +add_task(function* setup() { + yield SimpleTest.promiseFocus(window); +}); + +add_task(function* test_urlbar_new_URL() { + yield withTestPage(function*(aBrowser) { + gURLBar.value = ""; + let focusPromise = BrowserTestUtils.waitForEvent(gURLBar, "focus"); + gURLBar.focus(); + yield focusPromise; + info("focused"); + EventUtils.sendString("http://mochi.test:8888/"); + EventUtils.synthesizeKey("VK_RETURN", {}); + yield BrowserTestUtils.browserLoaded(aBrowser, false, "http://mochi.test:8888/"); + }); +}); + +add_task(function* test_urlbar_fragment_enter() { + yield withTestPage(function*(aBrowser) { + gURLBar.focus(); + EventUtils.synthesizeKey("VK_RIGHT", {}); + EventUtils.sendString("#fragment"); + EventUtils.synthesizeKey("VK_RETURN", {}); + }); +}); + +add_task(function* test_backButton_forwardButton() { + yield withTestPage(function*(aBrowser) { + // Load a new page in the tab so we can test going back + aBrowser.loadURI("https://example.com" + DIRECTORY_PATH + "formless_basic.html?second"); + yield BrowserTestUtils.browserLoaded(aBrowser, false, + "https://example.com" + DIRECTORY_PATH + + "formless_basic.html?second"); + yield fillTestPage(aBrowser); + + let forwardButton = document.getElementById("forward-button"); + // We need to wait for the forward button transition to complete before we + // can click it, so we hook up a listener to wait for it to be ready. + let forwardTransitionPromise = BrowserTestUtils.waitForEvent(forwardButton, "transitionend"); + + let backPromise = BrowserTestUtils.browserStopped(aBrowser); + EventUtils.synthesizeMouseAtCenter(document.getElementById("back-button"), {}); + yield backPromise; + + // Give a chance for the doorhanger to appear + yield new Promise(resolve => SimpleTest.executeSoon(resolve)); + ok(!getCaptureDoorhanger("any"), "No doorhanger should be present"); + + // Now go forward again after filling + yield fillTestPage(aBrowser); + + yield forwardTransitionPromise; + info("transition done"); + yield BrowserTestUtils.waitForCondition(() => { + return forwardButton.disabled == false; + }); + let forwardPromise = BrowserTestUtils.browserStopped(aBrowser); + info("click the forward button"); + EventUtils.synthesizeMouseAtCenter(forwardButton, {}); + yield forwardPromise; + }); +}); + + +add_task(function* test_reloadButton() { + yield withTestPage(function*(aBrowser) { + let reloadButton = document.getElementById("urlbar-reload-button"); + let loadPromise = BrowserTestUtils.browserLoaded(aBrowser, false, + "https://example.com" + DIRECTORY_PATH + + "formless_basic.html"); + + yield BrowserTestUtils.waitForCondition(() => { + return reloadButton.disabled == false; + }); + EventUtils.synthesizeMouseAtCenter(reloadButton, {}); + yield loadPromise; + }); +}); + +add_task(function* test_back_keyboard_shortcut() { + if (Services.prefs.getIntPref("browser.backspace_action") != 0) { + ok(true, "Skipped testing backspace to go back since it's disabled"); + return; + } + yield withTestPage(function*(aBrowser) { + // Load a new page in the tab so we can test going back + aBrowser.loadURI("https://example.com" + DIRECTORY_PATH + "formless_basic.html?second"); + yield BrowserTestUtils.browserLoaded(aBrowser, false, + "https://example.com" + DIRECTORY_PATH + + "formless_basic.html?second"); + yield fillTestPage(aBrowser); + + let backPromise = BrowserTestUtils.browserStopped(aBrowser); + EventUtils.synthesizeKey("VK_BACK_SPACE", {}); + yield backPromise; + }); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_hasInsecureLoginForms.js b/toolkit/components/passwordmgr/test/browser/browser_hasInsecureLoginForms.js new file mode 100644 index 000000000..039312b7d --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_hasInsecureLoginForms.js @@ -0,0 +1,93 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/LoginManagerParent.jsm", this); + +const testUrlPath = + "://example.com/browser/toolkit/components/passwordmgr/test/browser/"; + +/** + * Waits for the given number of occurrences of InsecureLoginFormsStateChange + * on the given browser element. + */ +function waitForInsecureLoginFormsStateChange(browser, count) { + return BrowserTestUtils.waitForEvent(browser, "InsecureLoginFormsStateChange", + false, () => --count == 0); +} + +/** + * Checks that hasInsecureLoginForms is true for a simple HTTP page and false + * for a simple HTTPS page. + */ +add_task(function* test_simple() { + for (let scheme of ["http", "https"]) { + let tab = gBrowser.addTab(scheme + testUrlPath + "form_basic.html"); + let browser = tab.linkedBrowser; + yield Promise.all([ + BrowserTestUtils.switchTab(gBrowser, tab), + BrowserTestUtils.browserLoaded(browser), + // One event is triggered by pageshow and one by DOMFormHasPassword. + waitForInsecureLoginFormsStateChange(browser, 2), + ]); + + Assert.equal(LoginManagerParent.hasInsecureLoginForms(browser), + scheme == "http"); + + gBrowser.removeTab(tab); + } +}); + +/** + * Checks that hasInsecureLoginForms is true if a password field is present in + * an HTTP page loaded as a subframe of a top-level HTTPS page, when mixed + * active content blocking is disabled. + * + * When the subframe is navigated to an HTTPS page, hasInsecureLoginForms should + * be set to false. + * + * Moving back in history should set hasInsecureLoginForms to true again. + */ +add_task(function* test_subframe_navigation() { + yield new Promise(resolve => SpecialPowers.pushPrefEnv({ + "set": [["security.mixed_content.block_active_content", false]], + }, resolve)); + + // Load the page with the subframe in a new tab. + let tab = gBrowser.addTab("https" + testUrlPath + "insecure_test.html"); + let browser = tab.linkedBrowser; + yield Promise.all([ + BrowserTestUtils.switchTab(gBrowser, tab), + BrowserTestUtils.browserLoaded(browser), + // Two events are triggered by pageshow and one by DOMFormHasPassword. + waitForInsecureLoginFormsStateChange(browser, 3), + ]); + + Assert.ok(LoginManagerParent.hasInsecureLoginForms(browser)); + + // Navigate the subframe to a secure page. + let promiseSubframeReady = Promise.all([ + BrowserTestUtils.browserLoaded(browser, true), + // One event is triggered by pageshow and one by DOMFormHasPassword. + waitForInsecureLoginFormsStateChange(browser, 2), + ]); + yield ContentTask.spawn(browser, null, function* () { + content.document.getElementById("test-iframe") + .contentDocument.getElementById("test-link").click(); + }); + yield promiseSubframeReady; + + Assert.ok(!LoginManagerParent.hasInsecureLoginForms(browser)); + + // Navigate back to the insecure page. We only have to wait for the + // InsecureLoginFormsStateChange event that is triggered by pageshow. + let promise = waitForInsecureLoginFormsStateChange(browser, 1); + yield ContentTask.spawn(browser, null, function* () { + content.document.getElementById("test-iframe") + .contentWindow.history.back(); + }); + yield promise; + + Assert.ok(LoginManagerParent.hasInsecureLoginForms(browser)); + + gBrowser.removeTab(tab); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_hasInsecureLoginForms_streamConverter.js b/toolkit/components/passwordmgr/test/browser/browser_hasInsecureLoginForms_streamConverter.js new file mode 100644 index 000000000..2dbffb9cc --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_hasInsecureLoginForms_streamConverter.js @@ -0,0 +1,102 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/LoginManagerParent.jsm", this); + +function* registerConverter() { + Cu.import("resource://gre/modules/Services.jsm", this); + Cu.import("resource://gre/modules/NetUtil.jsm", this); + + /** + * Converts the "test/content" MIME type, served by the test over HTTP, to an + * HTML viewer page containing the "form_basic.html" code. The viewer is + * served from a "resource:" URI while keeping the "resource:" principal. + */ + function TestStreamConverter() {} + + TestStreamConverter.prototype = { + classID: Components.ID("{5f01d6ef-c090-45a4-b3e5-940d64713eb7}"), + contractID: "@mozilla.org/streamconv;1?from=test/content&to=*/*", + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsIRequestObserver, + Ci.nsIStreamListener, + Ci.nsIStreamConverter, + ]), + + // nsIStreamConverter + convert() {}, + + // nsIStreamConverter + asyncConvertData(aFromType, aToType, aListener, aCtxt) { + this.listener = aListener; + }, + + // nsIRequestObserver + onStartRequest(aRequest, aContext) { + let channel = NetUtil.newChannel({ + uri: "resource://testing-common/form_basic.html", + loadUsingSystemPrincipal: true, + }); + channel.originalURI = aRequest.QueryInterface(Ci.nsIChannel).URI; + channel.loadGroup = aRequest.loadGroup; + channel.owner = Services.scriptSecurityManager + .createCodebasePrincipal(channel.URI, {}); + // In this test, we pass the new channel to the listener but don't fire a + // redirect notification, even if it would be required. This keeps the + // test code simpler and doesn't impact the principal check we're testing. + channel.asyncOpen2(this.listener); + }, + + // nsIRequestObserver + onStopRequest() {}, + + // nsIStreamListener + onDataAvailable() {}, + }; + + let factory = XPCOMUtils._getFactory(TestStreamConverter); + let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar); + registrar.registerFactory(TestStreamConverter.prototype.classID, "", + TestStreamConverter.prototype.contractID, factory); + this.cleanupFunction = function () { + registrar.unregisterFactory(TestStreamConverter.prototype.classID, factory); + }; +} + +/** + * Waits for the given number of occurrences of InsecureLoginFormsStateChange + * on the given browser element. + */ +function waitForInsecureLoginFormsStateChange(browser, count) { + return BrowserTestUtils.waitForEvent(browser, "InsecureLoginFormsStateChange", + false, () => --count == 0); +} + +/** + * Checks that hasInsecureLoginForms is false for a viewer served internally + * using a "resource:" URI. + */ +add_task(function* test_streamConverter() { + let originalBrowser = gBrowser.selectedTab.linkedBrowser; + + yield ContentTask.spawn(originalBrowser, null, registerConverter); + + let tab = gBrowser.addTab("http://example.com/browser/toolkit/components/" + + "passwordmgr/test/browser/streamConverter_content.sjs", + { relatedBrowser: originalBrowser.linkedBrowser }); + let browser = tab.linkedBrowser; + yield Promise.all([ + BrowserTestUtils.switchTab(gBrowser, tab), + BrowserTestUtils.browserLoaded(browser), + // One event is triggered by pageshow and one by DOMFormHasPassword. + waitForInsecureLoginFormsStateChange(browser, 2), + ]); + + Assert.ok(!LoginManagerParent.hasInsecureLoginForms(browser)); + + yield BrowserTestUtils.removeTab(tab); + + yield ContentTask.spawn(originalBrowser, null, function* () { + this.cleanupFunction(); + }); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_http_autofill.js b/toolkit/components/passwordmgr/test/browser/browser_http_autofill.js new file mode 100644 index 000000000..beb928a34 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_http_autofill.js @@ -0,0 +1,78 @@ +const TEST_URL_PATH = "://example.org/browser/toolkit/components/passwordmgr/test/browser/"; + +add_task(function* setup() { + let login = LoginTestUtils.testData.formLogin({ + hostname: "http://example.org", + formSubmitURL: "http://example.org", + username: "username", + password: "password", + }); + Services.logins.addLogin(login); + login = LoginTestUtils.testData.formLogin({ + hostname: "http://example.org", + formSubmitURL: "http://another.domain", + username: "username", + password: "password", + }); + Services.logins.addLogin(login); + yield SpecialPowers.pushPrefEnv({ "set": [["signon.autofillForms.http", false]] }); +}); + +add_task(function* test_http_autofill() { + for (let scheme of ["http", "https"]) { + let tab = yield BrowserTestUtils + .openNewForegroundTab(gBrowser, `${scheme}${TEST_URL_PATH}form_basic.html`); + + let [username, password] = yield ContentTask.spawn(gBrowser.selectedBrowser, null, function* () { + let doc = content.document; + let contentUsername = doc.getElementById("form-basic-username").value; + let contentPassword = doc.getElementById("form-basic-password").value; + return [contentUsername, contentPassword]; + }); + + is(username, scheme == "http" ? "" : "username", "Username filled correctly"); + is(password, scheme == "http" ? "" : "password", "Password filled correctly"); + + gBrowser.removeTab(tab); + } +}); + +add_task(function* test_iframe_in_http_autofill() { + for (let scheme of ["http", "https"]) { + let tab = yield BrowserTestUtils + .openNewForegroundTab(gBrowser, `${scheme}${TEST_URL_PATH}form_basic_iframe.html`); + + let [username, password] = yield ContentTask.spawn(gBrowser.selectedBrowser, null, function* () { + let doc = content.document; + let iframe = doc.getElementById("test-iframe"); + let contentUsername = iframe.contentWindow.document.getElementById("form-basic-username").value; + let contentPassword = iframe.contentWindow.document.getElementById("form-basic-password").value; + return [contentUsername, contentPassword]; + }); + + is(username, scheme == "http" ? "" : "username", "Username filled correctly"); + is(password, scheme == "http" ? "" : "password", "Password filled correctly"); + + gBrowser.removeTab(tab); + } +}); + +add_task(function* test_http_action_autofill() { + for (let type of ["insecure", "secure"]) { + let tab = yield BrowserTestUtils + .openNewForegroundTab(gBrowser, `https${TEST_URL_PATH}form_cross_origin_${type}_action.html`); + + let [username, password] = yield ContentTask.spawn(gBrowser.selectedBrowser, null, function* () { + let doc = content.document; + let contentUsername = doc.getElementById("form-basic-username").value; + let contentPassword = doc.getElementById("form-basic-password").value; + return [contentUsername, contentPassword]; + }); + + is(username, type == "insecure" ? "" : "username", "Username filled correctly"); + is(password, type == "insecure" ? "" : "password", "Password filled correctly"); + + gBrowser.removeTab(tab); + } +}); + diff --git a/toolkit/components/passwordmgr/test/browser/browser_insecurePasswordConsoleWarning.js b/toolkit/components/passwordmgr/test/browser/browser_insecurePasswordConsoleWarning.js new file mode 100644 index 000000000..f16ae1b98 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_insecurePasswordConsoleWarning.js @@ -0,0 +1,94 @@ +"use strict"; + +const WARNING_PATTERN = [{ + key: "INSECURE_FORM_ACTION", + msg: 'JavaScript Warning: "Password fields present in a form with an insecure (http://) form action. This is a security risk that allows user login credentials to be stolen."' +}, { + key: "INSECURE_PAGE", + msg: 'JavaScript Warning: "Password fields present on an insecure (http://) page. This is a security risk that allows user login credentials to be stolen."' +}]; + +add_task(function* testInsecurePasswordWarning() { + let warningPatternHandler; + + function messageHandler(msgObj) { + function findWarningPattern(msg) { + return WARNING_PATTERN.find(patternPair => { + return msg.indexOf(patternPair.msg) !== -1; + }); + } + + let warning = findWarningPattern(msgObj.message); + + // Only handle the insecure password related warning messages. + if (warning) { + // Prevent any unexpected or redundant matched warning message coming after + // the test case is ended. + ok(warningPatternHandler, "Invoke a valid warning message handler"); + warningPatternHandler(warning, msgObj.message); + } + } + Services.console.registerListener(messageHandler); + registerCleanupFunction(function() { + Services.console.unregisterListener(messageHandler); + }); + + for (let [origin, testFile, expectWarnings] of [ + ["http://127.0.0.1", "form_basic.html", []], + ["http://127.0.0.1", "formless_basic.html", []], + ["http://example.com", "form_basic.html", ["INSECURE_PAGE"]], + ["http://example.com", "formless_basic.html", ["INSECURE_PAGE"]], + ["https://example.com", "form_basic.html", []], + ["https://example.com", "formless_basic.html", []], + + // For a form with customized action link in the same origin. + ["http://127.0.0.1", "form_same_origin_action.html", []], + ["http://example.com", "form_same_origin_action.html", ["INSECURE_PAGE"]], + ["https://example.com", "form_same_origin_action.html", []], + + // For a form with an insecure (http) customized action link. + ["http://127.0.0.1", "form_cross_origin_insecure_action.html", ["INSECURE_FORM_ACTION"]], + ["http://example.com", "form_cross_origin_insecure_action.html", ["INSECURE_PAGE"]], + ["https://example.com", "form_cross_origin_insecure_action.html", ["INSECURE_FORM_ACTION"]], + + // For a form with a secure (https) customized action link. + ["http://127.0.0.1", "form_cross_origin_secure_action.html", []], + ["http://example.com", "form_cross_origin_secure_action.html", ["INSECURE_PAGE"]], + ["https://example.com", "form_cross_origin_secure_action.html", []], + ]) { + let testURL = origin + DIRECTORY_PATH + testFile; + let promiseConsoleMessages = new Promise(resolve => { + warningPatternHandler = function (warning, originMessage) { + ok(warning, "Handling a warning pattern"); + let fullMessage = `[${warning.msg} {file: "${testURL}" line: 0 column: 0 source: "0"}]`; + is(originMessage, fullMessage, "Message full matched:" + originMessage); + + let index = expectWarnings.indexOf(warning.key); + isnot(index, -1, "Found warning: " + warning.key + " for URL:" + testURL); + if (index !== -1) { + // Remove the shown message. + expectWarnings.splice(index, 1); + } + if (expectWarnings.length === 0) { + info("All warnings are shown for URL:" + testURL); + resolve(); + } + }; + }); + + yield BrowserTestUtils.withNewTab({ + gBrowser, + url: testURL + }, function*() { + if (expectWarnings.length === 0) { + info("All warnings are shown for URL:" + testURL); + return Promise.resolve(); + } + return promiseConsoleMessages; + }); + + // Remove warningPatternHandler to stop handling the matched warning pattern + // and the task should not get any warning anymore. + warningPatternHandler = null; + } +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_master_password_autocomplete.js b/toolkit/components/passwordmgr/test/browser/browser_master_password_autocomplete.js new file mode 100644 index 000000000..f3bc62b0a --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_master_password_autocomplete.js @@ -0,0 +1,59 @@ +const HOST = "https://example.com"; +const URL = HOST + "/browser/toolkit/components/passwordmgr/test/browser/form_basic.html"; +const TIMEOUT_PREF = "signon.masterPasswordReprompt.timeout_ms"; + +// Waits for the master password prompt and cancels it. +function waitForDialog() { + let dialogShown = TestUtils.topicObserved("common-dialog-loaded"); + return dialogShown.then(function([subject]) { + let dialog = subject.Dialog; + is(dialog.args.title, "Password Required"); + dialog.ui.button1.click(); + }); +} + +// Test that autocomplete does not trigger a master password prompt +// for a certain time after it was cancelled. +add_task(function* test_mpAutocompleteTimeout() { + let login = LoginTestUtils.testData.formLogin({ + hostname: "https://example.com", + formSubmitURL: "https://example.com", + username: "username", + password: "password", + }); + Services.logins.addLogin(login); + LoginTestUtils.masterPassword.enable(); + + registerCleanupFunction(function() { + LoginTestUtils.masterPassword.disable(); + Services.logins.removeAllLogins(); + }); + + // Set master password prompt timeout to 3s. + // If this test goes intermittent, you likely have to increase this value. + yield SpecialPowers.pushPrefEnv({set: [[TIMEOUT_PREF, 3000]]}); + + // Wait for initial master password dialog after opening the tab. + let dialogShown = waitForDialog(); + + yield BrowserTestUtils.withNewTab(URL, function*(browser) { + yield dialogShown; + + yield ContentTask.spawn(browser, null, function*() { + // Focus the password field to trigger autocompletion. + content.document.getElementById("form-basic-password").focus(); + }); + + // Wait 4s, dialog should not have been shown + // (otherwise the code below will not work). + yield new Promise((c) => setTimeout(c, 4000)); + + dialogShown = waitForDialog(); + yield ContentTask.spawn(browser, null, function*() { + // Re-focus the password field to trigger autocompletion. + content.document.getElementById("form-basic-username").focus(); + content.document.getElementById("form-basic-password").focus(); + }); + yield dialogShown; + }); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_notifications.js b/toolkit/components/passwordmgr/test/browser/browser_notifications.js new file mode 100644 index 000000000..4fb012f14 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_notifications.js @@ -0,0 +1,81 @@ +/** + * Test that the doorhanger notification for password saving is populated with + * the correct values in various password capture cases. + */ +add_task(function* test_save_change() { + let testCases = [{ + username: "username", + password: "password", + }, { + username: "", + password: "password", + }, { + username: "username", + oldPassword: "password", + password: "newPassword", + }, { + username: "", + oldPassword: "password", + password: "newPassword", + }]; + + for (let { username, oldPassword, password } of testCases) { + // Add a login for the origin of the form if testing a change notification. + if (oldPassword) { + Services.logins.addLogin(LoginTestUtils.testData.formLogin({ + hostname: "https://example.com", + formSubmitURL: "https://example.com", + username, + password: oldPassword, + })); + } + + yield BrowserTestUtils.withNewTab({ + gBrowser, + url: "https://example.com/browser/toolkit/components/" + + "passwordmgr/test/browser/form_basic.html", + }, function* (browser) { + // Submit the form in the content page with the credentials from the test + // case. This will cause the doorhanger notification to be displayed. + let promiseShown = BrowserTestUtils.waitForEvent(PopupNotifications.panel, + "popupshown", + (event) => event.target == PopupNotifications.panel); + yield ContentTask.spawn(browser, [username, password], + function* ([contentUsername, contentPassword]) { + let doc = content.document; + doc.getElementById("form-basic-username").value = contentUsername; + doc.getElementById("form-basic-password").value = contentPassword; + doc.getElementById("form-basic").submit(); + }); + yield promiseShown; + let notificationElement = PopupNotifications.panel.childNodes[0]; + // Style flush to make sure binding is attached + notificationElement.querySelector("#password-notification-password").clientTop; + + // Check the actual content of the popup notification. + Assert.equal(notificationElement.querySelector("#password-notification-username") + .value, username); + Assert.equal(notificationElement.querySelector("#password-notification-password") + .value, password); + + // Simulate the action on the notification to request the login to be + // saved, and wait for the data to be updated or saved based on the type + // of operation we expect. + let expectedNotification = oldPassword ? "modifyLogin" : "addLogin"; + let promiseLogin = TestUtils.topicObserved("passwordmgr-storage-changed", + (_, data) => data == expectedNotification); + notificationElement.button.doCommand(); + let [result] = yield promiseLogin; + + // Check that the values in the database match the expected values. + let login = oldPassword ? result.QueryInterface(Ci.nsIArray) + .queryElementAt(1, Ci.nsILoginInfo) + : result.QueryInterface(Ci.nsILoginInfo); + Assert.equal(login.username, username); + Assert.equal(login.password, password); + }); + + // Clean up the database before the next test case is executed. + Services.logins.removeAllLogins(); + } +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_notifications_2.js b/toolkit/components/passwordmgr/test/browser/browser_notifications_2.js new file mode 100644 index 000000000..48c73b0e6 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_notifications_2.js @@ -0,0 +1,125 @@ +add_task(function* setup() { + yield SpecialPowers.pushPrefEnv({"set": [ + ["signon.rememberSignons.visibilityToggle", true] + ]}); +}); + +/** + * Test that the doorhanger main action button is disabled + * when the password field is empty. + * + * Also checks that submiting an empty password throws an error. + */ +add_task(function* test_empty_password() { + yield BrowserTestUtils.withNewTab({ + gBrowser, + url: "https://example.com/browser/toolkit/components/" + + "passwordmgr/test/browser/form_basic.html", + }, function* (browser) { + // Submit the form in the content page with the credentials from the test + // case. This will cause the doorhanger notification to be displayed. + let promiseShown = BrowserTestUtils.waitForEvent(PopupNotifications.panel, + "popupshown", + (event) => event.target == PopupNotifications.panel); + yield ContentTask.spawn(browser, null, + function* () { + let doc = content.document; + doc.getElementById("form-basic-username").value = "username"; + doc.getElementById("form-basic-password").value = "p"; + doc.getElementById("form-basic").submit(); + }); + yield promiseShown; + + let notificationElement = PopupNotifications.panel.childNodes[0]; + let passwordTextbox = notificationElement.querySelector("#password-notification-password"); + let toggleCheckbox = notificationElement.querySelector("#password-notification-visibilityToggle"); + + // Synthesize input to empty the field + passwordTextbox.focus(); + yield EventUtils.synthesizeKey("VK_RIGHT", {}); + yield EventUtils.synthesizeKey("VK_BACK_SPACE", {}); + + let mainActionButton = document.getAnonymousElementByAttribute(notificationElement.button, "anonid", "button"); + Assert.ok(mainActionButton.disabled, "Main action button is disabled"); + + // Makes sure submiting an empty password throws an error + Assert.throws(notificationElement.button.doCommand(), + "Can't add a login with a null or empty password.", + "Should fail for an empty password"); + }); +}); + +/** + * Test that the doorhanger password field shows plain or * text + * when the checkbox is checked. + */ +add_task(function* test_toggle_password() { + yield BrowserTestUtils.withNewTab({ + gBrowser, + url: "https://example.com/browser/toolkit/components/" + + "passwordmgr/test/browser/form_basic.html", + }, function* (browser) { + // Submit the form in the content page with the credentials from the test + // case. This will cause the doorhanger notification to be displayed. + let promiseShown = BrowserTestUtils.waitForEvent(PopupNotifications.panel, + "popupshown", + (event) => event.target == PopupNotifications.panel); + yield ContentTask.spawn(browser, null, + function* () { + let doc = content.document; + doc.getElementById("form-basic-username").value = "username"; + doc.getElementById("form-basic-password").value = "p"; + doc.getElementById("form-basic").submit(); + }); + yield promiseShown; + + let notificationElement = PopupNotifications.panel.childNodes[0]; + let passwordTextbox = notificationElement.querySelector("#password-notification-password"); + let toggleCheckbox = notificationElement.querySelector("#password-notification-visibilityToggle"); + + yield EventUtils.synthesizeMouseAtCenter(toggleCheckbox, {}); + Assert.ok(toggleCheckbox.checked); + Assert.equal(passwordTextbox.type, "", "Password textbox changed to plain text"); + + yield EventUtils.synthesizeMouseAtCenter(toggleCheckbox, {}); + Assert.ok(!toggleCheckbox.checked); + Assert.equal(passwordTextbox.type, "password", "Password textbox changed to * text"); + }); +}); + +/** + * Test that the doorhanger password toggle checkbox is disabled + * when the master password is set. + */ +add_task(function* test_checkbox_disabled_if_has_master_password() { + yield BrowserTestUtils.withNewTab({ + gBrowser, + url: "https://example.com/browser/toolkit/components/" + + "passwordmgr/test/browser/form_basic.html", + }, function* (browser) { + // Submit the form in the content page with the credentials from the test + // case. This will cause the doorhanger notification to be displayed. + let promiseShown = BrowserTestUtils.waitForEvent(PopupNotifications.panel, + "popupshown", + (event) => event.target == PopupNotifications.panel); + + LoginTestUtils.masterPassword.enable(); + + yield ContentTask.spawn(browser, null, function* () { + let doc = content.document; + doc.getElementById("form-basic-username").value = "username"; + doc.getElementById("form-basic-password").value = "p"; + doc.getElementById("form-basic").submit(); + }); + yield promiseShown; + + let notificationElement = PopupNotifications.panel.childNodes[0]; + let passwordTextbox = notificationElement.querySelector("#password-notification-password"); + let toggleCheckbox = notificationElement.querySelector("#password-notification-visibilityToggle"); + + Assert.equal(passwordTextbox.type, "password", "Password textbox should show * text"); + Assert.ok(toggleCheckbox.getAttribute("hidden"), "checkbox is hidden when master password is set"); + }); + + LoginTestUtils.masterPassword.disable(); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_notifications_password.js b/toolkit/components/passwordmgr/test/browser/browser_notifications_password.js new file mode 100644 index 000000000..8ac49dac5 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_notifications_password.js @@ -0,0 +1,145 @@ +/** + * Test changing the password inside the doorhanger notification for passwords. + * + * We check the following cases: + * - Editing the password of a new login. + * - Editing the password of an existing login. + * - Changing both username and password to an existing login. + * - Changing the username to an existing login. + * - Editing username to an empty one and a new password. + * + * If both the username and password matches an already existing login, we should not + * update it's password, but only it's usage timestamp and count. + */ +add_task(function* test_edit_password() { + let testCases = [{ + usernameInPage: "username", + passwordInPage: "password", + passwordChangedTo: "newPassword", + timesUsed: 1, + }, { + usernameInPage: "username", + usernameInPageExists: true, + passwordInPage: "password", + passwordInStorage: "oldPassword", + passwordChangedTo: "newPassword", + timesUsed: 2, + }, { + usernameInPage: "username", + usernameChangedTo: "newUsername", + usernameChangedToExists: true, + passwordInPage: "password", + passwordChangedTo: "newPassword", + timesUsed: 2, + }, { + usernameInPage: "username", + usernameChangedTo: "newUsername", + usernameChangedToExists: true, + passwordInPage: "password", + passwordChangedTo: "password", + timesUsed: 2, + checkPasswordNotUpdated: true, + }, { + usernameInPage: "newUsername", + usernameChangedTo: "", + usernameChangedToExists: true, + passwordInPage: "password", + passwordChangedTo: "newPassword", + timesUsed: 2, + }]; + + for (let testCase of testCases) { + info("Test case: " + JSON.stringify(testCase)); + + // Create the pre-existing logins when needed. + if (testCase.usernameInPageExists) { + Services.logins.addLogin(LoginTestUtils.testData.formLogin({ + hostname: "https://example.com", + formSubmitURL: "https://example.com", + username: testCase.usernameInPage, + password: testCase.passwordInStorage, + })); + } + + if (testCase.usernameChangedToExists) { + Services.logins.addLogin(LoginTestUtils.testData.formLogin({ + hostname: "https://example.com", + formSubmitURL: "https://example.com", + username: testCase.usernameChangedTo, + password: testCase.passwordChangedTo, + })); + } + + yield BrowserTestUtils.withNewTab({ + gBrowser, + url: "https://example.com/browser/toolkit/components/" + + "passwordmgr/test/browser/form_basic.html", + }, function* (browser) { + // Submit the form in the content page with the credentials from the test + // case. This will cause the doorhanger notification to be displayed. + let promiseShown = BrowserTestUtils.waitForEvent(PopupNotifications.panel, + "popupshown", + (event) => event.target == PopupNotifications.panel); + yield ContentTask.spawn(browser, testCase, + function* (contentTestCase) { + let doc = content.document; + doc.getElementById("form-basic-username").value = contentTestCase.usernameInPage; + doc.getElementById("form-basic-password").value = contentTestCase.passwordInPage; + doc.getElementById("form-basic").submit(); + }); + yield promiseShown; + let notificationElement = PopupNotifications.panel.childNodes[0]; + // Style flush to make sure binding is attached + notificationElement.querySelector("#password-notification-password").clientTop; + + // Modify the username in the dialog if requested. + if (testCase.usernameChangedTo) { + notificationElement.querySelector("#password-notification-username") + .value = testCase.usernameChangedTo; + } + + // Modify the password in the dialog if requested. + if (testCase.passwordChangedTo) { + notificationElement.querySelector("#password-notification-password") + .value = testCase.passwordChangedTo; + } + + // We expect a modifyLogin notification if the final username used by the + // dialog exists in the logins database, otherwise an addLogin one. + let expectModifyLogin = typeof testCase.usernameChangedTo !== "undefined" + ? testCase.usernameChangedToExists + : testCase.usernameInPageExists; + + // Simulate the action on the notification to request the login to be + // saved, and wait for the data to be updated or saved based on the type + // of operation we expect. + let expectedNotification = expectModifyLogin ? "modifyLogin" : "addLogin"; + let promiseLogin = TestUtils.topicObserved("passwordmgr-storage-changed", + (_, data) => data == expectedNotification); + notificationElement.button.doCommand(); + let [result] = yield promiseLogin; + + // Check that the values in the database match the expected values. + let login = expectModifyLogin ? result.QueryInterface(Ci.nsIArray) + .queryElementAt(1, Ci.nsILoginInfo) + : result.QueryInterface(Ci.nsILoginInfo); + + Assert.equal(login.username, testCase.usernameChangedTo || + testCase.usernameInPage); + Assert.equal(login.password, testCase.passwordChangedTo || + testCase.passwordInPage); + + let meta = login.QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(meta.timesUsed, testCase.timesUsed); + + // Check that the password was not updated if the user is empty + if (testCase.checkPasswordNotUpdated) { + Assert.ok(meta.timeLastUsed > meta.timeCreated); + Assert.ok(meta.timeCreated == meta.timePasswordChanged); + } + }); + + // Clean up the database before the next test case is executed. + Services.logins.removeAllLogins(); + } +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_notifications_username.js b/toolkit/components/passwordmgr/test/browser/browser_notifications_username.js new file mode 100644 index 000000000..2c9ea2607 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_notifications_username.js @@ -0,0 +1,119 @@ +/** + * Test changing the username inside the doorhanger notification for passwords. + * + * We have to test combination of existing and non-existing logins both for + * the original one from the webpage and the final one used by the dialog. + * + * We also check switching to and from empty usernames. + */ +add_task(function* test_edit_username() { + let testCases = [{ + usernameInPage: "username", + usernameChangedTo: "newUsername", + }, { + usernameInPage: "username", + usernameInPageExists: true, + usernameChangedTo: "newUsername", + }, { + usernameInPage: "username", + usernameChangedTo: "newUsername", + usernameChangedToExists: true, + }, { + usernameInPage: "username", + usernameInPageExists: true, + usernameChangedTo: "newUsername", + usernameChangedToExists: true, + }, { + usernameInPage: "", + usernameChangedTo: "newUsername", + }, { + usernameInPage: "newUsername", + usernameChangedTo: "", + }, { + usernameInPage: "", + usernameChangedTo: "newUsername", + usernameChangedToExists: true, + }, { + usernameInPage: "newUsername", + usernameChangedTo: "", + usernameChangedToExists: true, + }]; + + for (let testCase of testCases) { + info("Test case: " + JSON.stringify(testCase)); + + // Create the pre-existing logins when needed. + if (testCase.usernameInPageExists) { + Services.logins.addLogin(LoginTestUtils.testData.formLogin({ + hostname: "https://example.com", + formSubmitURL: "https://example.com", + username: testCase.usernameInPage, + password: "old password", + })); + } + + if (testCase.usernameChangedToExists) { + Services.logins.addLogin(LoginTestUtils.testData.formLogin({ + hostname: "https://example.com", + formSubmitURL: "https://example.com", + username: testCase.usernameChangedTo, + password: "old password", + })); + } + + yield BrowserTestUtils.withNewTab({ + gBrowser, + url: "https://example.com/browser/toolkit/components/" + + "passwordmgr/test/browser/form_basic.html", + }, function* (browser) { + // Submit the form in the content page with the credentials from the test + // case. This will cause the doorhanger notification to be displayed. + let promiseShown = BrowserTestUtils.waitForEvent(PopupNotifications.panel, + "popupshown", + (event) => event.target == PopupNotifications.panel); + yield ContentTask.spawn(browser, testCase.usernameInPage, + function* (usernameInPage) { + let doc = content.document; + doc.getElementById("form-basic-username").value = usernameInPage; + doc.getElementById("form-basic-password").value = "password"; + doc.getElementById("form-basic").submit(); + }); + yield promiseShown; + let notificationElement = PopupNotifications.panel.childNodes[0]; + // Style flush to make sure binding is attached + notificationElement.querySelector("#password-notification-password").clientTop; + + // Modify the username in the dialog if requested. + if (testCase.usernameChangedTo) { + notificationElement.querySelector("#password-notification-username") + .value = testCase.usernameChangedTo; + } + + // We expect a modifyLogin notification if the final username used by the + // dialog exists in the logins database, otherwise an addLogin one. + let expectModifyLogin = testCase.usernameChangedTo + ? testCase.usernameChangedToExists + : testCase.usernameInPageExists; + + // Simulate the action on the notification to request the login to be + // saved, and wait for the data to be updated or saved based on the type + // of operation we expect. + let expectedNotification = expectModifyLogin ? "modifyLogin" : "addLogin"; + let promiseLogin = TestUtils.topicObserved("passwordmgr-storage-changed", + (_, data) => data == expectedNotification); + notificationElement.button.doCommand(); + let [result] = yield promiseLogin; + + // Check that the values in the database match the expected values. + let login = expectModifyLogin ? result.QueryInterface(Ci.nsIArray) + .queryElementAt(1, Ci.nsILoginInfo) + : result.QueryInterface(Ci.nsILoginInfo); + Assert.equal(login.username, testCase.usernameChangedTo || + testCase.usernameInPage); + Assert.equal(login.password, "password"); + }); + + // Clean up the database before the next test case is executed. + Services.logins.removeAllLogins(); + } +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_passwordmgr_contextmenu.js b/toolkit/components/passwordmgr/test/browser/browser_passwordmgr_contextmenu.js new file mode 100644 index 000000000..ece2b731f --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_passwordmgr_contextmenu.js @@ -0,0 +1,100 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +function test() { + waitForExplicitFinish(); + + Services.logins.removeAllLogins(); + + // Add some initial logins + let urls = [ + "http://example.com/", + "http://mozilla.org/", + "http://spreadfirefox.com/", + "https://support.mozilla.org/", + "http://hg.mozilla.org/" + ]; + let nsLoginInfo = new Components.Constructor("@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, "init"); + let logins = [ + new nsLoginInfo(urls[0], urls[0], null, "", "o hai", "u1", "p1"), + new nsLoginInfo(urls[1], urls[1], null, "ehsan", "coded", "u2", "p2"), + new nsLoginInfo(urls[2], urls[2], null, "this", "awesome", "u3", "p3"), + new nsLoginInfo(urls[3], urls[3], null, "array of", "logins", "u4", "p4"), + new nsLoginInfo(urls[4], urls[4], null, "then", "i wrote the test", "u5", "p5") + ]; + logins.forEach(login => Services.logins.addLogin(login)); + + // Open the password manager dialog + const PWMGR_DLG = "chrome://passwordmgr/content/passwordManager.xul"; + let pwmgrdlg = window.openDialog(PWMGR_DLG, "Toolkit:PasswordManager", ""); + SimpleTest.waitForFocus(doTest, pwmgrdlg); + + // Test if "Copy Username" and "Copy Password" works + function doTest() { + let doc = pwmgrdlg.document; + let selection = doc.getElementById("signonsTree").view.selection; + let menuitem = doc.getElementById("context-copyusername"); + + function copyField() { + info("Select all"); + selection.selectAll(); + assertMenuitemEnabled("copyusername", false); + assertMenuitemEnabled("editusername", false); + assertMenuitemEnabled("copypassword", false); + assertMenuitemEnabled("editpassword", false); + + info("Select the first row (with an empty username)"); + selection.select(0); + assertMenuitemEnabled("copyusername", false, "empty username"); + assertMenuitemEnabled("editusername", true); + assertMenuitemEnabled("copypassword", true); + assertMenuitemEnabled("editpassword", false, "password column hidden"); + + info("Clear the selection"); + selection.clearSelection(); + assertMenuitemEnabled("copyusername", false); + assertMenuitemEnabled("editusername", false); + assertMenuitemEnabled("copypassword", false); + assertMenuitemEnabled("editpassword", false); + + info("Select the third row and making the password column visible"); + selection.select(2); + doc.getElementById("passwordCol").hidden = false; + assertMenuitemEnabled("copyusername", true); + assertMenuitemEnabled("editusername", true); + assertMenuitemEnabled("copypassword", true); + assertMenuitemEnabled("editpassword", true, "password column visible"); + menuitem.doCommand(); + } + + function assertMenuitemEnabled(idSuffix, expected, reason = "") { + doc.defaultView.UpdateContextMenu(); + let actual = !doc.getElementById("context-" + idSuffix).getAttribute("disabled"); + is(actual, expected, idSuffix + " should be " + (expected ? "enabled" : "disabled") + + (reason ? ": " + reason : "")); + } + + function cleanUp() { + Services.ww.registerNotification(function (aSubject, aTopic, aData) { + Services.ww.unregisterNotification(arguments.callee); + Services.logins.removeAllLogins(); + doc.getElementById("passwordCol").hidden = true; + finish(); + }); + pwmgrdlg.close(); + } + + function testPassword() { + info("Testing Copy Password"); + waitForClipboard("coded", function copyPassword() { + menuitem = doc.getElementById("context-copypassword"); + menuitem.doCommand(); + }, cleanUp, cleanUp); + } + + info("Testing Copy Username"); + waitForClipboard("ehsan", copyField, testPassword, testPassword); + } +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_passwordmgr_editing.js b/toolkit/components/passwordmgr/test/browser/browser_passwordmgr_editing.js new file mode 100644 index 000000000..2b2e42273 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_passwordmgr_editing.js @@ -0,0 +1,126 @@ +const { ContentTaskUtils } = Cu.import("resource://testing-common/ContentTaskUtils.jsm", {}); +const PWMGR_DLG = "chrome://passwordmgr/content/passwordManager.xul"; + +var doc; +var pwmgr; +var pwmgrdlg; +var signonsTree; + +function addLogin(site, username, password) { + let nsLoginInfo = new Components.Constructor("@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, "init"); + let login = new nsLoginInfo(site, site, null, username, password, "u", "p"); + Services.logins.addLogin(login); +} + +function getUsername(row) { + return signonsTree.view.getCellText(row, signonsTree.columns.getNamedColumn("userCol")); +} + +function getPassword(row) { + return signonsTree.view.getCellText(row, signonsTree.columns.getNamedColumn("passwordCol")); +} + +function synthesizeDblClickOnCell(aTree, column, row) { + let tbo = aTree.treeBoxObject; + let rect = tbo.getCoordsForCellItem(row, aTree.columns[column], "text"); + let x = rect.x + rect.width / 2; + let y = rect.y + rect.height / 2; + // Simulate the double click. + EventUtils.synthesizeMouse(aTree.body, x, y, { clickCount: 2 }, + aTree.ownerDocument.defaultView); +} + +function* togglePasswords() { + pwmgrdlg.document.querySelector("#togglePasswords").doCommand(); + yield new Promise(resolve => waitForFocus(resolve, pwmgrdlg)); +} + +function* editUsernamePromises(site, oldUsername, newUsername) { + is(Services.logins.findLogins({}, site, "", "").length, 1, "Correct login found"); + let login = Services.logins.findLogins({}, site, "", "")[0]; + is(login.username, oldUsername, "Correct username saved"); + is(getUsername(0), oldUsername, "Correct username shown"); + synthesizeDblClickOnCell(signonsTree, 1, 0); + yield ContentTaskUtils.waitForCondition(() => signonsTree.getAttribute("editing"), + "Waiting for editing"); + + EventUtils.sendString(newUsername, pwmgrdlg); + let signonsIntro = doc.querySelector("#signonsIntro"); + EventUtils.sendMouseEvent({type: "click"}, signonsIntro, pwmgrdlg); + yield ContentTaskUtils.waitForCondition(() => !signonsTree.getAttribute("editing"), + "Waiting for editing to stop"); + + is(Services.logins.findLogins({}, site, "", "").length, 1, "Correct login replaced"); + login = Services.logins.findLogins({}, site, "", "")[0]; + is(login.username, newUsername, "Correct username updated"); + is(getUsername(0), newUsername, "Correct username shown after the update"); +} + +function* editPasswordPromises(site, oldPassword, newPassword) { + is(Services.logins.findLogins({}, site, "", "").length, 1, "Correct login found"); + let login = Services.logins.findLogins({}, site, "", "")[0]; + is(login.password, oldPassword, "Correct password saved"); + is(getPassword(0), oldPassword, "Correct password shown"); + + synthesizeDblClickOnCell(signonsTree, 2, 0); + yield ContentTaskUtils.waitForCondition(() => signonsTree.getAttribute("editing"), + "Waiting for editing"); + + EventUtils.sendString(newPassword, pwmgrdlg); + let signonsIntro = doc.querySelector("#signonsIntro"); + EventUtils.sendMouseEvent({type: "click"}, signonsIntro, pwmgrdlg); + yield ContentTaskUtils.waitForCondition(() => !signonsTree.getAttribute("editing"), + "Waiting for editing to stop"); + + is(Services.logins.findLogins({}, site, "", "").length, 1, "Correct login replaced"); + login = Services.logins.findLogins({}, site, "", "")[0]; + is(login.password, newPassword, "Correct password updated"); + is(getPassword(0), newPassword, "Correct password shown after the update"); +} + +add_task(function* test_setup() { + registerCleanupFunction(function() { + Services.logins.removeAllLogins(); + }); + + Services.logins.removeAllLogins(); + // Open the password manager dialog. + pwmgrdlg = window.openDialog(PWMGR_DLG, "Toolkit:PasswordManager", ""); + + Services.ww.registerNotification(function (aSubject, aTopic, aData) { + if (aTopic == "domwindowopened") { + let win = aSubject.QueryInterface(Ci.nsIDOMEventTarget); + SimpleTest.waitForFocus(function() { + EventUtils.sendKey("RETURN", win); + }, win); + } else if (aSubject.location == pwmgrdlg.location && aTopic == "domwindowclosed") { + // Unregister ourself. + Services.ww.unregisterNotification(arguments.callee); + } + }); + + yield new Promise((resolve) => { + SimpleTest.waitForFocus(() => { + doc = pwmgrdlg.document; + signonsTree = doc.querySelector("#signonsTree"); + resolve(); + }, pwmgrdlg); + }); +}); + +add_task(function* test_edit_multiple_logins() { + function* testLoginChange(site, oldUsername, oldPassword, newUsername, newPassword) { + addLogin(site, oldUsername, oldPassword); + yield* editUsernamePromises(site, oldUsername, newUsername); + yield* togglePasswords(); + yield* editPasswordPromises(site, oldPassword, newPassword); + yield* togglePasswords(); + } + + yield* testLoginChange("http://c.tn/", "userC", "passC", "usernameC", "passwordC"); + yield* testLoginChange("http://b.tn/", "userB", "passB", "usernameB", "passwordB"); + yield* testLoginChange("http://a.tn/", "userA", "passA", "usernameA", "passwordA"); + + pwmgrdlg.close(); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_passwordmgr_fields.js b/toolkit/components/passwordmgr/test/browser/browser_passwordmgr_fields.js new file mode 100644 index 000000000..95bcee9ed --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_passwordmgr_fields.js @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +function test() { + waitForExplicitFinish(); + + let pwmgr = Cc["@mozilla.org/login-manager;1"]. + getService(Ci.nsILoginManager); + pwmgr.removeAllLogins(); + + // add login data + let nsLoginInfo = new Components.Constructor("@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, "init"); + let login = new nsLoginInfo("http://example.com/", "http://example.com/", null, + "user", "password", "u1", "p1"); + pwmgr.addLogin(login); + + // Open the password manager dialog + const PWMGR_DLG = "chrome://passwordmgr/content/passwordManager.xul"; + let pwmgrdlg = window.openDialog(PWMGR_DLG, "Toolkit:PasswordManager", ""); + SimpleTest.waitForFocus(doTest, pwmgrdlg); + + function doTest() { + let doc = pwmgrdlg.document; + + let signonsTree = doc.querySelector("#signonsTree"); + is(signonsTree.view.rowCount, 1, "One entry in the passwords list"); + + is(signonsTree.view.getCellText(0, signonsTree.columns.getNamedColumn("siteCol")), + "http://example.com/", + "Correct website saved"); + + is(signonsTree.view.getCellText(0, signonsTree.columns.getNamedColumn("userCol")), + "user", + "Correct user saved"); + + let timeCreatedCol = doc.getElementById("timeCreatedCol"); + is(timeCreatedCol.getAttribute("hidden"), "true", + "Time created column is not displayed"); + + + let timeLastUsedCol = doc.getElementById("timeLastUsedCol"); + is(timeLastUsedCol.getAttribute("hidden"), "true", + "Last Used column is not displayed"); + + let timePasswordChangedCol = doc.getElementById("timePasswordChangedCol"); + is(timePasswordChangedCol.getAttribute("hidden"), "", + "Last Changed column is displayed"); + + // cleanup + Services.ww.registerNotification(function (aSubject, aTopic, aData) { + if (aSubject.location == pwmgrdlg.location && aTopic == "domwindowclosed") { + // unregister ourself + Services.ww.unregisterNotification(arguments.callee); + + pwmgr.removeAllLogins(); + + finish(); + } + }); + + pwmgrdlg.close(); + } +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_passwordmgr_observers.js b/toolkit/components/passwordmgr/test/browser/browser_passwordmgr_observers.js new file mode 100644 index 000000000..1dc7076aa --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_passwordmgr_observers.js @@ -0,0 +1,129 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function test() { + waitForExplicitFinish(); + + const LOGIN_HOST = "http://example.com"; + const LOGIN_COUNT = 5; + + let nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", Ci.nsILoginInfo, "init"); + let pmDialog = window.openDialog( + "chrome://passwordmgr/content/passwordManager.xul", + "Toolkit:PasswordManager", ""); + + let logins = []; + let loginCounter = 0; + let loginOrder = null; + let modifiedLogin; + let testNumber = 0; + let testObserver = { + observe: function (subject, topic, data) { + if (topic == "passwordmgr-dialog-updated") { + switch (testNumber) { + case 1: + case 2: + case 3: + case 4: + case 5: + is(countLogins(), loginCounter, "Verify login added"); + ok(getLoginOrder().startsWith(loginOrder), "Verify login order"); + runNextTest(); + break; + case 6: + is(countLogins(), loginCounter, "Verify login count"); + is(getLoginOrder(), loginOrder, "Verify login order"); + is(getLoginPassword(), "newpassword0", "Verify login modified"); + runNextTest(); + break; + case 7: + is(countLogins(), loginCounter, "Verify login removed"); + ok(loginOrder.endsWith(getLoginOrder()), "Verify login order"); + runNextTest(); + break; + case 8: + is(countLogins(), 0, "Verify all logins removed"); + runNextTest(); + break; + } + } + } + }; + + SimpleTest.waitForFocus(startTest, pmDialog); + + function createLogins() { + let login; + for (let i = 0; i < LOGIN_COUNT; i++) { + login = new nsLoginInfo(LOGIN_HOST + "?n=" + i, LOGIN_HOST + "?n=" + i, + null, "user" + i, "password" + i, "u" + i, "p" + i); + logins.push(login); + } + modifiedLogin = new nsLoginInfo(LOGIN_HOST + "?n=0", LOGIN_HOST + "?n=0", + null, "user0", "newpassword0", "u0", "p0"); + is(logins.length, LOGIN_COUNT, "Verify logins created"); + } + + function countLogins() { + let doc = pmDialog.document; + let signonsTree = doc.getElementById("signonsTree"); + return signonsTree.view.rowCount; + } + + function getLoginOrder() { + let doc = pmDialog.document; + let signonsTree = doc.getElementById("signonsTree"); + let column = signonsTree.columns[0]; // host column + let order = []; + for (let i = 0; i < signonsTree.view.rowCount; i++) { + order.push(signonsTree.view.getCellText(i, column)); + } + return order.join(','); + } + + function getLoginPassword() { + let doc = pmDialog.document; + let loginsTree = doc.getElementById("signonsTree"); + let column = loginsTree.columns[2]; // password column + return loginsTree.view.getCellText(0, column); + } + + function startTest() { + Services.obs.addObserver( + testObserver, "passwordmgr-dialog-updated", false); + is(countLogins(), 0, "Verify starts with 0 logins"); + createLogins(); + runNextTest(); + } + + function runNextTest() { + switch (++testNumber) { + case 1: // add the logins + for (let i = 0; i < logins.length; i++) { + loginCounter++; + loginOrder = getLoginOrder(); + Services.logins.addLogin(logins[i]); + } + break; + case 6: // modify a login + loginOrder = getLoginOrder(); + Services.logins.modifyLogin(logins[0], modifiedLogin); + break; + case 7: // remove a login + loginCounter--; + loginOrder = getLoginOrder(); + Services.logins.removeLogin(modifiedLogin); + break; + case 8: // remove all logins + Services.logins.removeAllLogins(); + break; + case 9: // finish + Services.obs.removeObserver( + testObserver, "passwordmgr-dialog-updated", false); + pmDialog.close(); + finish(); + break; + } + } +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_passwordmgr_sort.js b/toolkit/components/passwordmgr/test/browser/browser_passwordmgr_sort.js new file mode 100644 index 000000000..83272a9c4 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_passwordmgr_sort.js @@ -0,0 +1,208 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +function test() { + waitForExplicitFinish(); + + let pwmgr = Cc["@mozilla.org/login-manager;1"]. + getService(Ci.nsILoginManager); + pwmgr.removeAllLogins(); + + // Add some initial logins + let urls = [ + "http://example.com/", + "http://example.org/", + "http://mozilla.com/", + "http://mozilla.org/", + "http://spreadfirefox.com/", + "http://planet.mozilla.org/", + "https://developer.mozilla.org/", + "http://hg.mozilla.org/", + "http://dxr.mozilla.org/", + "http://feeds.mozilla.org/", + ]; + let users = [ + "user", + "username", + "ehsan", + "ehsan", + "john", + "what?", + "really?", + "you sure?", + "my user name", + "my username", + ]; + let pwds = [ + "password", + "password", + "mypass", + "mypass", + "smith", + "very secret", + "super secret", + "absolutely", + "mozilla", + "mozilla.com", + ]; + let nsLoginInfo = new Components.Constructor("@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, "init"); + for (let i = 0; i < 10; i++) + pwmgr.addLogin(new nsLoginInfo(urls[i], urls[i], null, users[i], pwds[i], + "u" + (i + 1), "p" + (i + 1))); + + // Open the password manager dialog + const PWMGR_DLG = "chrome://passwordmgr/content/passwordManager.xul"; + let pwmgrdlg = window.openDialog(PWMGR_DLG, "Toolkit:PasswordManager", ""); + SimpleTest.waitForFocus(doTest, pwmgrdlg); + + // the meat of the test + function doTest() { + let doc = pwmgrdlg.document; + let win = doc.defaultView; + let sTree = doc.getElementById("signonsTree"); + let filter = doc.getElementById("filter"); + let siteCol = doc.getElementById("siteCol"); + let userCol = doc.getElementById("userCol"); + let passwordCol = doc.getElementById("passwordCol"); + + let toggleCalls = 0; + function toggleShowPasswords(func) { + let toggleButton = doc.getElementById("togglePasswords"); + let showMode = (toggleCalls++ % 2) == 0; + + // only watch for a confirmation dialog every other time being called + if (showMode) { + Services.ww.registerNotification(function (aSubject, aTopic, aData) { + if (aTopic == "domwindowclosed") + Services.ww.unregisterNotification(arguments.callee); + else if (aTopic == "domwindowopened") { + let targetWin = aSubject.QueryInterface(Ci.nsIDOMEventTarget); + SimpleTest.waitForFocus(function() { + EventUtils.sendKey("RETURN", targetWin); + }, targetWin); + } + }); + } + + Services.obs.addObserver(function (aSubject, aTopic, aData) { + if (aTopic == "passwordmgr-password-toggle-complete") { + Services.obs.removeObserver(arguments.callee, aTopic); + func(); + } + }, "passwordmgr-password-toggle-complete", false); + + EventUtils.synthesizeMouse(toggleButton, 1, 1, {}, win); + } + + function clickCol(col) { + EventUtils.synthesizeMouse(col, 20, 1, {}, win); + setTimeout(runNextTest, 0); + } + + function setFilter(string) { + filter.value = string; + filter.doCommand(); + setTimeout(runNextTest, 0); + } + + function checkSortMarkers(activeCol) { + let isOk = true; + let col = null; + let hasAttr = false; + let treecols = activeCol.parentNode; + for (let i = 0; i < treecols.childNodes.length; i++) { + col = treecols.childNodes[i]; + if (col.nodeName != "treecol") + continue; + hasAttr = col.hasAttribute("sortDirection"); + isOk &= col == activeCol ? hasAttr : !hasAttr; + } + ok(isOk, "Only " + activeCol.id + " has a sort marker"); + } + + function checkSortDirection(col, ascending) { + checkSortMarkers(col); + let direction = ascending ? "ascending" : "descending"; + is(col.getAttribute("sortDirection"), direction, + col.id + ": sort direction is " + direction); + } + + function checkColumnEntries(aCol, expectedValues) { + let actualValues = getColumnEntries(aCol); + is(actualValues.length, expectedValues.length, "Checking length of expected column"); + for (let i = 0; i < expectedValues.length; i++) + is(actualValues[i], expectedValues[i], "Checking column entry #" + i); + } + + function getColumnEntries(aCol) { + let entries = []; + let column = sTree.columns[aCol]; + let numRows = sTree.view.rowCount; + for (let i = 0; i < numRows; i++) + entries.push(sTree.view.getCellText(i, column)); + return entries; + } + + let testCounter = 0; + let expectedValues; + function runNextTest() { + switch (testCounter++) { + case 0: + expectedValues = urls.slice().sort(); + checkColumnEntries(0, expectedValues); + checkSortDirection(siteCol, true); + // Toggle sort direction on Host column + clickCol(siteCol); + break; + case 1: + expectedValues.reverse(); + checkColumnEntries(0, expectedValues); + checkSortDirection(siteCol, false); + // Sort by Username + clickCol(userCol); + break; + case 2: + expectedValues = users.slice().sort(); + checkColumnEntries(1, expectedValues); + checkSortDirection(userCol, true); + // Sort by Password + clickCol(passwordCol); + break; + case 3: + expectedValues = pwds.slice().sort(); + checkColumnEntries(2, expectedValues); + checkSortDirection(passwordCol, true); + // Set filter + setFilter("moz"); + break; + case 4: + expectedValues = [ "absolutely", "mozilla", "mozilla.com", + "mypass", "mypass", "super secret", + "very secret" ]; + checkColumnEntries(2, expectedValues); + checkSortDirection(passwordCol, true); + // Reset filter + setFilter(""); + break; + case 5: + expectedValues = pwds.slice().sort(); + checkColumnEntries(2, expectedValues); + checkSortDirection(passwordCol, true); + // cleanup + Services.ww.registerNotification(function (aSubject, aTopic, aData) { + // unregister ourself + Services.ww.unregisterNotification(arguments.callee); + + pwmgr.removeAllLogins(); + finish(); + }); + pwmgrdlg.close(); + } + } + + // Toggle Show Passwords to display Password column, then start tests + toggleShowPasswords(runNextTest); + } +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_passwordmgr_switchtab.js b/toolkit/components/passwordmgr/test/browser/browser_passwordmgr_switchtab.js new file mode 100644 index 000000000..bd4f265b5 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_passwordmgr_switchtab.js @@ -0,0 +1,42 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const PROMPT_URL = "chrome://global/content/commonDialog.xul"; +var { interfaces: Ci } = Components; + +function test() { + waitForExplicitFinish(); + + let tab = gBrowser.addTab(); + isnot(tab, gBrowser.selectedTab, "New tab shouldn't be selected"); + + let listener = { + onOpenWindow: function(window) { + var domwindow = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + waitForFocus(() => { + is(domwindow.document.location.href, PROMPT_URL, "Should have seen a prompt window"); + is(domwindow.args.promptType, "promptUserAndPass", "Should be an authenticate prompt"); + + is(gBrowser.selectedTab, tab, "Should have selected the new tab"); + + domwindow.document.documentElement.cancelDialog(); + }, domwindow); + }, + + onCloseWindow: function() { + } + }; + + Services.wm.addListener(listener); + registerCleanupFunction(() => { + Services.wm.removeListener(listener); + gBrowser.removeTab(tab); + }); + + tab.linkedBrowser.addEventListener("load", () => { + finish(); + }, true); + tab.linkedBrowser.loadURI("http://example.com/browser/toolkit/components/passwordmgr/test/browser/authenticate.sjs"); +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_passwordmgrdlg.js b/toolkit/components/passwordmgr/test/browser/browser_passwordmgrdlg.js new file mode 100644 index 000000000..57cfa9f83 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_passwordmgrdlg.js @@ -0,0 +1,192 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +function test() { + waitForExplicitFinish(); + + let pwmgr = Cc["@mozilla.org/login-manager;1"]. + getService(Ci.nsILoginManager); + pwmgr.removeAllLogins(); + + // Add some initial logins + let urls = [ + "http://example.com/", + "http://example.org/", + "http://mozilla.com/", + "http://mozilla.org/", + "http://spreadfirefox.com/", + "http://planet.mozilla.org/", + "https://developer.mozilla.org/", + "http://hg.mozilla.org/", + "http://dxr.mozilla.org/", + "http://feeds.mozilla.org/", + ]; + let nsLoginInfo = new Components.Constructor("@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, "init"); + let logins = [ + new nsLoginInfo(urls[0], urls[0], null, "user", "password", "u1", "p1"), + new nsLoginInfo(urls[1], urls[1], null, "username", "password", "u2", "p2"), + new nsLoginInfo(urls[2], urls[2], null, "ehsan", "mypass", "u3", "p3"), + new nsLoginInfo(urls[3], urls[3], null, "ehsan", "mypass", "u4", "p4"), + new nsLoginInfo(urls[4], urls[4], null, "john", "smith", "u5", "p5"), + new nsLoginInfo(urls[5], urls[5], null, "what?", "very secret", "u6", "p6"), + new nsLoginInfo(urls[6], urls[6], null, "really?", "super secret", "u7", "p7"), + new nsLoginInfo(urls[7], urls[7], null, "you sure?", "absolutely", "u8", "p8"), + new nsLoginInfo(urls[8], urls[8], null, "my user name", "mozilla", "u9", "p9"), + new nsLoginInfo(urls[9], urls[9], null, "my username", "mozilla.com", "u10", "p10"), + ]; + logins.forEach(login => pwmgr.addLogin(login)); + + // Open the password manager dialog + const PWMGR_DLG = "chrome://passwordmgr/content/passwordManager.xul"; + let pwmgrdlg = window.openDialog(PWMGR_DLG, "Toolkit:PasswordManager", ""); + SimpleTest.waitForFocus(doTest, pwmgrdlg); + + // the meat of the test + function doTest() { + let doc = pwmgrdlg.document; + let win = doc.defaultView; + let filter = doc.getElementById("filter"); + let tree = doc.getElementById("signonsTree"); + let view = tree.view; + + is(filter.value, "", "Filter box should initially be empty"); + is(view.rowCount, 10, "There should be 10 passwords initially"); + + // Prepare a set of tests + // filter: the text entered in the filter search box + // count: the number of logins which should match the respective filter + // count2: the number of logins which should match the respective filter + // if the passwords are being shown as well + // Note: if a test doesn't have count2 set, count is used instead. + let tests = [ + {filter: "pass", count: 0, count2: 4}, + {filter: "", count: 10}, // test clearing the filter + {filter: "moz", count: 7}, + {filter: "mozi", count: 7}, + {filter: "mozil", count: 7}, + {filter: "mozill", count: 7}, + {filter: "mozilla", count: 7}, + {filter: "mozilla.com", count: 1, count2: 2}, + {filter: "user", count: 4}, + {filter: "user ", count: 1}, + {filter: " user", count: 2}, + {filter: "http", count: 10}, + {filter: "https", count: 1}, + {filter: "secret", count: 0, count2: 2}, + {filter: "secret!", count: 0}, + ]; + + let toggleCalls = 0; + function toggleShowPasswords(func) { + let toggleButton = doc.getElementById("togglePasswords"); + let showMode = (toggleCalls++ % 2) == 0; + + // only watch for a confirmation dialog every other time being called + if (showMode) { + Services.ww.registerNotification(function (aSubject, aTopic, aData) { + if (aTopic == "domwindowclosed") + Services.ww.unregisterNotification(arguments.callee); + else if (aTopic == "domwindowopened") { + let targetWin = aSubject.QueryInterface(Ci.nsIDOMEventTarget); + SimpleTest.waitForFocus(function() { + EventUtils.sendKey("RETURN", targetWin); + }, targetWin); + } + }); + } + + Services.obs.addObserver(function (aSubject, aTopic, aData) { + if (aTopic == "passwordmgr-password-toggle-complete") { + Services.obs.removeObserver(arguments.callee, aTopic); + func(); + } + }, "passwordmgr-password-toggle-complete", false); + + EventUtils.synthesizeMouse(toggleButton, 1, 1, {}, win); + } + + function runTests(mode, endFunction) { + let testCounter = 0; + + function setFilter(string) { + filter.value = string; + filter.doCommand(); + } + + function runOneTest(testCase) { + function tester() { + is(view.rowCount, expected, expected + " logins should match '" + testCase.filter + "'"); + } + + let expected; + switch (mode) { + case 1: // without showing passwords + expected = testCase.count; + break; + case 2: // showing passwords + expected = ("count2" in testCase) ? testCase.count2 : testCase.count; + break; + case 3: // toggle + expected = testCase.count; + tester(); + toggleShowPasswords(function () { + expected = ("count2" in testCase) ? testCase.count2 : testCase.count; + tester(); + toggleShowPasswords(proceed); + }); + return; + } + tester(); + proceed(); + } + + function proceed() { + // run the next test if necessary or proceed with the tests + if (testCounter != tests.length) + runNextTest(); + else + endFunction(); + } + + function runNextTest() { + let testCase = tests[testCounter++]; + setFilter(testCase.filter); + setTimeout(runOneTest, 0, testCase); + } + + runNextTest(); + } + + function step1() { + runTests(1, step2); + } + + function step2() { + toggleShowPasswords(function() { + runTests(2, step3); + }); + } + + function step3() { + toggleShowPasswords(function() { + runTests(3, lastStep); + }); + } + + function lastStep() { + // cleanup + Services.ww.registerNotification(function (aSubject, aTopic, aData) { + // unregister ourself + Services.ww.unregisterNotification(arguments.callee); + + pwmgr.removeAllLogins(); + finish(); + }); + pwmgrdlg.close(); + } + + step1(); + } +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_username_select_dialog.js b/toolkit/components/passwordmgr/test/browser/browser_username_select_dialog.js new file mode 100644 index 000000000..8df89b510 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_username_select_dialog.js @@ -0,0 +1,144 @@ +/* + * Test username selection dialog, on password update from a p-only form, + * when there are multiple saved logins on the domain. + */ + +// Copied from prompt_common.js. TODO: share the code. +function getSelectDialogDoc() { + // Trudge through all the open windows, until we find the one + // that has selectDialog.xul loaded. + var wm = Cc["@mozilla.org/appshell/window-mediator;1"]. + getService(Ci.nsIWindowMediator); + // var enumerator = wm.getEnumerator("navigator:browser"); + var enumerator = wm.getXULWindowEnumerator(null); + + while (enumerator.hasMoreElements()) { + var win = enumerator.getNext(); + var windowDocShell = win.QueryInterface(Ci.nsIXULWindow).docShell; + + var containedDocShells = windowDocShell.getDocShellEnumerator( + Ci.nsIDocShellTreeItem.typeChrome, + Ci.nsIDocShell.ENUMERATE_FORWARDS); + while (containedDocShells.hasMoreElements()) { + // Get the corresponding document for this docshell + var childDocShell = containedDocShells.getNext(); + // We don't want it if it's not done loading. + if (childDocShell.busyFlags != Ci.nsIDocShell.BUSY_FLAGS_NONE) + continue; + var childDoc = childDocShell.QueryInterface(Ci.nsIDocShell) + .contentViewer + .DOMDocument; + + if (childDoc.location.href == "chrome://global/content/selectDialog.xul") + return childDoc; + } + } + + return null; +} + +let nsLoginInfo = new Components.Constructor("@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, "init"); +let login1 = new nsLoginInfo("http://example.com", "http://example.com", null, + "notifyu1", "notifyp1", "user", "pass"); +let login1B = new nsLoginInfo("http://example.com", "http://example.com", null, + "notifyu1B", "notifyp1B", "user", "pass"); + +add_task(function* test_changeUPLoginOnPUpdateForm_accept() { + info("Select an u+p login from multiple logins, on password update form, and accept."); + Services.logins.addLogin(login1); + Services.logins.addLogin(login1B); + + yield testSubmittingLoginForm("subtst_notifications_change_p.html", function*(fieldValues) { + is(fieldValues.username, "null", "Checking submitted username"); + is(fieldValues.password, "pass2", "Checking submitted password"); + + yield ContentTaskUtils.waitForCondition(() => { + return getSelectDialogDoc(); + }, "Wait for selection dialog to be accessible."); + + let doc = getSelectDialogDoc(); + let dialog = doc.getElementsByTagName("dialog")[0]; + let listbox = doc.getElementById("list"); + + is(listbox.selectedIndex, 0, "Checking selected index"); + is(listbox.itemCount, 2, "Checking selected length"); + ['notifyu1', 'notifyu1B'].forEach((username, i) => { + is(listbox.getItemAtIndex(i).label, username, "Check username selection on dialog"); + }); + + dialog.acceptDialog(); + + yield ContentTaskUtils.waitForCondition(() => { + return !getSelectDialogDoc(); + }, "Wait for selection dialog to disappear."); + }); + + let logins = Services.logins.getAllLogins(); + is(logins.length, 2, "Should have 2 logins"); + + let login = SpecialPowers.wrap(logins[0]).QueryInterface(Ci.nsILoginMetaInfo); + is(login.username, "notifyu1", "Check the username unchanged"); + is(login.password, "pass2", "Check the password changed"); + is(login.timesUsed, 2, "Check times used"); + + login = SpecialPowers.wrap(logins[1]).QueryInterface(Ci.nsILoginMetaInfo); + is(login.username, "notifyu1B", "Check the username unchanged"); + is(login.password, "notifyp1B", "Check the password unchanged"); + is(login.timesUsed, 1, "Check times used"); + + // cleanup + login1.password = "pass2"; + Services.logins.removeLogin(login1); + login1.password = "notifyp1"; + + Services.logins.removeLogin(login1B); +}); + +add_task(function* test_changeUPLoginOnPUpdateForm_cancel() { + info("Select an u+p login from multiple logins, on password update form, and cancel."); + Services.logins.addLogin(login1); + Services.logins.addLogin(login1B); + + yield testSubmittingLoginForm("subtst_notifications_change_p.html", function*(fieldValues) { + is(fieldValues.username, "null", "Checking submitted username"); + is(fieldValues.password, "pass2", "Checking submitted password"); + + yield ContentTaskUtils.waitForCondition(() => { + return getSelectDialogDoc(); + }, "Wait for selection dialog to be accessible."); + + let doc = getSelectDialogDoc(); + let dialog = doc.getElementsByTagName("dialog")[0]; + let listbox = doc.getElementById("list"); + + is(listbox.selectedIndex, 0, "Checking selected index"); + is(listbox.itemCount, 2, "Checking selected length"); + ['notifyu1', 'notifyu1B'].forEach((username, i) => { + is(listbox.getItemAtIndex(i).label, username, "Check username selection on dialog"); + }); + + dialog.cancelDialog(); + + yield ContentTaskUtils.waitForCondition(() => { + return !getSelectDialogDoc(); + }, "Wait for selection dialog to disappear."); + }); + + let logins = Services.logins.getAllLogins(); + is(logins.length, 2, "Should have 2 logins"); + + let login = SpecialPowers.wrap(logins[0]).QueryInterface(Ci.nsILoginMetaInfo); + is(login.username, "notifyu1", "Check the username unchanged"); + is(login.password, "notifyp1", "Check the password unchanged"); + is(login.timesUsed, 1, "Check times used"); + + login = SpecialPowers.wrap(logins[1]).QueryInterface(Ci.nsILoginMetaInfo); + is(login.username, "notifyu1B", "Check the username unchanged"); + is(login.password, "notifyp1B", "Check the password unchanged"); + is(login.timesUsed, 1, "Check times used"); + + // cleanup + Services.logins.removeLogin(login1); + Services.logins.removeLogin(login1B); +}); diff --git a/toolkit/components/passwordmgr/test/browser/form_autofocus_js.html b/toolkit/components/passwordmgr/test/browser/form_autofocus_js.html new file mode 100644 index 000000000..76056e375 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/form_autofocus_js.html @@ -0,0 +1,10 @@ +<!DOCTYPE html><html><head><meta charset="utf-8"></head> +<body onload="document.getElementById('form-basic-username').focus();"> +<!-- Username field is focused by js onload --> +<form id="form-basic"> + <input id="form-basic-username" name="username"> + <input id="form-basic-password" name="password" type="password"> + <input id="form-basic-submit" type="submit"> +</form> + +</body></html> diff --git a/toolkit/components/passwordmgr/test/browser/form_basic.html b/toolkit/components/passwordmgr/test/browser/form_basic.html new file mode 100644 index 000000000..df2083a93 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/form_basic.html @@ -0,0 +1,12 @@ +<!DOCTYPE html><html><head><meta charset="utf-8"></head><body> +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + +<!-- Simplest form with username and password fields. --> +<form id="form-basic"> + <input id="form-basic-username" name="username"> + <input id="form-basic-password" name="password" type="password"> + <input id="form-basic-submit" type="submit"> +</form> + +</body></html> diff --git a/toolkit/components/passwordmgr/test/browser/form_basic_iframe.html b/toolkit/components/passwordmgr/test/browser/form_basic_iframe.html new file mode 100644 index 000000000..616f56947 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/form_basic_iframe.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html> + +<head> + <meta charset="utf-8"> +</head> + +<body> + <!-- Form in an iframe --> + <iframe src="https://example.org/browser/toolkit/components/passwordmgr/test/browser/form_basic.html" id="test-iframe"></iframe> +</body> + +</html> diff --git a/toolkit/components/passwordmgr/test/browser/form_cross_origin_insecure_action.html b/toolkit/components/passwordmgr/test/browser/form_cross_origin_insecure_action.html new file mode 100644 index 000000000..e8aa8b215 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/form_cross_origin_insecure_action.html @@ -0,0 +1,12 @@ +<!DOCTYPE html><html><head><meta charset="utf-8"></head><body> +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + +<!-- Simplest form with username and password fields. --> +<form id="form-basic" action="http://another.domain/custom_action.html"> + <input id="form-basic-username" name="username"> + <input id="form-basic-password" name="password" type="password"> + <input id="form-basic-submit" type="submit"> +</form> + +</body></html> diff --git a/toolkit/components/passwordmgr/test/browser/form_cross_origin_secure_action.html b/toolkit/components/passwordmgr/test/browser/form_cross_origin_secure_action.html new file mode 100644 index 000000000..892a9f6f6 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/form_cross_origin_secure_action.html @@ -0,0 +1,12 @@ +<!DOCTYPE html><html><head><meta charset="utf-8"></head><body> +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + +<!-- Simplest form with username and password fields. --> +<form id="form-basic" action="https://another.domain/custom_action.html"> + <input id="form-basic-username" name="username"> + <input id="form-basic-password" name="password" type="password"> + <input id="form-basic-submit" type="submit"> +</form> + +</body></html> diff --git a/toolkit/components/passwordmgr/test/browser/form_same_origin_action.html b/toolkit/components/passwordmgr/test/browser/form_same_origin_action.html new file mode 100644 index 000000000..8f0c9a14e --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/form_same_origin_action.html @@ -0,0 +1,12 @@ +<!DOCTYPE html><html><head><meta charset="utf-8"></head><body> +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + +<!-- Simplest form with username and password fields. --> +<form id="form-basic" action="./custom_action.html"> + <input id="form-basic-username" name="username"> + <input id="form-basic-password" name="password" type="password"> + <input id="form-basic-submit" type="submit"> +</form> + +</body></html> diff --git a/toolkit/components/passwordmgr/test/browser/formless_basic.html b/toolkit/components/passwordmgr/test/browser/formless_basic.html new file mode 100644 index 000000000..2f4c5de52 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/formless_basic.html @@ -0,0 +1,18 @@ +<!DOCTYPE html><html><head><meta charset="utf-8"></head><body> + +<!-- Simplest form with username and password fields. --> + <input id="form-basic-username" name="username"> + <input id="form-basic-password" name="password" type="password"> + <input id="form-basic-submit" type="submit"> + + <button id="add">Add input[type=password]</button> + + <script> + document.getElementById("add").addEventListener("click", function () { + var node = document.createElement("input"); + node.setAttribute("type", "password"); + document.querySelector("body").appendChild(node); + }); + </script> + +</body></html> diff --git a/toolkit/components/passwordmgr/test/browser/head.js b/toolkit/components/passwordmgr/test/browser/head.js new file mode 100644 index 000000000..926cb6616 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/head.js @@ -0,0 +1,137 @@ +const DIRECTORY_PATH = "/browser/toolkit/components/passwordmgr/test/browser/"; + +Cu.import("resource://testing-common/LoginTestUtils.jsm", this); +Cu.import("resource://testing-common/ContentTaskUtils.jsm", this); + +registerCleanupFunction(function* cleanup_removeAllLoginsAndResetRecipes() { + Services.logins.removeAllLogins(); + + let recipeParent = LoginTestUtils.recipes.getRecipeParent(); + if (!recipeParent) { + // No need to reset the recipes if the recipe module wasn't even loaded. + return; + } + yield recipeParent.then(recipeParentResult => recipeParentResult.reset()); +}); + +/** + * Loads a test page in `DIRECTORY_URL` which automatically submits to formsubmit.sjs and returns a + * promise resolving with the field values when the optional `aTaskFn` is done. + * + * @param {String} aPageFile - test page file name which auto-submits to formsubmit.sjs + * @param {Function} aTaskFn - task which can be run before the tab closes. + * @param {String} [aOrigin="http://example.com"] - origin of the server to use + * to load `aPageFile`. + */ +function testSubmittingLoginForm(aPageFile, aTaskFn, aOrigin = "http://example.com") { + return BrowserTestUtils.withNewTab({ + gBrowser, + url: aOrigin + DIRECTORY_PATH + aPageFile, + }, function*(browser) { + ok(true, "loaded " + aPageFile); + let fieldValues = yield ContentTask.spawn(browser, undefined, function*() { + yield ContentTaskUtils.waitForCondition(() => { + return content.location.pathname.endsWith("/formsubmit.sjs") && + content.document.readyState == "complete"; + }, "Wait for form submission load (formsubmit.sjs)"); + let username = content.document.getElementById("user").textContent; + let password = content.document.getElementById("pass").textContent; + return { + username, + password, + }; + }); + ok(true, "form submission loaded"); + if (aTaskFn) { + yield* aTaskFn(fieldValues); + } + return fieldValues; + }); +} + +function checkOnlyLoginWasUsedTwice({ justChanged }) { + // Check to make sure we updated the timestamps and use count on the + // existing login that was submitted for the test. + let logins = Services.logins.getAllLogins(); + is(logins.length, 1, "Should only have 1 login"); + ok(logins[0] instanceof Ci.nsILoginMetaInfo, "metainfo QI"); + is(logins[0].timesUsed, 2, "check .timesUsed for existing login submission"); + ok(logins[0].timeCreated < logins[0].timeLastUsed, "timeLastUsed bumped"); + if (justChanged) { + is(logins[0].timeLastUsed, logins[0].timePasswordChanged, "timeLastUsed == timePasswordChanged"); + } else { + is(logins[0].timeCreated, logins[0].timePasswordChanged, "timeChanged not updated"); + } +} + +// Begin popup notification (doorhanger) functions // + +const REMEMBER_BUTTON = 0; +const NEVER_BUTTON = 1; + +const CHANGE_BUTTON = 0; +const DONT_CHANGE_BUTTON = 1; + +/** + * Checks if we have a password capture popup notification + * of the right type and with the right label. + * + * @param {String} aKind The desired `passwordNotificationType` + * @param {Object} [popupNotifications = PopupNotifications] + * @return the found password popup notification. + */ +function getCaptureDoorhanger(aKind, popupNotifications = PopupNotifications) { + ok(true, "Looking for " + aKind + " popup notification"); + let notification = popupNotifications.getNotification("password"); + if (notification) { + is(notification.options.passwordNotificationType, aKind, "Notification type matches."); + if (aKind == "password-change") { + is(notification.mainAction.label, "Update", "Main action label matches update doorhanger."); + } else if (aKind == "password-save") { + is(notification.mainAction.label, "Remember", "Main action label matches save doorhanger."); + } + } + return notification; +} + +/** + * Clicks the specified popup notification button. + * + * @param {Element} aPopup Popup Notification element + * @param {Number} aButtonIndex Number indicating which button to click. + * See the constants in this file. + */ +function clickDoorhangerButton(aPopup, aButtonIndex) { + ok(true, "Looking for action at index " + aButtonIndex); + + let notifications = aPopup.owner.panel.childNodes; + ok(notifications.length > 0, "at least one notification displayed"); + ok(true, notifications.length + " notification(s)"); + let notification = notifications[0]; + + if (aButtonIndex == 0) { + ok(true, "Triggering main action"); + notification.button.doCommand(); + } else if (aButtonIndex <= aPopup.secondaryActions.length) { + ok(true, "Triggering secondary action " + aButtonIndex); + notification.childNodes[aButtonIndex].doCommand(); + } +} + +/** + * Checks the doorhanger's username and password. + * + * @param {String} username The username. + * @param {String} password The password. + */ +function* checkDoorhangerUsernamePassword(username, password) { + yield BrowserTestUtils.waitForCondition(() => { + return document.getElementById("password-notification-username").value == username; + }, "Wait for nsLoginManagerPrompter writeDataToUI()"); + is(document.getElementById("password-notification-username").value, username, + "Check doorhanger username"); + is(document.getElementById("password-notification-password").value, password, + "Check doorhanger password"); +} + +// End popup notification (doorhanger) functions // diff --git a/toolkit/components/passwordmgr/test/browser/insecure_test.html b/toolkit/components/passwordmgr/test/browser/insecure_test.html new file mode 100644 index 000000000..fedea1428 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/insecure_test.html @@ -0,0 +1,9 @@ +<!DOCTYPE html><html><head><meta charset="utf-8"></head><body> +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + +<!-- This frame is initially loaded over HTTP. --> +<iframe id="test-iframe" + src="http://example.org/browser/toolkit/components/passwordmgr/test/browser/insecure_test_subframe.html"/> + +</body></html> diff --git a/toolkit/components/passwordmgr/test/browser/insecure_test_subframe.html b/toolkit/components/passwordmgr/test/browser/insecure_test_subframe.html new file mode 100644 index 000000000..3f01e36a6 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/insecure_test_subframe.html @@ -0,0 +1,13 @@ +<!DOCTYPE html><html><head><meta charset="utf-8"></head><body> +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + +<form> + <input name="password" type="password"> +</form> + +<!-- Link to reload this page over HTTPS. --> +<a id="test-link" + href="https://example.org/browser/toolkit/components/passwordmgr/test/browser/insecure_test_subframe.html">HTTPS</a> + +</body></html> diff --git a/toolkit/components/passwordmgr/test/browser/multiple_forms.html b/toolkit/components/passwordmgr/test/browser/multiple_forms.html new file mode 100644 index 000000000..3f64f8993 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/multiple_forms.html @@ -0,0 +1,129 @@ +<!DOCTYPE html><html><head><meta charset="utf-8"></head><body> +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + + +<form class="test-form" + description="Password only form"> + <input id='test-password-1' type='password' name='pname' value=''> + <input type='submit'>Submit</input> +</form> + + +<form class="test-form" + description="Username only form"> + <input id='test-username-1' type='text' name='uname' value=''> + <input type='submit'>Submit</input> +</form> + + +<form class="test-form" + description="Simple username and password blank form"> + <input id='test-username-2' type='text' name='uname' value=''> + <input id='test-password-2' type='password' name='pname' value=''> + <button type='submit'>Submit</button> +</form> + + +<form class="test-form" + description="Simple username and password form, prefilled username"> + <input id='test-username-3' type='text' name='uname' value='testuser'> + <input id='test-password-3' type='password' name='pname' value=''> + <button type='submit'>Submit</button> +</form> + + +<form class="test-form" + description="Simple username and password form, prefilled username and password"> + <input id='test-username-4' type='text' name='uname' value='testuser'> + <input id='test-password-4' type='password' name='pname' value='testpass'> + <button type='submit'>Submit</button> +</form> + + +<form class="test-form" + description="One username and two passwords empty form"> + <input id='test-username-5' type='text' name='uname'> + <input id='test-password-5' type='password' name='pname'> + <input id='test-password2-5' type='password' name='pname2'> + <button type='submit'>Submit</button> +</form> + + +<form class="test-form" + description="One username and two passwords form, fields prefiled"> + <input id='test-username-6' type='text' name='uname' value="testuser"> + <input id='test-password-6' type='password' name='pname' value="testpass"> + <input id='test-password2-6' type='password' name='pname2' value="testpass"> + <button type='submit'>Submit</button> +</form> + + +<div class="test-form" + description="Username and password fields with no form"> + <input id='test-username-7' type='text' name='uname' value="testuser"> + <input id='test-password-7' type='password' name='pname' value="testpass"> +</div> + + +<form class="test-form" + description="Simple username and password blank form, with disabled password"> + <input id='test-username-8' type='text' name='uname' value=''> + <input id='test-password-8' type='password' name='pname' value='' disabled> + <button type='submit'>Submit</button> +</form> + + +<form class="test-form" + description="Simple username and password blank form, with disabled username"> + <input id='test-username-9' type='text' name='uname' value='' disabled> + <input id='test-password-9' type='password' name='pname' value=''> + <button type='submit'>Submit</button> +</form> + + +<form class="test-form" + description="Simple username and password blank form, with readonly password"> + <input id='test-username-10' type='text' name='uname' value=''> + <input id='test-password-10' type='password' name='pname' value='' readonly> + <button type='submit'>Submit</button> +</form> + + +<form class="test-form" + description="Simple username and password blank form, with readonly username"> + <input id='test-username-11' type='text' name='uname' value='' readonly> + <input id='test-password-11' type='password' name='pname' value=''> + <button type='submit'>Submit</button> +</form> + + +<form class="test-form" + description="Two username and one passwords form, fields prefiled"> + <input id='test-username-12' type='text' name='uname' value="testuser"> + <input id='test-username2-12' type='text' name='uname2' value="testuser"> + <input id='test-password-12' type='password' name='pname' value="testpass"> + <button type='submit'>Submit</button> +</form> + + +<form class="test-form" + description="Two username and one passwords form, one disabled username field"> + <input id='test-username-13' type='text' name='uname'> + <input id='test-username2-13' type='text' name='uname2' disabled> + <input id='test-password-13' type='password' name='pname'> + <button type='submit'>Submit</button> +</form> + + +<div class="test-form" + description="Second username and password fields with no form"> + <input id='test-username-14' type='text' name='uname'> + <input id='test-password-14' type='password' name='pname' expectedFail> +</div> + +<!-- Form in an iframe --> +<iframe src="https://example.org/browser/toolkit/components/passwordmgr/test/browser/form_basic.html" id="test-iframe"></iframe> + +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/browser/streamConverter_content.sjs b/toolkit/components/passwordmgr/test/browser/streamConverter_content.sjs new file mode 100644 index 000000000..84c75437e --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/streamConverter_content.sjs @@ -0,0 +1,6 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function handleRequest(request, response) { + response.setHeader("Content-Type", "test/content", false); +} diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_1.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_1.html new file mode 100644 index 000000000..b96faf2ee --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_1.html @@ -0,0 +1,29 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Subtest for Login Manager notifications - Basic 1un 1pw</title> +</head> +<body> +<h2>Subtest 1</h2> +<form id="form" action="formsubmit.sjs"> + <input id="user" name="user"> + <input id="pass" name="pass" type="password"> + <button type='submit'>Submit</button> +</form> + +<script> +function submitForm() { + userField.value = "notifyu1"; + passField.value = "notifyp1"; + form.submit(); +} + +window.onload = submitForm; +var form = document.getElementById("form"); +var userField = document.getElementById("user"); +var passField = document.getElementById("pass"); + +</script> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_10.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_10.html new file mode 100644 index 000000000..2dc96b4fd --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_10.html @@ -0,0 +1,27 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Subtest for Login Manager notifications</title> +</head> +<body> +<h2>Subtest 10</h2> +<form id="form" action="formsubmit.sjs"> + <input id="pass" name="pass" type="password"> + <button type='submit'>Submit</button> +</form> + +<script> +function submitForm() { + passField.value = "notifyp1"; + form.submit(); +} + +window.onload = submitForm; +var form = document.getElementById("form"); +var userField = document.getElementById("user"); +var passField = document.getElementById("pass"); + +</script> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_11.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_11.html new file mode 100644 index 000000000..cf3df5275 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_11.html @@ -0,0 +1,25 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Subtest for Login Manager notifications - Popup Windows</title> +</head> +<body> +<h2>Subtest 11 (popup windows)</h2> +<script> + +// Ignore the '?' and split on | +[username, password, features, autoClose] = window.location.search.substring(1).split('|'); + +var url = "subtst_notifications_11_popup.html?" + username + "|" + password; +var popupWin = window.open(url, "subtst_11", features); + +// Popup window will call this function on form submission. +function formSubmitted() { + if (autoClose) + popupWin.close(); +} + +</script> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_11_popup.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_11_popup.html new file mode 100644 index 000000000..2e8e4135c --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_11_popup.html @@ -0,0 +1,32 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Subtest for Login Manager notifications</title> +</head> +<body> +<h2>Subtest 11</h2> +<form id="form" action="formsubmit.sjs"> + <input id="user" name="user"> + <input id="pass" name="pass" type="password"> + <button type='submit'>Submit</button> +</form> + +<script> +function submitForm() { + // Get the password from the query string (exclude '?'). + [username, password] = window.location.search.substring(1).split('|'); + userField.value = username; + passField.value = password; + form.submit(); + window.opener.formSubmitted(); +} + +window.onload = submitForm; +var form = document.getElementById("form"); +var userField = document.getElementById("user"); +var passField = document.getElementById("pass"); + +</script> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_2.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_2.html new file mode 100644 index 000000000..72651d6c1 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_2.html @@ -0,0 +1,30 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Subtest for Login Manager notifications - autocomplete=off on the username field</title> +</head> +<body> +<h2>Subtest 2</h2> +(username autocomplete=off) +<form id="form" action="formsubmit.sjs"> + <input id="user" name="user" autocomplete="off"> + <input id="pass" name="pass" type="password"> + <button type='submit'>Submit</button> +</form> + +<script> +function submitForm() { + userField.value = "notifyu1"; + passField.value = "notifyp1"; + form.submit(); +} + +window.onload = submitForm; +var form = document.getElementById("form"); +var userField = document.getElementById("user"); +var passField = document.getElementById("pass"); + +</script> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_2pw_0un.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_2pw_0un.html new file mode 100644 index 000000000..7ddbf0851 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_2pw_0un.html @@ -0,0 +1,27 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Subtest for Login Manager notifications with 2 password fields and no username</title> +</head> +<body> +<h2>Subtest 24</h2> +<form id="form" action="formsubmit.sjs"> + <input id="pass1" name="pass1" type="password" value="staticpw"> + <input id="pass" name="pass" type="password"> + <button type="submit">Submit</button> +</form> + +<script> +function submitForm() { + pass.value = "notifyp1"; + form.submit(); +} + +window.onload = submitForm; +var form = document.getElementById("form"); +var pass = document.getElementById("pass"); + +</script> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_2pw_1un_1text.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_2pw_1un_1text.html new file mode 100644 index 000000000..893f18724 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_2pw_1un_1text.html @@ -0,0 +1,31 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Subtest for Login Manager notifications with 2 password fields and 1 username field and one other text field before the first password field</title> +</head> +<body> +<h2>1 username field followed by a text field followed by 2 username fields</h2> +<form id="form" action="formsubmit.sjs"> + <input id="user" name="user" value="staticpw"> + <input id="city" name="city" value="city"> + <input id="pass" name="pass" type="password"> + <input id="pin" name="pin" type="password" value="static-pin"> + <button type="submit">Submit</button> +</form> + +<script> +function submitForm() { + userField.value = "notifyu1"; + passField.value = "notifyp1"; + form.submit(); +} + +window.onload = submitForm; +var form = document.getElementById("form"); +var userField = document.getElementById("user"); +var passField = document.getElementById("pass"); + +</script> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_3.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_3.html new file mode 100644 index 000000000..291e735d0 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_3.html @@ -0,0 +1,30 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Subtest for Login Manager notifications - autocomplete=off on the password field</title> +</head> +<body> +<h2>Subtest 3</h2> +(password autocomplete=off) +<form id="form" action="formsubmit.sjs"> + <input id="user" name="user"> + <input id="pass" name="pass" type="password" autocomplete="off"> + <button type='submit'>Submit</button> +</form> + +<script> +function submitForm() { + userField.value = "notifyu1"; + passField.value = "notifyp1"; + form.submit(); +} + +window.onload = submitForm; +var form = document.getElementById("form"); +var userField = document.getElementById("user"); +var passField = document.getElementById("pass"); + +</script> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_4.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_4.html new file mode 100644 index 000000000..63df3a42d --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_4.html @@ -0,0 +1,30 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8" > + <title>Subtest for Login Manager notifications</title> +</head> +<body> +<h2>Subtest 4</h2> +(form autocomplete=off) +<form id="form" action="formsubmit.sjs" autocomplete="off"> + <input id="user" name="user"> + <input id="pass" name="pass" type="password"> + <button type='submit'>Submit</button> +</form> + +<script> +function submitForm() { + userField.value = "notifyu1"; + passField.value = "notifyp1"; + form.submit(); +} + +window.onload = submitForm; +var form = document.getElementById("form"); +var userField = document.getElementById("user"); +var passField = document.getElementById("pass"); + +</script> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_5.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_5.html new file mode 100644 index 000000000..72a3df95f --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_5.html @@ -0,0 +1,26 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Subtest for Login Manager notifications - Form with only a username field</title> +</head> +<body> +<h2>Subtest 5</h2> +<form id="form" action="formsubmit.sjs"> + <input id="user" name="user"> + <button type='submit'>Submit</button> +</form> + +<script> +function submitForm() { + userField.value = "notifyu1"; + form.submit(); +} + +window.onload = submitForm; +var form = document.getElementById("form"); +var userField = document.getElementById("user"); + +</script> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_6.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_6.html new file mode 100644 index 000000000..47e23e972 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_6.html @@ -0,0 +1,27 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Subtest for Login Manager notifications</title> +</head> +<body> +<h2>Subtest 6</h2> +(password-only form) +<form id="form" action="formsubmit.sjs"> + <input id="pass" name="pass" type="password"> + <button type='submit'>Submit</button> +</form> + +<script> +function submitForm() { + passField.value = "notifyp1"; + form.submit(); +} + +window.onload = submitForm; +var form = document.getElementById("form"); +var passField = document.getElementById("pass"); + +</script> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_8.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_8.html new file mode 100644 index 000000000..abeea4262 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_8.html @@ -0,0 +1,29 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Subtest for Login Manager notifications</title> +</head> +<body> +<h2>Subtest 8</h2> +<form id="form" action="formsubmit.sjs"> + <input id="user" name="user"> + <input id="pass" name="pass" type="password"> + <button type='submit'>Submit</button> +</form> + +<script> +function submitForm() { + userField.value = "notifyu1"; + passField.value = "pass2"; + form.submit(); +} + +window.onload = submitForm; +var form = document.getElementById("form"); +var userField = document.getElementById("user"); +var passField = document.getElementById("pass"); + +</script> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_9.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_9.html new file mode 100644 index 000000000..c6f741068 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_9.html @@ -0,0 +1,29 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Subtest for Login Manager notifications</title> +</head> +<body> +<h2>Subtest 9</h2> +<form id="form" action="formsubmit.sjs"> + <input id="user" name="user"> + <input id="pass" name="pass" type="password"> + <button type='submit'>Submit</button> +</form> + +<script> +function submitForm() { + userField.value = ""; + passField.value = "pass2"; + form.submit(); +} + +window.onload = submitForm; +var form = document.getElementById("form"); +var userField = document.getElementById("user"); +var passField = document.getElementById("pass"); + +</script> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_change_p.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_change_p.html new file mode 100644 index 000000000..d74f3bcdf --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_change_p.html @@ -0,0 +1,32 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Subtest for Login Manager notifications</title> +</head> +<body> +<h2>Change password</h2> +<form id="form" action="formsubmit.sjs"> + <input id="pass_current" name="pass_current" type="password" value="notifyp1"> + <input id="pass" name="pass" type="password"> + <input id="pass_confirm" name="pass_confirm" type="password"> + <button type='submit'>Submit</button> +</form> + +<script> +function submitForm() { + passField.value = "pass2"; + passConfirmField.value = "pass2"; + + form.submit(); +} + +window.onload = submitForm; +var form = document.getElementById("form"); +var userField = document.getElementById("user"); +var passField = document.getElementById("pass"); +var passConfirmField = document.getElementById("pass_confirm"); + +</script> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/chrome/chrome.ini b/toolkit/components/passwordmgr/test/chrome/chrome.ini new file mode 100644 index 000000000..093b87b7d --- /dev/null +++ b/toolkit/components/passwordmgr/test/chrome/chrome.ini @@ -0,0 +1,13 @@ +[DEFAULT] +skip-if = os == 'android' + +[test_privbrowsing_perwindowpb.html] +skip-if = true # Bug 1173337 +support-files = + ../formsubmit.sjs + notification_common.js + privbrowsing_perwindowpb_iframe.html + subtst_privbrowsing_1.html + subtst_privbrowsing_2.html + subtst_privbrowsing_3.html + subtst_privbrowsing_4.html diff --git a/toolkit/components/passwordmgr/test/chrome/notification_common.js b/toolkit/components/passwordmgr/test/chrome/notification_common.js new file mode 100644 index 000000000..e8a52929d --- /dev/null +++ b/toolkit/components/passwordmgr/test/chrome/notification_common.js @@ -0,0 +1,111 @@ +/* + * Initialization: for each test, remove any prior notifications. + */ +function cleanUpPopupNotifications() { + var container = getPopupNotifications(window.top); + var notes = container._currentNotifications; + info(true, "Removing " + notes.length + " popup notifications."); + for (var i = notes.length - 1; i >= 0; i--) { + notes[i].remove(); + } +} +cleanUpPopupNotifications(); + +/* + * getPopupNotifications + * + * Fetches the popup notification for the specified window. + */ +function getPopupNotifications(aWindow) { + var Ci = SpecialPowers.Ci; + var Cc = SpecialPowers.Cc; + ok(Ci != null, "Access Ci"); + ok(Cc != null, "Access Cc"); + + var chromeWin = SpecialPowers.wrap(aWindow) + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell) + .chromeEventHandler.ownerDocument.defaultView; + + var popupNotifications = chromeWin.PopupNotifications; + return popupNotifications; +} + + +/** + * Checks if we have a password popup notification + * of the right type and with the right label. + * + * @deprecated Write a browser-chrome test instead and use the fork of this method there. + * @returns the found password popup notification. + */ +function getPopup(aPopupNote, aKind) { + ok(true, "Looking for " + aKind + " popup notification"); + var notification = aPopupNote.getNotification("password"); + if (notification) { + is(notification.options.passwordNotificationType, aKind, "Notification type matches."); + if (aKind == "password-change") { + is(notification.mainAction.label, "Update", "Main action label matches update doorhanger."); + } else if (aKind == "password-save") { + is(notification.mainAction.label, "Remember", "Main action label matches save doorhanger."); + } + } + return notification; +} + + +/** + * @deprecated - Use a browser chrome test instead. + * + * Clicks the specified popup notification button. + */ +function clickPopupButton(aPopup, aButtonIndex) { + ok(true, "Looking for action at index " + aButtonIndex); + + var notifications = SpecialPowers.wrap(aPopup.owner).panel.childNodes; + ok(notifications.length > 0, "at least one notification displayed"); + ok(true, notifications.length + " notifications"); + var notification = notifications[0]; + + if (aButtonIndex == 0) { + ok(true, "Triggering main action"); + notification.button.doCommand(); + } else if (aButtonIndex <= aPopup.secondaryActions.length) { + var index = aButtonIndex; + ok(true, "Triggering secondary action " + index); + notification.childNodes[index].doCommand(); + } +} + +const kRememberButton = 0; +const kNeverButton = 1; + +const kChangeButton = 0; +const kDontChangeButton = 1; + +function dumpNotifications() { + try { + // PopupNotifications + var container = getPopupNotifications(window.top); + ok(true, "is popup panel open? " + container.isPanelOpen); + var notes = container._currentNotifications; + ok(true, "Found " + notes.length + " popup notifications."); + for (let i = 0; i < notes.length; i++) { + ok(true, "#" + i + ": " + notes[i].id); + } + + // Notification bars + var chromeWin = SpecialPowers.wrap(window.top) + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell) + .chromeEventHandler.ownerDocument.defaultView; + var nb = chromeWin.getNotificationBox(window.top); + notes = nb.allNotifications; + ok(true, "Found " + notes.length + " notification bars."); + for (let i = 0; i < notes.length; i++) { + ok(true, "#" + i + ": " + notes[i].getAttribute("value")); + } + } catch (e) { todo(false, "WOAH! " + e); } +} diff --git a/toolkit/components/passwordmgr/test/chrome/privbrowsing_perwindowpb_iframe.html b/toolkit/components/passwordmgr/test/chrome/privbrowsing_perwindowpb_iframe.html new file mode 100644 index 000000000..2efdab265 --- /dev/null +++ b/toolkit/components/passwordmgr/test/chrome/privbrowsing_perwindowpb_iframe.html @@ -0,0 +1,9 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> +</head> +<body> +<iframe id="iframe"></iframe> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/chrome/subtst_privbrowsing_1.html b/toolkit/components/passwordmgr/test/chrome/subtst_privbrowsing_1.html new file mode 100644 index 000000000..8c7202dd0 --- /dev/null +++ b/toolkit/components/passwordmgr/test/chrome/subtst_privbrowsing_1.html @@ -0,0 +1,33 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Subtest for Login Manager notifications (private browsing)</title> +</head> +<body> +<h2>Subtest 1</h2> +<!-- + Make sure that the password-save notification appears outside of + the private mode, but not inside it. +--> +<form id="form" action="formsubmit.sjs"> + <input id="user" name="user" type="text"> + <input id="pass" name="pass" type="password"> + <button type='submit'>Submit</button> +</form> + +<script> +function submitForm() { + userField.value = "notifyu1"; + passField.value = "notifyp1"; + form.submit(); +} + +window.onload = submitForm; +var form = document.getElementById("form"); +var userField = document.getElementById("user"); +var passField = document.getElementById("pass"); + +</script> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/chrome/subtst_privbrowsing_2.html b/toolkit/components/passwordmgr/test/chrome/subtst_privbrowsing_2.html new file mode 100644 index 000000000..bf3b85159 --- /dev/null +++ b/toolkit/components/passwordmgr/test/chrome/subtst_privbrowsing_2.html @@ -0,0 +1,33 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Subtest for Login Manager notifications (private browsing)</title> +</head> +<body> +<h2>Subtest 2</h2> +<!-- + Make sure that the password-change notification appears outside of + the private mode, but not inside it. +--> +<form id="form" action="formsubmit.sjs"> + <input id="pass" name="pass" type="password"> + <input id="newpass" name="newpass" type="password"> + <button type='submit'>Submit</button> +</form> + +<script> +function submitForm() { + passField.value = "notifyp1"; + passField2.value = "notifyp2"; + form.submit(); +} + +window.onload = submitForm; +var form = document.getElementById("form"); +var passField = document.getElementById("pass"); +var passField2 = document.getElementById("newpass"); + +</script> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/chrome/subtst_privbrowsing_3.html b/toolkit/components/passwordmgr/test/chrome/subtst_privbrowsing_3.html new file mode 100644 index 000000000..e88a302e0 --- /dev/null +++ b/toolkit/components/passwordmgr/test/chrome/subtst_privbrowsing_3.html @@ -0,0 +1,29 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Subtest for Login Manager notifications (private browsing)</title> +</head> +<body> +<h2>Subtest 3</h2> +<!-- + Make sure that the user/pass fields are auto-filled outside of + the private mode, but not inside it. +--> +<form id="form" action="formsubmit.sjs"> + <input id="user" name="user" type="text"> + <input id="pass" name="pass" type="password"> + <button type='submit'>Submit</button> +</form> + +<script> +function submitForm() { + form.submit(); +} + +var form = document.getElementById("form"); +window.addEventListener('message', () => { submitForm(); }); + +</script> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/chrome/subtst_privbrowsing_4.html b/toolkit/components/passwordmgr/test/chrome/subtst_privbrowsing_4.html new file mode 100644 index 000000000..184142743 --- /dev/null +++ b/toolkit/components/passwordmgr/test/chrome/subtst_privbrowsing_4.html @@ -0,0 +1,40 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Subtest for Login Manager notifications (private browsing)</title> + <script type="text/javascript" src="pwmgr_common.js"></script> +</head> +<body> +<h2>Subtest 4</h2> +<!-- + Make sure that the user/pass fields have manual filling enabled + in private mode. +--> +<form id="form" action="formsubmit.sjs"> + <input id="user" name="user" type="text"> + <input id="pass" name="pass" type="password"> + <button type='submit'>Submit</button> +</form> + +<script> +function startAutocomplete() { + userField.focus(); + doKey("down"); + setTimeout(submitForm, 100); +} + +function submitForm() { + doKey("down"); + doKey("return"); + setTimeout(function() { form.submit(); }, 100); +} + +var form = document.getElementById("form"); +var userField = document.getElementById("user"); + +window.addEventListener('message', () => { startAutocomplete(); }); + +</script> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/chrome/test_privbrowsing_perwindowpb.html b/toolkit/components/passwordmgr/test/chrome/test_privbrowsing_perwindowpb.html new file mode 100644 index 000000000..6b7d4abb3 --- /dev/null +++ b/toolkit/components/passwordmgr/test/chrome/test_privbrowsing_perwindowpb.html @@ -0,0 +1,322 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=248970 +--> +<head> + <meta charset="utf-8"> + <title>Test for Private Browsing</title> + <script type="application/javascript" + src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="notification_common.js"></script> + <link rel="stylesheet" type="text/css" + href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=248970">Mozilla Bug 248970</a> +<p id="display"></p> +<pre id="test"> +<script class="testbody" type="text/javascript"> + +/** Test for Bug 248970 **/ +// based on test_notifications.html + +const Ci = SpecialPowers.Ci; +const Cc = SpecialPowers.Cc; +const Cr = SpecialPowers.Cr; + +Components.utils.import("resource://gre/modules/Services.jsm"); + +var testpath = "/chrome/toolkit/components/passwordmgr/test/chrome/"; +var prefix = "http://test2.example.com" + testpath; +var subtests = [ + "subtst_privbrowsing_1.html", // 1 + "subtst_privbrowsing_1.html", // 2 + "subtst_privbrowsing_1.html", // 3 + "subtst_privbrowsing_2.html", // 4 + "subtst_privbrowsing_2.html", // 5 + "subtst_privbrowsing_2.html", // 6 + "subtst_privbrowsing_3.html", // 7 + "subtst_privbrowsing_3.html", // 8 + "subtst_privbrowsing_4.html", // 9 + "subtst_privbrowsing_3.html" // 10 + ]; +var observer; + +var testNum = 0; +function loadNextTest() { + // run the initialization code for each test + switch (++ testNum) { + case 1: + popupNotifications = normalWindowPopupNotifications; + iframe = normalWindowIframe; + break; + + case 2: + popupNotifications = privateWindowPopupNotifications; + iframe = privateWindowIframe; + break; + + case 3: + popupNotifications = normalWindowPopupNotifications; + iframe = normalWindowIframe; + break; + + case 4: + pwmgr.addLogin(login); + break; + + case 5: + popupNotifications = privateWindowPopupNotifications; + iframe = privateWindowIframe; + break; + + case 6: + popupNotifications = normalWindowPopupNotifications; + iframe = normalWindowIframe; + break; + + case 7: + pwmgr.addLogin(login); + break; + + case 8: + popupNotifications = privateWindowPopupNotifications; + iframe = privateWindowIframe; + break; + + case 9: + break; + + case 10: + popupNotifications = normalWindowPopupNotifications; + iframe = normalWindowIframe; + break; + + default: + ok(false, "Unexpected call to loadNextTest for test #" + testNum); + } + + if (testNum === 7) { + observer = SpecialPowers.wrapCallback(function(subject, topic, data) { + SimpleTest.executeSoon(() => { iframe.contentWindow.postMessage("go", "*"); }); + }); + SpecialPowers.addObserver(observer, "passwordmgr-processed-form", false); + } + + ok(true, "Starting test #" + testNum); + iframe.src = prefix + subtests[testNum - 1]; +} + +function checkTest() { + var popup; + var gotUser; + var gotPass; + + switch (testNum) { + case 1: + // run outside of private mode, popup notification should appear + popup = getPopup(popupNotifications, "password-save"); + ok(popup, "got popup notification"); + popup.remove(); + break; + + case 2: + // run inside of private mode, popup notification should not appear + popup = getPopup(popupNotifications, "password-save"); + ok(!popup, "checking for no popup notification"); + break; + + case 3: + // run outside of private mode, popup notification should appear + popup = getPopup(popupNotifications, "password-save"); + ok(popup, "got popup notification"); + popup.remove(); + break; + + case 4: + // run outside of private mode, popup notification should appear + popup = getPopup(popupNotifications, "password-change"); + ok(popup, "got popup notification"); + popup.remove(); + break; + + case 5: + // run inside of private mode, popup notification should not appear + popup = getPopup(popupNotifications, "password-change"); + ok(!popup, "checking for no popup notification"); + break; + + case 6: + // run outside of private mode, popup notification should appear + popup = getPopup(popupNotifications, "password-change"); + ok(popup, "got popup notification"); + popup.remove(); + pwmgr.removeLogin(login); + break; + + case 7: + // verify that the user/pass pair was autofilled + gotUser = iframe.contentDocument.getElementById("user").textContent; + gotPass = iframe.contentDocument.getElementById("pass").textContent; + is(gotUser, "notifyu1", "Checking submitted username"); + is(gotPass, "notifyp1", "Checking submitted password"); + break; + + case 8: + // verify that the user/pass pair was not autofilled + gotUser = iframe.contentDocument.getElementById("user").textContent; + gotPass = iframe.contentDocument.getElementById("pass").textContent; + is(gotUser, "", "Checking submitted username"); + is(gotPass, "", "Checking submitted password"); + break; + + case 9: + // verify that the user/pass pair was available for autocomplete + gotUser = iframe.contentDocument.getElementById("user").textContent; + gotPass = iframe.contentDocument.getElementById("pass").textContent; + is(gotUser, "notifyu1", "Checking submitted username"); + is(gotPass, "notifyp1", "Checking submitted password"); + break; + + case 10: + // verify that the user/pass pair was autofilled + gotUser = iframe.contentDocument.getElementById("user").textContent; + gotPass = iframe.contentDocument.getElementById("pass").textContent; + is(gotUser, "notifyu1", "Checking submitted username"); + is(gotPass, "notifyp1", "Checking submitted password"); + pwmgr.removeLogin(login); + break; + + default: + ok(false, "Unexpected call to checkTest for test #" + testNum); + + } +} + +var mainWindow = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShellTreeItem) + .rootTreeItem + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); +var contentPage = "http://mochi.test:8888/chrome/toolkit/components/passwordmgr/test/chrome/privbrowsing_perwindowpb_iframe.html"; +var testWindows = []; + +function whenDelayedStartupFinished(aWindow, aCallback) { + Services.obs.addObserver(function obs(aSubject, aTopic) { + if (aWindow == aSubject) { + Services.obs.removeObserver(obs, aTopic); + setTimeout(aCallback, 0); + } + }, "browser-delayed-startup-finished", false); +} + +function testOnWindow(aIsPrivate, aCallback) { + var win = mainWindow.OpenBrowserWindow({private: aIsPrivate}); + win.addEventListener("load", function onLoad() { + win.removeEventListener("load", onLoad, false); + whenDelayedStartupFinished(win, function() { + win.addEventListener("DOMContentLoaded", function onInnerLoad() { + if (win.content.location.href != contentPage) { + win.gBrowser.loadURI(contentPage); + return; + } + win.removeEventListener("DOMContentLoaded", onInnerLoad, true); + + win.content.addEventListener('load', function innerLoad2() { + win.content.removeEventListener('load', innerLoad2, false); + testWindows.push(win); + SimpleTest.executeSoon(function() { aCallback(win); }); + }, false, true); + }, true); + SimpleTest.executeSoon(function() { win.gBrowser.loadURI(contentPage); }); + }); + }, true); +} + +var ignoreLoad = false; +function handleLoad(aEvent) { + // ignore every other load event ... We get one for loading the subtest (which + // we want to ignore), and another when the subtest's form submits itself + // (which we want to handle, to start the next test). + ignoreLoad = !ignoreLoad; + if (ignoreLoad) { + ok(true, "Ignoring load of subtest #" + testNum); + return; + } + ok(true, "Processing submission of subtest #" + testNum); + + checkTest(); + + if (testNum < subtests.length) { + loadNextTest(); + } else { + ok(true, "private browsing notification tests finished."); + + testWindows.forEach(function(aWin) { + aWin.close(); + }); + + SpecialPowers.removeObserver(observer, "passwordmgr-processed-form"); + SimpleTest.finish(); + } +} + +var pwmgr = Cc["@mozilla.org/login-manager;1"]. + getService(Ci.nsILoginManager); +ok(pwmgr != null, "Access pwmgr"); + +// We need to make sure no logins have been stored by previous tests +// for forms in |url|, otherwise the change password notification +// would turn into a prompt, and the test will fail. +var url = "http://test2.example.com"; +is(pwmgr.countLogins(url, "", null), 0, "No logins should be stored for " + url); + +var nsLoginInfo = new SpecialPowers.wrap(SpecialPowers.Components).Constructor("@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, "init"); +var login = new nsLoginInfo(url, url, null, "notifyu1", "notifyp1", "user", "pass"); + +var normalWindow; +var privateWindow; + +var iframe; +var normalWindowIframe; +var privateWindowIframe; + +var popupNotifications; +var normalWindowPopupNotifications; +var privateWindowPopupNotifications; + +testOnWindow(false, function(aWin) { + var selectedBrowser = aWin.gBrowser.selectedBrowser; + normalWindowIframe = selectedBrowser.contentDocument.getElementById("iframe"); + normalWindowIframe.onload = handleLoad; + selectedBrowser.focus(); + + normalWindowPopupNotifications = getPopupNotifications(selectedBrowser.contentWindow.top); + ok(normalWindowPopupNotifications, "Got popupNotifications in normal window"); + // ignore the first load for this window; + ignoreLoad = false; + + testOnWindow(true, function(aPrivateWin) { + selectedBrowser = aPrivateWin.gBrowser.selectedBrowser; + privateWindowIframe = selectedBrowser.contentDocument.getElementById("iframe"); + privateWindowIframe.onload = handleLoad; + selectedBrowser.focus(); + + privateWindowPopupNotifications = getPopupNotifications(selectedBrowser.contentWindow.top); + ok(privateWindowPopupNotifications, "Got popupNotifications in private window"); + // ignore the first load for this window; + ignoreLoad = false; + + SimpleTest.executeSoon(loadNextTest); + }); +}); + +SimpleTest.waitForExplicitFinish(); +</script> +</pre> +</body> +</html> + diff --git a/toolkit/components/passwordmgr/test/chrome_timeout.js b/toolkit/components/passwordmgr/test/chrome_timeout.js new file mode 100644 index 000000000..9049d0bea --- /dev/null +++ b/toolkit/components/passwordmgr/test/chrome_timeout.js @@ -0,0 +1,11 @@ +const Cc = Components.classes; +const Ci = Components.interfaces; + +addMessageListener('setTimeout', msg => { + let timer = Cc['@mozilla.org/timer;1'].createInstance(Ci.nsITimer); + timer.init(_ => { + sendAsyncMessage('timeout'); + }, msg.delay, Ci.nsITimer.TYPE_ONE_SHOT); +}); + +sendAsyncMessage('ready'); diff --git a/toolkit/components/passwordmgr/test/formsubmit.sjs b/toolkit/components/passwordmgr/test/formsubmit.sjs new file mode 100644 index 000000000..4b4a387f7 --- /dev/null +++ b/toolkit/components/passwordmgr/test/formsubmit.sjs @@ -0,0 +1,37 @@ +function handleRequest(request, response) +{ + try { + reallyHandleRequest(request, response); + } catch (e) { + response.setStatusLine("1.0", 200, "AlmostOK"); + response.write("Error handling request: " + e); + } +} + + +function reallyHandleRequest(request, response) { + var match; + var requestAuth = true; + + // XXX I bet this doesn't work for POST requests. + var query = request.queryString; + + var user = null, pass = null; + // user=xxx + match = /user=([^&]*)/.exec(query); + if (match) + user = match[1]; + + // pass=xxx + match = /pass=([^&]*)/.exec(query); + if (match) + pass = match[1]; + + response.setStatusLine("1.0", 200, "OK"); + + response.setHeader("Content-Type", "application/xhtml+xml", false); + response.write("<html xmlns='http://www.w3.org/1999/xhtml'>"); + response.write("<p>User: <span id='user'>" + user + "</span></p>\n"); + response.write("<p>Pass: <span id='pass'>" + pass + "</span></p>\n"); + response.write("</html>"); +} diff --git a/toolkit/components/passwordmgr/test/mochitest.ini b/toolkit/components/passwordmgr/test/mochitest.ini new file mode 100644 index 000000000..640f5c256 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest.ini @@ -0,0 +1,20 @@ +[DEFAULT] +skip-if = e10s +support-files = + authenticate.sjs + blank.html + formsubmit.sjs + prompt_common.js + pwmgr_common.js + subtst_master_pass.html + subtst_prompt_async.html + chrome_timeout.js + +[test_master_password.html] +skip-if = toolkit == 'android' # Tests desktop prompts +[test_prompt_async.html] +skip-if = toolkit == 'android' # Tests desktop prompts +[test_xhr.html] +skip-if = toolkit == 'android' # Tests desktop prompts +[test_xml_load.html] +skip-if = toolkit == 'android' # Tests desktop prompts diff --git a/toolkit/components/passwordmgr/test/mochitest/auth2/authenticate.sjs b/toolkit/components/passwordmgr/test/mochitest/auth2/authenticate.sjs new file mode 100644 index 000000000..d2f650013 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/auth2/authenticate.sjs @@ -0,0 +1,220 @@ +function handleRequest(request, response) +{ + try { + reallyHandleRequest(request, response); + } catch (e) { + response.setStatusLine("1.0", 200, "AlmostOK"); + response.write("Error handling request: " + e); + } +} + + +function reallyHandleRequest(request, response) { + var match; + var requestAuth = true, requestProxyAuth = true; + + // Allow the caller to drive how authentication is processed via the query. + // Eg, http://localhost:8888/authenticate.sjs?user=foo&realm=bar + // The extra ? allows the user/pass/realm checks to succeed if the name is + // at the beginning of the query string. + var query = "?" + request.queryString; + + var expected_user = "", expected_pass = "", realm = "mochitest"; + var proxy_expected_user = "", proxy_expected_pass = "", proxy_realm = "mochi-proxy"; + var huge = false, plugin = false, anonymous = false; + var authHeaderCount = 1; + // user=xxx + match = /[^_]user=([^&]*)/.exec(query); + if (match) + expected_user = match[1]; + + // pass=xxx + match = /[^_]pass=([^&]*)/.exec(query); + if (match) + expected_pass = match[1]; + + // realm=xxx + match = /[^_]realm=([^&]*)/.exec(query); + if (match) + realm = match[1]; + + // proxy_user=xxx + match = /proxy_user=([^&]*)/.exec(query); + if (match) + proxy_expected_user = match[1]; + + // proxy_pass=xxx + match = /proxy_pass=([^&]*)/.exec(query); + if (match) + proxy_expected_pass = match[1]; + + // proxy_realm=xxx + match = /proxy_realm=([^&]*)/.exec(query); + if (match) + proxy_realm = match[1]; + + // huge=1 + match = /huge=1/.exec(query); + if (match) + huge = true; + + // plugin=1 + match = /plugin=1/.exec(query); + if (match) + plugin = true; + + // multiple=1 + match = /multiple=([^&]*)/.exec(query); + if (match) + authHeaderCount = match[1]+0; + + // anonymous=1 + match = /anonymous=1/.exec(query); + if (match) + anonymous = true; + + // Look for an authentication header, if any, in the request. + // + // EG: Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== + // + // This test only supports Basic auth. The value sent by the client is + // "username:password", obscured with base64 encoding. + + var actual_user = "", actual_pass = "", authHeader, authPresent = false; + if (request.hasHeader("Authorization")) { + authPresent = true; + authHeader = request.getHeader("Authorization"); + match = /Basic (.+)/.exec(authHeader); + if (match.length != 2) + throw new Error("Couldn't parse auth header: " + authHeader); + + var userpass = base64ToString(match[1]); // no atob() :-( + match = /(.*):(.*)/.exec(userpass); + if (match.length != 3) + throw new Error("Couldn't decode auth header: " + userpass); + actual_user = match[1]; + actual_pass = match[2]; + } + + var proxy_actual_user = "", proxy_actual_pass = ""; + if (request.hasHeader("Proxy-Authorization")) { + authHeader = request.getHeader("Proxy-Authorization"); + match = /Basic (.+)/.exec(authHeader); + if (match.length != 2) + throw new Error("Couldn't parse auth header: " + authHeader); + + var userpass = base64ToString(match[1]); // no atob() :-( + match = /(.*):(.*)/.exec(userpass); + if (match.length != 3) + throw new Error("Couldn't decode auth header: " + userpass); + proxy_actual_user = match[1]; + proxy_actual_pass = match[2]; + } + + // Don't request authentication if the credentials we got were what we + // expected. + if (expected_user == actual_user && + expected_pass == actual_pass) { + requestAuth = false; + } + if (proxy_expected_user == proxy_actual_user && + proxy_expected_pass == proxy_actual_pass) { + requestProxyAuth = false; + } + + if (anonymous) { + if (authPresent) { + response.setStatusLine("1.0", 400, "Unexpected authorization header found"); + } else { + response.setStatusLine("1.0", 200, "Authorization header not found"); + } + } else { + if (requestProxyAuth) { + response.setStatusLine("1.0", 407, "Proxy authentication required"); + for (i = 0; i < authHeaderCount; ++i) + response.setHeader("Proxy-Authenticate", "basic realm=\"" + proxy_realm + "\"", true); + } else if (requestAuth) { + response.setStatusLine("1.0", 401, "Authentication required"); + for (i = 0; i < authHeaderCount; ++i) + response.setHeader("WWW-Authenticate", "basic realm=\"" + realm + "\"", true); + } else { + response.setStatusLine("1.0", 200, "OK"); + } + } + + response.setHeader("Content-Type", "application/xhtml+xml", false); + response.write("<html xmlns='http://www.w3.org/1999/xhtml'>"); + response.write("<p>Login: <span id='ok'>" + (requestAuth ? "FAIL" : "PASS") + "</span></p>\n"); + response.write("<p>Proxy: <span id='proxy'>" + (requestProxyAuth ? "FAIL" : "PASS") + "</span></p>\n"); + response.write("<p>Auth: <span id='auth'>" + authHeader + "</span></p>\n"); + response.write("<p>User: <span id='user'>" + actual_user + "</span></p>\n"); + response.write("<p>Pass: <span id='pass'>" + actual_pass + "</span></p>\n"); + + if (huge) { + response.write("<div style='display: none'>"); + for (i = 0; i < 100000; i++) { + response.write("123456789\n"); + } + response.write("</div>"); + response.write("<span id='footnote'>This is a footnote after the huge content fill</span>"); + } + + if (plugin) { + response.write("<embed id='embedtest' style='width: 400px; height: 100px;' " + + "type='application/x-test'></embed>\n"); + } + + response.write("</html>"); +} + + +// base64 decoder +// +// Yoinked from extensions/xml-rpc/src/nsXmlRpcClient.js because btoa() +// doesn't seem to exist. :-( +/* Convert Base64 data to a string */ +const toBinaryTable = [ + -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, + -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, + -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,62, -1,-1,-1,63, + 52,53,54,55, 56,57,58,59, 60,61,-1,-1, -1, 0,-1,-1, + -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11,12,13,14, + 15,16,17,18, 19,20,21,22, 23,24,25,-1, -1,-1,-1,-1, + -1,26,27,28, 29,30,31,32, 33,34,35,36, 37,38,39,40, + 41,42,43,44, 45,46,47,48, 49,50,51,-1, -1,-1,-1,-1 +]; +const base64Pad = '='; + +function base64ToString(data) { + + var result = ''; + var leftbits = 0; // number of bits decoded, but yet to be appended + var leftdata = 0; // bits decoded, but yet to be appended + + // Convert one by one. + for (var i = 0; i < data.length; i++) { + var c = toBinaryTable[data.charCodeAt(i) & 0x7f]; + var padding = (data[i] == base64Pad); + // Skip illegal characters and whitespace + if (c == -1) continue; + + // Collect data into leftdata, update bitcount + leftdata = (leftdata << 6) | c; + leftbits += 6; + + // If we have 8 or more bits, append 8 bits to the result + if (leftbits >= 8) { + leftbits -= 8; + // Append if not padding. + if (!padding) + result += String.fromCharCode((leftdata >> leftbits) & 0xff); + leftdata &= (1 << leftbits) - 1; + } + } + + // If there are any bits left, the base64 string was corrupted + if (leftbits) + throw Components.Exception('Corrupted base64 string'); + + return result; +} diff --git a/toolkit/components/passwordmgr/test/mochitest/mochitest.ini b/toolkit/components/passwordmgr/test/mochitest/mochitest.ini new file mode 100644 index 000000000..a4170d7e0 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/mochitest.ini @@ -0,0 +1,69 @@ +[DEFAULT] +support-files = + ../../../prompts/test/chromeScript.js + ../../../prompts/test/prompt_common.js + ../../../satchel/test/parent_utils.js + ../../../satchel/test/satchel_common.js + ../authenticate.sjs + ../blank.html + ../browser/form_autofocus_js.html + ../browser/form_basic.html + ../browser/form_cross_origin_secure_action.html + ../pwmgr_common.js + auth2/authenticate.sjs + +[test_autocomplete_https_upgrade.html] +skip-if = toolkit == 'android' # autocomplete +[test_autofill_https_upgrade.html] +skip-if = toolkit == 'android' # Bug 1259768 +[test_autofill_password-only.html] +[test_autofocus_js.html] +skip-if = toolkit == 'android' # autocomplete +[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] +[test_basic_form_autocomplete.html] +skip-if = toolkit == 'android' # android:autocomplete. +[test_insecure_form_field_autocomplete.html] +skip-if = toolkit == 'android' # android:autocomplete. +[test_password_field_autocomplete.html] +skip-if = toolkit == 'android' # android:autocomplete. +[test_insecure_form_field_no_saved_login.html] +skip-if = toolkit == 'android' || os == 'linux' # android:autocomplete., linux: bug 1325778 +[test_basic_form_html5.html] +[test_basic_form_pwevent.html] +[test_basic_form_pwonly.html] +[test_bug_627616.html] +skip-if = toolkit == 'android' # Tests desktop prompts +[test_bug_776171.html] +[test_case_differences.html] +skip-if = toolkit == 'android' # autocomplete +[test_form_action_1.html] +[test_form_action_2.html] +[test_form_action_javascript.html] +[test_formless_autofill.html] +[test_formless_submit.html] +[test_formless_submit_navigation.html] +[test_formless_submit_navigation_negative.html] +[test_input_events.html] +[test_input_events_for_identical_values.html] +[test_maxlength.html] +[test_passwords_in_type_password.html] +[test_prompt.html] +skip-if = os == "linux" || toolkit == 'android' # Tests desktop prompts +[test_prompt_http.html] +skip-if = os == "linux" || toolkit == 'android' # Tests desktop prompts +[test_prompt_noWindow.html] +skip-if = e10s || toolkit == 'android' # Tests desktop prompts. e10s: bug 1217876 +[test_prompt_promptAuth.html] +skip-if = os == "linux" || toolkit == 'android' # Tests desktop prompts +[test_prompt_promptAuth_proxy.html] +skip-if = e10s || os == "linux" || toolkit == 'android' # Tests desktop prompts +[test_recipe_login_fields.html] +[test_username_focus.html] +skip-if = toolkit == 'android' # android:autocomplete. +[test_xhr_2.html] diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_https_upgrade.html b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_https_upgrade.html new file mode 100644 index 000000000..7d5725322 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_https_upgrade.html @@ -0,0 +1,218 @@ +<!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 chromeScript = runChecksAfterCommonInit(false); + +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. + let nsLoginInfo = Components.Constructor("@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, "init"); + + // 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"); + + let login1 = new nsLoginInfo("https://example.org", "https://example.org", null, + "name1", "pass1", "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"> + <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"> +const shiftModifier = SpecialPowers.Ci.nsIDOMEvent.SHIFT_MASK; + +let iframe = SpecialPowers.wrap(document.getElementsByTagName("iframe")[0]); +let iframeDoc; +let uname; +let pword; + +// Restore the form to the default state. +function restoreForm() { + pword.focus(); + uname.value = ""; + pword.value = ""; + uname.focus(); +} + +// Check for expected username/password in form. +function checkACForm(expectedUsername, expectedPassword) { + 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() { + // 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", "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(); + checkACForm("name", "pass"); +}); + +add_task(function* test_empty_second_entry() { + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + doKey("down"); // first + doKey("down"); // second + doKey("return"); // not "enter"! + yield promiseFormsProcessed(); + checkACForm("name1", "pass1"); +}); + +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 + let results = yield shownPromise; + checkArrayValues(results, ["name1"], "check result deduping for 'name1'"); + doKey("down"); // first + doKey("return"); // not "enter"! + yield promiseFormsProcessed(); + checkACForm("name1", "pass1"); + + let popupState = yield getPopupState(); + is(popupState.open, false, "Check popup is now closed"); +}); + +add_task(function* test_delete_first_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("", ""); + + 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> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autofill_https_upgrade.html b/toolkit/components/passwordmgr/test/mochitest/test_autofill_https_upgrade.html new file mode 100644 index 000000000..ee1424002 --- /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> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autofill_password-only.html b/toolkit/components/passwordmgr/test/mochitest/test_autofill_password-only.html new file mode 100644 index 000000000..983356371 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autofill_password-only.html @@ -0,0 +1,143 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test password-only forms should prefer a password-only login when present</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.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: Bug 444968 +<script> +let pwmgrCommonScript = runInParent(SimpleTest.getTestFileURL("pwmgr_common.js")); +pwmgrCommonScript.sendSyncMessage("setupParent", { selfFilling: true }); + +SimpleTest.waitForExplicitFinish(); + +let chromeScript = runInParent(function chromeSetup() { + const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; + let pwmgr = Cc["@mozilla.org/login-manager;1"].getService(Ci.nsILoginManager); + + let login1A = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + let login1B = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + let login2A = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + let login2B = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + let login2C = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + + login1A.init("http://mochi.test:8888", "http://bug444968-1", null, + "testuser1A", "testpass1A", "", ""); + login1B.init("http://mochi.test:8888", "http://bug444968-1", null, + "", "testpass1B", "", ""); + + login2A.init("http://mochi.test:8888", "http://bug444968-2", null, + "testuser2A", "testpass2A", "", ""); + login2B.init("http://mochi.test:8888", "http://bug444968-2", null, + "", "testpass2B", "", ""); + login2C.init("http://mochi.test:8888", "http://bug444968-2", null, + "testuser2C", "testpass2C", "", ""); + + pwmgr.addLogin(login1A); + pwmgr.addLogin(login1B); + pwmgr.addLogin(login2A); + pwmgr.addLogin(login2B); + pwmgr.addLogin(login2C); + + addMessageListener("removeLogins", function removeLogins() { + pwmgr.removeLogin(login1A); + pwmgr.removeLogin(login1B); + pwmgr.removeLogin(login2A); + pwmgr.removeLogin(login2B); + pwmgr.removeLogin(login2C); + }); +}); + +SimpleTest.registerCleanupFunction(() => chromeScript.sendSyncMessage("removeLogins")); + +registerRunTests(); +</script> + +<p id="display"></p> +<div id="content" style="display: none"> + <!-- first 3 forms have matching user+pass and pass-only logins --> + + <!-- user+pass form. --> + <form id="form1" action="http://bug444968-1"> + <input type="text" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <!-- password-only form. --> + <form id="form2" action="http://bug444968-1"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <!-- user+pass form, username prefilled --> + <form id="form3" action="http://bug444968-1"> + <input type="text" name="uname" value="testuser1A"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + + <!-- next 4 forms have matching user+pass (2x) and pass-only (1x) logins --> + + <!-- user+pass form. --> + <form id="form4" action="http://bug444968-2"> + <input type="text" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <!-- password-only form. --> + <form id="form5" action="http://bug444968-2"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <!-- user+pass form, username prefilled --> + <form id="form6" action="http://bug444968-2"> + <input type="text" name="uname" value="testuser2A"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <!-- user+pass form, username prefilled --> + <form id="form7" action="http://bug444968-2"> + <input type="text" name="uname" value="testuser2C"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + +</div> +<pre id="test"> +<script class="testbody" type="text/javascript"> + +/* Test for Login Manager: 444968 (password-only forms should prefer a + * password-only login when present ) + */ +function startTest() { + checkForm(1, "testuser1A", "testpass1A"); + checkForm(2, "testpass1B"); + checkForm(3, "testuser1A", "testpass1A"); + + checkUnmodifiedForm(4); // 2 logins match + checkForm(5, "testpass2B"); + checkForm(6, "testuser2A", "testpass2A"); + checkForm(7, "testuser2C", "testpass2C"); + + SimpleTest.finish(); +} + +window.addEventListener("runTests", startTest); +</script> +</pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autofocus_js.html b/toolkit/components/passwordmgr/test/mochitest/test_autofocus_js.html new file mode 100644 index 000000000..2ce3293dd --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autofocus_js.html @@ -0,0 +1,115 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test login autocomplete is activated when focused by js on load</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 chromeScript = runChecksAfterCommonInit(false); + +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. + let nsLoginInfo = Components.Constructor("@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, "init"); + + let login0 = new nsLoginInfo("https://example.org", "https://example.org", null, + "name", "pass", "uname", "pword"); + + let login1 = new nsLoginInfo("https://example.org", "https://example.org", null, + "name1", "pass1", "uname", "pword"); + + try { + Services.logins.addLogin(login0); + Services.logins.addLogin(login1); + } catch (e) { + assert.ok(false, "addLogin threw: " + e); + } +}); +</script> +<p id="display"></p> + +<div id="content"> + <iframe src="https://example.org/tests/toolkit/components/passwordmgr/test/mochitest/form_autofocus_js.html"></iframe> +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +let iframe = SpecialPowers.wrap(document.getElementsByTagName("iframe")[0]); +let iframeDoc; + +add_task(function* setup() { + yield new Promise(resolve => { + iframe.addEventListener("load", function onLoad() { + iframe.removeEventListener("load", onLoad); + resolve(); + }); + }); + + iframeDoc = iframe.contentDocument; + + SimpleTest.requestFlakyTimeout("Giving a chance for the unexpected popupshown to occur"); +}); + +add_task(function* test_initial_focus() { + let results = yield notifyMenuChanged(2, "name"); + checkArrayValues(results, ["name", "name1"], "Two results"); + doKey("down"); + doKey("return"); + yield promiseFormsProcessed(); + is(iframeDoc.getElementById("form-basic-password").value, "pass", "Check first password filled"); + let popupState = yield getPopupState(); + is(popupState.open, false, "Check popup is now closed"); +}); + +// This depends on the filling from the previous test. +add_task(function* test_not_reopened_if_filled() { + listenForUnexpectedPopupShown(); + let usernameField = iframeDoc.getElementById("form-basic-username"); + usernameField.focus(); + info("Waiting to see if a popupshown occurs"); + yield new Promise(resolve => setTimeout(resolve, 1000)); + + // cleanup + gPopupShownExpected = true; + iframeDoc.getElementById("form-basic-submit").focus(); +}); + +add_task(function* test_reopened_after_edit_not_matching_saved() { + let usernameField = iframeDoc.getElementById("form-basic-username"); + usernameField.value = "nam"; + let shownPromise = promiseACShown(); + usernameField.focus(); + yield shownPromise; + iframeDoc.getElementById("form-basic-submit").focus(); +}); + +add_task(function* test_not_reopened_after_selecting() { + let formFillController = SpecialPowers.Cc["@mozilla.org/satchel/form-fill-controller;1"]. + getService(SpecialPowers.Ci.nsIFormFillController); + let usernameField = iframeDoc.getElementById("form-basic-username"); + usernameField.value = ""; + iframeDoc.getElementById("form-basic-password").value = ""; + listenForUnexpectedPopupShown(); + formFillController.markAsLoginManagerField(usernameField); + info("Waiting to see if a popupshown occurs"); + yield new Promise(resolve => setTimeout(resolve, 1000)); + + // Cleanup + gPopupShownExpected = true; +}); + +</script> +</pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_basic_form.html b/toolkit/components/passwordmgr/test/mochitest/test_basic_form.html new file mode 100644 index 000000000..3c38343a5 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form.html @@ -0,0 +1,44 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test basic autofill</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.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: simple form fill + +<script> +runChecksAfterCommonInit(startTest); + +/** Test for Login Manager: form fill, multiple forms. **/ + +function startTest() { + is($_(1, "uname").value, "testuser", "Checking for filled username"); + is($_(1, "pword").value, "testpass", "Checking for filled password"); + + SimpleTest.finish(); +} +</script> + +<p id="display"></p> + +<div id="content" style="display: none"> + + <form id="form1" action="formtest.js"> + <p>This is form 1.</p> + <input type="text" name="uname"> + <input type="password" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + +</div> + +<pre id="test"></pre> +</body> +</html> + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_basic_form_0pw.html b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_0pw.html new file mode 100644 index 000000000..0b416673b --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_0pw.html @@ -0,0 +1,72 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test forms with no password fields</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.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: forms with no password fields +<p id="display"></p> + +<div id="content" style="display: none"> + + <!-- Form with no user field or password field --> + <form id="form1" action="formtest.js"> + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- Form with no user field or password field, but one other field --> + <form id="form2" action="formtest.js"> + <input type="checkbox"> + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- Form with no user field or password field, but one other field --> + <form id="form3" action="formtest.js"> + <input type="checkbox" name="uname" value=""> + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- Form with a text field, but no password field --> + <form id="form4" action="formtest.js"> + <input type="text" name="yyyyy"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- Form with a user field, but no password field --> + <form id="form5" action="formtest.js"> + <input type="text" name="uname"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +/** Test for Login Manager: form fill, no password fields. **/ + +function startTest() { + is($_(3, "uname").value, "", "Checking for unfilled checkbox (form 3)"); + is($_(4, "yyyyy").value, "", "Checking for unfilled text field (form 4)"); + is($_(5, "uname").value, "", "Checking for unfilled text field (form 5)"); + + SimpleTest.finish(); +} + +runChecksAfterCommonInit(startTest); +</script> +</pre> +</body> +</html> + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_basic_form_1pw.html b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_1pw.html new file mode 100644 index 000000000..3937fad4b --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_1pw.html @@ -0,0 +1,167 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test autofill for forms with 1 password field</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.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: forms with 1 password field +<script> +runChecksAfterCommonInit(() => startTest()); +</script> +<p id="display"></p> + +<div id="content" style="display: none"> + +<!-- no username fields --> + +<form id='form1' action='formtest.js'> 1 + <!-- Blank, so fill in the password --> + <input type='password' name='pname' value=''> + <button type='submit'>Submit</button> +</form> + +<form id='form2' action='formtest.js'> 2 + <!-- Already contains the password, so nothing to do. --> + <input type='password' name='pname' value='testpass'> + <button type='submit'>Submit</button> +</form> + +<form id='form3' action='formtest.js'> 3 + <!-- Contains unknown password, so don't change it --> + <input type='password' name='pname' value='xxxxxxxx'> + <button type='submit'>Submit</button> +</form> + + +<!-- username fields --> + +<form id='form4' action='formtest.js'> 4 + <!-- Blanks, so fill in login --> + <input type='text' name='uname' value=''> + <input type='password' name='pname' value=''> + <button type='submit'>Submit</button> +</form> + +<form id='form5' action='formtest.js'> 5 + <!-- Username already set, so fill in password --> + <input type='text' name='uname' value='testuser'> + <input type='password' name='pname' value=''> + <button type='submit'>Submit</button> +</form> + +<form id='form6' action='formtest.js'> 6 + <!-- Unknown username, so don't fill in password --> + <input type='text' name='uname' value='xxxxxxxx'> + <input type='password' name='pname' value=''> + <button type='submit'>Submit</button> +</form> + +<form id='form7' action='formtest.js'> 7 + <!-- Password already set, could fill in username but that's weird so we don't --> + <input type='text' name='uname' value=''> + <input type='password' name='pname' value='testpass'> + <button type='submit'>Submit</button> +</form> + +<form id='form8' action='formtest.js'> 8 + <!-- Unknown password, so don't fill in a username --> + <input type='text' name='uname' value=''> + <input type='password' name='pname' value='xxxxxxxx'> + <button type='submit'>Submit</button> +</form> + + + +<!-- extra text fields --> + +<form id='form9' action='formtest.js'> 9 + <!-- text field _after_ password should never be treated as a username field --> + <input type='password' name='pname' value=''> + <input type='text' name='uname' value=''> + <button type='submit'>Submit</button> +</form> + +<form id='form10' action='formtest.js'> 10 + <!-- only the first text field before the password should be for username --> + <input type='text' name='other' value=''> + <input type='text' name='uname' value=''> + <input type='password' name='pname' value=''> + <button type='submit'>Submit</button> +</form> + +<form id='form11' action='formtest.js'> 11 + <!-- variation just to make sure extra text field is still ignored --> + <input type='text' name='uname' value=''> + <input type='password' name='pname' value=''> + <input type='text' name='other' value=''> + <button type='submit'>Submit</button> +</form> + + + +<!-- same as last bunch, but with xxxx in the extra field. --> + +<form id='form12' action='formtest.js'> 12 + <!-- text field _after_ password should never be treated as a username field --> + <input type='password' name='pname' value=''> + <input type='text' name='uname' value='xxxxxxxx'> + <button type='submit'>Submit</button> +</form> + +<form id='form13' action='formtest.js'> 13 + <!-- only the first text field before the password should be for username --> + <input type='text' name='other' value='xxxxxxxx'> + <input type='text' name='uname' value=''> + <input type='password' name='pname' value=''> + <button type='submit'>Submit</button> +</form> + +<form id='form14' action='formtest.js'> 14 + <!-- variation just to make sure extra text field is still ignored --> + <input type='text' name='uname' value=''> + <input type='password' name='pname' value=''> + <input type='text' name='other' value='xxxxxxxx'> + <button type='submit'>Submit</button> +</form> + + +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +/** Test for Login Manager: simple form fill **/ + +function startTest() { + var f = 1; + + // 1-3 + checkForm(f++, "testpass"); + checkForm(f++, "testpass"); + checkForm(f++, "xxxxxxxx"); + + // 4-8 + checkForm(f++, "testuser", "testpass"); + checkForm(f++, "testuser", "testpass"); + checkForm(f++, "xxxxxxxx", ""); + checkForm(f++, "", "testpass"); + checkForm(f++, "", "xxxxxxxx"); + + // 9-14 + checkForm(f++, "testpass", ""); + checkForm(f++, "", "testuser", "testpass"); + checkForm(f++, "testuser", "testpass", ""); + checkForm(f++, "testpass", "xxxxxxxx"); + checkForm(f++, "xxxxxxxx", "testuser", "testpass"); + checkForm(f++, "testuser", "testpass", "xxxxxxxx"); + + SimpleTest.finish(); +} +</script> +</pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_basic_form_1pw_2.html b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_1pw_2.html new file mode 100644 index 000000000..0f6566b9c --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_1pw_2.html @@ -0,0 +1,109 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test forms with 1 password field, part 2</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.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: forms with 1 password field, part 2 +<script> +runChecksAfterCommonInit(() => startTest()); +</script> +<p id="display"></p> + +<div id="content" style="display: none"> + +<form id='form1' action='formtest.js'> 1 + <input type='password' name='pname' value=''> + <button type='submit'>Submit</button> +</form> + +<form id='form2' action='formtest.js'> 2 + <input type='password' name='pname' value='' disabled> + <button type='submit'>Submit</button> +</form> + +<form id='form3' action='formtest.js'> 3 + <input type='password' name='pname' value='' readonly> + <button type='submit'>Submit</button> +</form> + +<form id='form4' action='formtest.js'> 4 + <input type='text' name='uname' value=''> + <input type='password' name='pname' value=''> + <button type='submit'>Submit</button> +</form> + +<form id='form5' action='formtest.js'> 5 + <input type='text' name='uname' value='' disabled> + <input type='password' name='pname' value=''> + <button type='submit'>Submit</button> +</form> + +<form id='form6' action='formtest.js'> 6 + <input type='text' name='uname' value='' readonly> + <input type='password' name='pname' value=''> + <button type='submit'>Submit</button> +</form> + +<form id='form7' action='formtest.js'> 7 + <input type='text' name='uname' value=''> + <input type='password' name='pname' value='' disabled> + <button type='submit'>Submit</button> +</form> + +<form id='form8' action='formtest.js'> 8 + <input type='text' name='uname' value=''> + <input type='password' name='pname' value='' readonly> + <button type='submit'>Submit</button> +</form> + +<form id='form9' action='formtest.js'> 9 + <input type='text' name='uname' value='TESTUSER'> + <input type='password' name='pname' value=''> + <button type='submit'>Submit</button> +</form> + +<form id='form10' action='formtest.js'> 10 + <input type='text' name='uname' value='TESTUSER' readonly> + <input type='password' name='pname' value=''> + <button type='submit'>Submit</button> +</form> + +<form id='form11' action='formtest.js'> 11 + <input type='text' name='uname' value='TESTUSER' disabled> + <input type='password' name='pname' value=''> + <button type='submit'>Submit</button> +</form> + +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +/** Test for Login Manager: simple form fill, part 2 **/ + +function startTest() { + var f; + + // Test various combinations of disabled/readonly inputs + checkForm(1, "testpass"); // control + checkUnmodifiedForm(2); + checkUnmodifiedForm(3); + checkForm(4, "testuser", "testpass"); // control + for (f = 5; f <= 8; f++) { checkUnmodifiedForm(f); } + // Test case-insensitive comparison of username field + checkForm(9, "testuser", "testpass"); + checkForm(10, "TESTUSER", "testpass"); + checkForm(11, "TESTUSER", "testpass"); + + SimpleTest.finish(); +} +</script> +</pre> +</body> +</html> + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_basic_form_2pw_1.html b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_2pw_1.html new file mode 100644 index 000000000..128ffca7c --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_2pw_1.html @@ -0,0 +1,187 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test autofill for forms with 2 password fields</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.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: forms with 2 password fields +<script> +runChecksAfterCommonInit(() => startTest()); +</script> +<p id="display"></p> + +<div id="content" style="display: none"> + + +<!-- no username fields --> + +<form id='form1' action='formtest.js'> 1 + <!-- simple form, fill in first pw --> + <input type='password' name='pname' value=''> + <input type='password' name='qname' value=''> + <button type='submit'>Submit</button> +</form> + +<form id='form2' action='formtest.js'> 2 + <!-- same but reverse pname and qname, field names are ignored. --> + <input type='password' name='qname' value=''> + <input type='password' name='pname' value=''> + <button type='submit'>Submit</button> +</form> + +<form id='form3' action='formtest.js'> 3 + <!-- text field after password fields should be ignored, no username. --> + <input type='password' name='pname' value=''> + <input type='password' name='qname' value=''> + <input type='text' name='uname' value=''> + <button type='submit'>Submit</button> +</form> + +<form id='form4' action='formtest.js'> 4 + <!-- nothing to do, password already present --> + <input type='password' name='pname' value='testpass'> + <input type='password' name='qname' value=''> + <button type='submit'>Submit</button> +</form> + +<form id='form5' action='formtest.js'> 5 + <!-- don't clobber an existing unrecognized password --> + <input type='password' name='pname' value='xxxxxxxx'> + <input type='password' name='qname' value=''> + <button type='submit'>Submit</button> +</form> + +<form id='form6' action='formtest.js'> 6 + <!-- fill in first field, 2nd field shouldn't be touched anyway. --> + <input type='password' name='pname' value=''> + <input type='password' name='qname' value='xxxxxxxx'> + <button type='submit'>Submit</button> +</form> + + + +<!-- with username fields --> + + + +<form id='form7' action='formtest.js'> 7 + <!-- simple form, should fill in username and first pw --> + <input type='text' name='uname' value=''> + <input type='password' name='pname' value=''> + <input type='password' name='qname' value=''> + <button type='submit'>Submit</button> +</form> + +<form id='form8' action='formtest.js'> 8 + <!-- reverse pname and qname, field names are ignored. --> + <input type='text' name='uname' value=''> + <input type='password' name='qname' value=''> + <input type='password' name='pname' value=''> + <button type='submit'>Submit</button> +</form> + +<form id='form9' action='formtest.js'> 9 + <!-- username already filled, so just fill first password --> + <input type='text' name='uname' value='testuser'> + <input type='password' name='pname' value=''> + <input type='password' name='qname' value=''> + <button type='submit'>Submit</button> +</form> + +<form id='form10' action='formtest.js'> 10 + <!-- unknown username, don't fill in a password --> + <input type='text' name='uname' value='xxxxxxxx'> + <input type='password' name='pname' value=''> + <input type='password' name='qname' value=''> + <button type='submit'>Submit</button> +</form> + +<form id='form11' action='formtest.js'> 11 + <!-- don't clobber unknown password --> + <input type='text' name='uname' value='testuser'> + <input type='password' name='pname' value='xxxxxxxx'> + <input type='password' name='qname' value=''> + <button type='submit'>Submit</button> +</form> + +<form id='form12' action='formtest.js'> 12 + <!-- fill in 1st pass, don't clobber 2nd pass --> + <input type='text' name='uname' value='testuser'> + <input type='password' name='pname' value=''> + <input type='password' name='qname' value='xxxxxxxx'> + <button type='submit'>Submit</button> +</form> + +<form id='form13' action='formtest.js'> 13 + <!-- nothing to do, user and pass prefilled. life is easy. --> + <input type='text' name='uname' value='testuser'> + <input type='password' name='pname' value='testpass'> + <input type='password' name='qname' value=''> + <button type='submit'>Submit</button> +</form> + +<form id='form14' action='formtest.js'> 14 + <!-- shouldn't fill in username because 1st pw field is unknown. --> + <input type='text' name='uname' value=''> + <input type='password' name='pname' value='xxxxxxxx'> + <input type='password' name='qname' value='testpass'> + <button type='submit'>Submit</button> +</form> + +<form id='form15' action='formtest.js'> 15 + <!-- textfield in the middle of pw fields should be ignored --> + <input type='password' name='pname' value=''> + <input type='text' name='uname' value=''> + <input type='password' name='qname' value=''> + <button type='submit'>Submit</button> +</form> + +<form id='form16' action='formtest.js'> 16 + <!-- same, and don't clobber existing unknown password --> + <input type='password' name='pname' value='xxxxxxxx'> + <input type='text' name='uname' value=''> + <input type='password' name='qname' value=''> + <button type='submit'>Submit</button> +</form> + + +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +/** Test for Login Manager: simple form fill **/ + +function startTest() { + var f = 1; + + // 1-6 no username + checkForm(f++, "testpass", ""); + checkForm(f++, "testpass", ""); + checkForm(f++, "testpass", "", ""); + checkForm(f++, "testpass", ""); + checkForm(f++, "xxxxxxxx", ""); + checkForm(f++, "testpass", "xxxxxxxx"); + + // 7-15 with username + checkForm(f++, "testuser", "testpass", ""); + checkForm(f++, "testuser", "testpass", ""); + checkForm(f++, "testuser", "testpass", ""); + checkForm(f++, "xxxxxxxx", "", ""); + checkForm(f++, "testuser", "xxxxxxxx", ""); + checkForm(f++, "testuser", "testpass", "xxxxxxxx"); + checkForm(f++, "testuser", "testpass", ""); + checkForm(f++, "", "xxxxxxxx", "testpass"); + checkForm(f++, "testpass", "", ""); + checkForm(f++, "xxxxxxxx", "", ""); + + SimpleTest.finish(); +} +</script> +</pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_basic_form_2pw_2.html b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_2pw_2.html new file mode 100644 index 000000000..eba811cf9 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_2pw_2.html @@ -0,0 +1,105 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test for form fill with 2 password fields</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.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: form fill, 2 password fields +<p id="display"></p> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +/** Test for Login Manager: form fill, 2 password fields **/ + +/* + * If a form has two password fields, other things may be going on.... + * + * 1 - The user might be creating a new login (2nd field for typo checking) + * 2 - The user is changing a password (old and new password each have field) + * + * This test is for case #1. + */ + +var numSubmittedForms = 0; +var numStartingLogins = 0; + +function startTest() { + // Check for unfilled forms + is($_(1, "uname").value, "", "Checking username 1"); + is($_(1, "pword").value, "", "Checking password 1A"); + is($_(1, "qword").value, "", "Checking password 1B"); + + // Fill in the username and password fields, for account creation. + // Form 1 + $_(1, "uname").value = "newuser1"; + $_(1, "pword").value = "newpass1"; + $_(1, "qword").value = "newpass1"; + + var button = getFormSubmitButton(1); + + todo(false, "form submission disabled, can't auto-accept dialog yet"); + SimpleTest.finish(); +} + + +// 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 = 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 + } + + // submit the next form. + var button = getFormSubmitButton(formNum + 1); + button.click(); + + return false; // return false to cancel current form submission +} + + +function getFormSubmitButton(formNum) { + var form = $("form" + formNum); // by id, not name + ok(form != null, "getting form " + formNum); + + // we can't just call form.submit(), because that doesn't seem to + // invoke the form onsubmit handler. + var button = form.firstChild; + while (button && button.type != "submit") { button = button.nextSibling; } + ok(button != null, "getting form submit button"); + + return button; +} + +runChecksAfterCommonInit(startTest); + +</script> +</pre> +<div id="content" style="display: none"> + <form id="form1" onsubmit="return checkSubmit(1)" action="http://newuser.com"> + <input type="text" name="uname"> + <input type="password" name="pword"> + <input type="password" name="qword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + +</div> + +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_basic_form_3pw_1.html b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_3pw_1.html new file mode 100644 index 000000000..30b5a319f --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_3pw_1.html @@ -0,0 +1,177 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test autofill for forms with 3 password fields</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.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: forms with 3 password fields (form filling) +<script> +runChecksAfterCommonInit(() => startTest()); +</script> +<p id="display"></p> + +<div id="content" style="display: none"> + <p>The next three forms are <b>user/pass/passB/passC</b>, as all-empty, preuser(only), and preuser/pass</p> + <form id="form1" action="formtest.js"> + <input type="text" name="uname"> + <input type="password" name="pword"> + <input type="password" name="qword"> + <input type="password" name="rword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <form id="form2" action="formtest.js"> + <input type="text" name="uname" value="testuser"> + <input type="password" name="pword"> + <input type="password" name="qword"> + <input type="password" name="rword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <form id="form3" action="formtest.js"> + <input type="text" name="uname" value="testuser"> + <input type="password" name="pword" value="testpass"> + <input type="password" name="qword"> + <input type="password" name="rword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + + <p>The next three forms are <b>user/passB/pass/passC</b>, as all-empty, preuser(only), and preuser/pass</p> + <form id="form4" action="formtest.js"> + <input type="text" name="uname"> + <input type="password" name="qword"> + <input type="password" name="pword"> + <input type="password" name="rword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <form id="form5" action="formtest.js"> + <input type="text" name="uname" value="testuser"> + <input type="password" name="qword"> + <input type="password" name="pword"> + <input type="password" name="rword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <form id="form6" action="formtest.js"> + <input type="text" name="uname" value="testuser"> + <input type="password" name="qword"> + <input type="password" name="pword" value="testpass"> + <input type="password" name="rword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <p>The next three forms are <b>user/passB/passC/pass</b>, as all-empty, preuser(only), and preuser/pass</p> + <form id="form7" action="formtest.js"> + <input type="text" name="uname"> + <input type="password" name="qword"> + <input type="password" name="rword"> + <input type="password" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <form id="form8" action="formtest.js"> + <input type="text" name="uname" value="testuser"> + <input type="password" name="qword"> + <input type="password" name="rword"> + <input type="password" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <form id="form9" action="formtest.js"> + <input type="text" name="uname" value="testuser"> + <input type="password" name="qword"> + <input type="password" name="rword"> + <input type="password" name="pword" value="testpass"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +/** Test for Login Manager: form fill, 3 password fields **/ + +// Test to make sure 3-password forms are filled properly. + +function startTest() { + // Check form 1 + is($_(1, "uname").value, "testuser", "Checking username 1"); + is($_(1, "pword").value, "testpass", "Checking password 1"); + is($_(1, "qword").value, "", "Checking password 1 (q)"); + is($_(1, "rword").value, "", "Checking password 1 (r)"); + // Check form 2 + is($_(2, "uname").value, "testuser", "Checking username 2"); + is($_(2, "pword").value, "testpass", "Checking password 2"); + is($_(2, "qword").value, "", "Checking password 2 (q)"); + is($_(2, "rword").value, "", "Checking password 2 (r)"); + // Check form 3 + is($_(3, "uname").value, "testuser", "Checking username 3"); + is($_(3, "pword").value, "testpass", "Checking password 3"); + is($_(3, "qword").value, "", "Checking password 3 (q)"); + is($_(3, "rword").value, "", "Checking password 3 (r)"); + + // Check form 4 + is($_(4, "uname").value, "testuser", "Checking username 4"); + todo_is($_(4, "qword").value, "", "Checking password 4 (q)"); + todo_is($_(4, "pword").value, "testpass", "Checking password 4"); + is($_(4, "rword").value, "", "Checking password 4 (r)"); + // Check form 5 + is($_(5, "uname").value, "testuser", "Checking username 5"); + todo_is($_(5, "qword").value, "", "Checking password 5 (q)"); + todo_is($_(5, "pword").value, "testpass", "Checking password 5"); + is($_(5, "rword").value, "", "Checking password 5 (r)"); + // Check form 6 + is($_(6, "uname").value, "testuser", "Checking username 6"); + todo_is($_(6, "qword").value, "", "Checking password 6 (q)"); + is($_(6, "pword").value, "testpass", "Checking password 6"); + is($_(6, "rword").value, "", "Checking password 6 (r)"); + + // Check form 7 + is($_(7, "uname").value, "testuser", "Checking username 7"); + todo_is($_(7, "qword").value, "", "Checking password 7 (q)"); + is($_(7, "rword").value, "", "Checking password 7 (r)"); + todo_is($_(7, "pword").value, "testpass", "Checking password 7"); + // Check form 8 + is($_(8, "uname").value, "testuser", "Checking username 8"); + todo_is($_(8, "qword").value, "", "Checking password 8 (q)"); + is($_(8, "rword").value, "", "Checking password 8 (r)"); + todo_is($_(8, "pword").value, "testpass", "Checking password 8"); + // Check form 9 + is($_(9, "uname").value, "testuser", "Checking username 9"); + todo_is($_(9, "qword").value, "", "Checking password 9 (q)"); + is($_(9, "rword").value, "", "Checking password 9 (r)"); + is($_(9, "pword").value, "testpass", "Checking password 9"); + + // TODO: as with the 2-password cases, add tests to check for creating new + // logins and changing passwords. + SimpleTest.finish(); +} +</script> +</pre> +</body> +</html> + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_basic_form_autocomplete.html b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_autocomplete.html new file mode 100644 index 000000000..0eee8e696 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_autocomplete.html @@ -0,0 +1,859 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test basic login autocomplete</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: multiple login autocomplete + +<script> +var chromeScript = runChecksAfterCommonInit(); + +var setupScript = runInParent(function setup() { + 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", + Ci.nsILoginInfo, "init"); + assert.ok(nsLoginInfo != null, "nsLoginInfo constructor"); + + // login0 has no username, so should be filtered out from the autocomplete list. + var login0 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null, + "", "user0pass", "", "pword"); + + var login1 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null, + "tempuser1", "temppass1", "uname", "pword"); + + var login2 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null, + "testuser2", "testpass2", "uname", "pword"); + + var login3 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null, + "testuser3", "testpass3", "uname", "pword"); + + var login4 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null, + "zzzuser4", "zzzpass4", "uname", "pword"); + + // login 5 only used in the single-user forms + var login5 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete2", null, + "singleuser5", "singlepass5", "uname", "pword"); + + var login6A = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete3", null, + "form7user1", "form7pass1", "uname", "pword"); + var login6B = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete3", null, + "form7user2", "form7pass2", "uname", "pword"); + + var login7 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete4", null, + "form8user", "form8pass", "uname", "pword"); + + var login8A = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete5", null, + "form9userAB", "form9pass", "uname", "pword"); + var login8B = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete5", null, + "form9userAAB", "form9pass", "uname", "pword"); + var login8C = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete5", null, + "form9userAABzz", "form9pass", "uname", "pword"); + + var login10 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete7", null, + "testuser10", "testpass10", "uname", "pword"); + + + // try/catch in case someone runs the tests manually, twice. + try { + Services.logins.addLogin(login0); + Services.logins.addLogin(login1); + Services.logins.addLogin(login2); + Services.logins.addLogin(login3); + Services.logins.addLogin(login4); + Services.logins.addLogin(login5); + Services.logins.addLogin(login6A); + Services.logins.addLogin(login6B); + Services.logins.addLogin(login7); + Services.logins.addLogin(login8A); + Services.logins.addLogin(login8B); + // login8C is added later + Services.logins.addLogin(login10); + } catch (e) { + assert.ok(false, "addLogin threw: " + e); + } + + addMessageListener("addLogin", loginVariableName => { + let login = eval(loginVariableName); + assert.ok(!!login, "Login to add is defined: " + loginVariableName); + Services.logins.addLogin(login); + }); + addMessageListener("removeLogin", loginVariableName => { + let login = eval(loginVariableName); + assert.ok(!!login, "Login to delete is defined: " + loginVariableName); + Services.logins.removeLogin(login); + }); +}); +</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> + + <!-- other forms test single logins, with autocomplete=off set --> + <form id="form2" action="http://autocomplete2" onsubmit="return false;"> + <input type="text" name="uname"> + <input type="password" name="pword" autocomplete="off"> + <button type="submit">Submit</button> + </form> + + <form id="form3" action="http://autocomplete2" onsubmit="return false;"> + <input type="text" name="uname" autocomplete="off"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <form id="form4" action="http://autocomplete2" onsubmit="return false;" autocomplete="off"> + <input type="text" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <form id="form5" action="http://autocomplete2" onsubmit="return false;"> + <input type="text" name="uname" autocomplete="off"> + <input type="password" name="pword" autocomplete="off"> + <button type="submit">Submit</button> + </form> + + <!-- control --> + <form id="form6" action="http://autocomplete2" onsubmit="return false;"> + <input type="text" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <!-- This form will be manipulated to insert a different username field. --> + <form id="form7" action="http://autocomplete3" onsubmit="return false;"> + <input type="text" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <!-- test for no autofill after onblur with blank username --> + <form id="form8" action="http://autocomplete4" onsubmit="return false;"> + <input type="text" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <!-- test autocomplete dropdown --> + <form id="form9" action="http://autocomplete5" onsubmit="return false;"> + <input type="text" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <!-- test for onUsernameInput recipe testing --> + <form id="form11" action="http://autocomplete7" onsubmit="return false;"> + <input type="text" name="1"> + <input type="text" name="2"> + <button type="submit">Submit</button> + </form> + + <!-- tests <form>-less autocomplete --> + <div id="form12"> + <input type="text" name="uname" id="uname"> + <input type="password" name="pword" id="pword"> + <button type="submit">Submit</button> + </div> +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +/** Test for Login Manager: multiple login autocomplete. **/ + +var uname = $_(1, "uname"); +var pword = $_(1, "pword"); +const shiftModifier = SpecialPowers.Ci.nsIDOMEvent.SHIFT_MASK; + +// Restore the form to the default state. +function restoreForm() { + uname.value = ""; + pword.value = ""; + uname.focus(); +} + +// Check for expected username/password in form. +function checkACForm(expectedUsername, expectedPassword) { + var formID = uname.parentNode.id; + is(uname.value, expectedUsername, "Checking " + formID + " username is: " + expectedUsername); + is(pword.value, expectedPassword, "Checking " + formID + " password is: " + expectedPassword); +} + +function sendFakeAutocompleteEvent(element) { + var acEvent = document.createEvent("HTMLEvents"); + acEvent.initEvent("DOMAutoComplete", true, false); + element.dispatchEvent(acEvent); +} + +function spinEventLoop() { + return Promise.resolve(); +} + +add_task(function* setup() { + yield SpecialPowers.pushPrefEnv({"set": [["security.insecure_field_warning.contextual.enabled", false], + ["signon.autofillForms.http", true]]}); + listenForUnexpectedPopupShown(); +}); + +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"); +}); + +add_task(function* test_form1_menuitems() { + yield SimpleTest.promiseFocus(window); + // Trigger autocomplete popup + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + let results = yield shownPromise; + + let popupState = yield getPopupState(); + is(popupState.selectedIndex, -1, "Check no entries are selected upon opening"); + + let expectedMenuItems = ["tempuser1", + "testuser2", + "testuser3", + "zzzuser4"]; + checkArrayValues(results, expectedMenuItems, "Check all menuitems are displayed correctly."); + + checkACForm("", ""); // value shouldn't update just by selecting + doKey("return"); // not "enter"! + yield spinEventLoop(); // let focus happen + checkACForm("", ""); +}); + +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() { + // Trigger autocomplete popup + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); // first + doKey("down"); // second + doKey("return"); // not "enter"! + yield promiseFormsProcessed(); + checkACForm("testuser2", "testpass2"); +}); + +add_task(function* test_form1_third_entry() { + // Trigger autocomplete popup + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); // first + doKey("down"); // second + doKey("down"); // third + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("testuser3", "testpass3"); +}); + +add_task(function* test_form1_fourth_entry() { + // Trigger autocomplete popup + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); // first + doKey("down"); // second + doKey("down"); // third + doKey("down"); // fourth + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("zzzuser4", "zzzpass4"); +}); + +add_task(function* test_form1_wraparound_first_entry() { + // Trigger autocomplete popup + restoreForm(); + yield spinEventLoop(); // let focus happen + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); // first + doKey("down"); // second + doKey("down"); // third + doKey("down"); // fourth + doKey("down"); // deselects + doKey("down"); // first + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("tempuser1", "temppass1"); +}); + +add_task(function* test_form1_wraparound_up_last_entry() { + // Trigger autocomplete popup + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("up"); // last (fourth) + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("zzzuser4", "zzzpass4"); +}); + +add_task(function* test_form1_wraparound_down_up_up() { + // Trigger autocomplete popup + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); // select first entry + doKey("up"); // selects nothing! + doKey("up"); // select last entry + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("zzzuser4", "zzzpass4"); +}); + +add_task(function* test_form1_wraparound_up_last() { + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); + doKey("up"); // deselects + doKey("up"); // last entry + doKey("up"); + doKey("up"); + doKey("up"); // first entry + doKey("up"); // deselects + doKey("up"); // last entry + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("zzzuser4", "zzzpass4"); +}); + +add_task(function* test_form1_fill_username_without_autofill_right() { + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + // Set first entry w/o triggering autocomplete + doKey("down"); // first + doKey("right"); + yield spinEventLoop(); + checkACForm("tempuser1", ""); // empty password +}); + +add_task(function* test_form1_fill_username_without_autofill_left() { + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + // Set first entry w/o triggering autocomplete + doKey("down"); // first + doKey("left"); + checkACForm("tempuser1", ""); // empty password +}); + +add_task(function* test_form1_pageup_first() { + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + // Check first entry (page up) + doKey("down"); // first + doKey("down"); // second + doKey("page_up"); // first + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("tempuser1", "temppass1"); +}); + +add_task(function* test_form1_pagedown_last() { + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + /* test 13 */ + // Check last entry (page down) + doKey("down"); // first + doKey("page_down"); // last + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("zzzuser4", "zzzpass4"); +}); + +add_task(function* test_form1_untrusted_event() { + restoreForm(); + yield spinEventLoop(); + + // Send a fake (untrusted) event. + checkACForm("", ""); + uname.value = "zzzuser4"; + sendFakeAutocompleteEvent(uname); + yield spinEventLoop(); + checkACForm("zzzuser4", ""); +}); + +add_task(function* test_form1_delete() { + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + // 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 = LoginManager.countLogins("http://mochi.test:8888", "http://autocomplete:8888", null); + is(numLogins, 5, "Correct number of logins before deleting one"); + + let countChangedPromise = notifyMenuChanged(3); + 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 = LoginManager.countLogins("http://mochi.test:8888", "http://autocomplete:8888", null); + is(numLogins, 4, "Correct number of logins after deleting one"); + yield countChangedPromise; + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("testuser2", "testpass2"); +}); + +add_task(function* test_form1_first_after_deletion() { + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + // Check the new first entry (of 3) + doKey("down"); + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("testuser2", "testpass2"); +}); + +add_task(function* test_form1_delete_second() { + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + // Delete the second entry (of 3), "testuser3" + doKey("down"); + doKey("down"); + doKey("delete", shiftModifier); + checkACForm("", ""); + 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(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + // Check the new first entry (of 2) + doKey("down"); + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("testuser2", "testpass2"); +}); + +add_task(function* test_form1_delete_last() { + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + /* test 54 */ + // Delete the last entry (of 2), "zzzuser4" + doKey("down"); + doKey("down"); + doKey("delete", shiftModifier); + checkACForm("", ""); + 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(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + // Check the only remaining entry + doKey("down"); + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("testuser2", "testpass2"); +}); + +add_task(function* test_form1_check_only_entry_remaining() { + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + /* test 56 */ + // Delete the only remaining entry, "testuser2" + doKey("down"); + doKey("delete", shiftModifier); + checkACForm("", ""); + 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() { + // Turn our attention to form2 + uname = $_(2, "uname"); + pword = $_(2, "pword"); + checkACForm("singleuser5", "singlepass5"); + + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + // Check first entry + doKey("down"); + checkACForm("", ""); // value shouldn't update + doKey("return"); // not "enter"! + yield promiseFormsProcessed(); + checkACForm("singleuser5", "singlepass5"); +}); + +add_task(function* test_form3() { + uname = $_(3, "uname"); + pword = $_(3, "pword"); + checkACForm("singleuser5", "singlepass5"); + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + // Check first entry + doKey("down"); + checkACForm("", ""); // value shouldn't update + doKey("return"); // not "enter"! + yield promiseFormsProcessed(); + checkACForm("singleuser5", "singlepass5"); +}); + +add_task(function* test_form4() { + uname = $_(4, "uname"); + pword = $_(4, "pword"); + checkACForm("singleuser5", "singlepass5"); + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + // Check first entry + doKey("down"); + checkACForm("", ""); // value shouldn't update + doKey("return"); // not "enter"! + yield promiseFormsProcessed(); + checkACForm("singleuser5", "singlepass5"); +}); + +add_task(function* test_form5() { + uname = $_(5, "uname"); + pword = $_(5, "pword"); + checkACForm("singleuser5", "singlepass5"); + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + // Check first entry + doKey("down"); + checkACForm("", ""); // value shouldn't update + doKey("return"); // not "enter"! + yield promiseFormsProcessed(); + checkACForm("singleuser5", "singlepass5"); +}); + +add_task(function* test_form6() { + // (this is a control, w/o autocomplete=off, to ensure the login + // that was being suppressed would have been filled in otherwise) + uname = $_(6, "uname"); + pword = $_(6, "pword"); + checkACForm("singleuser5", "singlepass5"); +}); + +add_task(function* test_form6_changeUsername() { + // Test that the password field remains filled in after changing + // the username. + uname.focus(); + doKey("right"); + sendChar("X"); + // Trigger the 'blur' event on uname + pword.focus(); + yield spinEventLoop(); + checkACForm("singleuser5X", "singlepass5"); + + setupScript.sendSyncMessage("removeLogin", "login5"); +}); + +add_task(function* test_form7() { + uname = $_(7, "uname"); + pword = $_(7, "pword"); + checkACForm("", ""); + + // Insert a new username field into the form. We'll then make sure + // that invoking the autocomplete doesn't try to fill the form. + var newField = document.createElement("input"); + newField.setAttribute("type", "text"); + newField.setAttribute("name", "uname2"); + pword.parentNode.insertBefore(newField, pword); + is($_(7, "uname2").value, "", "Verifying empty uname2"); + + // Delete login6B. It was created just to prevent filling in a login + // automatically, removing it makes it more likely that we'll catch a + // future regression with form filling here. + setupScript.sendSyncMessage("removeLogin", "login6B"); +}); + +add_task(function* test_form7_2() { + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + // Check first entry + doKey("down"); + checkACForm("", ""); // value shouldn't update + doKey("return"); // not "enter"! + // The form changes, so we expect the old username field to get the + // selected autocomplete value, but neither the new username field nor + // the password field should have any values filled in. + yield spinEventLoop(); + checkACForm("form7user1", ""); + is($_(7, "uname2").value, "", "Verifying empty uname2"); + restoreForm(); // clear field, so reloading test doesn't fail + + setupScript.sendSyncMessage("removeLogin", "login6A"); +}); + +add_task(function* test_form8() { + uname = $_(8, "uname"); + pword = $_(8, "pword"); + checkACForm("form8user", "form8pass"); + restoreForm(); +}); + +add_task(function* test_form8_blur() { + checkACForm("", ""); + // Focus the previous form to trigger a blur. + $_(7, "uname").focus(); +}); + +add_task(function* test_form8_2() { + checkACForm("", ""); + restoreForm(); +}); + +add_task(function* test_form8_3() { + checkACForm("", ""); + setupScript.sendSyncMessage("removeLogin", "login7"); +}); + +add_task(function* test_form9_filtering() { + // Turn our attention to form9 to test the dropdown - bug 497541 + uname = $_(9, "uname"); + pword = $_(9, "pword"); + uname.focus(); + let shownPromise = promiseACShown(); + sendString("form9userAB"); + yield shownPromise; + + checkACForm("form9userAB", ""); + uname.focus(); + doKey("left"); + shownPromise = promiseACShown(); + sendChar("A"); + let results = yield shownPromise; + + checkACForm("form9userAAB", ""); + checkArrayValues(results, ["form9userAAB"], "Check dropdown is updated after inserting 'A'"); + doKey("down"); + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("form9userAAB", "form9pass"); +}); + +add_task(function* test_form9_autocomplete_cache() { + // Note that this addLogin call will only be seen by the autocomplete + // attempt for the sendChar if we do not successfully cache the + // autocomplete results. + setupScript.sendSyncMessage("addLogin", "login8C"); + uname.focus(); + let promise0 = notifyMenuChanged(0); + sendChar("z"); + yield promise0; + let popupState = yield getPopupState(); + is(popupState.open, false, "Check popup shouldn't open"); + + // check that empty results are cached - bug 496466 + promise0 = notifyMenuChanged(0); + sendChar("z"); + yield promise0; + popupState = yield getPopupState(); + is(popupState.open, false, "Check popup stays closed due to cached empty result"); +}); + +add_task(function* test_form11_recipes() { + yield loadRecipes({ + siteRecipes: [{ + "hosts": ["mochi.test:8888"], + "usernameSelector": "input[name='1']", + "passwordSelector": "input[name='2']" + }], + }); + uname = $_(11, "1"); + pword = $_(11, "2"); + + // First test DOMAutocomplete + // Switch the password field to type=password so _fillForm marks the username + // field for autocomplete. + pword.type = "password"; + yield promiseFormsProcessed(); + restoreForm(); + checkACForm("", ""); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); + checkACForm("", ""); // value shouldn't update + doKey("return"); // not "enter"! + yield promiseFormsProcessed(); + checkACForm("testuser10", "testpass10"); + + // Now test recipes with blur on the username field. + restoreForm(); + checkACForm("", ""); + uname.value = "testuser10"; + checkACForm("testuser10", ""); + doKey("tab"); + yield promiseFormsProcessed(); + checkACForm("testuser10", "testpass10"); + yield resetRecipes(); +}); + +add_task(function* test_form12_formless() { + // Test form-less autocomplete + uname = $_(12, "uname"); + pword = $_(12, "pword"); + restoreForm(); + checkACForm("", ""); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + // Trigger autocomplete + doKey("down"); + checkACForm("", ""); // value shouldn't update + let processedPromise = promiseFormsProcessed(); + doKey("return"); // not "enter"! + yield processedPromise; + checkACForm("testuser", "testpass"); +}); + +add_task(function* test_form12_open_on_trusted_focus() { + uname = $_(12, "uname"); + pword = $_(12, "pword"); + uname.value = ""; + pword.value = ""; + + // Move focus to the password field so we can test the first click on the + // username field. + pword.focus(); + checkACForm("", ""); + const firePrivEventPromise = new Promise((resolve) => { + uname.addEventListener("click", (e) => { + ok(e.isTrusted, "Ensure event is trusted"); + resolve(); + }); + }); + const shownPromise = promiseACShown(); + synthesizeMouseAtCenter(uname, {}); + yield firePrivEventPromise; + yield shownPromise; + doKey("down"); + const processedPromise = promiseFormsProcessed(); + doKey("return"); // not "enter"! + yield processedPromise; + checkACForm("testuser", "testpass"); +}); +</script> +</pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_basic_form_html5.html b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_html5.html new file mode 100644 index 000000000..40e322afd --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_html5.html @@ -0,0 +1,164 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test for html5 input types (email, tel, url, etc.)</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.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: html5 input types (email, tel, url, etc.) +<script> +runChecksAfterCommonInit(() => startTest()); + +runInParent(function setup() { + const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; + let pwmgr = Cc["@mozilla.org/login-manager;1"]. + getService(Ci.nsILoginManager); + + login1 = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + login2 = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + login3 = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + login4 = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + + login1.init("http://mochi.test:8888", "http://bug600551-1", null, + "testuser@example.com", "testpass1", "", ""); + login2.init("http://mochi.test:8888", "http://bug600551-2", null, + "555-555-5555", "testpass2", "", ""); + login3.init("http://mochi.test:8888", "http://bug600551-3", null, + "http://mozilla.org", "testpass3", "", ""); + login4.init("http://mochi.test:8888", "http://bug600551-4", null, + "123456789", "testpass4", "", ""); + + pwmgr.addLogin(login1); + pwmgr.addLogin(login2); + pwmgr.addLogin(login3); + pwmgr.addLogin(login4); +}); +</script> + +<p id="display"></p> +<div id="content" style="display: none"> + + <form id="form1" action="http://bug600551-1"> + <input type="email" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <form id="form2" action="http://bug600551-2"> + <input type="tel" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <form id="form3" action="http://bug600551-3"> + <input type="url" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <form id="form4" action="http://bug600551-4"> + <input type="number" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <!-- The following forms should not be filled with usernames --> + <form id="form5" action="formtest.js"> + <input type="search" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <form id="form6" action="formtest.js"> + <input type="datetime" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <form id="form7" action="formtest.js"> + <input type="date" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <form id="form8" action="formtest.js"> + <input type="month" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <form id="form9" action="formtest.js"> + <input type="week" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <form id="form10" action="formtest.js"> + <input type="time" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <form id="form11" action="formtest.js"> + <input type="datetime-local" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <form id="form12" action="formtest.js"> + <input type="range" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <form id="form13" action="formtest.js"> + <input type="color" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + +</div> +<pre id="test"> +<script class="testbody" type="text/javascript"> + +/* Test for Login Manager: 600551 + (Password manager not working with input type=email) + */ +function startTest() { + checkForm(1, "testuser@example.com", "testpass1"); + checkForm(2, "555-555-5555", "testpass2"); + checkForm(3, "http://mozilla.org", "testpass3"); + checkForm(4, "123456789", "testpass4"); + + is($_(5, "uname").value, "", "type=search should not be considered a username"); + + is($_(6, "uname").value, "", "type=datetime should not be considered a username"); + + is($_(7, "uname").value, "", "type=date should not be considered a username"); + + is($_(8, "uname").value, "", "type=month should not be considered a username"); + + is($_(9, "uname").value, "", "type=week should not be considered a username"); + + is($_(10, "uname").value, "", "type=time should not be considered a username"); + + is($_(11, "uname").value, "", "type=datetime-local should not be considered a username"); + + is($_(12, "uname").value, "50", "type=range should not be considered a username"); + + is($_(13, "uname").value, "#000000", "type=color should not be considered a username"); + + SimpleTest.finish(); +} +</script> +</pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_basic_form_pwevent.html b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_pwevent.html new file mode 100644 index 000000000..e0a2883c8 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_pwevent.html @@ -0,0 +1,55 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=355063 +--> +<head> + <meta charset="utf-8"/> + <title>Test for Bug 355063</title> + <script type="application/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> + <script type="text/javascript" src="pwmgr_common.js"></script> + <script type="application/javascript"> + /** Test for Bug 355063 **/ + + runChecksAfterCommonInit(function startTest() { + info("startTest"); + // Password Manager's own listener should always have been added first, so + // the test's listener should be called after the pwmgr's listener fills in + // a login. + // + SpecialPowers.addChromeEventListener("DOMFormHasPassword", function eventFired() { + SpecialPowers.removeChromeEventListener("DOMFormHasPassword", eventFired); + var passField = $("p1"); + passField.addEventListener("input", checkForm); + }); + addForm(); + }); + + function addForm() { + info("addForm"); + var c = document.getElementById("content"); + c.innerHTML = "<form id=form1>form1: <input id=u1><input type=password id=p1></form><br>"; + } + + function checkForm() { + info("checkForm"); + var userField = document.getElementById("u1"); + var passField = document.getElementById("p1"); + is(userField.value, "testuser", "checking filled username"); + is(passField.value, "testpass", "checking filled password"); + + SimpleTest.finish(); + } +</script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=355063">Mozilla Bug 355063</a> +<p id="display"></p> +<div id="content"> +forms go here! +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_basic_form_pwonly.html b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_pwonly.html new file mode 100644 index 000000000..40fec8c46 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_pwonly.html @@ -0,0 +1,213 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test forms and logins without a username</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.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: forms and logins without a username. +<script> +runChecksAfterCommonInit(() => startTest()); +runInParent(() => { + const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; + var pwmgr = Cc["@mozilla.org/login-manager;1"] + .getService(Ci.nsILoginManager); + + var nsLoginInfo = Components.Constructor("@mozilla.org/login-manager/loginInfo;1", Ci.nsILoginInfo); + + // pwlogin1 uses a unique formSubmitURL, to check forms where no other logins + // will apply. pwlogin2 uses the normal formSubmitURL, so that we can test + // forms with a mix of username and non-username logins that might apply. + // + // Note: pwlogin2 is deleted at the end of the test. + + pwlogin1 = new nsLoginInfo(); + pwlogin2 = new nsLoginInfo(); + + pwlogin1.init("http://mochi.test:8888", "http://mochi.test:1111", null, + "", "1234", "uname", "pword"); + + pwlogin2.init("http://mochi.test:8888", "http://mochi.test:8888", null, + "", "1234", "uname", "pword"); + + + pwmgr.addLogin(pwlogin1); + pwmgr.addLogin(pwlogin2); +}); +</script> +<p id="display"></p> + +<div id="content" style="display: none"> + + +<!-- simple form: no username field, 1 password field --> +<form id='form1' action='http://mochi.test:1111/formtest.js'> 1 + <input type='password' name='pname' value=''> + + <button type='submit'>Submit</button> + <button type='reset'> Reset </button> +</form> + +<!-- simple form: no username field, 2 password fields --> +<form id='form2' action='http://mochi.test:1111/formtest.js'> 2 + <input type='password' name='pname1' value=''> + <input type='password' name='pname2' value=''> + + <button type='submit'>Submit</button> + <button type='reset'> Reset </button> +</form> + +<!-- simple form: no username field, 3 password fields --> +<form id='form3' action='http://mochi.test:1111/formtest.js'> 3 + <input type='password' name='pname1' value=''> + <input type='password' name='pname2' value=''> + <input type='password' name='pname3' value=''> + + <button type='submit'>Submit</button> + <button type='reset'> Reset </button> +</form> + +<!-- 4 password fields, should be ignored. --> +<form id='form4' action='http://mochi.test:1111/formtest.js'> 4 + <input type='password' name='pname1' value=''> + <input type='password' name='pname2' value=''> + <input type='password' name='pname3' value=''> + <input type='password' name='pname4' value=''> + + <button type='submit'>Submit</button> + <button type='reset'> Reset </button> +</form> + + + +<!-- 1 username field --> +<form id='form5' action='http://mochi.test:1111/formtest.js'> 5 + <input type='text' name='uname' value=''> + <input type='password' name='pname' value=''> + + <button type='submit'>Submit</button> + <button type='reset'> Reset </button> +</form> + + +<!-- 1 username field, with a value set --> +<form id='form6' action='http://mochi.test:1111/formtest.js'> 6 + <input type='text' name='uname' value='someuser'> + <input type='password' name='pname' value=''> + + <button type='submit'>Submit</button> + <button type='reset'> Reset </button> +</form> + + + +<!-- +(The following forms have 2 potentially-matching logins, on is +password-only, the other is username+password) +--> + + + +<!-- 1 username field, with value set. Fill in the matching U+P login --> +<form id='form7' action='formtest.js'> 7 + <input type='text' name='uname' value='testuser'> + <input type='password' name='pname' value=''> + + <button type='submit'>Submit</button> + <button type='reset'> Reset </button> +</form> + +<!-- 1 username field, with value set. Don't fill in U+P login--> +<form id='form8' action='formtest.js'> 8 + <input type='text' name='uname' value='someuser'> + <input type='password' name='pname' value=''> + + <button type='submit'>Submit</button> + <button type='reset'> Reset </button> +</form> + + + +<!-- 1 username field, too small for U+P login --> +<form id='form9' action='formtest.js'> 9 + <input type='text' name='uname' value='' maxlength="4"> + <input type='password' name='pname' value=''> + + <button type='submit'>Submit</button> + <button type='reset'> Reset </button> +</form> + +<!-- 1 username field, too small for U+P login --> +<form id='form10' action='formtest.js'> 10 + <input type='text' name='uname' value='' maxlength="0"> + <input type='password' name='pname' value=''> + + <button type='submit'>Submit</button> + <button type='reset'> Reset </button> +</form> + +<!-- 1 username field, too small for U+P login --> +<form id='form11' action='formtest.js'> 11 + <input type='text' name='uname' value=''> + <input type='password' name='pname' value='' maxlength="4"> + + <button type='submit'>Submit</button> + <button type='reset'> Reset </button> +</form> + +<!-- 1 username field, too small for either login --> +<form id='form12' action='formtest.js'> 12 + <input type='text' name='uname' value=''> + <input type='password' name='pname' value='' maxlength="1"> + + <button type='submit'>Submit</button> + <button type='reset'> Reset </button> +</form> + +<!-- 1 username field, too small for either login --> +<form id='form13' action='formtest.js'> 13 + <input type='text' name='uname' value=''> + <input type='password' name='pname' value='' maxlength="0"> + + <button type='submit'>Submit</button> + <button type='reset'> Reset </button> +</form> + + + +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +/** Test for Login Manager: password-only logins **/ +function startTest() { + + checkForm(1, "1234"); + checkForm(2, "1234", ""); + checkForm(3, "1234", "", ""); + checkUnmodifiedForm(4); + + checkForm(5, "", "1234"); + checkForm(6, "someuser", ""); + + checkForm(7, "testuser", "testpass"); + checkForm(8, "someuser", ""); + + checkForm(9, "", "1234"); + checkForm(10, "", "1234"); + checkForm(11, "", "1234"); + + checkUnmodifiedForm(12); + checkUnmodifiedForm(13); + + SimpleTest.finish(); +} +</script> +</pre> +</body> +</html> + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_bug_627616.html b/toolkit/components/passwordmgr/test/mochitest/test_bug_627616.html new file mode 100644 index 000000000..ad4a41cdb --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_bug_627616.html @@ -0,0 +1,145 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test bug 627616 related to proxy authentication</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.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 class="testbody" type="text/javascript"> + SimpleTest.waitForExplicitFinish(); + + var Ci = SpecialPowers.Ci; + + function makeXHR(expectedStatus, expectedText, extra) { + var xhr = new XMLHttpRequest(); + xhr.open("GET", "authenticate.sjs?" + + "proxy_user=proxy_user&" + + "proxy_pass=proxy_pass&" + + "proxy_realm=proxy_realm&" + + "user=user1name&" + + "pass=user1pass&" + + "realm=mochirealm&" + + extra || ""); + xhr.onloadend = function() { + is(xhr.status, expectedStatus, "xhr.status"); + is(xhr.statusText, expectedText, "xhr.statusText"); + runNextTest(); + }; + return xhr; + } + + function testNonAnonymousCredentials() { + var xhr = makeXHR(200, "OK"); + xhr.send(); + } + + function testAnonymousCredentials() { + // Test that an anonymous request correctly performs proxy authentication + var xhr = makeXHR(401, "Authentication required"); + SpecialPowers.wrap(xhr).channel.loadFlags |= Ci.nsIChannel.LOAD_ANONYMOUS; + xhr.send(); + } + + function testAnonymousNoAuth() { + // Next, test that an anonymous request still does not include any non-proxy + // authentication headers. + var xhr = makeXHR(200, "Authorization header not found", "anonymous=1"); + SpecialPowers.wrap(xhr).channel.loadFlags |= Ci.nsIChannel.LOAD_ANONYMOUS; + xhr.send(); + } + + var gExpectedDialogs = 0; + var gCurrentTest; + function runNextTest() { + is(gExpectedDialogs, 0, "received expected number of auth dialogs"); + mm.sendAsyncMessage("prepareForNextTest"); + mm.addMessageListener("prepareForNextTestDone", function prepared(msg) { + mm.removeMessageListener("prepareForNextTestDone", prepared); + if (pendingTests.length > 0) { + ({expectedDialogs: gExpectedDialogs, + test: gCurrentTest} = pendingTests.shift()); + gCurrentTest.call(this); + } else { + mm.sendAsyncMessage("cleanup"); + mm.addMessageListener("cleanupDone", () => { + // mm.destroy() is called as a cleanup function by runInParent(), no + // need to do it here. + SimpleTest.finish(); + }); + } + }); + } + + var pendingTests = [{expectedDialogs: 2, test: testNonAnonymousCredentials}, + {expectedDialogs: 1, test: testAnonymousCredentials}, + {expectedDialogs: 0, test: testAnonymousNoAuth}]; + + let mm = runInParent(() => { + const { classes: parentCc, interfaces: parentCi, utils: parentCu } = Components; + + parentCu.import("resource://gre/modules/Services.jsm"); + parentCu.import("resource://gre/modules/NetUtil.jsm"); + parentCu.import("resource://gre/modules/Timer.jsm"); + parentCu.import("resource://gre/modules/XPCOMUtils.jsm"); + + let channel = NetUtil.newChannel({ + uri: "http://example.com", + loadUsingSystemPrincipal: true + }); + + let pps = parentCc["@mozilla.org/network/protocol-proxy-service;1"]. + getService(parentCi.nsIProtocolProxyService); + pps.asyncResolve(channel, 0, { + onProxyAvailable(req, uri, pi, status) { + let mozproxy = "moz-proxy://" + pi.host + ":" + pi.port; + let login = parentCc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(parentCi.nsILoginInfo); + login.init(mozproxy, null, "proxy_realm", "proxy_user", "proxy_pass", + "", ""); + Services.logins.addLogin(login); + + let login2 = parentCc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(parentCi.nsILoginInfo); + login2.init("http://mochi.test:8888", null, "mochirealm", "user1name", + "user1pass", "", ""); + Services.logins.addLogin(login2); + + sendAsyncMessage("setupDone"); + }, + QueryInterface: XPCOMUtils.generateQI([parentCi.nsIProtocolProxyCallback]), + }); + + addMessageListener("prepareForNextTest", message => { + parentCc["@mozilla.org/network/http-auth-manager;1"]. + getService(parentCi.nsIHttpAuthManager). + clearAll(); + sendAsyncMessage("prepareForNextTestDone"); + }); + + let dialogObserverTopic = "common-dialog-loaded"; + + function dialogObserver(subj, topic, data) { + subj.Dialog.ui.prompt.document.documentElement.acceptDialog(); + sendAsyncMessage("promptAccepted"); + } + + Services.obs.addObserver(dialogObserver, dialogObserverTopic, false); + + addMessageListener("cleanup", message => { + Services.obs.removeObserver(dialogObserver, dialogObserverTopic); + sendAsyncMessage("cleanupDone"); + }); + }); + + mm.addMessageListener("promptAccepted", msg => { + gExpectedDialogs--; + }); + mm.addMessageListener("setupDone", msg => { + runNextTest(); + }); +</script> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_bug_776171.html b/toolkit/components/passwordmgr/test/mochitest/test_bug_776171.html new file mode 100644 index 000000000..4ad08bee2 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_bug_776171.html @@ -0,0 +1,56 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=776171 +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug 776171 related to HTTP auth</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="pwmgr_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body onload="startTest()"> +<script class="testbody" type="text/javascript"> + +/** + * This test checks we correctly ignore authentication entry + * for a subpath and use creds from the URL when provided when XHR + * is used with filled user name and password. + * + * 1. connect auth2/authenticate.sjs that expects user1:pass1 password + * 2. connect a dummy URL at the same path + * 3. connect authenticate.sjs that again expects user1:pass1 password + * in this case, however, we have an entry without an identity + * for this path (that is a parent for auth2 path in the first step) + */ + +SimpleTest.waitForExplicitFinish(); + +function doxhr(URL, user, pass, next) { + var xhr = new XMLHttpRequest(); + if (user && pass) + xhr.open("POST", URL, true, user, pass); + else + xhr.open("POST", URL, true); + xhr.onload = function() { + is(xhr.status, 200, "Got status 200"); + next(); + }; + xhr.onerror = function() { + ok(false, "request passed"); + finishTest(); + }; + xhr.send(); +} + +function startTest() { + doxhr("auth2/authenticate.sjs?user=user1&pass=pass1&realm=realm1", "user1", "pass1", function() { + doxhr("auth2", null, null, function() { + doxhr("authenticate.sjs?user=user1&pass=pass1&realm=realm1", "user1", "pass1", SimpleTest.finish); + }); + }); +} +</script> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_case_differences.html b/toolkit/components/passwordmgr/test/mochitest/test_case_differences.html new file mode 100644 index 000000000..316f59da7 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_case_differences.html @@ -0,0 +1,147 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test autocomplete due to multiple matching 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> +runChecksAfterCommonInit(false); + +SpecialPowers.loadChromeScript(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", + Ci.nsILoginInfo, "init"); + + var login0 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null, + "name", "pass", "uname", "pword"); + + var login1 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null, + "Name", "Pass", "uname", "pword"); + + var login2 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null, + "USER", "PASS", "uname", "pword"); + + try { + Services.logins.addLogin(login0); + Services.logins.addLogin(login1); + Services.logins.addLogin(login2); + } 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> + +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +/** Test for Login Manager: autocomplete due to multiple matching logins **/ + +var uname = $_(1, "uname"); +var pword = $_(1, "pword"); + +// Restore the form to the default state. +function restoreForm() { + uname.value = ""; + pword.value = ""; + uname.focus(); +} + +// Check for expected username/password in form. +function checkACForm(expectedUsername, expectedPassword) { + var formID = uname.parentNode.id; + is(uname.value, expectedUsername, "Checking " + formID + " username"); + is(pword.value, expectedPassword, "Checking " + formID + " 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"); + + // Check first entry + let index0Promise = notifySelectedIndex(0); + doKey("down"); + yield index0Promise; + checkACForm("", ""); // value shouldn't update + doKey("return"); // not "enter"! + yield promiseFormsProcessed(); + checkACForm("name", "pass"); +}); + +add_task(function* test_empty_second_entry() { + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + doKey("down"); // first + doKey("down"); // second + doKey("return"); // not "enter"! + yield promiseFormsProcessed(); + checkACForm("Name", "Pass"); +}); + +add_task(function* test_empty_third_entry() { + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + doKey("down"); // first + doKey("down"); // second + doKey("down"); // third + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("USER", "PASS"); +}); + +add_task(function* test_preserve_matching_username_case() { + restoreForm(); + uname.value = "user"; + let shownPromise = promiseACShown(); + doKey("down"); // open + 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"); +}); +</script> +</pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_form_action_1.html b/toolkit/components/passwordmgr/test/mochitest/test_form_action_1.html new file mode 100644 index 000000000..430081b3a --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_form_action_1.html @@ -0,0 +1,137 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test for considering form action</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.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: Bug 360493 +<script> +runChecksAfterCommonInit(() => startTest()); +</script> +<p id="display"></p> +<div id="content" style="display: none"> + + <!-- normal form with normal relative action. --> + <form id="form1" action="formtest.js"> + <input type="text" name="uname"> + <input type="password" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- fully specify the action URL --> + <form id="form2" action="http://mochi.test:8888/tests/toolkit/components/passwordmgr/test/formtest.js"> + <input type="text" name="uname"> + <input type="password" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- fully specify the action URL, and change the path --> + <form id="form3" action="http://mochi.test:8888/zomg/wtf/bbq/passwordmgr/test/formtest.js"> + <input type="text" name="uname"> + <input type="password" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- fully specify the action URL, and change the path and filename --> + <form id="form4" action="http://mochi.test:8888/zomg/wtf/bbq/passwordmgr/test/not_a_test.js"> + <input type="text" name="uname"> + <input type="password" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- specify the action URL relative to the current document--> + <form id="form5" action="./formtest.js"> + <input type="text" name="uname"> + <input type="password" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- specify the action URL relative to the current server --> + <form id="form6" action="/tests/toolkit/components/passwordmgr/test/formtest.js"> + <input type="text" name="uname"> + <input type="password" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- Change the method from get to post --> + <form id="form7" action="formtest.js" method="POST"> + <input type="text" name="uname"> + <input type="password" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- Blank action URL specified --> + <form id="form8" action=""> + <input type="text" name="uname"> + <input type="password" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- |action| attribute entirely missing --> + <form id="form9" > + <input type="text" name="uname"> + <input type="password" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- action url as javascript --> + <form id="form10" action="javascript:alert('this form is not submitted so this alert should not be invoked');"> + <input type="text" name="uname"> + <input type="password" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- TODO: action=IP.ADDRESS instead of HOSTNAME? --> + <!-- TODO: test with |base href="http://othersite//"| ? --> +</div> +<pre id="test"> +<script class="testbody" type="text/javascript"> + +/** Test for Login Manager: 360493 (Cross-Site Forms + Password + Manager = Security Failure) **/ + +// This test is designed to make sure variations on the form's |action| +// and |method| continue to work with the fix for 360493. + +function startTest() { + for (var i = 1; i <= 9; i++) { + // Check form i + is($_(i, "uname").value, "testuser", "Checking for filled username " + i); + is($_(i, "pword").value, "testpass", "Checking for filled password " + i); + } + + // The login's formSubmitURL isn't "javascript:", so don't fill it in. + isnot($_(10, "uname"), "testuser", "Checking username w/ JS action URL"); + isnot($_(10, "pword"), "testpass", "Checking password w/ JS action URL"); + + SimpleTest.finish(); +} +</script> +</pre> +</body> +</html> + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_form_action_2.html b/toolkit/components/passwordmgr/test/mochitest/test_form_action_2.html new file mode 100644 index 000000000..0f0056de0 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_form_action_2.html @@ -0,0 +1,170 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test for considering form action</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.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: Bug 360493 +<script> +runChecksAfterCommonInit(() => startTest()); +</script> +<p id="display"></p> +<div id="content" style="display: none"> + + <!-- The tests in this page exercise things that shouldn't work. --> + + <!-- Change port # of action URL from 8888 to 7777 --> + <form id="form1" action="http://localhost:7777/tests/toolkit/components/passwordmgr/test/formtest.js"> + <input type="text" name="uname"> + <input type="password" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- No port # in action URL --> + <form id="form2" action="http://localhost/tests/toolkit/components/passwordmgr/test/formtest.js"> + <input type="text" name="uname"> + <input type="password" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- Change protocol from http:// to ftp://, include the expected 8888 port # --> + <form id="form3" action="ftp://localhost:8888/tests/toolkit/components/passwordmgr/test/formtest.js"> + <input type="text" name="uname"> + <input type="password" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- Change protocol from http:// to ftp://, no port # specified --> + <form id="form4" action="ftp://localhost/tests/toolkit/components/passwordmgr/test/formtest.js"> + <input type="text" name="uname"> + <input type="password" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- Try a weird URL. --> + <form id="form5" action="about:blank"> + <input type="text" name="uname"> + <input type="password" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- Try a weird URL. (If the normal embedded action URL doesn't work, that should mean other URLs won't either) --> + <form id="form6" action="view-source:http://localhost:8888/tests/toolkit/components/passwordmgr/test/formtest.js"> + <input type="text" name="uname"> + <input type="password" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- Try a weird URL. --> + <form id="form7" action="view-source:formtest.js"> + <input type="text" name="uname"> + <input type="password" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- Action URL points to a different host (this is the archetypical exploit) --> + <form id="form8" action="http://www.cnn.com/"> + <input type="text" name="uname"> + <input type="password" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- Action URL points to a different host, user field prefilled --> + <form id="form9" action="http://www.cnn.com/"> + <input type="text" name="uname" value="testuser"> + <input type="password" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- Try wrapping a evil form around a good form, to see if we can confuse the parser. --> + <form id="form10-A" action="http://www.cnn.com/"> + <form id="form10-B" action="formtest.js"> + <input type="text" name="uname"> + <input type="password" name="pword"> + + <button type="submit">Submit (inner)</button> + <button type="reset"> Reset (inner)</button> + </form> + <button type="submit" id="neutered_submit10">Submit (outer)</button> + <button type="reset">Reset (outer)</button> + </form> + + <!-- Try wrapping a good form around an evil form, to see if we can confuse the parser. --> + <form id="form11-A" action="formtest.js"> + <form id="form11-B" action="http://www.cnn.com/"> + <input type="text" name="uname"> + <input type="password" name="pword"> + + <button type="submit">Submit (inner)</button> + <button type="reset"> Reset (inner)</button> + </form> + <button type="submit" id="neutered_submit11">Submit (outer)</button> + <button type="reset">Reset (outer)</button> + </form> + +<!-- TODO: probably should have some accounts which have no port # in the action url. JS too. And different host/proto. --> +<!-- TODO: www.site.com vs. site.com? --> +<!-- TODO: foo.site.com vs. bar.site.com? --> + +</div> +<pre id="test"> +<script class="testbody" type="text/javascript"> + +/** Test for Login Manager: 360493 (Cross-Site Forms + Password Manager = Security Failure) **/ + +function startTest() { + for (var i = 1; i <= 8; i++) { + // Check form i + is($_(i, "uname").value, "", "Checking for unfilled username " + i); + is($_(i, "pword").value, "", "Checking for unfilled password " + i); + } + + is($_(9, "uname").value, "testuser", "Checking for unmodified username 9"); + is($_(9, "pword").value, "", "Checking for unfilled password 9"); + + is($_("10-A", "uname").value, "", "Checking for unfilled username 10A"); + is($_("10-A", "pword").value, "", "Checking for unfilled password 10A"); + + // The DOM indicates this form could be filled, as the evil inner form + // is discarded. And yet pwmgr seems not to fill it. Not sure why. + todo(false, "Mangled form combo not being filled when maybe it could be?"); + is($_("11-A", "uname").value, "testuser", "Checking filled username 11A"); + is($_("11-A", "pword").value, "testpass", "Checking filled password 11A"); + + // Verify this by making sure there are no extra forms in the document, and + // that the submit button for the neutered forms don't do anything. + // If the test finds extra forms the submit() causes the test to timeout, then + // there may be a security issue. + is(document.forms.length, 11, "Checking for unexpected forms"); + $("neutered_submit10").click(); + $("neutered_submit11").click(); + + SimpleTest.finish(); +} +</script> +</pre> +</body> +</html> + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_form_action_javascript.html b/toolkit/components/passwordmgr/test/mochitest/test_form_action_javascript.html new file mode 100644 index 000000000..d37e92c40 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_form_action_javascript.html @@ -0,0 +1,52 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test forms with a JS submit action</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.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: form with JS submit action +<script> +runChecksAfterCommonInit(() => startTest()); + +runInParent(function setup() { + const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; + Cu.import("resource://gre/modules/Services.jsm"); + + let jslogin = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo); + jslogin.init("http://mochi.test:8888", "javascript:", null, + "jsuser", "jspass123", "uname", "pword"); + Services.logins.addLogin(jslogin); +}); + +/** Test for Login Manager: JS action URL **/ + +function startTest() { + checkForm(1, "jsuser", "jspass123"); + + SimpleTest.finish(); +} +</script> + +<p id="display"></p> + +<div id="content" style="display: none"> + + +<form id='form1' action='javascript:alert("never shows")'> 1 + <input name="uname"> + <input name="pword" type="password"> + + <button type='submit'>Submit</button> + <button type='reset'> Reset </button> +</form> + +</div> + +<pre id="test"></pre> +</body> +</html> + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_formless_autofill.html b/toolkit/components/passwordmgr/test/mochitest/test_formless_autofill.html new file mode 100644 index 000000000..6263c818d --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_formless_autofill.html @@ -0,0 +1,147 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Test autofilling of fields outside of a form</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.js"></script> + <script src="pwmgr_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<script type="application/javascript;version=1.8"> +let chromeScript = runInParent(SimpleTest.getTestFileURL("pwmgr_common.js")); + +document.addEventListener("DOMContentLoaded", () => { + document.getElementById("loginFrame").addEventListener("load", (evt) => { + // Tell the parent to setup test logins. + chromeScript.sendAsyncMessage("setupParent", { selfFilling: true }); + }); +}); + +let doneSetupPromise = new Promise(resolve => { + // When the setup is done, load a recipe for this test. + chromeScript.addMessageListener("doneSetup", function doneSetup() { + resolve(); + }); +}); + +add_task(function* setup() { + info("Waiting for loads and setup"); + yield doneSetupPromise; + + yield loadRecipes({ + siteRecipes: [{ + hosts: ["mochi.test:8888"], + usernameSelector: "input[name='recipeuname']", + passwordSelector: "input[name='recipepword']", + }], + }); +}); + + +const DEFAULT_ORIGIN = "http://mochi.test:8888"; +const TESTCASES = [ + { + // Inputs + document: `<input type=password>`, + + // Expected outputs + expectedInputValues: ["testpass"], + }, + { + document: `<input> + <input type=password>`, + expectedInputValues: ["testuser", "testpass"], + }, + { + document: `<input> + <input type=password> + <input type=password>`, + expectedInputValues: ["testuser", "testpass", ""], + }, + { + document: `<input> + <input type=password> + <input type=password> + <input type=password>`, + expectedInputValues: ["testuser", "testpass", "", ""], + }, + { + document: `<input> + <input type=password form="form1"> + <input type=password> + <form id="form1"> + <input> + <input type=password> + </form>`, + expectedFormCount: 2, + expectedInputValues: ["testuser", "testpass", "testpass", "", ""], + }, + { + document: `<!-- formless password field selector recipe test --> + <input> + <input type=password> + <input> + <input type=password name="recipepword">`, + expectedInputValues: ["", "", "testuser", "testpass"], + }, + { + document: `<!-- formless username and password field selector recipe test --> + <input name="recipeuname"> + <input> + <input type=password> + <input type=password name="recipepword">`, + expectedInputValues: ["testuser", "", "", "testpass"], + }, + { + document: `<!-- form and formless recipe field selector test --> + <input name="recipeuname"> + <input> + <input type=password form="form1"> <!-- not filled since recipe affects both FormLikes --> + <input type=password> + <input type=password name="recipepword"> + <form id="form1"> + <input> + <input type=password> + </form>`, + expectedFormCount: 2, + expectedInputValues: ["testuser", "", "", "", "testpass", "", ""], + }, +]; + +add_task(function* test() { + let loginFrame = document.getElementById("loginFrame"); + let frameDoc = loginFrame.contentWindow.document; + + for (let tc of TESTCASES) { + info("Starting testcase: " + JSON.stringify(tc)); + + let numFormLikesExpected = tc.expectedFormCount || 1; + + let processedFormPromise = promiseFormsProcessed(numFormLikesExpected); + + frameDoc.documentElement.innerHTML = tc.document; + info("waiting for " + numFormLikesExpected + " processed form(s)"); + yield processedFormPromise; + + let testInputs = frameDoc.documentElement.querySelectorAll("input"); + is(testInputs.length, tc.expectedInputValues.length, "Check number of inputs"); + for (let i = 0; i < tc.expectedInputValues.length; i++) { + let expectedValue = tc.expectedInputValues[i]; + is(testInputs[i].value, expectedValue, + "Check expected input value " + i + ": " + expectedValue); + } + } +}); + +</script> + +<p id="display"></p> + +<div id="content"> + <iframe id="loginFrame" src="http://mochi.test:8888/tests/toolkit/components/passwordmgr/test/blank.html"></iframe> +</div> +<pre id="test"></pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_formless_submit.html b/toolkit/components/passwordmgr/test/mochitest/test_formless_submit.html new file mode 100644 index 000000000..468da1e7f --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_formless_submit.html @@ -0,0 +1,183 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Test capturing of fields outside of a form</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/SpawnTask.js"></script> + <script src="pwmgr_common.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<script type="application/javascript;version=1.8"> +const LMCBackstagePass = SpecialPowers.Cu.import("resource://gre/modules/LoginManagerContent.jsm"); +const { LoginManagerContent, LoginFormFactory } = LMCBackstagePass; + +let chromeScript = runInParent(SimpleTest.getTestFileURL("pwmgr_common.js")); + +let loadPromise = new Promise(resolve => { + document.addEventListener("DOMContentLoaded", () => { + document.getElementById("loginFrame").addEventListener("load", (evt) => { + resolve(); + }); + }); +}); + +add_task(function* setup() { + info("Waiting for page and frame loads"); + yield loadPromise; + + yield loadRecipes({ + siteRecipes: [{ + hosts: ["mochi.test:8888"], + usernameSelector: "input[name='recipeuname']", + passwordSelector: "input[name='recipepword']", + }], + }); +}); + +const DEFAULT_ORIGIN = "http://mochi.test:8888"; +const TESTCASES = [ + { + // Inputs + document: `<input type=password value="pass1">`, + inputIndexForFormLike: 0, + + // Expected outputs similar to RemoteLogins:onFormSubmit + hostname: DEFAULT_ORIGIN, + formSubmitURL: DEFAULT_ORIGIN, + usernameFieldValue: null, + newPasswordFieldValue: "pass1", + oldPasswordFieldValue: null, + }, + { + document: `<input value="user1"> + <input type=password value="pass1">`, + inputIndexForFormLike: 0, + hostname: DEFAULT_ORIGIN, + formSubmitURL: DEFAULT_ORIGIN, + usernameFieldValue: "user1", + newPasswordFieldValue: "pass1", + oldPasswordFieldValue: null, + }, + { + document: `<input value="user1"> + <input type=password value="pass1">`, + inputIndexForFormLike: 1, + hostname: DEFAULT_ORIGIN, + formSubmitURL: DEFAULT_ORIGIN, + usernameFieldValue: "user1", + newPasswordFieldValue: "pass1", + oldPasswordFieldValue: null, + }, + { + document: `<input value="user1"> + <input type=password value="pass1"> + <input type=password value="pass2">`, + inputIndexForFormLike: 2, + hostname: DEFAULT_ORIGIN, + formSubmitURL: DEFAULT_ORIGIN, + usernameFieldValue: "user1", + newPasswordFieldValue: "pass2", + oldPasswordFieldValue: "pass1", + }, + { + document: `<input value="user1"> + <input type=password value="pass1"> + <input type=password value="pass2"> + <input type=password value="pass2">`, + inputIndexForFormLike: 3, + hostname: DEFAULT_ORIGIN, + formSubmitURL: DEFAULT_ORIGIN, + usernameFieldValue: "user1", + newPasswordFieldValue: "pass2", + oldPasswordFieldValue: "pass1", + }, + { + document: `<input value="user1"> + <input type=password value="user2" form="form1"> + <input type=password value="pass1"> + <form id="form1"> + <input value="user3"> + <input type=password value="pass2"> + </form>`, + inputIndexForFormLike: 2, + hostname: DEFAULT_ORIGIN, + formSubmitURL: DEFAULT_ORIGIN, + usernameFieldValue: "user1", + newPasswordFieldValue: "pass1", + oldPasswordFieldValue: null, + }, + { + document: `<!-- recipe field override --> + <input name="recipeuname" value="username from recipe"> + <input value="default field username"> + <input type=password value="pass1"> + <input name="recipepword" type=password value="pass2">`, + inputIndexForFormLike: 2, + hostname: DEFAULT_ORIGIN, + formSubmitURL: DEFAULT_ORIGIN, + usernameFieldValue: "username from recipe", + newPasswordFieldValue: "pass2", + oldPasswordFieldValue: null, + }, +]; + +function getSubmitMessage() { + info("getSubmitMessage"); + return new Promise((resolve, reject) => { + chromeScript.addMessageListener("formSubmissionProcessed", function processed(...args) { + info("got formSubmissionProcessed"); + chromeScript.removeMessageListener("formSubmissionProcessed", processed); + resolve(...args); + }); + }); +} + +add_task(function* test() { + let loginFrame = document.getElementById("loginFrame"); + let frameDoc = loginFrame.contentWindow.document; + + for (let tc of TESTCASES) { + info("Starting testcase: " + JSON.stringify(tc)); + frameDoc.documentElement.innerHTML = tc.document; + let inputForFormLike = frameDoc.querySelectorAll("input")[tc.inputIndexForFormLike]; + + let formLike = LoginFormFactory.createFromField(inputForFormLike); + + info("Calling _onFormSubmit with FormLike"); + let processedPromise = getSubmitMessage(); + LoginManagerContent._onFormSubmit(formLike); + + let submittedResult = yield processedPromise; + + // Check data sent via RemoteLogins:onFormSubmit + is(submittedResult.hostname, tc.hostname, "Check hostname"); + is(submittedResult.formSubmitURL, tc.formSubmitURL, "Check formSubmitURL"); + + if (tc.usernameFieldValue === null) { + is(submittedResult.usernameField, tc.usernameFieldValue, "Check usernameField"); + } else { + is(submittedResult.usernameField.value, tc.usernameFieldValue, "Check usernameField"); + } + + is(submittedResult.newPasswordField.value, tc.newPasswordFieldValue, "Check newPasswordFieldValue"); + + if (tc.oldPasswordFieldValue === null) { + is(submittedResult.oldPasswordField, tc.oldPasswordFieldValue, "Check oldPasswordFieldValue"); + } else { + is(submittedResult.oldPasswordField.value, tc.oldPasswordFieldValue, "Check oldPasswordFieldValue"); + } + } +}); + +</script> + +<p id="display"></p> + +<div id="content"> + <iframe id="loginFrame" src="http://mochi.test:8888/tests/toolkit/components/passwordmgr/test/blank.html"></iframe> +</div> +<pre id="test"></pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_formless_submit_navigation.html b/toolkit/components/passwordmgr/test/mochitest/test_formless_submit_navigation.html new file mode 100644 index 000000000..b07d0886c --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_formless_submit_navigation.html @@ -0,0 +1,191 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Test capturing of fields outside of a form due to navigation</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/SpawnTask.js"></script> + <script src="pwmgr_common.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<script type="application/javascript;version=1.8"> +const LMCBackstagePass = SpecialPowers.Cu.import("resource://gre/modules/LoginManagerContent.jsm"); +const { LoginManagerContent, LoginFormFactory } = LMCBackstagePass; + +let chromeScript = runInParent(SimpleTest.getTestFileURL("pwmgr_common.js")); + +let loadPromise = new Promise(resolve => { + document.addEventListener("DOMContentLoaded", () => { + document.getElementById("loginFrame").addEventListener("load", (evt) => { + resolve(); + }); + }); +}); + +add_task(function* setup() { + yield SpecialPowers.pushPrefEnv({ + set: [ + ["signon.formlessCapture.enabled", true], + ], + }); + + info("Waiting for page and frame loads"); + yield loadPromise; + + yield loadRecipes({ + siteRecipes: [{ + hosts: ["test1.mochi.test:8888"], + usernameSelector: "input[name='recipeuname']", + passwordSelector: "input[name='recipepword']", + }], + }); +}); + +const DEFAULT_ORIGIN = "http://test1.mochi.test:8888"; +const SCRIPTS = { + PUSHSTATE: `history.pushState({}, "Pushed state", "?pushed");`, + WINDOW_LOCATION: `window.location = "data:text/html;charset=utf-8,window.location";`, +}; +const TESTCASES = [ + { + // Inputs + document: `<input type=password value="pass1">`, + + // Expected outputs similar to RemoteLogins:onFormSubmit + hostname: DEFAULT_ORIGIN, + formSubmitURL: DEFAULT_ORIGIN, + usernameFieldValue: null, + newPasswordFieldValue: "pass1", + oldPasswordFieldValue: null, + }, + { + document: `<input value="user1"> + <input type=password value="pass1">`, + + hostname: DEFAULT_ORIGIN, + formSubmitURL: DEFAULT_ORIGIN, + usernameFieldValue: "user1", + newPasswordFieldValue: "pass1", + oldPasswordFieldValue: null, + }, + { + document: `<input value="user1"> + <input type=password value="pass1"> + <input type=password value="pass2">`, + + hostname: DEFAULT_ORIGIN, + formSubmitURL: DEFAULT_ORIGIN, + usernameFieldValue: "user1", + newPasswordFieldValue: "pass2", + oldPasswordFieldValue: "pass1", + }, + { + document: `<input value="user1"> + <input type=password value="pass1"> + <input type=password value="pass2"> + <input type=password value="pass2">`, + + hostname: DEFAULT_ORIGIN, + formSubmitURL: DEFAULT_ORIGIN, + usernameFieldValue: "user1", + newPasswordFieldValue: "pass2", + oldPasswordFieldValue: "pass1", + }, + { + document: `<input value="user1"> + <input type=password value="user2" form="form1"> + <input type=password value="pass1"> + <form id="form1"> + <input value="user3"> + <input type=password value="pass2"> + </form>`, + + hostname: DEFAULT_ORIGIN, + formSubmitURL: DEFAULT_ORIGIN, + usernameFieldValue: "user1", + newPasswordFieldValue: "pass1", + oldPasswordFieldValue: null, + }, + { + document: `<!-- recipe field override --> + <input name="recipeuname" value="username from recipe"> + <input value="default field username"> + <input type=password value="pass1"> + <input name="recipepword" type=password value="pass2">`, + + hostname: DEFAULT_ORIGIN, + formSubmitURL: DEFAULT_ORIGIN, + usernameFieldValue: "username from recipe", + newPasswordFieldValue: "pass2", + oldPasswordFieldValue: null, + }, +]; + +function getSubmitMessage() { + info("getSubmitMessage"); + return new Promise((resolve, reject) => { + chromeScript.addMessageListener("formSubmissionProcessed", function processed(...args) { + info("got formSubmissionProcessed"); + chromeScript.removeMessageListener("formSubmissionProcessed", processed); + resolve(...args); + }); + }); +} + +add_task(function* test() { + let loginFrame = document.getElementById("loginFrame"); + + for (let tc of TESTCASES) { + for (let scriptName of Object.keys(SCRIPTS)) { + info("Starting testcase with script " + scriptName + ": " + JSON.stringify(tc)); + let loadedPromise = new Promise((resolve) => { + loginFrame.addEventListener("load", function frameLoaded() { + loginFrame.removeEventListener("load", frameLoaded); + resolve(); + }); + }); + loginFrame.src = DEFAULT_ORIGIN + "/tests/toolkit/components/passwordmgr/test/mochitest/blank.html"; + yield loadedPromise; + + let frameDoc = SpecialPowers.wrap(loginFrame.contentWindow).document; + frameDoc.documentElement.innerHTML = tc.document; + // Wait for the form to be processed before trying to submit. + yield promiseFormsProcessed(); + let processedPromise = getSubmitMessage(); + info("Running " + scriptName + " script to cause a submission"); + frameDoc.defaultView.eval(SCRIPTS[scriptName]); + + let submittedResult = yield processedPromise; + + // Check data sent via RemoteLogins:onFormSubmit + is(submittedResult.hostname, tc.hostname, "Check hostname"); + is(submittedResult.formSubmitURL, tc.formSubmitURL, "Check formSubmitURL"); + + if (tc.usernameFieldValue === null) { + is(submittedResult.usernameField, tc.usernameFieldValue, "Check usernameField"); + } else { + is(submittedResult.usernameField.value, tc.usernameFieldValue, "Check usernameField"); + } + + is(submittedResult.newPasswordField.value, tc.newPasswordFieldValue, "Check newPasswordFieldValue"); + + if (tc.oldPasswordFieldValue === null) { + is(submittedResult.oldPasswordField, tc.oldPasswordFieldValue, "Check oldPasswordFieldValue"); + } else { + is(submittedResult.oldPasswordField.value, tc.oldPasswordFieldValue, "Check oldPasswordFieldValue"); + } + } + } +}); + +</script> + +<p id="display"></p> + +<div id="content"> + <iframe id="loginFrame" src="http://test1.mochi.test:8888/tests/toolkit/components/passwordmgr/test/mochitest/blank.html"></iframe> +</div> +<pre id="test"></pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_formless_submit_navigation_negative.html b/toolkit/components/passwordmgr/test/mochitest/test_formless_submit_navigation_negative.html new file mode 100644 index 000000000..4283f128c --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_formless_submit_navigation_negative.html @@ -0,0 +1,121 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Test no capturing of fields outside of a form due to navigation</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/SpawnTask.js"></script> + <script src="pwmgr_common.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<script type="application/javascript;version=1.8"> +const LMCBackstagePass = SpecialPowers.Cu.import("resource://gre/modules/LoginManagerContent.jsm"); +const { LoginManagerContent, LoginFormFactory } = LMCBackstagePass; + +SimpleTest.requestFlakyTimeout("Testing that a message doesn't arrive"); + +let chromeScript = runInParent(SimpleTest.getTestFileURL("pwmgr_common.js")); + +let loadPromise = new Promise(resolve => { + document.addEventListener("DOMContentLoaded", () => { + document.getElementById("loginFrame").addEventListener("load", (evt) => { + resolve(); + }); + }); +}); + +function submissionProcessed(...args) { + ok(false, "No formSubmissionProcessed should occur in this test"); + info("got: " + JSON.stringify(args)); +} + +add_task(function* setup() { + yield SpecialPowers.pushPrefEnv({ + set: [ + ["signon.formlessCapture.enabled", true], + ], + }); + + info("Waiting for page and frame loads"); + yield loadPromise; + + chromeScript.addMessageListener("formSubmissionProcessed", submissionProcessed); + + SimpleTest.registerCleanupFunction(() => { + chromeScript.removeMessageListener("formSubmissionProcessed", submissionProcessed); + }); +}); + +const DEFAULT_ORIGIN = "http://test1.mochi.test:8888"; +const SCRIPTS = { + PUSHSTATE: `history.pushState({}, "Pushed state", "?pushed");`, + WINDOW_LOCATION: `window.location = "data:text/html;charset=utf-8,window.location";`, + WINDOW_LOCATION_RELOAD: `window.location.reload();`, + HISTORY_BACK: `history.back();`, + HISTORY_GO_MINUS1: `history.go(-1);`, +}; +const TESTCASES = [ + // Begin test cases that shouldn't trigger capture. + { + // For now we don't trigger upon navigation if <form> is used. + document: `<form><input type=password value="pass1"></form>`, + }, + { + // Empty password field + document: `<input type=password value="">`, + }, + { + // Test with an input that would normally be captured but with SCRIPTS that + // shouldn't trigger capture. + document: `<input type=password value="pass2">`, + wouldCapture: true, + }, +]; + +add_task(function* test() { + let loginFrame = document.getElementById("loginFrame"); + + for (let tc of TESTCASES) { + for (let scriptName of Object.keys(SCRIPTS)) { + if (tc.wouldCapture && ["PUSHSTATE", "WINDOW_LOCATION"].includes(scriptName)) { + // Don't run scripts that should actually capture for this testcase. + continue; + } + + info("Starting testcase with script " + scriptName + ": " + JSON.stringify(tc)); + let loadedPromise = new Promise((resolve) => { + loginFrame.addEventListener("load", function frameLoaded() { + loginFrame.removeEventListener("load", frameLoaded); + resolve(); + }); + }); + loginFrame.src = DEFAULT_ORIGIN + "/tests/toolkit/components/passwordmgr/test/mochitest/blank.html"; + yield loadedPromise; + + let frameDoc = SpecialPowers.wrap(loginFrame.contentWindow).document; + frameDoc.documentElement.innerHTML = tc.document; + + // Wait for the form to be processed before trying to submit. + yield promiseFormsProcessed(); + + info("Running " + scriptName + " script to check for a submission"); + frameDoc.defaultView.eval(SCRIPTS[scriptName]); + + // Wait for 5000ms to see if the promise above resolves. + yield new Promise(resolve => setTimeout(resolve, 5000)); + ok(true, "Done waiting for captures"); + } + } +}); + +</script> + +<p id="display"></p> + +<div id="content"> + <iframe id="loginFrame" src="http://test1.mochi.test:8888/tests/toolkit/components/passwordmgr/test/mochitest/blank.html"></iframe> +</div> +<pre id="test"></pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_input_events.html b/toolkit/components/passwordmgr/test/mochitest/test_input_events.html new file mode 100644 index 000000000..0e77956d8 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_input_events.html @@ -0,0 +1,96 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test for input events in Login Manager</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="pwmgr_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body onload="onNewEvent(event)"> +Login Manager test: input events should fire. + +<script> +runChecksAfterCommonInit(); + +SimpleTest.requestFlakyTimeout("untriaged"); + +/** Test for Login Manager: form fill, should get input events. **/ + +var usernameInputFired = false; +var passwordInputFired = false; +var usernameChangeFired = false; +var passwordChangeFired = false; +var onloadFired = false; + +function onNewEvent(e) { + info("Got " + e.type + " event."); + if (e.type == "load") { + onloadFired = true; + } else if (e.type == "input") { + if (e.target.name == "uname") { + is(e.target.value, "testuser", "Should get 'testuser' as username"); + ok(!usernameInputFired, "Should not have gotten an input event for the username field yet."); + usernameInputFired = true; + } else if (e.target.name == "pword") { + is(e.target.value, "testpass", "Should get 'testpass' as password"); + ok(!passwordInputFired, "Should not have gotten an input event for the password field yet."); + passwordInputFired = true; + } + } else if (e.type == "change") { + if (e.target.name == "uname") { + is(e.target.value, "testuser", "Should get 'testuser' as username"); + ok(usernameInputFired, "Should get input event before change event for username field."); + ok(!usernameChangeFired, "Should not have gotten a change event for the username field yet."); + usernameChangeFired = true; + } else if (e.target.name == "pword") { + is(e.target.value, "testpass", "Should get 'testpass' as password"); + ok(passwordInputFired, "Should get input event before change event for password field."); + ok(!passwordChangeFired, "Should not have gotten a change event for the password field yet."); + passwordChangeFired = true; + } + } + if (onloadFired && usernameInputFired && passwordInputFired && usernameChangeFired && passwordChangeFired) { + ok(true, "All events fired as expected, we're done."); + SimpleTest.finish(); + } +} + +SimpleTest.registerCleanupFunction(function cleanup() { + clearTimeout(timeout); + $_(1, "uname").removeAttribute("oninput"); + $_(1, "pword").removeAttribute("oninput"); + $_(1, "uname").removeAttribute("onchange"); + $_(1, "pword").removeAttribute("onchange"); + document.body.removeAttribute("onload"); +}); + +var timeout = setTimeout(function() { + ok(usernameInputFired, "Username input event should have fired by now."); + ok(passwordInputFired, "Password input event should have fired by now."); + ok(usernameChangeFired, "Username change event should have fired by now."); + ok(passwordChangeFired, "Password change event should have fired by now."); + ok(onloadFired, "Window load event should have fired by now."); + ok(false, "Not all events fired yet."); + SimpleTest.finish(); +}, 10000); + +</script> + +<p id="display"></p> + +<div id="content" style="display: none"> + + <form id="form1" action="formtest.js"> + <p>This is form 1.</p> + <input type="text" name="uname" oninput="onNewEvent(event)" onchange="onNewEvent(event)"> + <input type="password" name="pword" oninput="onNewEvent(event)" onchange="onNewEvent(event)"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + +</div> +<pre id="test"></pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_input_events_for_identical_values.html b/toolkit/components/passwordmgr/test/mochitest/test_input_events_for_identical_values.html new file mode 100644 index 000000000..d058a87f9 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_input_events_for_identical_values.html @@ -0,0 +1,51 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test for input events in Login Manager when username/password are filled in already</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="pwmgr_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body onload="onNewEvent(event)"> +Login Manager test: input events should fire. + +<script> +runChecksAfterCommonInit(); + +SimpleTest.requestFlakyTimeout("untriaged"); + +/** Test for Login Manager: form fill when form is already filled, should not get input events. **/ + +var onloadFired = false; + +function onNewEvent(e) { + console.error("Got " + e.type + " event."); + if (e.type == "load") { + onloadFired = true; + $_(1, "uname").focus(); + sendKey("Tab"); + } else { + ok(false, "Got an input event for " + e.target.name + " field, which shouldn't happen."); + } +} +</script> + +<p id="display"></p> + +<div id="content"> + + <form id="form1" action="formtest.js"> + <p>This is form 1.</p> + <input type="text" name="uname" oninput="onNewEvent(event)" value="testuser"> + <input type="password" name="pword" oninput="onNewEvent(event)" onfocus="setTimeout(function() { SimpleTest.finish() }, 1000);" value="testpass"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + +</div> +<pre id="test"></pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_insecure_form_field_autocomplete.html b/toolkit/components/passwordmgr/test/mochitest/test_insecure_form_field_autocomplete.html new file mode 100644 index 000000000..c5d0a44fa --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_insecure_form_field_autocomplete.html @@ -0,0 +1,861 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test insecure form field autocomplete</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> +var chromeScript = runChecksAfterCommonInit(); + +var setupScript = runInParent(function setup() { + 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", + Ci.nsILoginInfo, "init"); + assert.ok(nsLoginInfo != null, "nsLoginInfo constructor"); + + // login0 has no username, so should be filtered out from the autocomplete list. + var login0 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null, + "", "user0pass", "", "pword"); + + var login1 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null, + "tempuser1", "temppass1", "uname", "pword"); + + var login2 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null, + "testuser2", "testpass2", "uname", "pword"); + + var login3 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null, + "testuser3", "testpass3", "uname", "pword"); + + var login4 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null, + "zzzuser4", "zzzpass4", "uname", "pword"); + + // login 5 only used in the single-user forms + var login5 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete2", null, + "singleuser5", "singlepass5", "uname", "pword"); + + var login6A = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete3", null, + "form7user1", "form7pass1", "uname", "pword"); + var login6B = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete3", null, + "form7user2", "form7pass2", "uname", "pword"); + + var login7 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete4", null, + "form8user", "form8pass", "uname", "pword"); + + var login8A = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete5", null, + "form9userAB", "form9pass", "uname", "pword"); + var login8B = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete5", null, + "form9userAAB", "form9pass", "uname", "pword"); + var login8C = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete5", null, + "form9userAABzz", "form9pass", "uname", "pword"); + + var login10 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete7", null, + "testuser10", "testpass10", "uname", "pword"); + + + // try/catch in case someone runs the tests manually, twice. + try { + Services.logins.addLogin(login0); + Services.logins.addLogin(login1); + Services.logins.addLogin(login2); + Services.logins.addLogin(login3); + Services.logins.addLogin(login4); + Services.logins.addLogin(login5); + Services.logins.addLogin(login6A); + Services.logins.addLogin(login6B); + Services.logins.addLogin(login7); + Services.logins.addLogin(login8A); + Services.logins.addLogin(login8B); + // login8C is added later + Services.logins.addLogin(login10); + } catch (e) { + assert.ok(false, "addLogin threw: " + e); + } + + addMessageListener("addLogin", loginVariableName => { + let login = eval(loginVariableName); + assert.ok(!!login, "Login to add is defined: " + loginVariableName); + Services.logins.addLogin(login); + }); + addMessageListener("removeLogin", loginVariableName => { + let login = eval(loginVariableName); + assert.ok(!!login, "Login to delete is defined: " + loginVariableName); + Services.logins.removeLogin(login); + }); +}); +</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> + + <!-- other forms test single logins, with autocomplete=off set --> + <form id="form2" action="http://autocomplete2" onsubmit="return false;"> + <input type="text" name="uname"> + <input type="password" name="pword" autocomplete="off"> + <button type="submit">Submit</button> + </form> + + <form id="form3" action="http://autocomplete2" onsubmit="return false;"> + <input type="text" name="uname" autocomplete="off"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <form id="form4" action="http://autocomplete2" onsubmit="return false;" autocomplete="off"> + <input type="text" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <form id="form5" action="http://autocomplete2" onsubmit="return false;"> + <input type="text" name="uname" autocomplete="off"> + <input type="password" name="pword" autocomplete="off"> + <button type="submit">Submit</button> + </form> + + <!-- control --> + <form id="form6" action="http://autocomplete2" onsubmit="return false;"> + <input type="text" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <!-- This form will be manipulated to insert a different username field. --> + <form id="form7" action="http://autocomplete3" onsubmit="return false;"> + <input type="text" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <!-- test for no autofill after onblur with blank username --> + <form id="form8" action="http://autocomplete4" onsubmit="return false;"> + <input type="text" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <!-- test autocomplete dropdown --> + <form id="form9" action="http://autocomplete5" onsubmit="return false;"> + <input type="text" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <!-- test for onUsernameInput recipe testing --> + <form id="form11" action="http://autocomplete7" onsubmit="return false;"> + <input type="text" name="1"> + <input type="text" name="2"> + <button type="submit">Submit</button> + </form> + + <!-- tests <form>-less autocomplete --> + <div id="form12"> + <input type="text" name="uname" id="uname"> + <input type="password" name="pword" id="pword"> + <button type="submit">Submit</button> + </div> +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +/** Test for Login Manager: multiple login autocomplete. **/ + +var uname = $_(1, "uname"); +var pword = $_(1, "pword"); +const shiftModifier = SpecialPowers.Ci.nsIDOMEvent.SHIFT_MASK; + +// Restore the form to the default state. +function restoreForm() { + uname.value = ""; + pword.value = ""; + uname.focus(); +} + +// Check for expected username/password in form. +function checkACForm(expectedUsername, expectedPassword) { + var formID = uname.parentNode.id; + is(uname.value, expectedUsername, "Checking " + formID + " username is: " + expectedUsername); + is(pword.value, expectedPassword, "Checking " + formID + " password is: " + expectedPassword); +} + +function sendFakeAutocompleteEvent(element) { + var acEvent = document.createEvent("HTMLEvents"); + acEvent.initEvent("DOMAutoComplete", true, false); + element.dispatchEvent(acEvent); +} + +function spinEventLoop() { + return Promise.resolve(); +} + +add_task(function* setup() { + yield SpecialPowers.pushPrefEnv({"set": [["security.insecure_field_warning.contextual.enabled", true], + ["signon.autofillForms.http", true]]}); + listenForUnexpectedPopupShown(); +}); + +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"); +}); + +add_task(function* test_form1_warning_entry() { + yield SimpleTest.promiseFocus(window); + // Trigger autocomplete popup + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + let results = yield shownPromise; + + let popupState = yield getPopupState(); + is(popupState.selectedIndex, -1, "Check no entries are selected upon opening"); + + let expectedMenuItems = ["This connection is not secure. Logins entered here could be compromised. Learn More", + "tempuser1", + "testuser2", + "testuser3", + "zzzuser4"]; + checkArrayValues(results, expectedMenuItems, "Check all menuitems are displayed correctly."); + + doKey("down"); // select insecure warning + checkACForm("", ""); // value shouldn't update just by selecting + doKey("return"); // not "enter"! + yield spinEventLoop(); // let focus happen + checkACForm("", ""); +}); + +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"); // skip insecure warning + 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() { + // Trigger autocomplete popup + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); // skip insecure warning + doKey("down"); // first + doKey("down"); // second + doKey("return"); // not "enter"! + yield promiseFormsProcessed(); + checkACForm("testuser2", "testpass2"); +}); + +add_task(function* test_form1_third_entry() { + // Trigger autocomplete popup + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); // skip insecure warning + doKey("down"); // first + doKey("down"); // second + doKey("down"); // third + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("testuser3", "testpass3"); +}); + +add_task(function* test_form1_fourth_entry() { + // Trigger autocomplete popup + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); // skip insecure warning + doKey("down"); // first + doKey("down"); // second + doKey("down"); // third + doKey("down"); // fourth + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("zzzuser4", "zzzpass4"); +}); + +add_task(function* test_form1_wraparound_first_entry() { + // Trigger autocomplete popup + restoreForm(); + yield spinEventLoop(); // let focus happen + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); // skip insecure warning + doKey("down"); // first + doKey("down"); // second + doKey("down"); // third + doKey("down"); // fourth + doKey("down"); // deselects + doKey("down"); // skip insecure warning + doKey("down"); // first + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("tempuser1", "temppass1"); +}); + +add_task(function* test_form1_wraparound_up_last_entry() { + // Trigger autocomplete popup + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("up"); // last (fourth) + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("zzzuser4", "zzzpass4"); +}); + +add_task(function* test_form1_wraparound_down_up_up() { + // Trigger autocomplete popup + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); // select first entry + doKey("up"); // selects nothing! + doKey("up"); // select last entry + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("zzzuser4", "zzzpass4"); +}); + +add_task(function* test_form1_wraparound_up_last() { + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); + doKey("up"); // deselects + doKey("up"); // last entry + doKey("up"); + doKey("up"); + doKey("up"); // skip insecure warning + doKey("up"); // first entry + doKey("up"); // deselects + doKey("up"); // last entry + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("zzzuser4", "zzzpass4"); +}); + +add_task(function* test_form1_fill_username_without_autofill_right() { + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + // Set first entry w/o triggering autocomplete + doKey("down"); // skip insecure warning + doKey("down"); // first + doKey("right"); + yield spinEventLoop(); + checkACForm("tempuser1", ""); // empty password +}); + +add_task(function* test_form1_fill_username_without_autofill_left() { + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + // Set first entry w/o triggering autocomplete + doKey("down"); // skip insecure warning + doKey("down"); // first + doKey("left"); + checkACForm("tempuser1", ""); // empty password +}); + +add_task(function* test_form1_pageup_first() { + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + // Check first entry (page up) + doKey("down"); // first + doKey("down"); // second + doKey("page_up"); // first + doKey("down"); // skip insecure warning + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("tempuser1", "temppass1"); +}); + +add_task(function* test_form1_pagedown_last() { + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + // test 13 + // Check last entry (page down) + doKey("down"); // first + doKey("page_down"); // last + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("zzzuser4", "zzzpass4"); +}); + +add_task(function* test_form1_untrusted_event() { + restoreForm(); + yield spinEventLoop(); + + // Send a fake (untrusted) event. + checkACForm("", ""); + uname.value = "zzzuser4"; + sendFakeAutocompleteEvent(uname); + yield spinEventLoop(); + checkACForm("zzzuser4", ""); +}); + +add_task(function* test_form1_delete() { + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + // 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. + + doKey("down"); // skip insecure warning + // Delete the first entry (of 4), "tempuser1" + doKey("down"); + var numLogins; + numLogins = LoginManager.countLogins("http://mochi.test:8888", "http://autocomplete:8888", null); + is(numLogins, 5, "Correct number of logins before deleting one"); + + let countChangedPromise = notifyMenuChanged(4); + 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 = LoginManager.countLogins("http://mochi.test:8888", "http://autocomplete:8888", null); + is(numLogins, 4, "Correct number of logins after deleting one"); + yield countChangedPromise; + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("testuser2", "testpass2"); +}); + +add_task(function* test_form1_first_after_deletion() { + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); // skip insecure warning + // Check the new first entry (of 3) + doKey("down"); + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("testuser2", "testpass2"); +}); + +add_task(function* test_form1_delete_second() { + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); // skip insecure warning + // Delete the second entry (of 3), "testuser3" + doKey("down"); + doKey("down"); + doKey("delete", shiftModifier); + checkACForm("", ""); + 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(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); // skip insecure warning + // Check the new first entry (of 2) + doKey("down"); + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("testuser2", "testpass2"); +}); + +add_task(function* test_form1_delete_last() { + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); // skip insecure warning + // test 54 + // Delete the last entry (of 2), "zzzuser4" + doKey("down"); + doKey("down"); + doKey("delete", shiftModifier); + checkACForm("", ""); + 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(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); // skip insecure warning + // Check the only remaining entry + doKey("down"); + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("testuser2", "testpass2"); +}); + +add_task(function* test_form1_check_only_entry_remaining() { + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); // skip insecure warning + // test 56 + // Delete the only remaining entry, "testuser2" + doKey("down"); + doKey("delete", shiftModifier); + checkACForm("", ""); + 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() { + // Turn our attention to form2 + uname = $_(2, "uname"); + pword = $_(2, "pword"); + checkACForm("singleuser5", "singlepass5"); + + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); // skip insecure warning + // Check first entry + doKey("down"); + checkACForm("", ""); // value shouldn't update + doKey("return"); // not "enter"! + yield promiseFormsProcessed(); + checkACForm("singleuser5", "singlepass5"); +}); + +add_task(function* test_form3() { + uname = $_(3, "uname"); + pword = $_(3, "pword"); + checkACForm("singleuser5", "singlepass5"); + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); // skip insecure warning + // Check first entry + doKey("down"); + checkACForm("", ""); // value shouldn't update + doKey("return"); // not "enter"! + yield promiseFormsProcessed(); + checkACForm("singleuser5", "singlepass5"); +}); + +add_task(function* test_form4() { + uname = $_(4, "uname"); + pword = $_(4, "pword"); + checkACForm("singleuser5", "singlepass5"); + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); // skip insecure warning + // Check first entry + doKey("down"); + checkACForm("", ""); // value shouldn't update + doKey("return"); // not "enter"! + yield promiseFormsProcessed(); + checkACForm("singleuser5", "singlepass5"); +}); + +add_task(function* test_form5() { + uname = $_(5, "uname"); + pword = $_(5, "pword"); + checkACForm("singleuser5", "singlepass5"); + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); // skip insecure warning + // Check first entry + doKey("down"); + checkACForm("", ""); // value shouldn't update + doKey("return"); // not "enter"! + yield promiseFormsProcessed(); + checkACForm("singleuser5", "singlepass5"); +}); + +add_task(function* test_form6() { + // (this is a control, w/o autocomplete=off, to ensure the login + // that was being suppressed would have been filled in otherwise) + uname = $_(6, "uname"); + pword = $_(6, "pword"); + checkACForm("singleuser5", "singlepass5"); +}); + +add_task(function* test_form6_changeUsername() { + // Test that the password field remains filled in after changing + // the username. + uname.focus(); + doKey("right"); + sendChar("X"); + // Trigger the 'blur' event on uname + pword.focus(); + yield spinEventLoop(); + checkACForm("singleuser5X", "singlepass5"); + + setupScript.sendSyncMessage("removeLogin", "login5"); +}); + +add_task(function* test_form7() { + uname = $_(7, "uname"); + pword = $_(7, "pword"); + checkACForm("", ""); + + // Insert a new username field into the form. We'll then make sure + // that invoking the autocomplete doesn't try to fill the form. + var newField = document.createElement("input"); + newField.setAttribute("type", "text"); + newField.setAttribute("name", "uname2"); + pword.parentNode.insertBefore(newField, pword); + is($_(7, "uname2").value, "", "Verifying empty uname2"); + + // Delete login6B. It was created just to prevent filling in a login + // automatically, removing it makes it more likely that we'll catch a + // future regression with form filling here. + setupScript.sendSyncMessage("removeLogin", "login6B"); +}); + +add_task(function* test_form7_2() { + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); // skip insecure warning + // Check first entry + doKey("down"); + checkACForm("", ""); // value shouldn't update + doKey("return"); // not "enter"! + // The form changes, so we expect the old username field to get the + // selected autocomplete value, but neither the new username field nor + // the password field should have any values filled in. + yield spinEventLoop(); + checkACForm("form7user1", ""); + is($_(7, "uname2").value, "", "Verifying empty uname2"); + restoreForm(); // clear field, so reloading test doesn't fail + + setupScript.sendSyncMessage("removeLogin", "login6A"); +}); + +add_task(function* test_form8() { + uname = $_(8, "uname"); + pword = $_(8, "pword"); + checkACForm("form8user", "form8pass"); + restoreForm(); +}); + +add_task(function* test_form8_blur() { + checkACForm("", ""); + // Focus the previous form to trigger a blur. + $_(7, "uname").focus(); +}); + +add_task(function* test_form8_2() { + checkACForm("", ""); + restoreForm(); +}); + +add_task(function* test_form8_3() { + checkACForm("", ""); + setupScript.sendSyncMessage("removeLogin", "login7"); +}); + +add_task(function* test_form9_filtering() { + // Turn our attention to form9 to test the dropdown - bug 497541 + uname = $_(9, "uname"); + pword = $_(9, "pword"); + uname.focus(); + let shownPromise = promiseACShown(); + sendString("form9userAB"); + yield shownPromise; + + checkACForm("form9userAB", ""); + uname.focus(); + doKey("left"); + shownPromise = promiseACShown(); + sendChar("A"); + let results = yield shownPromise; + + checkACForm("form9userAAB", ""); + checkArrayValues(results, ["This connection is not secure. Logins entered here could be compromised. Learn More", "form9userAAB"], + "Check dropdown is updated after inserting 'A'"); + doKey("down"); // skip insecure warning + doKey("down"); + doKey("return"); + yield promiseFormsProcessed(); + checkACForm("form9userAAB", "form9pass"); +}); + +add_task(function* test_form9_autocomplete_cache() { + // Note that this addLogin call will only be seen by the autocomplete + // attempt for the sendChar if we do not successfully cache the + // autocomplete results. + setupScript.sendSyncMessage("addLogin", "login8C"); + uname.focus(); + let promise0 = notifyMenuChanged(1); + let shownPromise = promiseACShown(); + sendChar("z"); + yield promise0; + yield shownPromise; + let popupState = yield getPopupState(); + is(popupState.open, true, "Check popup should open"); + + // check that empty results are cached - bug 496466 + promise0 = notifyMenuChanged(1); + sendChar("z"); + yield promise0; + popupState = yield getPopupState(); + is(popupState.open, true, "Check popup stays opened due to cached empty result"); +}); + +add_task(function* test_form11_recipes() { + yield loadRecipes({ + siteRecipes: [{ + "hosts": ["mochi.test:8888"], + "usernameSelector": "input[name='1']", + "passwordSelector": "input[name='2']" + }], + }); + uname = $_(11, "1"); + pword = $_(11, "2"); + + // First test DOMAutocomplete + // Switch the password field to type=password so _fillForm marks the username + // field for autocomplete. + pword.type = "password"; + yield promiseFormsProcessed(); + restoreForm(); + checkACForm("", ""); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); // skip insecure warning + doKey("down"); + checkACForm("", ""); // value shouldn't update + doKey("return"); // not "enter"! + yield promiseFormsProcessed(); + checkACForm("testuser10", "testpass10"); + + // Now test recipes with blur on the username field. + restoreForm(); + checkACForm("", ""); + uname.value = "testuser10"; + checkACForm("testuser10", ""); + doKey("tab"); + yield promiseFormsProcessed(); + checkACForm("testuser10", "testpass10"); + yield resetRecipes(); +}); + +add_task(function* test_form12_formless() { + // Test form-less autocomplete + uname = $_(12, "uname"); + pword = $_(12, "pword"); + restoreForm(); + checkACForm("", ""); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + doKey("down"); // skip insecure warning + // Trigger autocomplete + doKey("down"); + checkACForm("", ""); // value shouldn't update + let processedPromise = promiseFormsProcessed(); + doKey("return"); // not "enter"! + yield processedPromise; + checkACForm("testuser", "testpass"); +}); +</script> +</pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_insecure_form_field_no_saved_login.html b/toolkit/components/passwordmgr/test/mochitest/test_insecure_form_field_no_saved_login.html new file mode 100644 index 000000000..c3a894958 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_insecure_form_field_no_saved_login.html @@ -0,0 +1,103 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test basic login, contextual inscure password warning without saved 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: contextual inscure password warning without saved logins + +<script> +let chromeScript = runChecksAfterCommonInit(); +</script> +<p id="display"></p> + +<!-- we presumably can't hide the content for this test. --> +<div id="content"> + + <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> + +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +/** Test for Login Manager: contextual inscure password warning without saved logins. **/ + +// Set to pref before the document loads. +SpecialPowers.setBoolPref( + "security.insecure_field_warning.contextual.enabled", true); + +SimpleTest.registerCleanupFunction(() => { + SpecialPowers.clearUserPref( + "security.insecure_field_warning.contextual.enabled"); +}); + +let uname = $_(1, "uname"); +let pword = $_(1, "pword"); +const shiftModifier = SpecialPowers.Ci.nsIDOMEvent.SHIFT_MASK; + +// Restore the form to the default state. +function restoreForm() { + uname.value = ""; + pword.value = ""; + uname.focus(); +} + +// Check for expected username/password in form. +function checkACForm(expectedUsername, expectedPassword) { + let formID = uname.parentNode.id; + is(uname.value, expectedUsername, "Checking " + formID + " username is: " + expectedUsername); + is(pword.value, expectedPassword, "Checking " + formID + " password is: " + expectedPassword); +} + +function spinEventLoop() { + return Promise.resolve(); +} + +add_task(function* setup() { + listenForUnexpectedPopupShown(); +}); + +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"); +}); + +add_task(function* test_form1_warning_entry() { + yield SimpleTest.promiseFocus(window); + // Trigger autocomplete popup + restoreForm(); + let shownPromise = promiseACShown(); + doKey("down"); // open + yield shownPromise; + + let popupState = yield getPopupState(); + is(popupState.open, true, "Check popup is opened"); + is(popupState.selectedIndex, -1, "Check no entries are selected upon opening"); + + doKey("down"); // select insecure warning + checkACForm("", ""); // value shouldn't update just by selecting + doKey("return"); // not "enter"! + yield spinEventLoop(); // let focus happen + checkACForm("", ""); +}); + +</script> +</pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_maxlength.html b/toolkit/components/passwordmgr/test/mochitest/test_maxlength.html new file mode 100644 index 000000000..2b6da33ec --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_maxlength.html @@ -0,0 +1,137 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test for maxlength attributes</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.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: Bug 391514 +<script> +runChecksAfterCommonInit(() => startTest()); +</script> +<p id="display"></p> +<div id="content" style="display: none"> + <!-- normal form. --> + <form id="form1" action="formtest.js"> + <input type="text" name="uname"> + <input type="password" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- limited username --> + <form id="form2" action="formtest.js"> + <input type="text" name="uname" maxlength="4"> + <input type="password" name="pword"> + </form> + + <!-- limited password --> + <form id="form3" action="formtest.js"> + <input type="text" name="uname"> + <input type="password" name="pword" maxlength="4"> + </form> + + <!-- limited username and password --> + <form id="form4" action="formtest.js"> + <input type="text" name="uname" maxlength="4"> + <input type="password" name="pword" maxlength="4"> + </form> + + + <!-- limited username --> + <form id="form5" action="formtest.js"> + <input type="text" name="uname" maxlength="0"> + <input type="password" name="pword"> + </form> + + <!-- limited password --> + <form id="form6" action="formtest.js"> + <input type="text" name="uname"> + <input type="password" name="pword" maxlength="0"> + </form> + + <!-- limited username and password --> + <form id="form7" action="formtest.js"> + <input type="text" name="uname" maxlength="0"> + <input type="password" name="pword" maxlength="0"> + </form> + + + <!-- limited, but ok, username --> + <form id="form8" action="formtest.js"> + <input type="text" name="uname" maxlength="999"> + <input type="password" name="pword"> + </form> + + <!-- limited, but ok, password --> + <form id="form9" action="formtest.js"> + <input type="text" name="uname"> + <input type="password" name="pword" maxlength="999"> + </form> + + <!-- limited, but ok, username and password --> + <form id="form10" action="formtest.js"> + <input type="text" name="uname" maxlength="999"> + <input type="password" name="pword" maxlength="999"> + </form> + + + <!-- limited, but ok, username --> + <!-- (note that filled values are exactly 8 characters) --> + <form id="form11" action="formtest.js"> + <input type="text" name="uname" maxlength="8"> + <input type="password" name="pword"> + </form> + + <!-- limited, but ok, password --> + <!-- (note that filled values are exactly 8 characters) --> + <form id="form12" action="formtest.js"> + <input type="text" name="uname"> + <input type="password" name="pword" maxlength="8"> + </form> + + <!-- limited, but ok, username and password --> + <!-- (note that filled values are exactly 8 characters) --> + <form id="form13" action="formtest.js"> + <input type="text" name="uname" maxlength="8"> + <input type="password" name="pword" maxlength="8"> + </form> + +</div> +<pre id="test"> +<script class="testbody" type="text/javascript"> + +/* Test for Login Manager: 391514 (Login Manager gets confused with + * password/PIN on usaa.com) + */ + +function startTest() { + var i; + + is($_(1, "uname").value, "testuser", "Checking for filled username 1"); + is($_(1, "pword").value, "testpass", "Checking for filled password 1"); + + for (i = 2; i < 8; i++) { + is($_(i, "uname").value, "", "Checking for unfilled username " + i); + is($_(i, "pword").value, "", "Checking for unfilled password " + i); + } + + for (i = 8; i < 14; i++) { + is($_(i, "uname").value, "testuser", "Checking for filled username " + i); + is($_(i, "pword").value, "testpass", "Checking for filled password " + i); + } + + // Note that tests 11-13 are limited to exactly the expected value. + // Assert this lest someone change the login we're testing with. + is($_(11, "uname").value.length, 8, "asserting test assumption is valid."); + + SimpleTest.finish(); +} +</script> +</pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_password_field_autocomplete.html b/toolkit/components/passwordmgr/test/mochitest/test_password_field_autocomplete.html new file mode 100644 index 000000000..443c8a5e9 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_password_field_autocomplete.html @@ -0,0 +1,291 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test basic login autocomplete</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: multiple login autocomplete + +<script> +var chromeScript = runChecksAfterCommonInit(); + +var setupScript = runInParent(function setup() { + 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", + Ci.nsILoginInfo, "init"); + assert.ok(nsLoginInfo != null, "nsLoginInfo constructor"); + + // login0 has no username, so should be filtered out from the autocomplete list. + var login0 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null, + "", "user0pass", "", "pword"); + + var login1 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null, + "tempuser1", "temppass1", "uname", "pword"); + + var login2 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null, + "testuser2", "testpass2", "uname", "pword"); + + var login3 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null, + "testuser3", "testpass3", "uname", "pword"); + + var login4 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null, + "zzzuser4", "zzzpass4", "uname", "pword"); + + + // try/catch in case someone runs the tests manually, twice. + try { + Services.logins.addLogin(login0); + Services.logins.addLogin(login1); + Services.logins.addLogin(login2); + Services.logins.addLogin(login3); + Services.logins.addLogin(login4); + } catch (e) { + assert.ok(false, "addLogin threw: " + e); + } + + addMessageListener("addLogin", loginVariableName => { + let login = eval(loginVariableName); + assert.ok(!!login, "Login to add is defined: " + loginVariableName); + Services.logins.addLogin(login); + }); + addMessageListener("removeLogin", loginVariableName => { + let login = eval(loginVariableName); + assert.ok(!!login, "Login to delete is defined: " + loginVariableName); + Services.logins.removeLogin(login); + }); +}); +</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> + + <form id="form2" action="http://autocomplete:8888/formtest.js" onsubmit="return false;"> + <input type="text" name="uname"> + <input type="password" name="pword" readonly="true"> + <button type="submit">Submit</button> + </form> + + <form id="form3" action="http://autocomplete:8888/formtest.js" onsubmit="return false;"> + <input type="text" name="uname"> + <input type="password" name="pword" disabled="true"> + <button type="submit">Submit</button> + </form> + +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +/** Test for Login Manager: multiple login autocomplete. **/ + +var uname = $_(1, "uname"); +var pword = $_(1, "pword"); +const shiftModifier = SpecialPowers.Ci.nsIDOMEvent.SHIFT_MASK; + +// Restore the form to the default state. +function* reinitializeForm(index) { + // Using innerHTML is for creating the autocomplete popup again, so the + // preference value will be applied to the constructor of + // UserAutoCompleteResult. + let form = document.getElementById("form" + index); + let temp = form.innerHTML; + form.innerHTML = ""; + form.innerHTML = temp; + + yield new Promise(resolve => { + let observer = SpecialPowers.wrapCallback(() => { + SpecialPowers.removeObserver(observer, "passwordmgr-processed-form"); + resolve(); + }); + SpecialPowers.addObserver(observer, "passwordmgr-processed-form", false); + }); + + yield SimpleTest.promiseFocus(window); + + uname = $_(index, "uname"); + pword = $_(index, "pword"); + uname.value = ""; + pword.value = ""; + pword.focus(); +} + +function generateDateString(date) { + let dateAndTimeFormatter = new Intl.DateTimeFormat(undefined, + { day: "numeric", month: "short", year: "numeric" }); + return dateAndTimeFormatter.format(date); +} + +const DATE_NOW_STRING = generateDateString(new Date()); + +// Check for expected username/password in form. +function checkACFormPasswordField(expectedPassword) { + var formID = uname.parentNode.id; + is(pword.value, expectedPassword, "Checking " + formID + " password is: " + expectedPassword); +} + +function spinEventLoop() { + return Promise.resolve(); +} + +add_task(function* setup() { + listenForUnexpectedPopupShown(); +}); + +add_task(function* test_form1_initial_empty() { + yield SimpleTest.promiseFocus(window); + + // Make sure initial form is empty. + checkACFormPasswordField(""); + let popupState = yield getPopupState(); + is(popupState.open, false, "Check popup is initially closed"); +}); + +add_task(function* test_form2_password_readonly() { + yield SpecialPowers.pushPrefEnv({"set": [ + ["security.insecure_field_warning.contextual.enabled", true], + ["signon.autofillForms.http", true] + ]}); + yield reinitializeForm(2); + + // Trigger autocomplete popup + doKey("down"); // open + let popupState = yield getPopupState(); + is(popupState.open, false, "Check popup is closed for a readonly field."); +}); + +add_task(function* test_form3_password_disabled() { + yield SpecialPowers.pushPrefEnv({"set": [ + ["security.insecure_field_warning.contextual.enabled", true], + ["signon.autofillForms.http", true] + ]}); + yield reinitializeForm(3); + + // Trigger autocomplete popup + doKey("down"); // open + let popupState = yield getPopupState(); + is(popupState.open, false, "Check popup is closed for a disabled field."); +}); + +add_task(function* test_form1_enabledInsecureFieldWarning_enabledInsecureAutoFillForm() { + yield SpecialPowers.pushPrefEnv({"set": [ + ["security.insecure_field_warning.contextual.enabled", true], + ["signon.autofillForms.http", true] + ]}); + yield reinitializeForm(1); + // Trigger autocomplete popup + let shownPromise = promiseACShown(); + doKey("down"); // open + let results = yield shownPromise; + + let popupState = yield getPopupState(); + is(popupState.selectedIndex, -1, "Check no entries are selected upon opening"); + + let expectedMenuItems = ["This connection is not secure. Logins entered here could be compromised. Learn More", + "No username (" + DATE_NOW_STRING + ")", + "tempuser1", + "testuser2", + "testuser3", + "zzzuser4"]; + checkArrayValues(results, expectedMenuItems, "Check all menuitems are displayed correctly."); + + doKey("down"); // select insecure warning + checkACFormPasswordField(""); // value shouldn't update just by selecting + doKey("return"); // not "enter"! + yield spinEventLoop(); // let focus happen + checkACFormPasswordField(""); +}); + +add_task(function* test_form1_disabledInsecureFieldWarning_enabledInsecureAutoFillForm() { + yield SpecialPowers.pushPrefEnv({"set": [ + ["security.insecure_field_warning.contextual.enabled", false], + ["signon.autofillForms.http", true] + ]}); + yield reinitializeForm(1); + + // Trigger autocomplete popup + let shownPromise = promiseACShown(); + doKey("down"); // open + let results = yield shownPromise; + + let popupState = yield getPopupState(); + is(popupState.selectedIndex, -1, "Check no entries are selected upon opening"); + + let expectedMenuItems = ["No username (" + DATE_NOW_STRING + ")", + "tempuser1", + "testuser2", + "testuser3", + "zzzuser4"]; + checkArrayValues(results, expectedMenuItems, "Check all menuitems are displayed correctly."); + + doKey("down"); // select first item + checkACFormPasswordField(""); // value shouldn't update just by selecting + doKey("return"); // not "enter"! + yield spinEventLoop(); // let focus happen + checkACFormPasswordField("user0pass"); +}); + +add_task(function* test_form1_enabledInsecureFieldWarning_disabledInsecureAutoFillForm() { + yield SpecialPowers.pushPrefEnv({"set": [ + ["security.insecure_field_warning.contextual.enabled", true], + ["signon.autofillForms.http", false] + ]}); + yield reinitializeForm(1); + + // Trigger autocomplete popup + let shownPromise = promiseACShown(); + doKey("down"); // open + let results = yield shownPromise; + + let popupState = yield getPopupState(); + is(popupState.selectedIndex, -1, "Check no entries are selected upon opening"); + + let expectedMenuItems = ["This connection is not secure. Logins entered here could be compromised. Learn More", + "No username (" + DATE_NOW_STRING + ")", + "tempuser1", + "testuser2", + "testuser3", + "zzzuser4"]; + checkArrayValues(results, expectedMenuItems, "Check all menuitems are displayed correctly."); + + doKey("down"); // select insecure warning + checkACFormPasswordField(""); // value shouldn't update just by selecting + doKey("return"); // not "enter"! + yield spinEventLoop(); // let focus happen + checkACFormPasswordField(""); +}); + +add_task(function* test_form1_disabledInsecureFieldWarning_disabledInsecureAutoFillForm() { + yield SpecialPowers.pushPrefEnv({"set": [ + ["security.insecure_field_warning.contextual.enabled", false], + ["signon.autofillForms.http", false] + ]}); + yield reinitializeForm(1); + + // Trigger autocomplete popup + doKey("down"); // open + let popupState = yield getPopupState(); + is(popupState.open, false, "Check popup is closed with no AutoFillForms."); +}); + +</script> +</pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_passwords_in_type_password.html b/toolkit/components/passwordmgr/test/mochitest/test_passwords_in_type_password.html new file mode 100644 index 000000000..e107cebe6 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_passwords_in_type_password.html @@ -0,0 +1,122 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test that passwords only get filled in type=password</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.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: Bug 242956 +<script> +runChecksAfterCommonInit(() => startTest()); +</script> +<p id="display"></p> +<div id="content" style="display: none"> + <!-- pword is not a type=password input --> + <form id="form1" action="formtest.js"> + <input type="text" name="uname"> + <input type="text" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- uname is not a type=text input --> + <form id="form2" action="formtest.js"> + <input type="password" name="uname"> + <input type="password" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- two "pword" inputs, (text + password) --> + <form id="form3" action="formtest.js"> + <input type="text" name="uname"> + <input type="text" name="pword"> + <input type="password" name="qword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- same thing, different order --> + <form id="form4" action="formtest.js"> + <input type="text" name="uname"> + <input type="password" name="pword"> + <input type="text" name="qword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- uname is not a type=text input (try a checkbox just for variety) --> + <form id="form5" action="formtest.js"> + <input type="checkbox" name="uname" value=""> + <input type="password" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- pword is not a type=password input (try a checkbox just for variety) --> + <form id="form6" action="formtest.js"> + <input type="text" name="uname"> + <input type="checkbox" name="pword" value=""> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + <!-- pword is not a type=password input --> + <form id="form7" action="formtest.js"> + <input type="text" name="uname" value="testuser"> + <input type="text" name="pword"> + + <button type="submit">Submit</button> + <button type="reset"> Reset </button> + </form> + + +</div> +<pre id="test"> +<script class="testbody" type="text/javascript"> + +/** Test for Login Manager: 242956 (Stored password is inserted into a + readable text input on a second page) **/ + +// Make sure that pwmgr only puts passwords into type=password <input>s. +// Might as well test the converse, too (username in password field). + +function startTest() { + is($_(1, "uname").value, "", "Checking for unfilled username 1"); + is($_(1, "pword").value, "", "Checking for unfilled password 1"); + + is($_(2, "uname").value, "testpass", "Checking for password not username 2"); + is($_(2, "pword").value, "", "Checking for unfilled password 2"); + + is($_(3, "uname").value, "", "Checking for unfilled username 3"); + is($_(3, "pword").value, "testuser", "Checking for unfilled password 3"); + is($_(3, "qword").value, "testpass", "Checking for unfilled qassword 3"); + + is($_(4, "uname").value, "testuser", "Checking for password not username 4"); + is($_(4, "pword").value, "testpass", "Checking for unfilled password 4"); + is($_(4, "qword").value, "", "Checking for unfilled qassword 4"); + + is($_(5, "uname").value, "", "Checking for unfilled username 5"); + is($_(5, "pword").value, "testpass", "Checking for filled password 5"); + + is($_(6, "uname").value, "", "Checking for unfilled username 6"); + is($_(6, "pword").value, "", "Checking for unfilled password 6"); + + is($_(7, "uname").value, "testuser", "Checking for unmodified username 7"); + is($_(7, "pword").value, "", "Checking for unfilled password 7"); + + SimpleTest.finish(); +} +</script> +</pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_prompt.html b/toolkit/components/passwordmgr/test/mochitest/test_prompt.html new file mode 100644 index 000000000..1050ab66b --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_prompt.html @@ -0,0 +1,705 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test prompter.{prompt,promptPassword,promptUsernameAndPassword}</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.js"></script> + <script type="text/javascript" src="pwmgr_common.js"></script> + <script type="text/javascript" src="prompt_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content" style="display: none"> +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> +var state, action; +var uname = { value: null }; +var pword = { value: null }; +var result = { value: null }; +var isOk; + +// Force parent to not look for tab-modal prompts, as they're not used for auth prompts. +isTabModal = false; + +let prompterParent = runInParent(() => { + const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + const promptFac = Cc["@mozilla.org/passwordmanager/authpromptfactory;1"]. + getService(Ci.nsIPromptFactory); + + Cu.import("resource://gre/modules/Services.jsm"); + let chromeWin = Services.wm.getMostRecentWindow("navigator:browser"); + let prompter1 = promptFac.getPrompt(chromeWin, Ci.nsIAuthPrompt); + + addMessageListener("proxyPrompter", function onMessage(msg) { + let rv = prompter1[msg.methodName](...msg.args); + return { + rv, + // Send the args back to content so out/inout args can be checked. + args: msg.args, + }; + }); +}); + +let prompter1 = new PrompterProxy(prompterParent); + +const defaultTitle = "the title"; +const defaultMsg = "the message"; + +function initLogins() { + const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + var login1, login2A, login2B, login2C, login2D, login2E; + var pwmgr = Cc["@mozilla.org/login-manager;1"]. + getService(Ci.nsILoginManager); + + login1 = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + login2A = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + login2B = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + login2C = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + login2D = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + login2E = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + + login1.init("http://example.com", null, "http://example.com", + "", "examplepass", "", ""); + login2A.init("http://example2.com", null, "http://example2.com", + "user1name", "user1pass", "", ""); + login2B.init("http://example2.com", null, "http://example2.com", + "user2name", "user2pass", "", ""); + login2C.init("http://example2.com", null, "http://example2.com", + "user3.name@host", "user3pass", "", ""); + login2D.init("http://example2.com", null, "http://example2.com", + "100@beef", "user3pass", "", ""); + login2E.init("http://example2.com", null, "http://example2.com", + "100%beef", "user3pass", "", ""); + + pwmgr.addLogin(login1); + pwmgr.addLogin(login2A); + pwmgr.addLogin(login2B); + pwmgr.addLogin(login2C); + pwmgr.addLogin(login2D); + pwmgr.addLogin(login2E); +} + +add_task(function* setup() { + runInParent(initLogins); +}); + +add_task(function* test_prompt_accept() { + state = { + msg : "the message", + title : "the title", + textValue : "abc", + passValue : "", + iconClass : "question-icon", + titleHidden : true, + textHidden : false, + passHidden : true, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "textField", + defButton : "button0", + }; + action = { + buttonClick : "ok", + textField : "xyz", + }; + promptDone = handlePrompt(state, action); + isOk = prompter1.prompt(defaultTitle, defaultMsg, "http://example.com", + Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, "abc", result); + yield promptDone; + + ok(isOk, "Checking dialog return value (accept)"); + is(result.value, "xyz", "Checking prompt() returned value"); +}); + +add_task(function* test_prompt_cancel() { + state = { + msg : "the message", + title : "the title", + textValue : "abc", + passValue : "", + iconClass : "question-icon", + titleHidden : true, + textHidden : false, + passHidden : true, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "textField", + defButton : "button0", + }; + action = { + buttonClick : "cancel", + }; + promptDone = handlePrompt(state, action); + isOk = prompter1.prompt(defaultTitle, defaultMsg, "http://example.com", + Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, "abc", result); + yield promptDone; + ok(!isOk, "Checking dialog return value (cancel)"); +}); + +add_task(function* test_promptPassword_defaultAccept() { + // Default password provided, existing logins are ignored. + state = { + msg : "the message", + title : "the title", + textValue : "", + passValue : "inputpw", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : true, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "passField", + defButton : "button0", + }; + action = { + buttonClick : "ok", + passField : "secret", + }; + pword.value = "inputpw"; + promptDone = handlePrompt(state, action); + isOk = prompter1.promptPassword(defaultTitle, defaultMsg, "http://example.com", + Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, pword); + yield promptDone; + ok(isOk, "Checking dialog return value (accept)"); + is(pword.value, "secret", "Checking returned password"); +}); + +add_task(function* test_promptPassword_defaultCancel() { + // Default password provided, existing logins are ignored. + state = { + msg : "the message", + title : "the title", + textValue : "", + passValue : "inputpw", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : true, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "passField", + defButton : "button0", + }; + action = { + buttonClick : "cancel", + }; + pword.value = "inputpw"; + promptDone = handlePrompt(state, action); + isOk = prompter1.promptPassword(defaultTitle, defaultMsg, "http://example.com", + Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, pword); + yield promptDone; + ok(!isOk, "Checking dialog return value (cancel)"); +}); + +add_task(function* test_promptPassword_emptyAccept() { + // No default password provided, realm does not match existing login. + state = { + msg : "the message", + title : "the title", + textValue : "", + passValue : "", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : true, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "passField", + defButton : "button0", + }; + action = { + buttonClick : "ok", + passField : "secret", + }; + pword.value = null; + promptDone = handlePrompt(state, action); + isOk = prompter1.promptPassword(defaultTitle, defaultMsg, "http://nonexample.com", + Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, pword); + yield promptDone; + ok(isOk, "Checking dialog return value (accept)"); + is(pword.value, "secret", "Checking returned password"); +}); + +add_task(function* test_promptPassword_saved() { + // No default password provided, matching login is returned w/o prompting. + pword.value = null; + isOk = prompter1.promptPassword(defaultTitle, defaultMsg, "http://example.com", + Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, pword); + ok(isOk, "Checking dialog return value (accept)"); + is(pword.value, "examplepass", "Checking returned password"); +}); + +add_task(function* test_promptPassword_noMatchingPasswordForEmptyUN() { + // No default password provided, none of the logins from this host are + // password-only so the user is prompted. + state = { + msg : "the message", + title : "the title", + textValue : "", + passValue : "", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : true, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "passField", + defButton : "button0", + }; + action = { + buttonClick : "ok", + passField : "secret", + }; + pword.value = null; + promptDone = handlePrompt(state, action); + isOk = prompter1.promptPassword(defaultTitle, defaultMsg, "http://example2.com", + Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, pword); + yield promptDone; + ok(isOk, "Checking dialog return value (accept)"); + is(pword.value, "secret", "Checking returned password"); +}); + +add_task(function* test_promptPassword_matchingPWForUN() { + // No default password provided, matching login is returned w/o prompting. + pword.value = null; + isOk = prompter1.promptPassword(defaultTitle, defaultMsg, "http://user1name@example2.com", + Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, pword); + ok(isOk, "Checking dialog return value (accept)"); + is(pword.value, "user1pass", "Checking returned password"); +}); + +add_task(function* test_promptPassword_matchingPWForUN2() { + // No default password provided, matching login is returned w/o prompting. + pword.value = null; + isOk = prompter1.promptPassword(defaultTitle, defaultMsg, "http://user2name@example2.com", + Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, pword); + ok(isOk, "Checking dialog return value (accept)"); + is(pword.value, "user2pass", "Checking returned password"); +}); + +add_task(function* test_promptPassword_matchingPWForUN3() { + // No default password provided, matching login is returned w/o prompting. + pword.value = null; + isOk = prompter1.promptPassword(defaultTitle, defaultMsg, "http://user3%2Ename%40host@example2.com", + Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, pword); + ok(isOk, "Checking dialog return value (accept)"); + is(pword.value, "user3pass", "Checking returned password"); +}); + +add_task(function* test_promptPassword_extraAt() { + // No default password provided, matching login is returned w/o prompting. + pword.value = null; + isOk = prompter1.promptPassword(defaultTitle, defaultMsg, "http://100@beef@example2.com", + Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, pword); + ok(isOk, "Checking dialog return value (accept)"); + is(pword.value, "user3pass", "Checking returned password"); +}); + +add_task(function* test_promptPassword_usernameEncoding() { + // No default password provided, matching login is returned w/o prompting. + pword.value = null; + isOk = prompter1.promptPassword(defaultTitle, defaultMsg, "http://100%25beef@example2.com", + Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, pword); + ok(isOk, "Checking dialog return value (accept)"); + is(pword.value, "user3pass", "Checking returned password"); + + // XXX test saving a password with Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY +}); + +add_task(function* test_promptPassword_realm() { + // We don't pre-fill or save for NS_GetAuthKey-generated realms, but we should still prompt + state = { + msg : "the message", + title : "the title", + textValue : "", + passValue : "", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : true, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "passField", + defButton : "button0", + }; + action = { + buttonClick : "ok", + passField : "fill2pass", + }; + pword.value = null; + promptDone = handlePrompt(state, action); + isOk = prompter1.promptPassword(defaultTitle, defaultMsg, "example2.com:80 (somerealm)", + Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, pword); + yield promptDone; + ok(isOk, "Checking dialog return value (accept)"); + is(pword.value, "fill2pass", "Checking returned password"); +}); + +add_task(function* test_promptPassword_realm2() { + // We don't pre-fill or save for NS_GetAuthKey-generated realms, but we should still prompt + state = { + msg : "the message", + title : "the title", + textValue : "", + passValue : "", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : true, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "passField", + defButton : "button0", + }; + action = { + buttonClick : "ok", + passField : "fill2pass", + }; + pword.value = null; + promptDone = handlePrompt(state, action); + isOk = prompter1.promptPassword(defaultTitle, defaultMsg, "example2.com:80 (somerealm)", + Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY, pword); + yield promptDone; + ok(isOk, "Checking dialog return value (accept)"); + is(pword.value, "fill2pass", "Checking returned password"); +}); + +add_task(function* test_promptUsernameAndPassword_accept() { + state = { + msg : "the message", + title : "the title", + textValue : "inuser", + passValue : "inpass", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "textField", + defButton : "button0", + }; + action = { + buttonClick : "ok", + textField : "outuser", + passField : "outpass", + }; + uname.value = "inuser"; + pword.value = "inpass"; + promptDone = handlePrompt(state, action); + isOk = prompter1.promptUsernameAndPassword(defaultTitle, defaultMsg, "http://nonexample.com", + Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, uname, pword); + yield promptDone; + ok(isOk, "Checking dialog return value (accept)"); + is(uname.value, "outuser", "Checking returned username"); + is(pword.value, "outpass", "Checking returned password"); +}); + +add_task(function* test_promptUsernameAndPassword_cancel() { + state = { + msg : "the message", + title : "the title", + textValue : "inuser", + passValue : "inpass", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "textField", + defButton : "button0", + }; + action = { + buttonClick : "cancel", + }; + uname.value = "inuser"; + pword.value = "inpass"; + promptDone = handlePrompt(state, action); + isOk = prompter1.promptUsernameAndPassword(defaultTitle, defaultMsg, "http://nonexample.com", + Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, uname, pword); + yield promptDone; + ok(!isOk, "Checking dialog return value (cancel)"); +}); + +add_task(function* test_promptUsernameAndPassword_autofill() { + // test filling in existing password-only login + state = { + msg : "the message", + title : "the title", + textValue : "", + passValue : "examplepass", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : false, + checkMsg : "Use Password Manager to remember this password.", + checked : true, + focused : "textField", + defButton : "button0", + }; + action = { + buttonClick : "ok", + }; + uname.value = null; + pword.value = null; + promptDone = handlePrompt(state, action); + isOk = prompter1.promptUsernameAndPassword(defaultTitle, defaultMsg, "http://example.com", + Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY, uname, pword); + yield promptDone; + ok(isOk, "Checking dialog return value (accept)"); + is(uname.value, "", "Checking returned username"); + is(pword.value, "examplepass", "Checking returned password"); +}); + +add_task(function* test_promptUsernameAndPassword_multipleExisting() { + // test filling in existing login (undetermined from multiple selection) + // user2name/user2pass would also be valid to fill here. + state = { + msg : "the message", + title : "the title", + textValue : "user1name", + passValue : "user1pass", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : false, + checkMsg : "Use Password Manager to remember this password.", + checked : true, + focused : "textField", + defButton : "button0", + }; + action = { + buttonClick : "ok", + }; + uname.value = null; + pword.value = null; + promptDone = handlePrompt(state, action); + isOk = prompter1.promptUsernameAndPassword(defaultTitle, defaultMsg, "http://example2.com", + Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY, uname, pword); + yield promptDone; + ok(isOk, "Checking dialog return value (accept)"); + ok(uname.value == "user1name" || uname.value == "user2name", "Checking returned username"); + ok(pword.value == "user1pass" || uname.value == "user2pass", "Checking returned password"); +}); + +add_task(function* test_promptUsernameAndPassword_multipleExisting1() { + // test filling in existing login (user1 from multiple selection) + state = { + msg : "the message", + title : "the title", + textValue : "user1name", + passValue : "user1pass", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : false, + checkMsg : "Use Password Manager to remember this password.", + checked : true, + focused : "textField", + defButton : "button0", + }; + action = { + buttonClick : "ok", + }; + uname.value = "user1name"; + pword.value = null; + promptDone = handlePrompt(state, action); + isOk = prompter1.promptUsernameAndPassword(defaultTitle, defaultMsg, "http://example2.com", + Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY, uname, pword); + yield promptDone; + ok(isOk, "Checking dialog return value (accept)"); + is(uname.value, "user1name", "Checking returned username"); + is(pword.value, "user1pass", "Checking returned password"); +}); + +add_task(function* test_promptUsernameAndPassword_multipleExisting2() { + // test filling in existing login (user2 from multiple selection) + state = { + msg : "the message", + title : "the title", + textValue : "user2name", + passValue : "user2pass", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : false, + checkMsg : "Use Password Manager to remember this password.", + checked : true, + focused : "textField", + defButton : "button0", + }; + action = { + buttonClick : "ok", + }; + uname.value = "user2name"; + pword.value = null; + promptDone = handlePrompt(state, action); + isOk = prompter1.promptUsernameAndPassword(defaultTitle, defaultMsg, "http://example2.com", + Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY, uname, pword); + yield promptDone; + ok(isOk, "Checking dialog return value (accept)"); + is(uname.value, "user2name", "Checking returned username"); + is(pword.value, "user2pass", "Checking returned password"); +}); + +add_task(function* test_promptUsernameAndPassword_passwordChange() { + // test changing password + state = { + msg : "the message", + title : "the title", + textValue : "user2name", + passValue : "user2pass", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : false, + checkMsg : "Use Password Manager to remember this password.", + checked : true, + focused : "textField", + defButton : "button0", + }; + action = { + buttonClick : "ok", + passField : "NEWuser2pass", + }; + uname.value = "user2name"; + pword.value = null; + promptDone = handlePrompt(state, action); + isOk = prompter1.promptUsernameAndPassword(defaultTitle, defaultMsg, "http://example2.com", + Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY, uname, pword); + yield promptDone; + ok(isOk, "Checking dialog return value (accept)"); + is(uname.value, "user2name", "Checking returned username"); + is(pword.value, "NEWuser2pass", "Checking returned password"); +}); + +add_task(function* test_promptUsernameAndPassword_changePasswordBack() { + // test changing password (back to original value) + state = { + msg : "the message", + title : "the title", + textValue : "user2name", + passValue : "NEWuser2pass", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : false, + checkMsg : "Use Password Manager to remember this password.", + checked : true, + focused : "textField", + defButton : "button0", + }; + action = { + buttonClick : "ok", + passField : "user2pass", + }; + uname.value = "user2name"; + pword.value = null; + promptDone = handlePrompt(state, action); + isOk = prompter1.promptUsernameAndPassword(defaultTitle, defaultMsg, "http://example2.com", + Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY, uname, pword); + yield promptDone; + ok(isOk, "Checking dialog return value (accept)"); + is(uname.value, "user2name", "Checking returned username"); + is(pword.value, "user2pass", "Checking returned password"); +}); + +add_task(function* test_promptUsernameAndPassword_realm() { + // We don't pre-fill or save for NS_GetAuthKey-generated realms, but we should still prompt + state = { + msg : "the message", + title : "the title", + textValue : "", + passValue : "", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "textField", + defButton : "button0", + }; + action = { + buttonClick : "ok", + textField : "fill2user", + passField : "fill2pass", + }; + uname.value = null; + pword.value = null; + promptDone = handlePrompt(state, action); + isOk = prompter1.promptUsernameAndPassword(defaultTitle, defaultMsg, "example2.com:80 (somerealm)", + Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, uname, pword); + yield promptDone; + ok(isOk, "Checking dialog return value (accept)"); + is(uname.value, "fill2user", "Checking returned username"); + is(pword.value, "fill2pass", "Checking returned password"); +}); + +add_task(function* test_promptUsernameAndPassword_realm2() { + // We don't pre-fill or save for NS_GetAuthKey-generated realms, but we should still prompt + state = { + msg : "the message", + title : "the title", + textValue : "", + passValue : "", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "textField", + defButton : "button0", + }; + action = { + buttonClick : "ok", + textField : "fill2user", + passField : "fill2pass", + }; + uname.value = null; + pword.value = null; + promptDone = handlePrompt(state, action); + isOk = prompter1.promptUsernameAndPassword(defaultTitle, defaultMsg, "example2.com:80 (somerealm)", + Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY, uname, pword); + yield promptDone; + ok(isOk, "Checking dialog return value (accept)"); + is(uname.value, "fill2user", "Checking returned username"); + is(pword.value, "fill2pass", "Checking returned password"); +}); + +</script> +</pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_prompt_http.html b/toolkit/components/passwordmgr/test/mochitest/test_prompt_http.html new file mode 100644 index 000000000..0dc8fdf9c --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_prompt_http.html @@ -0,0 +1,362 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test HTTP auth prompts by loading authenticate.sjs</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.js"></script> + <script type="text/javascript" src="pwmgr_common.js"></script> + <script type="text/javascript" src="prompt_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content" style="display: none"> + <iframe id="iframe"></iframe> +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> +var iframe = document.getElementById("iframe"); + +// Force parent to not look for tab-modal prompts, as they're not used for auth prompts. +isTabModal = false; + +const AUTHENTICATE_PATH = new URL("authenticate.sjs", window.location.href).pathname; + +let chromeScript = runInParent(SimpleTest.getTestFileURL("pwmgr_common.js")); + +runInParent(() => { + const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + Cu.import("resource://gre/modules/Services.jsm"); + + let pwmgr = Cc["@mozilla.org/login-manager;1"]. + getService(Ci.nsILoginManager); + + let login3A, login3B, login4; + login3A = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + login3B = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + login4 = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + let httpUpgradeLogin = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + let httpsDowngradeLogin = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + let dedupeHttpUpgradeLogin = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + let dedupeHttpsUpgradeLogin = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + + + login3A.init("http://mochi.test:8888", null, "mochitest", + "mochiuser1", "mochipass1", "", ""); + login3B.init("http://mochi.test:8888", null, "mochitest2", + "mochiuser2", "mochipass2", "", ""); + login4.init("http://mochi.test:8888", null, "mochitest3", + "mochiuser3", "mochipass3-old", "", ""); + // Logins to test scheme upgrades (allowed) and downgrades (disallowed) + httpUpgradeLogin.init("http://example.com", null, "schemeUpgrade", + "httpUser", "httpPass", "", ""); + httpsDowngradeLogin.init("https://example.com", null, "schemeDowngrade", + "httpsUser", "httpsPass", "", ""); + // HTTP and HTTPS version of the same domain and realm but with different passwords. + dedupeHttpUpgradeLogin.init("http://example.org", null, "schemeUpgradeDedupe", + "dedupeUser", "httpPass", "", ""); + dedupeHttpsUpgradeLogin.init("https://example.org", null, "schemeUpgradeDedupe", + "dedupeUser", "httpsPass", "", ""); + + + pwmgr.addLogin(login3A); + pwmgr.addLogin(login3B); + pwmgr.addLogin(login4); + pwmgr.addLogin(httpUpgradeLogin); + pwmgr.addLogin(httpsDowngradeLogin); + pwmgr.addLogin(dedupeHttpUpgradeLogin); + pwmgr.addLogin(dedupeHttpsUpgradeLogin); +}); + +add_task(function* test_iframe() { + let state = { + msg : "http://mochi.test:8888 is requesting your username and password. The site says: “mochitest”", + title : "Authentication Required", + textValue : "mochiuser1", + passValue : "mochipass1", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "textField", + defButton : "button0", + }; + let action = { + buttonClick : "ok", + }; + promptDone = handlePrompt(state, action); + + // The following tests are driven by iframe loads + + var iframeLoaded = onloadPromiseFor("iframe"); + iframe.src = "authenticate.sjs?user=mochiuser1&pass=mochipass1"; + yield promptDone; + yield iframeLoaded; + checkEchoedAuthInfo({user: "mochiuser1", pass: "mochipass1"}, + iframe.contentDocument); + + state = { + msg : "http://mochi.test:8888 is requesting your username and password. The site says: “mochitest2”", + title : "Authentication Required", + textValue : "mochiuser2", + passValue : "mochipass2", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "textField", + defButton : "button0", + }; + action = { + buttonClick : "ok", + }; + promptDone = handlePrompt(state, action); + // We've already authenticated to this host:port. For this next + // request, the existing auth should be sent, we'll get a 401 reply, + // and we should prompt for new auth. + iframeLoaded = onloadPromiseFor("iframe"); + iframe.src = "authenticate.sjs?user=mochiuser2&pass=mochipass2&realm=mochitest2"; + yield promptDone; + yield iframeLoaded; + checkEchoedAuthInfo({user: "mochiuser2", pass: "mochipass2"}, + iframe.contentDocument); + + // Now make a load that requests the realm from test 1000. It was + // already provided there, so auth will *not* be prompted for -- the + // networking layer already knows it! + iframeLoaded = onloadPromiseFor("iframe"); + iframe.src = "authenticate.sjs?user=mochiuser1&pass=mochipass1"; + yield iframeLoaded; + checkEchoedAuthInfo({user: "mochiuser1", pass: "mochipass1"}, + iframe.contentDocument); + + // Same realm we've already authenticated to, but with a different + // expected password (to trigger an auth prompt, and change-password + // popup notification). + state = { + msg : "http://mochi.test:8888 is requesting your username and password. The site says: “mochitest”", + title : "Authentication Required", + textValue : "mochiuser1", + passValue : "mochipass1", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "textField", + defButton : "button0", + }; + action = { + buttonClick : "ok", + passField : "mochipass1-new", + }; + promptDone = handlePrompt(state, action); + iframeLoaded = onloadPromiseFor("iframe"); + let promptShownPromise = promisePromptShown("passwordmgr-prompt-change"); + iframe.src = "authenticate.sjs?user=mochiuser1&pass=mochipass1-new"; + yield promptDone; + yield iframeLoaded; + checkEchoedAuthInfo({user: "mochiuser1", pass: "mochipass1-new"}, + iframe.contentDocument); + yield promptShownPromise; + + // Same as last test, but for a realm we haven't already authenticated + // to (but have an existing saved login for, so that we'll trigger + // a change-password popup notification. + state = { + msg : "http://mochi.test:8888 is requesting your username and password. The site says: “mochitest3”", + title : "Authentication Required", + textValue : "mochiuser3", + passValue : "mochipass3-old", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "textField", + defButton : "button0", + }; + action = { + buttonClick : "ok", + passField : "mochipass3-new", + }; + promptDone = handlePrompt(state, action); + iframeLoaded = onloadPromiseFor("iframe"); + promptShownPromise = promisePromptShown("passwordmgr-prompt-change"); + iframe.src = "authenticate.sjs?user=mochiuser3&pass=mochipass3-new&realm=mochitest3"; + yield promptDone; + yield iframeLoaded; + checkEchoedAuthInfo({user: "mochiuser3", pass: "mochipass3-new"}, + iframe.contentDocument); + yield promptShownPromise; + + // Housekeeping: Delete login4 to test the save prompt in the next test. + runInParent(() => { + const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + Cu.import("resource://gre/modules/Services.jsm"); + + var tmpLogin = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + tmpLogin.init("http://mochi.test:8888", null, "mochitest3", + "mochiuser3", "mochipass3-old", "", ""); + Services.logins.removeLogin(tmpLogin); + + // Clear cached auth from this subtest, and avoid leaking due to bug 459620. + var authMgr = Cc['@mozilla.org/network/http-auth-manager;1']. + getService(Ci.nsIHttpAuthManager); + authMgr.clearAll(); + }); + + state = { + msg : "http://mochi.test:8888 is requesting your username and password. The site says: “mochitest3”", + title : "Authentication Required", + textValue : "", + passValue : "", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "textField", + defButton : "button0", + }; + action = { + buttonClick : "ok", + textField : "mochiuser3", + passField : "mochipass3-old", + }; + // Trigger a new prompt, so we can test adding a new login. + promptDone = handlePrompt(state, action); + + iframeLoaded = onloadPromiseFor("iframe"); + promptShownPromise = promisePromptShown("passwordmgr-prompt-save"); + iframe.src = "authenticate.sjs?user=mochiuser3&pass=mochipass3-old&realm=mochitest3"; + yield promptDone; + yield iframeLoaded; + checkEchoedAuthInfo({user: "mochiuser3", pass: "mochipass3-old"}, + iframe.contentDocument); + yield promptShownPromise; +}); + +add_task(function* test_schemeUpgrade() { + let state = { + msg : "https://example.com is requesting your username and password. " + + "WARNING: Your password will not be sent to the website you are currently visiting!", + title : "Authentication Required", + textValue : "httpUser", + passValue : "httpPass", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "textField", + defButton : "button0", + }; + let action = { + buttonClick : "ok", + }; + let promptDone = handlePrompt(state, action); + + // The following tests are driven by iframe loads + + let iframeLoaded = onloadPromiseFor("iframe"); + iframe.src = "https://example.com" + AUTHENTICATE_PATH + + "?user=httpUser&pass=httpPass&realm=schemeUpgrade"; + yield promptDone; + yield iframeLoaded; + checkEchoedAuthInfo({user: "httpUser", pass: "httpPass"}, + SpecialPowers.wrap(iframe).contentDocument); +}); + +add_task(function* test_schemeDowngrade() { + let state = { + msg : "http://example.com is requesting your username and password. " + + "WARNING: Your password will not be sent to the website you are currently visiting!", + title : "Authentication Required", + textValue : "", // empty because we shouldn't downgrade + passValue : "", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "textField", + defButton : "button0", + }; + let action = { + buttonClick : "cancel", + }; + let promptDone = handlePrompt(state, action); + + // The following tests are driven by iframe loads + + let iframeLoaded = onloadPromiseFor("iframe"); + iframe.src = "http://example.com" + AUTHENTICATE_PATH + + "?user=unused&pass=unused&realm=schemeDowngrade"; + yield promptDone; + yield iframeLoaded; +}); + +add_task(function* test_schemeUpgrade_dedupe() { + let state = { + msg : "https://example.org is requesting your username and password. " + + "WARNING: Your password will not be sent to the website you are currently visiting!", + title : "Authentication Required", + textValue : "dedupeUser", + passValue : "httpsPass", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "textField", + defButton : "button0", + }; + let action = { + buttonClick : "ok", + }; + let promptDone = handlePrompt(state, action); + + // The following tests are driven by iframe loads + + let iframeLoaded = onloadPromiseFor("iframe"); + iframe.src = "https://example.org" + AUTHENTICATE_PATH + + "?user=dedupeUser&pass=httpsPass&realm=schemeUpgradeDedupe"; + yield promptDone; + yield iframeLoaded; + checkEchoedAuthInfo({user: "dedupeUser", pass: "httpsPass"}, + SpecialPowers.wrap(iframe).contentDocument); +}); +</script> +</pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_prompt_noWindow.html b/toolkit/components/passwordmgr/test/mochitest/test_prompt_noWindow.html new file mode 100644 index 000000000..92af172ca --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_prompt_noWindow.html @@ -0,0 +1,81 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test HTTP auth prompts by loading authenticate.sjs with no window</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.js"></script> + <script type="text/javascript" src="pwmgr_common.js"></script> + <script type="text/javascript" src="prompt_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content" style="display: none"> +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +// Force parent to not look for tab-modal prompts, as they're not used for auth prompts. +isTabModal = false; + +let chromeScript = runInParent(SimpleTest.getTestFileURL("pwmgr_common.js")); + +runInParent(() => { + const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + Cu.import("resource://gre/modules/Services.jsm"); + + let login = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + login.init("http://mochi.test:8888", null, "mochitest", + "mochiuser1", "mochipass1", "", ""); + Services.logins.addLogin(login); +}); + +add_task(function* test_sandbox_xhr() { + let state = { + msg : "http://mochi.test:8888 is requesting your username and password. The site says: “mochitest”", + title : "Authentication Required", + textValue : "mochiuser1", + passValue : "mochipass1", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "textField", + defButton : "button0", + }; + let action = { + buttonClick : "ok", + }; + let promptDone = handlePrompt(state, action); + + let url = new URL("authenticate.sjs?user=mochiuser1&pass=mochipass1", window.location.href); + let sandboxConstructor = SpecialPowers.Cu.Sandbox; + let sandbox = new sandboxConstructor(this, {wantXrays: true}); + function sandboxedRequest(sandboxedUrl) { + let req = SpecialPowers.Cc["@mozilla.org/xmlextras/xmlhttprequest;1"] + .createInstance(SpecialPowers.Ci.nsIXMLHttpRequest); + req.open("GET", sandboxedUrl, true); + req.send(null); + } + + let loginModifiedPromise = promiseStorageChanged(["modifyLogin"]); + sandbox.sandboxedRequest = sandboxedRequest(url); + info("send the XHR request in the sandbox"); + SpecialPowers.Cu.evalInSandbox("sandboxedRequest;", sandbox); + + yield promptDone; + info("prompt shown, waiting for metadata updates"); + // Ensure the timeLastUsed and timesUsed metadata are updated. + yield loginModifiedPromise; +}); +</script> +</pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_prompt_promptAuth.html b/toolkit/components/passwordmgr/test/mochitest/test_prompt_promptAuth.html new file mode 100644 index 000000000..36f53a54a --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_prompt_promptAuth.html @@ -0,0 +1,406 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test promptAuth prompts</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.js"></script> + <script type="text/javascript" src="pwmgr_common.js"></script> + <script type="text/javascript" src="prompt_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content" style="display: none"> +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> +var state, action; +var isOk; + +var level = Ci.nsIAuthPrompt2.LEVEL_NONE; +var authinfo = { + username : "", + password : "", + domain : "", + + flags : Ci.nsIAuthInformation.AUTH_HOST, + authenticationScheme : "basic", + realm : "" +}; + +// Force parent to not look for tab-modal prompts, as they're not used for auth prompts. +isTabModal = false; + +let chromeScript = runInParent(SimpleTest.getTestFileURL("pwmgr_common.js")); + +let prompterParent = runInParent(() => { + const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + const promptFac = Cc["@mozilla.org/passwordmanager/authpromptfactory;1"]. + getService(Ci.nsIPromptFactory); + + Cu.import("resource://gre/modules/Services.jsm"); + let chromeWin = Services.wm.getMostRecentWindow("navigator:browser"); + let prompter2 = promptFac.getPrompt(chromeWin, Ci.nsIAuthPrompt2); + + let ioService = Cc["@mozilla.org/network/io-service;1"].getService(Ci.nsIIOService); + let channels = {}; + channels.channel1 = ioService.newChannel2("http://example.com", + null, + null, + null, // aLoadingNode + Services. + scriptSecurityManager.getSystemPrincipal(), + null, // aTriggeringPrincipal + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_DATA_IS_NULL, + Ci.nsIContentPolicy.TYPE_OTHER); + + channels.channel2 = ioService.newChannel2("http://example2.com", + null, + null, + null, // aLoadingNode + Services. + scriptSecurityManager.getSystemPrincipal(), + null, // aTriggeringPrincipal + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_DATA_IS_NULL, + Ci.nsIContentPolicy.TYPE_OTHER); + + addMessageListener("proxyPrompter", function onMessage(msg) { + let args = [...msg.args]; + let channelName = args.shift(); + // Replace the channel name string (arg. 0) with the channel by that name. + args.unshift(channels[channelName]); + + let rv = prompter2[msg.methodName](...args); + return { + rv, + // Send the args back to content so out/inout args can be checked. + args: msg.args, + }; + }); + + Cu.import("resource://gre/modules/Services.jsm"); + + let pwmgr = Cc["@mozilla.org/login-manager;1"]. + getService(Ci.nsILoginManager); + + let login1, login2A, login2B, login2C, login2D, login2E; + login1 = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + login2A = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + login2B = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + login2C = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + login2D = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + login2E = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + + login1.init("http://example.com", null, "http://example.com", + "", "examplepass", "", ""); + login2A.init("http://example2.com", null, "http://example2.com", + "user1name", "user1pass", "", ""); + login2B.init("http://example2.com", null, "http://example2.com", + "user2name", "user2pass", "", ""); + login2C.init("http://example2.com", null, "http://example2.com", + "user3.name@host", "user3pass", "", ""); + login2D.init("http://example2.com", null, "http://example2.com", + "100@beef", "user3pass", "", ""); + login2E.init("http://example2.com", null, "http://example2.com", + "100%beef", "user3pass", "", ""); + + pwmgr.addLogin(login1); + pwmgr.addLogin(login2A); + pwmgr.addLogin(login2B); + pwmgr.addLogin(login2C); + pwmgr.addLogin(login2D); + pwmgr.addLogin(login2E); +}); + +let prompter2 = new PrompterProxy(prompterParent); + +add_task(function* test_accept() { + state = { + msg : "http://example.com is requesting your username and password. The site says: “some realm”", + title : "Authentication Required", + textValue : "inuser", + passValue : "inpass", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "textField", + defButton : "button0", + }; + action = { + buttonClick : "ok", + textField : "outuser", + passField : "outpass", + }; + authinfo.username = "inuser"; + authinfo.password = "inpass"; + authinfo.realm = "some realm"; + + promptDone = handlePrompt(state, action); + // Since prompter2 is actually a proxy to send a message to a chrome script and + // we can't send a channel in a message, we instead send the channel name that + // already exists in the chromeScript. + isOk = prompter2.promptAuth("channel1", level, authinfo); + yield promptDone; + + ok(isOk, "Checking dialog return value (accept)"); + is(authinfo.username, "outuser", "Checking returned username"); + is(authinfo.password, "outpass", "Checking returned password"); +}); + +add_task(function* test_cancel() { + state = { + msg : "http://example.com is requesting your username and password. The site says: “some realm”", + title : "Authentication Required", + textValue : "outuser", + passValue : "outpass", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "textField", + defButton : "button0", + }; + action = { + buttonClick : "cancel", + }; + promptDone = handlePrompt(state, action); + isOk = prompter2.promptAuth("channel1", level, authinfo); + yield promptDone; + + ok(!isOk, "Checking dialog return value (cancel)"); +}); + +add_task(function* test_pwonly() { + // test filling in password-only login + state = { + msg : "http://example.com is requesting your username and password. The site says: “http://example.com”", + title : "Authentication Required", + textValue : "", + passValue : "examplepass", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "textField", + defButton : "button0", + }; + action = { + buttonClick : "ok", + }; + authinfo.username = ""; + authinfo.password = ""; + authinfo.realm = "http://example.com"; + + promptDone = handlePrompt(state, action); + isOk = prompter2.promptAuth("channel1", level, authinfo); + yield promptDone; + + ok(isOk, "Checking dialog return value (accept)"); + is(authinfo.username, "", "Checking returned username"); + is(authinfo.password, "examplepass", "Checking returned password"); +}); + +add_task(function* test_multipleExisting() { + // test filling in existing login (undetermined from multiple selection) + // user2name/user2pass would also be valid to fill here. + state = { + msg : "http://example2.com is requesting your username and password. The site says: “http://example2.com”", + title : "Authentication Required", + textValue : "user1name", + passValue : "user1pass", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "textField", + defButton : "button0", + }; + action = { + buttonClick : "ok", + }; + authinfo.username = ""; + authinfo.password = ""; + authinfo.realm = "http://example2.com"; + + promptDone = handlePrompt(state, action); + isOk = prompter2.promptAuth("channel2", level, authinfo); + yield promptDone; + + ok(isOk, "Checking dialog return value (accept)"); + ok(authinfo.username == "user1name" || authinfo.username == "user2name", "Checking returned username"); + ok(authinfo.password == "user1pass" || authinfo.password == "user2pass", "Checking returned password"); +}); + +add_task(function* test_multipleExisting2() { + // test filling in existing login (undetermined --> user1) + // user2name/user2pass would also be valid to fill here. + state = { + msg : "http://example2.com is requesting your username and password. The site says: “http://example2.com”", + title : "Authentication Required", + textValue : "user1name", + passValue : "user1pass", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "textField", + defButton : "button0", + }; + // enter one of the known logins, test 504+505 exercise the two possible states. + action = { + buttonClick : "ok", + textField : "user1name", + passField : "user1pass", + }; + authinfo.username = ""; + authinfo.password = ""; + authinfo.realm = "http://example2.com"; + + promptDone = handlePrompt(state, action); + isOk = prompter2.promptAuth("channel2", level, authinfo); + yield promptDone; + + ok(isOk, "Checking dialog return value (accept)"); + is(authinfo.username, "user1name", "Checking returned username"); + is(authinfo.password, "user1pass", "Checking returned password"); +}); + +add_task(function* test_multipleExisting3() { + // test filling in existing login (undetermined --> user2) + // user2name/user2pass would also be valid to fill here. + state = { + msg : "http://example2.com is requesting your username and password. The site says: “http://example2.com”", + title : "Authentication Required", + textValue : "user1name", + passValue : "user1pass", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "textField", + defButton : "button0", + }; + // enter one of the known logins, test 504+505 exercise the two possible states. + action = { + buttonClick : "ok", + textField : "user2name", + passField : "user2pass", + }; + authinfo.username = ""; + authinfo.password = ""; + authinfo.realm = "http://example2.com"; + + promptDone = handlePrompt(state, action); + isOk = prompter2.promptAuth("channel2", level, authinfo); + yield promptDone; + + ok(isOk, "Checking dialog return value (accept)"); + is(authinfo.username, "user2name", "Checking returned username"); + is(authinfo.password, "user2pass", "Checking returned password"); +}); + +add_task(function* test_changingMultiple() { + // test changing a password (undetermined --> user2 w/ newpass) + // user2name/user2pass would also be valid to fill here. + state = { + msg : "http://example2.com is requesting your username and password. The site says: “http://example2.com”", + title : "Authentication Required", + textValue : "user1name", + passValue : "user1pass", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "textField", + defButton : "button0", + }; + // force to user2, and change the password + action = { + buttonClick : "ok", + textField : "user2name", + passField : "NEWuser2pass", + }; + authinfo.username = ""; + authinfo.password = ""; + authinfo.realm = "http://example2.com"; + + promptDone = handlePrompt(state, action); + isOk = prompter2.promptAuth("channel2", level, authinfo); + yield promptDone; + + ok(isOk, "Checking dialog return value (accept)"); + is(authinfo.username, "user2name", "Checking returned username"); + is(authinfo.password, "NEWuser2pass", "Checking returned password"); +}); + +add_task(function* test_changingMultiple2() { + // test changing a password (undetermined --> user2 w/ origpass) + // user2name/user2pass would also be valid to fill here. + state = { + msg : "http://example2.com is requesting your username and password. The site says: “http://example2.com”", + title : "Authentication Required", + textValue : "user1name", + passValue : "user1pass", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "textField", + defButton : "button0", + }; + // force to user2, and change the password back + action = { + buttonClick : "ok", + textField : "user2name", + passField : "user2pass", + }; + authinfo.username = ""; + authinfo.password = ""; + authinfo.realm = "http://example2.com"; + + promptDone = handlePrompt(state, action); + isOk = prompter2.promptAuth("channel2", level, authinfo); + yield promptDone; + + ok(isOk, "Checking dialog return value (accept)"); + is(authinfo.username, "user2name", "Checking returned username"); + is(authinfo.password, "user2pass", "Checking returned password"); +}); +</script> +</pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_prompt_promptAuth_proxy.html b/toolkit/components/passwordmgr/test/mochitest/test_prompt_promptAuth_proxy.html new file mode 100644 index 000000000..95dd4c7bc --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_prompt_promptAuth_proxy.html @@ -0,0 +1,264 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test promptAuth proxy prompts</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.js"></script> + <script type="text/javascript" src="pwmgr_common.js"></script> + <script type="text/javascript" src="prompt_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content" style="display: none"> + <iframe id="iframe"></iframe> +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> +var state, action; +var pwmgr; +var proxyLogin; +var isOk; +var mozproxy, proxiedHost = "http://mochi.test:8888"; +var proxyChannel; +var systemPrincipal = SpecialPowers.Services.scriptSecurityManager.getSystemPrincipal(); +var ioService = Cc["@mozilla.org/network/io-service;1"].getService(Ci.nsIIOService); + +var prefs = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefBranch); + +var level = Ci.nsIAuthPrompt2.LEVEL_NONE; + +var proxyAuthinfo = { + username : "", + password : "", + domain : "", + + flags : Ci.nsIAuthInformation.AUTH_PROXY, + authenticationScheme : "basic", + realm : "" +}; + +// Force parent to not look for tab-modal prompts, as they're not used for auth prompts. +isTabModal = false; + +const Cc_promptFac = Cc["@mozilla.org/passwordmanager/authpromptfactory;1"]; +ok(Cc_promptFac != null, "Access Cc[@mozilla.org/passwordmanager/authpromptfactory;1]"); + +const Ci_promptFac = Ci.nsIPromptFactory; +ok(Ci_promptFac != null, "Access Ci.nsIPromptFactory"); + +const promptFac = Cc_promptFac.getService(Ci_promptFac); +ok(promptFac != null, "promptFac getService()"); + +var prompter2 = promptFac.getPrompt(window, Ci.nsIAuthPrompt2); + +function initLogins(pi) { + pwmgr = Cc["@mozilla.org/login-manager;1"]. + getService(Ci.nsILoginManager); + + mozproxy = "moz-proxy://" + SpecialPowers.wrap(pi).host + ":" + + SpecialPowers.wrap(pi).port; + + proxyLogin = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + + proxyLogin.init(mozproxy, null, "Proxy Realm", + "proxuser", "proxpass", "", ""); + + pwmgr.addLogin(proxyLogin); +} + +var startupCompleteResolver; +var startupComplete = new Promise(resolve => startupCompleteResolver = resolve); + +function proxyChannelListener() { } +proxyChannelListener.prototype = { + onStartRequest: function(request, context) { + startupCompleteResolver(); + }, + onStopRequest: function(request, context, status) { } +}; + +var resolveCallback = SpecialPowers.wrapCallbackObject({ + QueryInterface : function (iid) { + const interfaces = [Ci.nsIProtocolProxyCallback, Ci.nsISupports]; + + if (!interfaces.some( function(v) { return iid.equals(v); } )) + throw SpecialPowers.Cr.NS_ERROR_NO_INTERFACE; + return this; + }, + + onProxyAvailable : function (req, uri, pi, status) { + initLogins(pi); + + // I'm cheating a bit here... We should probably do some magic foo to get + // something implementing nsIProxiedProtocolHandler and then call + // NewProxiedChannel(), so we have something that's definately a proxied + // channel. But Mochitests use a proxy for a number of hosts, so just + // requesting a normal channel will give us a channel that's proxied. + // The proxyChannel needs to move to at least on-modify-request to + // have valid ProxyInfo, but we use OnStartRequest during startup() + // for simplicity. + proxyChannel = ioService.newChannel2(proxiedHost, + null, + null, + null, // aLoadingNode + systemPrincipal, + null, // aTriggeringPrincipal + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_DATA_IS_NULL, + Ci.nsIContentPolicy.TYPE_OTHER); + proxyChannel.asyncOpen2(SpecialPowers.wrapCallbackObject(new proxyChannelListener())); + } +}); + +function startup() { + // Need to allow for arbitrary network servers defined in PAC instead of a hardcoded moz-proxy. + var ios = SpecialPowers.Cc["@mozilla.org/network/io-service;1"]. + getService(SpecialPowers.Ci.nsIIOService); + + var pps = SpecialPowers.Cc["@mozilla.org/network/protocol-proxy-service;1"].getService(); + + var channel = ios.newChannel2("http://example.com", + null, + null, + null, // aLoadingNode + systemPrincipal, + null, // aTriggeringPrincipal + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_DATA_IS_NULL, + Ci.nsIContentPolicy.TYPE_OTHER); + pps.asyncResolve(channel, 0, resolveCallback); +} + +startup(); + +add_task(function* setup() { + info("Waiting for startup to complete..."); + yield startupComplete; +}); + +add_task(function* test_noAutologin() { + // test proxy login (default = no autologin), make sure it prompts. + state = { + msg : "The proxy moz-proxy://127.0.0.1:8888 is requesting a username and password. The site says: “Proxy Realm”", + title : "Authentication Required", + textValue : "proxuser", + passValue : "proxpass", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "textField", + defButton : "button0", + }; + action = { + buttonClick : "ok", + }; + proxyAuthinfo.username = ""; + proxyAuthinfo.password = ""; + proxyAuthinfo.realm = "Proxy Realm"; + proxyAuthinfo.flags = Ci.nsIAuthInformation.AUTH_PROXY; + + var time1 = pwmgr.findLogins({}, mozproxy, null, "Proxy Realm")[0].QueryInterface(Ci.nsILoginMetaInfo).timeLastUsed; + promptDone = handlePrompt(state, action); + isOk = prompter2.promptAuth(proxyChannel, level, proxyAuthinfo); + yield promptDone; + var time2 = pwmgr.findLogins({}, mozproxy, null, "Proxy Realm")[0].QueryInterface(Ci.nsILoginMetaInfo).timeLastUsed; + + ok(isOk, "Checking dialog return value (accept)"); + isnot(time1, time2, "Checking that timeLastUsed was updated"); + is(proxyAuthinfo.username, "proxuser", "Checking returned username"); + is(proxyAuthinfo.password, "proxpass", "Checking returned password"); +}); + +add_task(function* test_autologin() { + // test proxy login (with autologin) + + // Enable the autologin pref. + prefs.setBoolPref("signon.autologin.proxy", true); + + proxyAuthinfo.username = ""; + proxyAuthinfo.password = ""; + proxyAuthinfo.realm = "Proxy Realm"; + proxyAuthinfo.flags = Ci.nsIAuthInformation.AUTH_PROXY; + + time1 = pwmgr.findLogins({}, mozproxy, null, "Proxy Realm")[0].QueryInterface(Ci.nsILoginMetaInfo).timeLastUsed; + isOk = prompter2.promptAuth(proxyChannel, level, proxyAuthinfo); + time2 = pwmgr.findLogins({}, mozproxy, null, "Proxy Realm")[0].QueryInterface(Ci.nsILoginMetaInfo).timeLastUsed; + + ok(isOk, "Checking dialog return value (accept)"); + isnot(time1, time2, "Checking that timeLastUsed was updated"); + is(proxyAuthinfo.username, "proxuser", "Checking returned username"); + is(proxyAuthinfo.password, "proxpass", "Checking returned password"); +}); + +add_task(function* test_autologin_incorrect() { + // test proxy login (with autologin), ensure it prompts after a failed auth. + state = { + msg : "The proxy moz-proxy://127.0.0.1:8888 is requesting a username and password. The site says: “Proxy Realm”", + title : "Authentication Required", + textValue : "proxuser", + passValue : "proxpass", + iconClass : "authentication-icon question-icon", + titleHidden : true, + textHidden : false, + passHidden : false, + checkHidden : true, + checkMsg : "", + checked : false, + focused : "textField", + defButton : "button0", + }; + action = { + buttonClick : "ok", + }; + + proxyAuthinfo.username = ""; + proxyAuthinfo.password = ""; + proxyAuthinfo.realm = "Proxy Realm"; + proxyAuthinfo.flags = (Ci.nsIAuthInformation.AUTH_PROXY | Ci.nsIAuthInformation.PREVIOUS_FAILED); + + time1 = pwmgr.findLogins({}, mozproxy, null, "Proxy Realm")[0].QueryInterface(Ci.nsILoginMetaInfo).timeLastUsed; + promptDone = handlePrompt(state, action); + isOk = prompter2.promptAuth(proxyChannel, level, proxyAuthinfo); + yield promptDone; + time2 = pwmgr.findLogins({}, mozproxy, null, "Proxy Realm")[0].QueryInterface(Ci.nsILoginMetaInfo).timeLastUsed; + + ok(isOk, "Checking dialog return value (accept)"); + isnot(time1, time2, "Checking that timeLastUsed was updated"); + is(proxyAuthinfo.username, "proxuser", "Checking returned username"); + is(proxyAuthinfo.password, "proxpass", "Checking returned password"); +}); + +add_task(function* test_autologin_private() { + // test proxy login (with autologin), ensure it prompts in Private Browsing mode. + state = { + msg : "the message", + title : "the title", + textValue : "proxuser", + passValue : "proxpass", + }; + action = { + buttonClick : "ok", + }; + + proxyAuthinfo.username = ""; + proxyAuthinfo.password = ""; + proxyAuthinfo.realm = "Proxy Realm"; + proxyAuthinfo.flags = Ci.nsIAuthInformation.AUTH_PROXY; + + prefs.clearUserPref("signon.autologin.proxy"); + + // XXX check for and kill popup notification?? + // XXX check for checkbox / checkstate on old prompts? + // XXX check NTLM domain stuff +}); +</script> +</pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_recipe_login_fields.html b/toolkit/components/passwordmgr/test/mochitest/test_recipe_login_fields.html new file mode 100644 index 000000000..943bffc52 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_recipe_login_fields.html @@ -0,0 +1,145 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Test for recipes overriding login fields</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/SpawnTask.js"></script> + <script src="pwmgr_common.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<script> +var chromeScript = runChecksAfterCommonInit(); + +let fillPromiseResolvers = []; + +function waitForFills(fillCount) { + let promises = []; + while (fillCount--) { + let promise = new Promise(resolve => fillPromiseResolvers.push(resolve)); + promises.push(promise); + } + + return Promise.all(promises); +} + +add_task(function* setup() { + if (document.readyState !== "complete") { + yield new Promise((resolve) => { + document.onreadystatechange = () => { + if (document.readyState !== "complete") { + return; + } + document.onreadystatechange = null; + resolve(); + }; + }); + } + + document.getElementById("content") + .addEventListener("input", function handleInputEvent(evt) { + let resolve = fillPromiseResolvers.shift(); + if (!resolve) { + ok(false, "Too many fills"); + return; + } + + resolve(evt.target); + }); +}); + +add_task(function* loadUsernamePasswordSelectorRecipes() { + yield loadRecipes({ + siteRecipes: [{ + hosts: ["mochi.test:8888"], + usernameSelector: "input[name='uname1']", + passwordSelector: "input[name='pword2']", + }], + }); +}); + +add_task(function* testOverriddingFields() { + // Insert the form dynamically so autofill is triggered after setup above. + document.getElementById("content").innerHTML = ` + <!-- form with recipe for the username and password --> + <form id="form1"> + <input type="text" name="uname1" data-expected="true"> + <input type="text" name="uname2" data-expected="false"> + <input type="password" name="pword1" data-expected="false"> + <input type="password" name="pword2" data-expected="true"> + </form>`; + + let elements = yield waitForFills(2); + for (let element of elements) { + is(element.dataset.expected, "true", `${element.name} was filled`); + } +}); + +add_task(function* testDefaultHeuristics() { + // Insert the form dynamically so autofill is triggered after setup above. + document.getElementById("content").innerHTML = ` + <!-- Fallback to the default heuristics since the selectors don't match --> + <form id="form2"> + <input type="text" name="uname3" data-expected="false"> + <input type="text" name="uname4" data-expected="true"> + <input type="password" name="pword3" data-expected="true"> + <input type="password" name="pword4" data-expected="false"> + </form>`; + + let elements = yield waitForFills(2); + for (let element of elements) { + is(element.dataset.expected, "true", `${element.name} was filled`); + } +}); + +add_task(function* loadNotUsernameSelectorRecipes() { + yield resetRecipes(); + yield loadRecipes({ + siteRecipes: [{ + hosts: ["mochi.test:8888"], + notUsernameSelector: "input[name='not_uname1']" + }], + }); +}); + +add_task(function* testNotUsernameField() { + document.getElementById("content").innerHTML = ` + <!-- The field matching notUsernameSelector should be skipped --> + <form id="form3"> + <input type="text" name="uname5" data-expected="true"> + <input type="text" name="not_uname1" data-expected="false"> + <input type="password" name="pword5" data-expected="true"> + </form>`; + + let elements = yield waitForFills(2); + for (let element of elements) { + is(element.dataset.expected, "true", `${element.name} was filled`); + } +}); + +add_task(function* testNotUsernameFieldNoUsername() { + document.getElementById("content").innerHTML = ` + <!-- The field matching notUsernameSelector should be skipped. + No username field should be found and filled in this case --> + <form id="form4"> + <input type="text" name="not_uname1" data-expected="false"> + <input type="password" name="pword6" data-expected="true"> + </form>`; + + let elements = yield waitForFills(1); + for (let element of elements) { + is(element.dataset.expected, "true", `${element.name} was filled`); + } +}); + +</script> + +<p id="display"></p> + +<div id="content"> + // Forms are inserted dynamically +</div> +<pre id="test"></pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_username_focus.html b/toolkit/components/passwordmgr/test/mochitest/test_username_focus.html new file mode 100644 index 000000000..c93c1e9c9 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_username_focus.html @@ -0,0 +1,263 @@ + +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test interaction between autocomplete and focus on username fields</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> +let pwmgrCommonScript = runInParent(SimpleTest.getTestFileURL("pwmgr_common.js")); + +let readyPromise = registerRunTests(); +let chromeScript = runInParent(function chromeSetup() { + const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; + let pwmgr = Cc["@mozilla.org/login-manager;1"].getService(Ci.nsILoginManager); + + let login1A = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + let login1B = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + let login2A = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + let login2B = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + let login2C = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + + login1A.init("http://mochi.test:8888", "http://username-focus-1", null, + "testuser1A", "testpass1A", "", ""); + + login2A.init("http://mochi.test:8888", "http://username-focus-2", null, + "testuser2A", "testpass2A", "", ""); + login2B.init("http://mochi.test:8888", "http://username-focus-2", null, + "testuser2B", "testpass2B", "", ""); + + pwmgr.addLogin(login1A); + pwmgr.addLogin(login2A); + pwmgr.addLogin(login2B); +}); +</script> + +<p id="display"></p> +<div id="content"> + <!-- first 3 forms have a matching user+pass login --> + + <!-- user+pass form. --> + <form id="form-autofilled" action="http://username-focus-1"> + <input type="text" name="uname"> + <input type="password" name="pword"> + <button type="submit" name="submit">Submit</button> + </form> + + <!-- user+pass form, username prefilled --> + <form id="form-autofilled-prefilled-un" action="http://username-focus-1"> + <input type="text" name="uname" value="testuser1A"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <!-- user+pass form. --> + <form id="form-autofilled-focused-dynamic" action="http://username-focus-1"> + <input type="text" name="uname"> + <input type="not-yet-password" name="pword"> + <button type="submit">Submit</button> + </form> + + + <!-- next 5 forms have matching user+pass (2x) logins --> + + <!-- user+pass form. --> + <form id="form-multiple" action="http://username-focus-2"> + <input type="text" name="uname"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <!-- user+pass form dynamic with existing focus --> + <form id="form-multiple-dynamic" action="http://username-focus-2"> + <input type="text" name="uname"> + <input type="not-yet-password" name="pword"> + <button type="submit">Submit</button> + </form> + + <!-- user+pass form, username prefilled --> + <form id="form-multiple-prefilled-un1" action="http://username-focus-2"> + <input type="text" name="uname" value="testuser2A"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <!-- user+pass form, different username prefilled --> + <form id="form-multiple-prefilled-un2" action="http://username-focus-2"> + <input type="text" name="uname" value="testuser2B"> + <input type="password" name="pword"> + <button type="submit">Submit</button> + </form> + + <!-- user+pass form, username prefilled with existing focus --> + <form id="form-multiple-prefilled-focused-dynamic" action="http://username-focus-2"> + <input type="text" name="uname" value="testuser2B"> + <input type="not-yet-password" name="pword"> + <button type="submit">Submit</button> + </form> + +</div> +<pre id="test"> +<script class="testbody" type="text/javascript"> +function removeFocus() { + $_("-autofilled", "submit").focus(); +} + +add_task(function* setup() { + yield SpecialPowers.pushPrefEnv({"set": [ + ["security.insecure_field_warning.contextual.enabled", false], + ]}); + + ok(readyPromise, "check promise is available"); + yield readyPromise; +}); + +add_task(function* test_autofilled() { + let usernameField = $_("-autofilled", "uname"); + info("Username and password already filled so don't show autocomplete"); + let noPopupPromise = promiseNoUnexpectedPopupShown(); + usernameField.focus(); + yield noPopupPromise; + + removeFocus(); + usernameField.value = "testuser"; + info("Focus when we don't have an exact match"); + shownPromise = promiseACShown(); + usernameField.focus(); + yield shownPromise; +}); + +add_task(function* test_autofilled_prefilled_un() { + let usernameField = $_("-autofilled-prefilled-un", "uname"); + info("Username and password already filled so don't show autocomplete"); + let noPopupPromise = promiseNoUnexpectedPopupShown(); + usernameField.focus(); + yield noPopupPromise; + + removeFocus(); + usernameField.value = "testuser"; + info("Focus when we don't have an exact match"); + shownPromise = promiseACShown(); + usernameField.focus(); + yield shownPromise; +}); + +add_task(function* test_autofilled_focused_dynamic() { + let usernameField = $_("-autofilled-focused-dynamic", "uname"); + let passwordField = $_("-autofilled-focused-dynamic", "pword"); + info("Username and password will be filled while username focused"); + let noPopupPromise = promiseNoUnexpectedPopupShown(); + usernameField.focus(); + yield noPopupPromise; + info("triggering autofill"); + noPopupPromise = promiseNoUnexpectedPopupShown(); + passwordField.type = "password"; + yield noPopupPromise; + + let popupState = yield getPopupState(); + is(popupState.open, false, "Check popup is closed"); + + removeFocus(); + passwordField.value = "test"; + info("Focus when we don't have an exact match"); + shownPromise = promiseACShown(); + usernameField.focus(); + yield shownPromise; +}); + +// Begin testing forms that have multiple saved logins + +add_task(function* test_multiple() { + let usernameField = $_("-multiple", "uname"); + info("Fields not filled due to multiple so autocomplete upon focus"); + shownPromise = promiseACShown(); + usernameField.focus(); + yield shownPromise; +}); + +add_task(function* test_multiple_dynamic() { + let usernameField = $_("-multiple-dynamic", "uname"); + let passwordField = $_("-multiple-dynamic", "pword"); + info("Fields not filled but username is focused upon marking so open"); + let noPopupPromise = promiseNoUnexpectedPopupShown(); + usernameField.focus(); + yield noPopupPromise; + + info("triggering _fillForm code"); + let shownPromise = promiseACShown(); + passwordField.type = "password"; + yield shownPromise; +}); + +add_task(function* test_multiple_prefilled_un1() { + let usernameField = $_("-multiple-prefilled-un1", "uname"); + info("Username and password already filled so don't show autocomplete"); + let noPopupPromise = promiseNoUnexpectedPopupShown(); + usernameField.focus(); + yield noPopupPromise; + + removeFocus(); + usernameField.value = "testuser"; + info("Focus when we don't have an exact match"); + shownPromise = promiseACShown(); + usernameField.focus(); + yield shownPromise; +}); + +add_task(function* test_multiple_prefilled_un2() { + let usernameField = $_("-multiple-prefilled-un2", "uname"); + info("Username and password already filled so don't show autocomplete"); + let noPopupPromise = promiseNoUnexpectedPopupShown(); + usernameField.focus(); + yield noPopupPromise; + + removeFocus(); + usernameField.value = "testuser"; + info("Focus when we don't have an exact match"); + shownPromise = promiseACShown(); + usernameField.focus(); + yield shownPromise; +}); + +add_task(function* test_multiple_prefilled_focused_dynamic() { + let usernameField = $_("-multiple-prefilled-focused-dynamic", "uname"); + let passwordField = $_("-multiple-prefilled-focused-dynamic", "pword"); + info("Username and password will be filled while username focused"); + let noPopupPromise = promiseNoUnexpectedPopupShown(); + usernameField.focus(); + yield noPopupPromise; + info("triggering autofill"); + noPopupPromise = promiseNoUnexpectedPopupShown(); + passwordField.type = "password"; + yield noPopupPromise; + + let popupState = yield getPopupState(); + is(popupState.open, false, "Check popup is closed"); + + removeFocus(); + passwordField.value = "test"; + info("Focus when we don't have an exact match"); + shownPromise = promiseACShown(); + usernameField.focus(); + yield shownPromise; +}); + +add_task(function* cleanup() { + removeFocus(); +}); +</script> +</pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/mochitest/test_xhr_2.html b/toolkit/components/passwordmgr/test/mochitest/test_xhr_2.html new file mode 100644 index 000000000..fa8357792 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_xhr_2.html @@ -0,0 +1,55 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=654348 +--> +<head> + <meta charset="utf-8"> + <title>Test XHR auth with user and pass arguments</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="pwmgr_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body onload="startTest()"> +<script class="testbody" type="text/javascript"> + +/** + * This test checks we correctly ignore authentication entry + * for a subpath and use creds from the URL when provided when XHR + * is used with filled user name and password. + * + * 1. connect authenticate.sjs that excepts user1:pass1 password + * 2. connect authenticate.sjs that this time expects differentuser2:pass2 password + * we must use the creds that are provided to the xhr witch are different and expected + */ + +function doxhr(URL, user, pass, code, next) { + var xhr = new XMLHttpRequest(); + if (user && pass) + xhr.open("POST", URL, true, user, pass); + else + xhr.open("POST", URL, true); + xhr.onload = function() { + is(xhr.status, code, "expected response code " + code); + next(); + }; + xhr.onerror = function() { + ok(false, "request passed"); + finishTest(); + }; + xhr.send(); +} + +function startTest() { + doxhr("authenticate.sjs?user=dummy&pass=pass1&realm=realm1&formauth=1", "dummy", "dummy", 403, function() { + doxhr("authenticate.sjs?user=dummy&pass=pass1&realm=realm1&formauth=1", "dummy", "pass1", 200, finishTest); + }); +} + +function finishTest() { + SimpleTest.finish(); +} + +</script> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/prompt_common.js b/toolkit/components/passwordmgr/test/prompt_common.js new file mode 100644 index 000000000..267e697ae --- /dev/null +++ b/toolkit/components/passwordmgr/test/prompt_common.js @@ -0,0 +1,79 @@ +/** + * NOTE: + * This file is currently only being used for tests which haven't been + * fixed to work with e10s. Favor using the `prompt_common.js` file that + * is in `toolkit/components/prompts/test/` instead. + */ + +var Ci = SpecialPowers.Ci; +ok(Ci != null, "Access Ci"); +var Cc = SpecialPowers.Cc; +ok(Cc != null, "Access Cc"); + +var didDialog; + +var timer; // keep in outer scope so it's not GC'd before firing +function startCallbackTimer() { + didDialog = false; + + // Delay before the callback twiddles the prompt. + const dialogDelay = 10; + + // Use a timer to invoke a callback to twiddle the authentication dialog + timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.init(observer, dialogDelay, Ci.nsITimer.TYPE_ONE_SHOT); +} + + +var observer = SpecialPowers.wrapCallbackObject({ + QueryInterface : function (iid) { + const interfaces = [Ci.nsIObserver, + Ci.nsISupports, Ci.nsISupportsWeakReference]; + + if (!interfaces.some( function(v) { return iid.equals(v); } )) + throw SpecialPowers.Components.results.NS_ERROR_NO_INTERFACE; + return this; + }, + + observe : function (subject, topic, data) { + var doc = getDialogDoc(); + if (doc) + handleDialog(doc, testNum); + else + startCallbackTimer(); // try again in a bit + } +}); + +function getDialogDoc() { + // Find the <browser> which contains notifyWindow, by looking + // through all the open windows and all the <browsers> in each. + var wm = Cc["@mozilla.org/appshell/window-mediator;1"]. + getService(Ci.nsIWindowMediator); + // var enumerator = wm.getEnumerator("navigator:browser"); + var enumerator = wm.getXULWindowEnumerator(null); + + while (enumerator.hasMoreElements()) { + var win = enumerator.getNext(); + var windowDocShell = win.QueryInterface(Ci.nsIXULWindow).docShell; + + var containedDocShells = windowDocShell.getDocShellEnumerator( + Ci.nsIDocShellTreeItem.typeChrome, + Ci.nsIDocShell.ENUMERATE_FORWARDS); + while (containedDocShells.hasMoreElements()) { + // Get the corresponding document for this docshell + var childDocShell = containedDocShells.getNext(); + // We don't want it if it's not done loading. + if (childDocShell.busyFlags != Ci.nsIDocShell.BUSY_FLAGS_NONE) + continue; + var childDoc = childDocShell.QueryInterface(Ci.nsIDocShell) + .contentViewer + .DOMDocument; + + // ok(true, "Got window: " + childDoc.location.href); + if (childDoc.location.href == "chrome://global/content/commonDialog.xul") + return childDoc; + } + } + + return null; +} diff --git a/toolkit/components/passwordmgr/test/pwmgr_common.js b/toolkit/components/passwordmgr/test/pwmgr_common.js new file mode 100644 index 000000000..fa7c4fd85 --- /dev/null +++ b/toolkit/components/passwordmgr/test/pwmgr_common.js @@ -0,0 +1,509 @@ +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; + } + + var element = form.children.namedItem(name); + if (!element) { + logWarning("$_ couldn't find requested element " + name); + return null; + } + + // Note that namedItem is a bit stupid, and will prefer an + // |id| attribute over a |name| attribute when looking for + // the element. Login Mananger happens to use .namedItem + // anyway, but let's rigorously check it here anyway so + // that we don't end up with tests that mistakenly pass. + + if (element.getAttribute("name") != name) { + logWarning("$_ got confused."); + return null; + } + + return element; +} + +/** + * Check a form for expected values. If an argument is null, a field's + * expected value will be the default value. + * + * <form id="form#"> + * checkForm(#, "foo"); + */ +function checkForm(formNum, val1, val2, val3) { + var e, form = document.getElementById("form" + formNum); + ok(form, "Locating form " + formNum); + + var numToCheck = arguments.length - 1; + + if (!numToCheck--) + return; + e = form.elements[0]; + if (val1 == null) + is(e.value, e.defaultValue, "Test default value of field " + e.name + + " in form " + formNum); + else + is(e.value, val1, "Test value of field " + e.name + + " in form " + formNum); + + + if (!numToCheck--) + return; + e = form.elements[1]; + if (val2 == null) + is(e.value, e.defaultValue, "Test default value of field " + e.name + + " in form " + formNum); + else + is(e.value, val2, "Test value of field " + e.name + + " in form " + formNum); + + + if (!numToCheck--) + return; + e = form.elements[2]; + if (val3 == null) + is(e.value, e.defaultValue, "Test default value of field " + e.name + + " in form " + formNum); + else + is(e.value, val3, "Test value of field " + e.name + + " in form " + formNum); +} + +/** + * Check a form for unmodified values from when page was loaded. + * + * <form id="form#"> + * checkUnmodifiedForm(#); + */ +function checkUnmodifiedForm(formNum) { + var form = document.getElementById("form" + formNum); + ok(form, "Locating form " + formNum); + + for (var i = 0; i < form.elements.length; i++) { + var ele = form.elements[i]; + + // No point in checking form submit/reset buttons. + if (ele.type == "submit" || ele.type == "reset") + continue; + + is(ele.value, ele.defaultValue, "Test to default value of field " + + ele.name + " in form " + formNum); + } +} + +/** + * Mochitest gives us a sendKey(), but it's targeted to a specific element. + * This basically sends an untargeted key event, to whatever's focused. + */ +function doKey(aKey, modifier) { + var keyName = "DOM_VK_" + aKey.toUpperCase(); + var key = KeyEvent[keyName]; + + // undefined --> null + if (!modifier) + modifier = null; + + // Window utils for sending fake sey events. + var wutils = SpecialPowers.wrap(window). + QueryInterface(SpecialPowers.Ci.nsIInterfaceRequestor). + getInterface(SpecialPowers.Ci.nsIDOMWindowUtils); + + if (wutils.sendKeyEvent("keydown", key, 0, modifier)) { + wutils.sendKeyEvent("keypress", key, 0, modifier); + } + wutils.sendKeyEvent("keyup", key, 0, modifier); +} + +/** + * Init with a common login + * If selfFilling is true or non-undefined, fires an event at the page so that + * the test can start checking filled-in values. Tests that check observer + * notifications might be confused by this. + */ +function commonInit(selfFilling) { + var pwmgr = SpecialPowers.Cc["@mozilla.org/login-manager;1"]. + getService(SpecialPowers.Ci.nsILoginManager); + ok(pwmgr != null, "Access LoginManager"); + + // Check that initial state has no logins + var logins = pwmgr.getAllLogins(); + is(logins.length, 0, "Not expecting logins to be present"); + var disabledHosts = pwmgr.getAllDisabledHosts(); + if (disabledHosts.length) { + ok(false, "Warning: wasn't expecting disabled hosts to be present."); + for (var host of disabledHosts) + pwmgr.setLoginSavingEnabled(host, true); + } + + // Add a login that's used in multiple tests + var login = SpecialPowers.Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(SpecialPowers.Ci.nsILoginInfo); + login.init("http://mochi.test:8888", "http://mochi.test:8888", null, + "testuser", "testpass", "uname", "pword"); + pwmgr.addLogin(login); + + // Last sanity check + logins = pwmgr.getAllLogins(); + is(logins.length, 1, "Checking for successful init login"); + disabledHosts = pwmgr.getAllDisabledHosts(); + is(disabledHosts.length, 0, "Checking for no disabled hosts"); + + if (selfFilling) + return; + + if (this.sendAsyncMessage) { + sendAsyncMessage("registerRunTests"); + } else { + registerRunTests(); + } +} + +function registerRunTests() { + return new Promise(resolve => { + // We provide a general mechanism for our tests to know when they can + // safely run: we add a final form that we know will be filled in, wait + // for the login manager to tell us that it's filled in and then continue + // with the rest of the tests. + window.addEventListener("DOMContentLoaded", (event) => { + var form = document.createElement('form'); + form.id = 'observerforcer'; + var username = document.createElement('input'); + username.name = 'testuser'; + form.appendChild(username); + var password = document.createElement('input'); + password.name = 'testpass'; + password.type = 'password'; + form.appendChild(password); + + var observer = SpecialPowers.wrapCallback(function(subject, topic, data) { + var formLikeRoot = subject.QueryInterface(SpecialPowers.Ci.nsIDOMNode); + if (formLikeRoot.id !== 'observerforcer') + return; + SpecialPowers.removeObserver(observer, "passwordmgr-processed-form"); + formLikeRoot.remove(); + SimpleTest.executeSoon(() => { + var runTestEvent = new Event("runTests"); + window.dispatchEvent(runTestEvent); + resolve(); + }); + }); + SpecialPowers.addObserver(observer, "passwordmgr-processed-form", false); + + document.body.appendChild(form); + }); + }); +} + +const masterPassword = "omgsecret!"; + +function enableMasterPassword() { + setMasterPassword(true); +} + +function disableMasterPassword() { + setMasterPassword(false); +} + +function setMasterPassword(enable) { + var oldPW, newPW; + if (enable) { + oldPW = ""; + newPW = masterPassword; + } else { + oldPW = masterPassword; + newPW = ""; + } + // Set master password. Note that this does not log you in, so the next + // invocation of pwmgr can trigger a MP prompt. + + var pk11db = Cc["@mozilla.org/security/pk11tokendb;1"].getService(Ci.nsIPK11TokenDB); + var token = pk11db.findTokenByName(""); + info("MP change from " + oldPW + " to " + newPW); + token.changePassword(oldPW, newPW); +} + +function logoutMasterPassword() { + var sdr = Cc["@mozilla.org/security/sdr;1"].getService(Ci.nsISecretDecoderRing); + sdr.logoutAndTeardown(); +} + +function dumpLogins(pwmgr) { + var logins = pwmgr.getAllLogins(); + ok(true, "----- dumpLogins: have " + logins.length + " logins. -----"); + for (var i = 0; i < logins.length; i++) + dumpLogin("login #" + i + " --- ", logins[i]); +} + +function dumpLogin(label, login) { + var loginText = ""; + loginText += "host: "; + loginText += login.hostname; + loginText += " / formURL: "; + loginText += login.formSubmitURL; + loginText += " / realm: "; + loginText += login.httpRealm; + loginText += " / user: "; + loginText += login.username; + loginText += " / pass: "; + loginText += login.password; + loginText += " / ufield: "; + loginText += login.usernameField; + loginText += " / pfield: "; + loginText += login.passwordField; + ok(true, label + loginText); +} + +function getRecipeParent() { + var { LoginManagerParent } = SpecialPowers.Cu.import("resource://gre/modules/LoginManagerParent.jsm", {}); + if (!LoginManagerParent.recipeParentPromise) { + return null; + } + return LoginManagerParent.recipeParentPromise.then((recipeParent) => { + return SpecialPowers.wrap(recipeParent); + }); +} + +/** + * Resolves when a specified number of forms have been processed. + */ +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(SpecialPowers.Cu.waiveXrays(subject), data); + } + } + SpecialPowers.addObserver(onProcessedForm, "passwordmgr-processed-form", false); + }); +} + +function loadRecipes(recipes) { + info("Loading recipes"); + return new Promise(resolve => { + chromeScript.addMessageListener("loadedRecipes", function loaded() { + chromeScript.removeMessageListener("loadedRecipes", loaded); + resolve(recipes); + }); + chromeScript.sendAsyncMessage("loadRecipes", recipes); + }); +} + +function resetRecipes() { + info("Resetting recipes"); + return new Promise(resolve => { + chromeScript.addMessageListener("recipesReset", function reset() { + chromeScript.removeMessageListener("recipesReset", reset); + resolve(); + }); + chromeScript.sendAsyncMessage("resetRecipes"); + }); +} + +function promiseStorageChanged(expectedChangeTypes) { + return new Promise((resolve, reject) => { + function onStorageChanged({ topic, data }) { + let changeType = expectedChangeTypes.shift(); + is(data, changeType, "Check expected passwordmgr-storage-changed type"); + if (expectedChangeTypes.length === 0) { + chromeScript.removeMessageListener("storageChanged", onStorageChanged); + resolve(); + } + } + chromeScript.addMessageListener("storageChanged", onStorageChanged); + }); +} + +function promisePromptShown(expectedTopic) { + return new Promise((resolve, reject) => { + function onPromptShown({ topic, data }) { + is(topic, expectedTopic, "Check expected prompt topic"); + chromeScript.removeMessageListener("promptShown", onPromptShown); + resolve(); + } + chromeScript.addMessageListener("promptShown", onPromptShown); + }); +} + +/** + * 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) { + let chromeScript = SpecialPowers.loadChromeScript(aFunctionOrURL); + SimpleTest.registerCleanupFunction(() => { + chromeScript.destroy(); + }); + return chromeScript; +} + +/** + * Run commonInit synchronously in the parent then run the test function after the runTests event. + * + * @param {Function} aFunction The test function to run + */ +function runChecksAfterCommonInit(aFunction = null) { + SimpleTest.waitForExplicitFinish(); + let pwmgrCommonScript = runInParent(SimpleTest.getTestFileURL("pwmgr_common.js")); + if (aFunction) { + window.addEventListener("runTests", aFunction); + pwmgrCommonScript.addMessageListener("registerRunTests", () => registerRunTests()); + } + pwmgrCommonScript.sendSyncMessage("setupParent"); + return pwmgrCommonScript; +} + +// 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/LoginManagerParent.jsm"); + Cu.import("resource://gre/modules/Services.jsm"); + Cu.import("resource://gre/modules/Task.jsm"); + + function onStorageChanged(subject, topic, data) { + sendAsyncMessage("storageChanged", { + topic, + data, + }); + } + Services.obs.addObserver(onStorageChanged, "passwordmgr-storage-changed", false); + + function onPrompt(subject, topic, data) { + sendAsyncMessage("promptShown", { + topic, + data, + }); + } + Services.obs.addObserver(onPrompt, "passwordmgr-prompt-change", false); + Services.obs.addObserver(onPrompt, "passwordmgr-prompt-save", false); + + addMessageListener("setupParent", ({selfFilling = false} = {selfFilling: false}) => { + // Force LoginManagerParent to init for the tests since it's normally delayed + // by apps such as on Android. + LoginManagerParent.init(); + + commonInit(selfFilling); + sendAsyncMessage("doneSetup"); + }); + + addMessageListener("loadRecipes", Task.async(function*(recipes) { + var recipeParent = yield LoginManagerParent.recipeParentPromise; + yield recipeParent.load(recipes); + sendAsyncMessage("loadedRecipes", recipes); + })); + + addMessageListener("resetRecipes", Task.async(function*() { + let recipeParent = yield LoginManagerParent.recipeParentPromise; + yield recipeParent.reset(); + sendAsyncMessage("recipesReset"); + })); + + 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; + }); + + let rv = Services.logins[msg.methodName](...recreatedArgs); + if (rv instanceof Ci.nsILoginInfo) { + rv = LoginHelper.loginToVanillaObject(rv); + } + return rv; + }); + + 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). + SpecialPowers.pushPrefEnv({"set": [["signon.autofillForms.http", true], + ["security.insecure_field_warning.contextual.enabled", false]] + }); + + SimpleTest.registerCleanupFunction(() => { + SpecialPowers.popPrefEnv(); + runInParent(function cleanupParent() { + const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; + Cu.import("resource://gre/modules/Services.jsm"); + Cu.import("resource://gre/modules/LoginManagerParent.jsm"); + + // Remove all logins and disabled hosts + Services.logins.removeAllLogins(); + + let disabledHosts = Services.logins.getAllDisabledHosts(); + disabledHosts.forEach(host => Services.logins.setLoginSavingEnabled(host, true)); + + let authMgr = Cc["@mozilla.org/network/http-auth-manager;1"]. + getService(Ci.nsIHttpAuthManager); + authMgr.clearAll(); + + if (LoginManagerParent._recipeManager) { + LoginManagerParent._recipeManager.reset(); + } + + // Cleanup PopupNotifications (if on a relevant platform) + let chromeWin = Services.wm.getMostRecentWindow("navigator:browser"); + if (chromeWin && chromeWin.PopupNotifications) { + let notes = chromeWin.PopupNotifications._currentNotifications; + if (notes.length > 0) { + dump("Removing " + notes.length + " popup notifications.\n"); + } + for (let note of notes) { + note.remove(); + } + } + }); + }); + + + 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]; + }; + }, + }); +} diff --git a/toolkit/components/passwordmgr/test/subtst_master_pass.html b/toolkit/components/passwordmgr/test/subtst_master_pass.html new file mode 100644 index 000000000..20211866a --- /dev/null +++ b/toolkit/components/passwordmgr/test/subtst_master_pass.html @@ -0,0 +1,12 @@ +<h2>MP subtest</h2> +This form triggers a MP and gets filled in.<br> +<form> +Username: <input type="text" id="userfield" name="u"><br> +Password: <input type="password" id="passfield" name="p"><br> +<script> + // Only notify when we fill in the password field. + document.getElementById("passfield").addEventListener("input", function() { + parent.postMessage("filled", "*"); + }); +</script> +</form> diff --git a/toolkit/components/passwordmgr/test/subtst_prompt_async.html b/toolkit/components/passwordmgr/test/subtst_prompt_async.html new file mode 100644 index 000000000..f60f63814 --- /dev/null +++ b/toolkit/components/passwordmgr/test/subtst_prompt_async.html @@ -0,0 +1,12 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Multiple auth request</title> +</head> +<body> + <iframe id="iframe1" src="http://example.com/tests/toolkit/components/passwordmgr/test/authenticate.sjs?r=1&user=user3name&pass=user3pass&realm=mochirealm3&proxy_user=proxy_user2&proxy_pass=proxy_pass2&proxy_realm=proxy_realm2"></iframe> + <iframe id="iframe2" src="http://example.com/tests/toolkit/components/passwordmgr/test/authenticate.sjs?r=2&user=user3name&pass=user3pass&realm=mochirealm3&proxy_user=proxy_user2&proxy_pass=proxy_pass2&proxy_realm=proxy_realm2"></iframe> + <iframe id="iframe3" src="http://example.com/tests/toolkit/components/passwordmgr/test/authenticate.sjs?r=3&user=user3name&pass=user3pass&realm=mochirealm3&proxy_user=proxy_user2&proxy_pass=proxy_pass2&proxy_realm=proxy_realm2"></iframe> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/test_master_password.html b/toolkit/components/passwordmgr/test/test_master_password.html new file mode 100644 index 000000000..c8884811f --- /dev/null +++ b/toolkit/components/passwordmgr/test/test_master_password.html @@ -0,0 +1,308 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test for master password</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="pwmgr_common.js"></script> + <script type="text/javascript" src="prompt_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +Login Manager test: master password. +<script> +"use strict"; + +commonInit(); +SimpleTest.waitForExplicitFinish(); +SimpleTest.requestFlakyTimeout("untriaged"); + +var pwmgr = SpecialPowers.Cc["@mozilla.org/login-manager;1"] + .getService(SpecialPowers.Ci.nsILoginManager); +var pwcrypt = SpecialPowers.Cc["@mozilla.org/login-manager/crypto/SDR;1"] + .getService(Ci.nsILoginManagerCrypto); + +var nsLoginInfo = new SpecialPowers.wrap(SpecialPowers.Components).Constructor("@mozilla.org/login-manager/loginInfo;1", Ci.nsILoginInfo); + +var exampleCom = "http://example.com/tests/toolkit/components/passwordmgr/test/"; +var exampleOrg = "http://example.org/tests/toolkit/components/passwordmgr/test/"; + +var login1 = new nsLoginInfo(); +var login2 = new nsLoginInfo(); + +login1.init("http://example.com", "http://example.com", null, + "user1", "pass1", "uname", "pword"); +login2.init("http://example.org", "http://example.org", null, + "user2", "pass2", "uname", "pword"); + +pwmgr.addLogin(login1); +pwmgr.addLogin(login2); +</script> + +<p id="display"></p> + +<div id="content" style="display: none"> +<iframe id="iframe1"></iframe> +<iframe id="iframe2"></iframe> +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> +var testNum = 1; +var iframe1 = document.getElementById("iframe1"); +var iframe2 = document.getElementById("iframe2"); + +// A couple of tests have to wait until the password manager gets around to +// filling in the password in the subtest (after we dismiss the master +// password dialog). In order to accomplish this, the test waits for an event +// and then posts a message back up to us telling us to continue. +var continuation = null; +addEventListener("message", () => { + if (continuation) { + var c = continuation; + continuation = null; + c(); + } +}); + +/* + * handleDialog + * + * Invoked a short period of time after calling startCallbackTimer(), and + * allows testing the actual auth dialog while it's being displayed. Tests + * should call startCallbackTimer() each time the auth dialog is expected (the + * timer is a one-shot). + */ +function handleDialog(doc, testNumber) { + ok(true, "handleDialog running for test " + testNumber); + + var clickOK = true; + var doNothing = false; + var passfield = doc.getElementById("password1Textbox"); + var dialog = doc.getElementById("commonDialog"); + + switch (testNumber) { + case 1: + is(passfield.getAttribute("value"), "", "Checking empty prompt"); + passfield.setAttribute("value", masterPassword); + is(passfield.getAttribute("value"), masterPassword, "Checking filled prompt"); + break; + + case 2: + clickOK = false; + break; + + case 3: + is(passfield.getAttribute("value"), "", "Checking empty prompt"); + passfield.setAttribute("value", masterPassword); + break; + + case 4: + doNothing = true; + break; + + case 5: + is(passfield.getAttribute("value"), "", "Checking empty prompt"); + passfield.setAttribute("value", masterPassword); + break; + + default: + ok(false, "Uhh, unhandled switch for testNum #" + testNumber); + break; + } + + didDialog = true; + + if (!doNothing) { + SpecialPowers.addObserver(outerWindowObserver, "outer-window-destroyed", false); + if (clickOK) + dialog.acceptDialog(); + else + dialog.cancelDialog(); + } + + ok(true, "handleDialog done for test " + testNumber); + + if (testNumber == 4) + checkTest4A(); +} + +var outerWindowObserver = { + observe: function(id) { + SpecialPowers.removeObserver(outerWindowObserver, "outer-window-destroyed"); + var func; + if (testNum == 1) + func = startTest2; + else if (testNum == 2) + func = startTest3; + + // For tests 3 and 4C, we use the 'continuation' mechanism, described + // above. + if (func) + setTimeout(func, 300); + } +}; + + +function startTest1() { + ok(pwcrypt.isLoggedIn, "should be initially logged in (no MP)"); + enableMasterPassword(); + ok(!pwcrypt.isLoggedIn, "should be logged out after setting MP"); + + // --- Test 1 --- + // Trigger a MP prompt via the API + startCallbackTimer(); + var logins = pwmgr.getAllLogins(); + ok(didDialog, "handleDialog was invoked"); + is(logins.length, 3, "expected number of logins"); + + ok(pwcrypt.isLoggedIn, "should be logged in after MP prompt"); + logoutMasterPassword(); + ok(!pwcrypt.isLoggedIn, "should be logged out"); +} + +function startTest2() { + // Try again but click cancel. + testNum++; + startCallbackTimer(); + var failedAsExpected = false; + logins = null; + try { + logins = pwmgr.getAllLogins(); + } catch (e) { failedAsExpected = true; } + ok(didDialog, "handleDialog was invoked"); + ok(failedAsExpected, "getAllLogins should have thrown"); + is(logins, null, "shouldn't have gotten logins"); + ok(!pwcrypt.isLoggedIn, "should still be logged out"); +} + +function startTest3() { + // Load a single iframe to trigger a MP + testNum++; + iframe1.src = exampleCom + "subtst_master_pass.html"; + continuation = checkTest3; + startCallbackTimer(); +} + +function checkTest3() { + ok(true, "checkTest3 starting"); + ok(didDialog, "handleDialog was invoked"); + + // check contents of iframe1 fields + var u = SpecialPowers.wrap(iframe1).contentDocument.getElementById("userfield"); + var p = SpecialPowers.wrap(iframe1).contentDocument.getElementById("passfield"); + is(u.value, "user1", "checking expected user to have been filled in"); + is(p.value, "pass1", "checking expected pass to have been filled in"); + + ok(pwcrypt.isLoggedIn, "should be logged in"); + logoutMasterPassword(); + ok(!pwcrypt.isLoggedIn, "should be logged out"); + + + // --- Test 4 --- + // first part of loading 2 MP-triggering iframes + testNum++; + iframe1.src = exampleOrg + "subtst_master_pass.html"; + // start the callback, but we'll not enter the MP, just call checkTest4A + startCallbackTimer(); +} + +function checkTest4A() { + ok(true, "checkTest4A starting"); + ok(didDialog, "handleDialog was invoked"); + + // check contents of iframe1 fields + var u = SpecialPowers.wrap(iframe1).contentDocument.getElementById("userfield"); + var p = SpecialPowers.wrap(iframe1).contentDocument.getElementById("passfield"); + is(u.value, "", "checking expected empty user"); + is(p.value, "", "checking expected empty pass"); + + + ok(!pwcrypt.isLoggedIn, "should be logged out"); + + // XXX check that there's 1 MP window open + + // Load another iframe with a login form + // This should detect that there's already a pending MP prompt, and not + // put up a second one. The load event will fire (note that when pwmgr is + // driven from DOMContentLoaded, if that blocks due to prompting for a MP, + // the load even will also be blocked until the prompt is dismissed). + iframe2.onload = checkTest4B_delay; + iframe2.src = exampleCom + "subtst_master_pass.html"; +} + +function checkTest4B_delay() { + // Testing a negative, wait a little to give the login manager a chance to + // (incorrectly) fill in the form. Note, we cannot use setTimeout() + // here because the modal window suspends all window timers. Instead we + // must use a chrome script to use nsITimer directly. + let chromeURL = SimpleTest.getTestFileURL("chrome_timeout.js"); + let script = SpecialPowers.loadChromeScript(chromeURL); + script.addMessageListener('ready', _ => { + script.sendAsyncMessage('setTimeout', { delay: 500 }); + }); + script.addMessageListener('timeout', checkTest4B); +} + +function checkTest4B() { + ok(true, "checkTest4B starting"); + // iframe2 should load without having triggered a MP prompt (because one + // is already waiting) + + // check contents of iframe2 fields + var u = SpecialPowers.wrap(iframe2).contentDocument.getElementById("userfield"); + var p = SpecialPowers.wrap(iframe2).contentDocument.getElementById("passfield"); + is(u.value, "", "checking expected empty user"); + is(p.value, "", "checking expected empty pass"); + + // XXX check that there's 1 MP window open + ok(!pwcrypt.isLoggedIn, "should be logged out"); + + continuation = checkTest4C; + + // Ok, now enter the MP. The MP prompt is already up, but we'll just reuse startCallBackTimer. + // --- Test 5 --- + testNum++; + startCallbackTimer(); +} + +function checkTest4C() { + ok(true, "checkTest4C starting"); + ok(didDialog, "handleDialog was invoked"); + + // We shouldn't have to worry about iframe1's load event racing with + // filling of iframe2's data. We notify observers synchronously, so + // iframe2's observer will process iframe2 before iframe1 even finishes + // processing the form (which is blocking its load event). + ok(pwcrypt.isLoggedIn, "should be logged in"); + + // check contents of iframe1 fields + var u = SpecialPowers.wrap(iframe1).contentDocument.getElementById("userfield"); + var p = SpecialPowers.wrap(iframe1).contentDocument.getElementById("passfield"); + is(u.value, "user2", "checking expected user to have been filled in"); + is(p.value, "pass2", "checking expected pass to have been filled in"); + + // check contents of iframe2 fields + u = SpecialPowers.wrap(iframe2).contentDocument.getElementById("userfield"); + p = SpecialPowers.wrap(iframe2).contentDocument.getElementById("passfield"); + is(u.value, "user1", "checking expected user to have been filled in"); + is(p.value, "pass1", "checking expected pass to have been filled in"); + + SimpleTest.finish(); +} + +// XXX do a test5ABC with clicking cancel? + +SimpleTest.registerCleanupFunction(function finishTest() { + disableMasterPassword(); + + pwmgr.removeLogin(login1); + pwmgr.removeLogin(login2); +}); + +window.addEventListener("runTests", startTest1); +</script> +</pre> +</body> +</html> + diff --git a/toolkit/components/passwordmgr/test/test_prompt_async.html b/toolkit/components/passwordmgr/test/test_prompt_async.html new file mode 100644 index 000000000..38b34679a --- /dev/null +++ b/toolkit/components/passwordmgr/test/test_prompt_async.html @@ -0,0 +1,540 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test for Async Auth Prompt</title> + <script type="text/javascript" src="/MochiKit/MochiKit.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="prompt_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + + <script class="testbody" type="text/javascript"> + SimpleTest.waitForExplicitFinish(); + SimpleTest.requestFlakyTimeout("untriaged"); + + const { NetUtil } = SpecialPowers.Cu.import('resource://gre/modules/NetUtil.jsm'); + + var prefs = Cc["@mozilla.org/preferences-service;1"]. + getService(Ci.nsIPrefBranch); + prefs.setIntPref("network.auth.subresource-http-auth-allow", 2); + // Class monitoring number of open dialog windows + // It checks there is always open just a single dialog per application + function dialogMonitor() { + var observerService = SpecialPowers.Cc["@mozilla.org/observer-service;1"] + .getService(Ci.nsIObserverService); + observerService.addObserver(this, "domwindowopened", false); + observerService.addObserver(this, "domwindowclosed", false); + } + + /* + * As documented in Bug 718543, checking equality of objects pulled + * from SpecialPowers-wrapped objects is unreliable. Because of that, + * `dialogMonitor` now tracks the number of open windows rather than + * specific window objects. + * + * NB: Because the constructor (above) adds |this| directly as an observer, + * we need to do SpecialPowers.wrapCallbackObject directly on the prototype. + */ + dialogMonitor.prototype = SpecialPowers.wrapCallbackObject({ + windowsOpen : 0, + windowsRegistered : 0, + + QueryInterface : function (iid) { + const interfaces = [Ci.nsIObserver, Ci.nsISupports]; + + if (!interfaces.some( function(v) { return iid.equals(v); } )) + throw SpecialPowers.Cr.NS_ERROR_NO_INTERFACE; + return this; + }, + + observe: function(subject, topic, data) { + if (topic === "domwindowopened") { + this.windowsOpen++; + this.windowsRegistered++; + return; + } + if (topic === "domwindowclosed") { + this.windowsOpen--; + return; + } + }, + + shutdown: function() { + var observerService = SpecialPowers.Cc["@mozilla.org/observer-service;1"] + .getService(Ci.nsIObserverService); + observerService.removeObserver(this, "domwindowopened"); + observerService.removeObserver(this, "domwindowclosed"); + }, + + reset: function() { + this.windowsOpen = 0; + this.windowsRegistered = 0; + } + }); + + var monitor = new dialogMonitor(); + + var pwmgr, logins = []; + + function initLogins(pi) { + pwmgr = SpecialPowers.Cc["@mozilla.org/login-manager;1"] + .getService(Ci.nsILoginManager); + + function addLogin(host, realm, user, pass) { + var login = SpecialPowers.Cc["@mozilla.org/login-manager/loginInfo;1"] + .createInstance(Ci.nsILoginInfo); + login.init(host, null, realm, user, pass, "", ""); + pwmgr.addLogin(login); + logins.push(login); + } + + var mozproxy = "moz-proxy://" + + SpecialPowers.wrap(pi).host + ":" + + SpecialPowers.wrap(pi).port; + + addLogin(mozproxy, "proxy_realm", + "proxy_user", "proxy_pass"); + addLogin(mozproxy, "proxy_realm2", + "proxy_user2", "proxy_pass2"); + addLogin(mozproxy, "proxy_realm3", + "proxy_user3", "proxy_pass3"); + addLogin(mozproxy, "proxy_realm4", + "proxy_user4", "proxy_pass4"); + addLogin(mozproxy, "proxy_realm5", + "proxy_user5", "proxy_pass5"); + addLogin("http://example.com", "mochirealm", + "user1name", "user1pass"); + addLogin("http://example.org", "mochirealm2", + "user2name", "user2pass"); + addLogin("http://example.com", "mochirealm3", + "user3name", "user3pass"); + addLogin("http://example.com", "mochirealm4", + "user4name", "user4pass"); + addLogin("http://example.com", "mochirealm5", + "user5name", "user5pass"); + addLogin("http://example.com", "mochirealm6", + "user6name", "user6pass"); + } + + function finishTest() { + ok(true, "finishTest removing testing logins..."); + for (i in logins) + pwmgr.removeLogin(logins[i]); + + var authMgr = SpecialPowers.Cc['@mozilla.org/network/http-auth-manager;1'] + .getService(Ci.nsIHttpAuthManager); + authMgr.clearAll(); + + monitor.shutdown(); + SimpleTest.finish(); + } + + var resolveCallback = SpecialPowers.wrapCallbackObject({ + QueryInterface : function (iid) { + const interfaces = [Ci.nsIProtocolProxyCallback, Ci.nsISupports]; + + if (!interfaces.some( function(v) { return iid.equals(v); } )) + throw SpecialPowers.Cr.NS_ERROR_NO_INTERFACE; + return this; + }, + + onProxyAvailable : function (req, uri, pi, status) { + initLogins(pi); + doTest(testNum); + } + }); + + function startup() { + // Need to allow for arbitrary network servers defined in PAC instead of a hardcoded moz-proxy. + var channel = NetUtil.newChannel({ + uri: "http://example.com", + loadUsingSystemPrincipal: true + }); + + var pps = SpecialPowers.Cc["@mozilla.org/network/protocol-proxy-service;1"] + .getService(); + + pps.asyncResolve(channel, 0, resolveCallback); + } + + // --------------- Test loop spin ---------------- + var testNum = 1; + var iframe1; + var iframe2a; + var iframe2b; + window.onload = function () { + iframe1 = document.getElementById("iframe1"); + iframe2a = document.getElementById("iframe2a"); + iframe2b = document.getElementById("iframe2b"); + iframe1.onload = onFrameLoad; + iframe2a.onload = onFrameLoad; + iframe2b.onload = onFrameLoad; + + startup(); + }; + + var expectedLoads; + var expectedDialogs; + function onFrameLoad() + { + if (--expectedLoads == 0) { + // All pages expected to load has loaded, continue with the next test + ok(true, "Expected frames loaded"); + + doCheck(testNum); + monitor.reset(); + + testNum++; + doTest(testNum); + } + } + + function doTest(testNumber) + { + /* + * These contentDocument variables are located here, + * rather than in the global scope, because SpecialPowers threw + * errors (complaining that the objects were deleted) + * when these were in the global scope. + */ + var iframe1Doc = SpecialPowers.wrap(iframe1).contentDocument; + var iframe2aDoc = SpecialPowers.wrap(iframe2a).contentDocument; + var iframe2bDoc = SpecialPowers.wrap(iframe2b).contentDocument; + var exampleCom = "http://example.com/tests/toolkit/components/passwordmgr/test/"; + var exampleOrg = "http://example.org/tests/toolkit/components/passwordmgr/test/"; + + switch (testNumber) + { + case 1: + // Load through a single proxy with authentication required 3 different + // pages, first with one login, other two with their own different login. + // We expect to show just a single dialog for proxy authentication and + // then two dialogs to authenticate to login 1 and then login 2. + ok(true, "doTest testNum 1"); + expectedLoads = 3; + expectedDialogs = 3; + iframe1.src = exampleCom + "authenticate.sjs?" + + "r=1&" + + "user=user1name&" + + "pass=user1pass&" + + "realm=mochirealm&" + + "proxy_user=proxy_user&" + + "proxy_pass=proxy_pass&" + + "proxy_realm=proxy_realm"; + iframe2a.src = exampleOrg + "authenticate.sjs?" + + "r=2&" + + "user=user2name&" + + "pass=user2pass&" + + "realm=mochirealm2&" + + "proxy_user=proxy_user&" + + "proxy_pass=proxy_pass&" + + "proxy_realm=proxy_realm"; + iframe2b.src = exampleOrg + "authenticate.sjs?" + + "r=3&" + + "user=user2name&" + + "pass=user2pass&" + + "realm=mochirealm2&" + + "proxy_user=proxy_user&" + + "proxy_pass=proxy_pass&" + + "proxy_realm=proxy_realm"; + break; + + case 2: + // Load an iframe with 3 subpages all requiring the same login through + // anuthenticated proxy. We expect 2 dialogs, proxy authentication + // and web authentication. + ok(true, "doTest testNum 2"); + expectedLoads = 3; + expectedDialogs = 2; + iframe1.src = exampleCom + "subtst_prompt_async.html"; + iframe2a.src = "about:blank"; + iframe2b.src = "about:blank"; + break; + + case 3: + // Load in the iframe page through unauthenticated proxy + // and discard the proxy authentication. We expect to see + // unauthenticated page content and just a single dialog. + ok(true, "doTest testNum 3"); + expectedLoads = 1; + expectedDialogs = 1; + iframe1.src = exampleCom + "authenticate.sjs?" + + "user=user4name&" + + "pass=user4pass&" + + "realm=mochirealm4&" + + "proxy_user=proxy_user3&" + + "proxy_pass=proxy_pass3&" + + "proxy_realm=proxy_realm3"; + break; + + case 4: + // Reload the frame from previous step and pass the proxy authentication + // but cancel the WWW authentication. We should get the proxy=ok and WWW=fail + // content as a result. + ok(true, "doTest testNum 4"); + expectedLoads = 1; + expectedDialogs = 2; + iframe1.src = exampleCom + "authenticate.sjs?" + + "user=user4name&" + + "pass=user4pass&" + + "realm=mochirealm4&" + + "proxy_user=proxy_user3&" + + "proxy_pass=proxy_pass3&" + + "proxy_realm=proxy_realm3"; + + + break; + + case 5: + // Same as the previous two steps but let the server generate + // huge content load to check http channel is capable to handle + // case when auth dialog is canceled or accepted before unauthenticated + // content data is load from the server. (This would be better to + // implement using delay of server response). + ok(true, "doTest testNum 5"); + expectedLoads = 1; + expectedDialogs = 1; + iframe1.src = exampleCom + "authenticate.sjs?" + + "user=user5name&" + + "pass=user5pass&" + + "realm=mochirealm5&" + + "proxy_user=proxy_user4&" + + "proxy_pass=proxy_pass4&" + + "proxy_realm=proxy_realm4&" + + "huge=1"; + break; + + case 6: + // Reload the frame from the previous step and let the proxy + // authentication pass but WWW fail. We expect two dialogs + // and an unathenticated page content load. + ok(true, "doTest testNum 6"); + expectedLoads = 1; + expectedDialogs = 2; + iframe1.src = exampleCom + "authenticate.sjs?" + + "user=user5name&" + + "pass=user5pass&" + + "realm=mochirealm5&" + + "proxy_user=proxy_user4&" + + "proxy_pass=proxy_pass4&" + + "proxy_realm=proxy_realm4&" + + "huge=1"; + break; + + case 7: + // Reload again and let pass all authentication dialogs. + // Check we get the authenticated content not broken by + // the unauthenticated content. + ok(true, "doTest testNum 7"); + expectedLoads = 1; + expectedDialogs = 1; + iframe1Doc.location.reload(); + break; + + case 8: + // Check we proccess all challenges sent by server when + // user cancels prompts + ok(true, "doTest testNum 8"); + expectedLoads = 1; + expectedDialogs = 5; + iframe1.src = exampleCom + "authenticate.sjs?" + + "user=user6name&" + + "pass=user6pass&" + + "realm=mochirealm6&" + + "proxy_user=proxy_user5&" + + "proxy_pass=proxy_pass5&" + + "proxy_realm=proxy_realm5&" + + "huge=1&" + + "multiple=3"; + break; + + case 9: + finishTest(); + return; + } + + startCallbackTimer(); + } + + function handleDialog(doc, testNumber) + { + var dialog = doc.getElementById("commonDialog"); + + switch (testNumber) + { + case 1: + case 2: + dialog.acceptDialog(); + break; + + case 3: + dialog.cancelDialog(); + setTimeout(onFrameLoad, 10); // there are no successful frames for test 3 + break; + + case 4: + if (expectedDialogs == 2) + dialog.acceptDialog(); + else + dialog.cancelDialog(); + break; + + case 5: + dialog.cancelDialog(); + setTimeout(onFrameLoad, 10); // there are no successful frames for test 5 + break; + + case 6: + if (expectedDialogs == 2) + dialog.acceptDialog(); + else + dialog.cancelDialog(); + break; + + case 7: + dialog.acceptDialog(); + break; + + case 8: + if (expectedDialogs == 3 || expectedDialogs == 1) + dialog.acceptDialog(); + else + dialog.cancelDialog(); + break; + + default: + ok(false, "Unhandled testNum " + testNumber + " in handleDialog"); + } + + if (--expectedDialogs > 0) + startCallbackTimer(); + } + + function doCheck(testNumber) + { + var iframe1Doc = SpecialPowers.wrap(iframe1).contentDocument; + var iframe2aDoc = SpecialPowers.wrap(iframe2a).contentDocument; + var iframe2bDoc = SpecialPowers.wrap(iframe2b).contentDocument; + var authok1; + var proxyok1; + var footnote; + switch (testNumber) + { + case 1: + ok(true, "doCheck testNum 1"); + is(monitor.windowsRegistered, 3, "Registered 3 open dialogs"); + + authok1 = iframe1Doc.getElementById("ok").textContent; + proxyok1 = iframe1Doc.getElementById("proxy").textContent; + + var authok2a = iframe2aDoc.getElementById("ok").textContent; + var proxyok2a = iframe2aDoc.getElementById("proxy").textContent; + + var authok2b = iframe2bDoc.getElementById("ok").textContent; + var proxyok2b = iframe2bDoc.getElementById("proxy").textContent; + + is(authok1, "PASS", "WWW Authorization OK, frame1"); + is(authok2a, "PASS", "WWW Authorization OK, frame2a"); + is(authok2b, "PASS", "WWW Authorization OK, frame2b"); + is(proxyok1, "PASS", "Proxy Authorization OK, frame1"); + is(proxyok2a, "PASS", "Proxy Authorization OK, frame2a"); + is(proxyok2b, "PASS", "Proxy Authorization OK, frame2b"); + break; + + case 2: + is(monitor.windowsRegistered, 2, "Registered 2 open dialogs"); + ok(true, "doCheck testNum 2"); + + function checkIframe(frame) { + var doc = SpecialPowers.wrap(frame).contentDocument; + + var authok = doc.getElementById("ok").textContent; + var proxyok = doc.getElementById("proxy").textContent; + + is(authok, "PASS", "WWW Authorization OK, " + frame.id); + is(proxyok, "PASS", "Proxy Authorization OK, " + frame.id); + } + + checkIframe(iframe1Doc.getElementById("iframe1")); + checkIframe(iframe1Doc.getElementById("iframe2")); + checkIframe(iframe1Doc.getElementById("iframe3")); + break; + + case 3: + ok(true, "doCheck testNum 3"); + is(monitor.windowsRegistered, 1, "Registered 1 open dialog"); + + // ensure that the page content is not displayed on failed proxy auth + is(iframe1Doc.getElementById("ok"), null, "frame did not load"); + break; + + case 4: + ok(true, "doCheck testNum 4"); + is(monitor.windowsRegistered, 2, "Registered 2 open dialogs"); + authok1 = iframe1Doc.getElementById("ok").textContent; + proxyok1 = iframe1Doc.getElementById("proxy").textContent; + + is(authok1, "FAIL", "WWW Authorization FAILED, frame1"); + is(proxyok1, "PASS", "Proxy Authorization OK, frame1"); + break; + + case 5: + ok(true, "doCheck testNum 5"); + is(monitor.windowsRegistered, 1, "Registered 1 open dialog"); + + // ensure that the page content is not displayed on failed proxy auth + is(iframe1Doc.getElementById("footnote"), null, "frame did not load"); + break; + + case 6: + ok(true, "doCheck testNum 6"); + is(monitor.windowsRegistered, 2, "Registered 2 open dialogs"); + authok1 = iframe1Doc.getElementById("ok").textContent; + proxyok1 = iframe1Doc.getElementById("proxy").textContent; + footnote = iframe1Doc.getElementById("footnote").textContent; + + is(authok1, "FAIL", "WWW Authorization FAILED, frame1"); + is(proxyok1, "PASS", "Proxy Authorization OK, frame1"); + is(footnote, "This is a footnote after the huge content fill", + "Footnote present and loaded completely"); + break; + + case 7: + ok(true, "doCheck testNum 7"); + is(monitor.windowsRegistered, 1, "Registered 1 open dialogs"); + authok1 = iframe1Doc.getElementById("ok").textContent; + proxyok1 = iframe1Doc.getElementById("proxy").textContent; + footnote = iframe1Doc.getElementById("footnote").textContent; + + is(authok1, "PASS", "WWW Authorization OK, frame1"); + is(proxyok1, "PASS", "Proxy Authorization OK, frame1"); + is(footnote, "This is a footnote after the huge content fill", + "Footnote present and loaded completely"); + break; + + case 8: + ok(true, "doCheck testNum 8"); + is(monitor.windowsRegistered, 5, "Registered 5 open dialogs"); + authok1 = iframe1Doc.getElementById("ok").textContent; + proxyok1 = iframe1Doc.getElementById("proxy").textContent; + footnote = iframe1Doc.getElementById("footnote").textContent; + + is(authok1, "PASS", "WWW Authorization OK, frame1"); + is(proxyok1, "PASS", "Proxy Authorization OK, frame1"); + is(footnote, "This is a footnote after the huge content fill", + "Footnote present and loaded completely"); + break; + + default: + ok(false, "Unhandled testNum " + testNumber + " in doCheck"); + } + } + + </script> +</head> +<body> + <iframe id="iframe1"></iframe> + <iframe id="iframe2a"></iframe> + <iframe id="iframe2b"></iframe> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/test_xhr.html b/toolkit/components/passwordmgr/test/test_xhr.html new file mode 100644 index 000000000..296371685 --- /dev/null +++ b/toolkit/components/passwordmgr/test/test_xhr.html @@ -0,0 +1,201 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test for XHR prompts</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="pwmgr_common.js"></script> + <script type="text/javascript" src="prompt_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +Login Manager test: XHR prompt +<p id="display"></p> + +<div id="content" style="display: none"> + <iframe id="iframe"></iframe> +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +/** Test for Login Manager: XHR prompts. **/ +var pwmgr, login1, login2; + +function initLogins() { + pwmgr = Cc["@mozilla.org/login-manager;1"]. + getService(Ci.nsILoginManager); + + login1 = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + login2 = Cc["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Ci.nsILoginInfo); + + login1.init("http://mochi.test:8888", null, "xhr", + "xhruser1", "xhrpass1", "", ""); + login2.init("http://mochi.test:8888", null, "xhr2", + "xhruser2", "xhrpass2", "", ""); + + pwmgr.addLogin(login1); + pwmgr.addLogin(login2); +} + +function finishTest() { + ok(true, "finishTest removing testing logins..."); + pwmgr.removeLogin(login1); + pwmgr.removeLogin(login2); + + SimpleTest.finish(); +} + +function handleDialog(doc, testNum) { + ok(true, "handleDialog running for test " + testNum); + + var clickOK = true; + var userfield = doc.getElementById("loginTextbox"); + var passfield = doc.getElementById("password1Textbox"); + var username = userfield.getAttribute("value"); + var password = passfield.getAttribute("value"); + var dialog = doc.getElementById("commonDialog"); + + switch (testNum) { + case 1: + is(username, "xhruser1", "Checking provided username"); + is(password, "xhrpass1", "Checking provided password"); + break; + + case 2: + is(username, "xhruser2", "Checking provided username"); + is(password, "xhrpass2", "Checking provided password"); + + // Check that the dialog is modal, chrome and dependent; + // We can't just check window.opener because that'll be + // a content window, which therefore isn't exposed (it'll lie and + // be null). + var win = doc.defaultView; + var Ci = SpecialPowers.Ci; + var treeOwner = win.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation). + QueryInterface(Ci.nsIDocShellTreeItem).treeOwner; + treeOwner.QueryInterface(Ci.nsIInterfaceRequestor); + var flags = treeOwner.getInterface(Ci.nsIXULWindow).chromeFlags; + var wbc = treeOwner.getInterface(Ci.nsIWebBrowserChrome); + info("Flags: " + flags); + ok((flags & Ci.nsIWebBrowserChrome.CHROME_OPENAS_CHROME) != 0, + "Dialog should be opened as chrome"); + ok((flags & Ci.nsIWebBrowserChrome.CHROME_OPENAS_DIALOG) != 0, + "Dialog should be opened as a dialog"); + ok((flags & Ci.nsIWebBrowserChrome.CHROME_DEPENDENT) != 0, + "Dialog should be opened as dependent."); + ok(wbc.isWindowModal(), "Dialog should be modal"); + + // Check that the right tab is focused: + var browserWin = SpecialPowers.Services.wm.getMostRecentWindow("navigator:browser"); + var spec = browserWin.gBrowser.selectedBrowser.currentURI.spec; + ok(spec.startsWith("http://mochi.test:8888"), + "Tab with remote URI (rather than about:blank) should be focused (" + spec + ")"); + + + break; + + default: + ok(false, "Uhh, unhandled switch for testNum #" + testNum); + break; + } + + // Explicitly cancel the dialog and report a fail in this failure + // case, rather than letting the dialog get stuck due to an auth + // failure and having the test timeout. + if (!username && !password) { + ok(false, "No values prefilled"); + clickOK = false; + } + + if (clickOK) + dialog.acceptDialog(); + else + dialog.cancelDialog(); + + ok(true, "handleDialog done"); + didDialog = true; +} + +var newWin; +function xhrLoad(xmlDoc) { + ok(true, "xhrLoad running for test " + testNum); + + // The server echos back the user/pass it received. + var username = xmlDoc.getElementById("user").textContent; + var password = xmlDoc.getElementById("pass").textContent; + var authok = xmlDoc.getElementById("ok").textContent; + + + switch (testNum) { + case 1: + is(username, "xhruser1", "Checking provided username"); + is(password, "xhrpass1", "Checking provided password"); + break; + + case 2: + is(username, "xhruser2", "Checking provided username"); + is(password, "xhrpass2", "Checking provided password"); + + newWin.close(); + break; + + default: + ok(false, "Uhh, unhandled switch for testNum #" + testNum); + break; + } + + doTest(); +} + +function doTest() { + switch (++testNum) { + case 1: + startCallbackTimer(); + makeRequest("authenticate.sjs?user=xhruser1&pass=xhrpass1&realm=xhr"); + break; + + case 2: + // Test correct parenting, by opening another tab in the foreground, + // and making sure the prompt re-focuses the original tab when shown: + newWin = window.open(); + newWin.focus(); + startCallbackTimer(); + makeRequest("authenticate.sjs?user=xhruser2&pass=xhrpass2&realm=xhr2"); + break; + + default: + finishTest(); + } +} + +function makeRequest(uri) { + var request = new XMLHttpRequest(); + request.open("GET", uri, true); + request.onreadystatechange = function () { + if (request.readyState == 4) + xhrLoad(request.responseXML); + }; + request.send(null); +} + + +initLogins(); + +// clear plain HTTP auth sessions before the test, to allow +// running them more than once. +var authMgr = SpecialPowers.Cc['@mozilla.org/network/http-auth-manager;1'] + .getService(SpecialPowers.Ci.nsIHttpAuthManager); +authMgr.clearAll(); + +// start the tests +testNum = 0; +doTest(); + +SimpleTest.waitForExplicitFinish(); +</script> +</pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/test_xml_load.html b/toolkit/components/passwordmgr/test/test_xml_load.html new file mode 100644 index 000000000..5672c7117 --- /dev/null +++ b/toolkit/components/passwordmgr/test/test_xml_load.html @@ -0,0 +1,191 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test XML document prompts</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="pwmgr_common.js"></script> + <script type="text/javascript" src="prompt_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +Login Manager test: XML prompt +<p id="display"></p> + +<div id="content" style="display: none"> + <iframe id="iframe"></iframe> +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +/** Test for Login Manager: XML prompts. **/ +var pwmgr, login1, login2; + +function initLogins() { + pwmgr = SpecialPowers.Cc["@mozilla.org/login-manager;1"] + .getService(Ci.nsILoginManager); + + login1 = SpecialPowers.Cc["@mozilla.org/login-manager/loginInfo;1"] + .createInstance(Ci.nsILoginInfo); + login2 = SpecialPowers.Cc["@mozilla.org/login-manager/loginInfo;1"] + .createInstance(Ci.nsILoginInfo); + + login1.init("http://mochi.test:8888", null, "xml", + "xmluser1", "xmlpass1", "", ""); + login2.init("http://mochi.test:8888", null, "xml2", + "xmluser2", "xmlpass2", "", ""); + + pwmgr.addLogin(login1); + pwmgr.addLogin(login2); +} + +function handleDialog(doc, testNum) { + ok(true, "handleDialog running for test " + testNum); + + var clickOK = true; + var userfield = doc.getElementById("loginTextbox"); + var passfield = doc.getElementById("password1Textbox"); + var username = userfield.getAttribute("value"); + var password = passfield.getAttribute("value"); + var dialog = doc.getElementById("commonDialog"); + + switch (testNum) { + case 1: + is(username, "xmluser1", "Checking provided username"); + is(password, "xmlpass1", "Checking provided password"); + break; + + case 2: + is(username, "xmluser2", "Checking provided username"); + is(password, "xmlpass2", "Checking provided password"); + + // Check that the dialog is modal, chrome and dependent; + // We can't just check window.opener because that'll be + // a content window, which therefore isn't exposed (it'll lie and + // be null). + var win = doc.defaultView; + var Ci = SpecialPowers.Ci; + var treeOwner = win.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation). + QueryInterface(Ci.nsIDocShellTreeItem).treeOwner; + treeOwner.QueryInterface(Ci.nsIInterfaceRequestor); + var flags = treeOwner.getInterface(Ci.nsIXULWindow).chromeFlags; + var wbc = treeOwner.getInterface(Ci.nsIWebBrowserChrome); + info("Flags: " + flags); + ok((flags & Ci.nsIWebBrowserChrome.CHROME_OPENAS_CHROME) != 0, + "Dialog should be opened as chrome"); + ok((flags & Ci.nsIWebBrowserChrome.CHROME_OPENAS_DIALOG) != 0, + "Dialog should be opened as a dialog"); + ok((flags & Ci.nsIWebBrowserChrome.CHROME_DEPENDENT) != 0, + "Dialog should be opened as dependent."); + ok(wbc.isWindowModal(), "Dialog should be modal"); + + // Check that the right tab is focused: + var browserWin = SpecialPowers.Services.wm.getMostRecentWindow("navigator:browser"); + var spec = browserWin.gBrowser.selectedBrowser.currentURI.spec; + ok(spec.startsWith("http://mochi.test:8888"), + "Tab with remote URI (rather than about:blank) should be focused (" + spec + ")"); + + break; + + default: + ok(false, "Uhh, unhandled switch for testNum #" + testNum); + break; + } + + // Explicitly cancel the dialog and report a fail in this failure + // case, rather than letting the dialog get stuck due to an auth + // failure and having the test timeout. + if (!username && !password) { + ok(false, "No values prefilled"); + clickOK = false; + } + + if (clickOK) + dialog.acceptDialog(); + else + dialog.cancelDialog(); + + ok(true, "handleDialog done"); + didDialog = true; +} + +var newWin; +function xmlLoad(responseDoc) { + ok(true, "xmlLoad running for test " + testNum); + + // The server echos back the user/pass it received. + var username = responseDoc.getElementById("user").textContent; + var password = responseDoc.getElementById("pass").textContent; + var authok = responseDoc.getElementById("ok").textContent; + + switch (testNum) { + case 1: + is(username, "xmluser1", "Checking provided username"); + is(password, "xmlpass1", "Checking provided password"); + break; + + case 2: + is(username, "xmluser2", "Checking provided username"); + is(password, "xmlpass2", "Checking provided password"); + + newWin.close(); + break; + + default: + ok(false, "Uhh, unhandled switch for testNum #" + testNum); + break; + } + + doTest(); +} + +function doTest() { + switch (++testNum) { + case 1: + startCallbackTimer(); + makeRequest("authenticate.sjs?user=xmluser1&pass=xmlpass1&realm=xml"); + break; + + case 2: + // Test correct parenting, by opening another tab in the foreground, + // and making sure the prompt re-focuses the original tab when shown: + newWin = window.open(); + newWin.focus(); + startCallbackTimer(); + makeRequest("authenticate.sjs?user=xmluser2&pass=xmlpass2&realm=xml2"); + break; + + default: + SimpleTest.finish(); + } +} + +function makeRequest(uri) { + var xmlDoc = document.implementation.createDocument("", "test", null); + + function documentLoaded(e) { + xmlLoad(xmlDoc); + } + xmlDoc.addEventListener("load", documentLoaded, false); + xmlDoc.load(uri); +} + + +initLogins(); + +// clear plain HTTP auth sessions before the test, to allow +// running them more than once. +var authMgr = SpecialPowers.Cc['@mozilla.org/network/http-auth-manager;1'] + .getService(SpecialPowers.Ci.nsIHttpAuthManager); +authMgr.clearAll(); + +// start the tests +testNum = 0; +doTest(); + +SimpleTest.waitForExplicitFinish(); +</script> +</pre> +</body> +</html> diff --git a/toolkit/components/passwordmgr/test/unit/.eslintrc.js b/toolkit/components/passwordmgr/test/unit/.eslintrc.js new file mode 100644 index 000000000..d35787cd2 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + "extends": [ + "../../../../../testing/xpcshell/xpcshell.eslintrc.js" + ] +}; diff --git a/toolkit/components/passwordmgr/test/unit/data/corruptDB.sqlite b/toolkit/components/passwordmgr/test/unit/data/corruptDB.sqlite Binary files differnew file mode 100644 index 000000000..b234246ca --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/corruptDB.sqlite diff --git a/toolkit/components/passwordmgr/test/unit/data/key3.db b/toolkit/components/passwordmgr/test/unit/data/key3.db Binary files differnew file mode 100644 index 000000000..a83a0a577 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/key3.db diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v1.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v1.sqlite Binary files differnew file mode 100644 index 000000000..fe030b61f --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/signons-v1.sqlite diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v1v2.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v1v2.sqlite Binary files differnew file mode 100644 index 000000000..729512a12 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/signons-v1v2.sqlite diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v2.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v2.sqlite Binary files differnew file mode 100644 index 000000000..a6c72b31e --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/signons-v2.sqlite diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v2v3.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v2v3.sqlite Binary files differnew file mode 100644 index 000000000..359df5d31 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/signons-v2v3.sqlite diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v3.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v3.sqlite Binary files differnew file mode 100644 index 000000000..918f4142f --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/signons-v3.sqlite diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v3v4.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v3v4.sqlite Binary files differnew file mode 100644 index 000000000..e06c33aae --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/signons-v3v4.sqlite diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v4.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v4.sqlite Binary files differnew file mode 100644 index 000000000..227c09c81 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/signons-v4.sqlite diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v4v5.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v4v5.sqlite Binary files differnew file mode 100644 index 000000000..4534cf255 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/signons-v4v5.sqlite diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v5v6.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v5v6.sqlite Binary files differnew file mode 100644 index 000000000..eb4ee6d01 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/signons-v5v6.sqlite diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v999-2.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v999-2.sqlite Binary files differnew file mode 100644 index 000000000..e09c4f710 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/signons-v999-2.sqlite diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v999.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v999.sqlite Binary files differnew file mode 100644 index 000000000..0328a1a02 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/signons-v999.sqlite diff --git a/toolkit/components/passwordmgr/test/unit/head.js b/toolkit/components/passwordmgr/test/unit/head.js new file mode 100644 index 000000000..baf958ab4 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/head.js @@ -0,0 +1,135 @@ +/** + * Provides infrastructure for automated login components tests. + */ + +"use strict"; + +// Globals + +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"); +Cu.import("resource://testing-common/MockDocument.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "DownloadPaths", + "resource://gre/modules/DownloadPaths.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", + "resource://gre/modules/FileUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Promise", + "resource://gre/modules/Promise.jsm"); + +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(); +} + +// Global helpers + +// 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. +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. + * + * @return nsIFile pointing to a non-existent file in a temporary directory. + * + * @note It is not enough to delete the file if it exists, or to delete the file + * after calling nsIFile.createUnique, because on Windows the delete + * operation in the file system may still be pending, preventing a new + * file with the same name to be created. + */ +function getTempFile(aLeafName) +{ + // Prepend a serial number to the extension in the suggested leaf name. + let [base, ext] = DownloadPaths.splitBaseNameAndExtension(aLeafName); + let leafName = base + "-" + gFileCounter + ext; + gFileCounter++; + + // Get a file reference under the temporary directory for this test file. + let file = FileUtils.getFile("TmpD", [leafName]); + do_check_false(file.exists()); + + do_register_cleanup(function () { + if (file.exists()) { + file.remove(false); + } + }); + + return file; +} + +const RecipeHelpers = { + initNewParent() { + return (new LoginRecipesParent({ defaults: null })).initializationPromise; + }, +}; + +// Initialization functions common to all tests + +add_task(function* test_common_initialize() +{ + // Before initializing the service for the first time, we should copy the key + // file required to decrypt the logins contained in the SQLite databases used + // by migration tests. This file is not required for the other tests. + yield OS.File.copy(do_get_file("data/key3.db").path, + OS.Path.join(OS.Constants.Path.profileDir, "key3.db")); + + // Ensure that the service and the storage module are initialized. + yield Services.logins.initializationPromise; + + // Ensure that every test file starts with an empty database. + LoginTestUtils.clearData(); + + // Clean up after every test. + do_register_cleanup(() => LoginTestUtils.clearData()); +}); + +/** + * Compare two FormLike to see if they represent the same information. Elements + * are compared using their @id attribute. + */ +function formLikeEqual(a, b) { + Assert.strictEqual(Object.keys(a).length, Object.keys(b).length, + "Check the formLikes have the same number of properties"); + + for (let propName of Object.keys(a)) { + if (propName == "elements") { + Assert.strictEqual(a.elements.length, b.elements.length, "Check element count"); + for (let i = 0; i < a.elements.length; i++) { + Assert.strictEqual(a.elements[i].id, b.elements[i].id, "Check element " + i + " id"); + } + continue; + } + Assert.strictEqual(a[propName], b[propName], "Compare formLike " + propName + " property"); + } +} diff --git a/toolkit/components/passwordmgr/test/unit/test_OSCrypto_win.js b/toolkit/components/passwordmgr/test/unit/test_OSCrypto_win.js new file mode 100644 index 000000000..94d2e50c0 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_OSCrypto_win.js @@ -0,0 +1,75 @@ +/** + * Tests the OSCrypto object. + */ + +"use strict"; + +// Globals + +XPCOMUtils.defineLazyModuleGetter(this, "OSCrypto", + "resource://gre/modules/OSCrypto.jsm"); + +var crypto = new OSCrypto(); + +// Tests + +add_task(function test_getIELoginHash() +{ + do_check_eq(crypto.getIELoginHash("https://bugzilla.mozilla.org/page.cgi"), + "4A66FE96607885790F8E67B56EEE52AB539BAFB47D"); + + do_check_eq(crypto.getIELoginHash("https://github.com/login"), + "0112F7DCE67B8579EA01367678AA44AB9868B5A143"); + + do_check_eq(crypto.getIELoginHash("https://login.live.com/login.srf"), + "FBF92E5D804C82717A57856533B779676D92903688"); + + do_check_eq(crypto.getIELoginHash("https://preview.c9.io/riadh/w1/pass.1.html"), + "6935CF27628830605927F86AB53831016FC8973D1A"); + + + do_check_eq(crypto.getIELoginHash("https://reviewboard.mozilla.org/account/login/"), + "09141FD287E2E59A8B1D3BB5671537FD3D6B61337A"); + + do_check_eq(crypto.getIELoginHash("https://www.facebook.com/"), + "EF44D3E034009CB0FD1B1D81A1FF3F3335213BD796"); + +}); + +add_task(function test_decryptData_encryptData() +{ + function decryptEncryptTest(key) { + do_check_eq(crypto.decryptData(crypto.encryptData("", key), key), + ""); + + do_check_eq(crypto.decryptData(crypto.encryptData("secret", key), key), + "secret"); + + do_check_eq(crypto.decryptData(crypto.encryptData("https://www.mozilla.org", key), + key), + "https://www.mozilla.org"); + + do_check_eq(crypto.decryptData(crypto.encryptData("https://reviewboard.mozilla.org", key), + key), + "https://reviewboard.mozilla.org"); + + do_check_eq(crypto.decryptData(crypto.encryptData("https://bugzilla.mozilla.org/page.cgi", + key), + key), + "https://bugzilla.mozilla.org/page.cgi"); + } + + let keys = [null, "a", "keys", "abcdedf", "pass", "https://bugzilla.mozilla.org/page.cgi", + "https://login.live.com/login.srf"]; + for (let key of keys) { + decryptEncryptTest(key); + } + let url = "https://twitter.com/"; + let value = [1, 0, 0, 0, 208, 140, 157, 223, 1, 21, 209, 17, 140, 122, 0, 192, 79, 194, 151, 235, 1, 0, 0, 0, 254, 58, 230, 75, 132, 228, 181, 79, 184, 160, 37, 106, 201, 29, 42, 152, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 16, 102, 0, 0, 0, 1, 0, 0, 32, 0, 0, 0, 90, 136, 17, 124, 122, 57, 178, 24, 34, 86, 209, 198, 184, 107, 58, 58, 32, 98, 61, 239, 129, 101, 56, 239, 114, 159, 139, 165, 183, 40, 183, 85, 0, 0, 0, 0, 14, 128, 0, 0, 0, 2, 0, 0, 32, 0, 0, 0, 147, 170, 34, 21, 53, 227, 191, 6, 201, 84, 106, 31, 57, 227, 46, 127, 219, 199, 80, 142, 37, 104, 112, 223, 26, 165, 223, 55, 176, 89, 55, 37, 112, 0, 0, 0, 98, 70, 221, 109, 5, 152, 46, 11, 190, 213, 226, 58, 244, 20, 180, 217, 63, 155, 227, 132, 7, 151, 235, 6, 37, 232, 176, 182, 141, 191, 251, 50, 20, 123, 53, 11, 247, 233, 112, 121, 130, 27, 168, 68, 92, 144, 192, 7, 12, 239, 53, 217, 253, 155, 54, 109, 236, 216, 225, 245, 79, 234, 165, 225, 104, 36, 77, 13, 195, 237, 143, 165, 100, 107, 230, 70, 54, 19, 179, 35, 8, 101, 93, 202, 121, 210, 222, 28, 93, 122, 36, 84, 185, 249, 238, 3, 102, 149, 248, 94, 137, 16, 192, 22, 251, 220, 22, 223, 16, 58, 104, 187, 64, 0, 0, 0, 70, 72, 15, 119, 144, 66, 117, 203, 190, 82, 131, 46, 111, 130, 238, 191, 170, 63, 186, 117, 46, 88, 171, 3, 94, 146, 75, 86, 243, 159, 63, 195, 149, 25, 105, 141, 42, 217, 108, 18, 63, 62, 98, 182, 241, 195, 12, 216, 152, 230, 176, 253, 202, 129, 41, 185, 135, 111, 226, 92, 27, 78, 27, 198]; + + let arr1 = crypto.arrayToString(value); + let arr2 = crypto.stringToArray(crypto.decryptData(crypto.encryptData(arr1, url), url)); + for (let i = 0; i < arr1.length; i++) { + do_check_eq(arr2[i], value[i]); + } +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_context_menu.js b/toolkit/components/passwordmgr/test/unit/test_context_menu.js new file mode 100644 index 000000000..722c13e15 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_context_menu.js @@ -0,0 +1,165 @@ +/* + * Test the password manager context menu. + */ + +"use strict"; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/LoginManagerContextMenu.jsm"); + +XPCOMUtils.defineLazyGetter(this, "_stringBundle", function() { + return Services.strings. + createBundle("chrome://passwordmgr/locale/passwordmgr.properties"); +}); + +/** + * Prepare data for the following tests. + */ +add_task(function* test_initialize() { + for (let login of loginList()) { + Services.logins.addLogin(login); + } +}); + +/** + * Tests if the LoginManagerContextMenu returns the correct login items. + */ +add_task(function* test_contextMenuAddAndRemoveLogins() { + const DOCUMENT_CONTENT = "<form><input id='pw' type=password></form>"; + const INPUT_QUERY = "input[type='password']"; + + let testHostnames = [ + "http://www.example.com", + "http://www2.example.com", + "http://www3.example.com", + "http://empty.example.com", + ]; + + for (let hostname of testHostnames) { + do_print("test for hostname: " + hostname); + // Get expected logins for this test. + let logins = getExpectedLogins(hostname); + + // Create the logins menuitems fragment. + let {fragment, document} = createLoginsFragment(hostname, DOCUMENT_CONTENT, INPUT_QUERY); + + if (!logins.length) { + Assert.ok(fragment === null, "Null returned. No logins where found."); + continue; + } + let items = [...fragment.querySelectorAll("menuitem")]; + + // Check if the items are those expected to be listed. + Assert.ok(checkLoginItems(logins, items), "All expected logins found."); + document.body.appendChild(fragment); + + // Try to clear the fragment. + LoginManagerContextMenu.clearLoginsFromMenu(document); + Assert.equal(fragment.querySelectorAll("menuitem").length, 0, "All items correctly cleared."); + } + + Services.logins.removeAllLogins(); +}); + +/** + * Create a fragment with a menuitem for each login. + */ +function createLoginsFragment(url, content, elementQuery) { + const CHROME_URL = "chrome://mock-chrome"; + + // Create a mock document. + let document = MockDocument.createTestDocument(CHROME_URL, content); + let inputElement = document.querySelector(elementQuery); + MockDocument.mockOwnerDocumentProperty(inputElement, document, url); + + // We also need a simple mock Browser object for this test. + let browser = { + ownerDocument: document + }; + + let URI = Services.io.newURI(url, null, null); + return { + document, + fragment: LoginManagerContextMenu.addLoginsToMenu(inputElement, browser, URI), + }; +} + +/** + * Check if every login have it's corresponding menuitem. + * Duplicates and empty usernames have a date appended. + */ +function checkLoginItems(logins, items) { + function findDuplicates(unfilteredLoginList) { + var seen = new Set(); + var duplicates = new Set(); + for (let login of unfilteredLoginList) { + if (seen.has(login.username)) { + duplicates.add(login.username); + } + seen.add(login.username); + } + return duplicates; + } + let duplicates = findDuplicates(logins); + + let dateAndTimeFormatter = new Intl.DateTimeFormat(undefined, + { day: "numeric", month: "short", year: "numeric" }); + for (let login of logins) { + if (login.username && !duplicates.has(login.username)) { + // If login is not duplicate and we can't find an item for it, fail. + if (!items.find(item => item.label == login.username)) { + return false; + } + continue; + } + + let meta = login.QueryInterface(Ci.nsILoginMetaInfo); + let time = dateAndTimeFormatter.format(new Date(meta.timePasswordChanged)); + // If login is duplicate, check if we have a login item with appended date. + if (login.username && !items.find(item => item.label == login.username + " (" + time + ")")) { + return false; + } + // If login is empty, check if we have a login item with appended date. + if (!login.username && + !items.find(item => item.label == _stringBundle.GetStringFromName("noUsername") + " (" + time + ")")) { + return false; + } + } + return true; +} + +/** + * Gets the list of expected logins for a hostname. + */ +function getExpectedLogins(hostname) { + return Services.logins.getAllLogins().filter(entry => entry["hostname"] === hostname); +} + +function loginList() { + return [ + new LoginInfo("http://www.example.com", "http://www.example.com", null, + "username1", "password", + "form_field_username", "form_field_password"), + + new LoginInfo("http://www.example.com", "http://www.example.com", null, + "username2", "password", + "form_field_username", "form_field_password"), + + new LoginInfo("http://www2.example.com", "http://www.example.com", null, + "username", "password", + "form_field_username", "form_field_password"), + new LoginInfo("http://www2.example.com", "http://www2.example.com", null, + "username", "password2", + "form_field_username", "form_field_password"), + new LoginInfo("http://www2.example.com", "http://www2.example.com", null, + "username2", "password2", + "form_field_username", "form_field_password"), + + new LoginInfo("http://www3.example.com", "http://www.example.com", null, + "", "password", + "form_field_username", "form_field_password"), + new LoginInfo("http://www3.example.com", "http://www3.example.com", null, + "", "password2", + "form_field_username", "form_field_password"), + ]; +} diff --git a/toolkit/components/passwordmgr/test/unit/test_dedupeLogins.js b/toolkit/components/passwordmgr/test/unit/test_dedupeLogins.js new file mode 100644 index 000000000..d688a6dbf --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_dedupeLogins.js @@ -0,0 +1,284 @@ +/* + * 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"], + "", + ], + ]; + + for (let tc of testcases) { + let description = tc.shift(); + let expectedException = tc.shift(); + Assert.throws(() => { + LoginHelper.dedupeLogins(...tc); + }, expectedException, `Check: ${description}`); + } +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_disabled_hosts.js b/toolkit/components/passwordmgr/test/unit/test_disabled_hosts.js new file mode 100644 index 000000000..ff3b7e868 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_disabled_hosts.js @@ -0,0 +1,196 @@ +/* -*- 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 getLoginSavingEnabled, setLoginSavingEnabled, and getAllDisabledHosts. + */ + +"use strict"; + +// Tests + +/** + * Tests setLoginSavingEnabled and getAllDisabledHosts. + */ +add_task(function test_setLoginSavingEnabled_getAllDisabledHosts() +{ + // Add some disabled hosts, and verify that different schemes for the same + // domain are considered different hosts. + let hostname1 = "http://disabled1.example.com"; + let hostname2 = "http://disabled2.example.com"; + let hostname3 = "https://disabled2.example.com"; + Services.logins.setLoginSavingEnabled(hostname1, false); + Services.logins.setLoginSavingEnabled(hostname2, false); + Services.logins.setLoginSavingEnabled(hostname3, false); + + LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(), + [hostname1, hostname2, hostname3]); + + // Adding the same host twice should not result in an error. + Services.logins.setLoginSavingEnabled(hostname2, false); + LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(), + [hostname1, hostname2, hostname3]); + + // Removing a disabled host should work. + Services.logins.setLoginSavingEnabled(hostname2, true); + LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(), + [hostname1, hostname3]); + + // Removing the last disabled host should work. + Services.logins.setLoginSavingEnabled(hostname1, true); + Services.logins.setLoginSavingEnabled(hostname3, true); + LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(), + []); +}); + +/** + * Tests setLoginSavingEnabled and getLoginSavingEnabled. + */ +add_task(function test_setLoginSavingEnabled_getLoginSavingEnabled() +{ + let hostname1 = "http://disabled.example.com"; + let hostname2 = "https://disabled.example.com"; + + // Hosts should not be disabled by default. + do_check_true(Services.logins.getLoginSavingEnabled(hostname1)); + do_check_true(Services.logins.getLoginSavingEnabled(hostname2)); + + // Test setting initial values. + Services.logins.setLoginSavingEnabled(hostname1, false); + Services.logins.setLoginSavingEnabled(hostname2, true); + do_check_false(Services.logins.getLoginSavingEnabled(hostname1)); + do_check_true(Services.logins.getLoginSavingEnabled(hostname2)); + + // Test changing values. + Services.logins.setLoginSavingEnabled(hostname1, true); + Services.logins.setLoginSavingEnabled(hostname2, false); + do_check_true(Services.logins.getLoginSavingEnabled(hostname1)); + do_check_false(Services.logins.getLoginSavingEnabled(hostname2)); + + // Clean up. + Services.logins.setLoginSavingEnabled(hostname2, true); +}); + +/** + * Tests setLoginSavingEnabled with invalid NUL characters in the hostname. + */ +add_task(function test_setLoginSavingEnabled_invalid_characters() +{ + let hostname = "http://null\0X.example.com"; + Assert.throws(() => Services.logins.setLoginSavingEnabled(hostname, false), + /Invalid hostname/); + + // Verify that no data was stored by the previous call. + LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(), + []); +}); + +/** + * Tests different values of the "signon.rememberSignons" property. + */ +add_task(function test_rememberSignons() +{ + let hostname1 = "http://example.com"; + let hostname2 = "http://localhost"; + + // The default value for the preference should be true. + do_check_true(Services.prefs.getBoolPref("signon.rememberSignons")); + + // Hosts should not be disabled by default. + Services.logins.setLoginSavingEnabled(hostname1, false); + do_check_false(Services.logins.getLoginSavingEnabled(hostname1)); + do_check_true(Services.logins.getLoginSavingEnabled(hostname2)); + + // Disable storage of saved passwords globally. + Services.prefs.setBoolPref("signon.rememberSignons", false); + do_register_cleanup( + () => Services.prefs.clearUserPref("signon.rememberSignons")); + + // All hosts should now appear disabled. + do_check_false(Services.logins.getLoginSavingEnabled(hostname1)); + do_check_false(Services.logins.getLoginSavingEnabled(hostname2)); + + // The list of disabled hosts should be unaltered. + LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(), + [hostname1]); + + // Changing values with the preference set should work. + Services.logins.setLoginSavingEnabled(hostname1, true); + Services.logins.setLoginSavingEnabled(hostname2, false); + + // All hosts should still appear disabled. + do_check_false(Services.logins.getLoginSavingEnabled(hostname1)); + do_check_false(Services.logins.getLoginSavingEnabled(hostname2)); + + // The list of disabled hosts should have been changed. + LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(), + [hostname2]); + + // Enable storage of saved passwords again. + Services.prefs.setBoolPref("signon.rememberSignons", true); + + // Hosts should now appear enabled as requested. + do_check_true(Services.logins.getLoginSavingEnabled(hostname1)); + do_check_false(Services.logins.getLoginSavingEnabled(hostname2)); + + // Clean up. + Services.logins.setLoginSavingEnabled(hostname2, true); + LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(), + []); +}); + +/** + * Tests storing disabled hosts with non-ASCII characters where IDN is supported. + */ +add_task(function* test_storage_setLoginSavingEnabled_nonascii_IDN_is_supported() +{ + let hostname = "http://大.net"; + let encoding = "http://xn--pss.net"; + + // Test adding disabled host with nonascii URL (http://大.net). + Services.logins.setLoginSavingEnabled(hostname, false); + yield* LoginTestUtils.reloadData(); + Assert.equal(Services.logins.getLoginSavingEnabled(hostname), false); + Assert.equal(Services.logins.getLoginSavingEnabled(encoding), false); + LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(), [hostname]); + + LoginTestUtils.clearData(); + + // Test adding disabled host with IDN ("http://xn--pss.net"). + Services.logins.setLoginSavingEnabled(encoding, false); + yield* LoginTestUtils.reloadData(); + Assert.equal(Services.logins.getLoginSavingEnabled(hostname), false); + Assert.equal(Services.logins.getLoginSavingEnabled(encoding), false); + LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(), [hostname]); + + LoginTestUtils.clearData(); +}); + +/** + * Tests storing disabled hosts with non-ASCII characters where IDN is not supported. + */ +add_task(function* test_storage_setLoginSavingEnabled_nonascii_IDN_not_supported() +{ + let hostname = "http://√.com"; + let encoding = "http://xn--19g.com"; + + // Test adding disabled host with nonascii URL (http://√.com). + Services.logins.setLoginSavingEnabled(hostname, false); + yield* LoginTestUtils.reloadData(); + Assert.equal(Services.logins.getLoginSavingEnabled(hostname), false); + Assert.equal(Services.logins.getLoginSavingEnabled(encoding), false); + LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(), [encoding]); + + LoginTestUtils.clearData(); + + // Test adding disabled host with IDN ("http://xn--19g.com"). + Services.logins.setLoginSavingEnabled(encoding, false); + yield* LoginTestUtils.reloadData(); + Assert.equal(Services.logins.getLoginSavingEnabled(hostname), false); + Assert.equal(Services.logins.getLoginSavingEnabled(encoding), false); + LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(), [encoding]); + + LoginTestUtils.clearData(); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_getFormFields.js b/toolkit/components/passwordmgr/test/unit/test_getFormFields.js new file mode 100644 index 000000000..46912ab8f --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_getFormFields.js @@ -0,0 +1,147 @@ +/* + * Test for LoginManagerContent._getFormFields. + */ + +"use strict"; + +// Services.prefs.setBoolPref("signon.debug", true); + +Cu.importGlobalProperties(["URL"]); + +const LMCBackstagePass = Cu.import("resource://gre/modules/LoginManagerContent.jsm"); +const { LoginManagerContent, LoginFormFactory } = LMCBackstagePass; +const TESTCASES = [ + { + description: "1 password field outside of a <form>", + document: `<input id="pw1" type=password>`, + returnedFieldIDs: [null, "pw1", null], + skipEmptyFields: undefined, + }, + { + description: "1 text field outside of a <form> without a password field", + document: `<input id="un1">`, + returnedFieldIDs: [null, null, null], + skipEmptyFields: undefined, + }, + { + description: "1 username & password field outside of a <form>", + document: `<input id="un1"> + <input id="pw1" type=password>`, + returnedFieldIDs: ["un1", "pw1", null], + skipEmptyFields: undefined, + }, + { + description: "1 username & password field in a <form>", + document: `<form> + <input id="un1"> + <input id="pw1" type=password> + </form>`, + returnedFieldIDs: ["un1", "pw1", null], + skipEmptyFields: undefined, + }, + { + description: "4 empty password fields outside of a <form>", + document: `<input id="pw1" type=password> + <input id="pw2" type=password> + <input id="pw3" type=password> + <input id="pw4" type=password>`, + returnedFieldIDs: [null, null, null], + skipEmptyFields: undefined, + }, + { + description: "4 password fields outside of a <form> (1 empty, 3 full) with skipEmpty", + document: `<input id="pw1" type=password> + <input id="pw2" type=password value="pass2"> + <input id="pw3" type=password value="pass3"> + <input id="pw4" type=password value="pass4">`, + returnedFieldIDs: [null, null, null], + skipEmptyFields: true, + }, + { + description: "Form with 1 password field", + document: `<form><input id="pw1" type=password></form>`, + returnedFieldIDs: [null, "pw1", null], + skipEmptyFields: undefined, + }, + { + description: "Form with 2 password fields", + document: `<form><input id="pw1" type=password><input id='pw2' type=password></form>`, + returnedFieldIDs: [null, "pw1", null], + skipEmptyFields: undefined, + }, + { + description: "1 password field in a form, 1 outside (not processed)", + document: `<form><input id="pw1" type=password></form><input id="pw2" type=password>`, + returnedFieldIDs: [null, "pw1", null], + skipEmptyFields: undefined, + }, + { + description: "1 password field in a form, 1 text field outside (not processed)", + document: `<form><input id="pw1" type=password></form><input>`, + returnedFieldIDs: [null, "pw1", null], + skipEmptyFields: undefined, + }, + { + description: "1 text field in a form, 1 password field outside (not processed)", + document: `<form><input></form><input id="pw1" type=password>`, + returnedFieldIDs: [null, null, null], + skipEmptyFields: undefined, + }, + { + description: "2 password fields outside of a <form> with 1 linked via @form", + document: `<input id="pw1" type=password><input id="pw2" type=password form='form1'> + <form id="form1"></form>`, + returnedFieldIDs: [null, "pw1", null], + skipEmptyFields: undefined, + }, + { + description: "2 password fields outside of a <form> with 1 linked via @form + skipEmpty", + document: `<input id="pw1" type=password><input id="pw2" type=password form="form1"> + <form id="form1"></form>`, + returnedFieldIDs: [null, null, null], + skipEmptyFields: true, + }, + { + description: "2 password fields outside of a <form> with 1 linked via @form + skipEmpty with 1 empty", + document: `<input id="pw1" type=password value="pass1"><input id="pw2" type=password form="form1"> + <form id="form1"></form>`, + returnedFieldIDs: [null, "pw1", null], + skipEmptyFields: true, + }, +]; + +for (let tc of TESTCASES) { + do_print("Sanity checking the testcase: " + tc.description); + + (function() { + let testcase = tc; + add_task(function*() { + do_print("Starting testcase: " + testcase.description); + let document = MockDocument.createTestDocument("http://localhost:8080/test/", + testcase.document); + + let input = document.querySelector("input"); + MockDocument.mockOwnerDocumentProperty(input, document, "http://localhost:8080/test/"); + + let formLike = LoginFormFactory.createFromField(input); + + let actual = LoginManagerContent._getFormFields(formLike, + testcase.skipEmptyFields, + new Set()); + + Assert.strictEqual(testcase.returnedFieldIDs.length, 3, + "_getFormFields returns 3 elements"); + + for (let i = 0; i < testcase.returnedFieldIDs.length; i++) { + let expectedID = testcase.returnedFieldIDs[i]; + if (expectedID === null) { + Assert.strictEqual(actual[i], expectedID, + "Check returned field " + i + " is null"); + } else { + Assert.strictEqual(actual[i].id, expectedID, + "Check returned field " + i + " ID"); + } + } + }); + })(); +} diff --git a/toolkit/components/passwordmgr/test/unit/test_getPasswordFields.js b/toolkit/components/passwordmgr/test/unit/test_getPasswordFields.js new file mode 100644 index 000000000..08fa422ab --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_getPasswordFields.js @@ -0,0 +1,156 @@ +/* + * Test for LoginManagerContent._getPasswordFields using LoginFormFactory. + */ + +"use strict"; + +const LMCBackstagePass = Cu.import("resource://gre/modules/LoginManagerContent.jsm"); +const { LoginManagerContent, LoginFormFactory } = LMCBackstagePass; +const TESTCASES = [ + { + description: "Empty document", + document: ``, + returnedFieldIDsByFormLike: [], + skipEmptyFields: undefined, + }, + { + description: "Non-password input with no <form> present", + document: `<input>`, + // Only the IDs of password fields should be in this array + returnedFieldIDsByFormLike: [[]], + skipEmptyFields: undefined, + }, + { + description: "1 password field outside of a <form>", + document: `<input id="pw1" type=password>`, + returnedFieldIDsByFormLike: [["pw1"]], + skipEmptyFields: undefined, + }, + { + description: "4 empty password fields outside of a <form>", + document: `<input id="pw1" type=password> + <input id="pw2" type=password> + <input id="pw3" type=password> + <input id="pw4" type=password>`, + returnedFieldIDsByFormLike: [[]], + skipEmptyFields: undefined, + }, + { + description: "4 password fields outside of a <form> (1 empty, 3 full) with skipEmpty", + document: `<input id="pw1" type=password> + <input id="pw2" type=password value="pass2"> + <input id="pw3" type=password value="pass3"> + <input id="pw4" type=password value="pass4">`, + returnedFieldIDsByFormLike: [["pw2", "pw3", "pw4"]], + skipEmptyFields: true, + }, + { + description: "Form with 1 password field", + document: `<form><input id="pw1" type=password></form>`, + returnedFieldIDsByFormLike: [["pw1"]], + skipEmptyFields: undefined, + }, + { + description: "Form with 2 password fields", + document: `<form><input id="pw1" type=password><input id='pw2' type=password></form>`, + returnedFieldIDsByFormLike: [["pw1", "pw2"]], + skipEmptyFields: undefined, + }, + { + description: "1 password field in a form, 1 outside", + document: `<form><input id="pw1" type=password></form><input id="pw2" type=password>`, + returnedFieldIDsByFormLike: [["pw1"], ["pw2"]], + skipEmptyFields: undefined, + }, + { + description: "2 password fields outside of a <form> with 1 linked via @form", + document: `<input id="pw1" type=password><input id="pw2" type=password form='form1'> + <form id="form1"></form>`, + returnedFieldIDsByFormLike: [["pw1"], ["pw2"]], + skipEmptyFields: undefined, + }, + { + description: "2 password fields outside of a <form> with 1 linked via @form + skipEmpty", + document: `<input id="pw1" type=password><input id="pw2" type=password form="form1"> + <form id="form1"></form>`, + returnedFieldIDsByFormLike: [[], []], + skipEmptyFields: true, + }, + { + description: "skipEmptyFields should also skip white-space only fields", + document: `<input id="pw-space" type=password value=" "> + <input id="pw-tab" type=password value=" "> + <input id="pw-newline" type=password form="form1" value=" +"> + <form id="form1"></form>`, + returnedFieldIDsByFormLike: [[], []], + skipEmptyFields: true, + }, + { + description: "2 password fields outside of a <form> with 1 linked via @form + skipEmpty with 1 empty", + document: `<input id="pw1" type=password value=" pass1 "><input id="pw2" type=password form="form1"> + <form id="form1"></form>`, + returnedFieldIDsByFormLike: [["pw1"], []], + skipEmptyFields: true, + }, +]; + +for (let tc of TESTCASES) { + do_print("Sanity checking the testcase: " + tc.description); + + (function() { + let testcase = tc; + add_task(function*() { + do_print("Starting testcase: " + testcase.description); + let document = MockDocument.createTestDocument("http://localhost:8080/test/", + testcase.document); + + let mapRootElementToFormLike = new Map(); + for (let input of document.querySelectorAll("input")) { + let formLike = LoginFormFactory.createFromField(input); + let existingFormLike = mapRootElementToFormLike.get(formLike.rootElement); + if (!existingFormLike) { + mapRootElementToFormLike.set(formLike.rootElement, formLike); + continue; + } + + // If the formLike is already present, ensure that the properties are the same. + do_print("Checking if the new FormLike for the same root has the same properties"); + formLikeEqual(formLike, existingFormLike); + } + + Assert.strictEqual(mapRootElementToFormLike.size, testcase.returnedFieldIDsByFormLike.length, + "Check the correct number of different formLikes were returned"); + + let formLikeIndex = -1; + for (let formLikeFromInput of mapRootElementToFormLike.values()) { + formLikeIndex++; + let pwFields = LoginManagerContent._getPasswordFields(formLikeFromInput, + testcase.skipEmptyFields); + + if (formLikeFromInput.rootElement instanceof Ci.nsIDOMHTMLFormElement) { + let formLikeFromForm = LoginFormFactory.createFromForm(formLikeFromInput.rootElement); + do_print("Checking that the FormLike created for the <form> matches" + + " the one from a password field"); + formLikeEqual(formLikeFromInput, formLikeFromForm); + } + + + if (testcase.returnedFieldIDsByFormLike[formLikeIndex].length === 0) { + Assert.strictEqual(pwFields, null, + "If no password fields were found null should be returned"); + } else { + Assert.strictEqual(pwFields.length, + testcase.returnedFieldIDsByFormLike[formLikeIndex].length, + "Check the # of password fields for formLike #" + formLikeIndex); + } + + for (let i = 0; i < testcase.returnedFieldIDsByFormLike[formLikeIndex].length; i++) { + let expectedID = testcase.returnedFieldIDsByFormLike[formLikeIndex][i]; + Assert.strictEqual(pwFields[i].element.id, expectedID, + "Check password field " + i + " ID"); + } + } + }); + })(); +} diff --git a/toolkit/components/passwordmgr/test/unit/test_getPasswordOrigin.js b/toolkit/components/passwordmgr/test/unit/test_getPasswordOrigin.js new file mode 100644 index 000000000..f2773ec62 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_getPasswordOrigin.js @@ -0,0 +1,28 @@ +/* + * Test for LoginUtils._getPasswordOrigin + */ + +"use strict"; + +const LMCBackstagePass = Cu.import("resource://gre/modules/LoginManagerContent.jsm"); +const TESTCASES = [ + ["javascript:void(0);", null], + ["javascript:void(0);", "javascript:", true], + ["chrome://MyAccount", null], + ["data:text/html,example", null], + ["http://username:password@example.com:80/foo?bar=baz#fragment", "http://example.com", true], + ["http://127.0.0.1:80/foo", "http://127.0.0.1"], + ["http://[::1]:80/foo", "http://[::1]"], + ["http://example.com:8080/foo", "http://example.com:8080"], + ["http://127.0.0.1:8080/foo", "http://127.0.0.1:8080", true], + ["http://[::1]:8080/foo", "http://[::1]:8080"], + ["https://example.com:443/foo", "https://example.com"], + ["https://[::1]:443/foo", "https://[::1]"], + ["https://[::1]:8443/foo", "https://[::1]:8443"], + ["ftp://username:password@[::1]:2121/foo", "ftp://[::1]:2121"], +]; + +for (let [input, expected, allowJS] of TESTCASES) { + let actual = LMCBackstagePass.LoginUtils._getPasswordOrigin(input, allowJS); + Assert.strictEqual(actual, expected, "Checking: " + input); +} diff --git a/toolkit/components/passwordmgr/test/unit/test_isOriginMatching.js b/toolkit/components/passwordmgr/test/unit/test_isOriginMatching.js new file mode 100644 index 000000000..660910dff --- /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)); + } +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_legacy_empty_formSubmitURL.js b/toolkit/components/passwordmgr/test/unit/test_legacy_empty_formSubmitURL.js new file mode 100644 index 000000000..4e16aa267 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_legacy_empty_formSubmitURL.js @@ -0,0 +1,107 @@ +/* -*- 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 the legacy case of a login store containing entries that have an empty + * string in the formSubmitURL field. + * + * In normal conditions, for the purpose of login autocomplete, HTML forms are + * identified using both the prePath of the URI on which they are located, and + * the prePath of the URI where the data will be submitted. This is represented + * by the hostname and formSubmitURL properties of the stored nsILoginInfo. + * + * When a new login for use in forms is saved (after the user replies to the + * password prompt), it is always stored with both the hostname and the + * formSubmitURL (that will be equal to the hostname when the form has no + * "action" attribute). + * + * When the same form is displayed again, the password is autocompleted. If + * there is another form on the same site that submits to a different site, it + * is considered a different form, so the password is not autocompleted, but a + * new password can be stored for the other form. + * + * However, the login database might contain data for an nsILoginInfo that has a + * valid hostname, but an empty formSubmitURL. This means that the login + * applies to all forms on the site, regardless of where they submit data to. + * + * A site can have at most one such login, and in case it is present, then it is + * not possible to store separate logins for forms on the same site that submit + * data to different sites. + * + * The only way to have such condition is to be using logins that were initially + * saved by a very old version of the browser, or because of data manually added + * by an extension in an old version. + */ + +"use strict"; + +// Tests + +/** + * Adds a login with an empty formSubmitURL, then it verifies that no other + * form logins can be added for the same host. + */ +add_task(function test_addLogin_wildcard() +{ + let loginInfo = TestData.formLogin({ hostname: "http://any.example.com", + formSubmitURL: "" }); + Services.logins.addLogin(loginInfo); + + // Normal form logins cannot be added anymore. + loginInfo = TestData.formLogin({ hostname: "http://any.example.com" }); + Assert.throws(() => Services.logins.addLogin(loginInfo), /already exists/); + + // Authentication logins can still be added. + loginInfo = TestData.authLogin({ hostname: "http://any.example.com" }); + Services.logins.addLogin(loginInfo); + + // Form logins can be added for other hosts. + loginInfo = TestData.formLogin({ hostname: "http://other.example.com" }); + Services.logins.addLogin(loginInfo); +}); + +/** + * Verifies that findLogins, searchLogins, and countLogins include all logins + * that have an empty formSubmitURL in the store, even when a formSubmitURL is + * specified. + */ +add_task(function test_search_all_wildcard() +{ + // Search a given formSubmitURL on any host. + let matchData = newPropertyBag({ formSubmitURL: "http://www.example.com" }); + do_check_eq(Services.logins.searchLogins({}, matchData).length, 2); + + do_check_eq(Services.logins.findLogins({}, "", "http://www.example.com", + null).length, 2); + + do_check_eq(Services.logins.countLogins("", "http://www.example.com", + null), 2); + + // Restrict the search to one host. + matchData.setProperty("hostname", "http://any.example.com"); + do_check_eq(Services.logins.searchLogins({}, matchData).length, 1); + + do_check_eq(Services.logins.findLogins({}, "http://any.example.com", + "http://www.example.com", + null).length, 1); + + do_check_eq(Services.logins.countLogins("http://any.example.com", + "http://www.example.com", + null), 1); +}); + +/** + * Verifies that specifying an empty string for formSubmitURL in searchLogins + * includes only logins that have an empty formSubmitURL in the store. + */ +add_task(function test_searchLogins_wildcard() +{ + let logins = Services.logins.searchLogins({}, + newPropertyBag({ formSubmitURL: "" })); + + let loginInfo = TestData.formLogin({ hostname: "http://any.example.com", + formSubmitURL: "" }); + LoginTestUtils.assertLoginListsEqual(logins, [loginInfo]); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_legacy_validation.js b/toolkit/components/passwordmgr/test/unit/test_legacy_validation.js new file mode 100644 index 000000000..709bc9818 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_legacy_validation.js @@ -0,0 +1,76 @@ +/* -*- 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 the legacy validation made when storing nsILoginInfo or disabled hosts. + * + * These rules exist because of limitations of the "signons.txt" storage file, + * that is not used anymore. They are still enforced by the Login Manager + * service, despite these values can now be safely stored in the back-end. + */ + +"use strict"; + +// Tests + +/** + * Tests legacy validation with addLogin. + */ +add_task(function test_addLogin_invalid_characters_legacy() +{ + // Test newlines and carriage returns in properties that contain URLs. + for (let testValue of ["http://newline\n.example.com", + "http://carriagereturn.example.com\r"]) { + let loginInfo = TestData.formLogin({ hostname: testValue }); + Assert.throws(() => Services.logins.addLogin(loginInfo), + /login values can't contain newlines/); + + loginInfo = TestData.formLogin({ formSubmitURL: testValue }); + Assert.throws(() => Services.logins.addLogin(loginInfo), + /login values can't contain newlines/); + + loginInfo = TestData.authLogin({ httpRealm: testValue }); + Assert.throws(() => Services.logins.addLogin(loginInfo), + /login values can't contain newlines/); + } + + // Test newlines and carriage returns in form field names. + for (let testValue of ["newline_field\n", "carriagereturn\r_field"]) { + let loginInfo = TestData.formLogin({ usernameField: testValue }); + Assert.throws(() => Services.logins.addLogin(loginInfo), + /login values can't contain newlines/); + + loginInfo = TestData.formLogin({ passwordField: testValue }); + Assert.throws(() => Services.logins.addLogin(loginInfo), + /login values can't contain newlines/); + } + + // Test a single dot as the value of usernameField and formSubmitURL. + let loginInfo = TestData.formLogin({ usernameField: "." }); + Assert.throws(() => Services.logins.addLogin(loginInfo), + /login values can't be periods/); + + loginInfo = TestData.formLogin({ formSubmitURL: "." }); + Assert.throws(() => Services.logins.addLogin(loginInfo), + /login values can't be periods/); + + // Test the sequence " (" inside the value of the "hostname" property. + loginInfo = TestData.formLogin({ hostname: "http://parens (.example.com" }); + Assert.throws(() => Services.logins.addLogin(loginInfo), + /bad parens in hostname/); +}); + +/** + * Tests legacy validation with setLoginSavingEnabled. + */ +add_task(function test_setLoginSavingEnabled_invalid_characters_legacy() +{ + for (let hostname of ["http://newline\n.example.com", + "http://carriagereturn.example.com\r", + "."]) { + Assert.throws(() => Services.logins.setLoginSavingEnabled(hostname, false), + /Invalid hostname/); + } +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_logins_change.js b/toolkit/components/passwordmgr/test/unit/test_logins_change.js new file mode 100644 index 000000000..79c6d2f54 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_logins_change.js @@ -0,0 +1,384 @@ +/* -*- 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 add, remove, and modify logins. + */ + +"use strict"; + +// Globals + +/** + * Verifies that the specified login is considered invalid by addLogin and by + * modifyLogin with both nsILoginInfo and nsIPropertyBag arguments. + * + * This test requires that the login store is empty. + * + * @param aLoginInfo + * nsILoginInfo corresponding to an invalid login. + * @param aExpectedError + * This argument is passed to the "Assert.throws" test to determine which + * error is expected from the modification functions. + */ +function checkLoginInvalid(aLoginInfo, aExpectedError) +{ + // Try to add the new login, and verify that no data is stored. + Assert.throws(() => Services.logins.addLogin(aLoginInfo), aExpectedError); + LoginTestUtils.checkLogins([]); + + // Add a login for the modification tests. + let testLogin = TestData.formLogin({ hostname: "http://modify.example.com" }); + Services.logins.addLogin(testLogin); + + // Try to modify the existing login using nsILoginInfo and nsIPropertyBag. + Assert.throws(() => Services.logins.modifyLogin(testLogin, aLoginInfo), + aExpectedError); + Assert.throws(() => Services.logins.modifyLogin(testLogin, newPropertyBag({ + hostname: aLoginInfo.hostname, + formSubmitURL: aLoginInfo.formSubmitURL, + httpRealm: aLoginInfo.httpRealm, + username: aLoginInfo.username, + password: aLoginInfo.password, + usernameField: aLoginInfo.usernameField, + passwordField: aLoginInfo.passwordField, + })), aExpectedError); + + // Verify that no data was stored by the previous calls. + LoginTestUtils.checkLogins([testLogin]); + Services.logins.removeLogin(testLogin); +} + +/** + * Verifies that two objects are not the same instance + * but have equal attributes. + * + * @param {Object} objectA + * An object to compare. + * + * @param {Object} objectB + * Another object to compare. + * + * @param {string[]} attributes + * Attributes to compare. + * + * @return true if all passed attributes are equal for both objects, false otherwise. + */ +function compareAttributes(objectA, objectB, attributes) { + // If it's the same object, we want to return false. + if (objectA == objectB) { + return false; + } + return attributes.every(attr => objectA[attr] == objectB[attr]); +} + +// Tests + +/** + * Tests that adding logins to the database works. + */ +add_task(function test_addLogin_removeLogin() +{ + // Each login from the test data should be valid and added to the list. + for (let loginInfo of TestData.loginList()) { + Services.logins.addLogin(loginInfo); + } + LoginTestUtils.checkLogins(TestData.loginList()); + + // Trying to add each login again should result in an error. + for (let loginInfo of TestData.loginList()) { + Assert.throws(() => Services.logins.addLogin(loginInfo), /already exists/); + } + + // Removing each login should succeed. + for (let loginInfo of TestData.loginList()) { + Services.logins.removeLogin(loginInfo); + } + + LoginTestUtils.checkLogins([]); +}); + +/** + * Tests invalid combinations of httpRealm and formSubmitURL. + * + * For an nsILoginInfo to be valid for storage, one of the two properties should + * be strictly equal to null, and the other must not be null or an empty string. + * + * The legacy case of an empty string in formSubmitURL and a null value in + * httpRealm is also supported for storage at the moment. + */ +add_task(function test_invalid_httpRealm_formSubmitURL() +{ + // httpRealm === null, formSubmitURL === null + checkLoginInvalid(TestData.formLogin({ formSubmitURL: null }), + /without a httpRealm or formSubmitURL/); + + // httpRealm === "", formSubmitURL === null + checkLoginInvalid(TestData.authLogin({ httpRealm: "" }), + /without a httpRealm or formSubmitURL/); + + // httpRealm === null, formSubmitURL === "" + // This is not enforced for now. + // checkLoginInvalid(TestData.formLogin({ formSubmitURL: "" }), + // /without a httpRealm or formSubmitURL/); + + // httpRealm === "", formSubmitURL === "" + checkLoginInvalid(TestData.formLogin({ formSubmitURL: "", httpRealm: "" }), + /both a httpRealm and formSubmitURL/); + + // !!httpRealm, !!formSubmitURL + checkLoginInvalid(TestData.formLogin({ httpRealm: "The HTTP Realm" }), + /both a httpRealm and formSubmitURL/); + + // httpRealm === "", !!formSubmitURL + checkLoginInvalid(TestData.formLogin({ httpRealm: "" }), + /both a httpRealm and formSubmitURL/); + + // !!httpRealm, formSubmitURL === "" + checkLoginInvalid(TestData.authLogin({ formSubmitURL: "" }), + /both a httpRealm and formSubmitURL/); +}); + +/** + * Tests null or empty values in required login properties. + */ +add_task(function test_missing_properties() +{ + checkLoginInvalid(TestData.formLogin({ hostname: null }), + /null or empty hostname/); + + checkLoginInvalid(TestData.formLogin({ hostname: "" }), + /null or empty hostname/); + + checkLoginInvalid(TestData.formLogin({ username: null }), + /null username/); + + checkLoginInvalid(TestData.formLogin({ password: null }), + /null or empty password/); + + checkLoginInvalid(TestData.formLogin({ password: "" }), + /null or empty password/); +}); + +/** + * Tests invalid NUL characters in nsILoginInfo properties. + */ +add_task(function test_invalid_characters() +{ + let loginList = [ + TestData.authLogin({ hostname: "http://null\0X.example.com" }), + TestData.authLogin({ httpRealm: "realm\0" }), + TestData.formLogin({ formSubmitURL: "http://null\0X.example.com" }), + TestData.formLogin({ usernameField: "field\0_null" }), + TestData.formLogin({ usernameField: ".\0" }), // Special single dot case + TestData.formLogin({ passwordField: "field\0_null" }), + TestData.formLogin({ username: "user\0name" }), + TestData.formLogin({ password: "pass\0word" }), + ]; + for (let loginInfo of loginList) { + checkLoginInvalid(loginInfo, /login values can't contain nulls/); + } +}); + +/** + * Tests removing a login that does not exists. + */ +add_task(function test_removeLogin_nonexisting() +{ + Assert.throws(() => Services.logins.removeLogin(TestData.formLogin()), + /No matching logins/); +}); + +/** + * Tests removing all logins at once. + */ +add_task(function test_removeAllLogins() +{ + for (let loginInfo of TestData.loginList()) { + Services.logins.addLogin(loginInfo); + } + Services.logins.removeAllLogins(); + LoginTestUtils.checkLogins([]); + + // The function should also work when there are no logins to delete. + Services.logins.removeAllLogins(); +}); + +/** + * Tests the modifyLogin function with an nsILoginInfo argument. + */ +add_task(function test_modifyLogin_nsILoginInfo() +{ + let loginInfo = TestData.formLogin(); + let updatedLoginInfo = TestData.formLogin({ + username: "new username", + password: "new password", + usernameField: "new_form_field_username", + passwordField: "new_form_field_password", + }); + let differentLoginInfo = TestData.authLogin(); + + // Trying to modify a login that does not exist should throw. + Assert.throws(() => Services.logins.modifyLogin(loginInfo, updatedLoginInfo), + /No matching logins/); + + // Add the first form login, then modify it to match the second. + Services.logins.addLogin(loginInfo); + Services.logins.modifyLogin(loginInfo, updatedLoginInfo); + + // The data should now match the second login. + LoginTestUtils.checkLogins([updatedLoginInfo]); + Assert.throws(() => Services.logins.modifyLogin(loginInfo, updatedLoginInfo), + /No matching logins/); + + // The login can be changed to have a different type and hostname. + Services.logins.modifyLogin(updatedLoginInfo, differentLoginInfo); + LoginTestUtils.checkLogins([differentLoginInfo]); + + // It is now possible to add a login with the old type and hostname. + Services.logins.addLogin(loginInfo); + LoginTestUtils.checkLogins([loginInfo, differentLoginInfo]); + + // Modifying a login to match an existing one should not be possible. + Assert.throws( + () => Services.logins.modifyLogin(loginInfo, differentLoginInfo), + /already exists/); + LoginTestUtils.checkLogins([loginInfo, differentLoginInfo]); + + LoginTestUtils.clearData(); +}); + +/** + * Tests the modifyLogin function with an nsIPropertyBag argument. + */ +add_task(function test_modifyLogin_nsIProperyBag() +{ + let loginInfo = TestData.formLogin(); + let updatedLoginInfo = TestData.formLogin({ + username: "new username", + password: "new password", + usernameField: "", + passwordField: "new_form_field_password", + }); + let differentLoginInfo = TestData.authLogin(); + let differentLoginProperties = newPropertyBag({ + hostname: differentLoginInfo.hostname, + formSubmitURL: differentLoginInfo.formSubmitURL, + httpRealm: differentLoginInfo.httpRealm, + username: differentLoginInfo.username, + password: differentLoginInfo.password, + usernameField: differentLoginInfo.usernameField, + passwordField: differentLoginInfo.passwordField, + }); + + // Trying to modify a login that does not exist should throw. + Assert.throws(() => Services.logins.modifyLogin(loginInfo, newPropertyBag()), + /No matching logins/); + + // Add the first form login, then modify it to match the second, changing + // only some of its properties and checking the behavior with an empty string. + Services.logins.addLogin(loginInfo); + Services.logins.modifyLogin(loginInfo, newPropertyBag({ + username: "new username", + password: "new password", + usernameField: "", + passwordField: "new_form_field_password", + })); + + // The data should now match the second login. + LoginTestUtils.checkLogins([updatedLoginInfo]); + Assert.throws(() => Services.logins.modifyLogin(loginInfo, newPropertyBag()), + /No matching logins/); + + // It is also possible to provide no properties to be modified. + Services.logins.modifyLogin(updatedLoginInfo, newPropertyBag()); + + // Specifying a null property for a required value should throw. + Assert.throws(() => Services.logins.modifyLogin(loginInfo, newPropertyBag({ + usernameField: null, + }))); + + // The login can be changed to have a different type and hostname. + Services.logins.modifyLogin(updatedLoginInfo, differentLoginProperties); + LoginTestUtils.checkLogins([differentLoginInfo]); + + // It is now possible to add a login with the old type and hostname. + Services.logins.addLogin(loginInfo); + LoginTestUtils.checkLogins([loginInfo, differentLoginInfo]); + + // Modifying a login to match an existing one should not be possible. + Assert.throws( + () => Services.logins.modifyLogin(loginInfo, differentLoginProperties), + /already exists/); + LoginTestUtils.checkLogins([loginInfo, differentLoginInfo]); + + LoginTestUtils.clearData(); +}); + +/** + * Tests the login deduplication function. + */ +add_task(function test_deduplicate_logins() { + // Different key attributes combinations and the amount of unique + // results expected for the TestData login list. + let keyCombinations = [ + { + keyset: ["username", "password"], + results: 13, + }, + { + keyset: ["hostname", "username"], + results: 17, + }, + { + keyset: ["hostname", "username", "password"], + results: 18, + }, + { + keyset: ["hostname", "username", "password", "formSubmitURL"], + results: 23, + }, + ]; + + let logins = TestData.loginList(); + + for (let testCase of keyCombinations) { + // Deduplicate the logins using the current testcase keyset. + let deduped = LoginHelper.dedupeLogins(logins, testCase.keyset); + Assert.equal(deduped.length, testCase.results, "Correct amount of results."); + + // Checks that every login after deduping is unique. + Assert.ok(deduped.every(loginA => + deduped.every(loginB => !compareAttributes(loginA, loginB, testCase.keyset)) + ), "Every login is unique."); + } +}); + +/** + * Ensure that the login deduplication function keeps the most recent login. + */ +add_task(function test_deduplicate_keeps_most_recent() { + // Logins to deduplicate. + let logins = [ + TestData.formLogin({timeLastUsed: Date.UTC(2004, 11, 4, 0, 0, 0)}), + TestData.formLogin({formSubmitURL: "http://example.com", timeLastUsed: Date.UTC(2015, 11, 4, 0, 0, 0)}), + ]; + + // Deduplicate the logins. + let deduped = LoginHelper.dedupeLogins(logins); + Assert.equal(deduped.length, 1, "Deduplicated the logins array."); + + // Verify that the remaining login have the most recent date. + let loginTimeLastUsed = deduped[0].QueryInterface(Ci.nsILoginMetaInfo).timeLastUsed; + Assert.equal(loginTimeLastUsed, Date.UTC(2015, 11, 4, 0, 0, 0), "Most recent login was kept."); + + // Deduplicate the reverse logins array. + deduped = LoginHelper.dedupeLogins(logins.reverse()); + Assert.equal(deduped.length, 1, "Deduplicated the reversed logins array."); + + // Verify that the remaining login have the most recent date. + loginTimeLastUsed = deduped[0].QueryInterface(Ci.nsILoginMetaInfo).timeLastUsed; + Assert.equal(loginTimeLastUsed, Date.UTC(2015, 11, 4, 0, 0, 0), "Most recent login was kept."); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_logins_decrypt_failure.js b/toolkit/components/passwordmgr/test/unit/test_logins_decrypt_failure.js new file mode 100644 index 000000000..ffbedb4de --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_logins_decrypt_failure.js @@ -0,0 +1,77 @@ +/* -*- 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 the case where there are logins that cannot be decrypted. + */ + +"use strict"; + +// Globals + +/** + * Resets the token used to decrypt logins. This is equivalent to resetting the + * master password when it is not known. + */ +function resetMasterPassword() +{ + let token = Cc["@mozilla.org/security/pk11tokendb;1"] + .getService(Ci.nsIPK11TokenDB).getInternalKeyToken(); + token.reset(); + token.changePassword("", ""); +} + +// Tests + +/** + * Resets the master password after some logins were added to the database. + */ +add_task(function test_logins_decrypt_failure() +{ + let logins = TestData.loginList(); + for (let loginInfo of logins) { + Services.logins.addLogin(loginInfo); + } + + // This makes the existing logins non-decryptable. + resetMasterPassword(); + + // These functions don't see the non-decryptable entries anymore. + do_check_eq(Services.logins.getAllLogins().length, 0); + do_check_eq(Services.logins.findLogins({}, "", "", "").length, 0); + do_check_eq(Services.logins.searchLogins({}, newPropertyBag()).length, 0); + Assert.throws(() => Services.logins.modifyLogin(logins[0], newPropertyBag()), + /No matching logins/); + Assert.throws(() => Services.logins.removeLogin(logins[0]), + /No matching logins/); + + // The function that counts logins sees the non-decryptable entries also. + do_check_eq(Services.logins.countLogins("", "", ""), logins.length); + + // Equivalent logins can be added. + for (let loginInfo of logins) { + Services.logins.addLogin(loginInfo); + } + LoginTestUtils.checkLogins(logins); + do_check_eq(Services.logins.countLogins("", "", ""), logins.length * 2); + + // Finding logins doesn't return the non-decryptable duplicates. + do_check_eq(Services.logins.findLogins({}, "http://www.example.com", + "", "").length, 1); + let matchData = newPropertyBag({ hostname: "http://www.example.com" }); + do_check_eq(Services.logins.searchLogins({}, matchData).length, 1); + + // Removing single logins does not remove non-decryptable logins. + for (let loginInfo of TestData.loginList()) { + Services.logins.removeLogin(loginInfo); + } + do_check_eq(Services.logins.getAllLogins().length, 0); + do_check_eq(Services.logins.countLogins("", "", ""), logins.length); + + // Removing all logins removes the non-decryptable entries also. + Services.logins.removeAllLogins(); + do_check_eq(Services.logins.getAllLogins().length, 0); + do_check_eq(Services.logins.countLogins("", "", ""), 0); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_logins_metainfo.js b/toolkit/components/passwordmgr/test/unit/test_logins_metainfo.js new file mode 100644 index 000000000..38344aa7d --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_logins_metainfo.js @@ -0,0 +1,284 @@ +/* -*- 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 the handling of nsILoginMetaInfo by methods that add, remove, modify, + * and find logins. + */ + +"use strict"; + +// Globals + +XPCOMUtils.defineLazyServiceGetter(this, "gUUIDGenerator", + "@mozilla.org/uuid-generator;1", + "nsIUUIDGenerator"); + +var gLooksLikeUUIDRegex = /^\{\w{8}-\w{4}-\w{4}-\w{4}-\w{12}\}$/; + +/** + * Retrieves the only login among the current data that matches the hostname of + * the given nsILoginInfo. In case there is more than one login for the + * hostname, the test fails. + */ +function retrieveLoginMatching(aLoginInfo) +{ + let logins = Services.logins.findLogins({}, aLoginInfo.hostname, "", ""); + do_check_eq(logins.length, 1); + return logins[0].QueryInterface(Ci.nsILoginMetaInfo); +} + +/** + * Checks that the nsILoginInfo and nsILoginMetaInfo properties of two different + * login instances are equal. + */ +function assertMetaInfoEqual(aActual, aExpected) +{ + do_check_neq(aActual, aExpected); + + // Check the nsILoginInfo properties. + do_check_true(aActual.equals(aExpected)); + + // Check the nsILoginMetaInfo properties. + do_check_eq(aActual.guid, aExpected.guid); + do_check_eq(aActual.timeCreated, aExpected.timeCreated); + do_check_eq(aActual.timeLastUsed, aExpected.timeLastUsed); + do_check_eq(aActual.timePasswordChanged, aExpected.timePasswordChanged); + do_check_eq(aActual.timesUsed, aExpected.timesUsed); +} + +/** + * nsILoginInfo instances with or without nsILoginMetaInfo properties. + */ +var gLoginInfo1; +var gLoginInfo2; +var gLoginInfo3; + +/** + * nsILoginInfo instances reloaded with all the nsILoginMetaInfo properties. + * These are often used to provide the reference values to test against. + */ +var gLoginMetaInfo1; +var gLoginMetaInfo2; +var gLoginMetaInfo3; + +// Tests + +/** + * Prepare the test objects that will be used by the following tests. + */ +add_task(function test_initialize() +{ + // Use a reference time from ten minutes ago to initialize one instance of + // nsILoginMetaInfo, to test that reference times are updated when needed. + let baseTimeMs = Date.now() - 600000; + + gLoginInfo1 = TestData.formLogin(); + gLoginInfo2 = TestData.formLogin({ + hostname: "http://other.example.com", + guid: gUUIDGenerator.generateUUID().toString(), + timeCreated: baseTimeMs, + timeLastUsed: baseTimeMs + 2, + timePasswordChanged: baseTimeMs + 1, + timesUsed: 2, + }); + gLoginInfo3 = TestData.authLogin(); +}); + +/** + * Tests the behavior of addLogin with regard to metadata. The logins added + * here are also used by the following tests. + */ +add_task(function test_addLogin_metainfo() +{ + // Add a login without metadata to the database. + Services.logins.addLogin(gLoginInfo1); + + // The object provided to addLogin should not have been modified. + do_check_eq(gLoginInfo1.guid, null); + do_check_eq(gLoginInfo1.timeCreated, 0); + do_check_eq(gLoginInfo1.timeLastUsed, 0); + do_check_eq(gLoginInfo1.timePasswordChanged, 0); + do_check_eq(gLoginInfo1.timesUsed, 0); + + // A login with valid metadata should have been stored. + gLoginMetaInfo1 = retrieveLoginMatching(gLoginInfo1); + do_check_true(gLooksLikeUUIDRegex.test(gLoginMetaInfo1.guid)); + let creationTime = gLoginMetaInfo1.timeCreated; + LoginTestUtils.assertTimeIsAboutNow(creationTime); + do_check_eq(gLoginMetaInfo1.timeLastUsed, creationTime); + do_check_eq(gLoginMetaInfo1.timePasswordChanged, creationTime); + do_check_eq(gLoginMetaInfo1.timesUsed, 1); + + // Add a login without metadata to the database. + let originalLogin = gLoginInfo2.clone().QueryInterface(Ci.nsILoginMetaInfo); + Services.logins.addLogin(gLoginInfo2); + + // The object provided to addLogin should not have been modified. + assertMetaInfoEqual(gLoginInfo2, originalLogin); + + // A login with the provided metadata should have been stored. + gLoginMetaInfo2 = retrieveLoginMatching(gLoginInfo2); + assertMetaInfoEqual(gLoginMetaInfo2, gLoginInfo2); + + // Add an authentication login to the database before continuing. + Services.logins.addLogin(gLoginInfo3); + gLoginMetaInfo3 = retrieveLoginMatching(gLoginInfo3); + LoginTestUtils.checkLogins([gLoginInfo1, gLoginInfo2, gLoginInfo3]); +}); + +/** + * Tests that adding a login with a duplicate GUID throws an exception. + */ +add_task(function test_addLogin_metainfo_duplicate() +{ + let loginInfo = TestData.formLogin({ + hostname: "http://duplicate.example.com", + guid: gLoginMetaInfo2.guid, + }); + Assert.throws(() => Services.logins.addLogin(loginInfo), + /specified GUID already exists/); + + // Verify that no data was stored by the previous call. + LoginTestUtils.checkLogins([gLoginInfo1, gLoginInfo2, gLoginInfo3]); +}); + +/** + * Tests that the existing metadata is not changed when modifyLogin is called + * with an nsILoginInfo argument. + */ +add_task(function test_modifyLogin_nsILoginInfo_metainfo_ignored() +{ + let newLoginInfo = gLoginInfo1.clone().QueryInterface(Ci.nsILoginMetaInfo); + newLoginInfo.guid = gUUIDGenerator.generateUUID().toString(); + newLoginInfo.timeCreated = Date.now(); + newLoginInfo.timeLastUsed = Date.now(); + newLoginInfo.timePasswordChanged = Date.now(); + newLoginInfo.timesUsed = 12; + Services.logins.modifyLogin(gLoginInfo1, newLoginInfo); + + newLoginInfo = retrieveLoginMatching(gLoginInfo1); + assertMetaInfoEqual(newLoginInfo, gLoginMetaInfo1); +}); + +/** + * Tests the modifyLogin function with an nsIProperyBag argument. + */ +add_task(function test_modifyLogin_nsIProperyBag_metainfo() +{ + // Use a new reference time that is two minutes from now. + let newTimeMs = Date.now() + 120000; + let newUUIDValue = gUUIDGenerator.generateUUID().toString(); + + // Check that properties are changed as requested. + Services.logins.modifyLogin(gLoginInfo1, newPropertyBag({ + guid: newUUIDValue, + timeCreated: newTimeMs, + timeLastUsed: newTimeMs + 2, + timePasswordChanged: newTimeMs + 1, + timesUsed: 2, + })); + + gLoginMetaInfo1 = retrieveLoginMatching(gLoginInfo1); + do_check_eq(gLoginMetaInfo1.guid, newUUIDValue); + do_check_eq(gLoginMetaInfo1.timeCreated, newTimeMs); + do_check_eq(gLoginMetaInfo1.timeLastUsed, newTimeMs + 2); + do_check_eq(gLoginMetaInfo1.timePasswordChanged, newTimeMs + 1); + do_check_eq(gLoginMetaInfo1.timesUsed, 2); + + // Check that timePasswordChanged is updated when changing the password. + let originalLogin = gLoginInfo2.clone().QueryInterface(Ci.nsILoginMetaInfo); + Services.logins.modifyLogin(gLoginInfo2, newPropertyBag({ + password: "new password", + })); + gLoginInfo2.password = "new password"; + + gLoginMetaInfo2 = retrieveLoginMatching(gLoginInfo2); + do_check_eq(gLoginMetaInfo2.password, gLoginInfo2.password); + do_check_eq(gLoginMetaInfo2.timeCreated, originalLogin.timeCreated); + do_check_eq(gLoginMetaInfo2.timeLastUsed, originalLogin.timeLastUsed); + LoginTestUtils.assertTimeIsAboutNow(gLoginMetaInfo2.timePasswordChanged); + + // Check that timePasswordChanged is not set to the current time when changing + // the password and specifying a new value for the property at the same time. + Services.logins.modifyLogin(gLoginInfo2, newPropertyBag({ + password: "other password", + timePasswordChanged: newTimeMs, + })); + gLoginInfo2.password = "other password"; + + gLoginMetaInfo2 = retrieveLoginMatching(gLoginInfo2); + do_check_eq(gLoginMetaInfo2.password, gLoginInfo2.password); + do_check_eq(gLoginMetaInfo2.timeCreated, originalLogin.timeCreated); + do_check_eq(gLoginMetaInfo2.timeLastUsed, originalLogin.timeLastUsed); + do_check_eq(gLoginMetaInfo2.timePasswordChanged, newTimeMs); + + // Check the special timesUsedIncrement property. + Services.logins.modifyLogin(gLoginInfo2, newPropertyBag({ + timesUsedIncrement: 2, + })); + + gLoginMetaInfo2 = retrieveLoginMatching(gLoginInfo2); + do_check_eq(gLoginMetaInfo2.timeCreated, originalLogin.timeCreated); + do_check_eq(gLoginMetaInfo2.timeLastUsed, originalLogin.timeLastUsed); + do_check_eq(gLoginMetaInfo2.timePasswordChanged, newTimeMs); + do_check_eq(gLoginMetaInfo2.timesUsed, 4); +}); + +/** + * Tests that modifying a login to a duplicate GUID throws an exception. + */ +add_task(function test_modifyLogin_nsIProperyBag_metainfo_duplicate() +{ + Assert.throws(() => Services.logins.modifyLogin(gLoginInfo1, newPropertyBag({ + guid: gLoginInfo2.guid, + })), /specified GUID already exists/); + LoginTestUtils.checkLogins([gLoginInfo1, gLoginInfo2, gLoginInfo3]); +}); + +/** + * Tests searching logins using nsILoginMetaInfo properties. + */ +add_task(function test_searchLogins_metainfo() +{ + // Find by GUID. + let logins = Services.logins.searchLogins({}, newPropertyBag({ + guid: gLoginMetaInfo1.guid, + })); + do_check_eq(logins.length, 1); + let foundLogin = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + assertMetaInfoEqual(foundLogin, gLoginMetaInfo1); + + // Find by timestamp. + logins = Services.logins.searchLogins({}, newPropertyBag({ + timePasswordChanged: gLoginMetaInfo2.timePasswordChanged, + })); + do_check_eq(logins.length, 1); + foundLogin = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + assertMetaInfoEqual(foundLogin, gLoginMetaInfo2); + + // Find using two properties at the same time. + logins = Services.logins.searchLogins({}, newPropertyBag({ + guid: gLoginMetaInfo3.guid, + timePasswordChanged: gLoginMetaInfo3.timePasswordChanged, + })); + do_check_eq(logins.length, 1); + foundLogin = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + assertMetaInfoEqual(foundLogin, gLoginMetaInfo3); +}); + +/** + * Tests that the default nsILoginManagerStorage module attached to the Login + * Manager service is able to save and reload nsILoginMetaInfo properties. + */ +add_task(function* test_storage_metainfo() +{ + yield* LoginTestUtils.reloadData(); + LoginTestUtils.checkLogins([gLoginInfo1, gLoginInfo2, gLoginInfo3]); + + assertMetaInfoEqual(retrieveLoginMatching(gLoginInfo1), gLoginMetaInfo1); + assertMetaInfoEqual(retrieveLoginMatching(gLoginInfo2), gLoginMetaInfo2); + assertMetaInfoEqual(retrieveLoginMatching(gLoginInfo3), gLoginMetaInfo3); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_logins_search.js b/toolkit/components/passwordmgr/test/unit/test_logins_search.js new file mode 100644 index 000000000..188c75039 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_logins_search.js @@ -0,0 +1,221 @@ +/* + * 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"; + +// Globals + +/** + * Returns a list of new nsILoginInfo objects that are a subset of the test + * data, built to match the specified query. + * + * @param 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 => entry[name] === aQuery[name])); +} + +/** + * Tests the searchLogins function. + * + * @param aQuery + * Each property and value of this object is translated to an entry in + * the nsIPropertyBag parameter of searchLogins. + * @param 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 checkSearchLogins(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); +} + +/** + * Tests findLogins, searchLogins, and countLogins with the same query. + * + * @param aQuery + * The "hostname", "formSubmitURL", and "httpRealm" properties of this + * object are passed as parameters to findLogins and countLogins. The + * same object is then passed to the checkSearchLogins function. + * @param 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 checkAllSearches(aQuery, aExpectedCount) +{ + do_print("Testing all search functions for " + JSON.stringify(aQuery)); + + let expectedLogins = buildExpectedLogins(aQuery); + do_check_eq(expectedLogins.length, aExpectedCount); + + // The findLogins and countLogins functions support wildcard matches by + // specifying empty strings as parameters, while searchLogins requires + // omitting the property entirely. + let hostname = ("hostname" in aQuery) ? aQuery.hostname : ""; + let formSubmitURL = ("formSubmitURL" in aQuery) ? aQuery.formSubmitURL : ""; + let httpRealm = ("httpRealm" in aQuery) ? aQuery.httpRealm : ""; + + // Test findLogins. + let outCount = {}; + let logins = Services.logins.findLogins(outCount, hostname, formSubmitURL, + httpRealm); + do_check_eq(outCount.value, expectedLogins.length); + LoginTestUtils.assertLoginListsEqual(logins, expectedLogins); + + // Test countLogins. + let count = Services.logins.countLogins(hostname, formSubmitURL, httpRealm); + do_check_eq(count, expectedLogins.length); + + // Test searchLogins. + checkSearchLogins(aQuery, aExpectedCount); +} + +// Tests + +/** + * Prepare data for the following tests. + */ +add_task(function test_initialize() +{ + for (let login of TestData.loginList()) { + Services.logins.addLogin(login); + } +}); + +/** + * Tests findLogins, searchLogins, and countLogins with basic queries. + */ +add_task(function test_search_all_basic() +{ + // Find all logins, using no filters in the search functions. + checkAllSearches({}, 23); + + // Find all form logins, then all authentication logins. + checkAllSearches({ httpRealm: null }, 14); + checkAllSearches({ formSubmitURL: null }, 9); + + // Find all form logins on one host, then all authentication logins. + checkAllSearches({ hostname: "http://www4.example.com", + httpRealm: null }, 3); + checkAllSearches({ hostname: "http://www2.example.org", + formSubmitURL: null }, 2); + + // Verify that scheme and subdomain are distinct in the hostname. + checkAllSearches({ hostname: "http://www.example.com" }, 1); + checkAllSearches({ hostname: "https://www.example.com" }, 1); + checkAllSearches({ hostname: "https://example.com" }, 1); + checkAllSearches({ hostname: "http://www3.example.com" }, 3); + + // Verify that scheme and subdomain are distinct in formSubmitURL. + checkAllSearches({ formSubmitURL: "http://www.example.com" }, 2); + checkAllSearches({ formSubmitURL: "https://www.example.com" }, 2); + checkAllSearches({ formSubmitURL: "http://example.com" }, 1); + + // Find by formSubmitURL on a single host. + checkAllSearches({ hostname: "http://www3.example.com", + formSubmitURL: "http://www.example.com" }, 1); + checkAllSearches({ hostname: "http://www3.example.com", + formSubmitURL: "https://www.example.com" }, 1); + checkAllSearches({ hostname: "http://www3.example.com", + formSubmitURL: "http://example.com" }, 1); + + // Find by httpRealm on all hosts. + checkAllSearches({ httpRealm: "The HTTP Realm" }, 3); + checkAllSearches({ httpRealm: "ftp://ftp.example.org" }, 1); + checkAllSearches({ httpRealm: "The HTTP Realm Other" }, 2); + + // Find by httpRealm on a single host. + checkAllSearches({ hostname: "http://example.net", + httpRealm: "The HTTP Realm" }, 1); + checkAllSearches({ hostname: "http://example.net", + httpRealm: "The HTTP Realm Other" }, 1); + checkAllSearches({ hostname: "ftp://example.net", + httpRealm: "ftp://example.net" }, 1); +}); + +/** + * Tests searchLogins with advanced queries. + */ +add_task(function test_searchLogins() +{ + checkSearchLogins({ usernameField: "form_field_username" }, 12); + checkSearchLogins({ passwordField: "form_field_password" }, 13); + + // Find all logins with an empty usernameField, including for authentication. + checkSearchLogins({ usernameField: "" }, 11); + + // Find form logins with an empty usernameField. + checkSearchLogins({ httpRealm: null, + usernameField: "" }, 2); + + // Find logins with an empty usernameField on one host. + checkSearchLogins({ hostname: "http://www6.example.com", + usernameField: "" }, 1); +}); + +/** + * Tests searchLogins with invalid arguments. + */ +add_task(function test_searchLogins_invalid() +{ + Assert.throws(() => Services.logins.searchLogins({}, + newPropertyBag({ username: "value" })), + /Unexpected field/); +}); + +/** + * Tests that matches are case-sensitive, compare the full field value, and are + * strict when interpreting the prePath of URIs. + */ +add_task(function test_search_all_full_case_sensitive() +{ + checkAllSearches({ hostname: "http://www.example.com" }, 1); + checkAllSearches({ hostname: "http://www.example.com/" }, 0); + checkAllSearches({ hostname: "http://" }, 0); + checkAllSearches({ hostname: "example.com" }, 0); + + checkAllSearches({ formSubmitURL: "http://www.example.com" }, 2); + checkAllSearches({ formSubmitURL: "http://www.example.com/" }, 0); + checkAllSearches({ formSubmitURL: "http://" }, 0); + checkAllSearches({ formSubmitURL: "example.com" }, 0); + + checkAllSearches({ httpRealm: "The HTTP Realm" }, 3); + checkAllSearches({ httpRealm: "The http Realm" }, 0); + checkAllSearches({ httpRealm: "The HTTP" }, 0); + checkAllSearches({ httpRealm: "Realm" }, 0); +}); + +/** + * Tests findLogins, searchLogins, and countLogins with queries that should + * return no values. + */ +add_task(function test_search_all_empty() +{ + checkAllSearches({ hostname: "http://nonexistent.example.com" }, 0); + checkAllSearches({ formSubmitURL: "http://www.example.com", + httpRealm: "The HTTP Realm" }, 0); + + checkSearchLogins({ hostname: "" }, 0); + checkSearchLogins({ id: "1000" }, 0); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_maybeImportLogin.js b/toolkit/components/passwordmgr/test/unit/test_maybeImportLogin.js new file mode 100644 index 000000000..19175df59 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_maybeImportLogin.js @@ -0,0 +1,169 @@ +"use strict"; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/LoginHelper.jsm"); + +const HOST1 = "https://www.example.com/"; +const HOST2 = "https://www.mozilla.org/"; + +const USER1 = "myuser"; +const USER2 = "anotheruser"; + +const PASS1 = "mypass"; +const PASS2 = "anotherpass"; +const PASS3 = "yetanotherpass"; + +add_task(function test_new_logins() { + let importedLogin = LoginHelper.maybeImportLogin({ + username: USER1, + password: PASS1, + hostname: HOST1, + formSubmitURL: HOST1, + }); + Assert.ok(importedLogin, "Return value should indicate imported login."); + let matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST1}); + Assert.equal(matchingLogins.length, 1, `There should be 1 login for ${HOST1}`); + + importedLogin = LoginHelper.maybeImportLogin({ + username: USER1, + password: PASS1, + hostname: HOST2, + formSubmitURL: HOST2, + }); + + Assert.ok(importedLogin, "Return value should indicate another imported login."); + matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST1}); + Assert.equal(matchingLogins.length, 1, `There should still be 1 login for ${HOST1}`); + + matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST2}); + Assert.equal(matchingLogins.length, 1, `There should also be 1 login for ${HOST2}`); + Assert.equal(Services.logins.getAllLogins().length, 2, "There should be 2 logins in total"); + Services.logins.removeAllLogins(); +}); + +add_task(function test_duplicate_logins() { + let importedLogin = LoginHelper.maybeImportLogin({ + username: USER1, + password: PASS1, + hostname: HOST1, + formSubmitURL: HOST1, + }); + Assert.ok(importedLogin, "Return value should indicate imported login."); + let matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST1}); + Assert.equal(matchingLogins.length, 1, `There should be 1 login for ${HOST1}`); + + importedLogin = LoginHelper.maybeImportLogin({ + username: USER1, + password: PASS1, + hostname: HOST1, + formSubmitURL: HOST1, + }); + Assert.ok(!importedLogin, "Return value should indicate no new login was imported."); + matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST1}); + Assert.equal(matchingLogins.length, 1, `There should still be 1 login for ${HOST1}`); + Services.logins.removeAllLogins(); +}); + +add_task(function test_different_passwords() { + let importedLogin = LoginHelper.maybeImportLogin({ + username: USER1, + password: PASS1, + hostname: HOST1, + formSubmitURL: HOST1, + timeCreated: new Date(Date.now() - 1000), + }); + Assert.ok(importedLogin, "Return value should indicate imported login."); + let matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST1}); + Assert.equal(matchingLogins.length, 1, `There should be 1 login for ${HOST1}`); + + // This item will be newer, so its password should take precedence. + importedLogin = LoginHelper.maybeImportLogin({ + username: USER1, + password: PASS2, + hostname: HOST1, + formSubmitURL: HOST1, + timeCreated: new Date(), + }); + Assert.ok(!importedLogin, "Return value should not indicate imported login (as we updated an existing one)."); + matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST1}); + Assert.equal(matchingLogins.length, 1, `There should still be 1 login for ${HOST1}`); + Assert.equal(matchingLogins[0].password, PASS2, "We should have updated the password for this login."); + + // Now try to update with an older password: + importedLogin = LoginHelper.maybeImportLogin({ + username: USER1, + password: PASS3, + hostname: HOST1, + formSubmitURL: HOST1, + timeCreated: new Date(Date.now() - 1000000), + }); + Assert.ok(!importedLogin, "Return value should not indicate imported login (as we didn't update anything)."); + matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST1}); + Assert.equal(matchingLogins.length, 1, `There should still be 1 login for ${HOST1}`); + Assert.equal(matchingLogins[0].password, PASS2, "We should NOT have updated the password for this login."); + + Services.logins.removeAllLogins(); +}); + +add_task(function test_different_usernames() { + let importedLogin = LoginHelper.maybeImportLogin({ + username: USER1, + password: PASS1, + hostname: HOST1, + formSubmitURL: HOST1, + }); + Assert.ok(importedLogin, "Return value should indicate imported login."); + let matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST1}); + Assert.equal(matchingLogins.length, 1, `There should be 1 login for ${HOST1}`); + + importedLogin = LoginHelper.maybeImportLogin({ + username: USER2, + password: PASS1, + hostname: HOST1, + formSubmitURL: HOST1, + }); + Assert.ok(importedLogin, "Return value should indicate another imported login."); + matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST1}); + Assert.equal(matchingLogins.length, 2, `There should now be 2 logins for ${HOST1}`); + + Services.logins.removeAllLogins(); +}); + +add_task(function test_different_targets() { + let importedLogin = LoginHelper.maybeImportLogin({ + username: USER1, + password: PASS1, + hostname: HOST1, + formSubmitURL: HOST1, + }); + Assert.ok(importedLogin, "Return value should indicate imported login."); + let matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST1}); + Assert.equal(matchingLogins.length, 1, `There should be 1 login for ${HOST1}`); + + // Not passing either a formSubmitURL or a httpRealm should be treated as + // the same as the previous login + importedLogin = LoginHelper.maybeImportLogin({ + username: USER1, + password: PASS1, + hostname: HOST1, + }); + Assert.ok(!importedLogin, "Return value should NOT indicate imported login " + + "(because a missing formSubmitURL and httpRealm should be duped to the existing login)."); + matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST1}); + Assert.equal(matchingLogins.length, 1, `There should still be 1 login for ${HOST1}`); + Assert.equal(matchingLogins[0].formSubmitURL, HOST1, "The form submission URL should have been kept."); + + importedLogin = LoginHelper.maybeImportLogin({ + username: USER1, + password: PASS1, + hostname: HOST1, + httpRealm: HOST1, + }); + Assert.ok(importedLogin, "Return value should indicate another imported login " + + "as an httpRealm login shouldn't be duped."); + matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST1}); + Assert.equal(matchingLogins.length, 2, `There should now be 2 logins for ${HOST1}`); + + Services.logins.removeAllLogins(); +}); + diff --git a/toolkit/components/passwordmgr/test/unit/test_module_LoginImport.js b/toolkit/components/passwordmgr/test/unit/test_module_LoginImport.js new file mode 100644 index 000000000..b8793e1bd --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_module_LoginImport.js @@ -0,0 +1,243 @@ +/* -*- 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 the LoginImport object. + */ + +"use strict"; + +// Globals + +Cu.import("resource://gre/modules/Task.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper", + "resource://gre/modules/LoginHelper.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "LoginImport", + "resource://gre/modules/LoginImport.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "LoginStore", + "resource://gre/modules/LoginStore.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Sqlite", + "resource://gre/modules/Sqlite.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "gLoginManagerCrypto", + "@mozilla.org/login-manager/crypto/SDR;1", + "nsILoginManagerCrypto"); +XPCOMUtils.defineLazyServiceGetter(this, "gUUIDGenerator", + "@mozilla.org/uuid-generator;1", + "nsIUUIDGenerator"); + +/** + * Creates empty login data tables in the given SQLite connection, resembling + * the most recent schema version (excluding indices). + */ +function promiseCreateDatabaseSchema(aConnection) +{ + return Task.spawn(function* () { + yield aConnection.setSchemaVersion(5); + yield aConnection.execute("CREATE TABLE moz_logins (" + + "id INTEGER PRIMARY KEY," + + "hostname TEXT NOT NULL," + + "httpRealm TEXT," + + "formSubmitURL TEXT," + + "usernameField TEXT NOT NULL," + + "passwordField TEXT NOT NULL," + + "encryptedUsername TEXT NOT NULL," + + "encryptedPassword TEXT NOT NULL," + + "guid TEXT," + + "encType INTEGER," + + "timeCreated INTEGER," + + "timeLastUsed INTEGER," + + "timePasswordChanged INTEGER," + + "timesUsed INTEGER)"); + yield aConnection.execute("CREATE TABLE moz_disabledHosts (" + + "id INTEGER PRIMARY KEY," + + "hostname TEXT UNIQUE)"); + yield aConnection.execute("CREATE TABLE moz_deleted_logins (" + + "id INTEGER PRIMARY KEY," + + "guid TEXT," + + "timeDeleted INTEGER)"); + }); +} + +/** + * Inserts a new entry in the database resembling the given nsILoginInfo object. + */ +function promiseInsertLoginInfo(aConnection, aLoginInfo) +{ + aLoginInfo.QueryInterface(Ci.nsILoginMetaInfo); + + // We can't use the aLoginInfo object directly in the execute statement + // because the bind code in Sqlite.jsm doesn't allow objects with extra + // properties beyond those being binded. So we might as well use an array as + // it is simpler. + let values = [ + aLoginInfo.hostname, + aLoginInfo.httpRealm, + aLoginInfo.formSubmitURL, + aLoginInfo.usernameField, + aLoginInfo.passwordField, + gLoginManagerCrypto.encrypt(aLoginInfo.username), + gLoginManagerCrypto.encrypt(aLoginInfo.password), + aLoginInfo.guid, + aLoginInfo.encType, + aLoginInfo.timeCreated, + aLoginInfo.timeLastUsed, + aLoginInfo.timePasswordChanged, + aLoginInfo.timesUsed, + ]; + + return aConnection.execute("INSERT INTO moz_logins (hostname, " + + "httpRealm, formSubmitURL, usernameField, " + + "passwordField, encryptedUsername, " + + "encryptedPassword, guid, encType, timeCreated, " + + "timeLastUsed, timePasswordChanged, timesUsed) " + + "VALUES (?" + ",?".repeat(12) + ")", values); +} + +/** + * Inserts a new disabled host entry in the database. + */ +function promiseInsertDisabledHost(aConnection, aHostname) +{ + return aConnection.execute("INSERT INTO moz_disabledHosts (hostname) " + + "VALUES (?)", [aHostname]); +} + +// Tests + +/** + * Imports login data from a SQLite file constructed using the test data. + */ +add_task(function* test_import() +{ + let store = new LoginStore(getTempFile("test-import.json").path); + let loginsSqlite = getTempFile("test-logins.sqlite").path; + + // Prepare the logins to be imported, including the nsILoginMetaInfo data. + let loginList = TestData.loginList(); + for (let loginInfo of loginList) { + loginInfo.QueryInterface(Ci.nsILoginMetaInfo); + loginInfo.guid = gUUIDGenerator.generateUUID().toString(); + loginInfo.timeCreated = Date.now(); + loginInfo.timeLastUsed = Date.now(); + loginInfo.timePasswordChanged = Date.now(); + loginInfo.timesUsed = 1; + } + + // Create and populate the SQLite database first. + let connection = yield Sqlite.openConnection({ path: loginsSqlite }); + try { + yield promiseCreateDatabaseSchema(connection); + for (let loginInfo of loginList) { + yield promiseInsertLoginInfo(connection, loginInfo); + } + yield promiseInsertDisabledHost(connection, "http://www.example.com"); + yield promiseInsertDisabledHost(connection, "https://www.example.org"); + } finally { + yield connection.close(); + } + + // The "load" method must be called before importing data. + yield store.load(); + yield new LoginImport(store, loginsSqlite).import(); + + // Verify that every login in the test data has a matching imported row. + do_check_eq(loginList.length, store.data.logins.length); + do_check_true(loginList.every(function (loginInfo) { + return store.data.logins.some(function (loginDataItem) { + let username = gLoginManagerCrypto.decrypt(loginDataItem.encryptedUsername); + let password = gLoginManagerCrypto.decrypt(loginDataItem.encryptedPassword); + return loginDataItem.hostname == loginInfo.hostname && + loginDataItem.httpRealm == loginInfo.httpRealm && + loginDataItem.formSubmitURL == loginInfo.formSubmitURL && + loginDataItem.usernameField == loginInfo.usernameField && + loginDataItem.passwordField == loginInfo.passwordField && + username == loginInfo.username && + password == loginInfo.password && + loginDataItem.guid == loginInfo.guid && + loginDataItem.encType == loginInfo.encType && + loginDataItem.timeCreated == loginInfo.timeCreated && + loginDataItem.timeLastUsed == loginInfo.timeLastUsed && + loginDataItem.timePasswordChanged == loginInfo.timePasswordChanged && + loginDataItem.timesUsed == loginInfo.timesUsed; + }); + })); + + // Verify that disabled hosts have been imported. + do_check_eq(store.data.disabledHosts.length, 2); + do_check_true(store.data.disabledHosts.indexOf("http://www.example.com") != -1); + do_check_true(store.data.disabledHosts.indexOf("https://www.example.org") != -1); +}); + +/** + * Tests imports of NULL values due to a downgraded database. + */ +add_task(function* test_import_downgraded() +{ + let store = new LoginStore(getTempFile("test-import-downgraded.json").path); + let loginsSqlite = getTempFile("test-logins-downgraded.sqlite").path; + + // Create and populate the SQLite database first. + let connection = yield Sqlite.openConnection({ path: loginsSqlite }); + try { + yield promiseCreateDatabaseSchema(connection); + yield connection.setSchemaVersion(3); + yield promiseInsertLoginInfo(connection, TestData.formLogin({ + guid: gUUIDGenerator.generateUUID().toString(), + timeCreated: null, + timeLastUsed: null, + timePasswordChanged: null, + timesUsed: 0, + })); + } finally { + yield connection.close(); + } + + // The "load" method must be called before importing data. + yield store.load(); + yield new LoginImport(store, loginsSqlite).import(); + + // Verify that the missing metadata was generated correctly. + let loginItem = store.data.logins[0]; + let creationTime = loginItem.timeCreated; + LoginTestUtils.assertTimeIsAboutNow(creationTime); + do_check_eq(loginItem.timeLastUsed, creationTime); + do_check_eq(loginItem.timePasswordChanged, creationTime); + do_check_eq(loginItem.timesUsed, 1); +}); + +/** + * Verifies that importing from a SQLite file with database version 2 fails. + */ +add_task(function* test_import_v2() +{ + let store = new LoginStore(getTempFile("test-import-v2.json").path); + let loginsSqlite = do_get_file("data/signons-v2.sqlite").path; + + // The "load" method must be called before importing data. + yield store.load(); + try { + yield new LoginImport(store, loginsSqlite).import(); + do_throw("The operation should have failed."); + } catch (ex) { } +}); + +/** + * Imports login data from a SQLite file, with database version 3. + */ +add_task(function* test_import_v3() +{ + let store = new LoginStore(getTempFile("test-import-v3.json").path); + let loginsSqlite = do_get_file("data/signons-v3.sqlite").path; + + // The "load" method must be called before importing data. + yield store.load(); + yield new LoginImport(store, loginsSqlite).import(); + + // We only execute basic integrity checks. + do_check_eq(store.data.logins[0].usernameField, "u1"); + do_check_eq(store.data.disabledHosts.length, 0); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_module_LoginStore.js b/toolkit/components/passwordmgr/test/unit/test_module_LoginStore.js new file mode 100644 index 000000000..335eb601b --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_module_LoginStore.js @@ -0,0 +1,206 @@ +/* -*- 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 the LoginStore object. + */ + +"use strict"; + +// Globals + +XPCOMUtils.defineLazyModuleGetter(this, "LoginStore", + "resource://gre/modules/LoginStore.jsm"); + +const TEST_STORE_FILE_NAME = "test-logins.json"; + +// Tests + +/** + * Saves login data to a file, then reloads it. + */ +add_task(function* test_save_reload() +{ + let storeForSave = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path); + + // The "load" method must be called before preparing the data to be saved. + yield storeForSave.load(); + + let rawLoginData = { + id: storeForSave.data.nextId++, + hostname: "http://www.example.com", + httpRealm: null, + formSubmitURL: "http://www.example.com/submit-url", + usernameField: "field_" + String.fromCharCode(533, 537, 7570, 345), + passwordField: "field_" + String.fromCharCode(421, 259, 349, 537), + encryptedUsername: "(test)", + encryptedPassword: "(test)", + guid: "(test)", + encType: Ci.nsILoginManagerCrypto.ENCTYPE_SDR, + timeCreated: Date.now(), + timeLastUsed: Date.now(), + timePasswordChanged: Date.now(), + timesUsed: 1, + }; + storeForSave.data.logins.push(rawLoginData); + + storeForSave.data.disabledHosts.push("http://www.example.org"); + + yield storeForSave._save(); + + // Test the asynchronous initialization path. + let storeForLoad = new LoginStore(storeForSave.path); + yield storeForLoad.load(); + + do_check_eq(storeForLoad.data.logins.length, 1); + do_check_matches(storeForLoad.data.logins[0], rawLoginData); + do_check_eq(storeForLoad.data.disabledHosts.length, 1); + do_check_eq(storeForLoad.data.disabledHosts[0], "http://www.example.org"); + + // Test the synchronous initialization path. + storeForLoad = new LoginStore(storeForSave.path); + storeForLoad.ensureDataReady(); + + do_check_eq(storeForLoad.data.logins.length, 1); + do_check_matches(storeForLoad.data.logins[0], rawLoginData); + do_check_eq(storeForLoad.data.disabledHosts.length, 1); + do_check_eq(storeForLoad.data.disabledHosts[0], "http://www.example.org"); +}); + +/** + * Checks that loading from a missing file results in empty arrays. + */ +add_task(function* test_load_empty() +{ + let store = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path); + + do_check_false(yield OS.File.exists(store.path)); + + yield store.load(); + + do_check_false(yield OS.File.exists(store.path)); + + do_check_eq(store.data.logins.length, 0); + do_check_eq(store.data.disabledHosts.length, 0); +}); + +/** + * Checks that saving empty data still overwrites any existing file. + */ +add_task(function* test_save_empty() +{ + let store = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path); + + yield store.load(); + + let createdFile = yield OS.File.open(store.path, { create: true }); + yield createdFile.close(); + + yield store._save(); + + do_check_true(yield OS.File.exists(store.path)); +}); + +/** + * Loads data from a string in a predefined format. The purpose of this test is + * to verify that the JSON format used in previous versions can be loaded. + */ +add_task(function* test_load_string_predefined() +{ + let store = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path); + + let string = "{\"logins\":[{" + + "\"id\":1," + + "\"hostname\":\"http://www.example.com\"," + + "\"httpRealm\":null," + + "\"formSubmitURL\":\"http://www.example.com/submit-url\"," + + "\"usernameField\":\"usernameField\"," + + "\"passwordField\":\"passwordField\"," + + "\"encryptedUsername\":\"(test)\"," + + "\"encryptedPassword\":\"(test)\"," + + "\"guid\":\"(test)\"," + + "\"encType\":1," + + "\"timeCreated\":1262304000000," + + "\"timeLastUsed\":1262390400000," + + "\"timePasswordChanged\":1262476800000," + + "\"timesUsed\":1}],\"disabledHosts\":[" + + "\"http://www.example.org\"]}"; + + yield OS.File.writeAtomic(store.path, + new TextEncoder().encode(string), + { tmpPath: store.path + ".tmp" }); + + yield store.load(); + + do_check_eq(store.data.logins.length, 1); + do_check_matches(store.data.logins[0], { + id: 1, + hostname: "http://www.example.com", + httpRealm: null, + formSubmitURL: "http://www.example.com/submit-url", + usernameField: "usernameField", + passwordField: "passwordField", + encryptedUsername: "(test)", + encryptedPassword: "(test)", + guid: "(test)", + encType: Ci.nsILoginManagerCrypto.ENCTYPE_SDR, + timeCreated: 1262304000000, + timeLastUsed: 1262390400000, + timePasswordChanged: 1262476800000, + timesUsed: 1, + }); + + do_check_eq(store.data.disabledHosts.length, 1); + do_check_eq(store.data.disabledHosts[0], "http://www.example.org"); +}); + +/** + * Loads login data from a malformed JSON string. + */ +add_task(function* test_load_string_malformed() +{ + let store = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path); + + let string = "{\"logins\":[{\"hostname\":\"http://www.example.com\"," + + "\"id\":1,"; + + yield OS.File.writeAtomic(store.path, new TextEncoder().encode(string), + { tmpPath: store.path + ".tmp" }); + + yield store.load(); + + // A backup file should have been created. + do_check_true(yield OS.File.exists(store.path + ".corrupt")); + yield OS.File.remove(store.path + ".corrupt"); + + // The store should be ready to accept new data. + do_check_eq(store.data.logins.length, 0); + do_check_eq(store.data.disabledHosts.length, 0); +}); + +/** + * Loads login data from a malformed JSON string, using the synchronous + * initialization path. + */ +add_task(function* test_load_string_malformed_sync() +{ + let store = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path); + + let string = "{\"logins\":[{\"hostname\":\"http://www.example.com\"," + + "\"id\":1,"; + + yield OS.File.writeAtomic(store.path, new TextEncoder().encode(string), + { tmpPath: store.path + ".tmp" }); + + store.ensureDataReady(); + + // A backup file should have been created. + do_check_true(yield OS.File.exists(store.path + ".corrupt")); + yield OS.File.remove(store.path + ".corrupt"); + + // The store should be ready to accept new data. + do_check_eq(store.data.logins.length, 0); + do_check_eq(store.data.disabledHosts.length, 0); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_notifications.js b/toolkit/components/passwordmgr/test/unit/test_notifications.js new file mode 100644 index 000000000..41caa2c1b --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_notifications.js @@ -0,0 +1,172 @@ +/* + * Tests notifications dispatched when modifying stored logins. + */ + +var expectedNotification; +var expectedData; + +var TestObserver = { + QueryInterface : XPCOMUtils.generateQI([Ci.nsIObserver, Ci.nsISupportsWeakReference]), + + observe : function (subject, topic, data) { + do_check_eq(topic, "passwordmgr-storage-changed"); + do_check_eq(data, expectedNotification); + + switch (data) { + case "addLogin": + do_check_true(subject instanceof Ci.nsILoginInfo); + do_check_true(subject instanceof Ci.nsILoginMetaInfo); + do_check_true(expectedData.equals(subject)); // nsILoginInfo.equals() + break; + case "modifyLogin": + do_check_true(subject instanceof Ci.nsIArray); + do_check_eq(subject.length, 2); + var oldLogin = subject.queryElementAt(0, Ci.nsILoginInfo); + var newLogin = subject.queryElementAt(1, Ci.nsILoginInfo); + do_check_true(expectedData[0].equals(oldLogin)); // nsILoginInfo.equals() + do_check_true(expectedData[1].equals(newLogin)); + break; + case "removeLogin": + do_check_true(subject instanceof Ci.nsILoginInfo); + do_check_true(subject instanceof Ci.nsILoginMetaInfo); + do_check_true(expectedData.equals(subject)); // nsILoginInfo.equals() + break; + case "removeAllLogins": + do_check_eq(subject, null); + break; + case "hostSavingEnabled": + case "hostSavingDisabled": + do_check_true(subject instanceof Ci.nsISupportsString); + do_check_eq(subject.data, expectedData); + break; + default: + do_throw("Unhandled notification: " + data + " / " + topic); + } + + expectedNotification = null; // ensure a duplicate is flagged as unexpected. + expectedData = null; + } +}; + +add_task(function test_notifications() +{ + +try { + +var testnum = 0; +var testdesc = "Setup of nsLoginInfo test-users"; + +var testuser1 = new LoginInfo("http://testhost1", "", null, + "dummydude", "itsasecret", "put_user_here", "put_pw_here"); + +var testuser2 = new LoginInfo("http://testhost2", "", null, + "dummydude2", "itsasecret2", "put_user2_here", "put_pw2_here"); + +Services.obs.addObserver(TestObserver, "passwordmgr-storage-changed", false); + + +/* ========== 1 ========== */ +testnum = 1; +testdesc = "Initial connection to storage module"; + +/* ========== 2 ========== */ +testnum++; +testdesc = "addLogin"; + +expectedNotification = "addLogin"; +expectedData = testuser1; +Services.logins.addLogin(testuser1); +LoginTestUtils.checkLogins([testuser1]); +do_check_eq(expectedNotification, null); // check that observer got a notification + +/* ========== 3 ========== */ +testnum++; +testdesc = "modifyLogin"; + +expectedNotification = "modifyLogin"; +expectedData = [testuser1, testuser2]; +Services.logins.modifyLogin(testuser1, testuser2); +do_check_eq(expectedNotification, null); +LoginTestUtils.checkLogins([testuser2]); + +/* ========== 4 ========== */ +testnum++; +testdesc = "removeLogin"; + +expectedNotification = "removeLogin"; +expectedData = testuser2; +Services.logins.removeLogin(testuser2); +do_check_eq(expectedNotification, null); +LoginTestUtils.checkLogins([]); + +/* ========== 5 ========== */ +testnum++; +testdesc = "removeAllLogins"; + +expectedNotification = "removeAllLogins"; +expectedData = null; +Services.logins.removeAllLogins(); +do_check_eq(expectedNotification, null); +LoginTestUtils.checkLogins([]); + +/* ========== 6 ========== */ +testnum++; +testdesc = "removeAllLogins (again)"; + +expectedNotification = "removeAllLogins"; +expectedData = null; +Services.logins.removeAllLogins(); +do_check_eq(expectedNotification, null); +LoginTestUtils.checkLogins([]); + +/* ========== 7 ========== */ +testnum++; +testdesc = "setLoginSavingEnabled / false"; + +expectedNotification = "hostSavingDisabled"; +expectedData = "http://site.com"; +Services.logins.setLoginSavingEnabled("http://site.com", false); +do_check_eq(expectedNotification, null); +LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(), + ["http://site.com"]); + +/* ========== 8 ========== */ +testnum++; +testdesc = "setLoginSavingEnabled / false (again)"; + +expectedNotification = "hostSavingDisabled"; +expectedData = "http://site.com"; +Services.logins.setLoginSavingEnabled("http://site.com", false); +do_check_eq(expectedNotification, null); +LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(), + ["http://site.com"]); + +/* ========== 9 ========== */ +testnum++; +testdesc = "setLoginSavingEnabled / true"; + +expectedNotification = "hostSavingEnabled"; +expectedData = "http://site.com"; +Services.logins.setLoginSavingEnabled("http://site.com", true); +do_check_eq(expectedNotification, null); +LoginTestUtils.checkLogins([]); + +/* ========== 10 ========== */ +testnum++; +testdesc = "setLoginSavingEnabled / true (again)"; + +expectedNotification = "hostSavingEnabled"; +expectedData = "http://site.com"; +Services.logins.setLoginSavingEnabled("http://site.com", true); +do_check_eq(expectedNotification, null); +LoginTestUtils.checkLogins([]); + +Services.obs.removeObserver(TestObserver, "passwordmgr-storage-changed"); + +LoginTestUtils.clearData(); + +} catch (e) { + throw new Error("FAILED in test #" + testnum + " -- " + testdesc + ": " + e); +} + +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_recipes_add.js b/toolkit/components/passwordmgr/test/unit/test_recipes_add.js new file mode 100644 index 000000000..ef5086c3b --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_recipes_add.js @@ -0,0 +1,177 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests adding and retrieving LoginRecipes in the parent process. + */ + +"use strict"; + +add_task(function* test_init() { + let parent = new LoginRecipesParent({ defaults: null }); + let initPromise1 = parent.initializationPromise; + let initPromise2 = parent.initializationPromise; + Assert.strictEqual(initPromise1, initPromise2, "Check that the same promise is returned"); + + let recipesParent = yield initPromise1; + Assert.ok(recipesParent instanceof LoginRecipesParent, "Check init return value"); + Assert.strictEqual(recipesParent._recipesByHost.size, 0, "Initially 0 recipes"); +}); + +add_task(function* test_get_missing_host() { + let recipesParent = yield RecipeHelpers.initNewParent(); + let exampleRecipes = recipesParent.getRecipesForHost("example.invalid"); + Assert.strictEqual(exampleRecipes.size, 0, "Check recipe count for example.invalid"); + +}); + +add_task(function* test_add_get_simple_host() { + let recipesParent = yield RecipeHelpers.initNewParent(); + Assert.strictEqual(recipesParent._recipesByHost.size, 0, "Initially 0 recipes"); + recipesParent.add({ + hosts: ["example.com"], + }); + Assert.strictEqual(recipesParent._recipesByHost.size, 1, + "Check number of hosts after the addition"); + + let exampleRecipes = recipesParent.getRecipesForHost("example.com"); + Assert.strictEqual(exampleRecipes.size, 1, "Check recipe count for example.com"); + let recipe = [...exampleRecipes][0]; + Assert.strictEqual(typeof(recipe), "object", "Check recipe type"); + Assert.strictEqual(recipe.hosts.length, 1, "Check that one host is present"); + Assert.strictEqual(recipe.hosts[0], "example.com", "Check the one host"); +}); + +add_task(function* test_add_get_non_standard_port_host() { + let recipesParent = yield RecipeHelpers.initNewParent(); + recipesParent.add({ + hosts: ["example.com:8080"], + }); + Assert.strictEqual(recipesParent._recipesByHost.size, 1, + "Check number of hosts after the addition"); + + let exampleRecipes = recipesParent.getRecipesForHost("example.com:8080"); + Assert.strictEqual(exampleRecipes.size, 1, "Check recipe count for example.com:8080"); + let recipe = [...exampleRecipes][0]; + Assert.strictEqual(typeof(recipe), "object", "Check recipe type"); + Assert.strictEqual(recipe.hosts.length, 1, "Check that one host is present"); + Assert.strictEqual(recipe.hosts[0], "example.com:8080", "Check the one host"); +}); + +add_task(function* test_add_multiple_hosts() { + let recipesParent = yield RecipeHelpers.initNewParent(); + recipesParent.add({ + hosts: ["example.com", "foo.invalid"], + }); + Assert.strictEqual(recipesParent._recipesByHost.size, 2, + "Check number of hosts after the addition"); + + let exampleRecipes = recipesParent.getRecipesForHost("example.com"); + Assert.strictEqual(exampleRecipes.size, 1, "Check recipe count for example.com"); + let recipe = [...exampleRecipes][0]; + Assert.strictEqual(typeof(recipe), "object", "Check recipe type"); + Assert.strictEqual(recipe.hosts.length, 2, "Check that two hosts are present"); + Assert.strictEqual(recipe.hosts[0], "example.com", "Check the first host"); + Assert.strictEqual(recipe.hosts[1], "foo.invalid", "Check the second host"); + + let fooRecipes = recipesParent.getRecipesForHost("foo.invalid"); + Assert.strictEqual(fooRecipes.size, 1, "Check recipe count for foo.invalid"); + let fooRecipe = [...fooRecipes][0]; + Assert.strictEqual(fooRecipe, recipe, "Check that the recipe is shared"); + Assert.strictEqual(typeof(fooRecipe), "object", "Check recipe type"); + Assert.strictEqual(fooRecipe.hosts.length, 2, "Check that two hosts are present"); + Assert.strictEqual(fooRecipe.hosts[0], "example.com", "Check the first host"); + Assert.strictEqual(fooRecipe.hosts[1], "foo.invalid", "Check the second host"); +}); + +add_task(function* test_add_pathRegex() { + let recipesParent = yield RecipeHelpers.initNewParent(); + recipesParent.add({ + hosts: ["example.com"], + pathRegex: /^\/mypath\//, + }); + Assert.strictEqual(recipesParent._recipesByHost.size, 1, + "Check number of hosts after the addition"); + + let exampleRecipes = recipesParent.getRecipesForHost("example.com"); + Assert.strictEqual(exampleRecipes.size, 1, "Check recipe count for example.com"); + let recipe = [...exampleRecipes][0]; + Assert.strictEqual(typeof(recipe), "object", "Check recipe type"); + Assert.strictEqual(recipe.hosts.length, 1, "Check that one host is present"); + Assert.strictEqual(recipe.hosts[0], "example.com", "Check the one host"); + Assert.strictEqual(recipe.pathRegex.toString(), "/^\\/mypath\\//", "Check the pathRegex"); +}); + +add_task(function* test_add_selectors() { + let recipesParent = yield RecipeHelpers.initNewParent(); + recipesParent.add({ + hosts: ["example.com"], + usernameSelector: "#my-username", + passwordSelector: "#my-form > input.password", + }); + Assert.strictEqual(recipesParent._recipesByHost.size, 1, + "Check number of hosts after the addition"); + + let exampleRecipes = recipesParent.getRecipesForHost("example.com"); + Assert.strictEqual(exampleRecipes.size, 1, "Check recipe count for example.com"); + let recipe = [...exampleRecipes][0]; + Assert.strictEqual(typeof(recipe), "object", "Check recipe type"); + Assert.strictEqual(recipe.hosts.length, 1, "Check that one host is present"); + Assert.strictEqual(recipe.hosts[0], "example.com", "Check the one host"); + Assert.strictEqual(recipe.usernameSelector, "#my-username", "Check the usernameSelector"); + Assert.strictEqual(recipe.passwordSelector, "#my-form > input.password", "Check the passwordSelector"); +}); + +/* Begin checking errors with add */ + +add_task(function* test_add_missing_prop() { + let recipesParent = yield RecipeHelpers.initNewParent(); + Assert.throws(() => recipesParent.add({}), /required/, "Some properties are required"); +}); + +add_task(function* test_add_unknown_prop() { + let recipesParent = yield RecipeHelpers.initNewParent(); + Assert.throws(() => recipesParent.add({ + unknownProp: true, + }), /supported/, "Unknown properties should cause an error to help with typos"); +}); + +add_task(function* test_add_invalid_hosts() { + let recipesParent = yield RecipeHelpers.initNewParent(); + Assert.throws(() => recipesParent.add({ + hosts: 404, + }), /array/, "hosts should be an array"); +}); + +add_task(function* test_add_empty_host_array() { + let recipesParent = yield RecipeHelpers.initNewParent(); + Assert.throws(() => recipesParent.add({ + hosts: [], + }), /array/, "hosts should be a non-empty array"); +}); + +add_task(function* test_add_pathRegex_non_regexp() { + let recipesParent = yield RecipeHelpers.initNewParent(); + Assert.throws(() => recipesParent.add({ + hosts: ["example.com"], + pathRegex: "foo", + }), /regular expression/, "pathRegex should be a RegExp"); +}); + +add_task(function* test_add_usernameSelector_non_string() { + let recipesParent = yield RecipeHelpers.initNewParent(); + Assert.throws(() => recipesParent.add({ + hosts: ["example.com"], + usernameSelector: 404, + }), /string/, "usernameSelector should be a string"); +}); + +add_task(function* test_add_passwordSelector_non_string() { + let recipesParent = yield RecipeHelpers.initNewParent(); + Assert.throws(() => recipesParent.add({ + hosts: ["example.com"], + passwordSelector: 404, + }), /string/, "passwordSelector should be a string"); +}); + +/* End checking errors with add */ diff --git a/toolkit/components/passwordmgr/test/unit/test_recipes_content.js b/toolkit/components/passwordmgr/test/unit/test_recipes_content.js new file mode 100644 index 000000000..3d3751452 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_recipes_content.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test filtering recipes in LoginRecipesContent. + */ + +"use strict"; + +Cu.importGlobalProperties(["URL"]); + +add_task(function* test_getFieldOverrides() { + let recipes = new Set([ + { // path doesn't match but otherwise good + hosts: ["example.com:8080"], + passwordSelector: "#password", + pathRegex: /^\/$/, + usernameSelector: ".username", + }, + { // match with no field overrides + hosts: ["example.com:8080"], + }, + { // best match (field selectors + path match) + description: "best match", + hosts: ["a.invalid", "example.com:8080", "other.invalid"], + passwordSelector: "#password", + pathRegex: /^\/first\/second\/$/, + usernameSelector: ".username", + }, + ]); + + let form = MockDocument.createTestDocument("http://localhost:8080/first/second/", "<form>"). + forms[0]; + let override = LoginRecipesContent.getFieldOverrides(recipes, form); + Assert.strictEqual(override.description, "best match", + "Check the best field override recipe was returned"); + Assert.strictEqual(override.usernameSelector, ".username", "Check usernameSelector"); + Assert.strictEqual(override.passwordSelector, "#password", "Check passwordSelector"); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_removeLegacySignonFiles.js b/toolkit/components/passwordmgr/test/unit/test_removeLegacySignonFiles.js new file mode 100644 index 000000000..51a107170 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_removeLegacySignonFiles.js @@ -0,0 +1,69 @@ +/** + * Tests the LoginHelper object. + */ + +"use strict"; + + +Cu.import("resource://gre/modules/Task.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper", + "resource://gre/modules/LoginHelper.jsm"); + + +function* createSignonFile(singon) { + let {file, pref} = singon; + + if (pref) { + Services.prefs.setCharPref(pref, file); + } + + yield OS.File.writeAtomic( + OS.Path.join(OS.Constants.Path.profileDir, file), new Uint8Array(1)); +} + +function* isSignonClear(singon) { + const {file, pref} = singon; + const fileExists = yield OS.File.exists( + OS.Path.join(OS.Constants.Path.profileDir, file)); + + if (pref) { + try { + Services.prefs.getCharPref(pref); + return false; + } catch (e) {} + } + + return !fileExists; +} + +add_task(function* test_remove_lagecy_signonfile() { + // In the last test case, signons3.txt being deleted even when + // it doesn't exist. + const signonsSettings = [[ + { file: "signons.txt" }, + { file: "signons2.txt" }, + { file: "signons3.txt" } + ], [ + { file: "signons.txt", pref: "signon.SignonFileName" }, + { file: "signons2.txt", pref: "signon.SignonFileName2" }, + { file: "signons3.txt", pref: "signon.SignonFileName3" } + ], [ + { file: "signons2.txt" }, + { file: "singons.txt", pref: "signon.SignonFileName" }, + { file: "customized2.txt", pref: "signon.SignonFileName2" }, + { file: "customized3.txt", pref: "signon.SignonFileName3" } + ]]; + + for (let setting of signonsSettings) { + for (let singon of setting) { + yield createSignonFile(singon); + } + + LoginHelper.removeLegacySignonFiles(); + + for (let singon of setting) { + equal(yield isSignonClear(singon), true); + } + } +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_search_schemeUpgrades.js b/toolkit/components/passwordmgr/test/unit/test_search_schemeUpgrades.js new file mode 100644 index 000000000..3406becff --- /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); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_storage.js b/toolkit/components/passwordmgr/test/unit/test_storage.js new file mode 100644 index 000000000..d65516d9b --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_storage.js @@ -0,0 +1,102 @@ +/* -*- 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 that the default nsILoginManagerStorage module attached to the Login + * Manager service is able to save and reload nsILoginInfo properties correctly, + * even when they include special characters. + */ + +"use strict"; + +// Globals + +function* reloadAndCheckLoginsGen(aExpectedLogins) +{ + yield LoginTestUtils.reloadData(); + LoginTestUtils.checkLogins(aExpectedLogins); + LoginTestUtils.clearData(); +} + +// Tests + +/** + * Tests addLogin with valid non-ASCII characters. + */ +add_task(function* test_storage_addLogin_nonascii() +{ + let hostname = "http://" + String.fromCharCode(355) + ".example.com"; + + // Store the strings "user" and "pass" using similarly looking glyphs. + let loginInfo = TestData.formLogin({ + hostname: hostname, + formSubmitURL: hostname, + username: String.fromCharCode(533, 537, 7570, 345), + password: String.fromCharCode(421, 259, 349, 537), + usernameField: "field_" + String.fromCharCode(533, 537, 7570, 345), + passwordField: "field_" + String.fromCharCode(421, 259, 349, 537), + }); + Services.logins.addLogin(loginInfo); + yield* reloadAndCheckLoginsGen([loginInfo]); + + // Store the string "test" using similarly looking glyphs. + loginInfo = TestData.authLogin({ + httpRealm: String.fromCharCode(355, 277, 349, 357), + }); + Services.logins.addLogin(loginInfo); + yield* reloadAndCheckLoginsGen([loginInfo]); +}); + +/** + * Tests addLogin with newline characters in the username and password. + */ +add_task(function* test_storage_addLogin_newlines() +{ + let loginInfo = TestData.formLogin({ + username: "user\r\nname", + password: "password\r\n", + }); + Services.logins.addLogin(loginInfo); + yield* reloadAndCheckLoginsGen([loginInfo]); +}); + +/** + * Tests addLogin with a single dot in fields where it is allowed. + * + * These tests exist to verify the legacy "signons.txt" storage format. + */ +add_task(function* test_storage_addLogin_dot() +{ + let loginInfo = TestData.formLogin({ hostname: ".", passwordField: "." }); + Services.logins.addLogin(loginInfo); + yield* reloadAndCheckLoginsGen([loginInfo]); + + loginInfo = TestData.authLogin({ httpRealm: "." }); + Services.logins.addLogin(loginInfo); + yield* reloadAndCheckLoginsGen([loginInfo]); +}); + +/** + * Tests addLogin with parentheses in hostnames. + * + * These tests exist to verify the legacy "signons.txt" storage format. + */ +add_task(function* test_storage_addLogin_parentheses() +{ + let loginList = [ + TestData.authLogin({ httpRealm: "(realm" }), + TestData.authLogin({ httpRealm: "realm)" }), + TestData.authLogin({ httpRealm: "(realm)" }), + TestData.authLogin({ httpRealm: ")realm(" }), + TestData.authLogin({ hostname: "http://parens(.example.com" }), + TestData.authLogin({ hostname: "http://parens).example.com" }), + TestData.authLogin({ hostname: "http://parens(example).example.com" }), + TestData.authLogin({ hostname: "http://parens)example(.example.com" }), + ]; + for (let loginInfo of loginList) { + Services.logins.addLogin(loginInfo); + } + yield* reloadAndCheckLoginsGen(loginList); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_storage_mozStorage.js b/toolkit/components/passwordmgr/test/unit/test_storage_mozStorage.js new file mode 100644 index 000000000..8eab6efe5 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_storage_mozStorage.js @@ -0,0 +1,507 @@ +/* + * This test interfaces directly with the mozStorage password storage module, + * bypassing the normal password manager usage. + */ + + +const ENCTYPE_BASE64 = 0; +const ENCTYPE_SDR = 1; +const PERMISSION_SAVE_LOGINS = "login-saving"; + +// Current schema version used by storage-mozStorage.js. This will need to be +// kept in sync with the version there (or else the tests fail). +const CURRENT_SCHEMA = 6; + +function* copyFile(aLeafName) +{ + yield OS.File.copy(OS.Path.join(do_get_file("data").path, aLeafName), + OS.Path.join(OS.Constants.Path.profileDir, aLeafName)); +} + +function openDB(aLeafName) +{ + var dbFile = new FileUtils.File(OS.Constants.Path.profileDir); + dbFile.append(aLeafName); + + return Services.storage.openDatabase(dbFile); +} + +function deleteFile(pathname, filename) +{ + var file = new FileUtils.File(pathname); + file.append(filename); + + // Suppress failures, this happens in the mozstorage tests on Windows + // because the module may still be holding onto the DB. (We don't + // have a way to explicitly shutdown/GC the module). + try { + if (file.exists()) + file.remove(false); + } catch (e) {} +} + +function reloadStorage(aInputPathName, aInputFileName) +{ + var inputFile = null; + if (aInputFileName) { + inputFile = Cc["@mozilla.org/file/local;1"]. + createInstance(Ci.nsILocalFile); + inputFile.initWithPath(aInputPathName); + inputFile.append(aInputFileName); + } + + let storage = Cc["@mozilla.org/login-manager/storage/mozStorage;1"] + .createInstance(Ci.nsILoginManagerStorage); + storage.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIVariant) + .initWithFile(inputFile); + + return storage; +} + +function checkStorageData(storage, ref_disabledHosts, ref_logins) +{ + LoginTestUtils.assertLoginListsEqual(storage.getAllLogins(), ref_logins); + LoginTestUtils.assertDisabledHostsEqual(getAllDisabledHostsFromPermissionManager(), + ref_disabledHosts); +} + +function getAllDisabledHostsFromPermissionManager() { + let disabledHosts = []; + let enumerator = Services.perms.enumerator; + + while (enumerator.hasMoreElements()) { + let perm = enumerator.getNext(); + if (perm.type == PERMISSION_SAVE_LOGINS && perm.capability == Services.perms.DENY_ACTION) { + disabledHosts.push(perm.principal.URI.prePath); + } + } + + return disabledHosts; +} + +function setLoginSavingEnabled(origin, enabled) { + let uri = Services.io.newURI(origin, null, null); + + if (enabled) { + Services.perms.remove(uri, PERMISSION_SAVE_LOGINS); + } else { + Services.perms.add(uri, PERMISSION_SAVE_LOGINS, Services.perms.DENY_ACTION); + } +} + +add_task(function* test_execute() +{ + +const OUTDIR = OS.Constants.Path.profileDir; + +try { + +var isGUID = /^\{[0-9a-f\d]{8}-[0-9a-f\d]{4}-[0-9a-f\d]{4}-[0-9a-f\d]{4}-[0-9a-f\d]{12}\}$/; +function getGUIDforID(conn, id) { + var stmt = conn.createStatement("SELECT guid from moz_logins WHERE id = " + id); + stmt.executeStep(); + var guid = stmt.getString(0); + stmt.finalize(); + return guid; +} + +function getEncTypeForID(conn, id) { + var stmt = conn.createStatement("SELECT encType from moz_logins WHERE id = " + id); + stmt.executeStep(); + var encType = stmt.row.encType; + stmt.finalize(); + return encType; +} + +function getAllDisabledHostsFromMozStorage(conn) { + let disabledHosts = []; + let stmt = conn.createStatement("SELECT hostname from moz_disabledHosts"); + + while (stmt.executeStep()) { + disabledHosts.push(stmt.row.hostname); + } + + return disabledHosts; +} + +var storage; +var dbConnection; +var testnum = 0; +var testdesc = "Setup of nsLoginInfo test-users"; +var nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Components.interfaces.nsILoginInfo); +do_check_true(nsLoginInfo != null); + +var testuser1 = new nsLoginInfo; +testuser1.init("http://test.com", "http://test.com", null, + "testuser1", "testpass1", "u1", "p1"); +var testuser1B = new nsLoginInfo; +testuser1B.init("http://test.com", "http://test.com", null, + "testuser1B", "testpass1B", "u1", "p1"); +var testuser2 = new nsLoginInfo; +testuser2.init("http://test.org", "http://test.org", null, + "testuser2", "testpass2", "u2", "p2"); +var testuser3 = new nsLoginInfo; +testuser3.init("http://test.gov", "http://test.gov", null, + "testuser3", "testpass3", "u3", "p3"); +var testuser4 = new nsLoginInfo; +testuser4.init("http://test.gov", "http://test.gov", null, + "testuser1", "testpass2", "u4", "p4"); +var testuser5 = new nsLoginInfo; +testuser5.init("http://test.gov", "http://test.gov", null, + "testuser2", "testpass1", "u5", "p5"); + + +/* ========== 1 ========== */ +testnum++; +testdesc = "Test downgrade from v999 storage"; + +yield* copyFile("signons-v999.sqlite"); +// Verify the schema version in the test file. +dbConnection = openDB("signons-v999.sqlite"); +do_check_eq(999, dbConnection.schemaVersion); +dbConnection.close(); + +storage = reloadStorage(OUTDIR, "signons-v999.sqlite"); +setLoginSavingEnabled("https://disabled.net", false); +checkStorageData(storage, ["https://disabled.net"], [testuser1]); + +// Check to make sure we downgraded the schema version. +dbConnection = openDB("signons-v999.sqlite"); +do_check_eq(CURRENT_SCHEMA, dbConnection.schemaVersion); +dbConnection.close(); + +deleteFile(OUTDIR, "signons-v999.sqlite"); + +/* ========== 2 ========== */ +testnum++; +testdesc = "Test downgrade from incompat v999 storage"; +// This file has a testuser999/testpass999, but is missing an expected column + +var origFile = OS.Path.join(OUTDIR, "signons-v999-2.sqlite"); +var failFile = OS.Path.join(OUTDIR, "signons-v999-2.sqlite.corrupt"); + +// Make sure we always start clean in a clean state. +yield* copyFile("signons-v999-2.sqlite"); +yield OS.File.remove(failFile); + +Assert.throws(() => reloadStorage(OUTDIR, "signons-v999-2.sqlite"), + /Initialization failed/); + +// Check to ensure the DB file was renamed to .corrupt. +do_check_false(yield OS.File.exists(origFile)); +do_check_true(yield OS.File.exists(failFile)); + +yield OS.File.remove(failFile); + +/* ========== 3 ========== */ +testnum++; +testdesc = "Test upgrade from v1->v2 storage"; + +yield* copyFile("signons-v1.sqlite"); +// Sanity check the test file. +dbConnection = openDB("signons-v1.sqlite"); +do_check_eq(1, dbConnection.schemaVersion); +dbConnection.close(); + +storage = reloadStorage(OUTDIR, "signons-v1.sqlite"); +checkStorageData(storage, ["https://disabled.net"], [testuser1, testuser2]); + +// Check to see that we added a GUIDs to the logins. +dbConnection = openDB("signons-v1.sqlite"); +do_check_eq(CURRENT_SCHEMA, dbConnection.schemaVersion); +var guid = getGUIDforID(dbConnection, 1); +do_check_true(isGUID.test(guid)); +guid = getGUIDforID(dbConnection, 2); +do_check_true(isGUID.test(guid)); +dbConnection.close(); + +deleteFile(OUTDIR, "signons-v1.sqlite"); + +/* ========== 4 ========== */ +testnum++; +testdesc = "Test upgrade v2->v1 storage"; +// This is the case where a v2 DB has been accessed with v1 code, and now we +// are upgrading it again. Any logins added by the v1 code must be properly +// upgraded. + +yield* copyFile("signons-v1v2.sqlite"); +// Sanity check the test file. +dbConnection = openDB("signons-v1v2.sqlite"); +do_check_eq(1, dbConnection.schemaVersion); +dbConnection.close(); + +storage = reloadStorage(OUTDIR, "signons-v1v2.sqlite"); +checkStorageData(storage, ["https://disabled.net"], [testuser1, testuser2, testuser3]); + +// While we're here, try modifying a login, to ensure that doing so doesn't +// change the existing GUID. +storage.modifyLogin(testuser1, testuser1B); +checkStorageData(storage, ["https://disabled.net"], [testuser1B, testuser2, testuser3]); + +// Check the GUIDs. Logins 1 and 2 should retain their original GUID, login 3 +// should have one created (because it didn't have one previously). +dbConnection = openDB("signons-v1v2.sqlite"); +do_check_eq(CURRENT_SCHEMA, dbConnection.schemaVersion); +guid = getGUIDforID(dbConnection, 1); +do_check_eq("{655c7358-f1d6-6446-adab-53f98ac5d80f}", guid); +guid = getGUIDforID(dbConnection, 2); +do_check_eq("{13d9bfdc-572a-4d4e-9436-68e9803e84c1}", guid); +guid = getGUIDforID(dbConnection, 3); +do_check_true(isGUID.test(guid)); +dbConnection.close(); + +deleteFile(OUTDIR, "signons-v1v2.sqlite"); + +/* ========== 5 ========== */ +testnum++; +testdesc = "Test upgrade from v2->v3 storage"; + +yield* copyFile("signons-v2.sqlite"); +// Sanity check the test file. +dbConnection = openDB("signons-v2.sqlite"); +do_check_eq(2, dbConnection.schemaVersion); + +storage = reloadStorage(OUTDIR, "signons-v2.sqlite"); + +// Check to see that we added the correct encType to the logins. +do_check_eq(CURRENT_SCHEMA, dbConnection.schemaVersion); +var encTypes = [ENCTYPE_BASE64, ENCTYPE_SDR, ENCTYPE_BASE64, ENCTYPE_BASE64]; +for (let i = 0; i < encTypes.length; i++) + do_check_eq(encTypes[i], getEncTypeForID(dbConnection, i + 1)); +dbConnection.close(); + +// There are 4 logins, but 3 will be invalid because we can no longer decrypt +// base64-encoded items. (testuser1/4/5) +checkStorageData(storage, ["https://disabled.net"], + [testuser2]); + +deleteFile(OUTDIR, "signons-v2.sqlite"); + +/* ========== 6 ========== */ +testnum++; +testdesc = "Test upgrade v3->v2 storage"; +// This is the case where a v3 DB has been accessed with v2 code, and now we +// are upgrading it again. Any logins added by the v2 code must be properly +// upgraded. + +yield* copyFile("signons-v2v3.sqlite"); +// Sanity check the test file. +dbConnection = openDB("signons-v2v3.sqlite"); +do_check_eq(2, dbConnection.schemaVersion); +encTypes = [ENCTYPE_BASE64, ENCTYPE_SDR, ENCTYPE_BASE64, ENCTYPE_BASE64, null]; +for (let i = 0; i < encTypes.length; i++) + do_check_eq(encTypes[i], getEncTypeForID(dbConnection, i + 1)); + +// Reload storage, check that the new login now has encType=1, others untouched +storage = reloadStorage(OUTDIR, "signons-v2v3.sqlite"); +do_check_eq(CURRENT_SCHEMA, dbConnection.schemaVersion); + +encTypes = [ENCTYPE_BASE64, ENCTYPE_SDR, ENCTYPE_BASE64, ENCTYPE_BASE64, ENCTYPE_SDR]; +for (let i = 0; i < encTypes.length; i++) + do_check_eq(encTypes[i], getEncTypeForID(dbConnection, i + 1)); + +// Sanity check that the data gets migrated +// There are 5 logins, but 3 will be invalid because we can no longer decrypt +// base64-encoded items. (testuser1/4/5). We no longer reencrypt with SDR. +checkStorageData(storage, ["https://disabled.net"], [testuser2, testuser3]); +encTypes = [ENCTYPE_BASE64, ENCTYPE_SDR, ENCTYPE_BASE64, ENCTYPE_BASE64, ENCTYPE_SDR]; +for (let i = 0; i < encTypes.length; i++) + do_check_eq(encTypes[i], getEncTypeForID(dbConnection, i + 1)); +dbConnection.close(); + +deleteFile(OUTDIR, "signons-v2v3.sqlite"); + + +/* ========== 7 ========== */ +testnum++; +testdesc = "Test upgrade from v3->v4 storage"; + +yield* copyFile("signons-v3.sqlite"); +// Sanity check the test file. +dbConnection = openDB("signons-v3.sqlite"); +do_check_eq(3, dbConnection.schemaVersion); + +storage = reloadStorage(OUTDIR, "signons-v3.sqlite"); +do_check_eq(CURRENT_SCHEMA, dbConnection.schemaVersion); + +// Remove old entry from permission manager. +setLoginSavingEnabled("https://disabled.net", true); + +// Check that timestamps and counts were initialized correctly +checkStorageData(storage, [], [testuser1, testuser2]); + +var logins = storage.getAllLogins(); +for (var i = 0; i < 2; i++) { + do_check_true(logins[i] instanceof Ci.nsILoginMetaInfo); + do_check_eq(1, logins[i].timesUsed); + LoginTestUtils.assertTimeIsAboutNow(logins[i].timeCreated); + LoginTestUtils.assertTimeIsAboutNow(logins[i].timeLastUsed); + LoginTestUtils.assertTimeIsAboutNow(logins[i].timePasswordChanged); +} + +/* ========== 8 ========== */ +testnum++; +testdesc = "Test upgrade from v3->v4->v3 storage"; + +yield* copyFile("signons-v3v4.sqlite"); +// Sanity check the test file. +dbConnection = openDB("signons-v3v4.sqlite"); +do_check_eq(3, dbConnection.schemaVersion); + +storage = reloadStorage(OUTDIR, "signons-v3v4.sqlite"); +do_check_eq(CURRENT_SCHEMA, dbConnection.schemaVersion); + +// testuser1 already has timestamps, testuser2 does not. +checkStorageData(storage, [], [testuser1, testuser2]); + +logins = storage.getAllLogins(); + +var t1, t2; +if (logins[0].username == "testuser1") { + t1 = logins[0]; + t2 = logins[1]; +} else { + t1 = logins[1]; + t2 = logins[0]; +} + +do_check_true(t1 instanceof Ci.nsILoginMetaInfo); +do_check_true(t2 instanceof Ci.nsILoginMetaInfo); + +do_check_eq(9, t1.timesUsed); +do_check_eq(1262049951275, t1.timeCreated); +do_check_eq(1262049951275, t1.timeLastUsed); +do_check_eq(1262049951275, t1.timePasswordChanged); + +do_check_eq(1, t2.timesUsed); +LoginTestUtils.assertTimeIsAboutNow(t2.timeCreated); +LoginTestUtils.assertTimeIsAboutNow(t2.timeLastUsed); +LoginTestUtils.assertTimeIsAboutNow(t2.timePasswordChanged); + + +/* ========== 9 ========== */ +testnum++; +testdesc = "Test upgrade from v4 storage"; + +yield* copyFile("signons-v4.sqlite"); +// Sanity check the test file. +dbConnection = openDB("signons-v4.sqlite"); +do_check_eq(4, dbConnection.schemaVersion); +do_check_false(dbConnection.tableExists("moz_deleted_logins")); + +storage = reloadStorage(OUTDIR, "signons-v4.sqlite"); +do_check_eq(CURRENT_SCHEMA, dbConnection.schemaVersion); +do_check_true(dbConnection.tableExists("moz_deleted_logins")); + + +/* ========== 10 ========== */ +testnum++; +testdesc = "Test upgrade from v4->v5->v4 storage"; + +yield copyFile("signons-v4v5.sqlite"); +// Sanity check the test file. +dbConnection = openDB("signons-v4v5.sqlite"); +do_check_eq(4, dbConnection.schemaVersion); +do_check_true(dbConnection.tableExists("moz_deleted_logins")); + +storage = reloadStorage(OUTDIR, "signons-v4v5.sqlite"); +do_check_eq(CURRENT_SCHEMA, dbConnection.schemaVersion); +do_check_true(dbConnection.tableExists("moz_deleted_logins")); + +/* ========== 11 ========== */ +testnum++; +testdesc = "Test upgrade from v5->v6 storage"; + +yield* copyFile("signons-v5v6.sqlite"); + +// Sanity check the test file. +dbConnection = openDB("signons-v5v6.sqlite"); +do_check_eq(5, dbConnection.schemaVersion); +do_check_true(dbConnection.tableExists("moz_disabledHosts")); + +// Initial disabled hosts inside signons-v5v6.sqlite +var disabledHosts = [ + "http://disabled1.example.com", + "http://大.net", + "http://xn--19g.com" +]; + +LoginTestUtils.assertDisabledHostsEqual(disabledHosts, getAllDisabledHostsFromMozStorage(dbConnection)); + +// Reload storage +storage = reloadStorage(OUTDIR, "signons-v5v6.sqlite"); +do_check_eq(CURRENT_SCHEMA, dbConnection.schemaVersion); + +// moz_disabledHosts should now be empty after migration. +LoginTestUtils.assertDisabledHostsEqual([], getAllDisabledHostsFromMozStorage(dbConnection)); + +// Get all the other hosts currently saved in the permission manager. +let hostsInPermissionManager = getAllDisabledHostsFromPermissionManager(); + +// All disabledHosts should have migrated to the permission manager +LoginTestUtils.assertDisabledHostsEqual(disabledHosts, hostsInPermissionManager); + +// Remove all disabled hosts from the permission manager before test ends +for (let host of disabledHosts) { + setLoginSavingEnabled(host, true); +} + +/* ========== 12 ========== */ +testnum++; +testdesc = "Create nsILoginInfo instances for testing with"; + +testuser1 = new nsLoginInfo; +testuser1.init("http://dummyhost.mozilla.org", "", null, + "dummydude", "itsasecret", "put_user_here", "put_pw_here"); + + +/* + * ---------------------- DB Corruption ---------------------- + * Try to initialize with a corrupt database file. This should create a backup + * file, then upon next use create a new database file. + */ + +/* ========== 13 ========== */ +testnum++; +testdesc = "Corrupt database and backup"; + +const filename = "signons-c.sqlite"; +const filepath = OS.Path.join(OS.Constants.Path.profileDir, filename); + +yield OS.File.copy(do_get_file("data/corruptDB.sqlite").path, filepath); + +// will init mozStorage module with corrupt database, init should fail +Assert.throws( + () => reloadStorage(OS.Constants.Path.profileDir, filename), + /Initialization failed/); + +// check that the backup file exists +do_check_true(yield OS.File.exists(filepath + ".corrupt")); + +// check that the original corrupt file has been deleted +do_check_false(yield OS.File.exists(filepath)); + +// initialize the storage module again +storage = reloadStorage(OS.Constants.Path.profileDir, filename); + +// use the storage module again, should work now +storage.addLogin(testuser1); +checkStorageData(storage, [], [testuser1]); + +// check the file exists +var file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFile); +file.initWithPath(OS.Constants.Path.profileDir); +file.append(filename); +do_check_true(file.exists()); + +deleteFile(OS.Constants.Path.profileDir, filename + ".corrupt"); +deleteFile(OS.Constants.Path.profileDir, filename); + +} catch (e) { + throw new Error("FAILED in test #" + testnum + " -- " + testdesc + ": " + e); +} + +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_telemetry.js b/toolkit/components/passwordmgr/test/unit/test_telemetry.js new file mode 100644 index 000000000..1d8f80226 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_telemetry.js @@ -0,0 +1,187 @@ +/* -*- 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 the statistics and other counters reported through telemetry. + */ + +"use strict"; + +// Globals + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +// To prevent intermittent failures when the test is executed at a time that is +// very close to a day boundary, we make it deterministic by using a static +// reference date for all the time-based statistics. +const gReferenceTimeMs = new Date("2000-01-01T00:00:00").getTime(); + +// Returns a milliseconds value to use with nsILoginMetaInfo properties, falling +// approximately in the middle of the specified number of days before the +// reference time, where zero days indicates a time within the past 24 hours. +var daysBeforeMs = days => gReferenceTimeMs - (days + 0.5) * MS_PER_DAY; + +/** + * Contains metadata that will be attached to test logins in order to verify + * that the statistics collection is working properly. Most properties of the + * logins are initialized to the default test values already. + * + * If you update this data or any of the telemetry histograms it checks, you'll + * probably need to update the expected statistics in the test below. + */ +const StatisticsTestData = [ + { + timeLastUsed: daysBeforeMs(0), + }, + { + timeLastUsed: daysBeforeMs(1), + }, + { + timeLastUsed: daysBeforeMs(7), + formSubmitURL: null, + httpRealm: "The HTTP Realm", + }, + { + username: "", + timeLastUsed: daysBeforeMs(7), + }, + { + username: "", + timeLastUsed: daysBeforeMs(30), + }, + { + username: "", + timeLastUsed: daysBeforeMs(31), + }, + { + timeLastUsed: daysBeforeMs(365), + }, + { + username: "", + timeLastUsed: daysBeforeMs(366), + }, + { + // If the login was saved in the future, it is ignored for statistiscs. + timeLastUsed: daysBeforeMs(-1), + }, + { + timeLastUsed: daysBeforeMs(1000), + }, +]; + +/** + * Triggers the collection of those statistics that are not accumulated each + * time an action is taken, but are a static snapshot of the current state. + */ +function triggerStatisticsCollection() { + Services.obs.notifyObservers(null, "gather-telemetry", "" + gReferenceTimeMs); +} + +/** + * Tests the telemetry histogram with the given ID contains only the specified + * non-zero ranges, expressed in the format { range1: value1, range2: value2 }. + */ +function testHistogram(histogramId, expectedNonZeroRanges) { + let snapshot = Services.telemetry.getHistogramById(histogramId).snapshot(); + + // Compute the actual ranges in the format { range1: value1, range2: value2 }. + let actualNonZeroRanges = {}; + for (let [index, range] of snapshot.ranges.entries()) { + let value = snapshot.counts[index]; + if (value > 0) { + actualNonZeroRanges[range] = value; + } + } + + // These are stringified to visualize the differences between the values. + do_print("Testing histogram: " + histogramId); + do_check_eq(JSON.stringify(actualNonZeroRanges), + JSON.stringify(expectedNonZeroRanges)); +} + +// Tests + +/** + * Enable local telemetry recording for the duration of the tests, and prepare + * the test data that will be used by the following tests. + */ +add_task(function test_initialize() { + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + do_register_cleanup(function () { + Services.telemetry.canRecordExtended = oldCanRecord; + }); + + let uniqueNumber = 1; + for (let loginModifications of StatisticsTestData) { + loginModifications.hostname = `http://${uniqueNumber++}.example.com`; + Services.logins.addLogin(TestData.formLogin(loginModifications)); + } +}); + +/** + * Tests the collection of statistics related to login metadata. + */ +add_task(function test_logins_statistics() { + // Repeat the operation twice to test that histograms are not accumulated. + for (let repeating of [false, true]) { + triggerStatisticsCollection(); + + // Should record 1 in the bucket corresponding to the number of passwords. + testHistogram("PWMGR_NUM_SAVED_PASSWORDS", + { 10: 1 }); + + // Should record 1 in the bucket corresponding to the number of passwords. + testHistogram("PWMGR_NUM_HTTPAUTH_PASSWORDS", + { 1: 1 }); + + // For each saved login, should record 1 in the bucket corresponding to the + // age in days since the login was last used. + testHistogram("PWMGR_LOGIN_LAST_USED_DAYS", + { 0: 1, 1: 1, 7: 2, 29: 2, 356: 2, 750: 1 }); + + // Should record the number of logins without a username in bucket 0, and + // the number of logins with a username in bucket 1. + testHistogram("PWMGR_USERNAME_PRESENT", + { 0: 4, 1: 6 }); + } +}); + +/** + * Tests the collection of statistics related to hosts for which passowrd saving + * has been explicitly disabled. + */ +add_task(function test_disabledHosts_statistics() { + // Should record 1 in the bucket corresponding to the number of sites for + // which password saving is disabled. + Services.logins.setLoginSavingEnabled("http://www.example.com", false); + triggerStatisticsCollection(); + testHistogram("PWMGR_BLOCKLIST_NUM_SITES", { 1: 1 }); + + Services.logins.setLoginSavingEnabled("http://www.example.com", true); + triggerStatisticsCollection(); + testHistogram("PWMGR_BLOCKLIST_NUM_SITES", { 0: 1 }); +}); + +/** + * Tests the collection of statistics related to general settings. + */ +add_task(function test_settings_statistics() { + let oldRememberSignons = Services.prefs.getBoolPref("signon.rememberSignons"); + do_register_cleanup(function () { + Services.prefs.setBoolPref("signon.rememberSignons", oldRememberSignons); + }); + + // Repeat the operation twice per value to test that histograms are reset. + for (let remember of [false, true, false, true]) { + // This change should be observed immediately by the login service. + Services.prefs.setBoolPref("signon.rememberSignons", remember); + + triggerStatisticsCollection(); + + // Should record 1 in either bucket 0 or bucket 1 based on the preference. + testHistogram("PWMGR_SAVING_ENABLED", remember ? { 1: 1 } : { 0: 1 }); + } +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_user_autocomplete_result.js b/toolkit/components/passwordmgr/test/unit/test_user_autocomplete_result.js new file mode 100644 index 000000000..e1d250a76 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_user_autocomplete_result.js @@ -0,0 +1,488 @@ +XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper", + "resource://gre/modules/LoginHelper.jsm"); +Cu.import("resource://gre/modules/LoginManagerContent.jsm"); +var nsLoginInfo = Components.Constructor("@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, "init"); + +const PREF_INSECURE_FIELD_WARNING_ENABLED = "security.insecure_field_warning.contextual.enabled"; +const PREF_INSECURE_AUTOFILLFORMS_ENABLED = "signon.autofillForms.http"; + +let matchingLogins = []; +matchingLogins.push(new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null, + "", "emptypass1", "uname", "pword")); + +matchingLogins.push(new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null, + "tempuser1", "temppass1", "uname", "pword")); + +matchingLogins.push(new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null, + "testuser2", "testpass2", "uname", "pword")); + +matchingLogins.push(new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null, + "testuser3", "testpass3", "uname", "pword")); + +matchingLogins.push(new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null, + "zzzuser4", "zzzpass4", "uname", "pword")); + +let meta = matchingLogins[0].QueryInterface(Ci.nsILoginMetaInfo); +let dateAndTimeFormatter = new Intl.DateTimeFormat(undefined, + { day: "numeric", month: "short", year: "numeric" }); +let time = dateAndTimeFormatter.format(new Date(meta.timePasswordChanged)); +const LABEL_NO_USERNAME = "No username (" + time + ")"; + +let expectedResults = [ + { + insecureFieldWarningEnabled: true, + insecureAutoFillFormsEnabled: true, + isSecure: true, + isPasswordField: false, + matchingLogins: matchingLogins, + items: [{ + value: "", + label: LABEL_NO_USERNAME, + style: "login", + }, { + value: "tempuser1", + label: "tempuser1", + style: "login", + }, { + value: "testuser2", + label: "testuser2", + style: "login", + }, { + value: "testuser3", + label: "testuser3", + style: "login", + }, { + value: "zzzuser4", + label: "zzzuser4", + style: "login", + }] + }, + { + insecureFieldWarningEnabled: true, + insecureAutoFillFormsEnabled: true, + isSecure: false, + isPasswordField: false, + matchingLogins: matchingLogins, + items: [{ + value: "", + label: "This connection is not secure. Logins entered here could be compromised. Learn More", + style: "insecureWarning" + }, { + value: "", + label: LABEL_NO_USERNAME, + style: "login", + }, { + value: "tempuser1", + label: "tempuser1", + style: "login", + }, { + value: "testuser2", + label: "testuser2", + style: "login", + }, { + value: "testuser3", + label: "testuser3", + style: "login", + }, { + value: "zzzuser4", + label: "zzzuser4", + style: "login", + }] + }, + { + insecureFieldWarningEnabled: true, + insecureAutoFillFormsEnabled: true, + isSecure: true, + isPasswordField: true, + matchingLogins: matchingLogins, + items: [{ + value: "emptypass1", + label: LABEL_NO_USERNAME, + style: "login", + }, { + value: "temppass1", + label: "tempuser1", + style: "login", + }, { + value: "testpass2", + label: "testuser2", + style: "login", + }, { + value: "testpass3", + label: "testuser3", + style: "login", + }, { + value: "zzzpass4", + label: "zzzuser4", + style: "login", + }] + }, + { + insecureFieldWarningEnabled: true, + insecureAutoFillFormsEnabled: true, + isSecure: false, + isPasswordField: true, + matchingLogins: matchingLogins, + items: [{ + value: "", + label: "This connection is not secure. Logins entered here could be compromised. Learn More", + style: "insecureWarning" + }, { + value: "emptypass1", + label: LABEL_NO_USERNAME, + style: "login", + }, { + value: "temppass1", + label: "tempuser1", + style: "login", + }, { + value: "testpass2", + label: "testuser2", + style: "login", + }, { + value: "testpass3", + label: "testuser3", + style: "login", + }, { + value: "zzzpass4", + label: "zzzuser4", + style: "login", + }] + }, + { + insecureFieldWarningEnabled: false, + insecureAutoFillFormsEnabled: true, + isSecure: true, + isPasswordField: false, + matchingLogins: matchingLogins, + items: [{ + value: "", + label: LABEL_NO_USERNAME, + style: "login", + }, { + value: "tempuser1", + label: "tempuser1", + style: "login", + }, { + value: "testuser2", + label: "testuser2", + style: "login", + }, { + value: "testuser3", + label: "testuser3", + style: "login", + }, { + value: "zzzuser4", + label: "zzzuser4", + style: "login", + }] + }, + { + insecureFieldWarningEnabled: false, + insecureAutoFillFormsEnabled: true, + isSecure: false, + isPasswordField: false, + matchingLogins: matchingLogins, + items: [{ + value: "", + label: LABEL_NO_USERNAME, + style: "login", + }, { + value: "tempuser1", + label: "tempuser1", + style: "login", + }, { + value: "testuser2", + label: "testuser2", + style: "login", + }, { + value: "testuser3", + label: "testuser3", + style: "login", + }, { + value: "zzzuser4", + label: "zzzuser4", + style: "login", + }] + }, + { + insecureFieldWarningEnabled: false, + insecureAutoFillFormsEnabled: true, + isSecure: true, + isPasswordField: true, + matchingLogins: matchingLogins, + items: [{ + value: "emptypass1", + label: LABEL_NO_USERNAME, + style: "login", + }, { + value: "temppass1", + label: "tempuser1", + style: "login", + }, { + value: "testpass2", + label: "testuser2", + style: "login", + }, { + value: "testpass3", + label: "testuser3", + style: "login", + }, { + value: "zzzpass4", + label: "zzzuser4", + style: "login", + }] + }, + { + insecureFieldWarningEnabled: false, + insecureAutoFillFormsEnabled: true, + isSecure: false, + isPasswordField: true, + matchingLogins: matchingLogins, + items: [{ + value: "emptypass1", + label: LABEL_NO_USERNAME, + style: "login", + }, { + value: "temppass1", + label: "tempuser1", + style: "login", + }, { + value: "testpass2", + label: "testuser2", + style: "login", + }, { + value: "testpass3", + label: "testuser3", + style: "login", + }, { + value: "zzzpass4", + label: "zzzuser4", + style: "login", + }] + }, + { + insecureFieldWarningEnabled: true, + insecureAutoFillFormsEnabled: false, + isSecure: true, + isPasswordField: false, + matchingLogins: matchingLogins, + items: [{ + value: "", + label: LABEL_NO_USERNAME, + style: "login", + }, { + value: "tempuser1", + label: "tempuser1", + style: "login", + }, { + value: "testuser2", + label: "testuser2", + style: "login", + }, { + value: "testuser3", + label: "testuser3", + style: "login", + }, { + value: "zzzuser4", + label: "zzzuser4", + style: "login", + }] + }, + { + insecureFieldWarningEnabled: true, + insecureAutoFillFormsEnabled: false, + isSecure: false, + isPasswordField: false, + matchingLogins: matchingLogins, + items: [{ + value: "", + label: "This connection is not secure. Logins entered here could be compromised. Learn More", + style: "insecureWarning" + }, { + value: "", + label: LABEL_NO_USERNAME, + style: "login", + }, { + value: "tempuser1", + label: "tempuser1", + style: "login", + }, { + value: "testuser2", + label: "testuser2", + style: "login", + }, { + value: "testuser3", + label: "testuser3", + style: "login", + }, { + value: "zzzuser4", + label: "zzzuser4", + style: "login", + }] + }, + { + insecureFieldWarningEnabled: true, + insecureAutoFillFormsEnabled: false, + isSecure: true, + isPasswordField: true, + matchingLogins: matchingLogins, + items: [{ + value: "emptypass1", + label: LABEL_NO_USERNAME, + style: "login", + }, { + value: "temppass1", + label: "tempuser1", + style: "login", + }, { + value: "testpass2", + label: "testuser2", + style: "login", + }, { + value: "testpass3", + label: "testuser3", + style: "login", + }, { + value: "zzzpass4", + label: "zzzuser4", + style: "login", + }] + }, + { + insecureFieldWarningEnabled: true, + insecureAutoFillFormsEnabled: false, + isSecure: false, + isPasswordField: true, + matchingLogins: matchingLogins, + items: [{ + value: "", + label: "This connection is not secure. Logins entered here could be compromised. Learn More", + style: "insecureWarning" + }, { + value: "emptypass1", + label: LABEL_NO_USERNAME, + style: "login", + }, { + value: "temppass1", + label: "tempuser1", + style: "login", + }, { + value: "testpass2", + label: "testuser2", + style: "login", + }, { + value: "testpass3", + label: "testuser3", + style: "login", + }, { + value: "zzzpass4", + label: "zzzuser4", + style: "login", + }] + }, + { + insecureFieldWarningEnabled: false, + insecureAutoFillFormsEnabled: false, + isSecure: true, + isPasswordField: false, + matchingLogins: matchingLogins, + items: [{ + value: "", + label: LABEL_NO_USERNAME, + style: "login", + }, { + value: "tempuser1", + label: "tempuser1", + style: "login", + }, { + value: "testuser2", + label: "testuser2", + style: "login", + }, { + value: "testuser3", + label: "testuser3", + style: "login", + }, { + value: "zzzuser4", + label: "zzzuser4", + style: "login", + }] + }, + { + insecureFieldWarningEnabled: false, + insecureAutoFillFormsEnabled: false, + isSecure: false, + isPasswordField: false, + matchingLogins: matchingLogins, + items: [] + }, + { + insecureFieldWarningEnabled: false, + insecureAutoFillFormsEnabled: false, + isSecure: true, + isPasswordField: true, + matchingLogins: matchingLogins, + items: [{ + value: "emptypass1", + label: LABEL_NO_USERNAME, + style: "login", + }, { + value: "temppass1", + label: "tempuser1", + style: "login", + }, { + value: "testpass2", + label: "testuser2", + style: "login", + }, { + value: "testpass3", + label: "testuser3", + style: "login", + }, { + value: "zzzpass4", + label: "zzzuser4", + style: "login", + }] + }, + { + insecureFieldWarningEnabled: false, + insecureAutoFillFormsEnabled: false, + isSecure: false, + isPasswordField: true, + matchingLogins: matchingLogins, + items: [] + }, +]; + +add_task(function* test_all_patterns() { + LoginHelper.createLogger("UserAutoCompleteResult"); + expectedResults.forEach(pattern => { + Services.prefs.setBoolPref(PREF_INSECURE_FIELD_WARNING_ENABLED, + pattern.insecureFieldWarningEnabled); + Services.prefs.setBoolPref(PREF_INSECURE_AUTOFILLFORMS_ENABLED, + pattern.insecureAutoFillFormsEnabled); + let actual = new UserAutoCompleteResult("", pattern.matchingLogins, + { + isSecure: pattern.isSecure, + isPasswordField: pattern.isPasswordField + }); + pattern.items.forEach((item, index) => { + equal(actual.getValueAt(index), item.value); + equal(actual.getLabelAt(index), item.label); + equal(actual.getStyleAt(index), item.style); + }); + + if (pattern.items.length != 0) { + Assert.throws(() => actual.getValueAt(pattern.items.length), + /Index out of range\./); + + Assert.throws(() => actual.getLabelAt(pattern.items.length), + /Index out of range\./); + + Assert.throws(() => actual.removeValueAt(pattern.items.length, true), + /Index out of range\./); + } + }); +}); diff --git a/toolkit/components/passwordmgr/test/unit/xpcshell.ini b/toolkit/components/passwordmgr/test/unit/xpcshell.ini new file mode 100644 index 000000000..8f8c92a28 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/xpcshell.ini @@ -0,0 +1,46 @@ +[DEFAULT] +head = head.js +tail = +support-files = data/** + +# Test JSON file access and import from SQLite, not applicable to Android. +[test_module_LoginImport.js] +skip-if = os == "android" +[test_module_LoginStore.js] +skip-if = os == "android" +[test_removeLegacySignonFiles.js] +skip-if = os == "android" + +# Test SQLite database backup and migration, applicable to Android only. +[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] +[test_logins_decrypt_failure.js] +skip-if = os == "android" # Bug 1171687: Needs fixing on Android +[test_user_autocomplete_result.js] +skip-if = os == "android" +[test_logins_metainfo.js] +[test_logins_search.js] +[test_maybeImportLogin.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] |