Source code

Revision control

Copy as Markdown

Other Tools

/* Any copyright is dedicated to the Public Domain.
/**
* Shared functions generally available for testing login components.
*/
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
});
import { Assert as AssertCls } from "resource://testing-common/Assert.sys.mjs";
let Assert = AssertCls;
import { TestUtils } from "resource://testing-common/TestUtils.sys.mjs";
import { setTimeout } from "resource://gre/modules/Timer.sys.mjs";
import { FileTestUtils } from "resource://testing-common/FileTestUtils.sys.mjs";
const LoginInfo = Components.Constructor(
"@mozilla.org/login-manager/loginInfo;1",
"nsILoginInfo",
"init"
);
export const 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.removeAllUserFacingLogins();
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,
});
return Services.logins.addLoginAsync(login);
},
/**
* Removes a login from the store
*/
async removeLogin(login) {
return Services.logins.removeLogin(login);
},
async modifyLogin(oldLogin, newLogin) {
const storageChangedPromise = TestUtils.topicObserved(
"passwordmgr-storage-changed",
(_, data) => data == "modifyLogin"
);
Services.logins.modifyLogin(oldLogin, newLogin);
await storageChangedPromise;
},
resetGeneratedPasswordsCache() {
let { LoginManagerParent } = ChromeUtils.importESModule(
"resource://gre/modules/LoginManagerParent.sys.mjs"
);
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.
*/
async checkLogins(expectedLogins, msg = "checkLogins", checkFn = undefined) {
this.assertLoginListsEqual(
await 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(
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(
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(
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(
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(
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(
null,
"the username",
"the password",
"form_field_username",
"form_field_password"
),
new LoginInfo(
null,
"the username",
"the password",
"form_field_username",
"form_field_password"
),
new LoginInfo(
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(
null,
"username one",
"password one",
"form_field_username",
"form_field_password"
),
new LoginInfo(
null,
"username two",
"password two",
"form_field_username",
"form_field_password"
),
new LoginInfo(
null,
"",
"password three",
"form_field_username",
"form_field_password"
),
// Username and passwords fields in forms may have no "name" attribute.
new LoginInfo(
null,
"multi username",
"multi password",
"",
""
),
// Forms with PIN-type authentication will typically have no username.
new LoginInfo(
null,
"",
"12345",
"",
"form_field_password"
),
// Logins can be saved on non-default ports
new LoginInfo(
null,
"8080_username",
"8080_pass"
),
new LoginInfo(
null,
"My dev server",
"8080_username2",
"8080_pass2"
),
// --- Examples of authentication logins (subdomains of example.org) ---
// Simple HTTP authentication login.
new LoginInfo(
null,
"The HTTP Realm",
"the username",
"the password"
),
// Simple FTP authentication login.
new LoginInfo(
null,
"the username",
"the password"
),
// Multiple HTTP authentication logins can be stored for different realms.
new LoginInfo(
null,
"The HTTP Realm",
"the username",
"the password"
),
new LoginInfo(
null,
"The HTTP Realm Other",
"the username other",
"the password other"
),
// --- Both form and authentication logins (example.net) ---
new LoginInfo(
null,
"the username",
"the password",
"form_field_username",
"form_field_password"
),
new LoginInfo(
null,
"the username",
"the password",
"form_field_username",
"form_field_password"
),
new LoginInfo(
null,
"username two",
"the password",
"form_field_username",
"form_field_password"
),
new LoginInfo(
null,
"The HTTP Realm",
"the username",
"the password"
),
new LoginInfo(
null,
"The HTTP Realm Other",
"username two",
"the password"
),
new LoginInfo(
null,
"the username",
"the password"
),
// null formActionOrigin, empty httpRealm
new LoginInfo(
null,
"",
"the username",
"the password"
),
// --- Examples of logins added by extensions (chrome scheme) ---
new LoginInfo(
null,
"Example Login One",
"the username",
"the password one",
"",
""
),
new LoginInfo(
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(
"javascript:",
null,
"javascript: username",
"javascript: password"
),
];
},
};
LoginTestUtils.recipes = {
getRecipeParent() {
let { LoginManagerParent } = ChromeUtils.importESModule(
"resource://gre/modules/LoginManagerParent.sys.mjs"
);
if (!LoginManagerParent.recipeParentPromise) {
return null;
}
return LoginManagerParent.recipeParentPromise.then(recipeParent => {
return recipeParent;
});
},
};
LoginTestUtils.primaryPassword = {
primaryPassword: "omgsecret!",
_set(enable, stayLoggedIn) {
let oldPW, newPW;
if (enable) {
oldPW = "";
newPW = this.primaryPassword;
} else {
oldPW = this.primaryPassword;
newPW = "";
}
try {
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);
if (!stayLoggedIn) {
token.logoutSimple();
}
}
} catch (e) {
dump(
"Tried to enable an already enabled primary password or disable an already disabled primary password!"
);
}
},
enable(stayLoggedIn = false) {
this._set(true, stayLoggedIn);
},
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
) {
// The test is already unreliable (see bug 1627419 and 1605494) and relied on
// the implicit 100ms initial timer of waitForCondition that bug 1596165 removed.
await new Promise(resolve => setTimeout(resolve, 100));
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.
* @param {string} extension
* Optional parameter. Either 'csv' or 'tsv'. Default is 'csv'.
* @returns {window.File} The File to the CSV file that was created.
*/
async setupCsvFileWithLines(csvLines, extension = "csv") {
let tmpFile = FileTestUtils.getTempFile(`firefox_logins.${extension}`);
await IOUtils.writeUTF8(tmpFile.path, csvLines.join("\r\n"));
return tmpFile;
},
};
LoginTestUtils.remoteSettings = {
relatedRealmsCollection: "websites-with-shared-credential-backends",
async setupWebsitesWithSharedCredentials(
relatedRealms = [["other-example.com", "example.com", "example.co.uk"]]
) {
let db = lazy.RemoteSettings(this.relatedRealmsCollection).db;
await db.clear();
await db.create({
id: "some-fake-ID-abc",
relatedRealms,
});
await db.importChanges({}, Date.now());
},
async cleanWebsitesWithSharedCredentials() {
let db = lazy.RemoteSettings(this.relatedRealmsCollection).db;
await db.importChanges({}, Date.now(), [], { clear: true });
},
improvedPasswordRulesCollection: "password-rules",
async setupImprovedPasswordRules(
origin = "example.com",
rules = "minlength: 6; maxlength: 16; required: lower, upper; required: digit; required: [&<>'\"!#$%(),:;=?[^`{|}~]]; max-consecutive: 2;"
) {
let db = lazy.RemoteSettings(this.improvedPasswordRulesCollection).db;
await db.clear();
await db.create({
id: "some-fake-ID",
Domain: origin,
"password-rules": rules,
});
await db.create({
id: "some-fake-ID-2",
Domain: origin,
"password-rules": rules,
});
await db.importChanges({}, Date.now());
},
async cleanImprovedPasswordRules() {
let db = lazy.RemoteSettings(this.improvedPasswordRulesCollection).db;
await db.importChanges({}, Date.now(), [], { clear: true });
},
};