feat: complete p0 hardening sprint
Some checks failed
CI / build-and-test (pull_request) Has been cancelled

This commit is contained in:
Vakanaut
2026-07-12 15:08:00 +03:00
parent b0ebdeca72
commit a317c9b760
36 changed files with 851 additions and 1608 deletions

View File

@@ -1,5 +1,7 @@
import assert from "node:assert/strict";
import { test } from "node:test";
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 {
@@ -11,12 +13,14 @@ import {
} from "../src/authService";
import { resolveTurnstileBypassToken } from "../src/config";
import { pool } from "../src/db";
import { extractRaceCoverImageFromHtml } from "../src/raceCoverImage";
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`);
@@ -81,7 +85,7 @@ test("GET /api/meta returns version for UI footer", async () => {
assert.ok(res.body.version.length > 0);
});
test("GET /api/ready succeeds with mock database", async () => {
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");
@@ -93,7 +97,6 @@ test("production config rejects Turnstile bypass token", () => {
resolveTurnstileBypassToken({
rawBypassToken: "unsafe-bypass",
securityProfile: "production",
useMockDb: false,
}),
/TURNSTILE_BYPASS_TOKEN/,
);
@@ -101,18 +104,9 @@ test("production config rejects Turnstile bypass token", () => {
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", () => {
@@ -161,10 +155,10 @@ test("GET /api/races accepts year and month", async () => {
assert.ok(Array.isArray(res.body));
});
test("GET /api/races/:id returns not_found", async () => {
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(404);
assert.equal(res.body.error, "not_found");
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));
});
@@ -327,6 +321,44 @@ test("POST /api/races stores manual coverImageUrl", async () => {
assert.equal(res.body.coverImageUrl, coverImageUrl);
});
test("POST /api/races rejects unknown and malformed fields", async () => {
const { agent, csrfToken } = await authAgent();
const res = await agent
.post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({
slug: "invalid-race",
date: "2026-02-30",
title: "Invalid Race",
distanceKm: 10,
startTime: "24:00",
unexpected: true,
})
.expect(400);
assert.equal(res.body.error, "validation_error");
assert.ok(res.body.details.some((detail: string) => detail.includes("date")));
assert.ok(res.body.details.some((detail: string) => detail.includes("startTime")));
assert.ok(res.body.details.some((detail: string) => detail.includes("Unrecognized")));
});
test("POST /api/races accepts a seconds-only finish time", async () => {
const { agent, csrfToken } = await authAgent();
const res = await agent
.post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({
slug: "2026-06-01-seconds-only",
date: "2026-06-01",
title: "Seconds Only",
distanceKm: 1,
finishTime: "05",
})
.expect(201);
assert.equal(res.body.finishTime, "05");
});
test("POST /api/races auto extracts coverImageUrl from officialUrl", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
@@ -381,6 +413,38 @@ test("POST /api/races succeeds when cover extraction fails", async () => {
}
});
test("cover extraction never fetches loopback URLs", async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return new Response("");
};
try {
assert.equal(await extractRaceCoverImage("http://127.0.0.1/private"), null);
assert.equal(calls, 0);
} finally {
globalThis.fetch = originalFetch;
}
});
test("cover extraction rejects a redirect to a private URL", async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return new Response(null, { status: 302, headers: { location: "http://127.0.0.1/private" } });
};
try {
assert.equal(await extractRaceCoverImage("https://8.8.8.8/race"), null);
assert.equal(calls, 1);
} finally {
globalThis.fetch = originalFetch;
}
});
test("PATCH /api/races/:id updates coverImageUrl explicitly", async () => {
const { agent, csrfToken } = await authAgent();
const created = await agent
@@ -403,3 +467,49 @@ test("PATCH /api/races/:id updates coverImageUrl explicitly", async () => {
assert.equal(res.body.coverImageUrl, coverImageUrl);
});
test("004 migrates legacy text race ids to UUIDs", async () => {
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("DROP TABLE races");
await client.query(`
CREATE TABLE races (
id TEXT PRIMARY KEY,
race_date DATE NOT NULL,
title TEXT NOT NULL,
distance_km NUMERIC(6,3) NOT NULL,
status TEXT CHECK (status IS NULL OR status IN ('planned', 'registered', 'completed')),
official_url TEXT,
start_time TEXT,
cluster_schedule TEXT,
bib_pickup TEXT,
bib_number TEXT,
finish_time TEXT,
notes TEXT,
finish_place TEXT,
cover_image_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
)
`);
await client.query(
"INSERT INTO races (id, race_date, title, distance_km) VALUES ($1, $2, $3, $4)",
["legacy-race", "2026-01-01", "Legacy Race", 10],
);
const migration = fs.readFileSync(path.resolve(__dirname, "../migrations/004_auth_and_race_ownership.sql"), "utf8");
await client.query(migration);
const { rows } = await client.query<{ id: string; slug: string; source: string }>(
"SELECT id, slug, source FROM races WHERE slug = $1",
["legacy-race"],
);
assert.equal(rows[0]?.slug, "legacy-race");
assert.equal(rows[0]?.source, "user");
assert.match(rows[0]?.id ?? "", /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
} finally {
await client.query("ROLLBACK");
client.release();
}
});