feat: complete p0 hardening sprint
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user