fix: clear runtime state after lifecycle failures

This commit is contained in:
2026-08-22 16:56:25 +02:00
parent c319e11d3e
commit 3acc63e968
4 changed files with 396 additions and 0 deletions
@@ -0,0 +1,45 @@
import { PASSWORD, mocks } from './wallet.lifecycle.testHarness';
import { afterEach, beforeEach, describe, expect, vi, it } from 'vitest';
import { useNwcStore } from './nwc';
import { useWalletStore } from './wallet';
const AUTO_LOCK_MS = 5 * 60 * 1000;
describe('idle auto-lock failure', () => {
beforeEach(() => {
vi.stubGlobal('window', {
addEventListener: () => undefined,
removeEventListener: () => undefined,
});
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('clears runtime keys and surfaces the error when an idle auto-lock drain fails', async () => {
// Given an encrypted unlocked wallet whose live NWC service rejects shutdown
const wallet = useWalletStore();
const nwc = useNwcStore();
await wallet.create(PASSWORD);
const serviceStop = vi.fn().mockRejectedValue(new Error('idle drain failed'));
mocks.startService.mockResolvedValue({ connections: [], stop: serviceStop });
await nwc.setEnabled(true);
expect(nwc.running).toBe(true);
expect(wallet.state).toBe('unlocked');
// When the wallet goes idle past the auto-lock timeout
await vi.advanceTimersByTimeAsync(AUTO_LOCK_MS + 2000);
// Then the rejected drain still leaves a truthful locked state: no usable
// key material, the failure surfaced, the service handle dropped
expect(wallet.state).toBe('locked');
expect(wallet.lifecycleError).toMatch(/idle drain failed/i);
expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.');
expect(wallet.bearers).toEqual([]);
expect(nwc.running).toBe(false);
expect(serviceStop).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,151 @@
import { MINT_KEY, PASSWORD, deferred, mocks } from './wallet.lifecycle.testHarness';
import { describe, expect, it, vi } from 'vitest';
import { savedKeyExists } from '@/lnurlcash/keys';
import { addTrustedMint } from '@/lnurlcash/trustedMints';
import { useNwcStore } from './nwc';
import { useWalletStore } from './wallet';
describe('complete owner teardown', () => {
it('clears runtime keys and the NWC service when ordinary lock drain fails', async () => {
// Given an encrypted unlocked wallet whose live NWC service rejects shutdown
const serviceStop = vi.fn().mockRejectedValue(new Error('NWC lock drain failed'));
mocks.startService.mockResolvedValue({ connections: [], stop: serviceStop });
const wallet = useWalletStore();
const nwc = useNwcStore();
await wallet.create(PASSWORD);
await nwc.setEnabled(true);
expect(nwc.running).toBe(true);
// When ordinary lock invalidates the session and shutdown rejects
const locking = wallet.lock();
// Then failure is truthful while no runtime capability remains usable
await expect(locking).rejects.toThrow('NWC lock drain failed');
expect(wallet.state).toBe('locked');
expect(wallet.lifecycleError).toMatch(/NWC lock drain failed/i);
expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.');
expect(wallet.bearers).toEqual([]);
expect(nwc.running).toBe(false);
expect(serviceStop).toHaveBeenCalledTimes(1);
});
it('waits for NWC drain and removes owner state plus idle listeners before none', async () => {
// Given an encrypted wallet with owner data and tracked window listeners
const listeners = new Map<string, Set<EventListenerOrEventListenerObject>>();
const cleanupOrder: string[] = [];
vi.stubGlobal('window', {
addEventListener: (event: string, listener: EventListenerOrEventListenerObject) => {
const registered = listeners.get(event) ?? new Set();
registered.add(listener);
listeners.set(event, registered);
},
removeEventListener: (event: string, listener: EventListenerOrEventListenerObject) => {
cleanupOrder.push(`listener:${event}`);
listeners.get(event)?.delete(listener);
},
});
const wallet = useWalletStore();
const nwc = useNwcStore();
await wallet.create(PASSWORD);
const ownerId = wallet.pubkey;
if (ownerId === null) throw new Error('Expected an unlocked owner.');
await addTrustedMint('owned.example', MINT_KEY, { ownerId });
localStorage.setItem(
'sattle_passkey_slots',
JSON.stringify([
{
credentialId: '11'.repeat(16),
hkdfSalt: '22'.repeat(16),
iv: '33'.repeat(12),
wrappedKey: '44'.repeat(48),
createdAt: 1,
ownerId,
},
]),
);
const drain = deferred();
const stopSpy = vi.spyOn(nwc, 'stop').mockReturnValue(drain.promise);
const removeItem = localStorage.removeItem.bind(localStorage);
localStorage.removeItem = (key: string): void => {
cleanupOrder.push(`storage:${key}`);
removeItem(key);
};
// When forget starts while NWC work is still draining
const forgetting = wallet.forgetWallet();
await vi.waitFor(() => expect(stopSpy).toHaveBeenCalled());
// Then completion and destructive storage removal wait for the drain,
// and the session stays commit-capable (fence and keys live) until it
// finishes so accepted NWC work can still reach its durable outcome
expect(savedKeyExists()).toBe(true);
expect(wallet.state).toBe('unlocked');
drain.resolve();
await forgetting;
expect(wallet.state).toBe('none');
expect(savedKeyExists()).toBe(false);
expect(localStorage.getItem('sattle_passkey_slots')).toBeNull();
expect(localStorage.getItem('sattle_nwc_connections')).toBeNull();
expect(localStorage.getItem('sattle_nwc_enabled')).toBeNull();
expect(localStorage.getItem('sattle_trusted_mints')).toBeNull();
expect([...listeners.values()].every((registered) => registered.size === 0)).toBe(true);
expect(cleanupOrder.indexOf('listener:scroll')).toBeLessThan(
cleanupOrder.indexOf('storage:sattle_linking_key'),
);
});
it('surfaces biometric deletion failure without reporting completion', async () => {
// Given an unlocked wallet whose secure-storage deletion rejects
const wallet = useWalletStore();
useNwcStore();
await wallet.create(PASSWORD);
mocks.disableBiometricUnlock.mockRejectedValue(new Error('secure delete failed'));
// When forget reaches biometric teardown
const forgetting = wallet.forgetWallet();
// Then the caller sees failure and the saved key is not falsely removed
await expect(forgetting).rejects.toThrow('secure delete failed');
expect(wallet.state).toBe('locked');
expect(wallet.lifecycleError).toMatch(/secure delete failed/i);
expect(savedKeyExists()).toBe(true);
expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.');
});
it('surfaces NWC drain failure without removing the saved owner', async () => {
// Given an unlocked wallet whose service drain rejects
const wallet = useWalletStore();
const nwc = useNwcStore();
await wallet.create(PASSWORD);
vi.spyOn(nwc, 'stop').mockRejectedValue(new Error('NWC drain failed'));
// When forget invalidates the session and requests the drain
const forgetting = wallet.forgetWallet();
// Then teardown rejects before deleting owner storage or reporting none
await expect(forgetting).rejects.toThrow('NWC drain failed');
expect(wallet.state).toBe('locked');
expect(wallet.lifecycleError).toMatch(/NWC drain failed/i);
expect(savedKeyExists()).toBe(true);
expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.');
});
it('completes teardown on retry after a transient biometric deletion failure', async () => {
// Given a forget that failed at secure-storage deletion
const wallet = useWalletStore();
useNwcStore();
await wallet.create(PASSWORD);
mocks.disableBiometricUnlock.mockRejectedValueOnce(new Error('secure delete failed'));
await expect(wallet.forgetWallet()).rejects.toThrow('secure delete failed');
expect(wallet.state).toBe('locked');
// When the holder retries the forget
await wallet.forgetWallet();
// Then teardown completes and the error surface clears
expect(wallet.state).toBe('none');
expect(wallet.lifecycleError).toBe('');
expect(savedKeyExists()).toBe(false);
});
});
+126
View File
@@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createWalletIdleWatch } from './walletIdle';
type ListenerMap = Map<string, Set<EventListenerOrEventListenerObject>>;
const stubWindowListeners = (): ListenerMap => {
const listeners: ListenerMap = new Map();
vi.stubGlobal('window', {
addEventListener: (event: string, listener: EventListenerOrEventListenerObject) => {
const registered = listeners.get(event) ?? new Set();
registered.add(listener);
listeners.set(event, registered);
},
removeEventListener: (event: string, listener: EventListenerOrEventListenerObject) => {
listeners.get(event)?.delete(listener);
},
});
return listeners;
};
const fireActivity = (listeners: ListenerMap): void => {
const handler = listeners.get('mousemove')?.values().next().value;
if (typeof handler !== 'function') throw new Error('Expected a registered activity listener.');
handler(new Event('mousemove'));
};
const AUTO_LOCK_MS = 5 * 60 * 1000;
const LOCK_WARNING_MS = 30 * 1000;
const startWatch = (lock: () => Promise<void>) => {
const listeners = stubWindowListeners();
let warningSecondsLeft: number | null = null;
const watch = createWalletIdleWatch({
isEncrypted: () => true,
isUnlocked: () => true,
isLockWarningVisible: () => warningSecondsLeft !== null,
lock,
setWarningSecondsLeft: (seconds) => {
warningSecondsLeft = seconds;
},
});
watch.start();
return {
listeners,
watch,
warningSecondsLeft: () => warningSecondsLeft,
};
};
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('wallet idle watch', () => {
it('locks on schedule once the warning is up, ignoring passive activity', () => {
// Given an unlocked encrypted wallet idle long enough to show the warning
const lock = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
const { listeners, warningSecondsLeft } = startWatch(lock);
vi.advanceTimersByTime(AUTO_LOCK_MS - LOCK_WARNING_MS + 1000);
expect(warningSecondsLeft()).toBe(LOCK_WARNING_MS / 1000 - 1);
// When passive activity arrives while the warning is displayed
fireActivity(listeners);
// Then the countdown is NOT reset - only an explicit postpone dismisses
// the warning, so the "stay unlocked" affordance cannot vanish under the
// pointer
vi.advanceTimersByTime(LOCK_WARNING_MS - 1000);
expect(lock).toHaveBeenCalledTimes(1);
});
it('postpones the auto-lock on activity before any warning', () => {
// Given an unlocked encrypted wallet with regular activity
const lock = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
const { listeners, warningSecondsLeft } = startWatch(lock);
// When activity keeps arriving before the warning threshold
vi.advanceTimersByTime(AUTO_LOCK_MS - 60 * 1000);
fireActivity(listeners);
vi.advanceTimersByTime(AUTO_LOCK_MS - 60 * 1000);
// Then no warning and no lock
expect(warningSecondsLeft()).toBeNull();
expect(lock).not.toHaveBeenCalled();
});
it('detaches every activity listener and stops ticking on stop', () => {
// Given a running watch
const lock = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
const { listeners, watch } = startWatch(lock);
expect(listeners.size).toBeGreaterThan(0);
// When the watch stops
watch.stop();
// Then every window listener is detached and the timer is gone
expect([...listeners.values()].every((registered) => registered.size === 0)).toBe(true);
vi.advanceTimersByTime(AUTO_LOCK_MS * 2);
expect(lock).not.toHaveBeenCalled();
});
it('surfaces a rejected auto-lock through the lock promise without an unhandled rejection', async () => {
// Given an unlocked encrypted wallet whose lock transition fails (the
// wallet's transition queue records the failure in lifecycleError - the
// idle watch only owes the promise a consumer)
const lock = vi.fn<() => Promise<void>>().mockRejectedValue(new Error('NWC drain failed'));
startWatch(lock);
// When the idle timeout fires
vi.advanceTimersByTime(AUTO_LOCK_MS + 1000);
await vi.advanceTimersByTimeAsync(0);
// Then the lock was requested and the watch keeps scheduling (a throw
// escaping the interval callback would kill it)
expect(lock).toHaveBeenCalled();
const calls = lock.mock.calls.length;
vi.advanceTimersByTime(2000);
expect(lock.mock.calls.length).toBeGreaterThan(calls);
});
});
+74
View File
@@ -0,0 +1,74 @@
const AUTO_LOCK_MS = 5 * 60 * 1000;
const LOCK_WARNING_MS = 30 * 1000;
const ACTIVITY_EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll'] as const;
type WalletIdleOptions = {
readonly isEncrypted: () => boolean;
readonly isUnlocked: () => boolean;
// once the warning is up, passive activity is deliberately ignored - only
// postpone() dismisses it, so the "stay unlocked" button can't vanish out
// from under the pointer before the click lands
readonly isLockWarningVisible: () => boolean;
readonly lock: () => Promise<void>;
readonly setWarningSecondsLeft: (seconds: number | null) => void;
};
export type WalletIdleWatch = {
readonly start: () => void;
readonly stop: () => void;
readonly postpone: () => void;
};
export const createWalletIdleWatch = (options: WalletIdleOptions): WalletIdleWatch => {
let lastActivity = Date.now();
let timer: ReturnType<typeof setInterval> | null = null;
let activityListener: (() => void) | null = null;
const stop = (): void => {
if (timer !== null) clearInterval(timer);
timer = null;
if (typeof window !== 'undefined' && activityListener !== null) {
for (const event of ACTIVITY_EVENTS) {
window.removeEventListener(event, activityListener);
}
}
activityListener = null;
options.setWarningSecondsLeft(null);
};
const postpone = (): void => {
lastActivity = Date.now();
options.setWarningSecondsLeft(null);
};
const start = (): void => {
stop();
if (typeof window === 'undefined' || !options.isEncrypted()) return;
lastActivity = Date.now();
activityListener = () => {
if (options.isUnlocked() && !options.isLockWarningVisible()) {
lastActivity = Date.now();
}
};
for (const event of ACTIVITY_EVENTS) {
window.addEventListener(event, activityListener, { passive: true });
}
timer = setInterval(() => {
if (!options.isUnlocked()) return;
const elapsed = Date.now() - lastActivity;
if (elapsed >= AUTO_LOCK_MS) {
// lock() routes through the wallet's transition queue, which records
// any rejection in lifecycleError - the failure IS surfaced there;
// this catch only keeps the fire-and-forget promise from becoming
// an unhandled rejection
void options.lock().catch(() => undefined);
return;
}
if (elapsed >= AUTO_LOCK_MS - LOCK_WARNING_MS) {
options.setWarningSecondsLeft(Math.ceil((AUTO_LOCK_MS - elapsed) / 1000));
}
}, 1000);
};
return { start, stop, postpone };
};