/** * Real-vault end-to-end test. Drives a live `@suaveplan/vault.Vault` * through `fromVault` → `createVaultSecretsAdapter` → `SecretsLike` * reads, exercising the full unlock + drive + encryption pipeline * without a single mock. * * Argon2id is enforced at the OWASP minimum (memoryKB=65_536, * iterations=3, parallelism=1) by `@suaveplan/crypto-vault`. Each * unlock costs ~5–30s on this hardware; the tests therefore share a * single unlocked vault across the describe block via `beforeAll`, * resetting per-test state by clearing the bridge's items so each * scenario starts from a known empty state. */ import { EventBus } from "@suaveplan/events"; import { afterAll, beforeAll, describe, expect, it, } from "@suaveplan/testing/runner"; import type { DriveLike, DriveSharingLike, SyncDelegationResult, SyncSnapshot, VaultConfig, VaultSyncLike, } from "@suaveplan/vault"; import { Vault } from "@suaveplan/vault"; import { createSecretsVaultWatcher, createVaultSecretsAdapter, fromVault, SECRETS_VAULT_CODES, SecretsVaultError, type SecretsVaultError as SecretsVaultErrorType, VAULT_SECRETS_CATEGORY, type VaultSecretsBackend, } from "../src/index.js"; function memoryDrive(): DriveLike & { blobs: Map } { const blobs = new Map(); return { blobs, async read(path) { const v = blobs.get(path); if (v === undefined) throw new Error(`missing ${path}`); return v; }, async write(path, data) { blobs.set(path, new Uint8Array(data)); }, async delete(path) { return blobs.delete(path); }, async exists(path) { return blobs.has(path); }, }; } function noopSharing(): DriveSharingLike { return { async createLink() { return { id: "x", url: "https://x.example/x" }; }, async revokeLink() {}, async isActive() { return true; }, }; } class NoopSync implements VaultSyncLike { public lastSnapshot: SyncSnapshot = { items: [], snapshotAt: 0 }; public hydrate(s: SyncSnapshot): void { this.lastSnapshot = s; } public async pull(): Promise { return { merged: [], conflicts: [], deleted: [], durationMs: 0 }; } public async push(): Promise { return { merged: [], conflicts: [], deleted: [], durationMs: 0 }; } public async sync(): Promise { return { merged: [], conflicts: [], deleted: [], durationMs: 0 }; } public currentSnapshot(): SyncSnapshot { return this.lastSnapshot; } } function makeConfig(drive = memoryDrive()): VaultConfig { return { vaultId: "secrets", email: "agent@suaveplan.example", containerPath: "/secrets.vault", attachmentsPrefix: "/secrets/att", // OWASP minimums enforced by @suaveplan/crypto-vault. argon2id: { memoryKB: 65_536, iterations: 3, parallelism: 1, outputLength: 32, }, argon2idSalt: new Uint8Array(16), drive, driveSharing: noopSharing(), sync: new NoopSync(), }; } async function clearSecrets(vault: Vault): Promise { const rows = vault.items.list({ category: VAULT_SECRETS_CATEGORY }); for (const row of rows) { vault.items.delete(row.id); } } describe("real @suaveplan/vault — end-to-end", () => { // Share one unlocked vault across the block — `beforeAll` runs // the ~5–30s Argon2id pipeline once instead of per-test. let vault: Vault; let backend: VaultSecretsBackend; beforeAll(async () => { vault = Vault.open(makeConfig()); await vault.unlock("pw", "sk"); backend = fromVault(vault); }, 120000); afterAll(async () => { await vault.lock(); }, 30000); it("unlock → set via bridge → adapter.require reads back", { timeout: 30000, }, async () => { await clearSecrets(vault); await backend.set("STRIPE_SECRET_KEY", "sk_live_real"); const adapter = createVaultSecretsAdapter({ vault: backend, allowlist: ["STRIPE_SECRET_KEY"], }); const value = await adapter.require("STRIPE_SECRET_KEY"); expect(value).toBe("sk_live_real"); adapter.dispose(); }); it("set then delete: listNames reflects the deletion", { timeout: 30000, }, async () => { await clearSecrets(vault); await backend.set("A", "1"); await backend.set("B", "2"); expect((await backend.listNames()).sort()).toEqual(["A", "B"]); await backend.delete("A"); expect(await backend.listNames()).toEqual(["B"]); }); it("watcher fans set() through the bridge as secret.changed", { timeout: 30000, }, async () => { await clearSecrets(vault); const adapter = createVaultSecretsAdapter({ vault: backend, allowlist: ["STRIPE"], }); const bus = new EventBus<{ "secret.changed": { name: string } }>(); const heard: string[] = []; bus.on("secret.changed", (p) => { heard.push(p.name); }); const watcher = createSecretsVaultWatcher({ vault: backend, allowlist: adapter.allowlist, bus, }); await backend.set("STRIPE", "sk_live_rotated"); await backend.set("AWS", "ignored — not in allowlist"); expect(heard).toEqual(["STRIPE"]); watcher.dispose(); adapter.dispose(); }); it("write-path errors surface as SECRETS_VAULT.WRITE_FAILED", { timeout: 30000, }, async () => { await clearSecrets(vault); // Empty title — vault.items.create's ItemFieldsSchema rejects // empty titles, surfacing as a VaultError that the bridge // wraps as WRITE_FAILED. let caught: unknown; try { await backend.set("", "value"); } catch (err) { caught = err; } expect(caught).toBeInstanceOf(SecretsVaultError); expect((caught as SecretsVaultErrorType).code).toBe( SECRETS_VAULT_CODES.WRITE_FAILED ); }); it("delete is a no-op for an absent name (no error, no fan-out)", { timeout: 30000, }, async () => { await clearSecrets(vault); const heard: string[] = []; const unsub = backend.onSecretUpdated((p) => { heard.push(p.name); }); await backend.delete("never-existed"); expect(heard).toEqual([]); unsub(); }); it("set overwrites an existing entry via the update path", { timeout: 30000, }, async () => { await clearSecrets(vault); await backend.set("ROT", "v1"); await backend.set("ROT", "v2"); await backend.set("ROT", "v3"); const adapter = createVaultSecretsAdapter({ vault: backend, allowlist: ["ROT"], }); await expect(adapter.require("ROT")).resolves.toBe("v3"); adapter.dispose(); }); it("listener errors do not propagate to the producer", { timeout: 30000, }, async () => { await clearSecrets(vault); const sync = backend.onSecretUpdated(() => { throw new Error("listener boom"); }); const async = backend.onSecretUpdated(async () => { throw new Error("async boom"); }); await expect(backend.set("X", "y")).resolves.toBeUndefined(); sync(); async(); }); }); // Locked-vault assertions need a SEPARATE, dedicated vault instance: // `vault.lock()` calls the global `zeroizeAll()` which would invalidate // the shared unlocked vault's key buffer. Run this AFTER the shared- // vault describe block so its `afterAll` `lock()` doesn't trip on a // zeroized buffer. describe("real @suaveplan/vault — locked-vault semantics", () => { it("locked vault → adapter.get raises SECRETS_VAULT.VAULT_LOCKED; bridge get returns undefined", { timeout: 120000, }, async () => { const v = Vault.open(makeConfig()); await v.unlock("pw", "sk"); const b = fromVault(v); await b.set("X", "y"); await v.flush(); await v.lock(); expect(b.isLocked).toBe(true); await expect(b.get("X")).resolves.toBeUndefined(); expect(await b.listNames()).toEqual([]); const adapter = createVaultSecretsAdapter({ vault: b, allowlist: ["X"], }); let caught: unknown; try { await adapter.get("X"); } catch (err) { caught = err; } expect(caught).toBeInstanceOf(SecretsVaultError); expect((caught as SecretsVaultErrorType).code).toBe( SECRETS_VAULT_CODES.VAULT_LOCKED ); adapter.dispose(); }); });