3b8345dfe0
REQ-006: configure BYOM endpoint (URL in DB, key in secret manager) REQ-007: validate-on-save test inference call (OpenAI-compatible) REQ-008: route all inference to BYOM (G-001: test-inference proxy endpoint) REQ-009: reject when unconfigured/unreachable (ByomUnconfiguredError/ByomUnreachableError) ---ci--- phase: 3 milestone: v0.1 status: execute ---/ci---
191 lines
7.8 KiB
TypeScript
191 lines
7.8 KiB
TypeScript
/**
|
|
* repository.test.ts — BYOM endpoint registry (REQ-006, REQ-007, Edge 11).
|
|
*
|
|
* Uses PGlite via @coreci/db createDb + migration 0001_init.sql + the
|
|
* LocalEncryptedProvider. Mocks `fetch` for the validate-on-save call.
|
|
*
|
|
* Cases:
|
|
* - saveEndpoint with validation ok → validated=true, row present, secret stored
|
|
* - saveEndpoint with validation fail → row deleted (Edge 11), secret still removed-from-DB? (secret stays so re-save can reuse; we assert row gone)
|
|
* - getEndpoint returns the validated endpoint (and null when none)
|
|
* - deleteEndpoint removes the row + the secret
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
|
|
import { readFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { createDb } from "@coreci/db";
|
|
import { setDbClient } from "@coreci/db";
|
|
import { withTenant } from "@coreci/db";
|
|
import { LocalEncryptedProvider, type SecretProvider } from "@coreci/secrets";
|
|
import { saveEndpoint, getEndpoint, deleteEndpoint, BYOM_SECRET_NAME } from "../src/repository.js";
|
|
|
|
const T1 = "00000000-0000-0000-0000-000000000001";
|
|
const MASTER_KEY = "test-master-key-for-byom-repository-tests-32+";
|
|
|
|
async function runMigration(db: { exec: (t: string) => Promise<void> }): Promise<void> {
|
|
const sql = await readFile(
|
|
join(import.meta.dirname, "..", "..", "db", "migrations", "0001_init.sql"),
|
|
"utf8",
|
|
);
|
|
await db.exec(sql);
|
|
}
|
|
|
|
function jsonResponse(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
const OK_BODY = {
|
|
id: "chatcmpl-1",
|
|
choices: [{ index: 0, message: { role: "assistant", content: "pong" }, finish_reason: "stop" }],
|
|
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
|
};
|
|
|
|
describe("BYOM repository (REQ-006, REQ-007, Edge 11)", () => {
|
|
let db: Awaited<ReturnType<typeof createDb>>;
|
|
let secrets: SecretProvider;
|
|
// Unique per-test secrets dir so tests don't share the local-encrypted store.
|
|
let secretsDir: string;
|
|
|
|
beforeAll(async () => {
|
|
db = await createDb({ mode: "pglite" });
|
|
setDbClient(db);
|
|
await runMigration(db);
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Fresh secrets dir + provider per test for isolation.
|
|
secretsDir = join(import.meta.dirname, ".secrets-test", `repo-${Math.random().toString(36).slice(2)}`);
|
|
secrets = new LocalEncryptedProvider({ baseDir: secretsDir, masterKey: MASTER_KEY });
|
|
|
|
// Ensure tenant T1 exists (RLS with CHECK requires the FK parent row).
|
|
await db.query(`INSERT INTO tenants (id, name) VALUES ($1, 'T1') ON CONFLICT DO NOTHING`, [T1]);
|
|
|
|
// Clean leftover byom_endpoints + audit_log rows for T1 (reset the chain so
|
|
// the next audit append is a fresh genesis row). Disable RLS to mutate.
|
|
await db.query("ALTER TABLE byom_endpoints DISABLE ROW LEVEL SECURITY");
|
|
await db.query(`DELETE FROM byom_endpoints WHERE tenant_id = $1`, [T1]);
|
|
await db.query("ALTER TABLE byom_endpoints ENABLE ROW LEVEL SECURITY");
|
|
await db.query("ALTER TABLE audit_log DISABLE ROW LEVEL SECURITY");
|
|
await db.query(`DELETE FROM audit_log WHERE tenant_id = $1`, [T1]);
|
|
await db.query("ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY");
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("saveEndpoint with validation ok → row validated=true, secret stored", async () => {
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(OK_BODY)));
|
|
|
|
const { endpointId, validation } = await saveEndpoint(db, secrets, T1, "https://byom.example.com", "sk-secret");
|
|
|
|
expect(validation.ok).toBe(true);
|
|
expect(endpointId).toBeTruthy();
|
|
|
|
// Row is present + validated.
|
|
const ep = await getEndpoint(db, T1);
|
|
expect(ep).not.toBeNull();
|
|
expect(ep!.id).toBe(endpointId);
|
|
expect(ep!.url).toBe("https://byom.example.com");
|
|
expect(ep!.validated).toBe(true);
|
|
|
|
// Secret is retrievable (key in the secret manager, not the DB — REQ-040).
|
|
const sv = await secrets.get(T1, BYOM_SECRET_NAME);
|
|
expect(sv.unwrap()).toBe("sk-secret");
|
|
|
|
// DB row does NOT contain the plaintext key (REQ-040 scan).
|
|
await db.query("ALTER TABLE byom_endpoints DISABLE ROW LEVEL SECURITY");
|
|
const rows = await db.query<{ url: string; secret_ref: string }>(
|
|
"SELECT url, secret_ref FROM byom_endpoints WHERE tenant_id = $1",
|
|
[T1],
|
|
);
|
|
await db.query("ALTER TABLE byom_endpoints ENABLE ROW LEVEL SECURITY");
|
|
for (const r of rows.rows) {
|
|
expect(r.secret_ref).not.toContain("sk-secret");
|
|
expect(r.url).not.toContain("sk-secret");
|
|
}
|
|
});
|
|
|
|
it("saveEndpoint with validation fail → row deleted (Edge 11)", async () => {
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("nope", { status: 401 })));
|
|
|
|
const { endpointId, validation } = await saveEndpoint(db, secrets, T1, "https://bad.example.com", "sk-bad");
|
|
|
|
expect(validation.ok).toBe(false);
|
|
expect(validation.error).toBe("auth_failed");
|
|
expect(endpointId).toBeTruthy();
|
|
|
|
// Row was deleted (rollback) — getEndpoint returns null.
|
|
const ep = await getEndpoint(db, T1);
|
|
expect(ep).toBeNull();
|
|
|
|
// No byom_endpoints rows remain for the tenant.
|
|
await db.query("ALTER TABLE byom_endpoints DISABLE ROW LEVEL SECURITY");
|
|
const rows = await db.query<{ id: string }>(
|
|
"SELECT id FROM byom_endpoints WHERE tenant_id = $1",
|
|
[T1],
|
|
);
|
|
await db.query("ALTER TABLE byom_endpoints ENABLE ROW LEVEL SECURITY");
|
|
expect(rows.rows).toHaveLength(0);
|
|
|
|
// Audit entries for config + validation-fail were still written (Edge 11
|
|
// surfaces errors but the events are auditable).
|
|
await db.query("ALTER TABLE audit_log DISABLE ROW LEVEL SECURITY");
|
|
const audit = await db.query<{ event_type: string; payload: any }>(
|
|
"SELECT event_type, payload FROM audit_log WHERE tenant_id = $1 ORDER BY id",
|
|
[T1],
|
|
);
|
|
await db.query("ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY");
|
|
const types = audit.rows.map((r) => r.event_type);
|
|
expect(types).toContain("config");
|
|
expect(types).toContain("validation");
|
|
const valEntry = audit.rows.find((r) => r.event_type === "validation");
|
|
expect(valEntry?.payload?.ok).toBe(false);
|
|
});
|
|
|
|
it("getEndpoint returns null when no validated endpoint exists", async () => {
|
|
const ep = await getEndpoint(db, T1);
|
|
expect(ep).toBeNull();
|
|
});
|
|
|
|
it("deleteEndpoint removes the row + the secret", async () => {
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(OK_BODY)));
|
|
await saveEndpoint(db, secrets, T1, "https://byom.example.com", "sk-to-delete");
|
|
expect(await getEndpoint(db, T1)).not.toBeNull();
|
|
|
|
await deleteEndpoint(db, secrets, T1);
|
|
|
|
expect(await getEndpoint(db, T1)).toBeNull();
|
|
|
|
// Secret is gone.
|
|
await expect(secrets.get(T1, BYOM_SECRET_NAME)).rejects.toThrow();
|
|
|
|
// No byom_endpoints rows remain.
|
|
await db.query("ALTER TABLE byom_endpoints DISABLE ROW LEVEL SECURITY");
|
|
const rows = await db.query<{ id: string }>(
|
|
"SELECT id FROM byom_endpoints WHERE tenant_id = $1",
|
|
[T1],
|
|
);
|
|
await db.query("ALTER TABLE byom_endpoints ENABLE ROW LEVEL SECURITY");
|
|
expect(rows.rows).toHaveLength(0);
|
|
});
|
|
|
|
it("saveEndpoint appends audit entries inside withTenant (audit-halt)", async () => {
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(OK_BODY)));
|
|
await saveEndpoint(db, secrets, T1, "https://byom.example.com", "sk-audited");
|
|
|
|
// Confirm a separate withTenant still works (the audit chain is consistent).
|
|
await withTenant(T1, async (c) => {
|
|
const res = await c.query<{ count: string }>(
|
|
"SELECT count(*) AS count FROM audit_log WHERE tenant_id = $1",
|
|
[T1],
|
|
);
|
|
const n = Number(res.rows[0]?.count ?? 0);
|
|
expect(n).toBeGreaterThanOrEqual(2); // config + validation
|
|
});
|
|
});
|
|
}); |