Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion e2e/04-unlock-password.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
*
* Covers § 8.4 of e2e/README.md. Cold-start the app with Alice's
* encrypted blob in IndexedDB and `authMethod='seed'` in localStorage,
* so `Home` renders `UnlockScreen`. 4 tests, 4 linux baselines.
* so `Home` renders `UnlockScreen`. 6 tests, 5 linux baselines (the
* reset-flow test is functional-only — the post-wipe screen is the
* Welcome screen already baselined by 01-onboarding-welcome).
*
* Closes the MVP triage gap noted in README.md (no E2E coverage on
* `Unlock wallet — password` previously).
Expand All @@ -18,6 +20,16 @@
* `data-unlocking` attribute itself is still useful as a synchronous DOM
* marker for functional assertions, just not for visual regression.
*
* The reset-link branch (PR #132) adds two functional tests that exercise
* the `unlock-reset-btn` escape hatch:
* - `unlock-reset-link-visible`: the link renders next to the password
* form, baselined via `04-unlock-reset-link` so a reflow that hides
* or re-styles it gets caught by visual regression.
* - `unlock-reset-flow-wipes-and-shows-onboarding`: click → accept the
* native confirm → the encrypted wallet is wiped and `Home` falls
* through to `<Onboarding />`. Welcome screen is already baselined
* by 01-onboarding-welcome, so this one asserts visibility only.
*
* Locators: testid-based. The wrong-password test still asserts on the
* literal `Incorrect password` text — there is only one error on this
* screen, so a `data-error-kind` discriminator would be redundant, but
Expand Down Expand Up @@ -95,4 +107,42 @@ test.describe('Unlock wallet — password', () => {
await expect(page.getByTestId('wallet-empty-banner')).not.toBeVisible({ timeout: 30_000 });
await snap(page, '04-unlock-success-wallet', { fullPage: true });
});

test('unlock-reset-link-visible', async ({ page }) => {
// The "Forgot password? Reset wallet" link renders under the unlock
// form as the escape hatch when the user can't remember their
// password. Baseline the idle state — `04-unlock-empty` already
// covers the form chrome; this snap is taken at a slightly taller
// viewport region to include the link below the submit button.
await arriveAtUnlock(page);
const resetBtn = page.getByTestId('unlock-reset-btn');
await expect(resetBtn).toBeVisible();
await expect(resetBtn).toHaveText(/forgot password\? reset wallet/i);
await expect(resetBtn).toBeEnabled();
await snap(page, '04-unlock-reset-link');
});

test('unlock-reset-flow-wipes-and-shows-onboarding', async ({ page }) => {
// Functional flow only — the post-reset screen (Welcome) is already
// baselined by 01-onboarding-welcome, so this test asserts that the
// reset chain (deleteWallet → deleteCredential → resetAuth) runs to
// completion and `Home` falls through to `<Onboarding />`.
test.setTimeout(60_000);
await arriveAtUnlock(page);

// Auto-accept the browser-native confirm. The disconnect spec uses
// the same `page.once('dialog', …)` pattern (see 05-disconnect.spec.ts
// ::post-disconnect-welcome) — keep the two flows in sync.
page.once('dialog', (dialog) => dialog.accept());
await page.getByTestId('unlock-reset-btn').click();

// After the wipe `Home` re-renders Onboarding. The Welcome heading
// and the create-wallet CTA are both gated on `account=null` +
// `hasStoredWallet=false`, so their visibility proves the reset
// chain ran end-to-end.
await expect(page.getByTestId('welcome-heading')).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('onboarding-create-btn')).toBeVisible();
// The unlock chrome must be gone — otherwise the wipe was partial.
await expect(page.getByTestId('unlock-heading')).not.toBeVisible();
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 19 additions & 0 deletions public/handbook/de/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1390,6 +1390,25 @@ <h2><span class="num">04</span>Wallet entsperren — Passwort</h2>
Erfolgreich entsperrt — Wallet öffnet sich mit aktuellem Stand.
</div>
</div>
<div class="test">
<div class="head">
<span class="name">unlock-reset-link</span
><span class="src"
><a
href="https://github.com/zk-coins/app/blob/develop/e2e/04-unlock-password.spec.ts#L111"
>L111</a
></span
>
</div>
<div class="img">
<img src="/handbook/screenshots/04-unlock-reset-link.png" alt="reset link" />
</div>
<div class="desc">
"Forgot password? Reset wallet" — Notausgang für User, die ihr Passwort vergessen
haben. Löscht den verschlüsselten Blob + Credential nur auf diesem Gerät; zur
Wiederherstellung wird die 12-Wort-Seed benötigt.
</div>
</div>
</div>
</details>

Expand Down
19 changes: 19 additions & 0 deletions public/handbook/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1391,6 +1391,25 @@ <h2><span class="num">04</span>Unlock wallet — Password</h2>
</div>
<div class="desc">Successfully unlocked — wallet opens with current state.</div>
</div>
<div class="test">
<div class="head">
<span class="name">unlock-reset-link</span
><span class="src"
><a
href="https://github.com/zk-coins/app/blob/develop/e2e/04-unlock-password.spec.ts#L111"
>L111</a
></span
>
</div>
<div class="img">
<img src="/handbook/screenshots/04-unlock-reset-link.png" alt="reset link" />
</div>
<div class="desc">
"Forgot password? Reset wallet" — escape hatch for the user who can't remember their
password. Wipes the encrypted blob + credential on this device only; restoring needs
the 12-word seed.
</div>
</div>
</div>
</details>

Expand Down
152 changes: 147 additions & 5 deletions src/__tests__/components/UnlockWallet.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,59 @@
* stand up the wallet-store wiring `Home` does.
*/

import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UnlockScreen } from '@/components/onboarding/UnlockScreen';
import {
UnlockScreen,
UNLOCK_RESET_CONFIRM,
UNLOCK_RESET_ERROR,
} from '@/components/onboarding/UnlockScreen';

// Toggle the PASSKEY feature flag from individual tests. The
// component imports `FEATURES.PASSKEY` synchronously at render
// time, so we replace the module export rather than mutate a
// frozen object.
const FEATURES_STATE = vi.hoisted(() => ({ PASSKEY: false }));
vi.mock('@/lib/features', () => ({
FEATURES: FEATURES_STATE,
}));

// jsdom does not implement `window.confirm` (calling it throws "Not
// implemented"), so each test that touches the reset flow installs a
// default no-op stub here that the per-test `vi.spyOn` can then attach
// to. `vi.restoreAllMocks()` in afterEach removes the spy; we re-install
// the stub before the next test instead of leaking a real implementation
// across tests.
beforeEach(() => {
window.confirm = () => false;
});

afterEach(() => {
FEATURES_STATE.PASSKEY = false;
vi.restoreAllMocks();
});

function renderUnlock(
overrides: Partial<{
authMethod: 'passkey' | 'seed' | null;
onUnlockPassword: (pw: string) => Promise<void>;
onUnlockPrf: (prf: Uint8Array) => Promise<void>;
onReset: () => Promise<void>;
}> = {},
) {
const onUnlockPassword = overrides.onUnlockPassword ?? vi.fn().mockResolvedValue(undefined);
const onUnlockPrf = overrides.onUnlockPrf ?? vi.fn().mockResolvedValue(undefined);
const onReset = overrides.onReset ?? vi.fn().mockResolvedValue(undefined);
render(
<UnlockScreen
authMethod="seed"
authMethod={overrides.authMethod ?? 'seed'}
onUnlockPassword={onUnlockPassword}
onUnlockPrf={onUnlockPrf}
onReset={onReset}
/>,
);
return { onUnlockPassword, onUnlockPrf };
return { onUnlockPassword, onUnlockPrf, onReset };
}

describe('UnlockScreen — password flow', () => {
Expand Down Expand Up @@ -69,3 +101,113 @@ describe('UnlockScreen — password flow', () => {
expect(onUnlockPassword).toHaveBeenCalledWith('goodpassword');
});
});

describe('UnlockScreen — reset escape hatch', () => {
it('renders the reset link in the password branch', () => {
renderUnlock();
const btn = screen.getByTestId('unlock-reset-btn');
expect(btn).toBeInTheDocument();
expect(btn).toHaveTextContent(/forgot password\? reset wallet/i);
});

it('renders the reset link in the passkey branch', () => {
FEATURES_STATE.PASSKEY = true;
renderUnlock({ authMethod: 'passkey' });
// Sanity-check we're actually on the passkey branch.
expect(screen.getByTestId('unlock-passkey-btn')).toBeInTheDocument();
expect(screen.queryByTestId('unlock-password-input')).not.toBeInTheDocument();
expect(screen.getByTestId('unlock-reset-btn')).toBeInTheDocument();
});

it('prompts the user with the documented confirm copy before resetting', async () => {
const user = userEvent.setup();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
const onReset = vi.fn().mockResolvedValue(undefined);
renderUnlock({ onReset });

await user.click(screen.getByTestId('unlock-reset-btn'));

expect(confirmSpy).toHaveBeenCalledWith(UNLOCK_RESET_CONFIRM);
await waitFor(() => expect(onReset).toHaveBeenCalledTimes(1));
});

it('does NOT call onReset when the user dismisses the confirm', async () => {
const user = userEvent.setup();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
const onReset = vi.fn().mockResolvedValue(undefined);
renderUnlock({ onReset });

await user.click(screen.getByTestId('unlock-reset-btn'));

expect(confirmSpy).toHaveBeenCalledWith(UNLOCK_RESET_CONFIRM);
expect(onReset).not.toHaveBeenCalled();
});

it('shows a transient "Resetting…" label while onReset is in flight', async () => {
const user = userEvent.setup();
vi.spyOn(window, 'confirm').mockReturnValue(true);
let resolveReset: (() => void) | undefined;
const onReset = vi.fn().mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveReset = resolve;
}),
);
renderUnlock({ onReset });

await user.click(screen.getByTestId('unlock-reset-btn'));

await waitFor(() => {
const btn = screen.getByTestId('unlock-reset-btn');
expect(btn).toHaveTextContent(/resetting…/i);
// `aria-busy` is the semantic signal that goes with the text
// change — assert both so a future refactor can't silently drop
// the screen-reader hint.
expect(btn).toHaveAttribute('aria-busy', 'true');
});
resolveReset?.();
await waitFor(() => {
const btn = screen.getByTestId('unlock-reset-btn');
expect(btn).toHaveTextContent(/forgot password/i);
expect(btn).toHaveAttribute('aria-busy', 'false');
});
});

