diff --git a/server/main.test.ts b/server/main.test.ts deleted file mode 100644 index 201a9eb..0000000 --- a/server/main.test.ts +++ /dev/null @@ -1,1576 +0,0 @@ -import { assert, assertEquals, assertExists } from "jsr:@std/assert"; -import { stub } from "jsr:@std/testing/mock"; -import { app } from "./main.ts"; -import { sqlWrapper } from "./db.ts"; -import { valkey } from "./valkey.ts"; -import { spireWrapper } from "./spire_ffi.ts"; -import { auditWrapper } from "./audit.ts"; -import { rateLimitWrapper } from "./ratelimit.ts"; - -const originalSql = sqlWrapper.sql; - -function setMockSql(mockImpl: () => Promise) { - sqlWrapper.sql = mockImpl as any; -} - -function restoreMockSql() { - sqlWrapper.sql = originalSql; -} - -// Bypass rate limit in tests to prevent Valkey Multi errors - -rateLimitWrapper.checkRateLimit = () => Promise.resolve(true); - -Deno.test("Tier 1 & 2: GET /api/forward-auth - Valid session", async () => { - const mockUser = { - id: "user-id-1", - username: "alice", - account_status: "active", - }; - - setMockSql(() => Promise.resolve([mockUser])); - - let queryCount = 0; - - setMockSql(() => { - queryCount++; - if (queryCount === 1) return Promise.resolve([mockUser]); // User lookup - return Promise.resolve([]); // isGlobalAdmin lookup - }); - - const valkeyStub = stub(valkey, "get", (key: any) => { - const k = String(key); - if (k.startsWith("auth:app_by_host:")) { - return Promise.resolve( - JSON.stringify({ id: "app-id-1", name: "test-app" }), - ); - } - if (k.startsWith("auth:grants:")) { - return Promise.resolve("viewer"); - } - return Promise.resolve( - JSON.stringify({ uuid: "user-id-1", username: "alice" }), - ); - }); - - const req = new Request("http://localhost/api/forward-auth", { - headers: { - Cookie: "session_id=mock-session-id", - "X-Forwarded-Host": "test.app.local", - }, - }); - - const res = await app.request(req); - assertEquals(res.status, 200); - assertEquals(res.headers.get("X-Forwarded-User"), "alice"); - assertEquals(res.headers.get("X-Forwarded-User-Id"), "user-id-1"); - assertEquals(res.headers.get("X-Forwarded-Scopes"), "viewer"); - assertEquals(res.headers.get("X-Forwarded-App-Id"), "app-id-1"); - - restoreMockSql(); - valkeyStub.restore(); -}); - -Deno.test("Ephemeral 1-Click Magic Link Redemption (/pass)", async (t) => { - await t.step("GET /pass with missing token redirects to login", async () => { - const res = await app.request("/pass", { method: "GET" }); - assertEquals(res.status, 302); - assertEquals( - res.headers.get("location"), - "/login?error=invalid_or_expired_pass", - ); - }); - - await t.step("GET /pass with invalid token redirects to login", async () => { - const valkeyStub = stub(valkey, "get", () => Promise.resolve(null)); - const originalSql = sqlWrapper.sql; - sqlWrapper.sql = () => Promise.resolve([]); - try { - const res = await app.request("/pass?token=invalid", { method: "GET" }); - assertEquals(res.status, 302); - assertEquals( - res.headers.get("location"), - "/login?error=invalid_or_expired_pass", - ); - } finally { - sqlWrapper.sql = originalSql; - valkeyStub.restore(); - } - }); - - await t.step( - "GET /pass with valid token sets cookies and redirects to app domain", - async () => { - const sessionToken = "ay_sess_valid_app"; - const valkeyGetStub = stub(valkey, "get", (key: any) => { - if (String(key) === sessionToken) { - return Promise.resolve(JSON.stringify({ - uuid: "user-uuid", - username: "testuser", - customScopes: ["app:test-app"], - })); - } - return Promise.resolve(null); - }); - const valkeyTtlStub = stub(valkey, "ttl", () => Promise.resolve(3600)); - - const originalSql = sqlWrapper.sql; - sqlWrapper.sql = (strings: any, ..._values: any[]) => { - const query = strings.join("?"); - if (query.includes("SELECT domain FROM apps WHERE name =")) { - return Promise.resolve([{ domain: "test-app.atyg.org" }]); - } - return Promise.resolve([]); - }; - - try { - const res = await app.request(`/pass?token=${sessionToken}`, { - method: "GET", - }); - assertEquals(res.status, 302); - assertEquals(res.headers.get("location"), "https://test-app.atyg.org"); - - // Verify cookies - const cookies = res.headers.get("set-cookie"); - assertExists(cookies); - assert(cookies.includes("session_id=;")); // clear host-only cookie - assert(cookies.includes(`session_id=${sessionToken};`)); // set wildcard cookie - // In tests, process.env.COOKIE_DOMAIN or getCookieDomain fallback doesn't output .atyg.org because rpID env variables may not be explicitly set in Deno tests. Wait, if it fails, let's just make sure it sets domain - } finally { - sqlWrapper.sql = originalSql; - valkeyGetStub.restore(); - valkeyTtlStub.restore(); - } - }, - ); - - await t.step( - "GET /pass with valid token defaults to /dashboard if no app scope", - async () => { - const sessionToken = "ay_sess_valid_dashboard"; - const valkeyGetStub = stub(valkey, "get", (key: any) => { - if (String(key) === sessionToken) { - return Promise.resolve(JSON.stringify({ - uuid: "user-uuid", - username: "testuser", - customScopes: ["read:audit"], - })); - } - return Promise.resolve(null); - }); - const valkeyTtlStub = stub(valkey, "ttl", () => Promise.resolve(3600)); - - try { - const res = await app.request(`/pass?token=${sessionToken}`, { - method: "GET", - }); - assertEquals(res.status, 302); - assertEquals(res.headers.get("location"), "/dashboard"); - } finally { - valkeyGetStub.restore(); - valkeyTtlStub.restore(); - } - }, - ); -}); - -Deno.test("Tier 1 & 2: GET /api/forward-auth - Missing session (API request)", async () => { - const req = new Request("http://localhost/api/forward-auth", { - headers: { "X-Forwarded-Host": "test.app.local" }, - }); - - const valkeyStub = stub(valkey, "get", (key: any) => { - const k = String(key); - if (k.startsWith("auth:app_by_host:")) { - return Promise.resolve( - JSON.stringify({ id: "app-id-1", name: "test-app" }), - ); - } - return Promise.resolve(null); - }); - - const res = await app.request(req); - assertEquals(res.status, 401); - valkeyStub.restore(); -}); - -Deno.test("Tier 1 & 2: GET /api/forward-auth - Missing session (Browser request with text/html)", async () => { - const req = new Request("http://localhost/api/forward-auth", { - headers: { - "X-Forwarded-Host": "ed-droid.atyg.org", - "X-Forwarded-Uri": "/control-panel", - "X-Forwarded-Proto": "https", - Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - }, - }); - - const valkeyStub = stub(valkey, "get", (key: any) => { - const k = String(key); - if (k.startsWith("auth:app_by_host:")) { - return Promise.resolve( - JSON.stringify({ id: "app-id-1", name: "ed-droid" }), - ); - } - return Promise.resolve(null); - }); - - const res = await app.request(req); - assertEquals(res.status, 302); - const location = res.headers.get("Location"); - assertExists(location); - assertEquals( - location?.includes( - "login?redirect=https%3A%2F%2Fed-droid.atyg.org%2Fcontrol-panel", - ), - true, - ); - valkeyStub.restore(); -}); - -Deno.test("Tier 1 & 2: GET /api/forward-auth - Expired session", async () => { - const valkeyStub = stub(valkey, "get", (key: any) => { - const k = String(key); - if (k.startsWith("auth:app_by_host:")) { - return Promise.resolve( - JSON.stringify({ id: "app-id-1", name: "test-app" }), - ); - } - return Promise.resolve(null); - }); - - const req = new Request("http://localhost/api/forward-auth", { - headers: { - Cookie: "session_id=expired-session-id", - "X-Forwarded-Host": "test.app.local", - }, - }); - const res = await app.request(req); - assertEquals(res.status, 401); - valkeyStub.restore(); -}); - -Deno.test("Tier 1 & 2: GET /api/forward-auth - Suspended account", async () => { - const mockUser = { - id: "user-id-1", - username: "alice", - account_status: "suspended", - }; - - setMockSql(() => Promise.resolve([mockUser])); - - const valkeyStub = stub(valkey, "get", (key: any) => { - const k = String(key); - if (k.startsWith("auth:app_by_host:")) { - return Promise.resolve( - JSON.stringify({ id: "app-id-1", name: "test-app" }), - ); - } - return Promise.resolve( - JSON.stringify({ uuid: "user-id-1", username: "alice" }), - ); - }); - - const req = new Request("http://localhost/api/forward-auth", { - headers: { - Cookie: "session_id=mock-session-id", - "X-Forwarded-Host": "test.app.local", - }, - }); - const res = await app.request(req); - assertEquals(res.status, 403); - assertEquals(await res.text(), "Forbidden: Account inactive"); - - restoreMockSql(); - valkeyStub.restore(); -}); - -Deno.test("Tier 3: ValidateSession ConnectRPC - Default-Deny", async () => { - const originalExtract = spireWrapper.extractSpiffeIdFromCert; - spireWrapper.extractSpiffeIdFromCert = () => "spiffe://system.local/ed-droid"; - - let queryCount = 0; - setMockSql(() => { - queryCount++; - if (queryCount === 1) { - return Promise.resolve([{ id: "app-1" }]); - } else { - return Promise.resolve([]); - } - }); - - // Re-define valkey stub - const valkeyStub = stub(valkey, "get", () => { - return Promise.resolve( - JSON.stringify({ uuid: "user-1", username: "alice" }), - ); - }); - - let auditCalled = false; - const originalAudit = auditWrapper.auditLog; - auditWrapper.auditLog = (..._args: any[]) => { - auditCalled = true; - }; - - const req = new Request( - "http://localhost/auth.v1.AuthService/ValidateSession", - { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-peer-cert": "cert-data", - }, - // The Connect protocol expects token under a specific format or we can just send it, - // actually our handler looks at `req.token` via connect protocol, which translates to the json body for unary requests in Connect HTTP POST. - // However, the `ValidateSession` requires an argument structured as `{ "token": "..." }`. - body: JSON.stringify({ token: "some-session-token" }), - }, - ); - - const res = await app.request(req); - const data = await res.json(); - - // The data structure from universal handler might wrap the response. - // Connect responses might look like just the json object. - // We'll check if valid is false. - - if (data.valid !== undefined) { - assertEquals(data.valid, false); - } else { - // If not direct format, just ensure valid is falsy or check for error - assertEquals(!!data.valid, false); - } - - assertEquals(auditCalled, true); - - spireWrapper.extractSpiffeIdFromCert = originalExtract; - restoreMockSql(); - valkeyStub.restore(); - auditWrapper.auditLog = originalAudit; -}); - -Deno.test("Tier 3: ValidateSession ConnectRPC - Valid RBAC Grant", async () => { - const originalExtract = spireWrapper.extractSpiffeIdFromCert; - spireWrapper.extractSpiffeIdFromCert = () => "spiffe://system.local/ed-droid"; - - let queryCount = 0; - setMockSql(() => { - queryCount++; - if (queryCount === 1) { - return Promise.resolve([{ id: "app-1" }]); - } else { - return Promise.resolve([{ role: "viewer" }]); - } - }); - - const valkeyStub = stub(valkey, "get", () => { - return Promise.resolve( - JSON.stringify({ uuid: "user-1", username: "alice" }), - ); - }); - - const req = new Request( - "http://localhost/auth.v1.AuthService/ValidateSession", - { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-peer-cert": "cert-data", - }, - body: JSON.stringify({ token: "some-session-token" }), - }, - ); - - const res = await app.request(req); - const data = await res.json(); - - if (data.valid !== undefined) { - assertEquals(data.valid, true); - assertEquals(data.uuid, "user-1"); - assertEquals(data.scopes[0], "viewer"); - } - - spireWrapper.extractSpiffeIdFromCert = originalExtract; - restoreMockSql(); - valkeyStub.restore(); -}); - -Deno.test("Tier 3: ValidateSession ConnectRPC - SPIFFE Attestation Failure", async () => { - const originalExtract = spireWrapper.extractSpiffeIdFromCert; - spireWrapper.extractSpiffeIdFromCert = () => null; - - const req = new Request( - "http://localhost/auth.v1.AuthService/ValidateSession", - { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-peer-cert": "invalid-cert", - }, - body: JSON.stringify({ token: "some-session-token" }), - }, - ); - - const res = await app.request(req); - const data = await res.json(); - - if (data.valid !== undefined) { - assertEquals(data.valid, false); - } else { - assertEquals(!!data.valid, false); - } - - spireWrapper.extractSpiffeIdFromCert = originalExtract; -}); - -Deno.test("Phase 4: Audit Ledger Verification - Login failed", async () => { - const mockUser = { - id: "user-2", - username: "bob", - account_status: "suspended", - }; - - let queryCount = 0; - setMockSql(() => { - queryCount++; - if (queryCount === 1) { - return Promise.resolve([{ user_id: "user-2" }]); - } else { - return Promise.resolve([mockUser]); - } - }); - - let auditArgs: any[] = []; - const originalAudit = auditWrapper.auditLog; - auditWrapper.auditLog = (...args: any[]) => { - auditArgs = args; - }; - - const req = new Request("http://localhost/api/login/verify", { - method: "POST", - headers: { - "Content-Type": "application/json", - "Cookie": "expected_authentication_challenge=challenge", - }, - body: JSON.stringify({ response: { id: "passkey-id" } }), - }); - - const res = await app.request(req); - assertEquals(res.status, 403); - - assertEquals(auditArgs.length > 0, true); - assertEquals(auditArgs[1], "login_failed"); - - restoreMockSql(); - auditWrapper.auditLog = originalAudit; -}); - -Deno.test("WebAuthn - /api/register/verify extracts PRF", async () => { - const { app } = await import("./main.ts"); - - const req = new Request("http://localhost/api/register/verify", { - method: "POST", - body: JSON.stringify({}), - }); - const res = await app.fetch(req); - assertEquals(res.status, 400); - const json = await res.json(); - assertEquals(json.error, "inviteCode or upgrade_session required"); -}); - -Deno.test("WebAuthn - /api/login/challenge handles username for PRF", async () => { - const { app } = await import("./main.ts"); - const { sqlWrapper } = await import("./db.ts"); - - const originalSql = sqlWrapper.sql; - try { - const mockSql = (strings: any, ..._values: any[]) => { - const query = strings.join("?"); - if (query.includes("SELECT id FROM users WHERE username =")) { - return Promise.resolve([{ id: "mock-user-id" }]); - } - if (query.includes("FROM passkeys WHERE user_id =")) { - return Promise.resolve([{ - credential_id: "mock-cred", - prf_enabled: true, - prf_salt: "bW9jay1zYWx0", // "mock-salt" - }]); - } - return Promise.resolve([]); - }; - sqlWrapper.sql = mockSql as any; - - const req = new Request("http://localhost/api/login/challenge", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username: "testuser" }), - }); - const res = await app.fetch(req); - assertEquals(res.status, 200); - - const json = await res.json(); - assertExists(json.options); - assertExists(json.options.extensions); - assertExists(json.options.extensions.prf); - assertExists(json.options.extensions.prf.evalByCredential); - assertExists(json.options.extensions.prf.evalByCredential["mock-cred"]); - } finally { - sqlWrapper.sql = originalSql; - } -}); - -Deno.test("Cookie Domain Scoping - getCookieDomain derives wildcard parent domain", async () => { - const { getCookieDomain } = await import("./auth-session.ts"); - - assertEquals(getCookieDomain("auth.atyg.org"), ".atyg.org"); - assertEquals(getCookieDomain("ed-droid.atyg.org"), ".atyg.org"); - assertEquals(getCookieDomain("atyg.org"), ".atyg.org"); - assertEquals(getCookieDomain("localhost"), undefined); -}); - -Deno.test("Tier 1 & 2: GET /api/forward-auth - Unregistered domain (API)", async () => { - // Override sql to return no app - sqlWrapper.sql = (async () => []) as any; - const req = new Request("http://localhost/api/forward-auth", { - headers: { - "X-Forwarded-Host": "unknown.atyg.org", - }, - }); - const res = await app.fetch(req); - assertEquals(res.status, 403); - const data = await res.json(); - assertEquals(data.error, "Application not registered"); -}); - -Deno.test("Tier 1 & 2: GET /api/forward-auth - Unregistered domain (Browser)", async () => { - // Override sql to return no app - sqlWrapper.sql = (async () => []) as any; - const req = new Request("http://localhost/api/forward-auth", { - headers: { - "X-Forwarded-Host": "unknown.atyg.org", - "Accept": "text/html", - }, - }); - const res = await app.fetch(req); - assertEquals(res.status, 302); - const location = res.headers.get("Location") || ""; - assertEquals( - true, - location.includes("/errors/unregistered?host=unknown.atyg.org"), - ); -}); - -Deno.test("Tier 1 & 2: GET /api/forward-auth - Dynamic Bypass (is_public)", async () => { - // Override sql to return public app - sqlWrapper.sql = (async (strings: any) => { - if (strings[0].includes("FROM apps")) { - return [{ - id: "public-app-id", - name: "Public App", - is_public: true, - }]; - } - return []; - }) as any; - - const req = new Request("http://localhost/api/forward-auth", { - headers: { - "X-Forwarded-Host": "public.atyg.org", - }, - }); - const res = await app.fetch(req); - assertEquals(res.status, 200); - assertEquals(res.headers.get("X-Forwarded-App-Id"), "public-app-id"); -}); - -Deno.test("Tier 1 & 2: GET /api/forward-auth - Dynamic Bypass (bypass_paths)", async () => { - // Override sql to return app with bypass path - sqlWrapper.sql = (async (strings: any) => { - if (strings[0].includes("FROM apps")) { - return [{ - id: "bypass-app-id", - name: "Bypass App", - bypass_paths: ["/api/public/*"], - }]; - } - return []; - }) as any; - - const req = new Request("http://localhost/api/forward-auth", { - headers: { - "X-Forwarded-Host": "bypass.atyg.org", - "X-Forwarded-Uri": "/api/public/status", - }, - }); - const res = await app.fetch(req); - assertEquals(res.status, 200); -}); - -Deno.test("Tier 1 & 2: POST /api/guests/sandbox - Creates guest session", async () => { - const { valkey } = await import("./valkey.ts"); - // Mock valkey.setex to prevent connection errors during tests - valkey.setex = async () => "OK" as any; - - const req = new Request("http://localhost/api/guests/sandbox", { - method: "POST", - }); - const res = await app.fetch(req); - assertEquals(res.status, 200); - const data = await res.json(); - assertEquals(data.success, true); - assertEquals(true, !!data.sessionId); - assertEquals(true, !!data.guestUuid); -}); - -Deno.test("Logout Return-Path Validation", async (t) => { - await t.step("GET /logout preserves valid redirect", async () => { - const req = new Request( - "http://localhost/logout?redirect=https://ed-droid.atyg.org/", - ); - const res = await app.fetch(req); - assertEquals(res.status, 302); - assertEquals( - res.headers.get("Location"), - "https://ed-droid.atyg.org/", - ); - }); - - await t.step("GET /logout intercepts malicious redirect", async () => { - const res = await app.request("/logout?redirect=https://evil.com", { - method: "GET", - }); - assertEquals(res.status, 302); - const location = res.headers.get("location"); - assertExists(location); - // Malicious redirect should be discarded, so it just redirects to /login - assertEquals(location, "/login"); - }); -}); - -Deno.test("App Launchpad Zero-Knowledge Query Logic", async (t) => { - await t.step( - "GET /dashboard redirects to /login if unauthenticated", - async () => { - const res = await app.request("/dashboard", { method: "GET" }); - assertEquals(res.status, 302); - assertEquals(res.headers.get("location"), "/login"); - }, - ); - - await t.step("GET /dashboard renders for admin", async () => { - const valkeyStub = stub(valkey, "get", (key: any) => { - if (String(key) === "valid_session") { - return Promise.resolve( - JSON.stringify({ uuid: "admin-uuid", username: "admin" }), - ); - } - return Promise.resolve(null); - }); - - const originalSql = sqlWrapper.sql; - sqlWrapper.sql = (strings: any, ..._values: any[]) => { - const query = strings.join("?"); - if (query.includes("g.role = 'admin'")) { - return Promise.resolve([{ id: "grant-id" }]); - } - if ( - query.includes("SELECT id, name, description, domain, 'Admin' as role") - ) { - return Promise.resolve([ - { - id: "app1", - name: "App 1", - description: "Desc", - domain: "app1.com", - role: "Admin", - }, - { - id: "app2", - name: "App 2", - description: "Desc", - domain: "app2.com", - role: "Admin", - }, - ]); - } - return Promise.resolve([]); - }; - - try { - const res = await app.request("/dashboard", { - method: "GET", - headers: { Cookie: "session_id=valid_session" }, - }); - assertEquals(res.status, 200); - const text = await res.text(); - assert(text.includes("App 1")); - assert(text.includes("App 2")); - assert(text.includes("Admin Console")); // Layout link - } finally { - sqlWrapper.sql = originalSql; - valkeyStub.restore(); - } - }); - - await t.step("GET /dashboard renders for regular user", async () => { - const valkeyStub = stub(valkey, "get", (key: any) => { - if (String(key) === "valid_session") { - return Promise.resolve( - JSON.stringify({ uuid: "user-uuid", username: "user" }), - ); - } - return Promise.resolve(null); - }); - - const originalSql = sqlWrapper.sql; - sqlWrapper.sql = (strings: any, ..._values: any[]) => { - const query = strings.join("?"); - if (query.includes("g.role = 'admin'")) { - return Promise.resolve([]); // Not admin - } - if (query.includes("ORDER BY created_at ASC LIMIT 1")) { - return Promise.resolve([{ id: "different-user" }]); - } - if ( - query.includes("SELECT a.id, a.name, a.description, a.domain, g.role") - ) { - return Promise.resolve([ - { - id: "app1", - name: "App 1", - description: "Desc", - domain: "app1.com", - role: "Viewer", - }, - ]); - } - return Promise.resolve([]); - }; - - try { - const res = await app.request("/dashboard", { - method: "GET", - headers: { Cookie: "session_id=valid_session" }, - }); - assertEquals(res.status, 200); - const text = await res.text(); - assert(text.includes("App 1")); - assert(!text.includes("App 2")); - assert(!text.includes("Admin Console")); // Layout link should be missing - } finally { - sqlWrapper.sql = originalSql; - valkeyStub.restore(); - } - }); -}); - -Deno.test("Cookie Shadowing & Multi-Cookie Iteration", async (t) => { - await t.step( - "GET /dashboard authenticates when fresh session is shadowed by stale host-only cookie", - async () => { - const valkeyStub = stub(valkey, "get", (key: any) => { - if (String(key) === "fresh_wildcard_session") { - return Promise.resolve( - JSON.stringify({ uuid: "user-uuid", username: "tylerg" }), - ); - } - return Promise.resolve(null); - }); - - const originalSql = sqlWrapper.sql; - sqlWrapper.sql = () => Promise.resolve([]); - - try { - const res = await app.request("/dashboard", { - method: "GET", - headers: { - // Android Chrome sends stale host cookie first, then wildcard cookie - Cookie: - "session_id=stale_host_cookie; session_id=fresh_wildcard_session", - }, - }); - assertEquals(res.status, 200); - const text = await res.text(); - assert(text.includes("Application Launchpad")); - - // Verify Set-Cookie header attempts to delete the host-only cookie - const setCookieHeader = res.headers.get("set-cookie") || ""; - assert( - setCookieHeader.includes("session_id=") && - (setCookieHeader.includes("Max-Age=0") || - setCookieHeader.includes("Expires=")), - ); - } finally { - sqlWrapper.sql = originalSql; - valkeyStub.restore(); - } - }, - ); -}); - -Deno.test("Agent Session Delegation & Scoped Permissions", async (t) => { - await t.step( - "POST /api/sessions/delegate - Mints child session with scopes", - async () => { - const valkeyStub = stub(valkey, "get", (key: any) => { - if (String(key) === "admin-session") { - return Promise.resolve( - JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }), - ); - } - return Promise.resolve(null); - }); - const valkeySetStub = stub(valkey, "set", () => Promise.resolve("OK")); - - const originalSql = sqlWrapper.sql; - sqlWrapper.sql = ((_query: any, ..._args: any[]) => { - return Promise.resolve([{ id: "mock-id" }]); - }) as any; - - try { - const res = await app.request("/api/sessions/delegate", { - method: "POST", - headers: { - Authorization: "Bearer admin-session", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - label: "Antigravity Assistant", - lifespanHours: 12, - mode: "read_only", - }), - }); - - assertEquals(res.status, 200); - const json = await res.json(); - assert(json.success === true); - assert(json.sessionId.startsWith("ay_sess_")); - assertEquals(json.label, "Antigravity Assistant"); - assert(json.scopes.includes("read:audit")); - } finally { - sqlWrapper.sql = originalSql; - valkeyStub.restore(); - valkeySetStub.restore(); - } - }, - ); - - await t.step( - "PUT /api/sessions/:id/scopes - Updates custom scopes", - async () => { - const valkeyStub = stub(valkey, "get", (key: any) => { - if (String(key) === "admin-session") { - return Promise.resolve( - JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }), - ); - } - return Promise.resolve(null); - }); - - const originalSql = sqlWrapper.sql; - sqlWrapper.sql = ((_query: any, ..._args: any[]) => { - return Promise.resolve([{ id: "ay_sess_123", is_agent: true }]); - }) as any; - - try { - const res = await app.request("/api/sessions/ay_sess_123/scopes", { - method: "PUT", - headers: { - Authorization: "Bearer admin-session", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - customScopes: ["read:audit", "app:ed-droid"], - }), - }); - - assertEquals(res.status, 200); - const json = await res.json(); - assert(json.success === true); - assertEquals(json.scopes, ["read:audit", "app:ed-droid"]); - } finally { - sqlWrapper.sql = originalSql; - valkeyStub.restore(); - } - }, - ); - - await t.step( - "POST /api/sessions/:id/extend - Extends session TTL", - async () => { - const valkeyStub = stub(valkey, "get", (key: any) => { - if (String(key) === "admin-session") { - return Promise.resolve( - JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }), - ); - } - return Promise.resolve(null); - }); - const valkeyExpireStub = stub(valkey, "expire", () => Promise.resolve(1)); - - const originalSql = sqlWrapper.sql; - sqlWrapper.sql = ((_query: any, ..._args: any[]) => { - return Promise.resolve([{ - id: "ay_sess_123", - expires_at: new Date().toISOString(), - }]); - }) as any; - - try { - const res = await app.request("/api/sessions/ay_sess_123/extend", { - method: "POST", - headers: { - Authorization: "Bearer admin-session", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - extendHours: 2, - }), - }); - - assertEquals(res.status, 200); - const json = await res.json(); - assert(json.success === true); - assert(json.newExpiresAt); - } finally { - sqlWrapper.sql = originalSql; - valkeyStub.restore(); - valkeyExpireStub.restore(); - } - }, - ); -}); - -Deno.test("Ephemeral 1-Click Magic Link Redemption (/pass)", async (t) => { - await t.step("GET /pass with missing token redirects to login", async () => { - const res = await app.request("/pass", { method: "GET" }); - assertEquals(res.status, 302); - assertEquals( - res.headers.get("location"), - "/login?error=invalid_or_expired_pass", - ); - }); - - await t.step("GET /pass with invalid token redirects to login", async () => { - const valkeyStub = stub(valkey, "get", () => Promise.resolve(null)); - const originalSql = sqlWrapper.sql; - sqlWrapper.sql = (() => Promise.resolve([])) as any; - try { - const res = await app.request("/pass?token=invalid", { method: "GET" }); - assertEquals(res.status, 302); - assertEquals( - res.headers.get("location"), - "/login?error=invalid_or_expired_pass", - ); - } finally { - sqlWrapper.sql = originalSql; - valkeyStub.restore(); - } - }); - - await t.step( - "GET /pass with valid token sets cookies and redirects to app domain", - async () => { - const sessionToken = "ay_sess_valid_app"; - const valkeyGetStub = stub(valkey, "get", (key: any) => { - if (String(key) === sessionToken) { - return Promise.resolve(JSON.stringify({ - uuid: "user-uuid", - username: "testuser", - customScopes: ["app:ed-droid"], - })); - } - return Promise.resolve(null); - }); - const valkeyTtlStub = stub(valkey, "ttl", () => Promise.resolve(3600)); - - const originalSql = sqlWrapper.sql; - sqlWrapper.sql = ((strings: any, ..._values: any[]) => { - const query = Array.isArray(strings) - ? strings.join("?") - : String(strings); - if (query.includes("SELECT domain FROM apps WHERE name =")) { - return Promise.resolve([{ domain: "ed-droid.atyg.org" }]); - } - return Promise.resolve([]); - }) as any; - - try { - const res = await app.request(`/pass?token=${sessionToken}`, { - method: "GET", - }); - assertEquals(res.status, 302); - assertEquals(res.headers.get("location"), "https://ed-droid.atyg.org"); - - const cookies = res.headers.get("set-cookie"); - assertExists(cookies); - assert(cookies.includes(`session_id=${sessionToken};`)); - } finally { - sqlWrapper.sql = originalSql; - valkeyGetStub.restore(); - valkeyTtlStub.restore(); - } - }, - ); - - await t.step( - "GET /pass with valid token defaults to /dashboard if no app scope", - async () => { - const sessionToken = "ay_sess_valid_dashboard"; - const valkeyGetStub = stub(valkey, "get", (key: any) => { - if (String(key) === sessionToken) { - return Promise.resolve(JSON.stringify({ - uuid: "user-uuid", - username: "testuser", - customScopes: ["read:audit"], - })); - } - return Promise.resolve(null); - }); - const valkeyTtlStub = stub(valkey, "ttl", () => Promise.resolve(3600)); - - try { - const res = await app.request(`/pass?token=${sessionToken}`, { - method: "GET", - }); - assertEquals(res.status, 302); - assertEquals(res.headers.get("location"), "/dashboard"); - } finally { - valkeyGetStub.restore(); - valkeyTtlStub.restore(); - } - }, - ); -}); - -Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => { - await t.step("POST /api/events creates an event pass", async () => { - const valkeyStub = stub(valkey, "get", (key: any) => { - if (String(key) === "admin-session") { - return Promise.resolve( - JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }), - ); - } - return Promise.resolve(null); - }); - - const originalSql = sqlWrapper.sql; - sqlWrapper.sql = ((strings: any, ..._values: any[]) => { - const query = Array.isArray(strings) - ? strings.join("?") - : String(strings); - if (query.includes("INSERT INTO event_passes")) { - return Promise.resolve([{ - id: "event-uuid-1", - slug: "deno-lab", - pin_code: "749-123", - name: "Deno Workshop", - max_seats: 50, - seats_claimed: 0, - lifespan_hours: 3, - }]); - } - return Promise.resolve([]); - }) as any; - - try { - const res = await app.request("/api/events", { - method: "POST", - headers: { - Authorization: "Bearer admin-session", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name: "Deno Workshop", - slug: "deno-lab", - pinCode: "749-123", - maxSeats: 50, - lifespanHours: 3, - }), - }); - - assertEquals(res.status, 200); - const json = await res.json(); - assert(json.success === true); - assertEquals(json.event.slug, "deno-lab"); - assertEquals(json.event.pin_code, "749-123"); - } finally { - sqlWrapper.sql = originalSql; - valkeyStub.restore(); - } - }); - - await t.step( - "POST /api/join redeems PIN / slug and mints guest session", - async () => { - const originalSql = sqlWrapper.sql; - sqlWrapper.sql = ((strings: any, ..._values: any[]) => { - const query = Array.isArray(strings) - ? strings.join("?") - : String(strings); - if (query.includes("UPDATE event_passes")) { - return Promise.resolve([{ - id: "event-uuid-1", - slug: "deno-lab", - pin_code: "749-123", - name: "Deno Workshop", - max_seats: 50, - seats_claimed: 1, - lifespan_hours: 3, - app_id: null, - }]); - } - return Promise.resolve([]); - }) as any; - - const valkeySetexStub = stub( - valkey, - "setex", - () => Promise.resolve("OK" as any), - ); - - try { - const res = await app.request("/api/join", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ code: "749-123" }), - }); - - assertEquals(res.status, 200); - const json = await res.json(); - assert(json.success === true); - assert(json.token.startsWith("ay_sess_")); - assertEquals(json.username, "guest_deno-lab_1"); - - const cookies = res.headers.get("set-cookie"); - assertExists(cookies); - assert(cookies.includes(`session_id=${json.token};`)); - } finally { - sqlWrapper.sql = originalSql; - valkeySetexStub.restore(); - } - }, - ); - - await t.step( - "GET /join/:slug?format=env returns CLI export string", - async () => { - const originalSql = sqlWrapper.sql; - sqlWrapper.sql = ((strings: any, ..._values: any[]) => { - const query = Array.isArray(strings) - ? strings.join("?") - : String(strings); - if (query.includes("UPDATE event_passes")) { - return Promise.resolve([{ - id: "event-uuid-1", - slug: "deno-lab", - pin_code: "749-123", - name: "Deno Workshop", - max_seats: 50, - seats_claimed: 2, - lifespan_hours: 3, - app_id: null, - }]); - } - return Promise.resolve([]); - }) as any; - - const valkeySetexStub = stub( - valkey, - "setex", - () => Promise.resolve("OK" as any), - ); - - try { - const res = await app.request("/join/deno-lab?format=env", { - method: "GET", - }); - - assertEquals(res.status, 200); - const text = await res.text(); - assert(text.includes('export AUTH_YES_TOKEN="ay_sess_')); - assert(text.includes('export AUTH_YES_USER="guest_deno-lab_2"')); - } finally { - sqlWrapper.sql = originalSql; - valkeySetexStub.restore(); - } - }, - ); - - await t.step( - "POST /api/events/:id/end master kill switch revokes sessions", - async () => { - const valkeyGetStub = stub(valkey, "get", (key: any) => { - if (String(key) === "admin-session") { - return Promise.resolve( - JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }), - ); - } - return Promise.resolve(null); - }); - - const originalSql = sqlWrapper.sql; - let updateEventCalled = false; - let deleteSessionsCalled = false; - - sqlWrapper.sql = ((strings: any, ..._values: any[]) => { - const query = Array.isArray(strings) - ? strings.join("?") - : String(strings); - if ( - query.includes("UPDATE event_passes") && - query.includes("is_active = FALSE") - ) { - updateEventCalled = true; - return Promise.resolve([{ slug: "deno-lab" }]); - } - if (query.includes("DELETE FROM sessions")) { - deleteSessionsCalled = true; - return Promise.resolve([{ id: "sess-1" }, { id: "sess-2" }]); - } - return Promise.resolve([]); - }) as any; - - const valkeyDelStub = stub( - valkey, - "del", - () => Promise.resolve(2 as any), - ); - - try { - const res = await app.request("/api/events/evt-123/end", { - method: "POST", - headers: { - Authorization: "Bearer admin-session", - }, - }); - - assertEquals(res.status, 200); - const json = await res.json(); - assert(json.success === true); - assertEquals(json.revokedCount, 2); - assert(updateEventCalled); - assert(deleteSessionsCalled); - } finally { - sqlWrapper.sql = originalSql; - valkeyGetStub.restore(); - valkeyDelStub.restore(); - } - }, - ); - - await t.step( - "POST /api/events/:id/extend adds time to event and sessions", - async () => { - const valkeyGetStub = stub(valkey, "get", (key: any) => { - if (String(key) === "admin-session") { - return Promise.resolve( - JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }), - ); - } - return Promise.resolve(null); - }); - - const futureDate = new Date(Date.now() + 7200000).toISOString(); - const originalSql = sqlWrapper.sql; - let updateEventCalled = false; - let updateSessionsCalled = false; - - sqlWrapper.sql = ((strings: any, ..._values: any[]) => { - const query = Array.isArray(strings) - ? strings.join("?") - : String(strings); - if ( - query.includes("UPDATE event_passes") && - query.includes("expires_at = expires_at + interval") - ) { - updateEventCalled = true; - return Promise.resolve([{ - slug: "deno-lab", - expires_at: futureDate, - }]); - } - if ( - query.includes("UPDATE sessions") && - query.includes("expires_at = expires_at + interval") - ) { - updateSessionsCalled = true; - return Promise.resolve([{ id: "sess-1" }, { id: "sess-2" }]); - } - return Promise.resolve([]); - }) as any; - - let expireCallCount = 0; - const valkeyExpireStub = stub(valkey, "expire", () => { - expireCallCount++; - return Promise.resolve(1 as any); - }); - - try { - const res = await app.request("/api/events/evt-123/extend", { - method: "POST", - headers: { - Authorization: "Bearer admin-session", - "Content-Type": "application/json", - }, - body: JSON.stringify({ extendHours: 2 }), - }); - - assertEquals(res.status, 200); - const json = await res.json(); - assert(json.success === true); - assertEquals(json.expiresAt, futureDate); - assert(updateEventCalled); - assert(updateSessionsCalled); - assertEquals(expireCallCount, 2); - } finally { - sqlWrapper.sql = originalSql; - valkeyGetStub.restore(); - valkeyExpireStub.restore(); - } - }, - ); -}); - -Deno.test("Zero-Trust Scope Guards", async (t) => { - const mockUserId = "user-id-guards"; - - await t.step( - "Delegated session is blocked from mutating sessions without scope", - async () => { - const mockSessionId = "ay_sess_delegated_123"; - - setMockSql( - (async (strings: any, ..._values: any[]) => { - const q = Array.isArray(strings) - ? strings.join("?") - : String(strings); - if (q.includes("SELECT id, is_agent FROM sessions")) { - return [{ id: "other_session", is_agent: false }]; - } - return []; - }) as any, - ); - - if ((valkey as any).get.restore) { - (valkey as any).get.restore(); - } - const valkeyStub = stub( - valkey, - "get", - () => - Promise.resolve( - JSON.stringify({ - uuid: mockUserId, - username: "agent-user", - isAgent: true, - customScopes: ["read:data"], - }), - ), - ); - - try { - const req = new Request( - "http://localhost/api/sessions/other_session/extend", - { - method: "POST", - headers: { - "Authorization": `Bearer ${mockSessionId}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ extendHours: 2 }), - }, - ); - const res = await app.fetch(req); - assertEquals(res.status, 403); - const body = await res.json(); - assertEquals(body.error, "Forbidden: Insufficient scopes"); - } finally { - valkeyStub.restore(); - } - }, - ); - - await t.step("Delegated session can self-revoke", async () => { - const mockSessionId = "ay_sess_delegated_123"; - - setMockSql( - (async (strings: any, ..._values: any[]) => { - const q = Array.isArray(strings) ? strings.join("?") : String(strings); - if (q.includes("SELECT id FROM sessions WHERE id =")) { - return [{ id: mockSessionId }]; - } - if (q.includes("DELETE FROM sessions")) { - return [{ id: mockSessionId }]; - } - return []; - }) as any, - ); - - if ((valkey as any).get.restore) { - (valkey as any).get.restore(); - } - const valkeyStub = stub( - valkey, - "get", - () => - Promise.resolve( - JSON.stringify({ - uuid: mockUserId, - username: "agent-user", - isAgent: true, - customScopes: ["read:data"], - }), - ), - ); - - const delStub = stub(valkey, "del", () => Promise.resolve(1 as any)); - - try { - const req = new Request( - `http://localhost/api/sessions/${mockSessionId}`, - { - method: "DELETE", - headers: { "Authorization": `Bearer ${mockSessionId}` }, - }, - ); - const res = await app.fetch(req); - assertEquals(res.status, 200); - const body = await res.json(); - assertEquals(body.success, true); - } finally { - valkeyStub.restore(); - delStub.restore(); - } - }); - - await t.step( - "Delegated session blocked from UI /admin redirect", - async () => { - const mockSessionId = "ay_sess_delegated_123"; - - setMockSql( - (async (strings: any, ..._values: any[]) => { - const q = Array.isArray(strings) - ? strings.join("?") - : String(strings); - if (q.includes("SELECT g.id")) { - return [{ id: "admin-grant-id" }]; - } - return []; - }) as any, - ); - - if ((valkey as any).get.restore) { - (valkey as any).get.restore(); - } - const valkeyStub = stub( - valkey, - "get", - () => - Promise.resolve( - JSON.stringify({ - uuid: mockUserId, - username: "agent-user", - isAgent: true, - customScopes: ["read:data"], - }), - ), - ); - - try { - const req = new Request("http://localhost/admin/users", { - headers: { "Cookie": `session_id=${mockSessionId}` }, - }); - const res = await app.fetch(req); - assertEquals(res.status, 302); - assertEquals(res.headers.get("location"), "/dashboard"); - } finally { - valkeyStub.restore(); - } - }, - ); - - await t.step( - "Delegated session without admin scope blocked from POST /api/sessions/delegate", - async () => { - const mockSessionId = "ay_sess_delegated_123"; - - if ((valkey as any).get.restore) { - (valkey as any).get.restore(); - } - const valkeyStub = stub( - valkey, - "get", - () => - Promise.resolve( - JSON.stringify({ - uuid: mockUserId, - username: "agent-user", - isAgent: true, - customScopes: ["write:sessions"], - }), - ), - ); - - try { - const req = new Request("http://localhost/api/sessions/delegate", { - method: "POST", - headers: { - "Authorization": `Bearer ${mockSessionId}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ label: "Sub Agent" }), - }); - const res = await app.fetch(req); - assertEquals(res.status, 403); - const body = await res.json(); - assertEquals(body.error, "Forbidden: Insufficient scopes"); - } finally { - valkeyStub.restore(); - } - }, - ); - - await t.step( - "Delegated session without write:events blocked from POST /api/events", - async () => { - const mockSessionId = "ay_sess_delegated_123"; - - if ((valkey as any).get.restore) { - (valkey as any).get.restore(); - } - const valkeyStub = stub( - valkey, - "get", - () => - Promise.resolve( - JSON.stringify({ - uuid: mockUserId, - username: "agent-user", - isAgent: true, - customScopes: ["read:data"], - }), - ), - ); - - try { - const req = new Request("http://localhost/api/events", { - method: "POST", - headers: { - "Authorization": `Bearer ${mockSessionId}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ name: "Hacking Workshop" }), - }); - const res = await app.fetch(req); - assertEquals(res.status, 403); - const body = await res.json(); - assertEquals(body.error, "Forbidden: Insufficient scopes"); - } finally { - valkeyStub.restore(); - } - }, - ); - - restoreMockSql(); -}); diff --git a/server/tests/auth.test.ts b/server/tests/auth.test.ts new file mode 100644 index 0000000..65540b1 --- /dev/null +++ b/server/tests/auth.test.ts @@ -0,0 +1,403 @@ +import { assert, assertEquals, assertExists } from "jsr:@std/assert"; +import { stub } from "jsr:@std/testing/mock"; +import { app } from "../main.ts"; +import { sqlWrapper } from "../db.ts"; +import { valkey } from "../valkey.ts"; +import { auditWrapper } from "../audit.ts"; +import { rateLimitWrapper } from "../ratelimit.ts"; + +const originalSql = sqlWrapper.sql; + +function setMockSql(mockImpl: () => Promise) { + sqlWrapper.sql = mockImpl as any; +} + +function restoreMockSql() { + sqlWrapper.sql = originalSql; +} + +rateLimitWrapper.checkRateLimit = () => Promise.resolve(true); + +Deno.test("Phase 4: Audit Ledger Verification - Login failed", async () => { + const mockUser = { + id: "user-2", + username: "bob", + account_status: "suspended", + }; + + let queryCount = 0; + setMockSql(() => { + queryCount++; + if (queryCount === 1) { + return Promise.resolve([{ user_id: "user-2" }]); + } else { + return Promise.resolve([mockUser]); + } + }); + + let auditArgs: any[] = []; + const originalAudit = auditWrapper.auditLog; + auditWrapper.auditLog = (...args: any[]) => { + auditArgs = args; + }; + + const req = new Request("http://localhost/api/login/verify", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Cookie": "expected_authentication_challenge=challenge", + }, + body: JSON.stringify({ response: { id: "passkey-id" } }), + }); + + const res = await app.request(req); + assertEquals(res.status, 403); + + assertEquals(auditArgs.length > 0, true); + assertEquals(auditArgs[1], "login_failed"); + + restoreMockSql(); + auditWrapper.auditLog = originalAudit; +}); + +Deno.test("WebAuthn - /api/register/verify extracts PRF", async () => { + const { app } = await import("../main.ts"); + + const req = new Request("http://localhost/api/register/verify", { + method: "POST", + body: JSON.stringify({}), + }); + const res = await app.fetch(req); + assertEquals(res.status, 400); + const json = await res.json(); + assertEquals(json.error, "inviteCode or upgrade_session required"); +}); + +Deno.test("WebAuthn - /api/login/challenge handles username for PRF", async () => { + const { app } = await import("../main.ts"); + const { sqlWrapper } = await import("../db.ts"); + + const originalSql = sqlWrapper.sql; + try { + const mockSql = (strings: any, ..._values: any[]) => { + const query = strings.join("?"); + if (query.includes("SELECT id FROM users WHERE username =")) { + return Promise.resolve([{ id: "mock-user-id" }]); + } + if (query.includes("FROM passkeys WHERE user_id =")) { + return Promise.resolve([{ + credential_id: "mock-cred", + prf_enabled: true, + prf_salt: "bW9jay1zYWx0", // "mock-salt" + }]); + } + return Promise.resolve([]); + }; + sqlWrapper.sql = mockSql as any; + + const req = new Request("http://localhost/api/login/challenge", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "testuser" }), + }); + const res = await app.fetch(req); + assertEquals(res.status, 200); + + const json = await res.json(); + assertExists(json.options); + assertExists(json.options.extensions); + assertExists(json.options.extensions.prf); + assertExists(json.options.extensions.prf.evalByCredential); + assertExists(json.options.extensions.prf.evalByCredential["mock-cred"]); + } finally { + sqlWrapper.sql = originalSql; + } +}); + +Deno.test("Logout Return-Path Validation", async (t) => { + await t.step("GET /logout preserves valid redirect", async () => { + const req = new Request( + "http://localhost/logout?redirect=https://ed-droid.atyg.org/", + ); + const res = await app.fetch(req); + assertEquals(res.status, 302); + assertEquals( + res.headers.get("Location"), + "https://ed-droid.atyg.org/", + ); + }); + + await t.step("GET /logout intercepts malicious redirect", async () => { + const res = await app.request("/logout?redirect=https://evil.com", { + method: "GET", + }); + assertEquals(res.status, 302); + const location = res.headers.get("location"); + assertExists(location); + // Malicious redirect should be discarded, so it just redirects to /login + assertEquals(location, "/login"); + }); +}); + +Deno.test("App Launchpad Zero-Knowledge Query Logic", async (t) => { + await t.step( + "GET /dashboard redirects to /login if unauthenticated", + async () => { + const res = await app.request("/dashboard", { method: "GET" }); + assertEquals(res.status, 302); + assertEquals(res.headers.get("location"), "/login"); + }, + ); + + await t.step("GET /dashboard renders for admin", async () => { + const valkeyStub = stub(valkey, "get", (key: any) => { + if (String(key) === "valid_session") { + return Promise.resolve( + JSON.stringify({ uuid: "admin-uuid", username: "admin" }), + ); + } + return Promise.resolve(null); + }); + + const originalSql = sqlWrapper.sql; + sqlWrapper.sql = (strings: any, ..._values: any[]) => { + const query = strings.join("?"); + if (query.includes("g.role = 'admin'")) { + return Promise.resolve([{ id: "grant-id" }]); + } + if ( + query.includes("SELECT id, name, description, domain, 'Admin' as role") + ) { + return Promise.resolve([ + { + id: "app1", + name: "App 1", + description: "Desc", + domain: "app1.com", + role: "Admin", + }, + { + id: "app2", + name: "App 2", + description: "Desc", + domain: "app2.com", + role: "Admin", + }, + ]); + } + return Promise.resolve([]); + }; + + try { + const res = await app.request("/dashboard", { + method: "GET", + headers: { Cookie: "session_id=valid_session" }, + }); + assertEquals(res.status, 200); + const text = await res.text(); + assert(text.includes("App 1")); + assert(text.includes("App 2")); + assert(text.includes("Admin Console")); // Layout link + } finally { + sqlWrapper.sql = originalSql; + valkeyStub.restore(); + } + }); + + await t.step("GET /dashboard renders for regular user", async () => { + const valkeyStub = stub(valkey, "get", (key: any) => { + if (String(key) === "valid_session") { + return Promise.resolve( + JSON.stringify({ uuid: "user-uuid", username: "user" }), + ); + } + return Promise.resolve(null); + }); + + const originalSql = sqlWrapper.sql; + sqlWrapper.sql = (strings: any, ..._values: any[]) => { + const query = strings.join("?"); + if (query.includes("g.role = 'admin'")) { + return Promise.resolve([]); // Not admin + } + if (query.includes("ORDER BY created_at ASC LIMIT 1")) { + return Promise.resolve([{ id: "different-user" }]); + } + if ( + query.includes("SELECT a.id, a.name, a.description, a.domain, g.role") + ) { + return Promise.resolve([ + { + id: "app1", + name: "App 1", + description: "Desc", + domain: "app1.com", + role: "Viewer", + }, + ]); + } + return Promise.resolve([]); + }; + + try { + const res = await app.request("/dashboard", { + method: "GET", + headers: { Cookie: "session_id=valid_session" }, + }); + assertEquals(res.status, 200); + const text = await res.text(); + assert(text.includes("App 1")); + assert(!text.includes("App 2")); + assert(!text.includes("Admin Console")); // Layout link should be missing + } finally { + sqlWrapper.sql = originalSql; + valkeyStub.restore(); + } + }); +}); + +Deno.test("Cookie Shadowing & Multi-Cookie Iteration", async (t) => { + await t.step( + "GET /dashboard authenticates when fresh session is shadowed by stale host-only cookie", + async () => { + const valkeyStub = stub(valkey, "get", (key: any) => { + if (String(key) === "fresh_wildcard_session") { + return Promise.resolve( + JSON.stringify({ uuid: "user-uuid", username: "tylerg" }), + ); + } + return Promise.resolve(null); + }); + + const originalSql = sqlWrapper.sql; + sqlWrapper.sql = () => Promise.resolve([]); + + try { + const res = await app.request("/dashboard", { + method: "GET", + headers: { + // Android Chrome sends stale host cookie first, then wildcard cookie + Cookie: + "session_id=stale_host_cookie; session_id=fresh_wildcard_session", + }, + }); + assertEquals(res.status, 200); + const text = await res.text(); + assert(text.includes("Application Launchpad")); + + // Verify Set-Cookie header attempts to delete the host-only cookie + const setCookieHeader = res.headers.get("set-cookie") || ""; + assert( + setCookieHeader.includes("session_id=") && + (setCookieHeader.includes("Max-Age=0") || + setCookieHeader.includes("Expires=")), + ); + } finally { + sqlWrapper.sql = originalSql; + valkeyStub.restore(); + } + }, + ); +}); + +Deno.test("Ephemeral 1-Click Magic Link Redemption (/pass)", async (t) => { + await t.step("GET /pass with missing token redirects to login", async () => { + const res = await app.request("/pass", { method: "GET" }); + assertEquals(res.status, 302); + assertEquals( + res.headers.get("location"), + "/login?error=invalid_or_expired_pass", + ); + }); + + await t.step("GET /pass with invalid token redirects to login", async () => { + const valkeyStub = stub(valkey, "get", () => Promise.resolve(null)); + const originalSql = sqlWrapper.sql; + sqlWrapper.sql = (() => Promise.resolve([])) as any; + try { + const res = await app.request("/pass?token=invalid", { method: "GET" }); + assertEquals(res.status, 302); + assertEquals( + res.headers.get("location"), + "/login?error=invalid_or_expired_pass", + ); + } finally { + sqlWrapper.sql = originalSql; + valkeyStub.restore(); + } + }); + + await t.step( + "GET /pass with valid token sets cookies and redirects to app domain", + async () => { + const sessionToken = "ay_sess_valid_app"; + const valkeyGetStub = stub(valkey, "get", (key: any) => { + if (String(key) === sessionToken) { + return Promise.resolve(JSON.stringify({ + uuid: "user-uuid", + username: "testuser", + customScopes: ["app:ed-droid"], + })); + } + return Promise.resolve(null); + }); + const valkeyTtlStub = stub(valkey, "ttl", () => Promise.resolve(3600)); + + const originalSql = sqlWrapper.sql; + sqlWrapper.sql = ((strings: any, ..._values: any[]) => { + const query = Array.isArray(strings) + ? strings.join("?") + : String(strings); + if (query.includes("SELECT domain FROM apps WHERE name =")) { + return Promise.resolve([{ domain: "ed-droid.atyg.org" }]); + } + return Promise.resolve([]); + }) as any; + + try { + const res = await app.request(`/pass?token=${sessionToken}`, { + method: "GET", + }); + assertEquals(res.status, 302); + assertEquals(res.headers.get("location"), "https://ed-droid.atyg.org"); + + const cookies = res.headers.get("set-cookie"); + assertExists(cookies); + assert(cookies.includes(`session_id=${sessionToken};`)); + } finally { + sqlWrapper.sql = originalSql; + valkeyGetStub.restore(); + valkeyTtlStub.restore(); + } + }, + ); + + await t.step( + "GET /pass with valid token defaults to /dashboard if no app scope", + async () => { + const sessionToken = "ay_sess_valid_dashboard"; + const valkeyGetStub = stub(valkey, "get", (key: any) => { + if (String(key) === sessionToken) { + return Promise.resolve(JSON.stringify({ + uuid: "user-uuid", + username: "testuser", + customScopes: ["read:audit"], + })); + } + return Promise.resolve(null); + }); + const valkeyTtlStub = stub(valkey, "ttl", () => Promise.resolve(3600)); + + try { + const res = await app.request(`/pass?token=${sessionToken}`, { + method: "GET", + }); + assertEquals(res.status, 302); + assertEquals(res.headers.get("location"), "/dashboard"); + } finally { + valkeyGetStub.restore(); + valkeyTtlStub.restore(); + } + }, + ); +}); diff --git a/server/tests/events.test.ts b/server/tests/events.test.ts new file mode 100644 index 0000000..8f5f0d0 --- /dev/null +++ b/server/tests/events.test.ts @@ -0,0 +1,297 @@ +import { assert, assertEquals, assertExists } from "jsr:@std/assert"; +import { stub } from "jsr:@std/testing/mock"; +import { app } from "../main.ts"; +import { sqlWrapper } from "../db.ts"; +import { valkey } from "../valkey.ts"; +import { rateLimitWrapper } from "../ratelimit.ts"; + +rateLimitWrapper.checkRateLimit = () => Promise.resolve(true); + +Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => { + await t.step("POST /api/events creates an event pass", async () => { + const valkeyStub = stub(valkey, "get", (key: any) => { + if (String(key) === "admin-session") { + return Promise.resolve( + JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }), + ); + } + return Promise.resolve(null); + }); + + const originalSql = sqlWrapper.sql; + sqlWrapper.sql = ((strings: any, ..._values: any[]) => { + const query = Array.isArray(strings) + ? strings.join("?") + : String(strings); + if (query.includes("INSERT INTO event_passes")) { + return Promise.resolve([{ + id: "event-uuid-1", + slug: "deno-lab", + pin_code: "749-123", + name: "Deno Workshop", + max_seats: 50, + seats_claimed: 0, + lifespan_hours: 3, + }]); + } + return Promise.resolve([]); + }) as any; + + try { + const res = await app.request("/api/events", { + method: "POST", + headers: { + Authorization: "Bearer admin-session", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Deno Workshop", + slug: "deno-lab", + pinCode: "749-123", + maxSeats: 50, + lifespanHours: 3, + }), + }); + + assertEquals(res.status, 200); + const json = await res.json(); + assert(json.success === true); + assertEquals(json.event.slug, "deno-lab"); + assertEquals(json.event.pin_code, "749-123"); + } finally { + sqlWrapper.sql = originalSql; + valkeyStub.restore(); + } + }); + + await t.step( + "POST /api/join redeems PIN / slug and mints guest session", + async () => { + const originalSql = sqlWrapper.sql; + sqlWrapper.sql = ((strings: any, ..._values: any[]) => { + const query = Array.isArray(strings) + ? strings.join("?") + : String(strings); + if (query.includes("UPDATE event_passes")) { + return Promise.resolve([{ + id: "event-uuid-1", + slug: "deno-lab", + pin_code: "749-123", + name: "Deno Workshop", + max_seats: 50, + seats_claimed: 1, + lifespan_hours: 3, + app_id: null, + }]); + } + return Promise.resolve([]); + }) as any; + + const valkeySetexStub = stub( + valkey, + "setex", + () => Promise.resolve("OK" as any), + ); + + try { + const res = await app.request("/api/join", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: "749-123" }), + }); + + assertEquals(res.status, 200); + const json = await res.json(); + assert(json.success === true); + assert(json.token.startsWith("ay_sess_")); + assertEquals(json.username, "guest_deno-lab_1"); + + const cookies = res.headers.get("set-cookie"); + assertExists(cookies); + assert(cookies.includes(`session_id=${json.token};`)); + } finally { + sqlWrapper.sql = originalSql; + valkeySetexStub.restore(); + } + }, + ); + + await t.step( + "GET /join/:slug?format=env returns CLI export string", + async () => { + const originalSql = sqlWrapper.sql; + sqlWrapper.sql = ((strings: any, ..._values: any[]) => { + const query = Array.isArray(strings) + ? strings.join("?") + : String(strings); + if (query.includes("UPDATE event_passes")) { + return Promise.resolve([{ + id: "event-uuid-1", + slug: "deno-lab", + pin_code: "749-123", + name: "Deno Workshop", + max_seats: 50, + seats_claimed: 2, + lifespan_hours: 3, + app_id: null, + }]); + } + return Promise.resolve([]); + }) as any; + + const valkeySetexStub = stub( + valkey, + "setex", + () => Promise.resolve("OK" as any), + ); + + try { + const res = await app.request("/join/deno-lab?format=env", { + method: "GET", + }); + + assertEquals(res.status, 200); + const text = await res.text(); + assert(text.includes('export AUTH_YES_TOKEN="ay_sess_')); + assert(text.includes('export AUTH_YES_USER="guest_deno-lab_2"')); + } finally { + sqlWrapper.sql = originalSql; + valkeySetexStub.restore(); + } + }, + ); + + await t.step( + "POST /api/events/:id/end master kill switch revokes sessions", + async () => { + const valkeyGetStub = stub(valkey, "get", (key: any) => { + if (String(key) === "admin-session") { + return Promise.resolve( + JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }), + ); + } + return Promise.resolve(null); + }); + + const originalSql = sqlWrapper.sql; + let updateEventCalled = false; + let deleteSessionsCalled = false; + + sqlWrapper.sql = ((strings: any, ..._values: any[]) => { + const query = Array.isArray(strings) + ? strings.join("?") + : String(strings); + if ( + query.includes("UPDATE event_passes") && + query.includes("is_active = FALSE") + ) { + updateEventCalled = true; + return Promise.resolve([{ slug: "deno-lab" }]); + } + if (query.includes("DELETE FROM sessions")) { + deleteSessionsCalled = true; + return Promise.resolve([{ id: "sess-1" }, { id: "sess-2" }]); + } + return Promise.resolve([]); + }) as any; + + const valkeyDelStub = stub( + valkey, + "del", + () => Promise.resolve(2 as any), + ); + + try { + const res = await app.request("/api/events/evt-123/end", { + method: "POST", + headers: { + Authorization: "Bearer admin-session", + }, + }); + + assertEquals(res.status, 200); + const json = await res.json(); + assert(json.success === true); + assertEquals(json.revokedCount, 2); + assert(updateEventCalled); + assert(deleteSessionsCalled); + } finally { + sqlWrapper.sql = originalSql; + valkeyGetStub.restore(); + valkeyDelStub.restore(); + } + }, + ); + + await t.step( + "POST /api/events/:id/extend adds time to event and sessions", + async () => { + const valkeyGetStub = stub(valkey, "get", (key: any) => { + if (String(key) === "admin-session") { + return Promise.resolve( + JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }), + ); + } + return Promise.resolve(null); + }); + + const futureDate = new Date(Date.now() + 7200000).toISOString(); + const originalSql = sqlWrapper.sql; + let updateEventCalled = false; + let updateSessionsCalled = false; + + sqlWrapper.sql = ((strings: any, ..._values: any[]) => { + const query = Array.isArray(strings) + ? strings.join("?") + : String(strings); + if ( + query.includes("UPDATE event_passes") && + query.includes("expires_at = expires_at + interval") + ) { + updateEventCalled = true; + return Promise.resolve([{ + slug: "deno-lab", + expires_at: futureDate, + }]); + } + if ( + query.includes("UPDATE sessions") && + query.includes("expires_at = expires_at + interval") + ) { + updateSessionsCalled = true; + return Promise.resolve([{ id: "sess-1" }, { id: "sess-2" }]); + } + return Promise.resolve([]); + }) as any; + + let expireCallCount = 0; + const valkeyExpireStub = stub(valkey, "expire", () => { + expireCallCount++; + return Promise.resolve(1 as any); + }); + + try { + const res = await app.request("/api/events/evt-123/extend", { + method: "POST", + headers: { + Authorization: "Bearer admin-session", + "Content-Type": "application/json", + }, + body: JSON.stringify({ extendHours: 2 }), + }); + + assertEquals(res.status, 200); + const json = await res.json(); + assert(json.success === true); + assertEquals(json.expiresAt, futureDate); + assert(updateEventCalled); + assert(updateSessionsCalled); + assertEquals(expireCallCount, 2); + } finally { + sqlWrapper.sql = originalSql; + valkeyGetStub.restore(); + valkeyExpireStub.restore(); + } + }, + ); +}); diff --git a/server/tests/forward_auth.test.ts b/server/tests/forward_auth.test.ts new file mode 100644 index 0000000..76a0e1b --- /dev/null +++ b/server/tests/forward_auth.test.ts @@ -0,0 +1,285 @@ +import { assertEquals, assertExists } from "jsr:@std/assert"; +import { stub } from "jsr:@std/testing/mock"; +import { app } from "../main.ts"; +import { sqlWrapper } from "../db.ts"; +import { valkey } from "../valkey.ts"; +import { rateLimitWrapper } from "../ratelimit.ts"; + +const originalSql = sqlWrapper.sql; + +function setMockSql(mockImpl: () => Promise) { + sqlWrapper.sql = mockImpl as any; +} + +function restoreMockSql() { + sqlWrapper.sql = originalSql; +} + +rateLimitWrapper.checkRateLimit = () => Promise.resolve(true); + +Deno.test("Tier 1 & 2: GET /api/forward-auth - Valid session", async () => { + const mockUser = { + id: "user-id-1", + username: "alice", + account_status: "active", + }; + + setMockSql(() => Promise.resolve([mockUser])); + + let queryCount = 0; + + setMockSql(() => { + queryCount++; + if (queryCount === 1) return Promise.resolve([mockUser]); // User lookup + return Promise.resolve([]); // isGlobalAdmin lookup + }); + + const valkeyStub = stub(valkey, "get", (key: any) => { + const k = String(key); + if (k.startsWith("auth:app_by_host:")) { + return Promise.resolve( + JSON.stringify({ id: "app-id-1", name: "test-app" }), + ); + } + if (k.startsWith("auth:grants:")) { + return Promise.resolve("viewer"); + } + return Promise.resolve( + JSON.stringify({ uuid: "user-id-1", username: "alice" }), + ); + }); + + const req = new Request("http://localhost/api/forward-auth", { + headers: { + Cookie: "session_id=mock-session-id", + "X-Forwarded-Host": "test.app.local", + }, + }); + + const res = await app.request(req); + assertEquals(res.status, 200); + assertEquals(res.headers.get("X-Forwarded-User"), "alice"); + assertEquals(res.headers.get("X-Forwarded-User-Id"), "user-id-1"); + assertEquals(res.headers.get("X-Forwarded-Scopes"), "viewer"); + assertEquals(res.headers.get("X-Forwarded-App-Id"), "app-id-1"); + + restoreMockSql(); + valkeyStub.restore(); +}); + +Deno.test("Tier 1 & 2: GET /api/forward-auth - Missing session (API request)", async () => { + const req = new Request("http://localhost/api/forward-auth", { + headers: { "X-Forwarded-Host": "test.app.local" }, + }); + + const valkeyStub = stub(valkey, "get", (key: any) => { + const k = String(key); + if (k.startsWith("auth:app_by_host:")) { + return Promise.resolve( + JSON.stringify({ id: "app-id-1", name: "test-app" }), + ); + } + return Promise.resolve(null); + }); + + const res = await app.request(req); + assertEquals(res.status, 401); + valkeyStub.restore(); +}); + +Deno.test("Tier 1 & 2: GET /api/forward-auth - Missing session (Browser request with text/html)", async () => { + const req = new Request("http://localhost/api/forward-auth", { + headers: { + "X-Forwarded-Host": "ed-droid.atyg.org", + "X-Forwarded-Uri": "/control-panel", + "X-Forwarded-Proto": "https", + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + }, + }); + + const valkeyStub = stub(valkey, "get", (key: any) => { + const k = String(key); + if (k.startsWith("auth:app_by_host:")) { + return Promise.resolve( + JSON.stringify({ id: "app-id-1", name: "ed-droid" }), + ); + } + return Promise.resolve(null); + }); + + const res = await app.request(req); + assertEquals(res.status, 302); + const location = res.headers.get("Location"); + assertExists(location); + assertEquals( + location?.includes( + "login?redirect=https%3A%2F%2Fed-droid.atyg.org%2Fcontrol-panel", + ), + true, + ); + valkeyStub.restore(); +}); + +Deno.test("Tier 1 & 2: GET /api/forward-auth - Expired session", async () => { + const valkeyStub = stub(valkey, "get", (key: any) => { + const k = String(key); + if (k.startsWith("auth:app_by_host:")) { + return Promise.resolve( + JSON.stringify({ id: "app-id-1", name: "test-app" }), + ); + } + return Promise.resolve(null); + }); + + const req = new Request("http://localhost/api/forward-auth", { + headers: { + Cookie: "session_id=expired-session-id", + "X-Forwarded-Host": "test.app.local", + }, + }); + const res = await app.request(req); + assertEquals(res.status, 401); + valkeyStub.restore(); +}); + +Deno.test("Tier 1 & 2: GET /api/forward-auth - Suspended account", async () => { + const mockUser = { + id: "user-id-1", + username: "alice", + account_status: "suspended", + }; + + setMockSql(() => Promise.resolve([mockUser])); + + const valkeyStub = stub(valkey, "get", (key: any) => { + const k = String(key); + if (k.startsWith("auth:app_by_host:")) { + return Promise.resolve( + JSON.stringify({ id: "app-id-1", name: "test-app" }), + ); + } + return Promise.resolve( + JSON.stringify({ uuid: "user-id-1", username: "alice" }), + ); + }); + + const req = new Request("http://localhost/api/forward-auth", { + headers: { + Cookie: "session_id=mock-session-id", + "X-Forwarded-Host": "test.app.local", + }, + }); + const res = await app.request(req); + assertEquals(res.status, 403); + assertEquals(await res.text(), "Forbidden: Account inactive"); + + restoreMockSql(); + valkeyStub.restore(); +}); + +Deno.test("Cookie Domain Scoping - getCookieDomain derives wildcard parent domain", async () => { + const { getCookieDomain } = await import("../auth-session.ts"); + + assertEquals(getCookieDomain("auth.atyg.org"), ".atyg.org"); + assertEquals(getCookieDomain("ed-droid.atyg.org"), ".atyg.org"); + assertEquals(getCookieDomain("atyg.org"), ".atyg.org"); + assertEquals(getCookieDomain("localhost"), undefined); +}); + +Deno.test("Tier 1 & 2: GET /api/forward-auth - Unregistered domain (API)", async () => { + // Override sql to return no app + sqlWrapper.sql = (async () => []) as any; + const req = new Request("http://localhost/api/forward-auth", { + headers: { + "X-Forwarded-Host": "unknown.atyg.org", + }, + }); + const res = await app.fetch(req); + assertEquals(res.status, 403); + const data = await res.json(); + assertEquals(data.error, "Application not registered"); + restoreMockSql(); +}); + +Deno.test("Tier 1 & 2: GET /api/forward-auth - Unregistered domain (Browser)", async () => { + // Override sql to return no app + sqlWrapper.sql = (async () => []) as any; + const req = new Request("http://localhost/api/forward-auth", { + headers: { + "X-Forwarded-Host": "unknown.atyg.org", + "Accept": "text/html", + }, + }); + const res = await app.fetch(req); + assertEquals(res.status, 302); + const location = res.headers.get("Location") || ""; + assertEquals( + true, + location.includes("/errors/unregistered?host=unknown.atyg.org"), + ); + restoreMockSql(); +}); + +Deno.test("Tier 1 & 2: GET /api/forward-auth - Dynamic Bypass (is_public)", async () => { + // Override sql to return public app + sqlWrapper.sql = (async (strings: any) => { + if (strings[0].includes("FROM apps")) { + return [{ + id: "public-app-id", + name: "Public App", + is_public: true, + }]; + } + return []; + }) as any; + + const req = new Request("http://localhost/api/forward-auth", { + headers: { + "X-Forwarded-Host": "public.atyg.org", + }, + }); + const res = await app.fetch(req); + assertEquals(res.status, 200); + assertEquals(res.headers.get("X-Forwarded-App-Id"), "public-app-id"); + restoreMockSql(); +}); + +Deno.test("Tier 1 & 2: GET /api/forward-auth - Dynamic Bypass (bypass_paths)", async () => { + // Override sql to return app with bypass path + sqlWrapper.sql = (async (strings: any) => { + if (strings[0].includes("FROM apps")) { + return [{ + id: "bypass-app-id", + name: "Bypass App", + bypass_paths: ["/api/public/*"], + }]; + } + return []; + }) as any; + + const req = new Request("http://localhost/api/forward-auth", { + headers: { + "X-Forwarded-Host": "bypass.atyg.org", + "X-Forwarded-Uri": "/api/public/status", + }, + }); + const res = await app.fetch(req); + assertEquals(res.status, 200); + restoreMockSql(); +}); + +Deno.test("Tier 1 & 2: POST /api/guests/sandbox - Creates guest session", async () => { + const { valkey } = await import("../valkey.ts"); + // Mock valkey.setex to prevent connection errors during tests + valkey.setex = async () => "OK" as any; + + const req = new Request("http://localhost/api/guests/sandbox", { + method: "POST", + }); + const res = await app.fetch(req); + assertEquals(res.status, 200); + const data = await res.json(); + assertEquals(data.success, true); + assertEquals(true, !!data.sessionId); + assertEquals(true, !!data.guestUuid); +}); diff --git a/server/tests/rpc.test.ts b/server/tests/rpc.test.ts new file mode 100644 index 0000000..00e8eab --- /dev/null +++ b/server/tests/rpc.test.ts @@ -0,0 +1,155 @@ +import { assertEquals } from "jsr:@std/assert"; +import { stub } from "jsr:@std/testing/mock"; +import { app } from "../main.ts"; +import { sqlWrapper } from "../db.ts"; +import { valkey } from "../valkey.ts"; +import { spireWrapper } from "../spire_ffi.ts"; +import { auditWrapper } from "../audit.ts"; + +const originalSql = sqlWrapper.sql; + +function setMockSql(mockImpl: () => Promise) { + sqlWrapper.sql = mockImpl as any; +} + +function restoreMockSql() { + sqlWrapper.sql = originalSql; +} + +Deno.test("Tier 3: ValidateSession ConnectRPC - Default-Deny", async () => { + const originalExtract = spireWrapper.extractSpiffeIdFromCert; + spireWrapper.extractSpiffeIdFromCert = () => "spiffe://system.local/ed-droid"; + + let queryCount = 0; + setMockSql(() => { + queryCount++; + if (queryCount === 1) { + return Promise.resolve([{ id: "app-1" }]); + } else { + return Promise.resolve([]); + } + }); + + // Re-define valkey stub + const valkeyStub = stub(valkey, "get", () => { + return Promise.resolve( + JSON.stringify({ uuid: "user-1", username: "alice" }), + ); + }); + + let auditCalled = false; + const originalAudit = auditWrapper.auditLog; + auditWrapper.auditLog = (..._args: any[]) => { + auditCalled = true; + }; + + const req = new Request( + "http://localhost/auth.v1.AuthService/ValidateSession", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-peer-cert": "cert-data", + }, + // The Connect protocol expects token under a specific format or we can just send it, + // actually our handler looks at `req.token` via connect protocol, which translates to the json body for unary requests in Connect HTTP POST. + // However, the `ValidateSession` requires an argument structured as `{ "token": "..." }`. + body: JSON.stringify({ token: "some-session-token" }), + }, + ); + + const res = await app.request(req); + const data = await res.json(); + + // The data structure from universal handler might wrap the response. + // Connect responses might look like just the json object. + // We'll check if valid is false. + + if (data.valid !== undefined) { + assertEquals(data.valid, false); + } else { + // If not direct format, just ensure valid is falsy or check for error + assertEquals(!!data.valid, false); + } + + assertEquals(auditCalled, true); + + spireWrapper.extractSpiffeIdFromCert = originalExtract; + restoreMockSql(); + valkeyStub.restore(); + auditWrapper.auditLog = originalAudit; +}); + +Deno.test("Tier 3: ValidateSession ConnectRPC - Valid RBAC Grant", async () => { + const originalExtract = spireWrapper.extractSpiffeIdFromCert; + spireWrapper.extractSpiffeIdFromCert = () => "spiffe://system.local/ed-droid"; + + let queryCount = 0; + setMockSql(() => { + queryCount++; + if (queryCount === 1) { + return Promise.resolve([{ id: "app-1" }]); + } else { + return Promise.resolve([{ role: "viewer" }]); + } + }); + + const valkeyStub = stub(valkey, "get", () => { + return Promise.resolve( + JSON.stringify({ uuid: "user-1", username: "alice" }), + ); + }); + + const req = new Request( + "http://localhost/auth.v1.AuthService/ValidateSession", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-peer-cert": "cert-data", + }, + body: JSON.stringify({ token: "some-session-token" }), + }, + ); + + const res = await app.request(req); + const data = await res.json(); + + if (data.valid !== undefined) { + assertEquals(data.valid, true); + assertEquals(data.uuid, "user-1"); + assertEquals(data.scopes[0], "viewer"); + } + + spireWrapper.extractSpiffeIdFromCert = originalExtract; + restoreMockSql(); + valkeyStub.restore(); +}); + +Deno.test("Tier 3: ValidateSession ConnectRPC - SPIFFE Attestation Failure", async () => { + const originalExtract = spireWrapper.extractSpiffeIdFromCert; + spireWrapper.extractSpiffeIdFromCert = () => null; + + const req = new Request( + "http://localhost/auth.v1.AuthService/ValidateSession", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-peer-cert": "invalid-cert", + }, + body: JSON.stringify({ token: "some-session-token" }), + }, + ); + + const res = await app.request(req); + const data = await res.json(); + + if (data.valid !== undefined) { + assertEquals(data.valid, false); + } else { + assertEquals(!!data.valid, false); + } + + spireWrapper.extractSpiffeIdFromCert = originalExtract; +}); diff --git a/server/tests/scopes.test.ts b/server/tests/scopes.test.ts new file mode 100644 index 0000000..caa01a0 --- /dev/null +++ b/server/tests/scopes.test.ts @@ -0,0 +1,395 @@ +import { assert, assertEquals } from "jsr:@std/assert"; +import { stub } from "jsr:@std/testing/mock"; +import { app } from "../main.ts"; +import { sqlWrapper } from "../db.ts"; +import { valkey } from "../valkey.ts"; +import { rateLimitWrapper } from "../ratelimit.ts"; + +const originalSql = sqlWrapper.sql; + +function setMockSql(mockImpl: () => Promise) { + sqlWrapper.sql = mockImpl as any; +} + +function restoreMockSql() { + sqlWrapper.sql = originalSql; +} + +rateLimitWrapper.checkRateLimit = () => Promise.resolve(true); + +Deno.test("Zero-Trust Scope Guards", async (t) => { + const mockUserId = "user-id-guards"; + + await t.step( + "Delegated session is blocked from mutating sessions without scope", + async () => { + const mockSessionId = "ay_sess_delegated_123"; + + setMockSql( + (async (strings: any, ..._values: any[]) => { + const q = Array.isArray(strings) + ? strings.join("?") + : String(strings); + if (q.includes("SELECT id, is_agent FROM sessions")) { + return [{ id: "other_session", is_agent: false }]; + } + return []; + }) as any, + ); + + if ((valkey as any).get.restore) { + (valkey as any).get.restore(); + } + const valkeyStub = stub( + valkey, + "get", + () => + Promise.resolve( + JSON.stringify({ + uuid: mockUserId, + username: "agent-user", + isAgent: true, + customScopes: ["read:data"], + }), + ), + ); + + try { + const req = new Request( + "http://localhost/api/sessions/other_session/extend", + { + method: "POST", + headers: { + "Authorization": `Bearer ${mockSessionId}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ extendHours: 2 }), + }, + ); + const res = await app.fetch(req); + assertEquals(res.status, 403); + const body = await res.json(); + assertEquals(body.error, "Forbidden: Insufficient scopes"); + } finally { + valkeyStub.restore(); + } + }, + ); + + await t.step("Delegated session can self-revoke", async () => { + const mockSessionId = "ay_sess_delegated_123"; + + setMockSql( + (async (strings: any, ..._values: any[]) => { + const q = Array.isArray(strings) ? strings.join("?") : String(strings); + if (q.includes("SELECT id FROM sessions WHERE id =")) { + return [{ id: mockSessionId }]; + } + if (q.includes("DELETE FROM sessions")) { + return [{ id: mockSessionId }]; + } + return []; + }) as any, + ); + + if ((valkey as any).get.restore) { + (valkey as any).get.restore(); + } + const valkeyStub = stub( + valkey, + "get", + () => + Promise.resolve( + JSON.stringify({ + uuid: mockUserId, + username: "agent-user", + isAgent: true, + customScopes: ["read:data"], + }), + ), + ); + + const delStub = stub(valkey, "del", () => Promise.resolve(1 as any)); + + try { + const req = new Request( + `http://localhost/api/sessions/${mockSessionId}`, + { + method: "DELETE", + headers: { "Authorization": `Bearer ${mockSessionId}` }, + }, + ); + const res = await app.fetch(req); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.success, true); + } finally { + valkeyStub.restore(); + delStub.restore(); + } + }); + + await t.step( + "Delegated session blocked from UI /admin redirect", + async () => { + const mockSessionId = "ay_sess_delegated_123"; + + setMockSql( + (async (strings: any, ..._values: any[]) => { + const q = Array.isArray(strings) + ? strings.join("?") + : String(strings); + if (q.includes("SELECT g.id")) { + return [{ id: "admin-grant-id" }]; + } + return []; + }) as any, + ); + + if ((valkey as any).get.restore) { + (valkey as any).get.restore(); + } + const valkeyStub = stub( + valkey, + "get", + () => + Promise.resolve( + JSON.stringify({ + uuid: mockUserId, + username: "agent-user", + isAgent: true, + customScopes: ["read:data"], + }), + ), + ); + + try { + const req = new Request("http://localhost/admin/users", { + headers: { "Cookie": `session_id=${mockSessionId}` }, + }); + const res = await app.fetch(req); + assertEquals(res.status, 302); + assertEquals(res.headers.get("location"), "/dashboard"); + } finally { + valkeyStub.restore(); + } + }, + ); + + await t.step( + "Delegated session without admin scope blocked from POST /api/sessions/delegate", + async () => { + const mockSessionId = "ay_sess_delegated_123"; + + if ((valkey as any).get.restore) { + (valkey as any).get.restore(); + } + const valkeyStub = stub( + valkey, + "get", + () => + Promise.resolve( + JSON.stringify({ + uuid: mockUserId, + username: "agent-user", + isAgent: true, + customScopes: ["write:sessions"], + }), + ), + ); + + try { + const req = new Request("http://localhost/api/sessions/delegate", { + method: "POST", + headers: { + "Authorization": `Bearer ${mockSessionId}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ label: "Sub Agent" }), + }); + const res = await app.fetch(req); + assertEquals(res.status, 403); + const body = await res.json(); + assertEquals(body.error, "Forbidden: Insufficient scopes"); + } finally { + valkeyStub.restore(); + } + }, + ); + + await t.step( + "Delegated session without write:events blocked from POST /api/events", + async () => { + const mockSessionId = "ay_sess_delegated_123"; + + if ((valkey as any).get.restore) { + (valkey as any).get.restore(); + } + const valkeyStub = stub( + valkey, + "get", + () => + Promise.resolve( + JSON.stringify({ + uuid: mockUserId, + username: "agent-user", + isAgent: true, + customScopes: ["read:data"], + }), + ), + ); + + try { + const req = new Request("http://localhost/api/events", { + method: "POST", + headers: { + "Authorization": `Bearer ${mockSessionId}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: "Hacking Workshop" }), + }); + const res = await app.fetch(req); + assertEquals(res.status, 403); + const body = await res.json(); + assertEquals(body.error, "Forbidden: Insufficient scopes"); + } finally { + valkeyStub.restore(); + } + }, + ); + + restoreMockSql(); +}); + +Deno.test("Agent Session Delegation & Scoped Permissions", async (t) => { + await t.step( + "POST /api/sessions/delegate - Mints child session with scopes", + async () => { + const valkeyStub = stub(valkey, "get", (key: any) => { + if (String(key) === "admin-session") { + return Promise.resolve( + JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }), + ); + } + return Promise.resolve(null); + }); + const valkeySetStub = stub(valkey, "set", () => Promise.resolve("OK")); + + const originalSql = sqlWrapper.sql; + sqlWrapper.sql = ((_query: any, ..._args: any[]) => { + return Promise.resolve([{ id: "mock-id" }]); + }) as any; + + try { + const res = await app.request("/api/sessions/delegate", { + method: "POST", + headers: { + Authorization: "Bearer admin-session", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + label: "Antigravity Assistant", + lifespanHours: 12, + mode: "read_only", + }), + }); + + assertEquals(res.status, 200); + const json = await res.json(); + assert(json.success === true); + assert(json.sessionId.startsWith("ay_sess_")); + assertEquals(json.label, "Antigravity Assistant"); + assert(json.scopes.includes("read:audit")); + } finally { + sqlWrapper.sql = originalSql; + valkeyStub.restore(); + valkeySetStub.restore(); + } + }, + ); + + await t.step( + "PUT /api/sessions/:id/scopes - Updates custom scopes", + async () => { + const valkeyStub = stub(valkey, "get", (key: any) => { + if (String(key) === "admin-session") { + return Promise.resolve( + JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }), + ); + } + return Promise.resolve(null); + }); + + const originalSql = sqlWrapper.sql; + sqlWrapper.sql = ((_query: any, ..._args: any[]) => { + return Promise.resolve([{ id: "ay_sess_123", is_agent: true }]); + }) as any; + + try { + const res = await app.request("/api/sessions/ay_sess_123/scopes", { + method: "PUT", + headers: { + Authorization: "Bearer admin-session", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + customScopes: ["read:audit", "app:ed-droid"], + }), + }); + + assertEquals(res.status, 200); + const json = await res.json(); + assert(json.success === true); + assertEquals(json.scopes, ["read:audit", "app:ed-droid"]); + } finally { + sqlWrapper.sql = originalSql; + valkeyStub.restore(); + } + }, + ); + + await t.step( + "POST /api/sessions/:id/extend - Extends session TTL", + async () => { + const valkeyStub = stub(valkey, "get", (key: any) => { + if (String(key) === "admin-session") { + return Promise.resolve( + JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }), + ); + } + return Promise.resolve(null); + }); + const valkeyExpireStub = stub(valkey, "expire", () => Promise.resolve(1)); + + const originalSql = sqlWrapper.sql; + sqlWrapper.sql = ((_query: any, ..._args: any[]) => { + return Promise.resolve([{ + id: "ay_sess_123", + expires_at: new Date().toISOString(), + }]); + }) as any; + + try { + const res = await app.request("/api/sessions/ay_sess_123/extend", { + method: "POST", + headers: { + Authorization: "Bearer admin-session", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + extendHours: 2, + }), + }); + + assertEquals(res.status, 200); + const json = await res.json(); + assert(json.success === true); + assert(json.newExpiresAt); + } finally { + sqlWrapper.sql = originalSql; + valkeyStub.restore(); + valkeyExpireStub.restore(); + } + }, + ); +}); diff --git a/tasks/new/2026-0825.01.jul.story.arch.monolith-decomposition-roadmap-1845.ph5.md b/tasks/complete/2026-0825.01.jul.story.arch.monolith-decomposition-roadmap-1845.ph5.md similarity index 100% rename from tasks/new/2026-0825.01.jul.story.arch.monolith-decomposition-roadmap-1845.ph5.md rename to tasks/complete/2026-0825.01.jul.story.arch.monolith-decomposition-roadmap-1845.ph5.md