Files
2026-05-27 23:29:53 +01:00

296 lines
8.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, Uint8Array> } {
const blobs = new Map<string, Uint8Array>();
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<SyncDelegationResult> {
return { merged: [], conflicts: [], deleted: [], durationMs: 0 };
}
public async push(): Promise<SyncDelegationResult> {
return { merged: [], conflicts: [], deleted: [], durationMs: 0 };
}
public async sync(): Promise<SyncDelegationResult> {
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<void> {
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();
});
});