/* Any copyright is dedicated to the Public Domain. * http://creativecommons.org/publicdomain/zero/1.0/ * * Tests the AS RustLogins write-only mirror */ ("use strict"); const { LoginManagerRustStorage } = ChromeUtils.importESModule( "resource://gre/modules/storage-rust.sys.mjs" ); const { sinon } = ChromeUtils.importESModule( "resource://testing-common/Sinon.sys.mjs" ); /** * Tests addLogin gets synced to Rust Storage */ add_task(async function test_mirror_addLogin() { await SpecialPowers.pushPrefEnv({ set: [["signon.rustMirror.enabled", true]], }); const loginInfo = LoginTestUtils.testData.formLogin({ username: "username", password: "password", }); await Services.logins.addLoginAsync(loginInfo); // note LoginManagerRustStorage is a singleton and already initialized when // Services.logins gets initialized. const rustStorage = new LoginManagerRustStorage(); const storedLoginInfos = await Services.logins.getAllLogins(); const rustStoredLoginInfos = await rustStorage.getAllLogins(); LoginTestUtils.assertLoginListsEqual(storedLoginInfos, rustStoredLoginInfos); LoginTestUtils.clearData(); rustStorage.removeAllLogins(); await SpecialPowers.flushPrefEnv(); }); /** * Tests modifyLogin gets synced to Rust Storage */ add_task(async function test_mirror_modifyLogin() { await SpecialPowers.pushPrefEnv({ set: [["signon.rustMirror.enabled", true]], }); const loginInfo = LoginTestUtils.testData.formLogin({ username: "username", password: "password", }); await Services.logins.addLoginAsync(loginInfo); const rustStorage = new LoginManagerRustStorage(); const [storedLoginInfo] = await Services.logins.getAllLogins(); const modifiedLoginInfo = LoginTestUtils.testData.formLogin({ username: "username", password: "password", usernameField: "new_form_field_username", passwordField: "new_form_field_password", }); Services.logins.modifyLogin(storedLoginInfo, modifiedLoginInfo); const [storedModifiedLoginInfo] = await Services.logins.getAllLogins(); const [rustStoredModifiedLoginInfo] = await rustStorage.searchLoginsAsync({ guid: storedLoginInfo.guid, }); LoginTestUtils.assertLoginListsEqual( [storedModifiedLoginInfo], [rustStoredModifiedLoginInfo] ); LoginTestUtils.clearData(); rustStorage.removeAllLogins(); await SpecialPowers.flushPrefEnv(); }); /** * Tests removeLogin gets synced to Rust Storage */ add_task(async function test_mirror_removeLogin() { await SpecialPowers.pushPrefEnv({ set: [["signon.rustMirror.enabled", true]], }); const loginInfo = LoginTestUtils.testData.formLogin({ username: "username", password: "password", }); await Services.logins.addLoginAsync(loginInfo); const rustStorage = new LoginManagerRustStorage(); const [storedLoginInfo] = await Services.logins.getAllLogins(); Services.logins.removeLogin(storedLoginInfo); const allLogins = await rustStorage.getAllLogins(); Assert.equal(allLogins.length, 0); LoginTestUtils.clearData(); rustStorage.removeAllLogins(); await SpecialPowers.flushPrefEnv(); }); /** * Verifies that the migration is triggered by according pref change */ add_task(async function test_migration_is_triggered_by_pref_change() { // enable rust mirror, triggering migration await SpecialPowers.pushPrefEnv({ set: [["signon.rustMirror.enabled", false]], }); Assert.equal( Services.prefs.getBoolPref("signon.rustMirror.migrationNeeded", false), true, "migrationNeeded is set to true" ); const prefChangePromise = TestUtils.waitForPrefChange( "signon.rustMirror.migrationNeeded" ); // enable rust mirror, triggering migration await SpecialPowers.pushPrefEnv({ set: [["signon.rustMirror.enabled", true]], }); await prefChangePromise; Assert.equal( Services.prefs.getBoolPref("signon.rustMirror.migrationNeeded", false), false, "migrationNeeded is set to false" ); await SpecialPowers.flushPrefEnv(); }); /** * Verifies that the migration is idempotent by ensuring that running * it multiple times does not create duplicate logins in the Rust store. */ add_task(async function test_migration_is_idempotent() { // ensure mirror is on await SpecialPowers.pushPrefEnv({ set: [["signon.rustMirror.enabled", true]], }); const login = LoginTestUtils.testData.formLogin({ username: "test-user", password: "secure-password", }); await Services.logins.addLoginAsync(login); const rustStorage = new LoginManagerRustStorage(); let rustLogins = await rustStorage.getAllLogins(); Assert.equal( rustLogins.length, 1, "Rust store contains login after first migration" ); // trigger again await SpecialPowers.pushPrefEnv({ set: [["signon.rustMirror.enabled", false]], }); // using the migrationNeeded pref change as an indicator that the migration did run const prefChangePromise = TestUtils.waitForPrefChange( "signon.rustMirror.migrationNeeded" ); await SpecialPowers.pushPrefEnv({ set: [["signon.rustMirror.enabled", true]], }); await prefChangePromise; rustLogins = await rustStorage.getAllLogins(); Assert.equal(rustLogins.length, 1, "No duplicate after second migration"); LoginTestUtils.clearData(); rustStorage.removeAllLogins(); await SpecialPowers.flushPrefEnv(); }); /** * Verify that the migration: * - continues when some rows fail (partial failure), * - still migrates valid logins, */ add_task(async function test_migration_partial_failure() { // ensure mirror is off await SpecialPowers.pushPrefEnv({ set: [["signon.rustMirror.enabled", false]], }); const rustStorage = new LoginManagerRustStorage(); // Save the first (valid) login into Rust for real, then simulate results sinon.stub(rustStorage, "addLoginsAsync").callsFake(async (logins, _cont) => { await rustStorage.addWithMeta(logins[0]); return [ { login: {}, error: null }, // row 0 success { login: null, error: { message: "row failed" } }, // row 1 failure ]; }); const login_ok = LoginTestUtils.testData.formLogin({ username: "test-user-ok", password: "secure-password", }); await Services.logins.addLoginAsync(login_ok); const login_bad = LoginTestUtils.testData.formLogin({ username: "test-user-bad", password: "secure-password", }); await Services.logins.addLoginAsync(login_bad); // trigger again await SpecialPowers.pushPrefEnv({ set: [["signon.rustMirror.enabled", false]], }); await SpecialPowers.pushPrefEnv({ set: [["signon.rustMirror.enabled", true]], }); // and wait a little, due to the lack of a migration-complete event. // eslint-disable-next-line mozilla/no-arbitrary-setTimeout await new Promise(resolve => setTimeout(resolve, 200)); const rustLogins = await rustStorage.getAllLogins(); Assert.equal(rustLogins.length, 1, "only valid login migrated"); sinon.restore(); LoginTestUtils.clearData(); rustStorage.removeAllLogins(); await SpecialPowers.flushPrefEnv(); }); /** * Verify that when the bulk add operation rejects (hard failure), * the migration itself rejects. */ add_task(async function test_migration_rejects_when_bulk_add_rejects() { // turn mirror off await SpecialPowers.pushPrefEnv({ set: [["signon.rustMirror.enabled", false]], }); const rustStorage = new LoginManagerRustStorage(); // force the bulk add to fail sinon.stub(rustStorage, "addLoginsAsync").rejects(new Error("bulk failed")); const login = LoginTestUtils.testData.formLogin({ username: "test-user", password: "secure-password", }); await Services.logins.addLoginAsync(login); // trigger again await SpecialPowers.pushPrefEnv({ set: [["signon.rustMirror.enabled", false]], }); await SpecialPowers.pushPrefEnv({ set: [["signon.rustMirror.enabled", true]], }); // and wait a little, due to the lack of a migration-complete event. // eslint-disable-next-line mozilla/no-arbitrary-setTimeout await new Promise(resolve => setTimeout(resolve, 200)); const rustLogins = await rustStorage.getAllLogins(); Assert.equal(rustLogins.length, 0, "zero logins migrated"); const newPrefValue = Services.prefs.getBoolPref( "signon.rustMirror.migrationNeeded", false ); Assert.equal(newPrefValue, true, "pref has not been reset"); sinon.restore(); LoginTestUtils.clearData(); rustStorage.removeAllLogins(); await SpecialPowers.flushPrefEnv(); }); /** * Tests that rust_migration_failure events are recorded * when a migration run encounters entry errors. */ add_task(async function test_rust_migration_failure_event() { // ensure mirror is off first await SpecialPowers.pushPrefEnv({ set: [["signon.rustMirror.enabled", false]], }); Services.fog.testResetFOG(); const rustStorage = new LoginManagerRustStorage(); // Stub addLoginsAsync to simulate a failure for one entry sinon .stub(rustStorage, "addLoginsAsync") .callsFake(async (_logins, _cont) => { return [ { login: {}, error: null }, // success { login: null, error: { message: "simulated migration failure" } }, // failure ]; }); // Add two logins to JSON so migration has something to work on const login_ok = LoginTestUtils.testData.formLogin({ username: "ok-user", password: "secure-password", }); await Services.logins.addLoginAsync(login_ok); const login_bad = LoginTestUtils.testData.formLogin({ username: "bad-user", password: "secure-password", }); await Services.logins.addLoginAsync(login_bad); // Trigger migration await SpecialPowers.pushPrefEnv({ set: [["signon.rustMirror.enabled", true]], }); await BrowserTestUtils.waitForCondition( () => Glean.pwmgr.rustMigrationFailure.testGetValue()?.length == 1, "event has been emitted" ); const [evt] = Glean.pwmgr.rustMigrationFailure.testGetValue(); Assert.ok(evt.extra?.run_id, "event has a run_id"); Assert.equal( evt.extra?.error_message, "simulated migration failure", "event has the expected error message" ); Assert.equal(evt.name, "rust_migration_failure", "event has correct name"); sinon.restore(); LoginTestUtils.clearData(); rustStorage.removeAllLogins(); await SpecialPowers.flushPrefEnv(); }); /** * Ensures that migrating a large number of logins (100) from the JSON store to * the Rust store completes within a reasonable time frame (under 1 second). **/ add_task(async function test_migration_time_under_threshold() { // ensure mirror is off await SpecialPowers.pushPrefEnv({ set: [["signon.rustMirror.enabled", false]], }); const numberOfLogins = 100; const logins = Array.from({ length: numberOfLogins }, (_, i) => LoginTestUtils.testData.formLogin({ origin: `https://www${i}.example.com`, username: `user${i}`, }) ); await Services.logins.addLogins(logins); await LoginTestUtils.reloadData(); const rustStorage = new LoginManagerRustStorage(); const start = Date.now(); // using the migrationNeeded pref change as an indicator that the migration did run const prefChangePromise = TestUtils.waitForPrefChange( "signon.rustMirror.migrationNeeded" ); await SpecialPowers.pushPrefEnv({ set: [["signon.rustMirror.enabled", true]], }); await prefChangePromise; const duration = Date.now() - start; Assert.less(duration, 2000, "Migration should complete under 2s"); Assert.equal(rustStorage.countLogins("", "", ""), numberOfLogins); LoginTestUtils.clearData(); rustStorage.removeAllLogins(); await SpecialPowers.flushPrefEnv(); }); /* * Tests that an error is logged when adding an invalid login to the Rust store. * The Rust store is stricter than the JSON store and rejects some formats, * such as single-dot origins. */ add_task(async function test_rust_mirror_addLogin_failure() { // ensure mirror is on, and reset poisoned flag await SpecialPowers.pushPrefEnv({ set: [ ["signon.rustMirror.enabled", true], ["signon.rustMirror.poisoned", false], ], }); Services.fog.testResetFOG(); // This login will be accepted by JSON but rejected by Rust const badLogin = LoginTestUtils.testData.formLogin({ origin: ".", passwordField: ".", }); await Services.logins.addLoginAsync(badLogin); const allLoginsJson = await Services.logins.getAllLogins(); Assert.equal( allLoginsJson.length, 1, "single dot origin login saved to JSON" ); await BrowserTestUtils.waitForCondition( () => Glean.pwmgr.rustMirrorStatus.testGetValue()?.length == 1, "event has been emitted" ); const rustStorage = new LoginManagerRustStorage(); const allLogins = await rustStorage.getAllLogins(); Assert.equal( allLogins.length, 0, "single dot origin login not saved to Rust" ); const [evt] = Glean.pwmgr.rustMirrorStatus.testGetValue(); Assert.ok(evt, "event has been emitted"); Assert.equal(evt.extra?.operation, "add", "event has operation"); Assert.equal(evt.extra?.status, "failure", "event has status=failure"); Assert.equal( evt.extra?.error_message, "Invalid login: Login has illegal origin", "event has error_message" ); Assert.equal(evt.extra?.poisoned, "false", "event is not poisoned"); Assert.equal(evt.name, "rust_mirror_status", "event has name"); // produce another failure const badLogin2 = LoginTestUtils.testData.formLogin({ username: "another-bad-login", origin: ".", passwordField: ".", }); await Services.logins.addLoginAsync(badLogin2); await BrowserTestUtils.waitForCondition( () => Glean.pwmgr.rustMirrorStatus.testGetValue()?.length == 2, "two events have been emitted" ); // eslint-disable-next-line no-unused-vars const [_, evt2] = Glean.pwmgr.rustMirrorStatus.testGetValue(); Assert.equal(evt2.extra?.poisoned, "true", "event is poisoned now"); LoginTestUtils.clearData(); await SpecialPowers.flushPrefEnv(); }); /* * Tests that we collect telemetry if non-ASCII origins get punycoded. */ add_task(async function test_punycode_origin_metric() { // ensure mirror is on await SpecialPowers.pushPrefEnv({ set: [["signon.rustMirror.enabled", true]], }); Services.fog.testResetFOG(); const punicodeOrigin = "https://münich.example.com"; const login = LoginTestUtils.testData.formLogin({ origin: punicodeOrigin, formActionOrigin: "https://example.com", username: "user1", password: "pass1", }); await Services.logins.addLoginAsync(login); await BrowserTestUtils.waitForCondition( () => Glean.pwmgr.rustIncompatibleLoginFormat.testGetValue()?.length == 1, "event has been emitted" ); const rustStorage = new LoginManagerRustStorage(); const allLogins = await rustStorage.getAllLogins(); Assert.equal(allLogins.length, 1, "punicode origin login saved to Rust"); const [rustLogin] = allLogins; Assert.equal( rustLogin.origin, "https://xn--mnich-kva.example.com", "origin has been punicoded on the Rust side" ); const [evt] = Glean.pwmgr.rustIncompatibleLoginFormat.testGetValue(); Assert.equal(evt.extra?.issue, "nonAsciiOrigin"); Assert.equal(evt.extra?.operation, "add"); Assert.ok("run_id" in evt.extra); LoginTestUtils.clearData(); rustStorage.removeAllLogins(); await SpecialPowers.flushPrefEnv(); }); /* * Tests that we collect telemetry if non-ASCII formorigins get punycoded. */ add_task(async function test_punycode_formActionOrigin_metric() { // ensure mirror is on await SpecialPowers.pushPrefEnv({ set: [["signon.rustMirror.enabled", true]], }); Services.fog.testResetFOG(); const punicodeOrigin = "https://münich.example.com"; const login = LoginTestUtils.testData.formLogin({ formActionOrigin: punicodeOrigin, origin: "https://example.com", username: "user1", password: "pass1", }); await Services.logins.addLoginAsync(login); await BrowserTestUtils.waitForCondition( () => Glean.pwmgr.rustIncompatibleLoginFormat.testGetValue()?.length == 1, "event has been emitted" ); const rustStorage = new LoginManagerRustStorage(); const allLogins = await rustStorage.getAllLogins(); Assert.equal(allLogins.length, 1, "punicode origin login saved to Rust"); const [rustLogin] = allLogins; Assert.equal( rustLogin.formActionOrigin, "https://xn--mnich-kva.example.com", "origin has been punicoded on the Rust side" ); const [evt] = Glean.pwmgr.rustIncompatibleLoginFormat.testGetValue(); Assert.equal(evt.extra?.issue, "nonAsciiFormAction"); Assert.equal(evt.extra?.operation, "add"); Assert.ok("run_id" in evt.extra); LoginTestUtils.clearData(); rustStorage.removeAllLogins(); await SpecialPowers.flushPrefEnv(); }); /* * Tests that we collect telemetry for single dot in origin */ add_task(async function test_single_dot_in_origin() { // ensure mirror is on await SpecialPowers.pushPrefEnv({ set: [["signon.rustMirror.enabled", true]], }); Services.fog.testResetFOG(); const badOrigin = "."; const login = LoginTestUtils.testData.formLogin({ origin: badOrigin, formActionOrigin: "https://example.com", username: "user1", password: "pass1", }); await Services.logins.addLoginAsync(login); await BrowserTestUtils.waitForCondition( () => Glean.pwmgr.rustIncompatibleLoginFormat.testGetValue()?.length == 1, "event has been emitted" ); const [evt] = Glean.pwmgr.rustIncompatibleLoginFormat.testGetValue(); Assert.equal(evt.extra?.issue, "dotOrigin"); Assert.equal(evt.extra?.operation, "add"); Assert.ok("run_id" in evt.extra); LoginTestUtils.clearData(); await SpecialPowers.flushPrefEnv(); }); /* * Tests that we collect telemetry if the username contains line breaks. */ add_task(async function test_username_linebreak_metric() { // ensure mirror is on await SpecialPowers.pushPrefEnv({ set: [["signon.rustMirror.enabled", true]], }); Services.fog.testResetFOG(); const login = LoginTestUtils.testData.formLogin({ origin: "https://example.com", formActionOrigin: "https://example.com", username: "user\nname", password: "pass1", }); await Services.logins.addLoginAsync(login); await BrowserTestUtils.waitForCondition( () => Glean.pwmgr.rustIncompatibleLoginFormat.testGetValue()?.length == 1, "event has been emitted" ); const [evt] = Glean.pwmgr.rustIncompatibleLoginFormat.testGetValue(); Assert.equal(evt.extra?.issue, "usernameLineBreak"); Assert.equal(evt.extra?.operation, "add"); Assert.ok("run_id" in evt.extra); LoginTestUtils.clearData(); const rustStorage = new LoginManagerRustStorage(); rustStorage.removeAllLogins(); await SpecialPowers.flushPrefEnv(); }); /** * Tests that a rust_migration_performance event is recorded after migration, * containing both duration and total number of migrated logins. */ add_task(async function test_migration_performance_probe() { // ensure mirror is off await SpecialPowers.pushPrefEnv({ set: [["signon.rustMirror.enabled", false]], }); Services.fog.testResetFOG(); const login = LoginTestUtils.testData.formLogin({ username: "perf-user", password: "perf-password", }); await Services.logins.addLoginAsync(login); // using the migrationNeeded pref change as an indicator that the migration did run const prefChangePromise = TestUtils.waitForPrefChange( "signon.rustMirror.migrationNeeded" ); await SpecialPowers.pushPrefEnv({ set: [["signon.rustMirror.enabled", true]], }); await prefChangePromise; const [evt] = Glean.pwmgr.rustMigrationStatus.testGetValue(); Assert.ok(evt, "rustMigrationStatus event should have been emitted"); Assert.equal( evt.extra?.number_of_logins_to_migrate, 1, "event should record number of logins to migrate" ); Assert.equal( evt.extra?.number_of_logins_migrated, 1, "event should record number of logins migrated" ); Assert.equal( evt.extra?.had_errors, "false", "event should record a boolean indicating migration errors" ); Assert.greaterOrEqual( parseInt(evt.extra?.duration_ms, 10), 0, "event should record non-negative duration in ms" ); sinon.restore(); LoginTestUtils.clearData(); await SpecialPowers.flushPrefEnv(); });