Files
suaveplan/packages/security-stack/security/vault-sync/src/engine/engine.test.ts
2026-05-27 23:47:53 +01:00

426 lines
15 KiB
TypeScript

import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import { VAULT_SYNC_CODES } from "../errors/codes.js";
import { VaultSyncError } from "../errors/vault-sync-error.js";
import type {
EncryptedSyncPayload,
SyncTransport,
VaultItem,
VaultSnapshot,
} from "../types/types.js";
import { SyncEngine } from "./engine.js";
class CountingRng implements Rng {
private state = 1;
public nextUint32(): number {
this.state = (this.state * 1664525 + 1013904223) >>> 0;
return this.state;
}
public nextFloat(): number {
return this.nextUint32() / 0x1_00_00_00_00;
}
}
class MemoryTransport implements SyncTransport {
public stored: VaultSnapshot | undefined;
public failOn: "fetch" | "push" | undefined;
public async fetchSnapshot(): Promise<EncryptedSyncPayload> {
if (this.failOn === "fetch") throw new Error("offline");
if (!this.stored) {
return {
version: "v1" as const,
nonce: new Uint8Array(24),
ciphertext: new Uint8Array(0),
};
}
// We pass the snapshot through as the envelope; the engine's
// injected decrypt callback turns it back into a snapshot.
return this.stored as unknown as EncryptedSyncPayload;
}
public async pushSnapshot(envelope: EncryptedSyncPayload): Promise<void> {
if (this.failOn === "push") throw new Error("offline");
this.stored = envelope as unknown as VaultSnapshot;
}
}
function snapshot(items: VaultItem[]): VaultSnapshot {
const clock: Record<string, number> = {};
for (const item of items) {
for (const [k, v] of Object.entries(item.clock)) {
clock[k] = Math.max(clock[k] ?? 0, v);
}
}
return { items, clock, snapshotAt: 1 };
}
function item(over: Partial<VaultItem> = {}): VaultItem {
return {
id: "i1",
title: "Email",
password: "pw",
folderId: null,
history: [],
nodeId: "a",
updatedAt: 1,
clock: { a: 1 },
...over,
};
}
function makeEngine(transport: MemoryTransport, init?: VaultSnapshot) {
return new SyncEngine({
nodeId: "a",
transport,
encrypt: async (snap) => snap as unknown as EncryptedSyncPayload,
decrypt: async (env) => env as VaultSnapshot,
...(init !== undefined ? { initialSnapshot: init } : {}),
});
}
describe("SyncEngine — pull", () => {
it("pull applies remote changes", { timeout: 5000 }, async () => {
const t = new MemoryTransport();
const engine = makeEngine(t);
t.stored = snapshot([
item({ id: "i1", title: "x" }),
item({ id: "i2", title: "y" }),
]);
const result = await engine.pull();
expect([...result.merged].sort()).toEqual(["i1", "i2"]);
expect(engine.currentSnapshot.items).toHaveLength(2);
});
it("empty pull returns empty result", { timeout: 5000 }, async () => {
const t = new MemoryTransport();
t.stored = snapshot([]);
const engine = makeEngine(t);
const result = await engine.pull();
expect(result.merged).toEqual([]);
expect(result.conflicts).toEqual([]);
});
it("propagates deletions as tombstones", { timeout: 5000 }, async () => {
const t = new MemoryTransport();
const engine = makeEngine(t);
t.stored = snapshot([item({ id: "i1", deleted: true })]);
const result = await engine.pull();
expect(result.deleted).toEqual(["i1"]);
});
it("merged-deleted items land in deleted[]", {
timeout: 5000,
}, async () => {
const t = new MemoryTransport();
const engine = makeEngine(
t,
snapshot([item({ id: "i1", deleted: true })])
);
t.stored = snapshot([item({ id: "i1", nodeId: "b", clock: { b: 1 } })]);
const result = await engine.pull();
// The local copy carries deleted=true; the merge propagates that.
expect(result.deleted).toContain("i1");
});
it("retains local-only deleted items in deleted[]", {
timeout: 5000,
}, async () => {
const t = new MemoryTransport();
const engine = makeEngine(
t,
snapshot([item({ id: "i1", deleted: true })])
);
t.stored = snapshot([item({ id: "i2", title: "remote" })]);
const result = await engine.pull();
// i1 is local-only and deleted; i2 is new from remote.
expect(result.deleted).toContain("i1");
expect(result.merged).toContain("i2");
});
it("retains local-only non-deleted items in the snapshot", {
timeout: 5000,
}, async () => {
const t = new MemoryTransport();
const engine = makeEngine(t, snapshot([item({ id: "i1" })]));
t.stored = snapshot([item({ id: "i2", title: "remote" })]);
const result = await engine.pull();
// i1 is local-only and NOT deleted; result should not include it.
expect(result.deleted).not.toContain("i1");
const ids = engine.currentSnapshot.items.map((i) => i.id).sort();
expect(ids).toEqual(["i1", "i2"]);
});
it("wraps transport errors with SYNC_TRANSPORT_FAILED", {
timeout: 5000,
}, async () => {
const t = new MemoryTransport();
t.failOn = "fetch";
const engine = makeEngine(t);
try {
await engine.pull();
expect.unreachable();
} catch (err) {
if (!(err instanceof VaultSyncError)) throw err;
expect(err.code).toBe(VAULT_SYNC_CODES["SYNC.TRANSPORT_FAILED"]);
}
});
});
describe("SyncEngine — push", () => {
it("push returns only local changes since last sync", {
timeout: 5000,
}, async () => {
const t = new MemoryTransport();
const engine = makeEngine(t);
engine.applyLocalMutation(item({ id: "i1" }));
engine.applyLocalMutation(item({ id: "i2" }));
const result = await engine.push();
expect([...result.merged].sort()).toEqual(["i1", "i2"]);
});
it("empty push returns empty result", { timeout: 5000 }, async () => {
const t = new MemoryTransport();
const engine = makeEngine(t);
const result = await engine.push();
expect(result.merged).toEqual([]);
expect(result.deleted).toEqual([]);
});
it("propagates locally-deleted items in deleted[]", {
timeout: 5000,
}, async () => {
const t = new MemoryTransport();
const engine = makeEngine(t);
engine.applyLocalMutation(item({ id: "i1", deleted: true }));
const result = await engine.push();
expect(result.deleted).toEqual(["i1"]);
expect(result.merged).toEqual([]);
});
it("wraps push transport errors with SYNC_TRANSPORT_FAILED", {
timeout: 5000,
}, async () => {
const t = new MemoryTransport();
t.failOn = "push";
const engine = makeEngine(t);
engine.applyLocalMutation(item({ id: "i1" }));
try {
await engine.push();
expect.unreachable();
} catch (err) {
if (!(err instanceof VaultSyncError)) throw err;
expect(err.code).toBe(VAULT_SYNC_CODES["SYNC.TRANSPORT_FAILED"]);
}
});
});
describe("SyncEngine — sync (bidirectional)", () => {
it("bidirectional sync reconciles divergent states", {
timeout: 5000,
}, async () => {
// A merging transport: pushSnapshot merges into stored state, fetch
// returns the merged remote state.
const merger = new MemoryTransport();
const original = merger.pushSnapshot.bind(merger);
merger.pushSnapshot = async (env): Promise<void> => {
const incoming = env as unknown as VaultSnapshot;
const existing =
(merger.stored as VaultSnapshot | undefined) ?? snapshot([]);
const byId = new Map<string, VaultItem>();
for (const i of existing.items) byId.set(i.id, i);
for (const i of incoming.items) byId.set(i.id, i);
await original({
...env,
} as never);
merger.stored = {
items: Array.from(byId.values()),
clock: { ...existing.clock, ...incoming.clock },
snapshotAt: Math.max(existing.snapshotAt, incoming.snapshotAt),
};
};
merger.stored = snapshot([
item({ id: "i2", title: "remote", nodeId: "b", clock: { b: 1 } }),
]);
const engine = makeEngine(merger);
engine.applyLocalMutation(item({ id: "i1", title: "local" }));
const result = await engine.sync();
expect(result.merged.length).toBeGreaterThanOrEqual(1);
const ids = engine.currentSnapshot.items.map((i) => i.id).sort();
expect(ids).toEqual(["i1", "i2"]);
});
it("detects concurrent edits as conflicts", {
timeout: 5000,
}, async () => {
const t = new MemoryTransport();
const local = item({
id: "i1",
folderId: "loc",
clock: { a: 1 },
});
// Remote: same item, concurrent edit to folderId.
t.stored = snapshot([
item({
id: "i1",
folderId: "rem",
nodeId: "b",
clock: { b: 1 },
}),
]);
const engine = new SyncEngine({
nodeId: "a",
transport: t,
encrypt: async (snap) => snap as unknown as EncryptedSyncPayload,
decrypt: async (env) => env as VaultSnapshot,
defaultStrategy: "atomic",
initialSnapshot: snapshot([local]),
});
const result = await engine.pull();
expect(result.conflicts).toContain("i1");
});
it("resolves conflicts with configured strategy", {
timeout: 5000,
}, async () => {
const t = new MemoryTransport();
const local = item({ id: "i1", title: "loc", updatedAt: 1 });
t.stored = snapshot([
item({
id: "i1",
title: "rem",
nodeId: "b",
clock: { b: 1 },
updatedAt: 5,
}),
]);
const engine = new SyncEngine({
nodeId: "a",
transport: t,
encrypt: async (snap) => snap as unknown as EncryptedSyncPayload,
decrypt: async (env) => env as VaultSnapshot,
initialSnapshot: snapshot([local]),
});
const result = await engine.pull();
expect(result.merged).toContain("i1");
const merged = engine.currentSnapshot.items.find((i) => i.id === "i1");
expect(merged?.title).toBe("rem");
});
it("uses history strategy when items have history", {
timeout: 5000,
}, async () => {
const t = new MemoryTransport();
const local = item({
id: "i1",
history: [{ historyId: "h1", timestamp: 1, note: "a" }],
clock: { a: 1 },
});
t.stored = snapshot([
item({
id: "i1",
history: [{ historyId: "h2", timestamp: 2, note: "b" }],
nodeId: "b",
clock: { b: 1 },
}),
]);
const engine = new SyncEngine({
nodeId: "a",
transport: t,
encrypt: async (snap) => snap as unknown as EncryptedSyncPayload,
decrypt: async (env) => env as VaultSnapshot,
initialSnapshot: snapshot([local]),
});
await engine.pull();
const merged = engine.currentSnapshot.items.find((i) => i.id === "i1");
expect(merged?.history).toHaveLength(2);
});
});
describe("SyncEngine — hydrate / mutations", () => {
it("hydrate validates snapshot shape", { timeout: 5000 }, () => {
const engine = makeEngine(new MemoryTransport());
try {
engine.hydrate({
items: [{ bogus: 1 } as unknown as VaultItem],
clock: {},
snapshotAt: 0,
});
expect.unreachable();
} catch (err) {
if (!(err instanceof VaultSyncError)) throw err;
expect(err.code).toBe(
VAULT_SYNC_CODES["MERGE.CONFLICT_UNRESOLVED"]
);
}
});
it("hydrate accepts a valid snapshot", { timeout: 5000 }, () => {
const engine = makeEngine(new MemoryTransport());
engine.hydrate(snapshot([item({ id: "i1" })]));
expect(engine.currentSnapshot.items).toHaveLength(1);
});
it("applyLocalMutation overwrites existing item by id", {
timeout: 5000,
}, () => {
const engine = makeEngine(new MemoryTransport());
engine.applyLocalMutation(item({ id: "i1", title: "a" }));
engine.applyLocalMutation(
item({ id: "i1", title: "b", clock: { a: 2 } })
);
expect(engine.currentSnapshot.items).toHaveLength(1);
expect(engine.currentSnapshot.items[0]?.title).toBe("b");
});
});
describe("SyncEngine — accepts injectable clock and rng", () => {
it("accepts injectable clock and rng", { timeout: 5000 }, () => {
const engine = new SyncEngine({
nodeId: "a",
transport: new MemoryTransport(),
encrypt: async (snap) => snap as unknown as EncryptedSyncPayload,
decrypt: async (env) => env as VaultSnapshot,
clock: () => 99,
rng: new CountingRng(),
});
expect(engine.owner).toBe("a");
expect(engine.randomSource).toBeDefined();
});
it("randomSource is undefined when no rng provided", {
timeout: 5000,
}, () => {
const engine = makeEngine(new MemoryTransport());
expect(engine.randomSource).toBeUndefined();
});
});
describe("SyncEngine — preferLocal on conflict", () => {
it("retains local copy with merged clock when atomic conflict", {
timeout: 5000,
}, async () => {
const t = new MemoryTransport();
const local = item({ id: "i1", folderId: "loc", clock: { a: 1 } });
t.stored = snapshot([
item({
id: "i1",
folderId: "rem",
nodeId: "b",
clock: { b: 1 },
}),
]);
const engine = new SyncEngine({
nodeId: "a",
transport: t,
encrypt: async (snap) => snap as unknown as EncryptedSyncPayload,
decrypt: async (env) => env as VaultSnapshot,
defaultStrategy: "atomic",
initialSnapshot: snapshot([local]),
});
const result = await engine.pull();
expect(result.conflicts).toContain("i1");
const stored = engine.currentSnapshot.items.find((i) => i.id === "i1");
expect(stored?.folderId).toBe("loc");
expect(stored?.clock).toEqual({ a: 1, b: 1 });
});
});