it('surfaces a user-readable error and clears resetting state when onReset throws', async () => {
const user = userEvent.setup();
vi.spyOn(window, 'confirm').mockReturnValue(true);
// Suppress the unhandled-rejection log Vitest prints for the
// intentionally-rejecting onReset — the component handles it.
const onReset = vi.fn().mockRejectedValue(new Error('IDB fail'));
renderUnlock({ onReset });

await user.click(screen.getByTestId('unlock-reset-btn'));

expect(await screen.findByTestId('unlock-error')).toHaveTextContent(UNLOCK_RESET_ERROR);
// Button is back to the idle label so the user can retry — and
// aria-busy is cleared.
const btn = screen.getByTestId('unlock-reset-btn');
expect(btn).toHaveTextContent(/forgot password\? reset wallet/i);
expect(btn).toHaveAttribute('aria-busy', 'false');
expect(btn).toBeEnabled();
});

it('clears a stale error banner when the user cancels the confirm', async () => {
// Type a wrong password first so the "Incorrect password" banner
// is on screen, then click reset and dismiss the confirm. The
// banner should disappear — leaving it would mislead the user
// about which action just happened.
const user = userEvent.setup();
const onUnlockPassword = vi.fn().mockRejectedValue(new Error('bad password'));
renderUnlock({ onUnlockPassword });

await user.type(screen.getByTestId('unlock-password-input'), 'wrongpass');
await user.click(screen.getByTestId('unlock-submit-btn'));
expect(await screen.findByTestId('unlock-error')).toHaveTextContent('Incorrect password');

vi.spyOn(window, 'confirm').mockReturnValue(false);
await user.click(screen.getByTestId('unlock-reset-btn'));

await waitFor(() => expect(screen.queryByTestId('unlock-error')).not.toBeInTheDocument());
});
});
20 changes: 18 additions & 2 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
'use client';

