diff --git a/extension/src/service-worker/__tests__/vault.test.ts b/extension/src/service-worker/__tests__/vault.test.ts index ad8f02f..f926151 100644 --- a/extension/src/service-worker/__tests__/vault.test.ts +++ b/extension/src/service-worker/__tests__/vault.test.ts @@ -3,52 +3,58 @@ import * as vault from '../vault'; import * as session from '../session'; import type { PopupState } from '../router/popup-only'; import type { GitHost } from '../git-host'; +import * as gitHostMod from '../git-host'; // --- Mock git-host module --- -// createGitHost is called internally by handleCreateVault; we need to intercept -// it and return a fake GitHost. uint8ArrayToBase64 must still work — vault.ts -// calls it for the imageBase64 storage value. +// createGitHost is called internally by handleCreateVault / handleAttachVault; +// we need to intercept it and return a fake GitHost. uint8ArrayToBase64 must +// still work — vault.ts calls it for the imageBase64 storage value. + +// Shared factory used both inside vi.mock and in beforeEach re-wire. +function makeHostMock(): GitHost & { _calls: Record } { + const calls: Record = { + writeFileCreateOnly: [], + writeFile: [], + readFile: [], + }; + return { + _calls: calls, + readFile: vi.fn().mockImplementation(async (path: string) => { + // Serve the vault-meta files needed by fetchVaultMeta + attach flow. + if (path === '.relicario/salt') return new Uint8Array(32); + if (path === '.relicario/params.json') { + return new TextEncoder().encode('{"argon2_m":65536,"argon2_t":3,"argon2_p":4}'); + } + if (path === 'manifest.enc') return new Uint8Array([0xab, 0xcd]); + // .relicario/devices.json throws so readDevices falls back to []. + throw new Error(`404: ${path}`); + }), + writeFile: vi.fn().mockImplementation(async (...args: unknown[]) => { + calls.writeFile.push(args); + }), + writeFileCreateOnly: vi.fn().mockImplementation(async (...args: unknown[]) => { + calls.writeFileCreateOnly.push(args); + }), + deleteFile: vi.fn(), + listDir: vi.fn().mockResolvedValue([]), + lastCommit: vi.fn().mockResolvedValue(null), + putBlob: vi.fn(), + getBlob: vi.fn(), + deleteBlob: vi.fn(), + }; +} vi.mock('../git-host', async () => { const actual = await vi.importActual('../git-host'); - const makeHostMock = (): GitHost & { _calls: Record } => { - const calls: Record = { - writeFileCreateOnly: [], - writeFile: [], - readFile: [], - }; - return { - _calls: calls, - readFile: vi.fn().mockImplementation(async (path: string) => { - // .relicario/devices.json throws so readDevices falls back to []. - throw new Error(`404: ${path}`); - }), - writeFile: vi.fn().mockImplementation(async (...args: unknown[]) => { - calls.writeFile.push(args); - }), - writeFileCreateOnly: vi.fn().mockImplementation(async (...args: unknown[]) => { - calls.writeFileCreateOnly.push(args); - }), - deleteFile: vi.fn(), - listDir: vi.fn().mockResolvedValue([]), - lastCommit: vi.fn().mockResolvedValue(null), - putBlob: vi.fn(), - getBlob: vi.fn(), - deleteBlob: vi.fn(), - }; - }; - // Expose a handle so tests can grab the last-created fake host. - let lastHost: ReturnType | null = null; - (globalThis as { __lastFakeGitHost?: typeof lastHost }).__lastFakeGitHost = null; + (globalThis as { __lastFakeGitHost?: ReturnType | null }).__lastFakeGitHost = null; return { ...actual, createGitHost: vi.fn().mockImplementation(() => { const h = makeHostMock(); - lastHost = h; - (globalThis as { __lastFakeGitHost?: typeof lastHost }).__lastFakeGitHost = h; + (globalThis as { __lastFakeGitHost?: ReturnType | null }).__lastFakeGitHost = h; return h; }), }; @@ -90,6 +96,7 @@ function makeWasm(overrides: Record = {}) { embed_image_secret: vi.fn(() => new Uint8Array([1, 2, 3])), unlock: vi.fn(() => fakeHandle), manifest_encrypt: vi.fn(() => new Uint8Array([9])), + manifest_decrypt: vi.fn(() => ({ schema_version: 2, items: {} })), default_vault_settings_json: vi.fn(() => '{}'), settings_encrypt: vi.fn(() => new Uint8Array([8])), register_device: vi.fn(() => ({ signing_public_key: 'pk', deploy_public_key: 'dk' })), @@ -208,3 +215,90 @@ describe('handleCreateVault', () => { expect(setCurrent).not.toHaveBeenCalled(); }); }); + +// --- attach_vault --- + +const ATTACH_MSG = { + config: { hostType: 'gitea' as const, hostUrl: 'https://g', repoPath: 'u/v', apiToken: 't' }, + passphrase: 'pw', + referenceImageBytes: new Uint8Array([1, 2, 3]).buffer, + deviceName: 'Dev2', +}; + +describe('handleAttachVault', () => { + let setCurrent: ReturnType; + + beforeEach(() => { + mockChromeStorage(); + // Re-wire createGitHost: vi.restoreAllMocks() in the create-vault afterEach + // strips the mockImplementation from the vi.fn(), leaving it returning undefined. + // We re-establish it here so each attach test starts with a fresh fake host. + vi.mocked(gitHostMod.createGitHost).mockImplementation(() => { + const h = makeHostMock(); + (globalThis as { __lastFakeGitHost?: ReturnType | null }).__lastFakeGitHost = h; + return h; + }); + setCurrent = vi.spyOn(session, 'setCurrent').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('Test 1 (happy path): returns ok:true, state populated, handle ownership transferred', async () => { + const wasm = makeWasm(); + const state = makeState(wasm); + + const resp = await vault.handleAttachVault(ATTACH_MSG, state); + + expect(resp.ok).toBe(true); + if (!resp.ok) throw new Error('expected ok:true'); + expect(resp.data.deviceName).toBe('Dev2'); + + // WASM calls in order: unlock → manifest_decrypt (verification) → register_device + expect(wasm.unlock).toHaveBeenCalled(); + expect(wasm.manifest_decrypt).toHaveBeenCalled(); + expect(wasm.register_device).toHaveBeenCalledWith('Dev2'); + + // chrome.storage.local.set received vaultConfig + imageBase64 + device_name + const chromeSets = (global as { chrome: { storage: { local: { set: ReturnType } } } }) + .chrome.storage.local.set.mock.calls; + const merged: Record = {}; + for (const [kv] of chromeSets) Object.assign(merged, kv); + expect(merged).toHaveProperty('vaultConfig'); + expect(merged).toHaveProperty('imageBase64'); + expect(merged).toHaveProperty('device_name', 'Dev2'); + + // session.setCurrent called — ownership transferred; handle NOT freed + expect(setCurrent).toHaveBeenCalled(); + expect(wasm._handle.free).not.toHaveBeenCalled(); + + // State wired up + expect(state.manifest).not.toBeNull(); + expect(state.gitHost).not.toBeNull(); + }); + + it('Test 2 (wrong credentials — manifest_decrypt throws): ok:false, handle locked+freed, no side effects', async () => { + const wasm = makeWasm({ + manifest_decrypt: vi.fn(() => { throw new Error('AEAD verification failed'); }), + }); + const state = makeState(wasm); + + const resp = await vault.handleAttachVault(ATTACH_MSG, state); + + expect(resp.ok).toBe(false); + if (resp.ok) throw new Error('expected ok:false'); + expect(resp.error).toBeTruthy(); + expect(resp.error.length).toBeGreaterThan(0); + + // register_device must NOT be called (we failed before it) + expect(wasm.register_device).not.toHaveBeenCalled(); + + // Finally block must lock then free the handle we own + expect(wasm.lock).toHaveBeenCalledWith(wasm._handle); + expect(wasm._handle.free).toHaveBeenCalled(); + + // Session must NOT have been updated + expect(setCurrent).not.toHaveBeenCalled(); + }); +}); diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index 9da12a1..8df97e9 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -631,7 +631,10 @@ export async function handle( case 'create_vault': return vault.handleCreateVault(msg, state); - // attach_vault lands in Task 3.3; get_vault_status in Phase 6 (Dev-C). + case 'attach_vault': + return vault.handleAttachVault(msg, state); + + // get_vault_status lands in Phase 6 (Dev-C). // Until each case lands, an unhandled popup message returns an explicit // error rather than falling through with no return. default: diff --git a/extension/src/service-worker/vault.ts b/extension/src/service-worker/vault.ts index d33ccb6..29df581 100644 --- a/extension/src/service-worker/vault.ts +++ b/extension/src/service-worker/vault.ts @@ -7,7 +7,7 @@ import { createGitHost, uint8ArrayToBase64 } from './git-host'; import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings, VaultConfig } from '../shared/types'; import * as session from './session'; import * as devices from './devices'; -import type { CreateVaultResponse } from '../shared/messages'; +import type { AttachVaultResponse, CreateVaultResponse } from '../shared/messages'; import type { PopupState } from './router/popup-only'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -81,6 +81,56 @@ export async function handleCreateVault( } } +export async function handleAttachVault( + msg: { config: VaultConfig; passphrase: string; referenceImageBytes: ArrayBuffer; deviceName: string }, + state: PopupState, +): Promise { + const w = state.wasm; + let handle: SessionHandle | null = null; + try { + const referenceImageBytes = new Uint8Array(msg.referenceImageBytes); + const { config } = msg; + const git = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken); + + const meta = await fetchVaultMeta(git); + const encryptedManifest = await git.readFile('manifest.enc'); + + handle = w.unlock(msg.passphrase, referenceImageBytes, meta.salt, meta.paramsJson); + // manifest_decrypt verifies the passphrase + reference image — throws on AEAD failure. + const manifest = w.manifest_decrypt(handle, encryptedManifest) as Manifest; + + const keys = w.register_device(msg.deviceName) as { signing_public_key: string; deploy_public_key: string }; + await devices.addDevice(git, { + name: msg.deviceName, + public_key: keys.signing_public_key, + added_at: Math.floor(Date.now() / 1000), + }); + + await chrome.storage.local.set({ + vaultConfig: config, + imageBase64: uint8ArrayToBase64(referenceImageBytes), + device_name: msg.deviceName, + }); + + // SW now owns the unlocked session — transfer ownership to the session. + session.setCurrent(handle); + state.gitHost = git; + state.manifest = manifest; + handle = null; // ownership transferred — do NOT lock-and-free in finally + + return { ok: true, data: { deviceName: msg.deviceName } }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } finally { + // Same .free() policy as handleCreateVault: lock THEN free, only if we still + // own the handle (success path transfers ownership to session.setCurrent). + if (handle) { + try { w.lock(handle); } catch { /* lock may already have happened */ } + handle.free(); + } + } +} + export interface VaultMeta { salt: Uint8Array; paramsJson: string;