padhle - post migration
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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)));
|
||||
}
|
||||
Reference in New Issue
Block a user