vault-createVaultFromRootKey.test.ts

8.2 KB · raw

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});