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-A30: storage failure leaves the receiver vault-less with no partial slot", async () => {127 const mock = makeChromeStorageMock();128 const storageSet = mock.chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;129 storageSet.mockRejectedValueOnce(new Error("quota"));130 vi.stubGlobal("chrome", mock.chrome);131 const vault = await import("./vault");132133 await expect(vault.createVaultFromRootKey(RECEIVER_PASSWORD, "77".repeat(32)))134 .rejects.toThrow(/STORAGE_QUOTA_EXCEEDED|quota/);135136 expect(mock.storage.peach_vault_a).toBeUndefined();137 expect(mock.storage.peach_vault_b).toBeUndefined();138 expect(await vault.hasVault()).toBe(false);139 expect(vault.isUnlocked()).toBe(false);140 });141142 it("T-A30: commits slot A, slot B, and timestamp together", async () => {143 const mock = makeChromeStorageMock();144 vi.stubGlobal("chrome", mock.chrome);145 const vault = await import("./vault");146147 await vault.createVaultFromRootKey(RECEIVER_PASSWORD, "88".repeat(32));148149 const storageSet = mock.chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;150 const commitCall = storageSet.mock.calls.find(([payload]) => (151 Boolean((payload as StorageShape).peach_vault_a) &&152 Boolean((payload as StorageShape).peach_vault_b)153 ));154 expect(commitCall).toBeDefined();155 expect((commitCall![0] as StorageShape).peach_vault_a).toMatchObject({ version: 1 } satisfies Partial<StoredVault>);156 expect((commitCall![0] as StorageShape).peach_vault_b).toMatchObject({ version: 1 } satisfies Partial<StoredVault>);157 expect((commitCall![0] as StorageShape).peach_last_modified_at).toEqual(expect.any(String));158 });159});