From 4101ba41b7e7eb67bd69532b50074e64d68625bf Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Sat, 30 May 2026 11:26:17 +0200 Subject: [PATCH] fix(profile/migration)(audit-333-c2): force durable flush before cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit profile/migration.ts walked persist → sanity → cleanup without forcing the TXF bundle to land durably: - stepPersistToOrbitDb (:585) accepted save()'s cid: 'debounced' return as success without driving the flush. - stepSanityCheck (:643) read back via load() which returns in-memory pendingData (source: 'cache') — passing even if nothing was pinned. - stepCleanup (:779) then deleted legacy KV keys and unpinned the legacy CID. A crash (or later flush failure) between persist and the debounced flush landing lost both the legacy KV state AND the now-unpinned legacy CID. Token-DB loss was only partially mitigated by the #330 legacy-token-DB fallback if wired; legacy KV and the unpinned CID were not. Fix: - stepPersistToOrbitDb now calls profileTokenStorage.awaitNextFlush(0) immediately after save(). awaitNextFlush drives flushScheduler.forceFlushSerialized() and throws on TIMEOUT or POINTER_MONOTONICITY_VIOLATION — converting a silent crash-window data loss into a recoverable MIGRATION_FAILED. timeoutMs=0 disables the wall-clock deadline (the 4-iteration cap still applies) so large migrations are not artificially capped. - A token-storage provider without awaitNextFlush() is refused outright with MIGRATION_FAILED. The pre-fix silent-loss path was the only alternative and it is unsafe. - stepSanityCheck rejects loadResult.source === 'cache' with a hard error. After awaitNextFlush, pendingData is null on the real provider and load() walks active bundles in OrbitDB returning source 'remote'. A 'cache' result here means the durability gate was bypassed — belt-and-braces over the awaitNextFlush guarantee. Existing migration test mocks updated to simulate the real flush contract (save → pendingData/source:'cache', awaitNextFlush → flushed/ source:'remote'). 9 new C2 regression tests cover: - awaitNextFlush is called exactly once after save() (happy path) - timeoutMs=0 is passed (no artificial deadline) - TIMEOUT, POINTER_MONOTONICITY_VIOLATION, and generic flush errors all become MIGRATION_FAILED before cleanup runs - Providers omitting awaitNextFlush are refused - Null-txfData path skips the flush entirely - sanity check rejects post-persist source='cache' reads - End-to-end failure-path invariant: any flush/sanity error leaves legacy KV intact and the legacy CID reclaimable All 442 unit test files (8127 tests) pass. tsc clean. eslint clean on touched files. Refs: #333 (C2). Stacked on top of #346 (C1). --- profile/migration.ts | 65 ++- tests/unit/profile/integration.test.ts | 15 +- .../migration-c2-flush-before-cleanup.test.ts | 471 ++++++++++++++++++ tests/unit/profile/migration.test.ts | 35 +- 4 files changed, 579 insertions(+), 7 deletions(-) create mode 100644 tests/unit/profile/migration-c2-flush-before-cleanup.test.ts diff --git a/profile/migration.ts b/profile/migration.ts index 9b5d4551..3137a10d 100644 --- a/profile/migration.ts +++ b/profile/migration.ts @@ -619,7 +619,52 @@ export class ProfileMigration { `Failed to save token data: ${saveResult.error ?? 'unknown error'}`, ); } - this.log(`Token data saved, CID: ${saveResult.cid ?? 'debounced'}`); + this.log(`Token data save() returned (initial CID: ${saveResult.cid ?? 'debounced'})`); + + // C2 fix (Audit #333): force durability before cleanup. + // + // `save()` is debounce-based — it may return `cid: 'debounced'` + // and `success: true` while the IPFS pin + OrbitDB bundle ref are + // still pending. The legacy cleanup step (5) deletes legacy KV + // keys and unpins the legacy CID. If the wallet crashes (or the + // debounced flush fails later) between these two points, we lose + // both the legacy state AND the new (un-pinned, gateway- + // reclaimable) CID. The Profile-side bundle ref is not yet on + // OrbitDB, so token-DB loss is also possible. + // + // `awaitNextFlush()` drives the flush via + // `flushScheduler.forceFlushSerialized()` and throws on TIMEOUT or + // POINTER_MONOTONICITY_VIOLATION — converting a silent crash- + // window data loss into a recoverable MIGRATION_FAILED. The 4- + // iteration cap inside `awaitNextFlush` is the only termination + // condition (we pass timeoutMs=0 → no wall-clock deadline) since + // migration durability legitimately scales with token count and + // testnet/IPFS latency. + if (typeof profileTokenStorage.awaitNextFlush === 'function') { + try { + await profileTokenStorage.awaitNextFlush(0); + this.log('Token data flush durable (Audit #333 C2)'); + } catch (err) { + throw new ProfileError( + 'MIGRATION_FAILED', + `Forced flush of token data failed; refusing to proceed to ` + + `cleanup. Reason: ${err instanceof Error ? err.message : String(err)}`, + err, + ); + } + } else { + // A token-storage provider without `awaitNextFlush` cannot + // guarantee durability — refusing to enter cleanup is the only + // safe option. This is a hard error rather than a warning + // because the alternative is the pre-fix silent-loss path. + throw new ProfileError( + 'MIGRATION_FAILED', + 'ProfileTokenStorageProvider lacks awaitNextFlush() — refusing ' + + 'to proceed without a durability gate (Audit #333 C2). ' + + 'Upgrade the SDK or wire a real provider implementing the ' + + 'TokenStorageProvider durability contract.', + ); + } } } @@ -676,6 +721,24 @@ export class ProfileMigration { `Failed to load token data from profile: ${loadResult.error ?? 'no data returned'}`, ); } else { + // C2 fix (Audit #333): post-flush durability assertion. + // + // After `stepPersistToOrbitDb`'s `awaitNextFlush()` returns, + // `pendingData` MUST be null on the real provider — load() then + // walks active bundles in OrbitDB and reports `source: + // 'remote'`. A `source: 'cache'` here means the sanity check is + // reading uncommitted in-memory state (the audit's exact + // complaint: "passes even if nothing was pinned"). Surface as a + // hard error so cleanup does not proceed against unverified + // backing. + if (loadResult.source === 'cache') { + errors.push( + `Sanity-check load returned source='cache' after persist's ` + + `awaitNextFlush(); the bundle ref / IPFS pin is not durable yet. ` + + `Refusing to proceed to cleanup (Audit #333 C2).`, + ); + } + const loadedData = loadResult.data; // Collect token IDs from loaded data diff --git a/tests/unit/profile/integration.test.ts b/tests/unit/profile/integration.test.ts index 122ea8f3..34bd82ee 100644 --- a/tests/unit/profile/integration.test.ts +++ b/tests/unit/profile/integration.test.ts @@ -325,19 +325,25 @@ describe('Profile Integration', () => { let savedData: TxfStorageDataBase | null = null; const historyEntries: any[] = []; + // C2 (Audit #333) — mock simulates the real flush contract: + // - save() places data in "pendingData" (source: 'cache') + // - awaitNextFlush() promotes it to "durable" (source: 'remote') + let _flushed = false; const profileTokenStorage = { setIdentity() {}, async initialize() { return true; }, async shutdown() {}, async save(data: TxfStorageDataBase) { savedData = data; + _flushed = false; return { success: true, timestamp: Date.now() }; }, + async awaitNextFlush(_timeoutMs?: number) { _flushed = true; }, async load() { return { success: savedData !== null, data: savedData, - source: 'cache', + source: _flushed ? 'remote' : 'cache', timestamp: Date.now(), }; }, @@ -395,12 +401,17 @@ describe('Profile Integration', () => { getStatus() { return 'connected'; }, } as any; + // C2 (Audit #333) — null-txfData path: no save() call, no flush + // requirement (migration's stepPersistToOrbitDb skips when + // data.txfData === null). The mock still implements awaitNextFlush + // for future-proofing in case the migration tightens the contract. const profileTokenStorage = { setIdentity() {}, async initialize() { return true; }, async shutdown() {}, async save() { return { success: true, timestamp: Date.now() }; }, - async load() { return { success: true, data: undefined, source: 'cache', timestamp: Date.now() }; }, + async awaitNextFlush(_timeoutMs?: number) { /* no-op */ }, + async load() { return { success: true, data: undefined, source: 'remote', timestamp: Date.now() }; }, async sync() { return { success: true, added: 0, removed: 0, conflicts: 0 }; }, async connect() {}, async disconnect() {}, diff --git a/tests/unit/profile/migration-c2-flush-before-cleanup.test.ts b/tests/unit/profile/migration-c2-flush-before-cleanup.test.ts new file mode 100644 index 00000000..44cf15ee --- /dev/null +++ b/tests/unit/profile/migration-c2-flush-before-cleanup.test.ts @@ -0,0 +1,471 @@ +/** + * Tests for Audit #333 C2: migration cleanup-before-flush. + * + * Background + * ---------- + * Before the C2 fix, `ProfileMigration` walked: + * 3. stepPersistToOrbitDb — save() returns success even with + * cid: 'debounced' (flush still pending). + * 4. stepSanityCheck — load() reads pendingData → passes even + * if nothing was pinned. + * 5. stepCleanup — deletes legacy KV + unpins legacy CID. + * + * A crash (or later flush failure) between (3) and the debounced + * flush landing lost both the legacy KV state and the unpinned CID + * (gateway-reclaimable). No `forceFlush`/`awaitNextFlush` existed in + * migration.ts. + * + * Fix + * --- + * - stepPersistToOrbitDb calls `awaitNextFlush(0)` after save() — + * driving `flushScheduler.forceFlushSerialized()` and converting + * any TIMEOUT / POINTER_MONOTONICITY_VIOLATION into a recoverable + * `MIGRATION_FAILED`. Providers without `awaitNextFlush` are + * rejected outright (no silent fallback). + * - stepSanityCheck rejects `loadResult.source === 'cache'` — a + * post-flush `load()` must read from durable bundles + * (source: 'remote'), not in-memory pendingData. This is a + * belt-and-braces gate over the awaitNextFlush guarantee. + * + * These tests assert both halves. + */ + +import { describe, it, expect } from 'vitest'; +import type { StorageProvider } from '../../../storage/storage-provider'; +import type { + TxfStorageDataBase, + TokenStorageProvider, +} from '../../../types'; +import type { ProfileTokenStorageProvider } from '../../../profile/profile-token-storage-provider'; +import { ProfileMigration } from '../../../profile/migration'; + +// --------------------------------------------------------------------------- +// Minimal mocks (kept self-contained — independent of the broader +// migration test fixtures so future test refactors do not affect this +// regression surface). +// --------------------------------------------------------------------------- + +function createMockLegacyStorage(initial: Record = {}): StorageProvider { + const store = new Map(Object.entries(initial)); + return { + id: 'mock-legacy', + name: 'Mock Legacy', + type: 'local' as const, + description: '', + setIdentity() {}, + async connect() {}, + async disconnect() {}, + isConnected() { return true; }, + getStatus() { return 'connected' as const; }, + async get(k: string) { return store.get(k) ?? null; }, + async set(k: string, v: string) { store.set(k, v); }, + async remove(k: string) { store.delete(k); }, + async has(k: string) { return store.has(k); }, + async keys(prefix?: string) { + const all = [...store.keys()]; + return prefix ? all.filter((k) => k.startsWith(prefix)) : all; + }, + async clear(prefix?: string) { + if (!prefix) { store.clear(); return; } + for (const k of store.keys()) if (k.startsWith(prefix)) store.delete(k); + }, + async saveTrackedAddresses() {}, + async loadTrackedAddresses() { return []; }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + _store: store as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; +} + +function createMockLegacyTokenStorage( + txfData: TxfStorageDataBase | null, +): TokenStorageProvider { + return { + setIdentity() {}, + async initialize() { return true; }, + async shutdown() {}, + async save() { return { success: true, timestamp: Date.now() }; }, + async load() { + return { + success: txfData !== null, + data: txfData ?? undefined, + source: 'local' as const, + timestamp: Date.now(), + }; + }, + async sync() { return { success: true, added: 0, removed: 0, conflicts: 0 }; }, + async clear() { return true; }, + async connect() {}, + async disconnect() {}, + isConnected() { return true; }, + getStatus() { return 'connected' as const; }, + id: 'mock-legacy-token', + name: 'Mock Legacy Token Storage', + type: 'local' as const, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; +} + +function createMockProfileStorage(): StorageProvider & { _store: Map } { + const store = new Map(); + return { + id: 'mock-profile', + name: 'Mock Profile', + type: 'p2p' as const, + description: '', + setIdentity() {}, + async connect() {}, + async disconnect() {}, + isConnected() { return true; }, + getStatus() { return 'connected' as const; }, + async get(k: string) { return store.get(k) ?? null; }, + async set(k: string, v: string) { store.set(k, v); }, + async remove(k: string) { store.delete(k); }, + async has(k: string) { return store.has(k); }, + async keys(prefix?: string) { + const all = [...store.keys()]; + return prefix ? all.filter((k) => k.startsWith(prefix)) : all; + }, + async clear(prefix?: string) { + if (!prefix) { store.clear(); return; } + for (const k of store.keys()) if (k.startsWith(prefix)) store.delete(k); + }, + async saveTrackedAddresses() {}, + async loadTrackedAddresses() { return []; }, + _store: store, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; +} + +/** + * Spec-shaped mock of ProfileTokenStorageProvider. + * + * Optional behaviours let individual tests simulate: + * - missing awaitNextFlush (legacy provider) + * - awaitNextFlush that throws (TIMEOUT / monotonicity violation) + * - load() that returns source='cache' even post-flush (buggy + * provider that ignores the durability contract) + */ +function createMockProfileTokenStorage(opts?: { + txfData?: TxfStorageDataBase | null; + omitAwaitNextFlush?: boolean; + awaitNextFlushThrows?: Error; + loadSourceStaysCache?: boolean; +}): ProfileTokenStorageProvider & { + _savedData: TxfStorageDataBase | null; + _awaitNextFlushCalls: number; +} { + let savedData: TxfStorageDataBase | null = null; + let flushed = false; + let awaitNextFlushCalls = 0; + + const base: Record = { + setIdentity() {}, + async initialize() { return true; }, + async shutdown() {}, + async save(data: TxfStorageDataBase) { + savedData = data; + flushed = false; + return { success: true, timestamp: Date.now() }; + }, + async load() { + const data = opts?.txfData !== undefined ? opts.txfData : savedData; + return { + success: data !== null, + data: data ?? undefined, + source: (opts?.loadSourceStaysCache || !flushed + ? 'cache' + : 'remote') as const, + timestamp: Date.now(), + }; + }, + async sync() { return { success: true, added: 0, removed: 0, conflicts: 0 }; }, + async clear() { return true; }, + async connect() {}, + async disconnect() {}, + isConnected() { return true; }, + getStatus() { return 'connected' as const; }, + id: 'mock-c2-token', + name: 'Mock C2 Token Storage', + type: 'p2p' as const, + async getHistoryEntries() { return []; }, + }; + + if (!opts?.omitAwaitNextFlush) { + base.awaitNextFlush = async (_timeoutMs?: number): Promise => { + awaitNextFlushCalls++; + if (opts?.awaitNextFlushThrows) throw opts.awaitNextFlushThrows; + flushed = true; + }; + } + + Object.defineProperty(base, '_savedData', { get: () => savedData }); + Object.defineProperty(base, '_awaitNextFlushCalls', { get: () => awaitNextFlushCalls }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return base as any; +} + +const SAMPLE_TXF: TxfStorageDataBase = { + _meta: { + version: 1, + address: 'DIRECT_aabbcc_ddeeff', + formatVersion: '1.0.0', + updatedAt: 1000, + }, + _token1: { id: 'token1', amount: '100' }, + _token2: { id: 'token2', amount: '200' }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as any; + +const SAMPLE_LEGACY = { + wallet_exists: 'true', + mnemonic: 'test mnemonic phrase', + master_key: 'abc123', + chain_code: 'def456', +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Audit #333 C2 — migration cleanup-before-flush', () => { + describe('stepPersistToOrbitDb forces flush', () => { + it('calls awaitNextFlush() exactly once after save()', async () => { + const legacyStorage = createMockLegacyStorage(SAMPLE_LEGACY); + const legacyTokenStorage = createMockLegacyTokenStorage(SAMPLE_TXF); + const profileStorage = createMockProfileStorage(); + const profileTokenStorage = createMockProfileTokenStorage(); + + const migration = new ProfileMigration(); + const result = await migration.migrate( + legacyStorage, + legacyTokenStorage, + profileStorage as unknown as StorageProvider, + profileTokenStorage, + ); + + expect(result.success).toBe(true); + expect(profileTokenStorage._awaitNextFlushCalls).toBe(1); + }); + + it('passes timeoutMs=0 (no wall-clock deadline) so large migrations are not artificially capped', async () => { + const legacyStorage = createMockLegacyStorage(SAMPLE_LEGACY); + const legacyTokenStorage = createMockLegacyTokenStorage(SAMPLE_TXF); + const profileStorage = createMockProfileStorage(); + + let capturedTimeout: number | undefined; + const profileTokenStorage = createMockProfileTokenStorage(); + const origAwait = profileTokenStorage.awaitNextFlush!.bind(profileTokenStorage); + profileTokenStorage.awaitNextFlush = async (timeoutMs?: number) => { + capturedTimeout = timeoutMs; + return origAwait(timeoutMs); + }; + + const migration = new ProfileMigration(); + const result = await migration.migrate( + legacyStorage, + legacyTokenStorage, + profileStorage as unknown as StorageProvider, + profileTokenStorage, + ); + + expect(result.success).toBe(true); + expect(capturedTimeout).toBe(0); + }); + + it('converts awaitNextFlush TIMEOUT into MIGRATION_FAILED before any cleanup', async () => { + const legacyStorage = createMockLegacyStorage(SAMPLE_LEGACY); + const legacyTokenStorage = createMockLegacyTokenStorage(SAMPLE_TXF); + const profileStorage = createMockProfileStorage(); + const profileTokenStorage = createMockProfileTokenStorage({ + awaitNextFlushThrows: Object.assign( + new Error('awaitNextFlush: timeout awaiting serialized flush'), + { code: 'TIMEOUT' }, + ), + }); + + const migration = new ProfileMigration(); + const result = await migration.migrate( + legacyStorage, + legacyTokenStorage, + profileStorage as unknown as StorageProvider, + profileTokenStorage, + ); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/Forced flush of token data failed/); + + // CRUCIAL: legacy keys MUST still be present — cleanup did not run. + // Recovery (retry the migration after fixing the flush issue) is + // possible because the legacy backing is intact. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const legacyStore = (legacyStorage as any)._store as Map; + expect(legacyStore.get('mnemonic')).toBe('test mnemonic phrase'); + expect(legacyStore.get('master_key')).toBe('abc123'); + expect(legacyStore.get('chain_code')).toBe('def456'); + }); + + it('converts awaitNextFlush POINTER_MONOTONICITY_VIOLATION into MIGRATION_FAILED', async () => { + const legacyStorage = createMockLegacyStorage(SAMPLE_LEGACY); + const legacyTokenStorage = createMockLegacyTokenStorage(SAMPLE_TXF); + const profileStorage = createMockProfileStorage(); + const profileTokenStorage = createMockProfileTokenStorage({ + awaitNextFlushThrows: Object.assign( + new Error('pointer monotonicity violation'), + { code: 'POINTER_MONOTONICITY_VIOLATION' }, + ), + }); + + const migration = new ProfileMigration(); + const result = await migration.migrate( + legacyStorage, + legacyTokenStorage, + profileStorage as unknown as StorageProvider, + profileTokenStorage, + ); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/Forced flush of token data failed/); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const legacyStore = (legacyStorage as any)._store as Map; + expect(legacyStore.get('mnemonic')).toBe('test mnemonic phrase'); + }); + + it('refuses providers that omit awaitNextFlush', async () => { + const legacyStorage = createMockLegacyStorage(SAMPLE_LEGACY); + const legacyTokenStorage = createMockLegacyTokenStorage(SAMPLE_TXF); + const profileStorage = createMockProfileStorage(); + const profileTokenStorage = createMockProfileTokenStorage({ omitAwaitNextFlush: true }); + + const migration = new ProfileMigration(); + const result = await migration.migrate( + legacyStorage, + legacyTokenStorage, + profileStorage as unknown as StorageProvider, + profileTokenStorage, + ); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/lacks awaitNextFlush/); + // Cleanup did not run — legacy state intact. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const legacyStore = (legacyStorage as any)._store as Map; + expect(legacyStore.has('mnemonic')).toBe(true); + }); + + it('skips the flush step when txfData is null (no tokens to migrate)', async () => { + const legacyStorage = createMockLegacyStorage(SAMPLE_LEGACY); + const legacyTokenStorage = createMockLegacyTokenStorage(null); + const profileStorage = createMockProfileStorage(); + const profileTokenStorage = createMockProfileTokenStorage(); + + const migration = new ProfileMigration(); + const result = await migration.migrate( + legacyStorage, + legacyTokenStorage, + profileStorage as unknown as StorageProvider, + profileTokenStorage, + ); + + expect(result.success).toBe(true); + // No save → no flush required → awaitNextFlush MUST NOT be called + // (otherwise we'd be forcing flushes on a provider with nothing + // pending, polluting telemetry and wasting a forceFlushSerialized + // round). + expect(profileTokenStorage._awaitNextFlushCalls).toBe(0); + }); + }); + + describe('stepSanityCheck rejects in-memory reads (belt-and-braces)', () => { + it('aborts when load() returns source="cache" after persist', async () => { + const legacyStorage = createMockLegacyStorage(SAMPLE_LEGACY); + const legacyTokenStorage = createMockLegacyTokenStorage(SAMPLE_TXF); + const profileStorage = createMockProfileStorage(); + // Buggy provider: awaitNextFlush returns OK but load() reports + // source='cache' anyway — the audit's exact concern. Sanity + // check must surface this. + const profileTokenStorage = createMockProfileTokenStorage({ + loadSourceStaysCache: true, + }); + + const migration = new ProfileMigration(); + const result = await migration.migrate( + legacyStorage, + legacyTokenStorage, + profileStorage as unknown as StorageProvider, + profileTokenStorage, + ); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/source='cache'|durable yet|Audit #333 C2/); + // Cleanup did not run. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const legacyStore = (legacyStorage as any)._store as Map; + expect(legacyStore.has('mnemonic')).toBe(true); + }); + }); + + describe('end-to-end durability invariant', () => { + it('happy path: persist → flush → sanity (source=remote) → cleanup', async () => { + const legacyStorage = createMockLegacyStorage(SAMPLE_LEGACY); + const legacyTokenStorage = createMockLegacyTokenStorage(SAMPLE_TXF); + const profileStorage = createMockProfileStorage(); + const profileTokenStorage = createMockProfileTokenStorage(); + + const migration = new ProfileMigration(); + const result = await migration.migrate( + legacyStorage, + legacyTokenStorage, + profileStorage as unknown as StorageProvider, + profileTokenStorage, + ); + + expect(result.success).toBe(true); + expect(profileTokenStorage._awaitNextFlushCalls).toBe(1); + + // Cleanup ran — legacy keys are gone (except migration tracking). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const legacyStore = (legacyStorage as any)._store as Map; + for (const key of legacyStore.keys()) { + expect(key).toMatch(/^migration\./); + } + + // Profile side has the token data + identity keys. + expect(profileStorage._store.has('identity.mnemonic')).toBe(true); + expect(profileTokenStorage._savedData).toEqual(SAMPLE_TXF); + }); + + it('failure-path invariant: any flush/sanity error leaves legacy untouched and unpinned-CID reclaimable', async () => { + // Simulate the exact crash-window the audit described: + // save() returned 'debounced'; we attempt awaitNextFlush; + // it fails. PRE-FIX the cleanup ran anyway, losing both the + // legacy KV and the unpinned CID. POST-FIX cleanup is gated. + const legacyStorage = createMockLegacyStorage(SAMPLE_LEGACY); + const legacyTokenStorage = createMockLegacyTokenStorage(SAMPLE_TXF); + const profileStorage = createMockProfileStorage(); + const profileTokenStorage = createMockProfileTokenStorage({ + awaitNextFlushThrows: new Error('IPFS pinning service unreachable'), + }); + + const migration = new ProfileMigration(); + const result = await migration.migrate( + legacyStorage, + legacyTokenStorage, + profileStorage as unknown as StorageProvider, + profileTokenStorage, + ); + + expect(result.success).toBe(false); + + // Legacy state intact — operator can re-run migration after + // fixing the IPFS connectivity. Pre-fix this would have been + // permanent loss. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const legacyStore = (legacyStorage as any)._store as Map; + expect(legacyStore.get('mnemonic')).toBe('test mnemonic phrase'); + expect(legacyStore.get('master_key')).toBe('abc123'); + expect(legacyStore.get('chain_code')).toBe('def456'); + expect(legacyStore.get('wallet_exists')).toBe('true'); + }); + }); +}); diff --git a/tests/unit/profile/migration.test.ts b/tests/unit/profile/migration.test.ts index 9f4526c8..efed3311 100644 --- a/tests/unit/profile/migration.test.ts +++ b/tests/unit/profile/migration.test.ts @@ -101,17 +101,34 @@ function createMockProfileStorage(): ProfileStorageProvider & { _store: Map }, -): ProfileTokenStorageProvider & { _savedData: TxfStorageDataBase | null; _historyEntries: any[] } { +): ProfileTokenStorageProvider & { + _savedData: TxfStorageDataBase | null; + _historyEntries: any[]; + _awaitNextFlushCalls: number; + _flushed: boolean; +} { let savedData: TxfStorageDataBase | null = null; const historyEntries: any[] = []; + // C2 (Audit #333) — mock simulates the real flush contract: + // - save() places data in "pendingData" (source: 'cache') + // - awaitNextFlush() promotes it to "durable" (source: 'remote') + // The migration's stepPersistToOrbitDb must call awaitNextFlush() or + // stepSanityCheck rejects the source='cache' load. + let awaitNextFlushCalls = 0; + let flushed = false; return { setIdentity() {}, async initialize() { return true; }, async shutdown() {}, async save(data: TxfStorageDataBase) { savedData = data; + flushed = false; // saved → not yet durable until awaitNextFlush return { success: true, timestamp: Date.now() }; }, + async awaitNextFlush(_timeoutMs?: number) { + awaitNextFlushCalls++; + flushed = true; + }, async load() { // loadData override takes priority (for sanity check simulation); // otherwise return saved data @@ -119,7 +136,12 @@ function createMockProfileTokenStorage( return { success: data !== null, data: data ?? undefined, - source: 'cache' as const, + // Mirror the real provider's contract: 'cache' when pendingData + // is still live, 'remote' once awaitNextFlush has driven the + // flush through to OrbitDB. `loadData` overrides (forced + // sanity-check scenarios) still report 'remote' because the + // override pretends to come from durable backing. + source: (flushed || loadData !== undefined ? 'remote' : 'cache') as const, timestamp: Date.now(), }; }, @@ -147,6 +169,8 @@ function createMockProfileTokenStorage( async addHistoryEntry(entry: any) { historyEntries.push(entry); }, get _savedData() { return savedData; }, _historyEntries: historyEntries, + get _awaitNextFlushCalls() { return awaitNextFlushCalls; }, + get _flushed() { return flushed; }, } as any; } @@ -454,9 +478,12 @@ describe('ProfileMigration', () => { async initialize() { return true; }, async shutdown() {}, async save() { return { success: true, timestamp: Date.now() }; }, + async awaitNextFlush(_timeoutMs?: number) { /* no-op */ }, async load() { - // Always return the incomplete data (simulates data loss) - return { success: true, data: lessData, source: 'cache' as const, timestamp: Date.now() }; + // Always return the incomplete data (simulates data loss after + // a "successful" flush — source='remote' satisfies the C2 gate + // so the sanity-check token-count mismatch path is exercised). + return { success: true, data: lessData, source: 'remote' as const, timestamp: Date.now() }; }, async sync() { return { success: true, added: 0, removed: 0, conflicts: 0 }; }, async clear() { return true; },