tor-browser

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

commit 2f46db151480d329d3512befea4df75ccf6538c7
parent 199c36415319b72ed07be35532dbe269e8b31950
Author: Jason Prickett <jprickett@mozilla.com>
Date:   Wed, 22 Oct 2025 16:53:01 +0000

Bug 1994820 - Add error handling to getBackupFileInfo and error display for restore component when used in about:welcome r=omc-reviewers,kpatenio,mviar

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

Diffstat:
Mbrowser/components/backup/BackupService.sys.mjs | 32+++++++++++++++++++++++++-------
Mbrowser/components/backup/content/restore-from-backup.css | 5+++++
Mbrowser/components/backup/content/restore-from-backup.mjs | 95++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mbrowser/components/backup/tests/chrome/test_restore_from_backup.html | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/backup/tests/xpcshell/test_BackupService.js | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 377 insertions(+), 29 deletions(-)

diff --git a/browser/components/backup/BackupService.sys.mjs b/browser/components/backup/BackupService.sys.mjs @@ -3807,13 +3807,31 @@ export class BackupService extends EventTarget { */ async getBackupFileInfo(backupFilePath) { lazy.logConsole.debug(`Getting info from backup file at ${backupFilePath}`); - let { archiveJSON, isEncrypted } = await this.sampleArchive(backupFilePath); - this.#_state.backupFileInfo = { - isEncrypted, - date: archiveJSON?.meta?.date, - deviceName: archiveJSON?.meta?.deviceName, - }; - this.#_state.backupFileToRestore = backupFilePath; + try { + let { archiveJSON, isEncrypted } = + await this.sampleArchive(backupFilePath); + this.#_state.backupFileInfo = { + isEncrypted, + date: archiveJSON?.meta?.date, + deviceName: archiveJSON?.meta?.deviceName, + }; + this.#_state.backupFileToRestore = backupFilePath; + // Clear any existing recovery error from state since we've successfully got our file info + this.setRecoveryError(ERRORS.NONE); + } catch (error) { + this.setRecoveryError(error.cause); + // Nullify the file info when we catch errors that indicate the file is invalid + switch (error.cause) { + case ERRORS.FILE_SYSTEM_ERROR: + case ERRORS.CORRUPTED_ARCHIVE: + case ERRORS.UNSUPPORTED_BACKUP_VERSION: + this.#_state.backupFileInfo = null; + this.#_state.backupFileToRestore = null; + break; + default: + break; + } + } this.stateUpdate(); } diff --git a/browser/components/backup/content/restore-from-backup.css b/browser/components/backup/content/restore-from-backup.css @@ -138,6 +138,7 @@ .field-error { color: var(--text-color-error); font-size: var(--font-size-small); + margin-block-start: var(--space-xsmall); } } @@ -169,6 +170,10 @@ } } + .field-error { + text-align: start; + } + @media (width <= 800px) { #restore-from-backup-button-group { justify-content: center; diff --git a/browser/components/backup/content/restore-from-backup.mjs b/browser/components/backup/content/restore-from-backup.mjs @@ -276,6 +276,37 @@ export default class RestoreFromBackup extends MozLitElement { } } + renderBackupFileInfo(backupFileInfo) { + return html`<p + id="restore-from-backup-backup-found-info" + data-l10n-id="backup-file-creation-date-and-device" + data-l10n-args=${JSON.stringify({ + machineName: backupFileInfo.deviceName ?? "", + date: backupFileInfo.date ? new Date(backupFileInfo.date).getTime() : 0, + })} + ></p>`; + } + + renderBackupFileStatus() { + const { backupFileInfo, recoveryErrorCode } = this.backupServiceState || {}; + + // We have errors and are embedded in about:welcome + if (recoveryErrorCode && this.aboutWelcomeEmbedded) { + return this.genericFileErrorTemplate(); + } + + // No backup file selected + if (!backupFileInfo) { + return this.getSupportLinkAnchor({ + id: "restore-from-backup-no-backup-file-link", + l10nId: "restore-from-backup-no-backup-file-link", + }); + } + + // Backup file found and no error + return this.renderBackupFileInfo(backupFileInfo); + } + controlsTemplate() { let iconURL = this.#placeholderFileIconURL; if ( @@ -302,27 +333,7 @@ export default class RestoreFromBackup extends MozLitElement { ></moz-button> </div> - ${!this.backupServiceState?.backupFileInfo - ? this.getSupportLinkAnchor({ - id: "restore-from-backup-no-backup-file-link", - l10nId: "restore-from-backup-no-backup-file-link", - }) - : null} - ${this.backupServiceState?.backupFileInfo - ? html`<p - id="restore-from-backup-backup-found-info" - data-l10n-id="backup-file-creation-date-and-device" - data-l10n-args=${JSON.stringify({ - machineName: - this.backupServiceState.backupFileInfo.deviceName ?? "", - date: this.backupServiceState.backupFileInfo.date - ? new Date( - this.backupServiceState.backupFileInfo.date - ).getTime() - : 0, - })} - ></p>` - : null} + ${this.renderBackupFileStatus()} </fieldset> <fieldset id="password-entry-controls"> @@ -340,6 +351,21 @@ export default class RestoreFromBackup extends MozLitElement { ); const backupFileName = this.backupServiceState?.backupFileToRestore || ""; + // Determine the ID of the element that will be rendered by renderBackupFileStatus() + // to reference with aria-describedby + let describedBy = ""; + const { backupFileInfo, recoveryErrorCode } = this.backupServiceState || {}; + + if (this.aboutWelcomeEmbedded) { + if (recoveryErrorCode) { + describedBy = "backup-generic-file-error"; + } else if (!backupFileInfo) { + describedBy = "restore-from-backup-no-backup-file-link"; + } else { + describedBy = "restore-from-backup-backup-found-info"; + } + } + if (this.aboutWelcomeEmbedded) { return html` <textarea @@ -349,6 +375,7 @@ export default class RestoreFromBackup extends MozLitElement { .value=${backupFileName} style=${styles} @input=${this.handleTextareaResize} + aria-describedby=${describedBy} ></textarea> `; } @@ -416,7 +443,8 @@ export default class RestoreFromBackup extends MozLitElement { > ${this.aboutWelcomeEmbedded ? null : this.headerTemplate()} <main id="restore-from-backup-content"> - ${this.backupServiceState?.recoveryErrorCode + ${!this.aboutWelcomeEmbedded && + this.backupServiceState?.recoveryErrorCode ? this.errorTemplate() : null} ${!this.aboutWelcomeEmbedded && @@ -502,6 +530,29 @@ export default class RestoreFromBackup extends MozLitElement { `; } + genericFileErrorTemplate() { + // We handle incorrect password errors in the password input + if (this.isIncorrectPassword) { + return null; + } + + // Note: the l10n id used here is interim, and will be updated in Bug 1994877 + return html` + <span + id="backup-generic-file-error" + class="field-error" + data-l10n-id="restored-from-backup-error-subtitle" + > + <a + id="backup-generic-error-link" + slot="support-link" + data-l10n-name="restore-problems" + href=${this.getSupportURLWithUTM("firefox-backup")} + ></a> + </span> + `; + } + render() { this.applyContentCustomizations(); return html` diff --git a/browser/components/backup/tests/chrome/test_restore_from_backup.html b/browser/components/backup/tests/chrome/test_restore_from_backup.html @@ -354,6 +354,166 @@ restoreFromBackup.remove(); }); + + /** + * Tests that the correct status is displayed under the input + * for different backup file info states. + */ + add_task(async function test_backup_file_status_rendering() { + let content = document.getElementById("content"); + let restoreFromBackup = document.createElement("restore-from-backup"); + content.appendChild(restoreFromBackup); + + // Test that when no backup file is selected, the support link is displayed + restoreFromBackup.backupServiceState = { + ...restoreFromBackup.backupServiceState, + backupFileInfo: null, + recoveryErrorCode: ERRORS.NONE, + }; + await restoreFromBackup.updateComplete; + + let noBackupLink = restoreFromBackup.shadowRoot.querySelector( + "#restore-from-backup-no-backup-file-link" + ); + ok(noBackupLink, "Should show support link when no backup file is selected"); + + let backupInfo = restoreFromBackup.shadowRoot.querySelector( + "#restore-from-backup-backup-found-info" + ); + ok(!backupInfo, "Should not show backup info when no backup file is selected"); + + // Test that when a backup file is selected, the backup info is displayed + const IS_ENCRYPTED = true; + const DATE = new Date("2024-01-01T00:00:00.000Z"); + const DEVICE_NAME = "test-device"; + + restoreFromBackup.backupServiceState = { + ...restoreFromBackup.backupServiceState, + backupFileInfo: { + isEncrypted: IS_ENCRYPTED, + date: DATE, + deviceName: DEVICE_NAME + }, + recoveryErrorCode: ERRORS.NONE, + }; + await restoreFromBackup.updateComplete; + + noBackupLink = restoreFromBackup.shadowRoot.querySelector( + "#restore-from-backup-no-backup-file-link" + ); + ok(!noBackupLink, "Should not show support link when backup file is found"); + + backupInfo = restoreFromBackup.shadowRoot.querySelector( + "#restore-from-backup-backup-found-info" + ); + ok(backupInfo, "Should show backup info when backup file is found"); + is(backupInfo.getAttribute("data-l10n-id"), "backup-file-creation-date-and-device", + "Should have correct l10n id for backup info"); + + // Test that when embedded in about:welcome, if an error occurs, + // the generic file error template is displayed + restoreFromBackup.aboutWelcomeEmbedded = true; + restoreFromBackup.backupServiceState = { + ...restoreFromBackup.backupServiceState, + backupFileInfo: null, + recoveryErrorCode: ERRORS.CORRUPTED_ARCHIVE, + }; + await restoreFromBackup.updateComplete; + + let errorTemplate = restoreFromBackup.shadowRoot.querySelector( + "#backup-generic-file-error" + ); + ok(errorTemplate, "Should show error template in embedded context with error"); + + // Test that when not embedded in about:welcome, if an error occurs, + // the support link is displayed instead of the generic file error template + restoreFromBackup.aboutWelcomeEmbedded = false; + restoreFromBackup.backupServiceState = { + ...restoreFromBackup.backupServiceState, + backupFileInfo: null, + recoveryErrorCode: ERRORS.FILE_SYSTEM_ERROR, + }; + await restoreFromBackup.updateComplete; + + errorTemplate = restoreFromBackup.shadowRoot.querySelector( + "#backup-generic-file-error" + ); + ok(!errorTemplate, "Should not show generic file error template when not embedded in about:welcome"); + + noBackupLink = restoreFromBackup.shadowRoot.querySelector( + "#restore-from-backup-no-backup-file-link" + ); + ok(noBackupLink, "Should show support link when not embedded in about:welcome, with error"); + + restoreFromBackup.remove(); + }); + + /** + * Tests that when embedded in about:welcome, the textarea's aria-describedby + * attribute correctly references the appropriate element based on the displayed status. + */ + add_task(async function test_textarea_aria_describedby_accessibility() { + let content = document.getElementById("content"); + let restoreFromBackup = document.createElement("restore-from-backup"); + content.appendChild(restoreFromBackup); + + restoreFromBackup.aboutWelcomeEmbedded = true; + await restoreFromBackup.updateComplete; + + let textarea = restoreFromBackup.shadowRoot.querySelector("#backup-filepicker-input"); + ok(textarea, "Textarea should be present when aboutWelcomeEmbedded is true"); + + // Test that when there is no backup file info, we should reference no-backup-file-link + restoreFromBackup.backupServiceState = { + ...restoreFromBackup.backupServiceState, + backupFileInfo: null, + recoveryErrorCode: ERRORS.NONE, + }; + await restoreFromBackup.updateComplete; + + let ariaDescribedBy = textarea.getAttribute("aria-describedby"); + is(ariaDescribedBy, "restore-from-backup-no-backup-file-link", + "aria-describedby should reference no-backup-file-link when no backup info"); + + let referencedElement = restoreFromBackup.shadowRoot.querySelector("#restore-from-backup-no-backup-file-link"); + ok(referencedElement, "Referenced element should exist"); + + // Test that when a backup file is found, we should reference backup-found-info + restoreFromBackup.backupServiceState = { + ...restoreFromBackup.backupServiceState, + backupFileInfo: { + date: new Date(), + deviceName: "test-device", + isEncrypted: false, + }, + recoveryErrorCode: ERRORS.NONE, + }; + await restoreFromBackup.updateComplete; + + ariaDescribedBy = textarea.getAttribute("aria-describedby"); + is(ariaDescribedBy, "restore-from-backup-backup-found-info", + "aria-describedby should reference backup-found-info when backup file is found"); + + referencedElement = restoreFromBackup.shadowRoot.querySelector("#restore-from-backup-backup-found-info"); + ok(referencedElement, "Referenced element should exist"); + + // Test that when we're in an error state, we should reference the generic-file-error + restoreFromBackup.backupServiceState = { + ...restoreFromBackup.backupServiceState, + backupFileInfo: null, + recoveryErrorCode: ERRORS.CORRUPTED_ARCHIVE, + }; + await restoreFromBackup.updateComplete; + + ariaDescribedBy = textarea.getAttribute("aria-describedby"); + is(ariaDescribedBy, "backup-generic-file-error", + "aria-describedby should reference generic-file-error when there's a recovery error"); + + referencedElement = restoreFromBackup.shadowRoot.querySelector("#backup-generic-file-error"); + ok(referencedElement, "Referenced element should exist"); + + restoreFromBackup.remove(); + }); </script> </head> <body> diff --git a/browser/components/backup/tests/xpcshell/test_BackupService.js b/browser/components/backup/tests/xpcshell/test_BackupService.js @@ -18,6 +18,9 @@ const { UIState } = ChromeUtils.importESModule( const { ClientID } = ChromeUtils.importESModule( "resource://gre/modules/ClientID.sys.mjs" ); +const { ERRORS } = ChromeUtils.importESModule( + "chrome://browser/content/backup/backup-constants.mjs" +); const LAST_BACKUP_TIMESTAMP_PREF_NAME = "browser.backup.scheduled.last-backup-timestamp"; @@ -932,3 +935,114 @@ add_task(async function test__deleteLastBackup_file_does_not_exist() { await maybeRemovePath(lastBackupFilePath); }); }); + +/** + * Tests that getBackupFileInfo properly handles errors, and clears file info + * for errors that indicate that the file is invalid. + */ +add_task(async function test_getBackupFileInfo_error_handling() { + let sandbox = sinon.createSandbox(); + + const testCases = [ + // Errors that should clear backupFileInfo + { error: ERRORS.FILE_SYSTEM_ERROR, shouldClear: true }, + { error: ERRORS.CORRUPTED_ARCHIVE, shouldClear: true }, + { error: ERRORS.UNSUPPORTED_BACKUP_VERSION, shouldClear: true }, + // Errors that shouldn't clear backupFileInfo + { error: ERRORS.INTERNAL_ERROR, shouldClear: false }, + { error: ERRORS.UNINITIALIZED, shouldClear: false }, + { error: ERRORS.INVALID_PASSWORD, shouldClear: false }, + ]; + + for (const testCase of testCases) { + let bs = new BackupService(); + + const DATE = "2024-06-25T21:59:11.777Z"; + const IS_ENCRYPTED = true; + const DEVICE_NAME = "test-device"; + + let fakeSampleArchiveResult = { + isEncrypted: IS_ENCRYPTED, + startByteOffset: 26985, + contentType: "multipart/mixed", + archiveJSON: { + version: 1, + meta: { date: DATE, deviceName: DEVICE_NAME }, + encConfig: {}, + }, + }; + + sandbox + .stub(BackupService.prototype, "sampleArchive") + .resolves(fakeSampleArchiveResult); + await bs.getBackupFileInfo("test-backup.html"); + + // Verify initial state was set + Assert.deepEqual( + bs.state.backupFileInfo, + { + isEncrypted: IS_ENCRYPTED, + date: DATE, + deviceName: DEVICE_NAME, + }, + "Initial state should be set correctly" + ); + Assert.strictEqual( + bs.state.backupFileToRestore, + "test-backup.html", + "Initial backupFileToRestore should be set correctly" + ); + + // Test when sampleArchive throws an error + sandbox.restore(); + sandbox + .stub(BackupService.prototype, "sampleArchive") + .rejects(new Error("Test error", { cause: testCase.error })); + const setRecoveryErrorStub = sandbox.stub(bs, "setRecoveryError"); + + try { + await bs.getBackupFileInfo("test-backup.html"); + } catch (error) { + Assert.ok( + false, + `Expected getBackupFileInfo to throw for error ${testCase.error}` + ); + } + + Assert.ok( + setRecoveryErrorStub.calledOnceWith(testCase.error), + `setRecoveryError should be called with ${testCase.error}` + ); + + // backupFileInfo should be either cleared or preserved based on error type + if (testCase.shouldClear) { + Assert.strictEqual( + bs.state.backupFileInfo, + null, + `backupFileInfo should be cleared for error ${testCase.error}` + ); + Assert.strictEqual( + bs.state.backupFileToRestore, + null, + `backupFileToRestore should be cleared for error ${testCase.error}` + ); + } else { + Assert.deepEqual( + bs.state.backupFileInfo, + { + isEncrypted: IS_ENCRYPTED, + date: DATE, + deviceName: DEVICE_NAME, + }, + `backupFileInfo should be preserved for error ${testCase.error}` + ); + Assert.strictEqual( + bs.state.backupFileToRestore, + "test-backup.html", + `backupFileToRestore should be preserved for error ${testCase.error}` + ); + } + + sandbox.restore(); + } +});