Files
OmniRoute/tests/unit/piiReproduction.test.ts
Diego Rodrigues de Sa e Souza 3d4f3e4960 test(infra): retry recursive temp-dir removal instead of failing a shard on ENOTEMPTY (#11966) (#11968)
* test(infra): retry recursive temp-dir removal instead of failing a shard on ENOTEMPTY (#11966)

Two shards on release/v3.8.51 went red in one day with the same signature —
"ENOTEMPTY, Directory not empty: /tmp/omniroute-<test>-XXXXXX" — from
combo-same-provider-cascade (Unit Tests fast-path 4/4, on a PR that touches only
.github/) and auth-policy-embeddings-webfetch-7785 (the 20k-test TIA step). Both pass
alone and on re-run: the cleanup races something still writing into the directory
(SQLite WAL/-shm checkpoint, a worker, the backup) and under a loaded hosted runner
the window opens. 1154 test files do their own cleanup with
fs.rmSync(dir, { recursive: true, force: true }); 57 already asked for retries.

One-shot codemod (scripts/ad-hoc/codemod-rm-maxretries.mjs, kept for the record):
every rm / rmSync / rmdirSync option object with `recursive: true` and no
`maxRetries` gains `maxRetries: 5, retryDelay: 100` — Node itself then retries
ENOTEMPTY/EBUSY/EPERM for up to ~0.5 s before giving up. 2243 call sites in 1292
files under tests/, the shared tests/_setup/isolateDataDir.ts exit hook included.
Only the option object changes: no call site, assertion or import is touched.

Validation: prettier and ESLint (with the frozen suppressions) clean on all 1292
files; a random 20-file sample runs green (quota-redis-store hangs identically on
the untouched tree — it needs a Redis on localhost, an environment matter). The
four unit shards on this PR are the full run.

* fix(quality): let check-forgotten-sibling-tests read a 1,000-file diff

The gate shells out to `git diff` through execFileSync with Node's default 1 MB
maxBuffer; the 1,292-file codemod in this PR is the first diff large enough to
overflow it, and the gate died with `spawnSync git ENOBUFS` before comparing
anything. 64 MB is far above any real PR and costs nothing when unused.
2026-08-29 01:17:40 -03:00

186 lines
7.3 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, maxRetries: 5, retryDelay: 100 });
});