import { describe, it, expect } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { AgeBackend, generateAgeIdentity } from "../backends/age-backend.js"; import { transferCredentials } from "../services/credential-transfer.js"; import type { CredentialBackend, CredentialEntry } from "../types.js"; // --------------------------------------------------------------------------- // In-memory backend for testing transfer logic // --------------------------------------------------------------------------- class InMemoryBackend implements CredentialBackend { readonly name = "in-memory"; private store = new Map(); async get(provider: string) { return this.store.get(provider); } async set(provider: string, entry: CredentialEntry) { this.store.set(provider, entry); } async remove(provider: string) { this.store.delete(provider); } async list() { return [...this.store.keys()]; } async check() { return { available: true }; } size() { return this.store.size; } } // --------------------------------------------------------------------------- // A backend that fails on set (for error path testing) // --------------------------------------------------------------------------- class FailingSetBackend implements CredentialBackend { readonly name = "failing"; private source = new Map(); constructor(entries: Array<[string, CredentialEntry]>) { for (const [k, v] of entries) { this.source.set(k, v); } } async get(provider: string) { return this.source.get(provider); } async set(_provider: string, _entry: CredentialEntry): Promise { throw new Error("write permission denied"); } async remove(_provider: string) { /* noop */ } async list() { return [...this.source.keys()]; } async check() { return { available: true }; } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("transferCredentials", () => { it("copies all credentials from source to target", async () => { const source = new InMemoryBackend(); const target = new InMemoryBackend(); await source.set("a", { type: "api_key", key: "key-a" }); await source.set("b", { type: "api_key", key: "key-b" }); const result = await transferCredentials(source, target); expect(result.copied).toBe(2); expect(result.skipped).toBe(0); expect(result.failed).toBe(0); expect(result.failures).toHaveLength(0); expect(await target.get("a")).toEqual({ type: "api_key", key: "key-a", }); expect(await target.get("b")).toEqual({ type: "api_key", key: "key-b", }); }); it("handles empty source", async () => { const source = new InMemoryBackend(); const target = new InMemoryBackend(); const result = await transferCredentials(source, target); expect(result.copied).toBe(0); expect(result.skipped).toBe(0); expect(result.failed).toBe(0); }); it("copies oauth entries correctly", async () => { const source = new InMemoryBackend(); const target = new InMemoryBackend(); const oauth: CredentialEntry = { type: "oauth", access: "acc-token", refresh: "ref-token", expires: 1700000000000, accountId: "acct-1", }; await source.set("oauth-provider", oauth); const result = await transferCredentials(source, target); expect(result.copied).toBe(1); expect(await target.get("oauth-provider")).toEqual(oauth); }); it("reports failures when target set throws", async () => { const source = new InMemoryBackend(); await source.set("a", { type: "api_key", key: "key-a" }); await source.set("b", { type: "api_key", key: "key-b" }); const target = new FailingSetBackend([]); const result = await transferCredentials(source, target); expect(result.copied).toBe(0); expect(result.failed).toBe(2); expect(result.failures).toHaveLength(2); expect(result.failures[0]?.error).toContain("write permission denied"); }); it("same-backend transfer rewrites entries (age re-encryption)", async () => { const tmp = mkdtempSync(join(tmpdir(), "vault-transfer-test-")); const identityPath = join(tmp, "age.txt"); const vaultPath = join(tmp, "vault.age.json"); try { await generateAgeIdentity(identityPath); const backend = new AgeBackend({ identityPath, vaultPath }); await backend.set("provider-1", { type: "api_key", key: "secret-1", }); await backend.set("provider-2", { type: "api_key", key: "secret-2", }); // Transfer to the same backend (re-encrypt) const result = await transferCredentials(backend, backend); expect(result.copied).toBe(2); expect(result.failed).toBe(0); // Verify data is still readable const entry1 = await backend.get("provider-1"); expect(entry1).toEqual({ type: "api_key", key: "secret-1" }); const entry2 = await backend.get("provider-2"); expect(entry2).toEqual({ type: "api_key", key: "secret-2" }); } finally { rmSync(tmp, { recursive: true, force: true }); } }); });