110 lines
4.9 KiB
JavaScript
110 lines
4.9 KiB
JavaScript
/**
|
|
* 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)));
|
|
}
|