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/profile/profile-token-storage/flush-scheduler.ts b/profile/profile-token-storage/flush-scheduler.ts index 783c773f..3d481d0a 100644 --- a/profile/profile-token-storage/flush-scheduler.ts +++ b/profile/profile-token-storage/flush-scheduler.ts @@ -62,7 +62,11 @@ // block-by-block via `pinCarBlocksToIpfs`; per-block IPFS dedup is // restored, closing the byte-cost-per-token-mutation regression // introduced by the #212 monolithic-raw interim. -import { fetchCarFromIpfs, pinCarBlocksToIpfs } from '../ipfs-client.js'; +import { + fetchCarFromIpfs, + pinCarBlocksToIpfs, + verifyCidAccessibleWithRetry, +} from '../ipfs-client.js'; import { extractCarRootCid } from '../../uxf/transfer-payload.js'; import { extractLostHeadCid } from '../orbitdb-adapter.js'; import type { OrbitDbAdapter } from '../orbitdb-adapter.js'; @@ -144,6 +148,29 @@ export const POINTER_MONOTONICITY_RECOVERED = 'POINTER_MONOTONICITY_RECOVERED'; */ export const MONOTONICITY_RECOVERY_PAYLOAD_CAP = 100; +/** + * Audit #333 C3 — OpLog auto-reset probe budget. + * + * Before `addBundleWithOplogAutoReset` falls through to the destructive + * `db.drop()` reset, it probes the configured IPFS gateways for the + * unreachable head CID with exponential backoff. The matcher + * (`extractLostHeadCid`) cannot distinguish a permanently-corrupt head + * (Helia GC) from a transiently-unreachable one (gateway blip / + * propagation lag), so a 30 s probe window — matching the Issue #239 + * shutdown gate convention for testnet propagation — gives a single + * miss a chance to recover before we commit to wiping all not-yet- + * bundled OUTBOX/SENT/disposition/finalization state. + * + * A genuinely-corrupt head will fail every gateway HEAD probe across + * the window; the reset then proceeds as before. The cost in the bad- + * case is +30 s before destructive teardown — a small price relative + * to the data-loss exposure on the false-positive path. + */ +export const OPLOG_RESET_PROBE_DEADLINE_MS = 30_000; + +/** Per-attempt HEAD timeout inside the probe loop. */ +export const OPLOG_RESET_PROBE_PER_ATTEMPT_MS = 5_000; + // ── Helpers ─────────────────────────────────────────────────────────────── /** @@ -1744,25 +1771,133 @@ export class FlushScheduler { throw err; } + const context = 'flush-scheduler.bundle-write'; + + // C3 (Audit #333): probe BEFORE the destructive reset. + // + // `extractLostHeadCid` matches both permanently-corrupt heads + // (Helia GC ran on a memory-blockstore wallet) and transiently + // unreachable ones (gateway blip, peer offline, propagation + // lag). The pre-fix code went straight from "matcher matched" to + // `db.drop()`, wiping all OUTBOX/SENT/disposition/finalization + // entries not yet captured in a pinned bundle — a permanent loss + // on a transient fetch failure. + // + // Probe the configured IPFS gateways for the lost CID with + // exponential backoff. If any gateway serves it within the + // deadline, retry `addBundle` ONCE — a transient miss often + // clears within the propagation window. Only fall through to + // the reset when the probe confirms unrecoverability OR the + // gateway-retry STILL fails the same way (in which case the + // local Helia blockstore is the bottleneck, not the network). + // + // Skipped only when no gateways are configured at all — that + // setup has no recovery surface, so destructive reset is the + // only forward path (preserves pre-fix behaviour). + // + // `effectiveLostHeadCid` may be updated by the probe-retry + // branch if a follow-up addBundle attempt surfaces a fresher + // unreachable CID; the reset call then uses the freshest one. + let effectiveLostHeadCid = lostHeadCid; + + if (this.host.ipfsGateways.length > 0) { + this.host.log( + `OpLog head unreachable (lostHeadCid=${lostHeadCid}); probing ` + + `${this.host.ipfsGateways.length} gateway(s) before destructive reset ` + + `(Audit #333 C3, deadline=${OPLOG_RESET_PROBE_DEADLINE_MS}ms)`, + ); + let probeOk = false; + let probeAttempts = 0; + let probeElapsedMs = 0; + try { + const probe = await verifyCidAccessibleWithRetry( + this.host.ipfsGateways, + lostHeadCid, + { + deadlineMs: OPLOG_RESET_PROBE_DEADLINE_MS, + perAttemptTimeoutMs: OPLOG_RESET_PROBE_PER_ATTEMPT_MS, + }, + ); + probeOk = probe.ok; + probeAttempts = probe.attempts; + probeElapsedMs = probe.elapsedMs; + } catch (probeErr) { + // Probe itself threw (e.g., gateway URL validation failure + // before any HEAD ran). Treat as "probe did not confirm + // recoverability" — log and fall through to reset, since + // we cannot prove the head is recoverable. + this.host.log( + `OpLog head probe threw before completing: ` + + `${probeErr instanceof Error ? probeErr.message : String(probeErr)}; ` + + `proceeding to reset (Audit #333 C3)`, + ); + } + + if (probeOk) { + this.host.log( + `OpLog head IS accessible via gateway after ${probeAttempts} ` + + `attempt(s) in ${probeElapsedMs}ms; retrying addBundle once before reset ` + + `(Audit #333 C3)`, + ); + try { + await this.bundleIndex.addBundle(cid, bundleRef); + // Probe-retry succeeded — the original failure was + // transient. Reset NOT performed. Caller (flush body) + // continues normally; no operational state was lost. + return; + } catch (retryErr) { + const retryLostHeadCid = extractLostHeadCid(retryErr); + if (retryLostHeadCid === null) { + // Retry hit a DIFFERENT error class (e.g., monotonicity + // violation, network down). Re-throw the new error; + // resetting the log would not help here. + throw retryErr; + } + // Same auto-reset signature again, despite the gateway + // probe succeeding. Local Helia is the bottleneck — fall + // through to the destructive reset, but use the + // most-recent lostHeadCid (which may differ if the + // baseline advanced during the probe). + this.host.log( + `Probe succeeded but addBundle still failed with ` + + `lostHeadCid=${retryLostHeadCid}; proceeding to reset ` + + `(Audit #333 C3)`, + ); + effectiveLostHeadCid = retryLostHeadCid; + } + } else { + this.host.log( + `OpLog head NOT accessible via any gateway after ${probeAttempts} ` + + `attempt(s) in ${probeElapsedMs}ms; head is genuinely unrecoverable. ` + + `Proceeding to reset (Audit #333 C3)`, + ); + } + } else { + this.host.log( + `OpLog head unreachable (lostHeadCid=${lostHeadCid}) and no IPFS ` + + `gateways configured; no recovery surface available. Proceeding to ` + + `reset (Audit #333 C3)`, + ); + } + this.host.log( - `OpLog head unreachable (lostHeadCid=${lostHeadCid}); auto-resetting Profile DB. ` + + `OpLog head unreachable (lostHeadCid=${effectiveLostHeadCid}); auto-resetting Profile DB. ` + `Prior OpLog history is permanently inaccessible. Token data on local IndexedDB is preserved.`, ); - const context = 'flush-scheduler.bundle-write'; // 3. Trigger event BEFORE the reset so operators see it even if // the reset itself fails. this.host.emitEvent({ type: 'profile:oplog-auto-resetting', timestamp: Date.now(), - data: { lostHeadCid, context }, + data: { lostHeadCid: effectiveLostHeadCid, context }, }); // 4. Run the reset. let resetResult: { recovered: true; lostHeadCid?: string; recoveredAt: number }; try { resetResult = await dbWithReset.resetCorruptedLog({ - lostHeadCid, + lostHeadCid: effectiveLostHeadCid, context, }); } catch (resetErr) { @@ -1774,7 +1909,7 @@ export class FlushScheduler { type: 'profile:recovered', timestamp: Date.now(), data: { - lostHeadCid, + lostHeadCid: effectiveLostHeadCid, recoveredAt: Date.now(), context, retrySucceeded: false, @@ -1795,7 +1930,7 @@ export class FlushScheduler { const marker: ProfileRecoveryMarker = { version: 1, recoveredAt: resetResult.recoveredAt, - lostHeadCid: resetResult.lostHeadCid ?? lostHeadCid, + lostHeadCid: resetResult.lostHeadCid ?? effectiveLostHeadCid, context, walkBackClosed: true, note: @@ -1823,7 +1958,7 @@ export class FlushScheduler { type: 'profile:recovered', timestamp: Date.now(), data: { - lostHeadCid, + lostHeadCid: effectiveLostHeadCid, recoveredAt: resetResult.recoveredAt, context, retrySucceeded: false, @@ -1838,7 +1973,7 @@ export class FlushScheduler { type: 'profile:recovered', timestamp: Date.now(), data: { - lostHeadCid, + lostHeadCid: effectiveLostHeadCid, recoveredAt: resetResult.recoveredAt, context, retrySucceeded: true, diff --git a/tests/unit/profile/flush-scheduler-c3-oplog-reset-probe.test.ts b/tests/unit/profile/flush-scheduler-c3-oplog-reset-probe.test.ts new file mode 100644 index 00000000..1a3c3e37 --- /dev/null +++ b/tests/unit/profile/flush-scheduler-c3-oplog-reset-probe.test.ts @@ -0,0 +1,431 @@ +/** + * Tests for Audit #333 C3: auto-drop on transient block-load error. + * + * Background + * ---------- + * Before the C3 fix, `FlushScheduler.addBundleWithOplogAutoReset` + * transitioned straight from "extractLostHeadCid matched the error" to + * `resetCorruptedLog()` (which does `db.drop()`). The matcher cannot + * distinguish a permanently-corrupt head (Helia GC ran on a memory- + * blockstore wallet) from a transiently-unreachable one (gateway blip, + * peer offline, propagation lag). A momentary fetch failure thus wiped + * all OUTBOX/SENT/disposition/finalization entries not yet captured in + * a pinned bundle — permanent data loss on a recoverable error. + * + * Fix + * --- + * - Probe configured IPFS gateways with exponential backoff + * (`verifyCidAccessibleWithRetry`) BEFORE the destructive reset. + * - On a successful probe, retry `addBundle` ONCE. If the retry + * succeeds the reset is SKIPPED — no operational state is lost. + * - On probe failure (CID NOT served by any gateway within the + * deadline) OR a retry that still hits the same auto-reset + * signature, fall through to the existing reset path. + * - With no gateways configured, the fix is a no-op (matches + * pre-fix behaviour) since there is no recovery surface. + * + * These tests assert each path. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + BundleIndex, + FlushScheduler, + type ProfileTokenStorageHost, +} from '../../../profile/profile-token-storage'; +import type { StorageEvent } from '../../../storage/storage-provider'; +import type { UxfBundleRef } from '../../../profile/types'; + +// Mock the gateway-probe helper so tests control the probe outcome +// without spinning up real HTTP. The mock is keyed on the CID to +// distinguish "served" vs "unreachable" across calls. +vi.mock('../../../profile/ipfs-client', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + verifyCidAccessibleWithRetry: vi.fn( + async ( + _gateways: string[], + cid: string, + _opts: { deadlineMs: number; perAttemptTimeoutMs?: number }, + ) => { + const outcome = (globalThis as unknown as { + __c3MockProbe?: Record; + }).__c3MockProbe?.[cid]; + return ( + outcome ?? { + ok: false, + attempts: 1, + elapsedMs: 0, + failureKind: 'gateway-not-serving' as const, + } + ); + }, + ), + }; +}); + +// --------------------------------------------------------------------------- +// Harness — minimal shape we need to drive +// `addBundleWithOplogAutoReset` through the C3 paths. +// --------------------------------------------------------------------------- + +interface Harness { + scheduler: FlushScheduler; + bundleIndex: BundleIndex; + emittedEvents: StorageEvent[]; + resetCorruptedLog: ReturnType; + writeRecoveryMarker: ReturnType; + logLines: string[]; + addBundleSpy: ReturnType; +} + +function buildHarness(opts: { + /** Behaviour of bundleIndex.addBundle, invoked on each attempt. */ + addBundleSequence: Array; + /** Gateways to expose on host.ipfsGateways. */ + ipfsGateways?: string[]; + resetImpl?: (reason: { + lostHeadCid?: string; + context: string; + }) => Promise<{ recovered: true; lostHeadCid?: string; recoveredAt: number }>; +}): Harness { + const emittedEvents: StorageEvent[] = []; + const logLines: string[] = []; + + const resetCorruptedLog = vi.fn( + opts.resetImpl ?? + (async (reason: { lostHeadCid?: string; context: string }) => ({ + recovered: true as const, + lostHeadCid: reason.lostHeadCid, + recoveredAt: Date.now(), + })), + ); + const writeRecoveryMarker = vi.fn(async () => {}); + + const dbBase: Record = { + async connect() {}, + async put() {}, + async get() { return null; }, + async del() {}, + async all() { return new Map(); }, + async close() {}, + onReplication() { return () => {}; }, + isConnected() { return true; }, + resetCorruptedLog, + }; + + const host: ProfileTokenStorageHost = { + db: dbBase as unknown as ProfileTokenStorageHost['db'], + ipfsGateways: opts.ipfsGateways ?? [], + options: undefined, + localCache: null, + flushDebounceMs: 0, + eventCallbacks: new Set(), + getHelia: () => null, + getStatus: () => 'ready', + setStatus: () => {}, + getInitialized: () => true, + setInitialized: () => {}, + getIsShuttingDown: () => false, + setIsShuttingDown: () => {}, + getIdentity: () => null, + setIdentityState: () => {}, + getEncryptionKey: () => null, + setEncryptionKey: () => {}, + getComputedAddressId: () => null, + setComputedAddressId: () => {}, + getReplicationUnsub: () => null, + setReplicationUnsub: () => {}, + getPendingData: () => null, + setPendingData: () => {}, + getFlushTimer: () => null, + setFlushTimer: () => {}, + getFlushPromise: () => null, + setFlushPromise: () => {}, + getLastPinnedCid: () => null, + setLastPinnedCid: () => {}, + getLastPinnedBundleCid: () => null, + setLastPinnedBundleCid: () => {}, + getLastVerifiedBundleCid: () => null, + setLastVerifiedBundleCid: () => {}, + getLastVerifiedSnapshotCid: () => null, + setLastVerifiedSnapshotCid: () => {}, + getLastDiscoveredPointerCid: () => null, + setLastDiscoveredPointerCid: () => {}, + getPendingPublishCid: () => null, + setPendingPublishCid: () => {}, + getKnownBundleCids: () => new Set(), + setKnownBundleCids: () => {}, + getLastLoadedData: () => null, + setLastLoadedData: () => {}, + getLastLoadedFromBundleCids: () => null, + setLastLoadedFromBundleCids: () => {}, + getLastTokenManifest: () => null, + setLastTokenManifest: () => {}, + getAddressId: () => 'DIRECT_abc_def', + log: (m) => { logLines.push(m); }, + emitEvent: (e) => { emittedEvents.push(e); }, + buildErrorEvent: (type, err) => ({ + type, + timestamp: Date.now(), + error: err instanceof Error ? err.message : String(err), + }), + writeProfileKey: async () => {}, + readProfileKey: async () => null, + readProfileKeyJson: async () => null, + writeRecoveryMarker, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const bundleIndex = new BundleIndex(host); + const scheduler = new FlushScheduler(host, bundleIndex); + + // Spy on bundleIndex.addBundle. Each call consumes the next sequence + // entry; throws when the entry is an Error, resolves otherwise. + let callIndex = 0; + const addBundleSpy = vi.fn(async (_cid: string, _ref: UxfBundleRef) => { + const step = opts.addBundleSequence[callIndex]; + callIndex++; + if (step === undefined) { + throw new Error( + `addBundleSequence exhausted at call ${callIndex} — test bug`, + ); + } + if (step === 'ok') return; + throw step; + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (bundleIndex as any).addBundle = addBundleSpy; + + return { + scheduler, + bundleIndex, + emittedEvents, + resetCorruptedLog, + writeRecoveryMarker, + logLines, + addBundleSpy, + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const SAMPLE_CID = 'bafy1234567890headcid'; +const ANOTHER_CID = 'bafy1234567890othercid'; +const SAMPLE_BUNDLE_REF: UxfBundleRef = { + cid: 'bafyzzzzzzzzzzzzzzzzzzzzbundleref', + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as any; + +function lostBlockError(cid: string): Error { + return new Error(`Failed to load block for ${cid}`); +} + +function setMockProbe(cid: string, ok: boolean, attempts = 1, elapsedMs = 100): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const g = globalThis as any; + if (!g.__c3MockProbe) g.__c3MockProbe = {}; + g.__c3MockProbe[cid] = ok + ? { ok: true, attempts, elapsedMs } + : { ok: false, attempts, elapsedMs, failureKind: 'gateway-not-serving' }; +} + +function clearMockProbes(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).__c3MockProbe = undefined; +} + +async function callAddBundleWithOplogAutoReset( + harness: Harness, + cid: string, +): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (harness.scheduler as any).addBundleWithOplogAutoReset( + cid, + SAMPLE_BUNDLE_REF, + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Audit #333 C3 — oplog auto-reset probe gate', () => { + beforeEach(() => clearMockProbes()); + afterEach(() => clearMockProbes()); + + describe('transient blip — probe recovers, NO reset', () => { + it('skips destructive reset when probe succeeds and retry succeeds', async () => { + const harness = buildHarness({ + addBundleSequence: [lostBlockError(SAMPLE_CID), 'ok'], + ipfsGateways: ['http://gateway.test'], + }); + setMockProbe(SAMPLE_CID, true, 2, 1200); + + await callAddBundleWithOplogAutoReset(harness, SAMPLE_CID); + + // addBundle was called twice: initial fail + post-probe retry. + expect(harness.addBundleSpy).toHaveBeenCalledTimes(2); + // CRUCIAL: reset NEVER ran. + expect(harness.resetCorruptedLog).not.toHaveBeenCalled(); + // No reset events emitted (no profile:oplog-auto-resetting, + // no profile:recovered). + expect( + harness.emittedEvents.filter((e) => + ['profile:oplog-auto-resetting', 'profile:recovered'].includes(e.type), + ), + ).toHaveLength(0); + // Logged that the probe succeeded. + expect( + harness.logLines.some((l) => /IS accessible via gateway/.test(l)), + ).toBe(true); + }); + }); + + describe('permanent loss — probe fails, RESET happens', () => { + it('proceeds to reset when probe returns ok=false', async () => { + const harness = buildHarness({ + addBundleSequence: [lostBlockError(SAMPLE_CID), 'ok'], + ipfsGateways: ['http://gateway.test'], + }); + setMockProbe(SAMPLE_CID, false, 5, 30_000); + + await callAddBundleWithOplogAutoReset(harness, SAMPLE_CID); + + // Probe failed → reset ran → addBundle retried once after reset. + expect(harness.resetCorruptedLog).toHaveBeenCalledTimes(1); + // resetCorruptedLog called with the original lostHeadCid. + expect(harness.resetCorruptedLog).toHaveBeenCalledWith( + expect.objectContaining({ lostHeadCid: SAMPLE_CID }), + ); + // Events fired in the right order: auto-resetting, then recovered. + const types = harness.emittedEvents.map((e) => e.type); + expect(types).toContain('profile:oplog-auto-resetting'); + expect(types).toContain('profile:recovered'); + // Marker written. + expect(harness.writeRecoveryMarker).toHaveBeenCalledTimes(1); + // Logged the no-gateway-served message. + expect( + harness.logLines.some((l) => /NOT accessible via any gateway/.test(l)), + ).toBe(true); + }); + }); + + describe('probe ok but retry still fails — RESET happens with fresh CID', () => { + it('falls through to reset using the freshest lostHeadCid', async () => { + const harness = buildHarness({ + // Initial fail with SAMPLE_CID; post-probe retry fails with + // ANOTHER_CID (a fresher unreachable head); after-reset retry + // succeeds. + addBundleSequence: [ + lostBlockError(SAMPLE_CID), + lostBlockError(ANOTHER_CID), + 'ok', + ], + ipfsGateways: ['http://gateway.test'], + }); + setMockProbe(SAMPLE_CID, true, 1, 200); + + await callAddBundleWithOplogAutoReset(harness, SAMPLE_CID); + + // resetCorruptedLog called with the FRESH lostHeadCid (the one + // from the post-probe retry, not the original one). + expect(harness.resetCorruptedLog).toHaveBeenCalledTimes(1); + expect(harness.resetCorruptedLog).toHaveBeenCalledWith( + expect.objectContaining({ lostHeadCid: ANOTHER_CID }), + ); + // The oplog-auto-resetting event also carries the fresh CID. + const resettingEvent = harness.emittedEvents.find( + (e) => e.type === 'profile:oplog-auto-resetting', + ); + expect(resettingEvent?.data).toMatchObject({ lostHeadCid: ANOTHER_CID }); + }); + }); + + describe('probe ok but retry hits DIFFERENT error class — RETHROW (no reset)', () => { + it('re-throws the new error class without resetting', async () => { + const otherErr = new Error('POINTER_MONOTONICITY_VIOLATION at refresh'); + const harness = buildHarness({ + addBundleSequence: [lostBlockError(SAMPLE_CID), otherErr], + ipfsGateways: ['http://gateway.test'], + }); + setMockProbe(SAMPLE_CID, true, 1, 200); + + await expect( + callAddBundleWithOplogAutoReset(harness, SAMPLE_CID), + ).rejects.toBe(otherErr); + + // Reset never ran — the new error class doesn't match the + // auto-reset signature, so resetting wouldn't help. + expect(harness.resetCorruptedLog).not.toHaveBeenCalled(); + }); + }); + + describe('no gateways configured — preserves pre-fix behaviour', () => { + it('skips the probe entirely and goes straight to reset', async () => { + const harness = buildHarness({ + addBundleSequence: [lostBlockError(SAMPLE_CID), 'ok'], + ipfsGateways: [], + }); + + await callAddBundleWithOplogAutoReset(harness, SAMPLE_CID); + + // Reset ran (no probe stood in the way). + expect(harness.resetCorruptedLog).toHaveBeenCalledTimes(1); + // Logged the no-gateways message. + expect( + harness.logLines.some( + (l) => /no IPFS gateways configured/.test(l) || /no recovery surface/.test(l), + ), + ).toBe(true); + }); + }); + + describe('probe itself throws — fall through to reset (cannot prove recoverability)', () => { + it('falls through to reset when the probe throws unexpectedly', async () => { + // Make verifyCidAccessibleWithRetry mock throw for this test by + // returning a special sentinel that triggers a throw inside the + // mock body. Simpler: spy on the mock and have it reject. + const ipfsClient = await import('../../../profile/ipfs-client'); + const spy = vi.spyOn(ipfsClient, 'verifyCidAccessibleWithRetry') + .mockRejectedValueOnce(new Error('gateway URL validation threw')); + + const harness = buildHarness({ + addBundleSequence: [lostBlockError(SAMPLE_CID), 'ok'], + ipfsGateways: ['http://gateway.test'], + }); + + await callAddBundleWithOplogAutoReset(harness, SAMPLE_CID); + + // Probe threw → treated as "cannot confirm recoverability" → reset. + expect(harness.resetCorruptedLog).toHaveBeenCalledTimes(1); + expect( + harness.logLines.some((l) => /probe threw before completing/.test(l)), + ).toBe(true); + + spy.mockRestore(); + }); + }); + + describe('unrelated errors — no probe, no reset (pass-through)', () => { + it('re-throws non-matching errors immediately', async () => { + const unrelated = new Error('network unreachable'); + const harness = buildHarness({ + addBundleSequence: [unrelated], + ipfsGateways: ['http://gateway.test'], + }); + + await expect( + callAddBundleWithOplogAutoReset(harness, SAMPLE_CID), + ).rejects.toBe(unrelated); + + // Neither probe nor reset ran. + expect(harness.resetCorruptedLog).not.toHaveBeenCalled(); + // Only the initial addBundle attempt — no retry. + expect(harness.addBundleSpy).toHaveBeenCalledTimes(1); + }); + }); +}); 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; },