Files
OmniRoute/tests/unit/piiSanitizerIpv6.test.ts

202 lines
8.7 KiB
TypeScript

/**
* Additional tests for piiSanitizer.ts changes introduced in this PR:
*
* 1. getMode() — String(value) coercion so that the string "false" returns "off"
* 2. IPv6 regex — expanded to handle compressed forms (::, ::1, fe80::1, etc.)
* and fixed lookbehind/lookahead boundary assertions.
*/
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-pii-ipv6-"));
process.env.DATA_DIR = tmpDir;
// Enable PII sanitization for all tests in this file
process.env.PII_RESPONSE_SANITIZATION = "true";
process.env.PII_RESPONSE_SANITIZATION_MODE = "redact";
import { sanitizePII } from "../../src/lib/piiSanitizer";
// ── getMode() via PII_RESPONSE_SANITIZATION_MODE env var ──────────────────────
test('getMode: string "false" maps to mode "off" (sanitization skipped)', () => {
const originalMode = process.env.PII_RESPONSE_SANITIZATION_MODE;
process.env.PII_RESPONSE_SANITIZATION_MODE = "false";
try {
// In "off" mode sanitizePII should return the raw text unchanged even when
// PII_RESPONSE_SANITIZATION is enabled.
const ip = "2001:db8:3333:4444:5555:6666:7777:8888";
const result = sanitizePII(ip);
// "off" mode means no redaction at all — text passes through as-is.
assert.strictEqual(result.text, ip, 'mode "off" should not redact anything');
assert.strictEqual(result.redacted, false, 'mode "off" should report redacted=false');
} finally {
process.env.PII_RESPONSE_SANITIZATION_MODE = originalMode;
}
});
test("getMode: invalid value falls back to redact (PII is still redacted)", () => {
const originalMode = process.env.PII_RESPONSE_SANITIZATION_MODE;
process.env.PII_RESPONSE_SANITIZATION_MODE = "not_a_valid_mode";
try {
const ip = "2001:db8:3333:4444:5555:6666:7777:8888";
const result = sanitizePII(ip);
// Invalid mode falls back to "redact" — IP should be redacted.
assert.ok(result.text.includes("[IP_REDACTED]"), "invalid mode falls back to redact");
} finally {
process.env.PII_RESPONSE_SANITIZATION_MODE = originalMode;
}
});
test("getMode: empty string falls back to redact", () => {
const originalMode = process.env.PII_RESPONSE_SANITIZATION_MODE;
process.env.PII_RESPONSE_SANITIZATION_MODE = "";
try {
const ip = "2001:db8:3333:4444:5555:6666:7777:8888";
const result = sanitizePII(ip);
assert.ok(result.text.includes("[IP_REDACTED]"), "empty mode string falls back to redact");
} finally {
process.env.PII_RESPONSE_SANITIZATION_MODE = originalMode;
}
});
// ── IPv6 regex — compressed-form detection ────────────────────────────────────
test("IPv6 :: (all-zeros) alone is redacted", () => {
const result = sanitizePII("::");
assert.ok(result.text.includes("[IP_REDACTED]"), "bare :: should be redacted as IPv6 all-zeros");
});
test("IPv6 ::1 (loopback) standalone is redacted", () => {
const result = sanitizePII("::1");
assert.ok(result.text.includes("[IP_REDACTED]"), "standalone ::1 should be redacted");
});
test("IPv6 ::1 embedded in sentence is redacted", () => {
const result = sanitizePII("connecting to ::1 on port 8080");
assert.ok(!result.text.includes("::1"), "::1 in sentence should be redacted");
assert.ok(result.text.includes("[IP_REDACTED]"), "redaction marker should appear");
});
test("IPv6 fe80::1 (link-local compressed) is redacted", () => {
const result = sanitizePII("fe80::1");
assert.ok(result.text.includes("[IP_REDACTED]"), "fe80::1 should be redacted");
assert.ok(!result.text.includes("fe80::1"), "raw fe80::1 should not remain");
});
test("IPv6 2001:db8:: (trailing double-colon) is redacted", () => {
const result = sanitizePII("2001:db8::");
assert.ok(result.text.includes("[IP_REDACTED]"), "trailing :: form should be redacted");
});
test("IPv6 ::ffff:0:0 (IPv4-mapped compressed prefix) is redacted", () => {
const result = sanitizePII("::ffff:0:0");
assert.ok(result.text.includes("[IP_REDACTED]"), "::ffff:0:0 should be redacted");
});
test("IPv6 full 8-segment address is redacted", () => {
const result = sanitizePII("1:2:3:4:5:6:7:8");
assert.ok(result.text.includes("[IP_REDACTED]"), "full 8-segment IPv6 should be redacted");
assert.ok(!result.text.includes("1:2:3:4:5:6:7:8"), "raw 8-segment should not remain");
});
// ── IPv6 regex — boundary / false-positive guards ─────────────────────────────
test("IPv6 9-segment address (invalid) is NOT redacted", () => {
// 9 colon-separated groups can never be a valid IPv6 address.
const invalid = "1:2:3:4:5:6:7:8:9";
const result = sanitizePII(invalid);
assert.strictEqual(result.text, invalid, "9-segment sequence should not be redacted");
});
test("IPv6 address preceded by a colon is NOT redacted (colon in lookbehind)", () => {
// The new lookbehind `(?<=^|[^A-Za-z0-9:])` must block matches where the
// preceding character is a colon (part of a longer sequence).
const text = "x:1:2:3:4:5:6:7:8";
const result = sanitizePII(text);
// The full sequence has a leading "x:" prefix — the 8-segment sub-slice should
// NOT be extracted as a standalone IPv6 address.
assert.strictEqual(result.text, text, "colon-prefixed sequence should not be redacted");
});
test("IPv6 followed by colon-hex suffix is NOT redacted (lookahead guard)", () => {
// The lookahead `(?!:[0-9a-fA-F:])` prevents a valid 8-segment address from
// being carved out of a longer colon-separated sequence.
const text = "1:2:3:4:5:6:7:8:extra";
const result = sanitizePII(text);
assert.strictEqual(result.text, text, "8-segment prefix of a longer colon sequence should not be redacted");
});
test("IPv6 xyz::1 (non-hex prefix) is NOT redacted", () => {
// x, y, z are outside [0-9a-fA-F] so the lookbehind must prevent a match.
const result = sanitizePII("xyz::1");
assert.strictEqual(result.text, "xyz::1", "xyz::1 should not be redacted (non-hex prefix)");
});
test("IPv6 abc::1 (valid hex prefix) IS redacted", () => {
// a, b, c are valid hex digits, so abc::1 is a valid compressed IPv6 address.
const result = sanitizePII("abc::1");
assert.ok(result.text.includes("[IP_REDACTED]"), "abc::1 should be redacted as valid compressed IPv6");
});
test("IPv6 full 8-segment with trailing alphanumeric is NOT redacted", () => {
// Lookahead must reject the match when the address is immediately followed by
// a letter/digit (8888abcd).
const text = "2001:db8:3333:4444:5555:6666:7777:8888abcd";
const result = sanitizePII(text);
assert.strictEqual(result.text, text, "8-segment address with trailing alnum should not be redacted");
});
test("multiple IPv6 addresses in the same string are all redacted", () => {
const text = "hosts: ::1 and 2001:db8::cafe";
const result = sanitizePII(text);
assert.ok(!result.text.includes("::1"), "first IPv6 should be redacted");
assert.ok(!result.text.includes("2001:db8::cafe"), "second IPv6 should be redacted");
const markerCount = (result.text.match(/\[IP_REDACTED\]/g) || []).length;
assert.ok(markerCount >= 2, "two redaction markers should be present");
});
// ── Regression: IPv6 redaction inside JSON SSE stream ────────────────────────
test("IPv6 address inside SSE JSON content is redacted end-to-end", async () => {
// This verifies the full pipeline: SSE transform → PII sanitizer → IPv6 regex.
process.env.PII_TEST_BYPASS_MIN_WINDOW = "true";
const { createPiiSseTransform } = await import("../../src/lib/streamingPiiTransform");
const transform = createPiiSseTransform();
const writer = transform.writable.getWriter();
const reader = transform.readable.getReader();
const encoder = new TextEncoder();
const writePromise = (async () => {
await writer.write(encoder.encode(
`data: {"choices":[{"delta":{"content":"server is at 2001:db8:3333:4444:5555:6666:7777:8888"}}]}\n\n`
));
await writer.write(encoder.encode(`data: [DONE]\n\n`));
await writer.close();
})();
const chunks: string[] = [];
let res = await reader.read();
while (!res.done) {
chunks.push(new TextDecoder().decode(res.value));
res = await reader.read();
}
await writePromise;
const output = chunks.join("");
assert.ok(!output.includes("2001:db8:3333:4444:5555:6666:7777:8888"),
"full IPv6 address in SSE stream should be redacted");
assert.ok(output.includes("[IP_REDACTED]"),
"redaction marker should appear in SSE stream output");
});
test.after(() => {
resetDbInstance();
fs.rmSync(tmpDir, { recursive: true, force: true });
});