be70f56657
Wave A (Phase 1) implements the M1 foundations for REQ-038, REQ-039, REQ-040: Monorepo scaffold (pnpm workspaces, TS strict, NodeNext): - apps/control-plane (Next.js App Router scaffold — Wave B adds routes) - apps/relay-agent (Go module scaffold — Wave D adds WebSocket + install) - packages/db, packages/secrets, packages/runtime, packages/config packages/db (REQ-038, REQ-039): - 0001_init.sql: tenants, users, tenant_memberships, targets, byom_endpoints, invitations, audit_log (append-only hash-chain), runtime_health (not audited) - RLS policies on all tenant-scoped tables + FORCE ROW LEVEL SECURITY - withTenant(tenantId, fn): SET LOCAL app.tenant_id + transaction - audit.ts: appendAudit with sha256(prev_hash || canonical(payload)) chain; AuditWriteHaltError on write failure (Edge 7 — halts + rolls back) - BEFORE INSERT trigger enforces chain integrity (G-006: per-tenant concurrent-write serialization documented; M3 mitigation noted) - create-db.ts: PGlite (dev/test) + pg Pool (prod) behind DbClient interface - pen-test scaffold: cross-tenant isolation (PGlite RLS limitation documented; prod RLS test runs at M1 review) - 12 tests, 98% coverage packages/secrets (REQ-040, G-005, G-010): - SecretProvider interface: get/put/delete + SecretRef + SecretValue (toString returns [REDACTED]; unwrap is the only read path) - AwsSecretsManagerProvider (prod): coreci/<tenantId>/<name>, KMS-backed - LocalEncryptedProvider (dev): AES-256-GCM, PBKDF2 from SECRET_MASTER_KEY_DEV - relay-token.ts: JWT HS256 sign/verify/issue (G-005 contract — locked so Wave B + Wave D parallelize without blocking) - 24 tests (14 provider + 10 relay-token) packages/runtime (G-002, G-003): - Trigger.dev bootstrap (initRuntime) — pre-investment for M3 (G-003) - runtimeHealthCheck task: writes to runtime_health, NOT audit_log (G-002) - 6 tests packages/config (G-010): - Two-tier credential taxonomy: infra env vars (tier a) vs tenant creds (SecretProvider only, tier b). PO's 'no env vars' applies to tenant creds. Coverage: db 98%, secrets/runtime/config typecheck clean. All 45 tests passing. Go relay-agent builds. ---ci--- phase: 1 milestone: v0.1 status: verify ---/ci---
136 lines
5.1 KiB
TypeScript
136 lines
5.1 KiB
TypeScript
/**
|
|
* LocalEncryptedProvider unit tests (REQ-040, D-003).
|
|
*
|
|
* AWS SM impl is not unit-tested (requires AWS creds); LocalEncryptedProvider
|
|
* is the tested dev impl. Uses a temp dir for `.secrets/`.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, beforeEach, afterAll } from "vitest";
|
|
import { mkdtemp, rm, readFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { LocalEncryptedProvider, SecretValue } from "../src/index.js";
|
|
|
|
const T1 = "00000000-0000-0000-0000-000000000001";
|
|
const SIGNING_KEY = "test-master-key-for-pbkdf2-dev-only-0123456789";
|
|
|
|
let tmpDir: string;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await mkdtemp(join(tmpdir(), "coreci-secrets-"));
|
|
process.env.SECRET_MASTER_KEY_DEV = SIGNING_KEY;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("LocalEncryptedProvider", () => {
|
|
let provider: LocalEncryptedProvider;
|
|
|
|
beforeEach(() => {
|
|
provider = new LocalEncryptedProvider({ baseDir: tmpDir });
|
|
});
|
|
|
|
it("put returns a local SecretRef", async () => {
|
|
const ref = await provider.put(T1, "byom", "sk-byom-abc123");
|
|
expect(ref).toBe(`local:${T1}/byom`);
|
|
});
|
|
|
|
it("get returns the secret put", async () => {
|
|
await provider.put(T1, "byom", "sk-byom-abc123");
|
|
const got = await provider.get(T1, "byom");
|
|
expect(got.unwrap()).toBe("sk-byom-abc123");
|
|
});
|
|
|
|
it("put overwrites an existing secret", async () => {
|
|
await provider.put(T1, "byom", "first");
|
|
await provider.put(T1, "byom", "second");
|
|
const got = await provider.get(T1, "byom");
|
|
expect(got.unwrap()).toBe("second");
|
|
});
|
|
|
|
it("get throws if the secret does not exist", async () => {
|
|
await expect(provider.get(T1, "missing")).rejects.toThrow(/not found/);
|
|
});
|
|
|
|
it("delete removes the secret", async () => {
|
|
await provider.put(T1, "byom", "sk-x");
|
|
await provider.delete(T1, "byom");
|
|
await expect(provider.get(T1, "byom")).rejects.toThrow(/not found/);
|
|
});
|
|
|
|
it("delete is a no-op for a missing secret", async () => {
|
|
await expect(provider.delete(T1, "never")).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("secrets are isolated by tenant", async () => {
|
|
const T2 = "00000000-0000-0000-0000-000000000002";
|
|
await provider.put(T1, "byom", "t1-key");
|
|
await provider.put(T2, "byom", "t2-key");
|
|
expect((await provider.get(T1, "byom")).unwrap()).toBe("t1-key");
|
|
expect((await provider.get(T2, "byom")).unwrap()).toBe("t2-key");
|
|
});
|
|
|
|
it("SecretValue.toString() returns [REDACTED]", async () => {
|
|
const ref = await provider.put(T1, "byom", "super-secret-key");
|
|
void ref;
|
|
const got = await provider.get(T1, "byom");
|
|
expect(`${got}`).toBe("[REDACTED]");
|
|
expect(String(got)).toBe("[REDACTED]");
|
|
expect(got.toString()).toBe("[REDACTED]");
|
|
});
|
|
|
|
it("SecretValue.toJSON() returns [REDACTED]", async () => {
|
|
await provider.put(T1, "byom", "super-secret-key");
|
|
const got = await provider.get(T1, "byom");
|
|
expect(JSON.stringify(got)).toBe('"[REDACTED]"');
|
|
});
|
|
|
|
it("SecretValue.unwrap() returns the raw secret", async () => {
|
|
await provider.put(T1, "byom", "raw-value-xyz");
|
|
const got = await provider.get(T1, "byom");
|
|
expect(got.unwrap()).toBe("raw-value-xyz");
|
|
});
|
|
|
|
it("stores ciphertext at rest (not plaintext) on disk", async () => {
|
|
await provider.put(T1, "byom", "plaintext-must-not-appear-on-disk");
|
|
const onDisk = await readFile(join(tmpDir, ".secrets", "local-encrypted.json"), "utf8");
|
|
expect(onDisk).not.toContain("plaintext-must-not-appear-on-disk");
|
|
expect(onDisk).toContain("ciphertext");
|
|
expect(onDisk).toContain("iv");
|
|
expect(onDisk).toContain("authTag");
|
|
expect(onDisk).toContain("salt");
|
|
});
|
|
|
|
it("throws if SECRET_MASTER_KEY_DEV is missing and no override", () => {
|
|
const saved = process.env.SECRET_MASTER_KEY_DEV;
|
|
delete process.env.SECRET_MASTER_KEY_DEV;
|
|
expect(() => new LocalEncryptedProvider({ baseDir: tmpDir })).toThrow(/SECRET_MASTER_KEY_DEV/);
|
|
process.env.SECRET_MASTER_KEY_DEV = saved;
|
|
});
|
|
|
|
it("tampered ciphertext fails authenticated decryption (GCM auth tag)", async () => {
|
|
await provider.put(T1, "byom", "legit");
|
|
const onDisk = await readFile(join(tmpDir, ".secrets", "local-encrypted.json"), "utf8");
|
|
const store = JSON.parse(onDisk) as Record<string, { ciphertext: string }>;
|
|
const key = `${T1}/byom`;
|
|
const entry = store[key]!;
|
|
const buf = Buffer.from(entry.ciphertext, "base64");
|
|
buf[0] = buf[0]! ^ 0xff;
|
|
entry.ciphertext = buf.toString("base64");
|
|
store[key] = entry;
|
|
await rm(join(tmpDir, ".secrets", "local-encrypted.json"));
|
|
const { writeFile } = await import("node:fs/promises");
|
|
await writeFile(join(tmpDir, ".secrets", "local-encrypted.json"), JSON.stringify(store));
|
|
const provider2 = new LocalEncryptedProvider({ baseDir: tmpDir });
|
|
await expect(provider2.get(T1, "byom")).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("SecretValue (direct)", () => {
|
|
it("redacts via toString even when interpolated in a template", () => {
|
|
const v = new SecretValue("leak-me");
|
|
expect(`value=${v}`).toBe("value=[REDACTED]");
|
|
});
|
|
}); |