fix: consume owner monitor transition failures

This commit is contained in:
2026-08-24 16:26:29 +02:00
parent 72f387f6e5
commit e892a330fc
3 changed files with 93 additions and 13 deletions
@@ -1,10 +1,11 @@
import { MINT_KEY, OTHER_OWNER_ID, PASSWORD } from './wallet.lifecycle.testHarness'; import { MINT_KEY, mocks, OTHER_OWNER_ID, PASSWORD } from './wallet.lifecycle.testHarness';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { savedKeyOwnerId } from '@/lnurlcash/keys'; import { savedKeyOwnerId } from '@/lnurlcash/keys';
import { readNwcEnabled, writeNwcConnections, writeNwcEnabled } from '@/lnurlcash/nwc'; import { readNwcEnabled, writeNwcConnections, writeNwcEnabled } from '@/lnurlcash/nwc';
import type { NwcConnectionRecord } from '@/lnurlcash/nwc'; import type { NwcConnectionRecord } from '@/lnurlcash/nwc';
import { addTrustedMint, readTrustedMints } from '@/lnurlcash/trustedMints'; import { addTrustedMint, readTrustedMints } from '@/lnurlcash/trustedMints';
import { useNwcStore } from './nwc';
import { useWalletStore } from './wallet'; import { useWalletStore } from './wallet';
describe('cross-tab owner invalidation', () => { describe('cross-tab owner invalidation', () => {
@@ -41,6 +42,36 @@ describe('cross-tab owner invalidation', () => {
expect(readNwcEnabled(OTHER_OWNER_ID)).toBe(false); expect(readNwcEnabled(OTHER_OWNER_ID)).toBe(false);
}); });
it('surfaces a failed stale-tab drain without an unhandled rejection', async () => {
// Given an unlocked old-owner tab whose live NWC service rejects shutdown
const events = new EventTarget();
vi.stubGlobal('window', events);
const wallet = useWalletStore();
const nwc = useNwcStore();
await wallet.create(PASSWORD);
const serviceStop = vi.fn().mockRejectedValue(new Error('stale drain failed'));
mocks.startService.mockResolvedValue({ connections: [], stop: serviceStop });
await nwc.setEnabled(true);
// When another tab replaces the saved wallet owner
localStorage.setItem(
'sattle_linking_key',
JSON.stringify({ enc: false, value: '09'.repeat(32), ownerId: OTHER_OWNER_ID, version: 1 }),
);
events.dispatchEvent(
Object.defineProperties(new Event('storage'), {
key: { value: 'sattle_linking_key' },
}),
);
await vi.waitFor(() => expect(wallet.state).toBe('locked'));
// Then the stale runtime is cleared and the queue surfaces the drain failure
expect(wallet.lifecycleError).toMatch(/stale drain failed/i);
expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.');
expect(nwc.running).toBe(false);
expect(serviceStop).toHaveBeenCalledTimes(1);
});
it('rejects old-owner trust and NWC writes during the markerless forget gap', async () => { it('rejects old-owner trust and NWC writes during the markerless forget gap', async () => {
// Given wallet A was active and an old tab retained only its owner identifier // Given wallet A was active and an old tab retained only its owner identifier
const wallet = useWalletStore(); const wallet = useWalletStore();
+47
View File
@@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { linkingPubKeyHex } from '@/lnurlcash/keys';
import { stubLocalStorage } from '@/lnurlcash/test-utils';
import { startWalletOwnerMonitor } from './walletOwnerMonitor';
const OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(7));
const OTHER_OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(9));
describe('wallet owner monitor', () => {
beforeEach(() => {
vi.unstubAllGlobals();
stubLocalStorage();
});
it('consumes a background transition rejection after an owner replacement', () => {
// Given an unlocked stale owner and a transition promise observed by the queue
const events = new EventTarget();
vi.stubGlobal('window', events);
localStorage.setItem(
'sattle_linking_key',
JSON.stringify({
enc: false,
value: '09'.repeat(32),
ownerId: OTHER_OWNER_ID,
version: 1,
}),
);
const transition = Promise.resolve();
const catchRejection = vi.spyOn(transition, 'catch');
startWalletOwnerMonitor({
snapshot: () => ({ token: 1, state: 'unlocked', ownerId: OWNER_ID }),
deactivate: vi.fn().mockResolvedValue(undefined),
runTransition: vi.fn().mockReturnValue(transition),
});
// When the browser reports that the saved owner changed
events.dispatchEvent(
Object.defineProperties(new Event('storage'), {
key: { value: 'sattle_linking_key' },
}),
);
// Then the fire-and-forget transition has a rejection consumer
expect(catchRejection).toHaveBeenCalledOnce();
});
});
+4 -2
View File
@@ -24,7 +24,8 @@ export const startWalletOwnerMonitor = (monitor: WalletOwnerMonitor): (() => voi
) { ) {
return; return;
} }
void monitor.runTransition(async () => { void monitor
.runTransition(async () => {
const current = monitor.snapshot(); const current = monitor.snapshot();
if ( if (
current.token !== expected.token || current.token !== expected.token ||
@@ -35,5 +36,6 @@ export const startWalletOwnerMonitor = (monitor: WalletOwnerMonitor): (() => voi
return; return;
} }
await monitor.deactivate(); await monitor.deactivate();
}); })
.catch(() => undefined);
}); });