gecko-dev/toolkit/components/passwordmgr/test/LoginTestUtils.jsm

610 строки
16 KiB
JavaScript

/* 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";
const EXPORTED_SYMBOLS = ["LoginTestUtils"];
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
let { Assert: AssertCls } = ChromeUtils.import(
"resource://testing-common/Assert.jsm"
);
let Assert = AssertCls;
const { TestUtils } = ChromeUtils.import(
"resource://testing-common/TestUtils.jsm"
);
const { FileTestUtils } = ChromeUtils.import(
"resource://testing-common/FileTestUtils.jsm"
);
const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
const LoginInfo = Components.Constructor(
"@mozilla.org/login-manager/loginInfo;1",
"nsILoginInfo",
"init"
);
this.LoginTestUtils = {
setAssertReporter(reporterFunc) {
Assert = new AssertCls(Cu.waiveXrays(reporterFunc));
},
/**
* Forces the storage module to save all data, and the Login Manager service
* to replace the storage module with a newly initialized instance.
*/
async reloadData() {
Services.obs.notifyObservers(null, "passwordmgr-storage-replace");
await TestUtils.topicObserved("passwordmgr-storage-replace-complete");
},
/**
* Erases all the data stored by the Login Manager service.
*/
clearData() {
Services.logins.removeAllLogins();
for (let origin of Services.logins.getAllDisabledHosts()) {
Services.logins.setLoginSavingEnabled(origin, true);
}
},
/**
* Add a new login to the store
*/
async addLogin({
username,
password,
origin = "https://example.com",
formActionOrigin,
}) {
const login = LoginTestUtils.testData.formLogin({
origin,
formActionOrigin: formActionOrigin || origin,
username,
password,
});
let storageChangedPromised = TestUtils.topicObserved(
"passwordmgr-storage-changed",
(_, data) => data == "addLogin"
);
Services.logins.addLogin(login);
let [savedLogin] = await storageChangedPromised;
return savedLogin;
},
resetGeneratedPasswordsCache() {
let { LoginManagerParent } = ChromeUtils.import(
"resource://gre/modules/LoginManagerParent.jsm"
);
LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().clear();
},
/**
* Checks that the currently stored list of nsILoginInfo matches the provided
* array. If no `checkFn` is provided, the comparison uses the "equals"
* method of nsILoginInfo, that does not include nsILoginMetaInfo properties in the test.
*/
checkLogins(expectedLogins, msg = "checkLogins", checkFn = undefined) {
this.assertLoginListsEqual(
Services.logins.getAllLogins(),
expectedLogins,
msg,
checkFn
);
},
/**
* Checks that the two provided arrays of nsILoginInfo have the same length,
* and every login in "expected" is also found in "actual". If no `checkFn`
* is provided, the comparison uses the "equals" method of nsILoginInfo, that
* does not include nsILoginMetaInfo properties in the test.
*/
assertLoginListsEqual(
actual,
expected,
msg = "assertLoginListsEqual",
checkFn = undefined
) {
Assert.equal(expected.length, actual.length, msg);
Assert.ok(
expected.every(e =>
actual.some(a => {
return checkFn ? checkFn(a, e) : a.equals(e);
})
),
msg
);
},
/**
* 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.
*/
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)) {
if (name == "httpRealm" && value !== null) {
throw new Error("httpRealm not supported for form logins");
}
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)) {
if (name == "formActionOrigin" && value !== null) {
throw new Error(
"formActionOrigin not supported for HTTP auth. logins"
);
}
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 can be treated as completely different sites depending on the UI invoked.
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 origin, but with different origins 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"
),
// Logins can be saved on non-default ports
new LoginInfo(
"https://www7.example.com:8080",
"https://www7.example.com:8080",
null,
"8080_username",
"8080_pass"
),
new LoginInfo(
"https://www7.example.com:8080",
null,
"My dev server",
"8080_username2",
"8080_pass2"
),
// --- 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"
),
// -- file:// URIs throw accessing nsIURI.host
new LoginInfo(
"file://",
"file://",
null,
"file: username",
"file: password"
),
// -- javascript: URIs throw accessing nsIURI.host.
// They should only be used for the formActionOrigin.
new LoginInfo(
"https://js.example.com",
"javascript:",
null,
"javascript: username",
"javascript: password"
),
];
},
};
LoginTestUtils.recipes = {
getRecipeParent() {
let { LoginManagerParent } = ChromeUtils.import(
"resource://gre/modules/LoginManagerParent.jsm"
);
if (!LoginManagerParent.recipeParentPromise) {
return null;
}
return LoginManagerParent.recipeParentPromise.then(recipeParent => {
return recipeParent;
});
},
};
LoginTestUtils.masterPassword = {
masterPassword: "omgsecret!",
_set(enable) {
let oldPW, newPW;
if (enable) {
oldPW = "";
newPW = this.masterPassword;
} else {
oldPW = this.masterPassword;
newPW = "";
}
// Set master password. Note that this logs in the user if no password was
// set before. But after logging out the next invocation of pwmgr can
// trigger a MP prompt.
let pk11db = Cc["@mozilla.org/security/pk11tokendb;1"].getService(
Ci.nsIPK11TokenDB
);
let token = pk11db.getInternalKeyToken();
if (token.needsUserInit) {
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);
token.logoutSimple();
}
},
enable() {
this._set(true);
},
disable() {
this._set(false);
},
};
/**
* Utilities related to interacting with login fields in content.
*/
LoginTestUtils.loginField = {
checkPasswordMasked(field, expected, msg) {
let { editor } = field;
let valueLength = field.value.length;
Assert.equal(
editor.autoMaskingEnabled,
expected,
`Check autoMaskingEnabled: ${msg}`
);
Assert.equal(editor.unmaskedStart, 0, `unmaskedStart is 0: ${msg}`);
if (expected) {
Assert.equal(editor.unmaskedEnd, 0, `Password is masked: ${msg}`);
} else {
Assert.equal(
editor.unmaskedEnd,
valueLength,
`Unmasked to the end: ${msg}`
);
}
},
};
LoginTestUtils.generation = {
LENGTH: 15,
REGEX: /^[a-km-np-zA-HJ-NP-Z2-9]{15}$/,
};
LoginTestUtils.telemetry = {
async waitForEventCount(
count,
process = "content",
category = "pwmgr",
method = undefined
) {
let events = await TestUtils.waitForCondition(() => {
let events = Services.telemetry.snapshotEvents(
Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
false
)[process];
if (!events) {
return null;
}
events = events.filter(
e => e[1] == category && (!method || e[2] == method)
);
dump(`Waiting for ${count} events, got ${events.length}\n`);
return events.length == count ? events : null;
}, "waiting for telemetry event count of: " + count);
Assert.equal(events.length, count, "waiting for telemetry event count");
return events;
},
};
LoginTestUtils.file = {
/**
* Given an array of strings it creates a temporary CSV file that has them as content.
*
* @param {string[]} csvLines
* The lines that make up the CSV file.
* @returns {window.File} The File to the CSV file that was created.
*/
async setupCsvFileWithLines(csvLines) {
let tmpFile = FileTestUtils.getTempFile("firefox_logins.csv");
await OS.File.writeAtomic(
tmpFile.path,
new TextEncoder().encode(csvLines.join("\r\n"))
);
return tmpFile;
},
};