import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; import { after, test } from "node:test"; import request from "supertest"; import { buildHelmetOptions, createApp } from "../src/app"; import { cleanupExpiredAuthRows, createResetToken, createVerificationToken, resetPassword, verifyEmailToken, } from "../src/authService"; import { resolveTurnstileBypassToken } from "../src/config"; import { pool } from "../src/db"; import { extractRaceCoverImage, extractRaceCoverImageFromHtml } from "../src/raceCoverImage"; import { hashPassword, normalizeEmail } from "../src/security"; const app = createApp(); let userCounter = 0; after(() => pool.end()); async function authAgent() { userCounter += 1; const email = normalizeEmail(`runner${userCounter}@example.com`); const password = "correct horse battery staple"; const passwordHash = await hashPassword(password); const inserted = await pool.query<{ id: string }>( `INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id`, [email, passwordHash], ); await pool.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()) WHERE id = $1", [ inserted.rows[0].id, ]); const agent = request.agent(app); const login = await agent.post("/api/auth/login").send({ email, password }).expect(200); return { agent, csrfToken: login.body.csrfToken as string }; } async function createVerifiedUser(email: string, password: string) { return createUser(email, password, true); } async function createUnverifiedUser(email: string, password: string) { return createUser(email, password, false); } async function createUser(email: string, password: string, verified: boolean) { const passwordHash = await hashPassword(password); const inserted = await pool.query<{ id: string }>( `INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id`, [normalizeEmail(email), passwordHash], ); if (verified) { await pool.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()) WHERE id = $1", [ inserted.rows[0].id, ]); } return inserted.rows[0].id; } async function loginAgent(email: string, password: string) { const agent = request.agent(app); const login = await agent.post("/api/auth/login").send({ email, password }).expect(200); return { agent, csrfToken: login.body.csrfToken as string }; } async function countByTokenHash(table: "sessions" | "email_verification_tokens" | "password_reset_tokens", tokenHash: string) { const { rows } = await pool.query<{ count: string }>( `SELECT COUNT(*)::text AS count FROM ${table} WHERE token_hash = $1`, [tokenHash], ); return Number(rows[0]?.count ?? "0"); } test("GET /api/health returns ok", async () => { const res = await request(app).get("/api/health").expect(200); assert.equal(res.body.status, "ok"); assert.equal(typeof res.body.version, "string"); assert.ok(res.body.version.length > 0); }); test("GET /api/meta returns version for UI footer", async () => { const res = await request(app).get("/api/meta").expect(200); assert.equal(typeof res.body.version, "string"); assert.ok(res.body.version.length > 0); }); test("GET /api/ready succeeds with PostgreSQL", async () => { const res = await request(app).get("/api/ready").expect(200); assert.equal(res.body.status, "ready"); assert.equal(res.body.db, "connected"); }); test("production config rejects Turnstile bypass token", () => { assert.throws( () => resolveTurnstileBypassToken({ rawBypassToken: "unsafe-bypass", securityProfile: "production", }), /TURNSTILE_BYPASS_TOKEN/, ); assert.equal( resolveTurnstileBypassToken({ rawBypassToken: "dev-bypass", securityProfile: "development", }), "dev-bypass", ); }); test("production CSP allows Turnstile without unsafe script directives", () => { const options = buildHelmetOptions("production") as any; const directives = options.contentSecurityPolicy.directives; assert.ok(directives.scriptSrc.includes("https://challenges.cloudflare.com")); assert.ok(directives.frameSrc.includes("https://challenges.cloudflare.com")); assert.ok(directives.connectSrc.includes("https://challenges.cloudflare.com")); assert.ok(!directives.scriptSrc.includes("'unsafe-inline'")); assert.ok(!directives.scriptSrc.includes("'unsafe-eval'")); assert.deepEqual(directives.frameAncestors, ["'none'"]); assert.deepEqual(directives.objectSrc, ["'none'"]); }); test("duplicate registration keeps generic accepted response", async () => { const payload = { email: "duplicate-register@example.com", password: "correct horse battery staple", turnstileToken: "mock-turnstile-token", }; const first = await request(app).post("/api/auth/register").send(payload).expect(202); const second = await request(app).post("/api/auth/register").send(payload).expect(202); assert.deepEqual(first.body, { ok: true }); assert.deepEqual(second.body, { ok: true }); }); test("GET /api/races rejects invalid year", async () => { const { agent } = await authAgent(); const res = await agent.get("/api/races?year=bad").expect(400); assert.equal(res.body.error, "validation_error"); assert.ok(Array.isArray(res.body.details)); }); test("GET /api/races rejects month out of range", async () => { const { agent } = await authAgent(); const res = await agent.get("/api/races?month=13").expect(400); assert.equal(res.body.error, "validation_error"); }); test("GET /api/races accepts year and month", async () => { const { agent } = await authAgent(); const res = await agent.get("/api/races?year=2026&month=5").expect(200); assert.ok(Array.isArray(res.body)); }); test("GET /api/races/:id rejects a non-UUID id", async () => { const { agent } = await authAgent(); const res = await agent.get("/api/races/does-not-exist").expect(400); assert.equal(res.body.error, "validation_error"); assert.ok(Array.isArray(res.body.details)); }); test("GET /api/races requires authentication", async () => { const res = await request(app).get("/api/races").expect(401); assert.equal(res.body.error, "unauthorized"); }); test("public profile uses its username and separate visibility settings", async () => { const { agent } = await authAgent(); const user = await agent.get("/api/auth/me").expect(200); await agent .post("/api/races") .set("X-CSRF-Token", user.body.csrfToken as string) .send({ slug: "2026-08-01-planned-race", date: "2026-08-01", title: "Planned Race", distanceKm: 10, }) .expect(201); await agent .post("/api/races") .set("X-CSRF-Token", user.body.csrfToken as string) .send({ slug: "2026-07-01-completed-race", date: "2026-07-01", title: "Completed Race", distanceKm: 10, status: "completed", finishTime: "00:40:00", finishPlace: "12", notes: "Private note", }) .expect(201); await request(app).get("/api/users/public-runner/races").expect(404); await agent .patch("/api/auth/profile") .set("X-CSRF-Token", user.body.csrfToken as string) .send({ profileUsername: "public-runner", isFutureRacesPublic: true, isCompletedRacesPublic: false }) .expect(200); const futureRaces = await request(app).get("/api/users/public-runner/races").expect(200); assert.deepEqual(futureRaces.body, [{ date: "2026-08-01", title: "Planned Race", distanceKm: 10, status: null, coverImageUrl: null, finishTime: null, finishPlace: null, }]); await agent .patch("/api/auth/profile") .set("X-CSRF-Token", user.body.csrfToken as string) .send({ isFutureRacesPublic: false, isCompletedRacesPublic: true }) .expect(200); const completedRaces = await request(app).get("/api/users/public-runner/races").expect(200); assert.deepEqual(completedRaces.body, [{ date: "2026-07-01", title: "Completed Race", distanceKm: 10, status: "completed", coverImageUrl: null, finishTime: "00:40:00", finishPlace: "12", }]); }); test("login uses generic response for missing user and wrong password", async () => { const password = "correct horse battery staple"; await createVerifiedUser("generic@example.com", password); const wrongPassword = await request(app) .post("/api/auth/login") .send({ email: "generic@example.com", password: "wrong password" }) .expect(401); const missingUser = await request(app) .post("/api/auth/login") .send({ email: "missing@example.com", password }) .expect(401); assert.deepEqual(missingUser.body, wrongPassword.body); }); test("account session endpoints list and revoke only the current user's sessions", async () => { userCounter += 1; const email = `sessions${userCounter}@example.com`; const password = "correct horse battery staple"; await createVerifiedUser(email, password); const first = await loginAgent(email, password); const second = await loginAgent(email, password); const listed = await first.agent.get("/api/auth/sessions").expect(200); assert.equal(listed.body.sessions.length, 2); assert.ok(listed.body.sessions.every((session: { tokenHash?: unknown }) => session.tokenHash === undefined)); const other = listed.body.sessions.find((session: { current: boolean }) => !session.current); assert.ok(other); const outsider = await authAgent(); const foreign = (await outsider.agent.get("/api/auth/sessions").expect(200)).body.sessions[0]; await first.agent .delete(`/api/auth/sessions/${foreign.id}`) .set("X-CSRF-Token", first.csrfToken) .expect(404); await first.agent .delete(`/api/auth/sessions/${other.id}`) .set("X-CSRF-Token", first.csrfToken) .expect(204); await second.agent.get("/api/auth/me").expect(401); await first.agent.get("/api/auth/sessions").expect(200).then((res) => assert.equal(res.body.sessions.length, 1)); }); test("password change checks the current password and revokes other sessions", async () => { userCounter += 1; const email = `password${userCounter}@example.com`; const password = "correct horse battery staple"; const nextPassword = "another correct horse battery staple"; await createVerifiedUser(email, password); const first = await loginAgent(email, password); const second = await loginAgent(email, password); await first.agent .post("/api/auth/password") .set("X-CSRF-Token", first.csrfToken) .send({ currentPassword: "wrong password", newPassword: nextPassword }) .expect(400); await first.agent .post("/api/auth/password") .set("X-CSRF-Token", first.csrfToken) .send({ currentPassword: password, newPassword: nextPassword }) .expect(200); await second.agent.get("/api/auth/me").expect(401); await request(app).post("/api/auth/login").send({ email, password }).expect(401); await request(app).post("/api/auth/login").send({ email, password: nextPassword }).expect(200); }); test("revoke other sessions retains the current session", async () => { userCounter += 1; const email = `revoke-others${userCounter}@example.com`; const password = "correct horse battery staple"; await createVerifiedUser(email, password); const first = await loginAgent(email, password); const second = await loginAgent(email, password); await first.agent .post("/api/auth/sessions/revoke-others") .set("X-CSRF-Token", first.csrfToken) .expect(200); await first.agent.get("/api/auth/me").expect(200); await second.agent.get("/api/auth/me").expect(401); }); test("GET /api/races/:id returns not_found for another user's race", async () => { const first = await authAgent(); const created = await first.agent .post("/api/races") .set("X-CSRF-Token", first.csrfToken) .send({ slug: "2026-07-01-private-race", date: "2026-07-01", title: "Private Race", distanceKm: 10, }) .expect(201); const second = await authAgent(); const res = await second.agent.get(`/api/races/${created.body.id}`).expect(404); assert.equal(res.body.error, "not_found"); }); test("new password reset token invalidates previous token", async () => { const userId = await createVerifiedUser("reset@example.com", "correct horse battery staple"); const client = await pool.connect(); const first = await createResetToken(client, userId); const second = await createResetToken(client, userId); client.release(); assert.equal(await resetPassword(first, "new correct horse battery staple"), false); assert.equal(await resetPassword(second, "new correct horse battery staple"), true); assert.equal(await resetPassword(second, "new correct horse battery staple"), false); }); test("email verification token is single use", async () => { const userId = await createUnverifiedUser("verify-once@example.com", "correct horse battery staple"); const client = await pool.connect(); const token = await createVerificationToken(client, userId); client.release(); assert.equal(await verifyEmailToken(token), true); assert.equal(await verifyEmailToken(token), false); }); test("auth cleanup removes expired rows and keeps active rows", async () => { const userId = await createVerifiedUser("cleanup@example.com", "correct horse battery staple"); const past = new Date(Date.now() - 60_000); const future = new Date(Date.now() + 60_000); const expiredSession = "expired-session-token-hash"; const activeSession = "active-session-token-hash"; const expiredVerify = "expired-verify-token-hash"; const activeVerify = "active-verify-token-hash"; const expiredReset = "expired-reset-token-hash"; const activeReset = "active-reset-token-hash"; await pool.query( "INSERT INTO sessions (user_id, token_hash, csrf_token_hash, expires_at) VALUES ($1, $2, $3, $4)", [userId, expiredSession, "expired-csrf", past], ); await pool.query( "INSERT INTO sessions (user_id, token_hash, csrf_token_hash, expires_at) VALUES ($1, $2, $3, $4)", [userId, activeSession, "active-csrf", future], ); await pool.query( "INSERT INTO email_verification_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)", [userId, expiredVerify, past], ); await pool.query( "INSERT INTO email_verification_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)", [userId, activeVerify, future], ); await pool.query( "INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)", [userId, expiredReset, past], ); await pool.query( "INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)", [userId, activeReset, future], ); await cleanupExpiredAuthRows(); assert.equal(await countByTokenHash("sessions", expiredSession), 0); assert.equal(await countByTokenHash("sessions", activeSession), 1); assert.equal(await countByTokenHash("email_verification_tokens", expiredVerify), 0); assert.equal(await countByTokenHash("email_verification_tokens", activeVerify), 1); assert.equal(await countByTokenHash("password_reset_tokens", expiredReset), 0); assert.equal(await countByTokenHash("password_reset_tokens", activeReset), 1); }); test("extractRaceCoverImageFromHtml prefers runc.run intro image", () => { const html = `