import { afterEach, describe, expect, it } from "vitest"; import type { FastifyInstance } from "fastify"; import { buildApp } from "../src/app.js"; import type { AppConfig } from "../src/config.js"; import { CSRF_COOKIE, SESSION_COOKIE } from "../src/security.js"; const apps: FastifyInstance[] = []; function testConfig(): AppConfig { return { nodeEnv: "test", host: "127.0.0.1", port: 8787, databasePath: ":memory:", sessionTtlMs: 60_000, cookieSecure: false, allowedOrigins: new Set(["http://mg4pi.local:8787"]), updateStatusPath: `/tmp/pi-car-companion-test-${process.pid}-missing-update.json`, versionFile: `/tmp/pi-car-companion-test-${process.pid}-missing-revision` }; } async function createApp(): Promise { const app = await buildApp(testConfig()); apps.push(app); return app; } async function csrf(app: FastifyInstance): Promise { const response = await app.inject({ method: "GET", url: "/api/auth/state" }); expect(response.statusCode).toBe(200); return response.json<{ csrfToken: string }>().csrfToken; } function mutationHeaders(token: string) { return { cookie: `${CSRF_COOKIE}=${token}`, "x-csrf-token": token, origin: "http://mg4pi.local:8787" }; } async function setup(app: FastifyInstance, token: string) { return app.inject({ method: "POST", url: "/api/auth/setup", headers: mutationHeaders(token), payload: { username: "owner", password: "correct-horse-battery-staple" } }); } afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); }); describe("authentication boundary", () => { it("creates exactly one initial administrator", async () => { const app = await createApp(); const token = await csrf(app); expect((await setup(app, token)).statusCode).toBe(201); const second = await app.inject({ method: "POST", url: "/api/auth/setup", headers: mutationHeaders(token), payload: { username: "second", password: "another-secure-password" } }); expect(second.statusCode).toBe(409); expect(second.json()).toMatchObject({ error: { code: "SETUP_COMPLETE" } }); }); it("rejects state changes without a matching CSRF token", async () => { const app = await createApp(); const response = await app.inject({ method: "POST", url: "/api/auth/setup", payload: { username: "owner", password: "correct-horse-battery-staple" } }); expect(response.statusCode).toBe(403); expect(response.json()).toMatchObject({ error: { code: "CSRF_REJECTED" } }); }); it("rejects an untrusted browser origin", async () => { const app = await createApp(); const token = await csrf(app); const response = await app.inject({ method: "POST", url: "/api/auth/setup", headers: { ...mutationHeaders(token), origin: "https://attacker.example" }, payload: { username: "owner", password: "correct-horse-battery-staple" } }); expect(response.statusCode).toBe(403); expect(response.json()).toMatchObject({ error: { code: "ORIGIN_REJECTED" } }); }); it("accepts a same-host origin for dynamic LAN addresses", async () => { const app = await createApp(); const token = await csrf(app); const response = await app.inject({ method: "POST", url: "/api/auth/setup", headers: { ...mutationHeaders(token), host: "192.168.4.20:8787", origin: "http://192.168.4.20:8787" }, payload: { username: "owner", password: "correct-horse-battery-staple" } }); expect(response.statusCode).toBe(201); }); it("requires a valid session for system status", async () => { const app = await createApp(); expect((await app.inject({ method: "GET", url: "/api/status" })).statusCode).toBe(401); const token = await csrf(app); await setup(app, token); const login = await app.inject({ method: "POST", url: "/api/auth/login", headers: mutationHeaders(token), payload: { username: "owner", password: "correct-horse-battery-staple" } }); expect(login.statusCode).toBe(200); const sessionCookie = login.cookies.find((cookie) => cookie.name === SESSION_COOKIE); expect(sessionCookie).toBeDefined(); const status = await app.inject({ method: "GET", url: "/api/status", headers: { cookie: `${SESSION_COOKIE}=${sessionCookie?.value ?? ""}` } }); expect(status.statusCode, status.body).toBe(200); expect(status.json()).toMatchObject({ service: { state: "healthy" } }); const update = await app.inject({ method: "POST", url: "/api/system/update", headers: { ...mutationHeaders(token), cookie: `${CSRF_COOKIE}=${token}; ${SESSION_COOKIE}=${sessionCookie?.value ?? ""}` } }); expect(update.statusCode).toBe(503); expect(update.json()).toMatchObject({ error: { code: "UPDATES_UNAVAILABLE" } }); }); it("does not expose update state without authentication", async () => { const app = await createApp(); const response = await app.inject({ method: "GET", url: "/api/system/update" }); expect(response.statusCode).toBe(401); }); });