sync-generation.test.ts

12.7 KB · raw

1// The §11 secure-reset contract: the durable generation record, the six states,2// create-on-miss refusal, archival by generation, crash recovery at every step,3// and the sync gate. Runs entirely against an in-memory io — no Chrome, no4// vault, no crypto.56import { 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  /** Storage that refuses the write, to model a generation that cannot land. */40  failWrites = false;41  failArchive = false;42  failClear = false;43  failIdentity = false;4445  /** Simulated process death: the checkpoint with this name throws. */46  crashAt: string | null = null;4748  archiveRef: string | null = null;49  archiveCount = 0;50  cleared = false;51  identityMints = 0;52  deviceId = "anchor-device";5354  /** The order the reset actually touched storage. */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    // The rollback case: a build that does not know the generation must not165    // treat v2 state as legacy trust.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      // Never a wrong outcome: nothing retired yet, mid-flight, or committed.271      const state = io.record?.state;272      expect([LEGACY_ACTIVE, RESET_CONFIRMED, V2_ANCHOR], name).toContain(state);273      // Trust is never cleared without an archive first.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    // The io is the whole surface the reset can reach. It carries no root key,378    // no recovery material, and no vault-id parameter, so no rotation is379    // possible by construction; what is worth pinning is the order that makes380    // the archive safe.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});