426 lines
15 KiB
TypeScript
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 });
|
|
});
|
|
});
|