tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 0c730e993de2c7c247d767968d81dbab50591f69
parent 6af9eae6467ea546ffa5f6be701791cafe6df9eb
Author: Tessa Heidkamp <theidkamp@mozilla.com>
Date:   Thu,  2 Oct 2025 09:07:02 +0000

Bug 1977783 - Add Telemetry for JSON to Rust Storage r=dimi,data-stewards

Differential Revision: https://phabricator.services.mozilla.com/D260959

Diffstat:
Mtoolkit/components/passwordmgr/LoginManagerRustMirror.sys.mjs | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtoolkit/components/passwordmgr/metrics.yaml | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtoolkit/components/passwordmgr/storage-rust.sys.mjs | 19++++++++++++-------
Mtoolkit/components/passwordmgr/test/browser/browser_rust_mirror.js | 380+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
4 files changed, 628 insertions(+), 26 deletions(-)

diff --git a/toolkit/components/passwordmgr/LoginManagerRustMirror.sys.mjs b/toolkit/components/passwordmgr/LoginManagerRustMirror.sys.mjs @@ -7,6 +7,97 @@ ChromeUtils.defineESModuleGetters(lazy, { LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", }); +/* Check if an url has punicode encoded hostname */ +function isPunycode(origin) { + try { + return origin && new URL(origin).hostname.startsWith("xn--"); + } catch (_) { + return false; + } +} + +function recordIncompatibleFormats(runId, operation, loginInfo) { + if (isPunycode(loginInfo.origin)) { + Glean.pwmgr.rustIncompatibleLoginFormat.record({ + run_id: runId, + issue: "nonAsciiOrigin", + operation, + }); + } + if (isPunycode(loginInfo.formActionOrigin)) { + Glean.pwmgr.rustIncompatibleLoginFormat.record({ + run_id: runId, + issue: "nonAsciiFormAction", + operation, + }); + } + + if (loginInfo.origin === ".") { + Glean.pwmgr.rustIncompatibleLoginFormat.record({ + run_id: runId, + issue: "dotOrigin", + operation, + }); + } + + if ( + loginInfo.username?.includes("\n") || + loginInfo.username?.includes("\r") + ) { + Glean.pwmgr.rustIncompatibleLoginFormat.record({ + run_id: runId, + issue: "usernameLineBreak", + operation, + }); + } +} + +function recordMirrorStatus(runId, operation, status, error = null) { + const poisoned = Services.prefs.getBoolPref( + "signon.rustMirror.poisoned", + false + ); + + let errorMessage = ""; + if (error) { + errorMessage = error.message ?? String(error); + } + + Glean.pwmgr.rustMirrorStatus.record({ + run_id: runId, + operation, + status, + error_message: errorMessage, + poisoned, + }); + + if (status === "failure" && !poisoned) { + Services.prefs.setBoolPref("signon.rustMirror.poisoned", true); + } +} + +function recordMigrationStatus( + runId, + duration, + numberOfLoginsToMigrate, + numberOfLoginsMigrated +) { + Glean.pwmgr.rustMigrationStatus.record({ + run_id: runId, + duration_ms: duration, + number_of_logins_to_migrate: numberOfLoginsToMigrate, + number_of_logins_migrated: numberOfLoginsMigrated, + had_errors: numberOfLoginsMigrated < numberOfLoginsToMigrate, + }); +} + +function recordMigrationFailure(runId, error) { + Glean.pwmgr.rustMigrationFailure.record({ + run_id: runId, + error_message: error.message ?? String(error), + }); +} + export class LoginManagerRustMirror { #logger = null; #jsonStorage = null; @@ -69,6 +160,7 @@ export class LoginManagerRustMirror { this.#logger.log("Rust Mirror is enabled."); } catch (e) { this.#logger.error("Login migration failed", e); + recordMirrorStatus("migration-enable", "failure", e); } } @@ -100,6 +192,7 @@ export class LoginManagerRustMirror { return; } + const runId = Services.uuid.generateUUID(); let loginToModify; let newLoginData; @@ -107,10 +200,13 @@ export class LoginManagerRustMirror { case "addLogin": this.#logger.log(`adding login ${subject.guid}...`); try { + recordIncompatibleFormats(runId, "add", subject); await this.#rustStorage.addLoginsAsync([subject]); + recordMirrorStatus(runId, "add", "success"); this.#logger.log(`added login ${subject.guid}.`); } catch (e) { this.#logger.error("mirror-error:", e); + recordMirrorStatus(runId, "add", "failure", e); } break; @@ -119,10 +215,13 @@ export class LoginManagerRustMirror { newLoginData = subject.queryElementAt(1, Ci.nsILoginInfo); this.#logger.log(`modifying login ${loginToModify.guid}...`); try { + recordIncompatibleFormats(runId, "modify", newLoginData); this.#rustStorage.modifyLogin(loginToModify, newLoginData); + recordMirrorStatus(runId, "modify", "success"); this.#logger.log(`modified login ${loginToModify.guid}.`); } catch (e) { this.#logger.error("error: modifyLogin:", e); + recordMirrorStatus(runId, "modify", "failure", e); } break; @@ -130,9 +229,11 @@ export class LoginManagerRustMirror { this.#logger.log(`removing login ${subject.guid}...`); try { this.#rustStorage.removeLogin(subject); + recordMirrorStatus(runId, "remove", "success"); this.#logger.log(`removed login ${subject.guid}.`); } catch (e) { this.#logger.error("error: removeLogin:", e); + recordMirrorStatus(runId, "remove", "failure", e); } break; @@ -140,9 +241,11 @@ export class LoginManagerRustMirror { this.#logger.log("removing all logins..."); try { this.#rustStorage.removeAllLogins(); + recordMirrorStatus(runId, "remove-all", "success"); this.#logger.log("removed all logins."); } catch (e) { this.#logger.error("error: removeAllLogins:", e); + recordMirrorStatus(runId, "remove-all", "failure", e); } break; @@ -188,15 +291,33 @@ export class LoginManagerRustMirror { // wait until loaded await this.#jsonStorage.initializationPromise; + const t0 = Date.now(); + const runId = Services.uuid.generateUUID(); + let numberOfLoginsToMigrate = 0; + let numberOfLoginsMigrated = 0; + try { this.#rustStorage.removeAllLogins(); this.#logger.log("Cleared existing Rust logins."); - const logins = await this.#jsonStorage.getAllLogins(); + Services.prefs.setBoolPref("signon.rustMirror.poisoned", false); - await this.#rustStorage.addLoginsAsync(logins, true); + const logins = await this.#jsonStorage.getAllLogins(); + numberOfLoginsToMigrate = logins.length; + + const results = await this.#rustStorage.addLoginsAsync(logins, true); + for (const { error } of results) { + if (error) { + this.#logger.error("error during migration:", error.message); + recordMigrationFailure(runId, error); + } else { + numberOfLoginsMigrated += 1; + } + } - this.#logger.log(`Successfully migrated ${logins.length} logins.`); + this.#logger.log( + `Successfully migrated ${numberOfLoginsMigrated}/${numberOfLoginsToMigrate} logins.` + ); // Migration complete, don't run again Services.prefs.setBoolPref("signon.rustMirror.migrationNeeded", false); @@ -205,6 +326,13 @@ export class LoginManagerRustMirror { } catch (e) { this.#logger.error("migration error:", e); } finally { + const duration = Date.now() - t0; + recordMigrationStatus( + runId, + duration, + numberOfLoginsToMigrate, + numberOfLoginsMigrated + ); this.#migrationInProgress = false; } } diff --git a/toolkit/components/passwordmgr/metrics.yaml b/toolkit/components/passwordmgr/metrics.yaml @@ -838,6 +838,127 @@ pwmgr: no_lint: - UNIT_IN_NAME + rust_mirror_status: + type: event + description: > + Records the outcome of each operation mirrored into the Rust password store. + Each record contains the operation type and whether it + succeeded or failed. + bugs: + - https://bugzil.la/1981812 + data_reviews: + - https://bugzil.la/1981812 + data_sensitivity: + - technical + notification_emails: + - passwords-dev@mozilla.org + expires: never + extra_keys: + run_id: + type: string + description: Unique identifier for the login change event + operation: + description: > + The type of operation that failed (e.g. "add", "modify", "remove", "remove-all", "migration", etc.). + type: string + status: + description: > + The result of the operation ("success" or "failure"). + type: string + error_message: + description: > + The error message or exception string from the failure. + type: string + poisoned: + description: > + Whether there was a previous error, meaning that this event must be viewed with caution. + type: boolean + + rust_migration_failure: + type: event + description: > + Records errors during a migration run. + bugs: + - https://bugzil.la/1991676 + data_reviews: + - https://bugzil.la/1991676 + data_sensitivity: + - technical + notification_emails: + - passwords-dev@mozilla.org + expires: never + extra_keys: + run_id: + type: string + description: Unique identifier for the migration run. + error_message: + type: string + description: The error message from the failed login entry. + + rust_incompatible_login_format: + type: event + description: > + Records whenever a login format is incompatible with the Rust password + store. Includes which field was affected and during which operation. + bugs: + - https://bugzil.la/1981814 + data_reviews: + - https://bugzil.la/1981814 + data_sensitivity: + - technical + notification_emails: + - passwords-dev@mozilla.org + expires: never + extra_keys: + run_id: + type: string + description: Unique identifier for the login change event + issue: + description: > + Which kind of incompatible format (dotOrigin, nonAsciiOrigin, + nonAsciiFormAction, usernameLineBreak). + type: string + operation: + description: > + The operation being performed when the incompatible format was found + (e.g. addLogin, modifyLogin, removeLogin). + type: string + + rust_migration_status: + type: event + description: > + One record per rolling migration run, recording duration, total number + of logins migrated and whether there have been errors. + bugs: + - https://bugzil.la/1985800 + data_reviews: + - https://bugzil.la/1985800 + data_sensitivity: + - technical + notification_emails: + - passwords-dev@mozilla.org + expires: never + extra_keys: + run_id: + type: string + description: Unique identifier for the migration run. + number_of_logins_to_migrate: + description: > + Total number of logins to be migrated during this run. + type: quantity + number_of_logins_migrated: + description: > + Number of logins migrated during this run. + type: quantity + duration_ms: + description: > + Duration of the migration in milliseconds. + type: quantity + had_errors: + description: > + Whether the migration had errors. + type: boolean + saving_enabled: type: boolean lifetime: application diff --git a/toolkit/components/passwordmgr/storage-rust.sys.mjs b/toolkit/components/passwordmgr/storage-rust.sys.mjs @@ -134,14 +134,19 @@ class RustLoginsStoreAdapter { const loginEntriesWithMeta = loginInfos.map(loginInfoToLoginEntryWithMeta); const results = this.#store.addManyWithMeta(loginEntriesWithMeta); - // on continuous mode, return result objects, which could be either a login or an error + // on continuous mode, return result objects, which could be either a login + // or an error containing the error message if (continueOnDuplicates) { - return results - .filter(l => l instanceof BulkResultEntry.Success) - .map(({ login, message }) => ({ - login: loginToLoginInfo(login), - error: { message }, - })); + return results.map(l => { + if (l instanceof BulkResultEntry.Error) { + return { + error: { message: l.message }, + }; + } + return { + login: loginToLoginInfo(l.login), + }; + }); } // otherwise throw first error diff --git a/toolkit/components/passwordmgr/test/browser/browser_rust_mirror.js b/toolkit/components/passwordmgr/test/browser/browser_rust_mirror.js @@ -36,7 +36,7 @@ add_task(async function test_mirror_addLogin() { LoginTestUtils.clearData(); rustStorage.removeAllLogins(); - await SpecialPowers.popPrefEnv(); + await SpecialPowers.flushPrefEnv(); }); /** @@ -77,7 +77,7 @@ add_task(async function test_mirror_modifyLogin() { LoginTestUtils.clearData(); rustStorage.removeAllLogins(); - await SpecialPowers.popPrefEnv(); + await SpecialPowers.flushPrefEnv(); }); /** @@ -105,7 +105,7 @@ add_task(async function test_mirror_removeLogin() { LoginTestUtils.clearData(); rustStorage.removeAllLogins(); - await SpecialPowers.popPrefEnv(); + await SpecialPowers.flushPrefEnv(); }); /** @@ -139,8 +139,7 @@ add_task(async function test_migration_is_triggered_by_pref_change() { "migrationNeeded is set to false" ); - await SpecialPowers.popPrefEnv(); - await SpecialPowers.popPrefEnv(); + await SpecialPowers.flushPrefEnv(); }); /** @@ -186,9 +185,7 @@ add_task(async function test_migration_is_idempotent() { LoginTestUtils.clearData(); rustStorage.removeAllLogins(); - await SpecialPowers.popPrefEnv(); - await SpecialPowers.popPrefEnv(); - await SpecialPowers.popPrefEnv(); + await SpecialPowers.flushPrefEnv(); }); /** @@ -196,7 +193,6 @@ add_task(async function test_migration_is_idempotent() { * - 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({ @@ -242,9 +238,7 @@ add_task(async function test_migration_partial_failure() { sinon.restore(); LoginTestUtils.clearData(); rustStorage.removeAllLogins(); - await SpecialPowers.popPrefEnv(); - await SpecialPowers.popPrefEnv(); - await SpecialPowers.popPrefEnv(); + await SpecialPowers.flushPrefEnv(); }); /** @@ -286,15 +280,74 @@ add_task(async function test_migration_rejects_when_bulk_add_rejects() { "signon.rustMirror.migrationNeeded", false ); - Assert.equal(newPrefValue, true, "pref has not been reset"); sinon.restore(); LoginTestUtils.clearData(); rustStorage.removeAllLogins(); - await SpecialPowers.popPrefEnv(); - await SpecialPowers.popPrefEnv(); - await SpecialPowers.popPrefEnv(); + 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(); }); /** @@ -336,4 +389,299 @@ add_task(async function test_migration_time_under_threshold() { 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(); });