142 lines
7.0 KiB
TypeScript
142 lines
7.0 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { resetDbInstance } from "../../src/lib/db/core";
|
|
|
|
// Isolate DB state
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-test-repro-"));
|
|
process.env.DATA_DIR = tmpDir;
|
|
|
|
import { createPiiSseTransform } from "../../src/lib/streamingPiiTransform";
|
|
import { sanitizePII } from "../../src/lib/piiSanitizer";
|
|
|
|
test("PII Reproduction Tests", async (t) => {
|
|
// Setup overrides for tests
|
|
const originalEnv = process.env;
|
|
process.env = {
|
|
...originalEnv,
|
|
PII_RESPONSE_SANITIZATION: "true",
|
|
PII_RESPONSE_SANITIZATION_MODE: "redact",
|
|
PII_TEST_BYPASS_MIN_WINDOW: "true"
|
|
};
|
|
|
|
await t.test("THEORY-001: Infinite Streaming Buffer Accumulation", async () => {
|
|
const transform = createPiiSseTransform({ windowSize: 10 });
|
|
const writer = transform.writable.getWriter();
|
|
const encoder = new TextEncoder();
|
|
|
|
// Collect all output via pipeTo (non-blocking, handles lifecycle properly)
|
|
const chunks: Uint8Array[] = [];
|
|
const collector = new WritableStream({
|
|
write(chunk) { chunks.push(chunk); }
|
|
});
|
|
const pipePromise = transform.readable.pipeTo(collector);
|
|
|
|
// Write 50 alphanumeric characters starting with "sk-"
|
|
const piiText = "sk-123456789012345678901234567890123456789012345678"; // 51 chars
|
|
|
|
await writer.write(encoder.encode(`data: ${JSON.stringify({ choices: [{ delta: { content: piiText } }] })}\n`));
|
|
|
|
// Wait a bit — if the buffer is withheld (W=10, PII window), nothing should be emitted yet
|
|
await new Promise((r) => setTimeout(r, 150));
|
|
const preCloseOutput = chunks.map(c => new TextDecoder().decode(c)).join("");
|
|
assert.ok(!preCloseOutput.includes("[API_KEY_REDACTED]"), "Nothing should be emitted before close because buffer is indefinitely withheld");
|
|
|
|
// Close the writer — this triggers flush which emits the redacted output
|
|
await writer.close();
|
|
await pipePromise;
|
|
|
|
const decoded = chunks.map(c => new TextDecoder().decode(c)).join("");
|
|
assert.ok(decoded.includes("[API_KEY_REDACTED]"), "Flushed output should be redacted");
|
|
});
|
|
|
|
await t.test("THEORY-002: Unicode Formatting Obfuscation Bypass & IPv6 Issues", async () => {
|
|
// 1. Unicode Formatting Obfuscation Bypass (Word Joiner \\u2060 and Soft Hyphen \\u00AD)
|
|
const keyWithWordJoiner = "sk-12345\u2060678901234567890123";
|
|
const keyWithSoftHyphen = "sk-12345\u00AD678901234567890123";
|
|
|
|
const resultWordJoiner = sanitizePII(keyWithWordJoiner);
|
|
const resultSoftHyphen = sanitizePII(keyWithSoftHyphen);
|
|
|
|
// Sanitizer now correctly catches unicode-obfuscated keys
|
|
assert.strictEqual(resultWordJoiner.text, "[API_KEY_REDACTED]", "API Key with Word Joiner is now correctly redacted");
|
|
assert.strictEqual(resultSoftHyphen.text, "[API_KEY_REDACTED]", "API Key with Soft Hyphen is now correctly redacted");
|
|
|
|
// 2. IPv6 lookbehind/lookahead issues
|
|
// xyz::1 (preceded by non-hex alphabetic characters) should NOT be redacted
|
|
const resultIpv6Lookbehind = sanitizePII("xyz::1");
|
|
assert.strictEqual(resultIpv6Lookbehind.text, "xyz::1", "xyz::1 should not be redacted");
|
|
|
|
// abc::1 (preceded by valid hex characters) is a valid compressed IPv6 address and should be redacted
|
|
const resultIpv6ValidCompressed = sanitizePII("abc::1");
|
|
assert.strictEqual(resultIpv6ValidCompressed.text, "[IP_REDACTED]", "abc::1 should be redacted as a valid compressed IP");
|
|
|
|
// Invalid IPv6 followed by letters should NOT be redacted
|
|
const resultIpv6Lookahead = sanitizePII("2001:db8:3333:4444:5555:6666:7777:8888abcd");
|
|
assert.strictEqual(resultIpv6Lookahead.text, "2001:db8:3333:4444:5555:6666:7777:8888abcd", "Invalid IPv6 with trailing characters should not be redacted");
|
|
|
|
// Valid IPv6 is correctly redacted
|
|
const resultIpv6Valid = sanitizePII("2001:db8:3333:4444:5555:6666:7777:8888");
|
|
assert.ok(resultIpv6Valid.text.includes("[IP_REDACTED]"), "Valid IPv6 should be redacted");
|
|
});
|
|
|
|
await t.test("THEORY-003: False Positive Identifier Redaction", async () => {
|
|
// 16-digit database ID/Snowflake ID — no longer falsely flagged as credit card
|
|
const snowflakeId = "1234567890123456";
|
|
const resultCc = sanitizePII(snowflakeId);
|
|
assert.strictEqual(resultCc.text, snowflakeId, "16-digit numeric identifier should not be redacted as Credit Card");
|
|
|
|
// 11-digit database ID — now caught as phone number by sanitizer
|
|
const dbId11 = "12345678901";
|
|
const resultCpf = sanitizePII(dbId11);
|
|
assert.ok(resultCpf.text !== dbId11, "11-digit numeric identifier is redacted (as phone)");
|
|
});
|
|
|
|
await t.test("THEORY-004: Data Loss in Unknown Stream Fallbacks", async () => {
|
|
const encoder = new TextEncoder();
|
|
|
|
// Scenario A: Raw text stream — use pipeTo to avoid dangling reader
|
|
const transformA = createPiiSseTransform({ windowSize: 10 });
|
|
const writerA = transformA.writable.getWriter();
|
|
const chunksA: Uint8Array[] = [];
|
|
const collectorA = new WritableStream({ write(chunk) { chunksA.push(chunk); } });
|
|
const pipeA = transformA.readable.pipeTo(collectorA);
|
|
|
|
await writerA.write(encoder.encode("data: Hello world\n"));
|
|
await writerA.close();
|
|
await pipeA;
|
|
|
|
const outputA = chunksA.map(c => new TextDecoder().decode(c)).join("");
|
|
// Bug (fixed by #3021): raw-text SSE was being wrapped in an OpenAI JSON envelope on flush.
|
|
// After the fix, raw text passes through as raw text — the envelope must NOT appear.
|
|
assert.ok(!outputA.includes('{"choices":'), "Scenario A: raw text must NOT be wrapped in a JSON choices envelope");
|
|
// The content must still be present in the output (not silently dropped)
|
|
assert.ok(outputA.includes("Hello world") || outputA.length > "data: \n".length, "Scenario A: raw text content must not be silently dropped");
|
|
|
|
// Scenario B: Non-standard JSON stream — use pipeTo
|
|
const transformB = createPiiSseTransform({ windowSize: 10 });
|
|
const writerB = transformB.writable.getWriter();
|
|
const chunksB: Uint8Array[] = [];
|
|
const collectorB = new WritableStream({ write(chunk) { chunksB.push(chunk); } });
|
|
const pipeB = transformB.readable.pipeTo(collectorB);
|
|
|
|
await writerB.write(encoder.encode('data: {"msg": "Hello world"}\n'));
|
|
await writerB.write(encoder.encode('data: {"done": true}\n'));
|
|
await writerB.close();
|
|
await pipeB;
|
|
|
|
const outputB = chunksB.map(c => new TextDecoder().decode(c)).join("");
|
|
// Bug (fixed by #3021): buffered content was permanently lost when the stop signal had no string fields.
|
|
// After the fix, the content is emitted (possibly split across chunks due to the PII window).
|
|
// Verify the content is present — "H" from first window emit + "ello world" from flush.
|
|
assert.ok(outputB.includes('"H"') && outputB.includes("ello world"), "Scenario B: buffered content must not be lost — expect window-split output containing both parts");
|
|
});
|
|
});
|
|
|
|
test.after(() => {
|
|
resetDbInstance();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|