import { test, expect, vi } from "vitest"; import Fastify from "fastify"; import { authenticateSession, authPreHandler, getPrincipal } from "../src/auth/auth.js"; import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expandLocalHome, localPrincipal, upstreamPrincipal } from "../src/auth/principal.js"; import { buildApp } from "../src/app.js"; import { loadConfig } from "../src/config.js"; test("server smoke trusted claims transform through nginx to a non-admin principal", () => { const smoke = readFileSync("../scripts/unified-deployment-smoke.sh", "utf8"); const nginx = readFileSync("../docker/nginx.conf.template", "utf8"); const helper = smoke.match(/task13_server_auth_headers\(\) \{([\s\S]*?)\n\}/)?.[1] ?? ""; const trusted = Object.fromEntries( [...helper.matchAll(/-H '([^:']+): ([^']+)'/g)].map((match) => [match[1].toLowerCase(), match[2]]), ); const normalized: Record = {}; for (const [header, suffix] of [ ["x-thoth-principal-issuer", "principal_issuer"], ["x-thoth-principal-subject", "principal_subject"], ["x-thoth-principal-display-name", "principal_display_name"], ["x-thoth-is-admin", "is_admin"], ]) { expect(nginx).toContain(`$http_x_thoth_trusted_${suffix}`); const value = trusted[`x-thoth-trusted-${header.slice("x-thoth-".length)}`]; if (value !== undefined) normalized[header] = value; } expect(upstreamPrincipal(normalized)).toEqual({ issuer: "task13-proxy", subject: "task13-user", displayName: "Task 13 User", roles: ["user"], permissions: ["session.use"], isAdmin: false, }); }); test("local mode resolves a stable local principal", async () => { const app = Fastify(); app.addHook("preHandler", authPreHandler("none")); app.get("/me", async (req) => getPrincipal(req)); expect((await app.inject({ method: "GET", url: "/me" })).json()).toMatchObject({ issuer: "local", subject: expect.any(String), roles: ["admin"], permissions: [ "session.use", "session.read_all", "session.manage_all", "settings.manage", "workspace.manage", "workspace.secrets.manage", "pi.manage", "auth.diagnostics.read", ], isAdmin: true, }); }); test("mock mode makes a principal from the test header", async () => { const app = Fastify(); app.addHook("preHandler", authPreHandler("mock")); app.get("/me", async (req) => getPrincipal(req)); const res = await app.inject({ method: "GET", url: "/me", headers: { "x-mock-user": "alice" }, }); expect(res.json()).toEqual({ issuer: "mock", subject: "alice", displayName: "alice", roles: ["user"], permissions: ["session.use"], isAdmin: false, }); }); test("upstream mode accepts only normalized proxy principal headers", async () => { const app = Fastify(); app.addHook("preHandler", authPreHandler("upstream")); app.get("/me", async (req) => getPrincipal(req)); expect((await app.inject({ method: "GET", url: "/me" })).statusCode).toBe(401); const authenticated = await app.inject({ method: "GET", url: "/me", headers: { "x-thoth-principal-issuer": "portal", "x-thoth-principal-subject": "42", "x-thoth-principal-display-name": "Alice", "x-thoth-is-admin": "1", "x-authenticated-user": "must-not-be-used", }, }); expect(authenticated.json()).toEqual({ issuer: "portal", subject: "42", displayName: "Alice", roles: ["user", "admin"], permissions: [ "session.use", "session.read_all", "session.manage_all", "settings.manage", "workspace.manage", "workspace.secrets.manage", "pi.manage", "auth.diagnostics.read", ], isAdmin: true, }); }); test("upstream mode rejects legacy client identity headers without proxy principal fields", async () => { const app = Fastify(); app.addHook("preHandler", authPreHandler("upstream")); app.get("/me", async (req) => getPrincipal(req)); for (const headers of [ { "x-authenticated-user": "mallory" }, { "x-mock-user": "mallory" }, { "x-authenticated-user": "mallory", "x-mock-user": "mallory" }, ]) { expect((await app.inject({ method: "GET", url: "/me", headers })).statusCode).toBe(401); } }); test("buildApp exposes safe nullable session metadata for authenticated legacy modes", async () => { const configurations = [ { name: "none", config: loadConfig({ NODE_ENV: "test", AUTH_MODE: "none", THT_HARNESS_DIR: "/tmp/h" }), headers: {}, expected: { issuer: "local", roles: ["admin"] }, }, { name: "mock", config: loadConfig({ NODE_ENV: "test", AUTH_MODE: "mock", THT_HARNESS_DIR: "/tmp/h" }), headers: { "x-mock-user": "legacy-mock" }, expected: { issuer: "mock", subject: "legacy-mock", roles: ["user"] }, }, { name: "upstream", config: loadConfig({ NODE_ENV: "production", AUTH_MODE: "upstream", THT_HARNESS_DIR: "/tmp/h" }), headers: { "x-thoth-principal-issuer": "portal", "x-thoth-principal-subject": "legacy-upstream", "x-thoth-is-admin": "0", }, expected: { issuer: "portal", subject: "legacy-upstream", roles: ["user"] }, }, ]; for (const legacy of configurations) { const app = buildApp(legacy.config, { thtRunner: {} as any, listModels: async () => [] }); try { const response = await app.inject({ method: "GET", url: "/me", headers: legacy.headers }); expect(response.statusCode, legacy.name).toBe(200); expect(response.json()).toMatchObject({ ...legacy.expected, csrfToken: null, session: null, }); } finally { await app.close(); } } }); test("the session boundary exposes only exact health and authentication protocol paths", async () => { const app = Fastify(); app.addHook("preHandler", authenticateSession({ mode: "local", authentication: { current: () => ({ sourcePath: "/private/auth.yaml", revision: "a".repeat(64), value: { version: 1, mode: "local", publicUrl: "http://127.0.0.1:8787", session: { regularTtlSeconds: 43_200, regularIdleSeconds: 7_200, rememberTtlSeconds: 2_592_000, rememberIdleSeconds: 604_800, oidcTtlSeconds: 28_800, }, local: { usersFile: "users.yaml" }, }, }), }, sessionStore: { resolve: async () => undefined } as any, })); app.get("/health", async () => ({ ok: true })); app.get("/auth/config", async () => ({ mode: "local" })); app.get("/healthz", async () => ({ ok: true })); app.get("/auth/configured", async () => ({ mode: "local" })); expect((await app.inject({ method: "GET", url: "/health?probe=1" })).statusCode).toBe(200); expect((await app.inject({ method: "GET", url: "/auth/config?ui=1" })).statusCode).toBe(200); expect((await app.inject({ method: "GET", url: "/healthz" })).statusCode).toBe(401); expect((await app.inject({ method: "GET", url: "/auth/configured" })).statusCode).toBe(401); }); test("the session boundary retains the exact loopback tht maintenance identity in configured auth modes", async () => { const app = Fastify(); app.addHook("preHandler", authenticateSession({ mode: "local" })); app.get("/private", async (request) => getPrincipal(request)); app.post("/private", async (request) => getPrincipal(request)); const headers = { "x-thoth-principal-issuer": "tht", "x-thoth-principal-subject": "tht-maintenance", "x-thoth-principal-display-name": "Tht maintenance", "x-thoth-is-admin": "1", }; for (const method of ["GET", "POST"] as const) { const response = await app.inject({ method, url: "/private", headers, remoteAddress: "127.0.0.1" }); expect(response.statusCode).toBe(200); expect(response.json()).toMatchObject({ issuer: "tht", subject: "tht-maintenance", isAdmin: true }); } }); test("the session boundary rejects tht maintenance headers outside exact loopback provenance", async () => { const app = Fastify(); app.addHook("preHandler", authenticateSession({ mode: "local" })); app.get("/private", async (request) => getPrincipal(request)); const exact = { "x-thoth-principal-issuer": "tht", "x-thoth-principal-subject": "tht-maintenance", "x-thoth-principal-display-name": "Tht maintenance", "x-thoth-is-admin": "1", }; expect((await app.inject({ method: "GET", url: "/private", headers: exact, remoteAddress: "172.30.0.9" })).statusCode).toBe(503); expect((await app.inject({ method: "GET", url: "/private", remoteAddress: "127.0.0.1", headers: { ...exact, "x-thoth-principal-subject": "not-maintenance" }, })).statusCode).toBe(503); }); test("the session boundary touches a valid cookie session through the bounded Task 7 store operation", async () => { const sessions = { resolve: vi.fn(async () => ({ version: 1, issuer: "local", subject: "user-1", method: "local", roles: ["user"], permissions: ["session.use"], userAuthRevision: 1, authConfigRevision: "b".repeat(64), remembered: false, createdAt: "2026-08-16T00:00:00.000Z", lastSeenAt: "2026-08-16T00:00:00.000Z", idleExpiresAt: "2026-08-16T02:00:00.000Z", absoluteExpiresAt: "2026-08-16T12:00:00.000Z", })), touch: vi.fn(async () => {}), }; const app = Fastify(); app.addHook("preHandler", authenticateSession({ mode: "local", authentication: { current: () => ({ sourcePath: "/private/auth.yaml", revision: "b".repeat(64), value: { version: 1, mode: "local", publicUrl: "http://127.0.0.1:8787", session: { regularTtlSeconds: 43_200, regularIdleSeconds: 7_200, rememberTtlSeconds: 2_592_000, rememberIdleSeconds: 604_800, oidcTtlSeconds: 28_800, }, local: { usersFile: "users.yaml" }, }, }), }, sessionStore: sessions as any, })); app.get("/private", async (request) => getPrincipal(request)); const token = "z".repeat(43); expect((await app.inject({ method: "GET", url: "/private", headers: { cookie: `thothii_session=${token}` } })).statusCode).toBe(200); expect(sessions.touch).toHaveBeenCalledWith(token); }); test("local identity expands tilde homes and restores private POSIX permissions", () => { expect(expandLocalHome("~/thoth-test", "/home/tester")).toBe("/home/tester/thoth-test"); expect(expandLocalHome("~", "/home/tester")).toBe("/home/tester"); const home = mkdtempSync(join(tmpdir(), "thoth-principal-")); chmodSync(home, 0o755); const previous = process.env.THT_HOME; process.env.THT_HOME = home; try { localPrincipal(); if (process.platform !== "win32") { expect(statSync(home).mode & 0o777).toBe(0o700); expect(statSync(join(home, "identity.json")).mode & 0o777).toBe(0o600); } } finally { if (previous === undefined) delete process.env.THT_HOME; else process.env.THT_HOME = previous; rmSync(home, { recursive: true, force: true }); } });