import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { AppShell } from '@/components/AppShell';
import { Onboarding } from '@/components/onboarding/Onboarding';
import { UnlockScreen } from '@/components/onboarding/UnlockScreen';
import { WalletScreen } from '@/components/screens/WalletScreen';
import { useWalletStore } from '@/stores/wallet';
import { useAuthStore } from '@/stores/auth';
import { useCapabilities } from '@/stores/capabilities';
import { deleteCredential } from '@/lib/crypto/storage';

export default function Home() {
const {
Expand All @@ -18,8 +19,9 @@ export default function Home() {
checkForStoredWallet,
unlockWithPassword,
unlockWithPrf,
deleteWallet,
} = useWalletStore();
const { hydrate } = useAuthStore();
const { hydrate, reset: resetAuth } = useAuthStore();
const [hydrated, setHydrated] = useState(false);

useEffect(() => {
Expand All @@ -35,6 +37,19 @@ export default function Home() {
})();
}, [checkForStoredWallet, hydrate]);

// Escape hatch for users stranded on the unlock screen (forgotten
// password / passkey gone). Reuses the disconnect chain from
// `/settings` so both flows wipe the same surfaces: encrypted
// wallet blob in IndexedDB, passkey credential record, auth-store
// state. `deleteWallet` clears `hasStoredWallet` + `isLocked` in
// the wallet store, so the next render falls through to
// `<Onboarding />` automatically — no reload needed.
const handleReset = useCallback(async () => {
await deleteWallet();
await deleteCredential();
resetAuth();
}, [deleteWallet, resetAuth]);

if (!hydrated) return null;

if (account && !isLocked) {
Expand All @@ -51,6 +66,7 @@ export default function Home() {
authMethod={storedAuthMethod}
onUnlockPassword={unlockWithPassword}
onUnlockPrf={unlockWithPrf}
onReset={handleReset}
/>
);
}
Expand Down
Loading
Loading