diff --git a/src/core/sse_adapter.ts b/src/core/sse_adapter.ts index b10b8a9..490e729 100644 --- a/src/core/sse_adapter.ts +++ b/src/core/sse_adapter.ts @@ -17,6 +17,10 @@ export function streamDatastar( aborted: boolean; }) => Promise, ) { + // Set anti-buffering headers for Nginx / Traefik reverse proxies + c.header("X-Accel-Buffering", "no"); + c.header("Cache-Control", "no-cache, no-transform"); + return streamSSE(c, async (stream) => { // Add custom datastar helper methods const adapter = { diff --git a/src/main.ts b/src/main.ts index 7670316..9ceaa1f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,6 +3,7 @@ import { serveStatic } from "jsr:@hono/hono@4/deno"; import { initDb } from "./core/db.ts"; import { pingValkey } from "./core/valkey.ts"; import { contentNegotiation } from "./core/content_negotiation.ts"; +import { payloadCapGuard } from "./core/auth_guards.ts"; import { authRoutes } from "./features/auth/routes.tsx"; import { adminRoutes } from "./features/admin/routes.tsx"; @@ -11,6 +12,7 @@ import { sessionRoutes } from "./features/sessions/routes.tsx"; const app: Hono = new Hono(); +app.use("*", payloadCapGuard); app.use("*", contentNegotiation()); // Serve static assets (specifically Datastar and client scripts) diff --git a/src/tests/arch/content_negotiation.test.ts b/src/tests/arch/content_negotiation.test.ts new file mode 100644 index 0000000..09192fa --- /dev/null +++ b/src/tests/arch/content_negotiation.test.ts @@ -0,0 +1,43 @@ +import { assertEquals } from "jsr:@std/assert@1"; +import { determineClientType } from "../../core/content_negotiation.ts"; +import { Hono } from "jsr:@hono/hono@4"; + +Deno.test("[Arch] Content Negotiation: Correctly resolves datastar, browser, shell, and cli clients", async () => { + const testApp = new Hono(); + testApp.get("/test-negotiate", (c) => { + const clientType = determineClientType(c); + return c.json({ clientType }); + }); + + // Test 1: Datastar request + const reqDatastar = new Request("http://localhost/test-negotiate", { + headers: { "Datastar-Request": "true" }, + }); + const resDatastar = await testApp.fetch(reqDatastar); + const dataDatastar = await resDatastar.json(); + assertEquals(dataDatastar.clientType, "datastar"); + + // Test 2: Standard Browser HTML request + const reqBrowser = new Request("http://localhost/test-negotiate", { + headers: { "Accept": "text/html" }, + }); + const resBrowser = await testApp.fetch(reqBrowser); + const dataBrowser = await resBrowser.json(); + assertEquals(dataBrowser.clientType, "browser"); + + // Test 3: Curl CLI request for shell script + const reqShell = new Request("http://localhost/test-negotiate", { + headers: { "User-Agent": "curl/7.88.1" }, + }); + const resShell = await testApp.fetch(reqShell); + const dataShell = await resShell.json(); + assertEquals(dataShell.clientType, "shell"); + + // Test 4: Explicit JSON REST client + const reqJson = new Request("http://localhost/test-negotiate", { + headers: { "Accept": "application/json" }, + }); + const resJson = await testApp.fetch(reqJson); + const dataJson = await resJson.json(); + assertEquals(dataJson.clientType, "cli"); +}); diff --git a/src/tests/arch/error_fragment.test.ts b/src/tests/arch/error_fragment.test.ts new file mode 100644 index 0000000..379290f --- /dev/null +++ b/src/tests/arch/error_fragment.test.ts @@ -0,0 +1,28 @@ +import { assertEquals, assertStringIncludes } from "jsr:@std/assert@1"; +import { renderErrorToastFragment } from "../../core/error_fragments.tsx"; +import app from "../../main.ts"; + +Deno.test("[Arch] Error Fragment: renderErrorToastFragment outputs Datastar morph target #status-banner", async () => { + const errorMessage = "Invalid PIN code or workshop pass expired."; + const fragment = renderErrorToastFragment(errorMessage); + const html = String(await fragment); + + assertStringIncludes(html, 'id="status-banner"'); + assertStringIncludes(html, errorMessage); + assertStringIncludes(html, "display: block;"); +}); + +Deno.test("[Arch] Error Fragment: Validation errors on action routes return HTML fragments", async () => { + const req = new Request("http://localhost/api/join", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + const res = await app.fetch(req); + assertEquals(res.status, 400); + + const html = await res.text(); + assertStringIncludes(html, 'id="status-banner"'); + assertStringIncludes(html, "Event code or PIN is required"); +}); diff --git a/src/tests/arch/proxy_buffering.test.ts b/src/tests/arch/proxy_buffering.test.ts new file mode 100644 index 0000000..8d6bc1d --- /dev/null +++ b/src/tests/arch/proxy_buffering.test.ts @@ -0,0 +1,29 @@ +import { assertEquals } from "jsr:@std/assert@1"; +import { Hono } from "jsr:@hono/hono@4"; +import { streamDatastar } from "../../core/sse_adapter.ts"; + +Deno.test("[Arch] Proxy Buffering: streamDatastar emits anti-buffering headers", async () => { + const testApp = new Hono(); + testApp.get("/api/test-sse", (c) => { + return streamDatastar(c, async (stream) => { + await stream.write({ + event: "datastar-fragment", + data: 'Active', + }); + }); + }); + + const res = await testApp.fetch(new Request("http://localhost/api/test-sse")); + assertEquals(res.status, 200); + + // Assert required proxy headers to bypass Nginx / Traefik buffer accumulation + assertEquals(res.headers.get("x-accel-buffering"), "no"); + assertEquals( + res.headers.get("content-type")?.includes("text/event-stream"), + true, + ); + assertEquals( + res.headers.get("cache-control")?.includes("no-cache"), + true, + ); +}); diff --git a/src/tests/arch/sse_lifecycle.test.ts b/src/tests/arch/sse_lifecycle.test.ts new file mode 100644 index 0000000..e67fd79 --- /dev/null +++ b/src/tests/arch/sse_lifecycle.test.ts @@ -0,0 +1,54 @@ +import { assertEquals } from "jsr:@std/assert@1"; +import { Hono } from "jsr:@hono/hono@4"; +import { streamDatastar } from "../../core/sse_adapter.ts"; + +Deno.test("[Arch] SSE Lifecycle: streamDatastar executes clean teardown on client abort", async () => { + let setupExecuted = false; + let teardownExecuted = false; + + const testApp = new Hono(); + testApp.get("/test/stream", (c) => { + return streamDatastar(c, async (stream) => { + setupExecuted = true; + try { + await stream.write({ + event: "datastar-fragment", + data: "
Connected
", + }); + + // Simulate keepalive loop until abort + while (!stream.aborted) { + await stream.sleep(10); + } + } finally { + teardownExecuted = true; + } + }); + }); + + const abortController = new AbortController(); + const req = new Request("http://localhost/test/stream", { + signal: abortController.signal, + }); + + const res = await testApp.fetch(req); + assertEquals(res.status, 200); + assertEquals(res.headers.get("content-type"), "text/event-stream"); + + // Read first chunk to ensure stream has started + const reader = res.body?.getReader(); + if (reader) { + const { value } = await reader.read(); + const text = new TextDecoder().decode(value); + assertEquals(text.includes("
Connected
"), true); + assertEquals(setupExecuted, true); + + // Abort connection + abortController.abort(); + await reader.cancel(); + } + + // Allow microtasks to complete teardown + await new Promise((resolve) => setTimeout(resolve, 50)); + assertEquals(teardownExecuted, true); +}); diff --git a/src/tests/arch/transport_efficiency.test.ts b/src/tests/arch/transport_efficiency.test.ts new file mode 100644 index 0000000..5e083d4 --- /dev/null +++ b/src/tests/arch/transport_efficiency.test.ts @@ -0,0 +1,54 @@ +import { assertEquals, assertNotEquals } from "jsr:@std/assert@1"; +import app from "../../main.ts"; + +Deno.test("[Arch] Transport Efficiency: Point-to-point routes return text/html or application/json without SSE overhead", async () => { + // Test 1: Public Join Page + const startJoin = performance.now(); + const resJoin = await app.fetch(new Request("http://localhost/join")); + const durationJoin = performance.now() - startJoin; + + assertEquals(resJoin.status, 200); + assertEquals( + resJoin.headers.get("content-type")?.includes("text/html"), + true, + ); + assertNotEquals( + resJoin.headers.get("content-type"), + "text/event-stream", + ); + // Ensure synchronous rendering execution is rapid (<50ms under test runtime) + assertEquals(durationJoin < 100, true); + + // Test 2: Login Route + const startLogin = performance.now(); + const resLogin = await app.fetch(new Request("http://localhost/login")); + const durationLogin = performance.now() - startLogin; + + assertEquals(resLogin.status, 200); + assertEquals( + resLogin.headers.get("content-type")?.includes("text/html"), + true, + ); + assertNotEquals( + resLogin.headers.get("content-type"), + "text/event-stream", + ); + assertEquals(durationLogin < 100, true); +}); + +Deno.test("[Arch] Transport Efficiency: Invariant payload cap guard prevents oversized requests (>16KB)", async () => { + // Create an oversized body (20KB) + const largeBody = "A".repeat(20 * 1024); + const req = new Request("http://localhost/api/join", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": String(largeBody.length), + }, + body: JSON.stringify({ code: largeBody }), + }); + + const res = await app.fetch(req); + // Max 16KB payload cap guard triggers 413 Payload Too Large or 400 validation + assertEquals(res.status === 413 || res.status === 400, true); +}); diff --git a/src/tests/arch/xss_fuzzing.test.tsx b/src/tests/arch/xss_fuzzing.test.tsx new file mode 100644 index 0000000..502a792 --- /dev/null +++ b/src/tests/arch/xss_fuzzing.test.tsx @@ -0,0 +1,76 @@ +import { assertNotEquals, assertStringIncludes } from "jsr:@std/assert@1"; +import { EventCockpitDeckFragment } from "../../features/events/fragments.tsx"; +import { SessionTableFragment } from "../../features/sessions/fragments.tsx"; +import { AdminUsersPageFragment } from "../../features/admin/fragments.tsx"; + +Deno.test("[Arch] XSS Fuzzing: EventCockpitDeckFragment escapes malicious event names and payloads", () => { + const xssPayload = + ''; + const mockEvents = [ + { + id: "event-1", + name: xssPayload, + slug: "xss-test", + pin_code: "123-456", + seats_claimed: 5, + max_seats: 50, + expires_at: new Date().toISOString(), + }, + ]; + + const html = String(); + + // Assert raw script tag is NOT rendered + assertNotEquals(html.includes(''), true); + // Assert escaped HTML entity is present + assertStringIncludes(html, "<script>"); +}); + +Deno.test("[Arch] XSS Fuzzing: SessionTableFragment escapes malicious session labels", () => { + const xssLabel = '">'; + const mockSessions = [ + { + id: "sess-xss", + label: xssLabel, + is_agent: true, + custom_scopes: ["read:events"], + created_at: new Date().toISOString(), + expires_at: new Date().toISOString(), + }, + ]; + + const html = String( + , + ); + + assertNotEquals( + html.includes('">'), + true, + ); + assertStringIncludes(html, "<script>"); +}); + +Deno.test("[Arch] XSS Fuzzing: AdminUsersPageFragment escapes malicious usernames and emails", () => { + const xssUsername = ''; + const mockUsers = [ + { + id: "user-1", + username: xssUsername, + role: "viewer", + created_at: new Date().toISOString(), + }, + ]; + + const html = String( + , + ); + + assertNotEquals( + html.includes(''), + true, + ); + assertStringIncludes(html, "<img"); +});