Files
buzz/desktop/tests/e2e/profile-backup-settings.spec.ts
bd0bff24bf feat(desktop): add password-protected backups in settings (#3701)
**Category:** new-feature
**User Impact:** Users can create, download, and verify a
password-protected backup of their private identity from desktop
Settings.

**Problem:** Buzz does not currently give signed-in users a
Settings-based path to protect or validate their private identity
independently of onboarding. **Solution:** Add a focused backup menu to
the private-key row, keep encryption and verification local in Rust, and
preserve completed encrypted backups briefly so native saves can be
retried without repeating encryption.

<details>
<summary>File changes</summary>

**desktop/src/features/settings/**
Adds the background backup lifecycle, create and test dialogs,
private-key menu integration, password handling, and focused unit
coverage.

**desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx**
Extends the masked private-key display with reusable overflow-menu
actions used by Settings.

**desktop/src/app/App.tsx**
Mounts the backup provider at app scope so encryption and save work
survive closing Settings or the modal.

**desktop/src/shared/api/tauriIdentity.ts**
Adds typed desktop bindings for local backup creation, save, selection,
and verification.

**desktop/src-tauri/src/key_backup.rs and
desktop/src-tauri/src/commands/identity.rs**
Implements local NIP-49 encryption, password generation, file handling,
and public-identity-only verification results.

**desktop/src-tauri/src/egress_guard.rs and guarded call sites**
Blocks encrypted secret material from relay, websocket, snapshot,
sharing, and huddle egress paths.

**desktop/src-tauri tests and fixtures**
Covers encryption, verification, file behavior, and fail-closed
no-egress protections.

**desktop/src/testing/e2eBridge.ts, desktop/tests/, and
desktop/playwright.config.ts**
Expands the mock native bridge and browser coverage across create,
retry, expiry, and current/different-identity verification states.

**desktop/src-tauri/Cargo.toml, Cargo.lock, and assets**
Adds the local cryptography/password-generation dependencies and
embedded short-word list.

</details>

## Reproduction steps

1. Run the desktop app and open **Settings → Profile → Identity**.
2. Open the private-key overflow menu and choose **Create backup**.
3. Enter or generate a valid password, submit, and confirm progress
continues if the dialog or Settings is closed.
4. Save the resulting `.ncryptsec` file; cancel and retry to confirm the
temporary download remains available.
5. Choose **Test backup**, select the file, enter a wrong password, then
retry with the correct password.
6. Confirm success identifies whether the backup matches the current
identity and displays only the public `npub`.

## Screenshots

| Settings identity | Private-key menu | Create backup |
|---|---|---|
| <img width="1280" height="720" alt="image"
src="https://github.com/user-attachments/assets/981e391b-6829-4081-95ca-ca75a369de71"
/> | <img width="1280" height="720" alt="image"
src="https://github.com/user-attachments/assets/7972c68e-7635-47d8-b0ad-9639390d3e6c"
/> | <img width="1280" height="720" alt="image"
src="https://github.com/user-attachments/assets/4709c8f7-cf02-46f1-bec9-b3f98fe56fb2"
/> |

| Encrypting | Download available | Test success |
|---|---|---|
| <img width="1280" height="720" alt="image"
src="https://github.com/user-attachments/assets/1ac3e934-2b4b-4135-bae6-126c715c8c59"
/> | <img width="1280" height="720" alt="image"
src="https://github.com/user-attachments/assets/cb6f07ee-a16f-44a5-b9a0-6b9fe0e4d40d"
/> | <img width="1280" height="720" alt="image"
src="https://github.com/user-attachments/assets/ea58b1b1-966c-46aa-8d59-92c9f06a25bd"
/> |

Visual review and additional states: [Buzz
thread](buzz://message?channel=50ca7ef1-201e-4159-9499-40de3964b7c3&id=87eceb5f0f82fd50c32e560de3d35be48e293760f6620718aafdcef289d475fe)

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
2026-07-30 11:15:39 -07:00

260 lines
8.9 KiB
TypeScript

import { expect, test, type Page } from "@playwright/test";
import { npubEncode } from "nostr-tools/nip19";
import { installMockBridge } from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
const CURRENT_PUBKEY = "deadbeef".repeat(8);
const DIFFERENT_PUBKEY = "c0ffee00".repeat(8);
const BACKUP_FILE = {
name: "identity.ncryptsec",
mimeType: "text/plain",
buffer: Buffer.from("ncryptsec1mockbackupmaterial"),
};
async function openIdentity(page: Page) {
const identity = page.getByTestId("profile-identity-card");
if (
!(await identity.evaluate(
(element) => element instanceof HTMLDetailsElement && element.open,
))
) {
await page.getByTestId("profile-identity-toggle").click();
}
}
async function openBackupSettings(
page: Page,
mock?: Parameters<typeof installMockBridge>[1],
) {
await installMockBridge(page, mock);
await page.goto("/");
await openSettings(page, "profile");
await openIdentity(page);
}
async function openPrivateKeyMenu(page: Page) {
const reveal = page.getByTestId("profile-private-key-toggle");
if ((await reveal.textContent())?.trim() === "Reveal") {
await reveal.click();
}
await page.getByTestId("nsec-actions").click();
await expect(page.getByTestId("private-key-create-backup")).toBeVisible();
}
async function openCreateBackup(page: Page) {
await openPrivateKeyMenu(page);
await page.getByTestId("private-key-create-backup").click();
const dialog = page.getByTestId("encrypted-backup-dialog");
await expect(dialog).toBeVisible();
return dialog;
}
async function openTestBackup(page: Page) {
await openPrivateKeyMenu(page);
await page.getByTestId("private-key-test-backup").click();
const dialog = page.getByTestId("backup-test-dialog");
await expect(dialog).toBeVisible();
return dialog;
}
async function selectBackupFile(page: Page) {
await page.getByTestId("backup-test-file-input").setInputFiles(BACKUP_FILE);
await expect(page.getByTestId("backup-test-file-accepted")).toContainText(
BACKUP_FILE.name,
);
}
async function verifyBackup(page: Page, password: string) {
await page.getByTestId("backup-test-password").fill(password);
await page.getByTestId("backup-test-verify").click();
}
async function backupSaveCallCount(page: Page) {
return page.evaluate(
() =>
window.__BUZZ_E2E_COMMANDS__?.filter(
(command) => command === "save_ncryptsec_copy",
).length ?? 0,
);
}
test("private key menu replaces the backup settings rows", async ({ page }) => {
await openBackupSettings(page);
await expect(page.getByTestId("profile-encrypted-backup-row")).toHaveCount(0);
await expect(page.getByTestId("profile-backup-test-row")).toHaveCount(0);
await openPrivateKeyMenu(page);
await expect(page.getByTestId("nsec-copy")).toContainText("Copy");
await expect(page.getByTestId("private-key-create-backup")).toHaveText(
"Create backup",
);
await expect(page.getByTestId("private-key-test-backup")).toHaveText(
"Test backup",
);
await page.getByTestId("nsec-copy").click();
await expect(page.getByText(/clipboard$/i)).toBeVisible();
await expect(page.getByTestId("private-key-create-backup")).toHaveCount(0);
await openPrivateKeyMenu(page);
await page.getByTestId("private-key-test-backup").click();
const testDialog = page.getByTestId("backup-test-dialog");
await expect(testDialog).toContainText("Test a key backup");
await expect(testDialog.getByText("Select your backup file")).toBeVisible();
await expect(testDialog).toContainText("standard NIP-49 format");
});
test("creation requires a sufficiently long password and exposes a temporary header download", async ({
page,
}) => {
await openBackupSettings(page, {
backupSavePaths: [
"/Users/test/Downloads/identity.ncryptsec",
"/Users/test/Desktop/identity-copy.ncryptsec",
],
});
const dialog = await openCreateBackup(page);
const password = dialog.getByTestId("backup-passphrase-input");
const submit = dialog.getByTestId("encrypted-backup-create");
await expect(password).toHaveAttribute(
"placeholder",
"Password (min 12 characters)",
);
await expect(submit).toBeDisabled();
await password.fill("short");
await expect(submit).toBeDisabled();
await password.fill("custom password");
await expect(submit).toBeEnabled();
await submit.click();
await expect.poll(() => backupSaveCallCount(page)).toBe(1);
await expect(dialog).toBeHidden();
const keyRow = page.getByTestId("profile-private-key-row");
const download = keyRow.getByTestId("encrypted-backup-download");
await expect(download).toBeVisible();
await expect(download).toHaveText("Download backup");
await expect(download).toHaveClass(/bg-primary/);
await expect(
download.getByTestId("encrypted-backup-availability-fill"),
).toBeVisible();
await expect(keyRow.getByTestId("profile-private-key-toggle")).toBeVisible();
await download.click();
await expect.poll(() => backupSaveCallCount(page)).toBe(2);
});
test("encryption and native save continue after closing the dialog and settings", async ({
page,
}) => {
await openBackupSettings(page, {
backupEncryptionDelayMs: 750,
backupSavePaths: [null],
});
const dialog = await openCreateBackup(page);
await dialog
.getByTestId("backup-passphrase-input")
.fill("background password");
await dialog.getByTestId("encrypted-backup-create").click();
await expect(dialog.getByTestId("encrypted-backup-progress")).toBeVisible();
await dialog.getByRole("button", { name: "Close" }).click();
await page.getByTestId("settings-back-to-app").click();
await expect(page.getByTestId("settings-back-to-app")).toHaveCount(0);
await expect(
page.getByText("Preparing backup…", { exact: true }),
).toBeVisible();
await expect.poll(() => backupSaveCallCount(page)).toBe(1);
const readyToast = page.getByText("Backup ready to download", {
exact: true,
});
await expect(readyToast).toBeVisible();
await expect(
page.getByText("Your backup will be available to download for 5 minutes.", {
exact: true,
}),
).toBeVisible();
await page.getByRole("button", { name: "Open settings" }).click();
await expect(page.getByTestId("settings-back-to-app")).toBeVisible();
await openIdentity(page);
await expect(page.getByTestId("encrypted-backup-download")).toBeVisible();
});
test("the temporary download expires after five minutes", async ({ page }) => {
await page.clock.install({ time: new Date("2026-07-29T12:00:00Z") });
await openBackupSettings(page);
const dialog = await openCreateBackup(page);
await dialog.getByTestId("backup-passphrase-input").fill("expiring password");
await dialog.getByTestId("encrypted-backup-create").click();
await page.clock.fastForward(1);
await expect.poll(() => backupSaveCallCount(page)).toBe(1);
const download = page.getByTestId("encrypted-backup-download");
await expect(download).toHaveText("Download backup");
await expect(
download.getByTestId("encrypted-backup-availability-fill"),
).toBeVisible();
await page.clock.fastForward(5 * 60 * 1000 + 1);
await expect(page.getByTestId("encrypted-backup-download")).toHaveCount(0);
});
test("wrong backup password permits a successful retry in the test modal", async ({
page,
}) => {
await openBackupSettings(page, {
backupVerificationErrors: ["Wrong password.", null],
});
const dialog = await openTestBackup(page);
await selectBackupFile(page);
await verifyBackup(page, "wrong password");
await expect(dialog.getByTestId("backup-test-error")).toHaveText(
"Wrong password.",
);
await expect(dialog.getByTestId("backup-test-password")).toHaveValue("");
await expect(dialog.getByTestId("backup-test-verify")).toBeDisabled();
await verifyBackup(page, "correct password");
await expect(dialog.getByTestId("backup-test-success")).toContainText(
"It restores your current Buzz identity.",
);
});
for (const identity of [
{
label: "current",
pubkey: CURRENT_PUBKEY,
message: "It restores your current Buzz identity.",
},
{
label: "different",
pubkey: DIFFERENT_PUBKEY,
message: "It restores a different identity than the one signed in here.",
},
]) {
test(`successful modal verification identifies the ${identity.label} identity using only its npub`, async ({
page,
}) => {
await openBackupSettings(page, {
backupVerificationPubkeys: [identity.pubkey],
});
const dialog = await openTestBackup(page);
await selectBackupFile(page);
await verifyBackup(page, "one-time password");
const success = dialog.getByTestId("backup-test-success");
await expect(success).toContainText(identity.message);
await expect(success.getByTestId("backup-test-npub")).toContainText(
npubEncode(identity.pubkey),
);
await expect(success).not.toContainText(identity.pubkey);
await expect(success).not.toContainText("one-time password");
await expect(success).not.toContainText(BACKUP_FILE.buffer.toString());
});
}