123456import { describe, expect, it } from "vitest";7import {8 GENERATION_UNKNOWN,9 LEGACY_ACTIVE,10 REPAIRING,11 RESET_CONFIRMED,12 RESET_REQUIRED,13 V2_ACTIVE,14 V2_ANCHOR,15 assertSyncAllowed,16 confirmSecureReset,17 decodeGeneration,18 encodeGeneration,19 enterResetRequired,20 isPostReset,21 mintDecision,22 onDirectV2PairingEstablished,23 resolveGeneration,24 type ResetResult,25 type SyncGeneration,26 type SyncGenerationBlob,27 type SyncGenerationIo,28} from "./sync-generation";2930class CrashAt extends Error {}3132class FakeGenerationIo implements SyncGenerationIo {33 blob: unknown = null;34 pairedDevices = false;35 revocations = false;36 pendingHolders = false;37 legacyRouteMaterial = false;3839 40 failWrites = false;41 failArchive = false;42 failClear = false;43 failIdentity = false;4445 46 crashAt: string | null = null;4748 archiveRef: string | null = null;49 archiveCount = 0;50 cleared = false;51 identityMints = 0;52 deviceId = "anchor-device";5354 55 readonly calls: string[] = [];5657 get record(): SyncGeneration | null {58 return decodeGeneration(this.blob)?.active ?? null;59 }6061 async readBlob(): Promise<unknown> {62 return this.blob;63 }6465 async writeBlob(blob: SyncGenerationBlob): Promise<boolean> {66 if (this.failWrites) return false;67 this.blob = JSON.parse(encodeGeneration(blob)) as unknown;68 return true;69 }7071 async hasPairedDevices(): Promise<boolean> {72 return this.pairedDevices;73 }7475 async hasRevocations(): Promise<boolean> {76 return this.revocations;77 }7879 async hasPendingHolders(): Promise<boolean> {80 return this.pendingHolders;81 }8283 async hasLegacyRouteMaterial(): Promise<boolean> {84 return this.legacyRouteMaterial;85 }8687 async archiveLegacyState(generation: number): Promise<string | null> {88 if (this.failArchive) return null;89 this.archiveCount += 1;90 this.archiveRef = `peach_sync_archive_g${generation}_v1`;91 this.calls.push(`archive:${generation}`);92 return this.archiveRef;93 }9495 async clearLegacyTrustState(): Promise<boolean> {96 if (this.failClear) return false;97 this.cleared = true;98 this.pairedDevices = false;99 this.revocations = false;100 this.pendingHolders = false;101 this.legacyRouteMaterial = false;102 this.calls.push("clear");103 return true;104 }105106 async ensureV2Identity(): Promise<boolean> {107 if (this.failIdentity) return false;108 this.identityMints += 1;109 this.calls.push("identity");110 return true;111 }112113 async getDeviceId(): Promise<string> {114 return this.deviceId;115 }116117 reset(nowMs = 1_000): Promise<ResetResult> {118 return confirmSecureReset(this, {119 nowMs,120 checkpoint: (name) => {121 if (name === this.crashAt) throw new CrashAt(name);122 },123 });124 }125}126127const CHECKPOINTS = [128 "before-confirmed",129 "before-archive",130 "after-archive",131 "before-clear",132 "after-clear",133 "before-identity",134 "after-identity",135 "before-generation-write",136 "after-generation-write",137];138139describe("sync generation — derived state", () => {140 it("derives V2_ACTIVE at generation 2 for a fresh install, and persists it", async () => {141 const io = new FakeGenerationIo();142 const record = await resolveGeneration(io, 1_000);143 expect(record.state).toBe(V2_ACTIVE);144 expect(record.generation).toBe(2);145 expect(io.record).toEqual(record);146 });147148 it("derives LEGACY_ACTIVE at generation 1 when any trust state is present", async () => {149 const io = new FakeGenerationIo();150 io.pairedDevices = true;151 const record = await resolveGeneration(io, 1_000);152 expect(record.state).toBe(LEGACY_ACTIVE);153 expect(record.generation).toBe(1);154 });155156 it("resolves an unreadable record to UNKNOWN rather than to a generation", async () => {157 const io = new FakeGenerationIo();158 io.blob = "{ not json";159 expect((await resolveGeneration(io, 1_000)).state).toBe(GENERATION_UNKNOWN);160 expect(await assertSyncAllowed(io, 1_000)).toBe("sync_generation_unknown");161 });162163 it("refuses a record of another schema instead of misreading it", async () => {164 165 166 const io = new FakeGenerationIo();167 io.blob = {168 schemaVersion: 99,169 active: { schemaVersion: 99, generation: 7, state: V2_ACTIVE, createdAt: 1 },170 };171 expect(await assertSyncAllowed(io, 1_000)).toBe("sync_generation_unknown");172 });173174 it("refuses an unrecognised state instead of guessing", async () => {175 const io = new FakeGenerationIo();176 io.blob = {177 schemaVersion: 1,178 active: { schemaVersion: 1, generation: 1, state: "WOBBLING", createdAt: 1 },179 };180 expect(await assertSyncAllowed(io, 1_000)).toBe("sync_generation_unknown");181 });182183 it("does not trust a derived record that could not be persisted", async () => {184 const io = new FakeGenerationIo();185 io.pairedDevices = true;186 io.failWrites = true;187 expect((await resolveGeneration(io, 1_000)).state).toBe(GENERATION_UNKNOWN);188 expect(await assertSyncAllowed(io, 1_000)).toBe("sync_generation_unknown");189 });190});191192describe("sync generation — create-on-miss refusal (§11.5)", () => {193 it("refuses to mint over every class of trust state", async () => {194 const classes: Array<[string, (io: FakeGenerationIo) => void]> = [195 ["paired device", (io) => (io.pairedDevices = true)],196 ["revocation", (io) => (io.revocations = true)],197 ["pending holder", (io) => (io.pendingHolders = true)],198 ["legacy route material", (io) => (io.legacyRouteMaterial = true)],199 ];200 for (const [label, configure] of classes) {201 const io = new FakeGenerationIo();202 configure(io);203 expect(await mintDecision(io, 1_000), label).toBe("REFUSE_RESET_REQUIRED");204 expect(io.record?.state, label).toBe(RESET_REQUIRED);205 expect(io.identityMints, label).toBe(0);206 expect(await assertSyncAllowed(io, 1_000), label).toBe("reset_required");207 }208 });209210 it("mints only on a genuinely fresh installation", async () => {211 const io = new FakeGenerationIo();212 expect(await mintDecision(io, 1_000)).toBe("MINT");213 expect(io.record).toBeNull();214 });215});216217describe("sync generation — the reset", () => {218 it("archives, clears, mints, then flips the pointer — in that order", async () => {219 const io = new FakeGenerationIo();220 io.pairedDevices = true;221 io.revocations = true;222223 expect(await io.reset()).toBe("V2_ANCHOR");224 expect(io.calls).toEqual(["archive:1", "clear", "identity"]);225 expect(io.record).toMatchObject({226 state: V2_ANCHOR,227 generation: 2,228 anchorDeviceId: "anchor-device",229 legacyArchiveRef: "peach_sync_archive_g1_v1",230 });231 expect(io.cleared).toBe(true);232 });233234 it("does not retire the v2 generation or mint twice when repeated", async () => {235 const io = new FakeGenerationIo();236 io.pairedDevices = true;237 await io.reset();238 const firstArchive = io.record?.legacyArchiveRef;239240 expect(await io.reset()).toBe("reset_not_completed");241 expect(io.record?.legacyArchiveRef).toBe(firstArchive);242 expect(io.identityMints).toBe(1);243 });244245 it("stays in RESET_CONFIRMED and reports the failure when a step fails", async () => {246 for (const configure of [247 (io: FakeGenerationIo) => (io.failArchive = true),248 (io: FakeGenerationIo) => (io.failClear = true),249 (io: FakeGenerationIo) => (io.failIdentity = true),250 (io: FakeGenerationIo) => (io.failWrites = true),251 ]) {252 const io = new FakeGenerationIo();253 io.pairedDevices = true;254 configure(io);255 expect(await io.reset()).toBe("reset_not_completed");256 expect(io.record?.state === RESET_CONFIRMED || io.record === null).toBe(true);257 }258 });259});260261describe("sync generation — crash injection (§11.8)", () => {262 it("leaves the archive durable and the retry reaches V2_ANCHOR from any crash point", async () => {263 for (const name of CHECKPOINTS) {264 const io = new FakeGenerationIo();265 io.pairedDevices = true;266 io.crashAt = name;267268 await expect(io.reset()).rejects.toThrow(CrashAt);269270 271 const state = io.record?.state;272 expect([LEGACY_ACTIVE, RESET_CONFIRMED, V2_ANCHOR], name).toContain(state);273 274 expect(io.cleared && io.archiveCount === 0, `${name} cleared without an archive`).toBe(false);275276 io.crashAt = null;277 await io.reset();278 expect(io.record?.state, `retry after ${name}`).toBe(V2_ANCHOR);279 expect(io.record?.generation, `retry after ${name}`).toBe(2);280 expect(io.archiveRef).not.toBeNull();281 }282 });283284 it("blocks sync before the pointer flip and allows it after", async () => {285 const before = new FakeGenerationIo();286 before.pairedDevices = true;287 before.crashAt = "before-generation-write";288 await expect(before.reset()).rejects.toThrow(CrashAt);289 expect(before.record?.state).toBe(RESET_CONFIRMED);290 expect(await assertSyncAllowed(before, 1_000)).toBe("reset_required");291292 const after = new FakeGenerationIo();293 after.pairedDevices = true;294 after.crashAt = "after-generation-write";295 await expect(after.reset()).rejects.toThrow(CrashAt);296 expect(after.record?.state).toBe(V2_ANCHOR);297 expect(await assertSyncAllowed(after, 1_000)).toBe("allowed");298 expect(after.archiveRef).not.toBeNull();299 expect(after.identityMints).toBe(1);300 });301302 it("loses nothing when the crash lands after the archive but before the clear", async () => {303 const io = new FakeGenerationIo();304 io.pairedDevices = true;305 io.revocations = true;306 io.crashAt = "before-clear";307308 await expect(io.reset()).rejects.toThrow(CrashAt);309310 expect(io.archiveCount).toBe(1);311 expect(io.cleared).toBe(false);312 expect(io.pairedDevices).toBe(true);313 });314});315316describe("sync generation — the sync gate (§7)", () => {317 it("allows a legacy device to sync before it resets", async () => {318 const io = new FakeGenerationIo();319 io.pairedDevices = true;320 expect(await assertSyncAllowed(io, 1_000)).toBe("allowed");321 expect(await isPostReset(io, 1_000)).toBe(false);322 });323324 it("allows a device that has already reset to sync and pair again", async () => {325 const io = new FakeGenerationIo();326 io.pairedDevices = true;327 await io.reset();328 expect(await assertSyncAllowed(io, 1_000)).toBe("allowed");329 expect(await isPostReset(io, 1_000)).toBe(true);330 });331332 it("blocks sync from RESET_REQUIRED and never walks a reset device backwards", async () => {333 const legacy = new FakeGenerationIo();334 legacy.pairedDevices = true;335 await enterResetRequired(legacy, 1_000);336 expect(await assertSyncAllowed(legacy, 1_000)).toBe("reset_required");337338 const reset = new FakeGenerationIo();339 reset.pairedDevices = true;340 await reset.reset();341 await enterResetRequired(reset, 2_000);342 expect(reset.record?.state).toBe(V2_ANCHOR);343 });344});345346describe("sync generation — repair progression (§11.3)", () => {347 it("is REPAIRING until a direct peer exists, then V2_ACTIVE", async () => {348 const io = new FakeGenerationIo();349 io.pairedDevices = true;350 await io.reset();351352 await onDirectV2PairingEstablished(io, false, 2_000);353 expect(io.record?.state).toBe(REPAIRING);354 expect(await isPostReset(io, 2_000)).toBe(true);355356 await onDirectV2PairingEstablished(io, true, 3_000);357 expect(io.record?.state).toBe(V2_ACTIVE);358 });359360 it("never disturbs a device that never reset", async () => {361 const io = new FakeGenerationIo();362 io.pairedDevices = true;363 await onDirectV2PairingEstablished(io, true, 2_000);364 expect(io.record?.state).toBe(LEGACY_ACTIVE);365 });366});367368describe("sync generation — §15.4: the reset retires a generation, it does not rotate the root", () => {369 it("reaches storage only through archive, clear and identity, in that order", async () => {370 const io = new FakeGenerationIo();371 io.pairedDevices = true;372 io.revocations = true;373 io.legacyRouteMaterial = true;374375 expect(await io.reset()).toBe("V2_ANCHOR");376377 378 379 380 381 expect(io.calls).toEqual(["archive:1", "clear", "identity"]);382 expect(io.record?.legacyArchiveRef).toBe("peach_sync_archive_g1_v1");383 expect(io.record?.generation).toBe(2);384 });385});