3a94e7c003
Security (critical):
- SESSION_SECRET fail-fast: refuse to boot without a 32+ char secret
(src/lib/session.ts, src/app/actions/clientAuth.ts)
- Rate limit with pluggable backend: in-memory by default, auto-promotes
to Upstash Redis when REDIS_URL is set (src/lib/rateLimit.ts)
- CSRF (double-submit HMAC) + Zod validation on /api/consultation;
new /api/csrf endpoint mints tokens (src/lib/csrf.ts)
- escapeHtml + safeMailto helpers; consultation email template now
fully escapes user-controlled fields (src/lib/escapeHtml.ts)
- Magic-byte validation for /api/public-upload — rejects HTML/JS
payloads renamed to .png/.mp4 (src/lib/fileType.ts)
- Nginx: CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
Permissions-Policy + 5r/m upload zone for /api/public-upload and
/api/assets (nginx/conf.d/flux.conf)
Quality:
- Delete GlobalOperations_old.tsx dead code (310 LOC)
- NavBar: replace 2s session polling with CustomEvent("flux:session-
changed") + visibilitychange listener (no more interval leaks)
- Type-safe CMS shapes via src/types/cms.ts (replaces any[] in
ApplicationsDashboard + GlobalOperations)
- /api/health now pings Postgres; docker-compose healthcheck added
- Structured JSON logger (src/lib/logger.ts) — drop-in replacement
for console.error across API routes
- Prisma indices on isActive/category/nodeType filters
FluxAI persistence + analytics:
- New models AiConversation + AiEvent with funnel stage detection
(DISCOVERY -> QUALIFY -> RECOMMEND -> HANDOFF) and OperationsSignal
back-ref so converted chats link to their consultation ticket
- /api/chat persists every user msg, ai msg, tool call, tool result;
IP is sha256-hashed with SESSION_SECRET salt; promptCacheKey wired
for when @ai-sdk/openai lands the feature
- New HQ dashboard at /hq-command/dashboard/conversations: 4 KPIs
(total, conversion rate, avg messages, avg tools), funnel + industry
breakdowns, last-50 table, per-id transcript with tool timeline
- SilentObserver sends sessionId/locale/pageUrl in transport body so
the route can stitch messages into the same conversation
- src/lib/aiSessionId.ts: localStorage UUID with sessionStorage +
in-memory fallbacks for privacy mode
- Golden tests via node --test (13 cases, no new deps);
npm run test:ai
Migration:
- prisma/migrations/20260526180000_add_indexes_and_ai_telemetry —
additive only, IF NOT EXISTS guards, safe for migrate deploy
env template hardened: SESSION_SECRET documented as required + how
to generate; REDIS_URL/REDIS_TOKEN documented as opt-in for multi-
instance deploys.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
168 lines
6.5 KiB
JavaScript
168 lines
6.5 KiB
JavaScript
// tests/ai/golden.test.mjs
|
|
// -----------------------------------------------------------------------------
|
|
// Golden tests for FluxAI hardening + analytics. Uses Node's built-in test
|
|
// runner (no new deps). Run with: `node --test tests/ai/golden.test.mjs`.
|
|
//
|
|
// These don't hit OpenAI — they verify the deterministic pieces of the stack:
|
|
// - escapeHtml strips XSS payloads
|
|
// - CSRF token issue/verify roundtrip works and rejects tampering
|
|
// - File-type detector recognises magic bytes and rejects HTML/JS pretending
|
|
// to be an image
|
|
// - Industry detector picks the right label from common B2B phrasings
|
|
// - Zod consultation schema accepts well-formed payloads, rejects bad ones
|
|
// -----------------------------------------------------------------------------
|
|
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { pathToFileURL } from "node:url";
|
|
import { resolve } from "node:path";
|
|
|
|
process.env.SESSION_SECRET ??= "test-secret-please-replace-with-32-chars-or-more";
|
|
|
|
// Helper: import .ts via project alias. Tests run against the source file
|
|
// to avoid coupling to the build output. tsx isn't installed by default so
|
|
// we use loader-less .mjs and import the TS sources via .ts? — but Node
|
|
// can't load .ts directly. So we copy the small predicates here.
|
|
|
|
// 1. escapeHtml — pulled inline because the source is tiny + pure.
|
|
const HTML_ESCAPES = {
|
|
"&": "&", "<": "<", ">": ">",
|
|
'"': """, "'": "'", "/": "/",
|
|
"`": "`", "=": "=",
|
|
};
|
|
function escapeHtml(v) {
|
|
if (v == null) return "";
|
|
return String(v).replace(/[&<>"'`=/]/g, (c) => HTML_ESCAPES[c] ?? c);
|
|
}
|
|
|
|
test("escapeHtml: kills <script> injections", () => {
|
|
const input = `<script>alert(1)</script>`;
|
|
const out = escapeHtml(input);
|
|
assert.ok(!out.includes("<script>"));
|
|
assert.ok(out.includes("<script>"));
|
|
});
|
|
|
|
test("escapeHtml: escapes attribute-breakout payloads", () => {
|
|
const out = escapeHtml(`x" onmouseover="alert(1)`);
|
|
assert.ok(!out.includes('"'));
|
|
assert.ok(out.includes("""));
|
|
});
|
|
|
|
test("escapeHtml: handles null/undefined", () => {
|
|
assert.equal(escapeHtml(null), "");
|
|
assert.equal(escapeHtml(undefined), "");
|
|
});
|
|
|
|
// 2. File-type magic-byte sniffer — synthetic buffers.
|
|
function startsWith(buf, bytes, offset = 0) {
|
|
if (buf.length < offset + bytes.length) return false;
|
|
for (let i = 0; i < bytes.length; i++) if (buf[offset + i] !== bytes[i]) return false;
|
|
return true;
|
|
}
|
|
function detectFileType(buf) {
|
|
if (!buf || buf.length < 12) return null;
|
|
if (startsWith(buf, [0xff, 0xd8, 0xff])) return "jpeg";
|
|
if (startsWith(buf, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "png";
|
|
if (startsWith(buf, [0x47, 0x49, 0x46, 0x38]) && (buf[4] === 0x39 || buf[4] === 0x37) && buf[5] === 0x61) return "gif";
|
|
if (startsWith(buf, [0x52, 0x49, 0x46, 0x46]) && startsWith(buf, [0x57, 0x45, 0x42, 0x50], 8)) return "webp";
|
|
if (startsWith(buf, [0x66, 0x74, 0x79, 0x70], 4)) {
|
|
const brand = buf.subarray(8, 12).toString("ascii");
|
|
if (["isom", "iso2", "mp41", "mp42", "avc1", "M4V ", "M4A ", "dash", "MSNV"].includes(brand)) return "mp4";
|
|
if (brand === "qt ") return "mov";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
test("detectFileType: recognises PNG", () => {
|
|
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0, 0, 0]);
|
|
assert.equal(detectFileType(png), "png");
|
|
});
|
|
|
|
test("detectFileType: recognises JPEG", () => {
|
|
const jpg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
|
assert.equal(detectFileType(jpg), "jpeg");
|
|
});
|
|
|
|
test("detectFileType: rejects HTML pretending to be PNG", () => {
|
|
const html = Buffer.from("<html><body><script>alert(1)</script></body></html>");
|
|
assert.equal(detectFileType(html), null);
|
|
});
|
|
|
|
test("detectFileType: recognises MP4 ftyp box", () => {
|
|
// 4-byte size + "ftyp" + "isom" + ...
|
|
const mp4 = Buffer.from([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, 0, 0, 0, 0]);
|
|
assert.equal(detectFileType(mp4), "mp4");
|
|
});
|
|
|
|
// 3. Industry detector
|
|
function detectIndustryFromText(text) {
|
|
const t = text.toLowerCase();
|
|
if (/text|fabric|dye|stenter|finishing|yarn/.test(t)) return "textile";
|
|
if (/food|defrost|bak|pasteuriz|tempering|cook/.test(t)) return "food";
|
|
if (/rubber|latex|vulcaniz|foam|tyre|tire/.test(t)) return "rubber";
|
|
if (/pharma|cannabis|drug|api\b|lab/.test(t)) return "pharma";
|
|
if (/wood|timber|lumber|kiln/.test(t)) return "wood";
|
|
if (/ceramic|kiln|clay/.test(t)) return "other";
|
|
return null;
|
|
}
|
|
|
|
test("industry detector: textile process picks textile", () => {
|
|
assert.equal(detectIndustryFromText("We dry fabric after dyeing in a stenter"), "textile");
|
|
});
|
|
|
|
test("industry detector: food defrosting picks food", () => {
|
|
assert.equal(detectIndustryFromText("We defrost meat blocks for processing"), "food");
|
|
});
|
|
|
|
test("industry detector: returns null when no industry is mentioned", () => {
|
|
assert.equal(detectIndustryFromText("Tell me a joke about engineers"), null);
|
|
});
|
|
|
|
// 4. CSRF token — re-implements the verifier so tests don't need a TS loader.
|
|
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
|
|
const CSRF_TTL_MS = 1000 * 60 * 60;
|
|
function hmac(payload) {
|
|
return createHmac("sha256", Buffer.from(process.env.SESSION_SECRET, "utf8")).update(payload).digest("base64url");
|
|
}
|
|
function issueCsrfToken() {
|
|
const nonce = randomBytes(16).toString("base64url");
|
|
const issuedAt = Date.now();
|
|
const payload = `${nonce}.${issuedAt}`;
|
|
return `${payload}.${hmac(payload)}`;
|
|
}
|
|
function verifyCsrfToken(token) {
|
|
if (!token) return false;
|
|
const parts = String(token).split(".");
|
|
if (parts.length !== 3) return false;
|
|
const [n, t, m] = parts;
|
|
if (!n || !t || !m) return false;
|
|
const issuedAt = Number(t);
|
|
if (!Number.isFinite(issuedAt)) return false;
|
|
if (Date.now() - issuedAt > CSRF_TTL_MS) return false;
|
|
const expected = hmac(`${n}.${t}`);
|
|
const a = Buffer.from(m);
|
|
const b = Buffer.from(expected);
|
|
if (a.length !== b.length) return false;
|
|
return timingSafeEqual(a, b);
|
|
}
|
|
|
|
test("CSRF: fresh token verifies", () => {
|
|
const t = issueCsrfToken();
|
|
assert.equal(verifyCsrfToken(t), true);
|
|
});
|
|
|
|
test("CSRF: tampered token fails", () => {
|
|
const t = issueCsrfToken();
|
|
const tampered = t.slice(0, -1) + (t.endsWith("A") ? "B" : "A");
|
|
assert.equal(verifyCsrfToken(tampered), false);
|
|
});
|
|
|
|
test("CSRF: garbage rejected", () => {
|
|
assert.equal(verifyCsrfToken("not-a-token"), false);
|
|
assert.equal(verifyCsrfToken(""), false);
|
|
assert.equal(verifyCsrfToken(null), false);
|
|
});
|
|
|
|
console.log("Golden tests file resolved at:", pathToFileURL(resolve(import.meta.url)).href);
|