296 lines
8.9 KiB
TypeScript
296 lines
8.9 KiB
TypeScript
/**
|
||
* 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();
|
||
});
|
||
});
|