/** * Log redaction — unit + integration tests. * * Anonymous trial ids are capabilities (no server-side ownership check), so a * trial/session UUID embedded in an error message must never reach a log sink. * These tests prove: * 1. `redactSecrets` scrubs UUIDs (trial/session ids) and API keys. * 2. The real chat error-logging path (via `logError`) never writes a trial * id to stderr even when an error message contains one. */ import test, { before, after, mock } from "node:test"; import assert from "node:assert/strict"; import express from "express"; import { randomUUID } from "node:crypto"; import { redactSecrets } from "../src/services/logRedact.js"; import { createChatRouter } from "../src/routes/chat.js"; import { optionalAuth } from "../src/middleware/supabaseAuth.js"; const TRIAL_ID = "7c2f4a1e-9b3d-4a5b-8c1e-2f3a4b5c6d7e"; const SESSION_ID = "00000000-1111-2222-3333-444444444444"; // ─── fake AI streamer that throws an error whose message contains the trial // id (simulates an AI provider echoing an id back). It records the exact // message it throws so tests can prove the raw id was really present. ─── let throwId = null; const thrownMessages = []; async function throwingStream() { const msg = `provider failed for chat ${throwId} · key sk-abcdefghijklmnopqrstuvwxyz123456789012`; thrownMessages.push(msg); throw new Error(msg); } let server; let base; before(async () => { const app = express(); app.use(express.json()); app.use("/api/chat", optionalAuth, createChatRouter({ streamFn: throwingStream })); server = app.listen(0, "127.0.0.1"); await new Promise((resolve) => server.once("listening", resolve)); base = `http://127.0.0.1:${server.address().port}`; }); after(async () => { if (server) await new Promise((resolve) => server.close(resolve)); mock.restoreAll(); }); test("redactSecrets scrubs UUIDs (trial/session ids) and API keys", () => { assert.equal(redactSecrets(`chat ${TRIAL_ID}`), "chat [REDACTED_UUID]"); assert.equal(redactSecrets(SESSION_ID), "[REDACTED_UUID]"); assert.equal(redactSecrets("sk-abcdefghijklmnopqrstuvwxyz123456789012"), "[REDACTED_KEY]"); assert.equal(redactSecrets("sb_secret_abcDEF123==="), "[REDACTED_KEY]"); assert.equal(redactSecrets("no secrets here"), "no secrets here"); assert.equal(redactSecrets(42), 42, "non-strings pass through unchanged"); }); test("anonymous chat error logging never writes the trial id to stderr", async () => { // First message creates a trial; capture its real id from the session event. const res1 = await fetch(`${base}/api/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "first" }), }); const events1 = parseSse(await res1.text()); const trialId = events1.find((e) => e.type === "session").id; assert.ok(trialId, "trial id present"); // Configure the fake AI to fail WITH the trial id embedded in its message. throwId = trialId; // Spy on console.error and drive a second message on the same trial. const calls = []; mock.method(console, "error", (...args) => calls.push(args)); const res2 = await fetch(`${base}/api/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "second", chatId: trialId }), }); // The router catches the throw, logs via logError, and returns a clean SSE error. assert.equal(res2.status, 200); const events2 = parseSse(await res2.text()); assert.ok(events2.some((e) => e.type === "error"), "in-stream error event"); // The raw throw happened, so console.error must have been called... assert.ok(calls.length > 0, "console.error was called for the error"); const joined = calls.map((c) => c.join(" ")).join("\n"); assert.ok(joined.includes("Anonymous chat error"), "tag present"); // Prove the REAL trial id was genuinely in the message the provider threw, // so the redaction below is meaningful (not a vacuous pass on a null id). // The fake streamer throws on the first (id-setting) call too, so the // assertion targets the LAST throw, which carries the real trial id. const lastThrow = thrownMessages[thrownMessages.length - 1]; assert.ok(lastThrow.includes(trialId), "raw error message contained the trial id"); assert.ok(lastThrow.includes("sk-abcdefghijklmnopqrstuvwxyz"), "raw error message contained the API key"); // ...but the trial id and the sk- key must NOT appear in the log output. assert.ok(!joined.includes(trialId), "trial id redacted from log output"); assert.ok(!joined.includes("sk-abcdefghijklmnopqrstuvwxyz"), "API key redacted from log output"); assert.ok(joined.includes("[REDACTED_UUID]"), "redaction marker present"); assert.ok(joined.includes("[REDACTED_KEY]"), "key redaction marker present"); }); /** Parse SSE text into events. */ function parseSse(text) { return text .split("\n\n") .filter((block) => block.startsWith("data: ")) .map((block) => JSON.parse(block.slice(6))); }