mirror of
https://github.com/tompro/sattle.git
synced 2026-08-26 23:05:58 +00:00
fix: clear runtime state after lifecycle failures
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user