From f6566adec1e2ce9b03582410a5a9124d678385a8 Mon Sep 17 00:00:00 2001 From: temrjan Date: Fri, 3 Jul 2026 11:03:12 +0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(mobile):=20A2a=20=E2=80=94=20onboardin?= =?UTF-8?q?g=20shell=20+=20PIN=20screens,=20F1=20fix=20(decouple=20generat?= =?UTF-8?q?e/commit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F1 (audit crit): deviceWallet.createWallet() committed the wallet in the same call that generated the mnemonic — abandoning onboarding before the backup quiz left a committed-but-unbacked-up, unrecoverable wallet. Split into generateMnemonic() (pure, no I/O) + commitWallet(mnemonic) (the atomic commit); regression test proves generation alone never touches storage - 12-word mnemonic, not 24 (ratified Gate-1 A2a; matches the governing spec + the flipbook's ShowPhrase mock); spy-tested against the FFI call so a silent revert to 24 cannot pass jest with the word-count-agnostic bridge mock - onboardingStore: in-memory-only step machine (welcome/keepItSafe/ createPin/confirmPin/pinConfirmed) holding the mnemonic and pending PIN; PIN persists via pinSetupStore ahead of the wallet commit (the A1c reconcile() contract already anticipates this); pendingPin clears the moment it's hashed - PinKeypad: shared dots+keypad component (CreatePin/ConfirmPin now, A3's UnlockPin later) - Welcome screen ports the flipbook 1:1 (wordmark text, not the SVG logo — react-native-svg isn't a dependency yet, flagged rather than silently added); KeepItSafe/CreatePin/ConfirmPin built from the governing spec's language (no flipbook frames for these — ratified, closes carry-over #7) - RootNavigator's no_wallet phase now renders OnboardingNavigator instead of a placeholder - integration test caught a real bug: KeepItSafe's Continue button relied solely on the Pressable disabled prop, which a direct onPress call bypasses — onPress is now itself gated on all 3 acknowledgements - 98 tests total (23 new/changed), 2 mutations verified red before green --- __tests__/App.test.tsx | 2 +- src/components/PinKeypad.tsx | 79 ++++++++++++ src/components/__tests__/PinKeypad.test.tsx | 101 +++++++++++++++ src/lib/__tests__/deviceWallet.test.ts | 84 +++++++++--- src/lib/deviceWallet.ts | 55 +++++--- src/navigation/OnboardingNavigator.tsx | 38 ++++++ src/navigation/RootNavigator.tsx | 7 +- .../__tests__/OnboardingNavigator.test.tsx | 120 +++++++++++++++++ .../__tests__/RootNavigator.test.tsx | 4 +- src/screens/onboarding/ConfirmPin.tsx | 32 +++++ src/screens/onboarding/CreatePin.tsx | 22 ++++ src/screens/onboarding/KeepItSafe.tsx | 79 ++++++++++++ src/screens/onboarding/Welcome.tsx | 56 ++++++++ src/stores/__tests__/onboardingStore.test.ts | 121 ++++++++++++++++++ src/stores/__tests__/walletStore.test.ts | 6 +- src/stores/onboardingStore.ts | 90 +++++++++++++ 16 files changed, 846 insertions(+), 50 deletions(-) create mode 100644 src/components/PinKeypad.tsx create mode 100644 src/components/__tests__/PinKeypad.test.tsx create mode 100644 src/navigation/OnboardingNavigator.tsx create mode 100644 src/navigation/__tests__/OnboardingNavigator.test.tsx create mode 100644 src/screens/onboarding/ConfirmPin.tsx create mode 100644 src/screens/onboarding/CreatePin.tsx create mode 100644 src/screens/onboarding/KeepItSafe.tsx create mode 100644 src/screens/onboarding/Welcome.tsx create mode 100644 src/stores/__tests__/onboardingStore.test.ts create mode 100644 src/stores/onboardingStore.ts diff --git a/__tests__/App.test.tsx b/__tests__/App.test.tsx index a67d4b4..db1b312 100644 --- a/__tests__/App.test.tsx +++ b/__tests__/App.test.tsx @@ -19,7 +19,7 @@ test('boots to the onboarding destination when no wallet is stored', async () => // set) inside act so its final phase update is act-wrapped, not a warning. await new Promise((resolve) => setImmediate(resolve)); }); - expect(JSON.stringify(tree?.toJSON())).toContain('Onboarding'); + expect(JSON.stringify(tree?.toJSON())).toContain('RUST'); await act(async () => { tree?.unmount(); }); diff --git a/src/components/PinKeypad.tsx b/src/components/PinKeypad.tsx new file mode 100644 index 0000000..f3ace68 --- /dev/null +++ b/src/components/PinKeypad.tsx @@ -0,0 +1,79 @@ +/** + * PinKeypad — dots + numeric keypad, the visual language of the flipbook's + * "Вход — Locked" screen (`design-handoff/prototypes/Rustok Flipbook.dc.html`, + * :228-261). Controlled and purpose-agnostic: it collects exactly `length` + * digits and calls `onComplete` — it knows nothing about create/confirm/unlock + * (A2's CreatePin/ConfirmPin and A3's UnlockPin all render this one component). + * + * `error` triggers a visual reset (dots clear) without touching internal + * timing — the caller owns any shake/haptics choreography. + */ + +import { useEffect, useState } from 'react'; +import { Pressable, Text, View } from 'react-native'; + +interface PinKeypadProps { + readonly length: number; + readonly onComplete: (pin: string) => void; + /** When true, clears the entered digits (e.g. after a mismatch). */ + readonly error?: boolean; +} + +const KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '', '0', '⌫'] as const; + +export function PinKeypad({ length, onComplete, error = false }: PinKeypadProps) { + const [digits, setDigits] = useState(''); + + useEffect(() => { + if (error) { + setDigits(''); + } + }, [error]); + + function press(key: string) { + if (key === '') { + return; + } + if (key === '⌫') { + setDigits((prev) => prev.slice(0, -1)); + return; + } + const next = digits + key; + setDigits(next); + if (next.length === length) { + onComplete(next); + setDigits(''); + } + } + + return ( + + + {Array.from({ length }, (_, i) => ( + + ))} + + + {KEYS.map((key, i) => ( + press(key)} + className="aspect-square w-[62px] items-center justify-center rounded-full border border-borderDefault" + > + {key} + + ))} + + + ); +} diff --git a/src/components/__tests__/PinKeypad.test.tsx b/src/components/__tests__/PinKeypad.test.tsx new file mode 100644 index 0000000..5de03c2 --- /dev/null +++ b/src/components/__tests__/PinKeypad.test.tsx @@ -0,0 +1,101 @@ +/** + * PinKeypad tests — driven via `testID`s (no @testing-library/react-native in + * this project; TestRenderer + Pressable.props.onPress(), matching the + * render-smoke convention used elsewhere — RootNavigator.test.tsx). + */ + +import TestRenderer, { act } from 'react-test-renderer'; + +import { PinKeypad } from '../PinKeypad'; + +function pressKey(tree: TestRenderer.ReactTestRenderer, key: string) { + const testID = key === '⌫' ? 'pin-key-backspace' : `pin-key-${key}`; + act(() => { + tree.root.findByProps({ testID }).props.onPress(); + }); +} + +function litDots(tree: TestRenderer.ReactTestRenderer, length: number): number { + let lit = 0; + for (let i = 0; i < length; i += 1) { + const dot = tree.root.findByProps({ testID: `pin-dot-${i}` }); + if (JSON.stringify(dot.props.className).includes('bg-accent')) { + lit += 1; + } + } + return lit; +} + +describe('PinKeypad', () => { + it('calls onComplete with the digits once length is reached, then clears', () => { + const onComplete = jest.fn(); + let tree: TestRenderer.ReactTestRenderer; + act(() => { + tree = TestRenderer.create(); + }); + + for (const key of ['1', '2', '3']) { + pressKey(tree!, key); + expect(onComplete).not.toHaveBeenCalled(); + } + pressKey(tree!, '4'); + + expect(onComplete).toHaveBeenCalledTimes(1); + expect(onComplete).toHaveBeenCalledWith('1234'); + expect(litDots(tree!, 4)).toBe(0); // cleared after completion + }); + + it('backspace removes the last digit', () => { + const onComplete = jest.fn(); + let tree: TestRenderer.ReactTestRenderer; + act(() => { + tree = TestRenderer.create(); + }); + + pressKey(tree!, '1'); + pressKey(tree!, '2'); + expect(litDots(tree!, 4)).toBe(2); + + pressKey(tree!, '⌫'); + expect(litDots(tree!, 4)).toBe(1); + + pressKey(tree!, '3'); + pressKey(tree!, '4'); + expect(onComplete).not.toHaveBeenCalled(); // only 3 digits entered net of the backspace + }); + + it('starts a fresh entry after completion, not accumulating onto the finished one', () => { + const onComplete = jest.fn(); + let tree: TestRenderer.ReactTestRenderer; + act(() => { + tree = TestRenderer.create(); + }); + + pressKey(tree!, '1'); + pressKey(tree!, '2'); + expect(onComplete).toHaveBeenCalledWith('12'); + onComplete.mockClear(); + + pressKey(tree!, '3'); // a new entry, not a 3rd digit glued onto the completed one + expect(onComplete).not.toHaveBeenCalled(); + expect(litDots(tree!, 2)).toBe(1); + }); + + it('the error prop clears any entered digits', () => { + const onComplete = jest.fn(); + let tree: TestRenderer.ReactTestRenderer; + act(() => { + tree = TestRenderer.create(); + }); + + pressKey(tree!, '1'); + pressKey(tree!, '2'); + expect(litDots(tree!, 4)).toBe(2); + + act(() => { + tree.update(); + }); + + expect(litDots(tree!, 4)).toBe(0); + }); +}); diff --git a/src/lib/__tests__/deviceWallet.test.ts b/src/lib/__tests__/deviceWallet.test.ts index 569a316..b68b991 100644 --- a/src/lib/__tests__/deviceWallet.test.ts +++ b/src/lib/__tests__/deviceWallet.test.ts @@ -6,11 +6,14 @@ * commit ordering, rollback, reconciliation, and error propagation. */ +import * as bridge from 'react-native-rustok-bridge'; +import { FfiWordCount } from 'react-native-rustok-bridge'; import * as Keychain from 'react-native-keychain'; import * as MMKV from 'react-native-mmkv'; import { - createWallet, + commitWallet, + generateMnemonic, getStoredAddress, hasWallet, NoWalletError, @@ -33,6 +36,14 @@ const mmkvMock = MMKV as unknown as { __resetMMKV: () => void }; const KEYSTORE_SERVICE = 'com.rustokwallet.keystore'; +/** generate + commit in one call — the pre-A2a `createWallet` shape, for tests + * that only care about a wallet existing (unlock/reconcile/wipeWallet). */ +async function createTestWallet(): Promise<{ address: string; mnemonic: string }> { + const generated = generateMnemonic(); + const { address } = await commitWallet(generated.mnemonic); + return { address, mnemonic: generated.mnemonic }; +} + beforeEach(() => { jest.restoreAllMocks(); keychainMock.__resetKeychain(); @@ -40,31 +51,62 @@ beforeEach(() => { mmkvMock.__resetMMKV(); }); -describe('createWallet', () => { - it('commits the blob to MMKV and the secret to the keychain, returning address + mnemonic', async () => { - const created = await createWallet(); +describe('generateMnemonic', () => { + it('returns a non-empty mnemonic and its address without persisting anything', () => { + const generated = generateMnemonic(); + + expect(generated.mnemonic.length).toBeGreaterThan(0); + expect(generated.address).toMatch(/^0xMOCK[0-9a-f]{12}$/); + // F1 regression (audit crit): generation must NEVER commit. The old + // combined `createWallet` committed in the same call that generated the + // mnemonic — abandoning onboarding before the backup quiz left a + // committed-but-never-backed-up wallet (unrecoverable, blob ≠ seed). + expect(hasWallet()).toBe(false); + }); + + it('requests exactly 12 words from the FFI (ratified Gate-1 A2a — not 24)', () => { + const spy = jest.spyOn(bridge, 'generateWallet'); + + generateMnemonic(); + + expect(spy).toHaveBeenCalledWith(FfiWordCount.Words12); + }); + + it('two calls never collide on address (fresh randomness per call)', () => { + const first = generateMnemonic(); + const second = generateMnemonic(); + + expect(second.address).not.toBe(first.address); + }); +}); + +describe('commitWallet', () => { + it('commits the blob to MMKV and the secret to the keychain, returning the address', async () => { + const generated = generateMnemonic(); + + const created = await commitWallet(generated.mnemonic); - expect(created.address).toMatch(/^0xMOCK[0-9a-f]{12}$/); - expect(created.mnemonic.length).toBeGreaterThan(0); + expect(created.address).toBe(generated.address); // same mnemonic → same derived address expect(hasWallet()).toBe(true); expect(getStoredAddress()).toBe(created.address); expect(await hasKeystoreSecret()).toBe(true); }); it('rejects with WalletExistsError when a wallet already exists (never overwrites)', async () => { - await createWallet(); - await expect(createWallet()).rejects.toBeInstanceOf(WalletExistsError); + await createTestWallet(); + await expect(commitWallet(generateMnemonic().mnemonic)).rejects.toBeInstanceOf(WalletExistsError); }); - it('dedupes concurrent calls via single-flight (one wallet created)', async () => { - const [a, b] = await Promise.all([createWallet(), createWallet()]); + it('dedupes concurrent calls via single-flight (one wallet committed)', async () => { + const mnemonic = generateMnemonic().mnemonic; + const [a, b] = await Promise.all([commitWallet(mnemonic), commitWallet(mnemonic)]); expect(a.address).toBe(b.address); expect(getStoredAddress()).toBe(a.address); }); it('leaves a clean state when the keychain write fails (nothing persisted)', async () => { keychainMock.__failNextKeychainOp(new Error('keystore write failed')); - await expect(createWallet()).rejects.toThrow(); + await expect(commitWallet(generateMnemonic().mnemonic)).rejects.toThrow(); expect(hasWallet()).toBe(false); expect(await hasKeystoreSecret()).toBe(false); @@ -75,7 +117,7 @@ describe('createWallet', () => { throw new Error('mmkv write failed'); }); - await expect(createWallet()).rejects.toThrow('mmkv write failed'); + await expect(commitWallet(generateMnemonic().mnemonic)).rejects.toThrow('mmkv write failed'); expect(hasWallet()).toBe(false); expect(getStoredAddress()).toBeNull(); // no orphan address left behind @@ -85,7 +127,7 @@ describe('createWallet', () => { describe('unlock', () => { it('re-imports the stored wallet and signs with the retained secret', async () => { - const created = await createWallet(); + const created = await createTestWallet(); const session = await unlock(); expect(session.address).toBe(created.address); @@ -99,7 +141,7 @@ describe('unlock', () => { }); it('propagates a Wallet error when the stored secret no longer matches the blob', async () => { - await createWallet(); + await createTestWallet(); // Overwrite the keychain with a different, well-formed secret so retrieval // passes the shape guard but decryption (importKeystore) fails. const wrongSecret = 'f'.repeat(64); @@ -111,7 +153,7 @@ describe('unlock', () => { }); it('rejects a malformed stored secret before it reaches the crypto path', async () => { - await createWallet(); + await createTestWallet(); await Keychain.setGenericPassword('rustok-keystore', 'too-short', { service: KEYSTORE_SERVICE, }); @@ -122,7 +164,7 @@ describe('unlock', () => { describe('reconcile', () => { it('clears an orphan blob when its keystore secret is missing', async () => { - await createWallet(); + await createTestWallet(); await Keychain.resetGenericPassword({ service: KEYSTORE_SERVICE }); // secret gone, blob remains await reconcile(); @@ -131,7 +173,7 @@ describe('reconcile', () => { }); it('wipes an orphan keystore secret when no blob is stored', async () => { - await createWallet(); + await createTestWallet(); clearWalletRecord(); // blob gone, secret remains await reconcile(); @@ -140,7 +182,7 @@ describe('reconcile', () => { }); it('is a no-op for a consistent wallet', async () => { - await createWallet(); + await createTestWallet(); await reconcile(); expect(hasWallet()).toBe(true); expect(await hasKeystoreSecret()).toBe(true); @@ -158,7 +200,7 @@ describe('reconcile', () => { }); it('clears the PIN when the wallet itself reconciles away (blob without secret)', async () => { - await createWallet(); + await createTestWallet(); await setPin('123456'); await Keychain.resetGenericPassword({ service: KEYSTORE_SERVICE }); // secret gone @@ -169,7 +211,7 @@ describe('reconcile', () => { }); it('keeps the PIN for a consistent wallet', async () => { - await createWallet(); + await createTestWallet(); await setPin('123456'); await reconcile(); @@ -180,7 +222,7 @@ describe('reconcile', () => { describe('wipeWallet', () => { it('clears the blob, the keystore secret, and the PIN', async () => { - await createWallet(); + await createTestWallet(); await setPin('123456'); await wipeWallet(); diff --git a/src/lib/deviceWallet.ts b/src/lib/deviceWallet.ts index 581fd45..b915f5e 100644 --- a/src/lib/deviceWallet.ts +++ b/src/lib/deviceWallet.ts @@ -34,7 +34,7 @@ import { saveWalletRecord, } from './walletStorage'; -/** Thrown by {@link createWallet} when a wallet already exists (single-wallet). */ +/** Thrown by {@link commitWallet} when a wallet already exists (single-wallet). */ export class WalletExistsError extends Error { override readonly name = 'WalletExistsError'; constructor() { @@ -50,11 +50,9 @@ export class NoWalletError extends Error { } } -/** The freshly created wallet. The mnemonic MUST be backed up then discarded. */ +/** The freshly committed wallet. The mnemonic MUST already be backed up. */ export interface CreatedWallet { readonly address: string; - /** Show once for backup; unrecoverable afterwards (the blob is not the seed). */ - readonly mnemonic: string; } /** @@ -66,47 +64,64 @@ export interface UnlockedSession { signMessage(message: ArrayBuffer): Promise; } -let inFlightCreate: Promise | null = null; +/** + * Generates a fresh 12-word mnemonic + its address, WITHOUT touching storage. + * Pure/sync (no I/O — `generateWallet` does no FFI round-trip). The caller + * (onboarding) holds the mnemonic in memory only, shows it, runs the backup + * quiz, and commits via {@link commitWallet} — never persist it earlier: the + * mnemonic-3 invariant (spec) requires backup-before-commit, and the old + * combined `createWallet` violated it (audit F1 — commit happened before the + * user had ever seen the phrase). + */ +export function generateMnemonic(): { mnemonic: string; address: string } { + return generateWallet(FfiWordCount.Words12); +} + +let inFlightCommit: Promise | null = null; /** - * Creates a fresh wallet and commits it, generating the mnemonic + a random - * keystore secret, then persisting {blob → MMKV, secret → Keychain}. + * Commits an already-backed-up mnemonic: generates a random keystore secret, + * imports the mnemonic under it, and persists {blob → MMKV, secret → + * Keychain}. Callers MUST have already shown the mnemonic and verified the + * backup (onboarding quiz) — this function only encrypts and stores. * * Commit order is Keychain-first, MMKV-blob-last (the blob write is the atomic * commit point). If the MMKV write fails after the secret was stored, the secret - * is rolled back so no orphan is left. Concurrent calls dedupe via single-flight. + * is rolled back so no orphan is left. Concurrent calls dedupe via single-flight + * (the mnemonic differing across calls cannot happen — onboarding holds exactly + * one in flight). * * @throws {WalletExistsError} if a wallet already exists (never overwrites). */ -export function createWallet(): Promise { +export function commitWallet(mnemonic: string): Promise { // Synchronous precondition, kept OUT of the single-flight so its rejection // never becomes the cached in-flight promise. if (hasWalletRecord()) { return Promise.reject(new WalletExistsError()); } - if (inFlightCreate !== null) { - return inFlightCreate; + if (inFlightCommit !== null) { + return inFlightCommit; } // `.finally` clears the slot on a microtask after the commit settles, so a - // fast/synchronous failure inside `commitNewWallet` can't leave a stale promise. - inFlightCreate = commitNewWallet().finally(() => { - inFlightCreate = null; + // fast/synchronous failure inside `commit` can't leave a stale promise. + inFlightCommit = commit(mnemonic).finally(() => { + inFlightCommit = null; }); - return inFlightCreate; + return inFlightCommit; } -async function commitNewWallet(): Promise { - const generated = generateWallet(FfiWordCount.Words24); +async function commit(mnemonic: string): Promise { const secretHex = randomKeystoreSecretHex(); const password = asciiToArrayBuffer(secretHex); - const wallet = await FfiWallet.importMnemonic(generated.mnemonic, password); + const wallet = await FfiWallet.importMnemonic(mnemonic, password); const blob = wallet.exportKeystore(); + const address = wallet.address(); // Keychain first (may prompt); MMKV blob last = commit marker. await saveKeystoreSecret(secretHex); try { - saveWalletRecord({ blob, address: generated.address }); + saveWalletRecord({ blob, address }); } catch (error: unknown) { // Roll back everything so a failed commit leaves nothing behind: clear any // partially-written record (address lands before the blob) and wipe the @@ -116,7 +131,7 @@ async function commitNewWallet(): Promise { throw error; } - return { address: generated.address, mnemonic: generated.mnemonic }; + return { address }; } /** diff --git a/src/navigation/OnboardingNavigator.tsx b/src/navigation/OnboardingNavigator.tsx new file mode 100644 index 0000000..299a9dd --- /dev/null +++ b/src/navigation/OnboardingNavigator.tsx @@ -0,0 +1,38 @@ +import { assertNever } from '../lib/assertNever'; +import { PlaceholderScreen } from '../screens/PlaceholderScreen'; +import { ConfirmPin } from '../screens/onboarding/ConfirmPin'; +import { CreatePin } from '../screens/onboarding/CreatePin'; +import { KeepItSafe } from '../screens/onboarding/KeepItSafe'; +import { Welcome } from '../screens/onboarding/Welcome'; +import { useOnboardingStore } from '../stores/onboardingStore'; + +/** + * The Create-wallet flow's shell: a pure function of `onboardingStore.step`, + * same pattern as `RootNavigator` over `walletStore.phase` — no + * `@react-navigation` history here, the store IS the source of truth (no + * resumable back-stack, matching the "no resumable onboarding" invariant). + * `pinConfirmed` is A2a's landing state; A2b replaces it with ShowPhrase. + */ +export function OnboardingNavigator() { + const step = useOnboardingStore((state) => state.step); + + switch (step) { + case 'welcome': + return ; + case 'keepItSafe': + return ; + case 'createPin': + return ; + case 'confirmPin': + return ; + case 'pinConfirmed': + return ( + + ); + default: + return assertNever(step); + } +} diff --git a/src/navigation/RootNavigator.tsx b/src/navigation/RootNavigator.tsx index de3154e..4a97c02 100644 --- a/src/navigation/RootNavigator.tsx +++ b/src/navigation/RootNavigator.tsx @@ -2,13 +2,14 @@ import { assertNever } from '../lib/assertNever'; import { PlaceholderScreen } from '../screens/PlaceholderScreen'; import { SplashScreen } from '../screens/SplashScreen'; import { useWalletStore } from '../stores/walletStore'; +import { OnboardingNavigator } from './OnboardingNavigator'; /** * Top-level shell: renders exactly one destination for the current wallet phase. * A pure function of `walletStore.phase` — the one-time `hydrate` that drives the * phase lives in `App`, keeping this a testable phase→screen map. Real per-phase - * navigators (onboarding stack, home tabs) replace the placeholders in later - * A-slices; the `switch` is exhaustive so adding a phase is a compile error here. + * navigators (home tabs) replace the remaining placeholders in later A-slices; + * the `switch` is exhaustive so adding a phase is a compile error here. */ export function RootNavigator() { const phase = useWalletStore((state) => state.phase); @@ -18,7 +19,7 @@ export function RootNavigator() { case 'loading': return ; case 'no_wallet': - return ; + return ; case 'locked': return ( void }; + +/** No-op when the element's `onPress` is unset (disabled) — mirrors a real touch. */ +function tap(tree: TestRenderer.ReactTestRenderer, testID: string) { + act(() => { + const onPress = tree.root.findByProps({ testID }).props.onPress as (() => void) | undefined; + onPress?.(); + }); +} + +function pressPin(tree: TestRenderer.ReactTestRenderer, pin: string) { + for (const digit of pin) { + tap(tree, `pin-key-${digit}`); + } +} + +beforeEach(() => { + mmkvMock.__resetMMKV(); + act(() => { + useOnboardingStore.setState({ step: 'welcome', mnemonic: null, pendingPin: null }); + }); +}); + +describe('OnboardingNavigator — Create happy path', () => { + it('walks Welcome→KeepItSafe→CreatePin→ConfirmPin to the pinConfirmed placeholder, persisting the PIN without committing the wallet', async () => { + let tree: TestRenderer.ReactTestRenderer; + act(() => { + tree = TestRenderer.create(); + }); + expect(JSON.stringify(tree!.toJSON())).toContain('Создать кошелёк'); + + tap(tree!, 'welcome-create'); + expect(useOnboardingStore.getState().mnemonic).not.toBeNull(); + expect(JSON.stringify(tree!.toJSON())).toContain('Прежде чем начать'); + + for (let i = 0; i < 3; i += 1) { + tap(tree!, `keep-it-safe-check-${i}`); + } + tap(tree!, 'keep-it-safe-continue'); + expect(JSON.stringify(tree!.toJSON())).toContain('Придумайте PIN'); + + pressPin(tree!, '123456'); + expect(JSON.stringify(tree!.toJSON())).toContain('Повторите PIN'); + + pressPin(tree!, '123456'); + // confirmPin's setPin is async — flush the pending microtask inside act. + await act(async () => { + await new Promise((resolve) => setImmediate(resolve)); + }); + + expect(useOnboardingStore.getState().step).toBe('pinConfirmed'); + expect(hasPin()).toBe(true); // PIN persisted... + expect(hasWallet()).toBe(false); // ...but the wallet is NOT committed (F1, A2b's job) + + act(() => { + tree.unmount(); + }); + }); + + it('KeepItSafe Продолжить stays inert until all 3 acknowledgements are checked', () => { + let tree: TestRenderer.ReactTestRenderer; + act(() => { + tree = TestRenderer.create(); + }); + tap(tree!, 'welcome-create'); + + tap(tree!, 'keep-it-safe-check-0'); + tap(tree!, 'keep-it-safe-continue'); // 1 of 3 — must not advance + expect(JSON.stringify(tree!.toJSON())).toContain('Прежде чем начать'); + + tap(tree!, 'keep-it-safe-check-1'); + tap(tree!, 'keep-it-safe-check-2'); + tap(tree!, 'keep-it-safe-continue'); // now all 3 + expect(JSON.stringify(tree!.toJSON())).toContain('Придумайте PIN'); + + act(() => { + tree.unmount(); + }); + }); + + it('a PIN mismatch on ConfirmPin returns to CreatePin without persisting', () => { + let tree: TestRenderer.ReactTestRenderer; + act(() => { + tree = TestRenderer.create(); + }); + tap(tree!, 'welcome-create'); + for (let i = 0; i < 3; i += 1) { + tap(tree!, `keep-it-safe-check-${i}`); + } + tap(tree!, 'keep-it-safe-continue'); + + pressPin(tree!, '123456'); + pressPin(tree!, '000000'); // mismatch + + expect(useOnboardingStore.getState().step).toBe('createPin'); + expect(JSON.stringify(tree!.toJSON())).toContain('Придумайте PIN'); + expect(hasPin()).toBe(false); + + act(() => { + tree.unmount(); + }); + }); +}); diff --git a/src/navigation/__tests__/RootNavigator.test.tsx b/src/navigation/__tests__/RootNavigator.test.tsx index 0ec89e0..ca9c074 100644 --- a/src/navigation/__tests__/RootNavigator.test.tsx +++ b/src/navigation/__tests__/RootNavigator.test.tsx @@ -35,8 +35,8 @@ describe('RootNavigator', () => { expect(renderAt('loading')).toContain('Rustok'); }); - it('renders the onboarding destination when there is no wallet', () => { - expect(renderAt('no_wallet')).toContain('Onboarding'); + it('renders the onboarding Welcome screen when there is no wallet', () => { + expect(renderAt('no_wallet')).toContain('RUST'); }); it('renders the locked destination carrying the stored address', () => { diff --git a/src/screens/onboarding/ConfirmPin.tsx b/src/screens/onboarding/ConfirmPin.tsx new file mode 100644 index 0000000..773083e --- /dev/null +++ b/src/screens/onboarding/ConfirmPin.tsx @@ -0,0 +1,32 @@ +/** + * ConfirmPin — PIN re-entry. `onboardingStore.confirmPin` already routes the + * step back to `createPin` on a mismatch, so a fresh `PinKeypad` there is the + * reset — no local error/shake state needed here (a fuller shake/haptics + * choreography lands with the Quiz wrong-answer case in A2b, per the spec's + * open question, so it isn't built twice). + */ + +import { Text, View } from 'react-native'; + +import { PinKeypad } from '../../components/PinKeypad'; +import { useOnboardingStore } from '../../stores/onboardingStore'; + +const PIN_LENGTH = 6; + +export function ConfirmPin() { + const confirmPin = useOnboardingStore((state) => state.confirmPin); + + function handleComplete(pin: string) { + // PinKeypad always yields exactly 6 digits, so `InvalidPinError` cannot + // fire here; a remaining crypto/storage throw from `pinAuth.setPin` has + // no error UI yet in this slice (no-floating-promises requires a catch). + confirmPin(pin).catch(() => undefined); + } + + return ( + + Повторите PIN + + + ); +} diff --git a/src/screens/onboarding/CreatePin.tsx b/src/screens/onboarding/CreatePin.tsx new file mode 100644 index 0000000..9018507 --- /dev/null +++ b/src/screens/onboarding/CreatePin.tsx @@ -0,0 +1,22 @@ +/** + * CreatePin — first PIN entry. Reuses `PinKeypad` (A3's UnlockPin will too) + * with the visual language of the flipbook's "Вход — Locked" keypad. + */ + +import { Text, View } from 'react-native'; + +import { PinKeypad } from '../../components/PinKeypad'; +import { useOnboardingStore } from '../../stores/onboardingStore'; + +const PIN_LENGTH = 6; + +export function CreatePin() { + const submitPin = useOnboardingStore((state) => state.submitPin); + + return ( + + Придумайте PIN + + + ); +} diff --git a/src/screens/onboarding/KeepItSafe.tsx b/src/screens/onboarding/KeepItSafe.tsx new file mode 100644 index 0000000..a9fd6e0 --- /dev/null +++ b/src/screens/onboarding/KeepItSafe.tsx @@ -0,0 +1,79 @@ +/** + * KeepItSafe — not in the flipbook (built from the governing spec's language, + * ratified Gate-1 A2a carry-over #7). 3 acknowledgement cards in the + * `surface` token language A1b already established; `Продолжить` gates on + * all three. + */ + +import { useState } from 'react'; +import { Pressable, Text, View } from 'react-native'; + +import { useOnboardingStore } from '../../stores/onboardingStore'; + +const ACKNOWLEDGEMENTS = [ + 'Я один владею фразой восстановления', + 'Никто не спросит её по телефону или в чате — это признак мошенничества', + 'Если я её потеряю, кошелёк нельзя будет восстановить', +] as const; + +export function KeepItSafe() { + const acceptSafety = useOnboardingStore((state) => state.acceptSafety); + const [checked, setChecked] = useState(ACKNOWLEDGEMENTS.map(() => false)); + const allChecked = checked.every(Boolean); + + function toggle(index: number) { + setChecked((prev) => prev.map((value, i) => (i === index ? !value : value))); + } + + return ( + + + + Прежде чем начать + + {ACKNOWLEDGEMENTS.map((text, i) => ( + toggle(i)} + className="flex-row items-center gap-3 rounded-field border border-borderDefault bg-surface p-4" + > + + {checked[i] && } + + {text} + + ))} + + + + Продолжить + + + + ); +} diff --git a/src/screens/onboarding/Welcome.tsx b/src/screens/onboarding/Welcome.tsx new file mode 100644 index 0000000..012658a --- /dev/null +++ b/src/screens/onboarding/Welcome.tsx @@ -0,0 +1,56 @@ +/** + * Welcome — 1:1 port of the flipbook's "Онбординг — Welcome" screen + * (`design-handoff/prototypes/Rustok Flipbook.dc.html:187-199`). + * + * The logo is rendered as the wordmark text (RUST + teal OK), not the + * flipbook's polygon-SVG mark — `react-native-svg` isn't a project dependency + * yet and adding a new native dep is out of A2a's scope (flagged in the + * Gate-2 report, not silently pulled in). `SplashScreen` already uses the + * same text-wordmark fallback. + */ + +import { Pressable, Text, View } from 'react-native'; + +import { useOnboardingStore } from '../../stores/onboardingStore'; + +export function Welcome() { + const beginCreate = useOnboardingStore((state) => state.beginCreate); + + return ( + + + + RUSTOK + + + Пульт одобрения для вашего агента. Агент предлагает — вы осознанно подписываете. Ключ + только на этом устройстве. + + + + + + Создать кошелёк + + + {/* A4 — import flow. Visually present, inert until then. */} + + + Импортировать фразу + + + + self-custody · ключи не покидают устройство + + + + ); +} diff --git a/src/stores/__tests__/onboardingStore.test.ts b/src/stores/__tests__/onboardingStore.test.ts new file mode 100644 index 0000000..a046ade --- /dev/null +++ b/src/stores/__tests__/onboardingStore.test.ts @@ -0,0 +1,121 @@ +/** + * onboardingStore tests — the Create-flow step machine over real pinAuth + * (MMKV/argon2 mocked at the native boundary, as in the pinAuth suite). + * `generateMnemonic` runs against the real deviceWallet/bridge mock — no + * storage is touched by it (F1), so no wallet setup/teardown is needed here. + */ + +import * as MMKV from 'react-native-mmkv'; + +import { hasPin } from '../../lib/pinAuth'; +import { useOnboardingStore } from '../onboardingStore'; + +const mmkvMock = MMKV as unknown as { __resetMMKV: () => void }; + +function resetStore(): void { + useOnboardingStore.setState({ step: 'welcome', mnemonic: null, pendingPin: null }); +} + +beforeEach(() => { + mmkvMock.__resetMMKV(); + resetStore(); +}); + +describe('useOnboardingStore', () => { + it('starts at welcome with no mnemonic or pending PIN', () => { + const state = useOnboardingStore.getState(); + expect(state.step).toBe('welcome'); + expect(state.mnemonic).toBeNull(); + expect(state.pendingPin).toBeNull(); + }); + + it('beginCreate generates a mnemonic in memory and advances to keepItSafe', () => { + useOnboardingStore.getState().beginCreate(); + + const state = useOnboardingStore.getState(); + expect(state.step).toBe('keepItSafe'); + expect(state.mnemonic).not.toBeNull(); + expect(state.mnemonic?.length).toBeGreaterThan(0); + }); + + it('acceptSafety advances to createPin without touching the mnemonic', () => { + useOnboardingStore.getState().beginCreate(); + const mnemonic = useOnboardingStore.getState().mnemonic; + + useOnboardingStore.getState().acceptSafety(); + + const state = useOnboardingStore.getState(); + expect(state.step).toBe('createPin'); + expect(state.mnemonic).toBe(mnemonic); + }); + + it('submitPin records the entry and advances to confirmPin', () => { + useOnboardingStore.getState().submitPin('123456'); + + const state = useOnboardingStore.getState(); + expect(state.step).toBe('confirmPin'); + expect(state.pendingPin).toBe('123456'); + }); + + describe('confirmPin', () => { + it('on match: persists the PIN, clears pendingPin, and advances to pinConfirmed', async () => { + useOnboardingStore.getState().submitPin('123456'); + + const result = await useOnboardingStore.getState().confirmPin('123456'); + + expect(result).toBe('ok'); + const state = useOnboardingStore.getState(); + expect(state.step).toBe('pinConfirmed'); + expect(state.pendingPin).toBeNull(); // plaintext PIN does not survive its own commit + expect(hasPin()).toBe(true); // persisted via pinSetupStore + }); + + it('on mismatch: does NOT persist, clears pendingPin, and returns to createPin', async () => { + useOnboardingStore.getState().submitPin('123456'); + + const result = await useOnboardingStore.getState().confirmPin('000000'); + + expect(result).toBe('mismatch'); + const state = useOnboardingStore.getState(); + expect(state.step).toBe('createPin'); + expect(state.pendingPin).toBeNull(); + expect(hasPin()).toBe(false); + }); + }); + + describe('cancel', () => { + it('wipes the mnemonic and pendingPin, returning to welcome', () => { + useOnboardingStore.getState().beginCreate(); + useOnboardingStore.getState().acceptSafety(); + useOnboardingStore.getState().submitPin('123456'); + + useOnboardingStore.getState().cancel(); + + const state = useOnboardingStore.getState(); + expect(state.step).toBe('welcome'); + expect(state.mnemonic).toBeNull(); + expect(state.pendingPin).toBeNull(); + }); + + it('does not un-persist an already-confirmed PIN (cancel is a flow reset, not a wipe)', async () => { + useOnboardingStore.getState().submitPin('123456'); + await useOnboardingStore.getState().confirmPin('123456'); + + useOnboardingStore.getState().cancel(); + + expect(hasPin()).toBe(true); // deviceWallet.reconcile() owns orphan cleanup, not cancel() + }); + }); + + it('start resets an in-progress flow the same way cancel does', () => { + useOnboardingStore.getState().beginCreate(); + useOnboardingStore.getState().submitPin('123456'); + + useOnboardingStore.getState().start(); + + const state = useOnboardingStore.getState(); + expect(state.step).toBe('welcome'); + expect(state.mnemonic).toBeNull(); + expect(state.pendingPin).toBeNull(); + }); +}); diff --git a/src/stores/__tests__/walletStore.test.ts b/src/stores/__tests__/walletStore.test.ts index c4c339b..95eb2a7 100644 --- a/src/stores/__tests__/walletStore.test.ts +++ b/src/stores/__tests__/walletStore.test.ts @@ -9,7 +9,7 @@ import * as Keychain from 'react-native-keychain'; import * as MMKV from 'react-native-mmkv'; -import { createWallet } from '../../lib/deviceWallet'; +import { commitWallet, generateMnemonic } from '../../lib/deviceWallet'; import { useWalletStore } from '../walletStore'; const keychainMock = Keychain as unknown as { @@ -47,7 +47,7 @@ describe('useWalletStore', () => { }); it('routes to locked with the stored address when a wallet exists', async () => { - const created = await createWallet(); + const created = await commitWallet(generateMnemonic().mnemonic); resetStore(); // simulate a fresh launch over the persisted wallet await useWalletStore.getState().hydrate(); @@ -57,7 +57,7 @@ describe('useWalletStore', () => { }); it('still routes locked by the blob truth when reconcile\'s keychain probe fails', async () => { - await createWallet(); + await commitWallet(generateMnemonic().mnemonic); resetStore(); // The next keychain op is reconcile's secret probe; failing it makes // reconcile reject. The blob is still in MMKV, so phase must not regress diff --git a/src/stores/onboardingStore.ts b/src/stores/onboardingStore.ts new file mode 100644 index 0000000..961ca98 --- /dev/null +++ b/src/stores/onboardingStore.ts @@ -0,0 +1,90 @@ +/** + * onboardingStore — the Create-wallet flow's step machine, in memory only. + * + * Holds the mnemonic and the in-progress PIN entry between screens. NEVER + * persisted: the mnemonic must live only in JS memory until the user has + * backed it up (spec's mnemonic-3 invariant — reveal-later is impossible, + * the stored blob is an encrypted derived key, not the seed). `cancel()` + * wipes both, and `confirmPin('ok')` clears `pendingPin` the moment it has + * done its job (persisted via `pinSetupStore`) — a plaintext PIN has no + * reason to survive its own commit. + * + * The PIN is set here (A2a), the mnemonic is committed to the wallet in A2b + * (after ShowPhrase + the backup quiz). That gap is intentional and already + * covered by `deviceWallet.reconcile()` (A1c): a PIN without a wallet on a + * cold start is orphaned onboarding state and gets cleared — see the + * reconcile-boundary note there. `reconcile()` runs ONLY on cold start, never + * mid-onboarding, or it would wipe a PIN the user just set. + */ + +import { create } from 'zustand'; + +import { generateMnemonic } from '../lib/deviceWallet'; +import { usePinSetupStore } from './pinSetupStore'; + +/** + * `pinConfirmed` is A2a's landing state: the PIN is set, but the wallet is + * NOT committed yet (that needs ShowPhrase + the backup quiz — A2b). It + * renders a placeholder screen in A2a and A2b will replace it with the real + * `showPhrase` step. + */ +export type OnboardingStep = 'welcome' | 'keepItSafe' | 'createPin' | 'confirmPin' | 'pinConfirmed'; + +interface OnboardingState { + readonly step: OnboardingStep; + /** null until `beginCreate`; never log or persist. */ + readonly mnemonic: string | null; + /** The PIN entered on CreatePin, awaiting the ConfirmPin re-entry. */ + readonly pendingPin: string | null; + + /** Resets to the flow's entry point, discarding any in-progress state. */ + start: () => void; + /** Generates the mnemonic (in memory only) and advances to KeepItSafe. */ + beginCreate: () => void; + /** Advances past the 3 KeepItSafe acknowledgements to CreatePin. */ + acceptSafety: () => void; + /** Records the first PIN entry and advances to ConfirmPin. */ + submitPin: (pin: string) => void; + /** + * Compares the re-entered PIN against `pendingPin`. On match, persists the + * PIN via `pinSetupStore` (ahead of the wallet commit, by design) and + * clears `pendingPin` — it has no further use once hashed. On mismatch, + * clears `pendingPin` and returns to CreatePin for a retry. + */ + confirmPin: (pin: string) => Promise<'ok' | 'mismatch'>; + /** Wipes mnemonic/pendingPin and returns to welcome — abandon the flow. */ + cancel: () => void; +} + +export const useOnboardingStore = create((set, get) => ({ + step: 'welcome', + mnemonic: null, + pendingPin: null, + + start: () => { + set({ step: 'welcome', mnemonic: null, pendingPin: null }); + }, + beginCreate: () => { + const { mnemonic } = generateMnemonic(); + set({ step: 'keepItSafe', mnemonic }); + }, + acceptSafety: () => { + set({ step: 'createPin' }); + }, + submitPin: (pin: string) => { + set({ step: 'confirmPin', pendingPin: pin }); + }, + confirmPin: async (pin: string) => { + const { pendingPin } = get(); + if (pin !== pendingPin) { + set({ step: 'createPin', pendingPin: null }); + return 'mismatch'; + } + await usePinSetupStore.getState().setPin(pin); + set({ step: 'pinConfirmed', pendingPin: null }); + return 'ok'; + }, + cancel: () => { + set({ step: 'welcome', mnemonic: null, pendingPin: null }); + }, +})); From 4cde9136d7bc2901f755e83ec61e8ff5df143bcd Mon Sep 17 00:00:00 2001 From: temrjan Date: Fri, 3 Jul 2026 13:49:28 +0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(mobile):=20A2a=20Gate-2=20findings=20?= =?UTF-8?q?=E2=80=94=20PinKeypad=20double-tap,=20onboardingStore=20setPin-?= =?UTF-8?q?failure=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MINOR #2: PinKeypad read/wrote a stale `digits` state closure on same-tick double-taps (multi-touch), letting onComplete fire twice with different strings. First fix attempt (functional setState update) traded this for a worse bug — calling onComplete (which drives a zustand set()) from inside a state updater fires during React's render phase ("Cannot update a component while rendering a different component"). Final fix: a digitsRef mirror read/written synchronously in the press() event handler (not during render), with onComplete called from press() itself. New double-tap test catches both the original bug and would catch the render-phase regression (backspace test also went red on the interim mutation, confirming ref/state sync matters) - MINOR #1: onboardingStore.confirmPin's security-relevant ordering (persist PIN before advancing step) had no regression test; a future reorder would go green silently. New test mocks pinAuth.setPin to reject and asserts the step never advances and nothing persists — mutation (reordering set() before the await) turns it red - NIT #3: stale createWallet.inFlightCreate reference in a pinAuth.ts docstring, now commitWallet.inFlightCommit - NIT #4: dropped a cannot-fail 'starts at welcome' test (asserted only the beforeEach-forced initial state) - all mutations verified red before green; 99/99 green, 0 lint errors --- src/components/PinKeypad.tsx | 19 ++++++++++++--- src/components/__tests__/PinKeypad.test.tsx | 22 +++++++++++++++++ src/lib/pinAuth.ts | 2 +- src/stores/__tests__/onboardingStore.test.ts | 25 ++++++++++++++------ 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/src/components/PinKeypad.tsx b/src/components/PinKeypad.tsx index f3ace68..d569c9a 100644 --- a/src/components/PinKeypad.tsx +++ b/src/components/PinKeypad.tsx @@ -9,7 +9,7 @@ * timing — the caller owns any shake/haptics choreography. */ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Pressable, Text, View } from 'react-native'; interface PinKeypadProps { @@ -23,9 +23,16 @@ const KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '', '0', '⌫'] as co export function PinKeypad({ length, onComplete, error = false }: PinKeypadProps) { const [digits, setDigits] = useState(''); + // Mirrors `digits` synchronously. `press` reads/writes this instead of the + // `digits` state closure so a same-tick double-tap (multi-touch) sees the + // PRIOR press's result, not a stale render-time value — without that, two + // presses landing before a re-render both compute off the same base and + // `onComplete` can fire twice with different strings (Gate-2 MINOR #2). + const digitsRef = useRef(''); useEffect(() => { if (error) { + digitsRef.current = ''; setDigits(''); } }, [error]); @@ -35,13 +42,19 @@ export function PinKeypad({ length, onComplete, error = false }: PinKeypadProps) return; } if (key === '⌫') { - setDigits((prev) => prev.slice(0, -1)); + digitsRef.current = digitsRef.current.slice(0, -1); + setDigits(digitsRef.current); return; } - const next = digits + key; + const next = digitsRef.current + key; + digitsRef.current = next; setDigits(next); if (next.length === length) { + // Fired from the Pressable's event handler, not from a state updater — + // `onComplete` (which drives an onboardingStore/pinAttemptsStore + // `set()`) must never run during React's render phase. onComplete(next); + digitsRef.current = ''; setDigits(''); } } diff --git a/src/components/__tests__/PinKeypad.test.tsx b/src/components/__tests__/PinKeypad.test.tsx index 5de03c2..06b4db1 100644 --- a/src/components/__tests__/PinKeypad.test.tsx +++ b/src/components/__tests__/PinKeypad.test.tsx @@ -81,6 +81,28 @@ describe('PinKeypad', () => { expect(litDots(tree!, 2)).toBe(1); }); + it('a same-tick double-tap (multi-touch) fires onComplete at most once (Gate-2 MINOR #2)', () => { + // Two presses dispatched inside ONE act() — no re-render happens between + // them, so a version reading `digits` off the render-time closure sees + // the SAME stale value for both and can complete twice (e.g. '12' then + // '13'). The functional setState update fixes this: each queued updater + // sees the prior updater's result, not a stale closure. + const onComplete = jest.fn(); + let tree: TestRenderer.ReactTestRenderer; + act(() => { + tree = TestRenderer.create(); + }); + pressKey(tree!, '1'); // settle to digits='1' via its own render + + act(() => { + tree.root.findByProps({ testID: 'pin-key-2' }).props.onPress(); + tree.root.findByProps({ testID: 'pin-key-3' }).props.onPress(); + }); + + expect(onComplete).toHaveBeenCalledTimes(1); + expect(onComplete).toHaveBeenCalledWith('12'); + }); + it('the error prop clears any entered digits', () => { const onComplete = jest.fn(); let tree: TestRenderer.ReactTestRenderer; diff --git a/src/lib/pinAuth.ts b/src/lib/pinAuth.ts index f5fc85d..df3175f 100644 --- a/src/lib/pinAuth.ts +++ b/src/lib/pinAuth.ts @@ -115,7 +115,7 @@ export async function setPin(pin: string): Promise { /** * Chains verify attempts so they run strictly one-at-a-time. NOT a dedupe - * single-flight (à la `createWallet.inFlightCreate`): concurrent callers may + * single-flight (à la `commitWallet.inFlightCommit`): concurrent callers may * carry DIFFERENT pins, so every attempt must be evaluated individually, in * order — deduping would hand caller B the verdict on caller A's pin. */ diff --git a/src/stores/__tests__/onboardingStore.test.ts b/src/stores/__tests__/onboardingStore.test.ts index a046ade..121889e 100644 --- a/src/stores/__tests__/onboardingStore.test.ts +++ b/src/stores/__tests__/onboardingStore.test.ts @@ -7,6 +7,7 @@ import * as MMKV from 'react-native-mmkv'; +import * as pinAuth from '../../lib/pinAuth'; import { hasPin } from '../../lib/pinAuth'; import { useOnboardingStore } from '../onboardingStore'; @@ -22,13 +23,6 @@ beforeEach(() => { }); describe('useOnboardingStore', () => { - it('starts at welcome with no mnemonic or pending PIN', () => { - const state = useOnboardingStore.getState(); - expect(state.step).toBe('welcome'); - expect(state.mnemonic).toBeNull(); - expect(state.pendingPin).toBeNull(); - }); - it('beginCreate generates a mnemonic in memory and advances to keepItSafe', () => { useOnboardingStore.getState().beginCreate(); @@ -81,6 +75,23 @@ describe('useOnboardingStore', () => { expect(state.pendingPin).toBeNull(); expect(hasPin()).toBe(false); }); + + it('on a setPin failure: does NOT advance step, propagates the rejection, and persists nothing (Gate-2 MINOR #1)', async () => { + // The security-relevant ordering is `await setPin()` BEFORE `set(step: + // 'pinConfirmed')` — if that were ever flipped, this would go green + // with an advanced step and an unset PIN (silent data loss, surfacing + // only later as an unrecoverable Unlock screen after the A2b commit). + jest.spyOn(pinAuth, 'setPin').mockRejectedValueOnce(new Error('crypto failed')); + useOnboardingStore.getState().submitPin('123456'); + + await expect(useOnboardingStore.getState().confirmPin('123456')).rejects.toThrow( + 'crypto failed', + ); + + const state = useOnboardingStore.getState(); + expect(state.step).toBe('confirmPin'); // never advanced past the failed persist + expect(hasPin()).toBe(false); + }); }); describe('cancel', () => {