From 106dd4831ed98382a43259c1b04e45459fa07e7a Mon Sep 17 00:00:00 2001 From: Larry Klosowski Date: Thu, 22 Oct 2026 17:42:35 -0700 Subject: [PATCH] test(docs): pin the gated content route, route limiters and fail-closed tier defaults - /api/content commercial/academic branch: entitlement gate, org scope, expiry, embargo, disclosure acknowledgement and the 503 when a read cannot be logged. - Route-level limiter on /api/chat and /api/sandbox/relay (memory/provider mocked; relay fails closed with no URL, so no request leaves the process). - Repo-tier override merges over the declared map (visible with MEM_DEFAULT_TIER=public); an unlisted repo defaults to confidential. - Principal lookup refuses a gated tier for a non-verified KYC status. - The content generator, run on a temp-dir fixture corpus, keeps gated bodies out of the client module and in the server-only module; no client component imports the bodies. - Mermaid initialises with securityLevel "strict". Each guard was reverted on its own and the matching test went red; 124 -> 145 passing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KGMeatV8UjtCB4CTbWuAv4 --- test/content-gated-route.test.ts | 137 +++++++++++++++++++++++++++++ test/route-limits.test.ts | 54 ++++++++++++ test/tier-defaults.test.ts | 143 +++++++++++++++++++++++++++++++ 3 files changed, 334 insertions(+) create mode 100644 test/content-gated-route.test.ts create mode 100644 test/route-limits.test.ts create mode 100644 test/tier-defaults.test.ts diff --git a/test/content-gated-route.test.ts b/test/content-gated-route.test.ts new file mode 100644 index 00000000..8273306d --- /dev/null +++ b/test/content-gated-route.test.ts @@ -0,0 +1,137 @@ +/** + * /api/content — the commercial/academic (content-pipeline) branch. The confidential branch is + * covered in confidential-paths.test.ts; this file pins the second branch of the same route, which + * serves gated bodies from the server-only content-bodies module: the entitlement gate, the org + * scope, the embargo, the disclosure acknowledgement, and the fail-closed access log. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const h = vi.hoisted(() => ({ + session: {} as Record, + logOk: true, + logged: [] as unknown[], +})); + +vi.mock("@/lib/auth/request-session", () => ({ + resolveRequestSession: async () => h.session, +})); +vi.mock("@/lib/content/confidential-store", () => ({ + CONFIDENTIAL_DOCS: {}, + CONFIDENTIAL_DISCLOSURES: {}, + authorizeConfidentialRead: () => false, +})); +vi.mock("@/lib/content/access-log", () => ({ + recordAccess: (e: unknown) => { + if (!h.logOk) return false; + h.logged.push(e); + return true; + }, +})); +vi.mock("@/content/_generated/content", () => { + const base = { sourceKind: "authored", source: "test", toc: [], body: null }; + return { + CONTENT_NAV: [], + CONTENT_DOCS: { + "/g/commercial": { ...base, slug: "/g/commercial", title: "Commercial", tier: "commercial", orgId: null }, + "/g/org": { ...base, slug: "/g/org", title: "Org", tier: "commercial", orgId: "org-a" }, + "/g/embargo": { ...base, slug: "/g/embargo", title: "Embargo", tier: "commercial", orgId: null, embargoUntil: 9_999_999_999_999 }, + "/g/nda": { ...base, slug: "/g/nda", title: "NDA", tier: "commercial", orgId: null, disclosureRequired: true, disclosureId: "nda-x" }, + "/g/public": { ...base, slug: "/g/public", title: "Public", tier: "public", orgId: null, body: "PUBLIC" }, + }, + }; +}); +vi.mock("@/content/_generated/content-bodies", () => ({ + CONTENT_BODIES: { + "/g/commercial": "COMMERCIAL-BODY", + "/g/org": "ORG-BODY", + "/g/embargo": "EMBARGO-BODY", + "/g/nda": "NDA-BODY", + }, +})); + +const commercial = (orgId: string | null = null) => ({ + required: true, + authenticated: true, + sub: "u:reader", + kycStatus: "verified", + entitlement: { tier: "commercial", orgId, expiresAt: null }, +}); + +async function get(path: string, headers: Record = {}) { + const { GET } = await import("@/app/api/content/[...slug]/route"); + const res = await GET(new Request(`http://x/api/content${path}`, { headers }), { + params: Promise.resolve({ slug: path.slice(1).split("/") }), + }); + const text = await res.text(); + return { status: res.status, text }; +} + +beforeEach(() => { + h.session = commercial(); + h.logOk = true; + h.logged.length = 0; +}); + +describe("/api/content gated content-pipeline branch", () => { + it("serves a commercial body to a commercial reader and logs the read", async () => { + const r = await get("/g/commercial"); + expect(r.status).toBe(200); + expect(r.text).toContain("COMMERCIAL-BODY"); + expect(h.logged).toHaveLength(1); + }); + + it("an anonymous or public-tier caller gets 404 and no body", async () => { + h.session = { required: true, authenticated: false, kycStatus: "none" }; + const r = await get("/g/commercial"); + expect(r.status).toBe(404); + expect(r.text).not.toContain("COMMERCIAL-BODY"); + expect(h.logged).toHaveLength(0); + }); + + it("an expired entitlement collapses to public and is refused", async () => { + h.session = { ...commercial(), entitlement: { tier: "commercial", orgId: null, expiresAt: 1 } }; + const r = await get("/g/commercial"); + expect(r.status).toBe(404); + expect(r.text).not.toContain("COMMERCIAL-BODY"); + }); + + it("an org-scoped body is refused to another org's reader", async () => { + h.session = commercial("org-b"); + const other = await get("/g/org"); + expect(other.status).toBe(404); + expect(other.text).not.toContain("ORG-BODY"); + h.session = commercial("org-a"); + const own = await get("/g/org"); + expect(own.status).toBe(200); + expect(own.text).toContain("ORG-BODY"); + }); + + it("an embargoed body is withheld (423) even from an entitled reader", async () => { + const r = await get("/g/embargo"); + expect(r.status).toBe(423); + expect(r.text).not.toContain("EMBARGO-BODY"); + }); + + it("a disclosure-gated body needs the matching acknowledgement", async () => { + const none = await get("/g/nda"); + expect(none.status).toBe(412); + expect(none.text).not.toContain("NDA-BODY"); + const wrong = await get("/g/nda", { "x-codex-ack": "other" }); + expect(wrong.status).toBe(412); + const ok = await get("/g/nda", { "x-codex-ack": "nda-x" }); + expect(ok.status).toBe(200); + expect(ok.text).toContain("NDA-BODY"); + }); + + it("a read that cannot be access-logged is not served (503, no body)", async () => { + h.logOk = false; + const r = await get("/g/commercial"); + expect(r.status).toBe(503); + expect(r.text).not.toContain("COMMERCIAL-BODY"); + }); + + it("public docs are not served by this route", async () => { + const r = await get("/g/public"); + expect(r.status).toBe(404); + }); +}); diff --git a/test/route-limits.test.ts b/test/route-limits.test.ts new file mode 100644 index 00000000..02599da1 --- /dev/null +++ b/test/route-limits.test.ts @@ -0,0 +1,54 @@ +/** + * Every unauthenticated route that fans out to a paid or shared backend applies the limiter at the + * route itself. rate-limit.test.ts pins sandbox/infer; this file pins the chat and sandbox/relay + * routes. Memory and the model provider are mocked so no test request leaves the process. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { resetRateLimits } from "@/lib/security/rate-limit"; + +vi.mock("@/lib/ai/memory", () => ({ searchMemory: async () => [] })); +vi.mock("@/lib/ai/provider", () => ({ answer: async () => ({ text: "ok", sources: [] }) })); + +beforeEach(() => { + resetRateLimits(); + delete process.env.CITRATE_RELAY_URL; // relay route fails closed (503) before any fetch + delete process.env.UPSTASH_REDIS_REST_URL; + delete process.env.UPSTASH_REDIS_REST_TOKEN; +}); + +function req(path: string, ip: string): Request { + return new Request(`http://localhost${path}`, { + method: "POST", + headers: { "x-forwarded-for": ip, "content-type": "application/json" }, + body: JSON.stringify({ query: "what is citrate" }), + }); +} + +async function statuses(post: (r: Request) => Promise, path: string, ip: string, n: number) { + const out: number[] = []; + for (let i = 0; i < n; i++) out.push((await post(req(path, ip))).status); + return out; +} + +describe("route-level limiter", () => { + it("/api/chat returns 429 after 20 requests from one IP", async () => { + const { POST } = await import("@/app/api/chat/route"); + const s = await statuses(POST, "/api/chat", "7.7.7.7", 21); + expect(s.slice(0, 20).every((x) => x !== 429)).toBe(true); + expect(s[20]).toBe(429); + }); + + it("/api/sandbox/relay returns 429 after 10 requests from one IP", async () => { + const { POST } = await import("@/app/api/sandbox/relay/route"); + const s = await statuses(POST, "/api/sandbox/relay", "8.8.4.4", 11); + expect(s.slice(0, 10).every((x) => x === 503)).toBe(true); // fail-closed: no relay configured + expect(s[10]).toBe(429); + }); + + it("the relay limit is per IP: another caller is not throttled by the first", async () => { + const { POST } = await import("@/app/api/sandbox/relay/route"); + await statuses(POST, "/api/sandbox/relay", "8.8.4.4", 11); + const other = await POST(req("/api/sandbox/relay", "1.0.0.1")); + expect(other.status).toBe(503); + }); +}); diff --git a/test/tier-defaults.test.ts b/test/tier-defaults.test.ts new file mode 100644 index 00000000..b39ce6e8 --- /dev/null +++ b/test/tier-defaults.test.ts @@ -0,0 +1,143 @@ +/** + * Fail-closed defaults on the tier/entitlement path, and the client/server content split: + * - a partial MEMORY_REPO_TIERS override never drops a declared tier, even when an operator sets + * MEM_DEFAULT_TIER=public (where a replace-instead-of-merge bug becomes visible); + * - an unlisted repo with no MEM_DEFAULT_TIER is confidential; + * - a principal lookup does not hand out a gated tier to an unverified KYC status; + * - org-scoped content is readable only by that org (or an admin); + * - the generated client module carries no gated body, and the body module is server-only; + * - mermaid renders with securityLevel "strict" (its SVG is inserted via innerHTML). + */ +import { describe, it, expect, afterEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { resolveRepoTiers, repoTierFrom, DEFAULT_REPO_TIERS } from "@/lib/ai/memory"; +import { resolveEntitlement } from "@/lib/auth/entitlement"; +import { canRead } from "@/prototype/fixtures"; +import { CONTENT_DOCS } from "@/content/_generated/content"; + +const ROOT = path.resolve(__dirname, ".."); +const saved = process.env.MEM_DEFAULT_TIER; +afterEach(() => { + if (saved === undefined) delete process.env.MEM_DEFAULT_TIER; + else process.env.MEM_DEFAULT_TIER = saved; +}); + +describe("memory repo tiers", () => { + it("a partial override keeps every declared tier when MEM_DEFAULT_TIER=public", () => { + process.env.MEM_DEFAULT_TIER = "public"; + const map = resolveRepoTiers('{"citrate-docs":"public"}'); + const declared = Object.entries(DEFAULT_REPO_TIERS); + expect(declared.length).toBeGreaterThan(0); + for (const [repo, tier] of declared) expect(repoTierFrom(map, repo)).toBe(tier); + expect(repoTierFrom(map, "citrate-docs")).toBe("public"); + }); + + it("an unlisted repo with no MEM_DEFAULT_TIER is confidential", () => { + delete process.env.MEM_DEFAULT_TIER; + expect(repoTierFrom(resolveRepoTiers(undefined), "some-unlisted-repo")).toBe("confidential"); + }); +}); + +describe("principal lookup and KYC", () => { + it("a gated principal with a non-verified KYC status resolves to public", () => { + for (const kycStatus of ["pending", "rejected", "none"]) { + const e = resolveEntitlement({}, { sub: "uuid:7f3c1a90-builder", kycStatus }); + expect(e?.tier).toBe("public"); + } + }); + + it("the same principal with verified KYC keeps its tier (control)", () => { + const e = resolveEntitlement({}, { sub: "uuid:7f3c1a90-builder", kycStatus: "verified" }); + expect(e?.tier).toBe("commercial"); + }); +}); + +describe("org scope", () => { + const reader = (orgId: string | null) => + ({ required: true, authenticated: true, sub: "u", kycStatus: "verified", entitlement: { tier: "commercial", orgId, expiresAt: null } }) as never; + const node = { tier: "commercial" as const, orgId: "org-a" }; + + it("org-scoped content is refused to another org and to an org-less reader", () => { + expect(canRead(reader("org-b"), node, Date.now())).toBe(false); + expect(canRead(reader(null), node, Date.now())).toBe(false); + }); + + it("the owning org reads it (control)", () => { + expect(canRead(reader("org-a"), node, Date.now())).toBe(true); + }); +}); + +/** + * Run the real generator against a throwaway fixture corpus (the committed corpus is all public + * today, so it cannot exercise the split). The script resolves ROOT from its own location, so a + * copy under a temp dir reads and writes only inside that dir. + */ +function runGeneratorOnFixture(): { client: string; bodies: string } { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "docs-gen-")); + try { + fs.mkdirSync(path.join(tmp, "scripts"), { recursive: true }); + fs.copyFileSync(path.join(ROOT, "scripts/build-content.mjs"), path.join(tmp, "scripts/build-content.mjs")); + const dir = path.join(tmp, "content", "guides"); + fs.mkdirSync(dir, { recursive: true }); + const page = (tier: string, marker: string) => `---\ntitle: ${tier} page\ntier: ${tier}\n---\n# ${tier}\n\n${marker}\n`; + fs.writeFileSync(path.join(dir, "pub.md"), page("public", "PUBLIC-MARKER-1")); + fs.writeFileSync(path.join(dir, "com.md"), page("commercial", "COMMERCIAL-MARKER-2")); + fs.writeFileSync(path.join(dir, "aca.md"), page("academic", "ACADEMIC-MARKER-3")); + execFileSync(process.execPath, [path.join(tmp, "scripts/build-content.mjs")], { cwd: tmp, stdio: "pipe" }); + const gen = path.join(tmp, "content", "_generated"); + return { + client: fs.readFileSync(path.join(gen, "content.ts"), "utf8"), + bodies: fs.readFileSync(path.join(gen, "content-bodies.ts"), "utf8"), + }; + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + +describe("generated content split", () => { + it("the generator keeps gated bodies out of the client module and puts them in the server-only one", () => { + const { client, bodies } = runGeneratorOnFixture(); + expect(client).toContain("PUBLIC-MARKER-1"); // control: public bodies stay client-side + expect(client).not.toContain("COMMERCIAL-MARKER-2"); + expect(client).not.toContain("ACADEMIC-MARKER-3"); + expect(bodies).toMatch(/^import "server-only";$/m); + expect(bodies).toContain("COMMERCIAL-MARKER-2"); + expect(bodies).toContain("ACADEMIC-MARKER-3"); + expect(bodies).not.toContain("PUBLIC-MARKER-1"); + }); + + it("the committed client module carries no body for any gated doc", () => { + for (const d of Object.values(CONTENT_DOCS)) if (d.tier !== "public") expect(d.body ?? null).toBeNull(); + }); + + it("the body module is server-only and no client component imports it", () => { + const bodies = fs.readFileSync(path.join(ROOT, "content/_generated/content-bodies.ts"), "utf8"); + expect(bodies).toMatch(/^import "server-only";$/m); + const offenders: string[] = []; + const walk = (dir: string) => { + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + if (ent.name === "node_modules" || ent.name.startsWith(".")) continue; + const p = path.join(dir, ent.name); + if (ent.isDirectory()) walk(p); + else if (/\.(tsx?|jsx?)$/.test(ent.name)) { + const src = fs.readFileSync(p, "utf8"); + if (/^\s*["']use client["']/.test(src) && src.includes("content/_generated/content-bodies")) offenders.push(p); + } + } + }; + for (const d of ["app", "components", "lib"]) walk(path.join(ROOT, d)); + expect(offenders).toEqual([]); + }); +}); + +describe("mermaid rendering", () => { + it("initialises with securityLevel strict", () => { + const md = fs.readFileSync(path.join(ROOT, "lib/md.tsx"), "utf8"); + const levels = [...md.matchAll(/securityLevel:\s*"([a-z]+)"/g)].map((m) => m[1]); + expect(levels.length).toBeGreaterThan(0); + expect(levels.every((l) => l === "strict")).toBe(true); + }); +});