406 lines
14 KiB
TypeScript
406 lines
14 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { 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 { extractRaceCoverImageFromHtml } from "../src/raceCoverImage";
|
|
import { hashPassword, normalizeEmail } from "../src/security";
|
|
|
|
const app = createApp();
|
|
let userCounter = 0;
|
|
|
|
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 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 mock database", 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",
|
|
useMockDb: false,
|
|
}),
|
|
/TURNSTILE_BYPASS_TOKEN/,
|
|
);
|
|
assert.equal(
|
|
resolveTurnstileBypassToken({
|
|
rawBypassToken: "dev-bypass",
|
|
securityProfile: "development",
|
|
useMockDb: false,
|
|
}),
|
|
"dev-bypass",
|
|
);
|
|
assert.equal(
|
|
resolveTurnstileBypassToken({
|
|
rawBypassToken: "mock-bypass",
|
|
securityProfile: "production",
|
|
useMockDb: true,
|
|
}),
|
|
"mock-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 returns not_found", async () => {
|
|
const { agent } = await authAgent();
|
|
const res = await agent.get("/api/races/does-not-exist").expect(404);
|
|
assert.equal(res.body.error, "not_found");
|
|
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("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("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 = `
|
|
<meta property="og:image" content="https://example.com/og.jpg">
|
|
<div class="run-intro__image">
|
|
<div class="run-intro__image-left-shadow"></div>
|
|
<img src="/uploads/race_landing_header_backgrounds/header.jpg" alt="">
|
|
<div class="run-intro__image-right-shadow"></div>
|
|
</div>
|
|
`;
|
|
|
|
assert.equal(
|
|
extractRaceCoverImageFromHtml(html, "https://aprilrun5km.runc.run/"),
|
|
"https://aprilrun5km.runc.run/uploads/race_landing_header_backgrounds/header.jpg",
|
|
);
|
|
});
|
|
|
|
test("extractRaceCoverImageFromHtml reads Open Graph and Twitter images", () => {
|
|
assert.equal(
|
|
extractRaceCoverImageFromHtml(
|
|
'<meta property="og:image" content="/cover.png">',
|
|
"https://example.com/race",
|
|
),
|
|
"https://example.com/cover.png",
|
|
);
|
|
assert.equal(
|
|
extractRaceCoverImageFromHtml(
|
|
'<meta name="twitter:image" content="https://cdn.example.com/twitter.jpg">',
|
|
"https://example.com/race",
|
|
),
|
|
"https://cdn.example.com/twitter.jpg",
|
|
);
|
|
});
|
|
|
|
test("POST /api/races stores manual coverImageUrl", async () => {
|
|
const { agent, csrfToken } = await authAgent();
|
|
const coverImageUrl = "https://example.com/manual.jpg";
|
|
const res = await agent
|
|
.post("/api/races")
|
|
.set("X-CSRF-Token", csrfToken)
|
|
.send({
|
|
slug: "2026-06-01-manual-cover",
|
|
date: "2026-06-01",
|
|
title: "Manual Cover",
|
|
distanceKm: 10,
|
|
officialUrl: "https://example.com/race",
|
|
coverImageUrl,
|
|
})
|
|
.expect(201);
|
|
|
|
assert.equal(res.body.coverImageUrl, coverImageUrl);
|
|
});
|
|
|
|
test("POST /api/races auto extracts coverImageUrl from officialUrl", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = async () =>
|
|
new Response('<meta property="og:image" content="/auto.jpg">', {
|
|
status: 200,
|
|
headers: { "content-type": "text/html" },
|
|
});
|
|
|
|
try {
|
|
const { agent, csrfToken } = await authAgent();
|
|
const res = await agent
|
|
.post("/api/races")
|
|
.set("X-CSRF-Token", csrfToken)
|
|
.send({
|
|
slug: "2026-06-02-auto-cover",
|
|
date: "2026-06-02",
|
|
title: "Auto Cover",
|
|
distanceKm: 21.1,
|
|
officialUrl: "https://example.com/race",
|
|
})
|
|
.expect(201);
|
|
|
|
assert.equal(res.body.coverImageUrl, "https://example.com/auto.jpg");
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
test("POST /api/races succeeds when cover extraction fails", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = async () => {
|
|
throw new Error("network down");
|
|
};
|
|
|
|
try {
|
|
const { agent, csrfToken } = await authAgent();
|
|
const res = await agent
|
|
.post("/api/races")
|
|
.set("X-CSRF-Token", csrfToken)
|
|
.send({
|
|
slug: "2026-06-03-cover-fail",
|
|
date: "2026-06-03",
|
|
title: "Cover Fail",
|
|
distanceKm: 5,
|
|
officialUrl: "https://example.com/race",
|
|
})
|
|
.expect(201);
|
|
|
|
assert.equal(res.body.coverImageUrl, null);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
test("PATCH /api/races/:id updates coverImageUrl explicitly", async () => {
|
|
const { agent, csrfToken } = await authAgent();
|
|
const created = await agent
|
|
.post("/api/races")
|
|
.set("X-CSRF-Token", csrfToken)
|
|
.send({
|
|
slug: "2026-06-04-patch-cover",
|
|
date: "2026-06-04",
|
|
title: "Patch Cover",
|
|
distanceKm: 10,
|
|
})
|
|
.expect(201);
|
|
|
|
const coverImageUrl = "https://example.com/patched.jpg";
|
|
const res = await agent
|
|
.patch(`/api/races/${created.body.id}`)
|
|
.set("X-CSRF-Token", csrfToken)
|
|
.send({ coverImageUrl })
|
|
.expect(200);
|
|
|
|
assert.equal(res.body.coverImageUrl, coverImageUrl);
|
|
});
|