1import { beforeEach, describe, expect, it, vi } from "vitest";2import { computeVaultId } from "../shared/sync-identity";3import type { StoredVault } from "../shared/types";45type StorageShape = Record<string, unknown>;67function makeChromeStorageMock(): { storage: StorageShape; chrome: typeof chrome } {8 const storage: StorageShape = {};9 const chromeMock = {10 storage: {11 local: {12 get: vi.fn(async (key?: string | string[]) => {13 if (!key) return { ...storage };14 if (typeof key === "string") return { [key]: storage[key] };15 return key.reduce<StorageShape>((acc, k) => {16 acc[k] = storage[k];17 return acc;18 }, {});19 }),20 set: vi.fn(async (next: StorageShape) => {21 Object.assign(storage, next);22 }),23 remove: vi.fn(async (keys: string | string[]) => {24 for (const k of Array.isArray(keys) ? keys : [keys]) delete storage[k];25 }),26 },27 session: { get: vi.fn(async () => ({})), set: vi.fn(), remove: vi.fn() },28 },29 alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },30 idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },31 } as unknown as typeof chrome;32 return { storage, chrome: chromeMock };33}3435describe("vault adoption: createVaultFromRootKey", () => {36 const SOURCE_PASSWORD = "source-adoption-passphrase-123";37 const RECEIVER_PASSWORD = "receiver-local-passphrase-456";3839 beforeEach(() => {40 vi.resetModules();41 const mock = makeChromeStorageMock();42 vi.stubGlobal("chrome", mock.chrome);43 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));44 });4546 it("T-A01: round-trips an existing root key under a different local password", async () => {47 const sourceVault = await import("./vault");48 await sourceVault.createVault(SOURCE_PASSWORD);49 const rootKeyHex = sourceVault.getRootKeyHex();50 expect(rootKeyHex).toMatch(/^[0-9a-f]{64}$/);51 const sourceVaultId = await computeVaultId(new Uint8Array(Buffer.from(rootKeyHex!, "hex")));5253 vi.resetModules();54 const mock = makeChromeStorageMock();55 vi.stubGlobal("chrome", mock.chrome);56 const receiverVault = await import("./vault");57 const result = await receiverVault.createVaultFromRootKey(RECEIVER_PASSWORD, rootKeyHex!);5859 expect(result.vaultId).toBe(sourceVaultId);60 receiverVault.lock();61 expect(await receiverVault.unlock(RECEIVER_PASSWORD)).toBe(true);62 expect(receiverVault.getRootKeyHex()).toBe(rootKeyHex);63 });6465 it("T-A02: recovery words match the source vault because they derive from the same root key prefix", async () => {66 const sourceVault = await import("./vault");67 await sourceVault.createVault(SOURCE_PASSWORD);68 const rootKeyHex = sourceVault.getRootKeyHex()!;69 const sourceWords = await sourceVault.getRecoveryWords();7071 vi.resetModules();72 const mock = makeChromeStorageMock();73 vi.stubGlobal("chrome", mock.chrome);74 const receiverVault = await import("./vault");75 await receiverVault.createVaultFromRootKey(RECEIVER_PASSWORD, rootKeyHex);7677 expect(await receiverVault.getRecoveryWords()).toEqual(sourceWords);78 });7980 it.each([81 ["63 chars", "a".repeat(63)],82 ["65 chars", "a".repeat(65)],83 ["uppercase", "A".repeat(64)],84 ["non-hex", "g".repeat(64)],85 ["empty", ""],86 ])("T-A03: rejects invalid root key input: %s", async (_label, rootKeyHex) => {87 const vault = await import("./vault");88 await expect(vault.createVaultFromRootKey(RECEIVER_PASSWORD, rootKeyHex))89 .rejects.toThrow(/ADOPT_INVALID_ROOT_KEY/);90 });9192 it("T-A04: zeroes the decoded root key buffer on success and failure", async () => {93 const fillSpy = vi.spyOn(Uint8Array.prototype, "fill");94 const vault = await import("./vault");95 await vault.createVaultFromRootKey(RECEIVER_PASSWORD, "11".repeat(32));96 expect(fillSpy).toHaveBeenCalledWith(0);9798 vi.resetModules();99 const mock = makeChromeStorageMock();100 const storageSet = mock.chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;101 storageSet.mockRejectedValueOnce(new Error("quota"));102 vi.stubGlobal("chrome", mock.chrome);103 const failingVault = await import("./vault");104105 fillSpy.mockClear();106 await expect(failingVault.createVaultFromRootKey(RECEIVER_PASSWORD, "22".repeat(32))).rejects.toThrow();107 expect(fillSpy).toHaveBeenCalledWith(0);108 });109110 it("T-A05: adopted vault supports normal entry writes and keeps the original vault id", async () => {111 const vault = await import("./vault");112 const rootKeyHex = "33".repeat(32);113 const { vaultId } = await vault.createVaultFromRootKey(RECEIVER_PASSWORD, rootKeyHex);114 const entry = await vault.addEntry({115 type: "login",116 title: "Example",117 url: "https://example.com",118 username: "june",119 password: "secret",120 });121122 expect(vault.getEntries()).toContainEqual(expect.objectContaining({ id: entry.id, title: "Example" }));123 expect(vaultId).toBe(await computeVaultId(new Uint8Array(Buffer.from(rootKeyHex, "hex"))));124 });125126 it("T-A06: refuses to overwrite an existing local vault", async () => {127 const vault = await import("./vault");128 await vault.createVault(RECEIVER_PASSWORD);129 const originalRoot = vault.getRootKeyHex();130 await vault.addEntry({131 type: "login",132 title: "Must survive",133 username: "june",134 password: "local-only",135 });136137 await expect(138 vault.createVaultFromRootKey(RECEIVER_PASSWORD, "44".repeat(32)),139 ).rejects.toThrow("ADOPT_VAULT_ALREADY_EXISTS");140141 expect(vault.getRootKeyHex()).toBe(originalRoot);142 expect(vault.getEntries()).toContainEqual(143 expect.objectContaining({ title: "Must survive" }),144 );145 });146147 it("T-A30: storage failure leaves the receiver vault-less with no partial slot", async () => {148 const mock = makeChromeStorageMock();149 const storageSet = mock.chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;150 storageSet.mockRejectedValueOnce(new Error("quota"));151 vi.stubGlobal("chrome", mock.chrome);152 const vault = await import("./vault");153154 await expect(vault.createVaultFromRootKey(RECEIVER_PASSWORD, "77".repeat(32)))155 .rejects.toThrow(/STORAGE_QUOTA_EXCEEDED|quota/);156157 expect(mock.storage.peach_vault_a).toBeUndefined();158 expect(mock.storage.peach_vault_b).toBeUndefined();159 expect(await vault.hasVault()).toBe(false);160 expect(vault.isUnlocked()).toBe(false);161 });162163 it("T-A30: commits slot A, slot B, and timestamp together", async () => {164 const mock = makeChromeStorageMock();165 vi.stubGlobal("chrome", mock.chrome);166 const vault = await import("./vault");167168 await vault.createVaultFromRootKey(RECEIVER_PASSWORD, "88".repeat(32));169170 const storageSet = mock.chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;171 const commitCall = storageSet.mock.calls.find(([payload]) => (172 Boolean((payload as StorageShape).peach_vault_a) &&173 Boolean((payload as StorageShape).peach_vault_b)174 ));175 expect(commitCall).toBeDefined();176 expect((commitCall![0] as StorageShape).peach_vault_a).toMatchObject({ version: 1 } satisfies Partial<StoredVault>);177 expect((commitCall![0] as StorageShape).peach_vault_b).toMatchObject({ version: 1 } satisfies Partial<StoredVault>);178 expect((commitCall![0] as StorageShape).peach_last_modified_at).toEqual(expect.any(String));179 });180181 it("rolls back both stored slots and clears the unlocked adopted vault", async () => {182 const mock = makeChromeStorageMock();183 vi.stubGlobal("chrome", mock.chrome);184 const vault = await import("./vault");185186 await vault.createVaultFromRootKey(RECEIVER_PASSWORD, "99".repeat(32));187 expect(vault.isUnlocked()).toBe(true);188 expect(mock.storage.peach_vault_a).toBeDefined();189 expect(mock.storage.peach_vault_b).toBeDefined();190 expect(mock.storage.peach_last_modified_at).toBeDefined();191192 await vault.rollbackAdoptedVault();193194 expect(vault.isUnlocked()).toBe(false);195 expect(vault.getRootKeyHex()).toBeNull();196 expect(() => vault.getEntries()).toThrow("vault_locked");197 expect(await vault.hasVault()).toBe(false);198 expect(mock.storage.peach_vault_a).toBeUndefined();199 expect(mock.storage.peach_vault_b).toBeUndefined();200 expect(mock.storage.peach_last_modified_at).toBeUndefined();201 });202});