Initial Commit
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const pkgRoot = resolve(__dirname, "..");
|
||||
const srcRoot = join(pkgRoot, "src");
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) out.push(...walk(full));
|
||||
else out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("@suaveplan/api-secrets-vault dep graph + layout", () => {
|
||||
it("package.json carries the required @suaveplan/* runtime deps", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(pkgRoot, "package.json"), "utf8")
|
||||
) as { dependencies: Record<string, string> };
|
||||
const suaveplan = Object.keys(pkg.dependencies)
|
||||
.filter((k) => k.startsWith("@suaveplan/"))
|
||||
.sort();
|
||||
expect(suaveplan).toEqual([
|
||||
"@suaveplan/api-secrets",
|
||||
"@suaveplan/api-types",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/logger",
|
||||
"@suaveplan/vault",
|
||||
]);
|
||||
});
|
||||
|
||||
it("zero peerDependencies", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(pkgRoot, "package.json"), "utf8")
|
||||
) as { peerDependencies?: Record<string, string> };
|
||||
expect(Object.keys(pkg.peerDependencies ?? {})).toEqual([]);
|
||||
});
|
||||
|
||||
it("every src file uses default-import z (no named { z })", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bad: string[] = [];
|
||||
for (const file of walk(srcRoot)) {
|
||||
if (!file.endsWith(".ts")) continue;
|
||||
const src = readFileSync(file, "utf8");
|
||||
if (/import\s*\{\s*z\s*\}\s*from\s*["']zod["']/.test(src)) {
|
||||
bad.push(file);
|
||||
}
|
||||
}
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
|
||||
it("no static-analysis suppression comments in src/", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bad: string[] = [];
|
||||
for (const file of walk(srcRoot)) {
|
||||
if (!file.endsWith(".ts")) continue;
|
||||
const src = readFileSync(file, "utf8");
|
||||
if (/biome-ignore|eslint-disable|@ts-(ignore|nocheck)/.test(src)) {
|
||||
bad.push(file);
|
||||
}
|
||||
}
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
|
||||
it("package.json exposes the documented subpath exports", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(pkgRoot, "package.json"), "utf8")
|
||||
) as { exports: Record<string, unknown> };
|
||||
const subpaths = Object.keys(pkg.exports)
|
||||
.filter((k) => k !== "./package.json" && k !== ".")
|
||||
.sort();
|
||||
expect(subpaths).toEqual([
|
||||
"./adapter",
|
||||
"./allowlist",
|
||||
"./error-codes",
|
||||
"./from-vault",
|
||||
"./otel",
|
||||
"./types",
|
||||
"./watcher",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Test setup for @suaveplan/api-secrets-vault.
|
||||
*
|
||||
* Installs an in-memory OpenTelemetry MeterProvider BEFORE any source
|
||||
* module imports so the meter resolved on first call binds to it. The
|
||||
* helper `flushTestMetrics` collects every snapshot emitted since the
|
||||
* previous call, which the OTel assertion tests use to verify counter
|
||||
* emission without relying on real exporters.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
Reference in New Issue
Block a user