1import { beforeEach, describe, expect, it, vi } from "vitest";23type StorageShape = Record<string, unknown>;45function makeChromeMock(local: StorageShape, sync: StorageShape): typeof chrome {6 const area = (store: StorageShape) => ({7 get: vi.fn(async (key?: string | string[]) => {8 if (!key) return store;9 if (typeof key === "string") return { [key]: store[key] };10 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = store[k]; return acc; }, {});11 }),12 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),13 remove: vi.fn(async (keys: string | string[]) => {14 const list = Array.isArray(keys) ? keys : [keys];15 for (const k of list) delete store[k];16 }),17 });18 return {19 storage: { local: area(local), sync: area(sync) },20 alarms: {21 clear: vi.fn(),22 create: vi.fn(),23 onAlarm: { addListener: vi.fn() },24 },25 idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },26 runtime: { sendMessage: vi.fn(async () => undefined) },27 } as unknown as typeof chrome;28}2930describe("ChirpSyncVaultAdapter", () => {31 let local: StorageShape;32 let sync: StorageShape;3334 beforeEach(() => {35 vi.resetModules();36 local = {};37 sync = {};38 (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);39 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));40 });4142 async function setupVault(): Promise<{ vault: typeof import("./vault"); adapter: import("./chirp-sync-coordinator").ChirpSyncVaultAdapter }> {43 const vault = await import("./vault");44 await vault.createVault("correct horse battery staple");45 const { createChirpSyncVaultAdapter } = await import("./chirp-sync-vault-adapter");46 const adapter = createChirpSyncVaultAdapter();47 return { vault, adapter };48 }4950 it("computeVaultId returns a stable 16-char hex string for the same masterKey", async () => {51 const { adapter } = await setupVault();52 const id1 = await adapter.computeVaultId();53 const id2 = await adapter.computeVaultId();54 expect(id1).toBe(id2);55 expect(id1).toMatch(/^[0-9a-f]{16}$/);56 });5758 it("getOurDeviceId is stable across calls and a fresh UUID v4 on first call", async () => {59 const { adapter } = await setupVault();60 const id1 = await adapter.getOurDeviceId();61 const id2 = await adapter.getOurDeviceId();62 expect(id1).toBe(id2);63 expect(id1).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);64 });6566 it("buildEntryManifest reports {id: modifiedAt} for live entries only", async () => {67 const { vault, adapter } = await setupVault();68 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });69 const b = await vault.addEntry({ type: "login", title: "Beta", password: "pw" });70 await vault.deleteEntry(b.id);71 const m = await adapter.buildEntryManifest();72 expect(Object.keys(m).sort()).toEqual([a.id]);73 expect(m[a.id]).toBe(a.modifiedAt);74 });7576 it("buildTombstoneManifest reports {id: deletedAt} for soft-deleted entries", async () => {77 const { vault, adapter } = await setupVault();78 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });79 await vault.deleteEntry(a.id);80 const t = await adapter.buildTombstoneManifest();81 expect(Object.keys(t)).toEqual([a.id]);82 expect(typeof t[a.id]).toBe("number");83 expect(t[a.id]).toBeGreaterThan(0);84 });8586 it("getEncryptedEntries returns opaque base64 strings keyed by id", async () => {87 const { vault, adapter } = await setupVault();88 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });89 const env = await adapter.getEncryptedEntries([a.id]);90 expect(Object.keys(env)).toEqual([a.id]);91 expect(env[a.id]).toMatch(/^[A-Za-z0-9+/]+=*$/);92 93 expect(env[a.id]).not.toContain("Alpha");94 });9596 it("getEncryptedEntries silently skips unknown ids (peer may have a manifest item we dropped)", async () => {97 const { adapter } = await setupVault();98 const env = await adapter.getEncryptedEntries(["does-not-exist"]);99 expect(env).toEqual({});100 });101102 it("encryption uses a fresh nonce per call — same entry yields different envelopes", async () => {103 const { vault, adapter } = await setupVault();104 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });105 const e1 = await adapter.getEncryptedEntries([a.id]);106 const e2 = await adapter.getEncryptedEntries([a.id]);107 expect(e1[a.id]).not.toBe(e2[a.id]);108 });109110 it("round-trips: encrypt on one adapter, apply on a fresh import of the same vault", async () => {111 const { vault, adapter } = await setupVault();112 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "secret", url: "https://a.example" });113 const envelopes = await adapter.getEncryptedEntries([a.id]);114 115 116 117 118 119 120 121 vault.lock();122 await vault.unlock("correct horse battery staple");123 const { createChirpSyncVaultAdapter } = await import("./chirp-sync-vault-adapter");124 const adapter2 = createChirpSyncVaultAdapter();125 126 await adapter2.applyEntries(envelopes);127 expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Alpha");128 });129130 it("applyEntries decrypts and merges newer remote entries (last-write-wins)", async () => {131 const { vault, adapter } = await setupVault();132 const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });133 134 135 136 await vault.updateEntry(local.id, { title: "Updated" });137 const newer = vault.getEntries().find((e) => e.id === local.id);138 expect(newer?.title).toBe("Updated");139 const envelopes = await adapter.getEncryptedEntries([local.id]);140 141 142 143 144 145 146 const older: typeof local = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };147 await vault.restoreEntries([older], "replace");148 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");149 await adapter.applyEntries(envelopes);150 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");151 });152153 it("applyEntries rejects envelopes whose decrypted id differs from the map key", async () => {154 const { vault, adapter } = await setupVault();155 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });156 const env = await adapter.getEncryptedEntries([a.id]);157 158 const swapped: Record<string, string> = { "wrong-id": env[a.id] };159 await adapter.applyEntries(swapped);160 161 expect(vault.getEntries().map((e) => e.id)).toEqual([a.id]);162 });163164 it("applyTombstones soft-deletes locally-live entries with newer deletedAt", async () => {165 const { vault, adapter } = await setupVault();166 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });167 await adapter.applyTombstones({ [a.id]: a.modifiedAt + 10_000 });168 expect(vault.getEntries().find((e) => e.id === a.id)).toBeUndefined();169 expect(vault.getDeletedEntries().find((e) => e.id === a.id)).toBeDefined();170 });171172 it("applyTombstones leaves entries alone when local modifiedAt is newer", async () => {173 const { vault, adapter } = await setupVault();174 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });175 await adapter.applyTombstones({ [a.id]: a.modifiedAt - 10_000 });176 expect(vault.getEntries().find((e) => e.id === a.id)).toBeDefined();177 });178179 it("methods throw VaultLockedError when the vault is locked", async () => {180 const { vault, adapter } = await setupVault();181 vault.lock();182 await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" });183 await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" });184 await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" });185 await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" });186 await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" });187 await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" });188 });189});