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:
5
backend/package-lock.json
generated
5
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "calendar-run-backend",
|
||||
"version": "1.4.1",
|
||||
"version": "1.4.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "calendar-run-backend",
|
||||
"version": "1.4.1",
|
||||
"version": "1.4.2",
|
||||
"dependencies": {
|
||||
"argon2": "^0.44.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
@@ -28,7 +28,6 @@
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/pg": "^8.11.10",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.19.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "calendar-run-backend",
|
||||
"version": "1.4.1",
|
||||
"version": "1.4.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
@@ -8,7 +8,7 @@
|
||||
"start": "node dist/index.js",
|
||||
"db:migrate": "ts-node src/migrate.ts",
|
||||
"seed": "ts-node src/seed.ts",
|
||||
"test": "cross-env CALENDAR_RUN_MOCK_DB=1 tsx --test test/app.test.ts"
|
||||
"test": "tsx --import ./test/setup.ts --test test/app.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"argon2": "^0.44.0",
|
||||
@@ -31,7 +31,6 @@
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/pg": "^8.11.10",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.19.2",
|
||||
|
||||
@@ -16,13 +16,6 @@ function optionalEnv(name: string): string | null {
|
||||
return value ? value : null;
|
||||
}
|
||||
|
||||
function requireEnvUnlessMock(name: string, fallback: string): string {
|
||||
if (useMockDb) {
|
||||
return process.env[name]?.trim() || fallback;
|
||||
}
|
||||
return requireEnv(name);
|
||||
}
|
||||
|
||||
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
|
||||
if (value == null || value.trim() === "") {
|
||||
return fallback;
|
||||
@@ -30,50 +23,33 @@ function parseBoolean(value: string | undefined, fallback: boolean): boolean {
|
||||
return value === "1" || value.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
const useMockDb =
|
||||
process.env.CALENDAR_RUN_MOCK_DB === "1" ||
|
||||
process.env.CALENDAR_RUN_MOCK_DB?.toLowerCase() === "true";
|
||||
|
||||
const securityProfile = process.env.SECURITY_PROFILE?.trim() || process.env.NODE_ENV || "development";
|
||||
|
||||
export function resolveTurnstileBypassToken(params: {
|
||||
rawBypassToken?: string;
|
||||
securityProfile: string;
|
||||
useMockDb: boolean;
|
||||
}): string {
|
||||
const raw = params.rawBypassToken?.trim() ?? "";
|
||||
if (raw && params.securityProfile === "production" && !params.useMockDb) {
|
||||
if (raw && params.securityProfile === "production") {
|
||||
throw new Error("TURNSTILE_BYPASS_TOKEN is not allowed in production");
|
||||
}
|
||||
if (raw) {
|
||||
return raw;
|
||||
}
|
||||
return params.useMockDb ? "mock-turnstile-token" : "";
|
||||
return raw;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
useMockDb,
|
||||
db: useMockDb
|
||||
? {
|
||||
host: "mock",
|
||||
port: 5432,
|
||||
database: "mock",
|
||||
user: "mock",
|
||||
password: "mock",
|
||||
}
|
||||
: {
|
||||
host: requireEnv("DB_HOST"),
|
||||
port: parseInt(requireEnv("DB_PORT"), 10),
|
||||
database: requireEnv("DB_NAME"),
|
||||
user: requireEnv("DB_USER"),
|
||||
password: requireEnv("DB_PASSWORD"),
|
||||
},
|
||||
db: {
|
||||
host: requireEnv("DB_HOST"),
|
||||
port: parseInt(requireEnv("DB_PORT"), 10),
|
||||
database: requireEnv("DB_NAME"),
|
||||
user: requireEnv("DB_USER"),
|
||||
password: requireEnv("DB_PASSWORD"),
|
||||
},
|
||||
apiPort: parseInt(process.env.PORT || process.env.API_PORT || "3001", 10),
|
||||
/** Одно значение или несколько через запятую (прод: https://домен) */
|
||||
corsOrigin: parseCorsOrigins(),
|
||||
appBaseUrl: process.env.APP_BASE_URL?.trim() || "http://localhost:5173",
|
||||
session: {
|
||||
secret: requireEnvUnlessMock("SESSION_SECRET", "mock-session-secret-change-me"),
|
||||
secret: requireEnv("SESSION_SECRET"),
|
||||
cookieName:
|
||||
process.env.SESSION_COOKIE_NAME?.trim() ||
|
||||
(process.env.NODE_ENV === "production" ? "__Host-sid" : "sid"),
|
||||
@@ -89,11 +65,10 @@ export const config = {
|
||||
from: process.env.SMTP_FROM?.trim() || "Calendar Run <no-reply@example.com>",
|
||||
},
|
||||
turnstile: {
|
||||
secretKey: process.env.TURNSTILE_SECRET_KEY?.trim() || (useMockDb ? "mock-turnstile-secret" : ""),
|
||||
secretKey: process.env.TURNSTILE_SECRET_KEY?.trim() || "",
|
||||
bypassToken: resolveTurnstileBypassToken({
|
||||
rawBypassToken: process.env.TURNSTILE_BYPASS_TOKEN,
|
||||
securityProfile,
|
||||
useMockDb,
|
||||
}),
|
||||
},
|
||||
authCleanupIntervalHours: parseInt(process.env.AUTH_CLEANUP_INTERVAL_HOURS || "24", 10),
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Pool, PoolConfig, QueryResult, QueryResultRow } from "pg";
|
||||
import crypto from "crypto";
|
||||
import { Pool, PoolConfig } from "pg";
|
||||
import { config } from "./config";
|
||||
import type { RaceRow } from "./mappers/race";
|
||||
|
||||
const poolConfig: PoolConfig = {
|
||||
host: config.db.host,
|
||||
@@ -14,509 +12,13 @@ const poolConfig: PoolConfig = {
|
||||
connectionTimeoutMillis: 5_000,
|
||||
};
|
||||
|
||||
function mockRowFromInsert(sql: string, params: unknown[]): RaceRow {
|
||||
const match = sql.match(/INSERT INTO races\s*\(([^)]+)\)\s*VALUES/i);
|
||||
const now = new Date();
|
||||
if (!match) {
|
||||
return {
|
||||
id: String(params[0] ?? ""),
|
||||
slug: String(params[0] ?? ""),
|
||||
owner_user_id: null,
|
||||
race_date: "",
|
||||
title: "",
|
||||
distance_km: "0",
|
||||
status: null,
|
||||
official_url: null,
|
||||
cover_image_url: null,
|
||||
start_time: null,
|
||||
cluster_schedule: null,
|
||||
bib_pickup: null,
|
||||
bib_number: null,
|
||||
finish_time: null,
|
||||
finish_place: null,
|
||||
notes: null,
|
||||
created_at: now,
|
||||
updated_at: null,
|
||||
};
|
||||
}
|
||||
const cols = match[1].split(",").map((c) => c.trim());
|
||||
const row: Record<string, unknown> = {};
|
||||
cols.forEach((col, i) => {
|
||||
row[col] = params[i];
|
||||
});
|
||||
return {
|
||||
id: String(row.id ?? crypto.randomUUID()),
|
||||
slug: String(row.slug ?? row.id ?? ""),
|
||||
owner_user_id: row.owner_user_id != null ? String(row.owner_user_id) : null,
|
||||
race_date: String(row.race_date ?? ""),
|
||||
title: String(row.title ?? ""),
|
||||
distance_km: String(row.distance_km ?? "0"),
|
||||
status: row.status != null ? String(row.status) : null,
|
||||
official_url: row.official_url != null ? String(row.official_url) : null,
|
||||
cover_image_url: row.cover_image_url != null ? String(row.cover_image_url) : null,
|
||||
start_time: row.start_time != null ? String(row.start_time) : null,
|
||||
cluster_schedule: row.cluster_schedule != null ? String(row.cluster_schedule) : null,
|
||||
bib_pickup: row.bib_pickup != null ? String(row.bib_pickup) : null,
|
||||
bib_number: row.bib_number != null ? String(row.bib_number) : null,
|
||||
finish_time: row.finish_time != null ? String(row.finish_time) : null,
|
||||
finish_place: row.finish_place != null ? String(row.finish_place) : null,
|
||||
notes: row.notes != null ? String(row.notes) : null,
|
||||
created_at: now,
|
||||
updated_at: null,
|
||||
};
|
||||
}
|
||||
export const pool = new Pool(poolConfig);
|
||||
|
||||
function createMockPool(): Pool {
|
||||
const emptyResult = <T extends QueryResultRow>(): QueryResult<T> =>
|
||||
({
|
||||
rows: [],
|
||||
rowCount: 0,
|
||||
command: "",
|
||||
oid: 0,
|
||||
fields: [],
|
||||
}) as QueryResult<T>;
|
||||
|
||||
const store = new Map<string, RaceRow>();
|
||||
const users = new Map<string, any>();
|
||||
const sessions = new Map<string, any>();
|
||||
const verificationTokens = new Map<string, any>();
|
||||
const resetTokens = new Map<string, any>();
|
||||
const appSettings = new Map<string, string>();
|
||||
|
||||
const result = <T extends QueryResultRow>(rows: T[], command = "SELECT"): QueryResult<T> =>
|
||||
({ rows, rowCount: rows.length, command, oid: 0, fields: [] }) as QueryResult<T>;
|
||||
|
||||
const mockQuery = async <T extends QueryResultRow>(
|
||||
text: string,
|
||||
params?: unknown[],
|
||||
): Promise<QueryResult<T>> => {
|
||||
const sql = text.replace(/\s+/g, " ").trim();
|
||||
const p = params ?? [];
|
||||
|
||||
if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK" || sql.includes("pg_advisory_xact_lock")) {
|
||||
return result<T>([]);
|
||||
}
|
||||
|
||||
if (sql.includes("SELECT COUNT(*)::text AS count FROM users")) {
|
||||
return result([{ count: String(users.size) } as unknown as T]);
|
||||
}
|
||||
|
||||
if (sql.includes("SELECT COUNT(*)::text AS count FROM sessions")) {
|
||||
const tokenHash = p[0] != null ? String(p[0]) : null;
|
||||
const count = Array.from(sessions.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length;
|
||||
return result([{ count: String(count) } as unknown as T]);
|
||||
}
|
||||
|
||||
if (sql.includes("SELECT COUNT(*)::text AS count FROM email_verification_tokens")) {
|
||||
const tokenHash = p[0] != null ? String(p[0]) : null;
|
||||
const count = Array.from(verificationTokens.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length;
|
||||
return result([{ count: String(count) } as unknown as T]);
|
||||
}
|
||||
|
||||
if (sql.includes("SELECT COUNT(*)::text AS count FROM password_reset_tokens")) {
|
||||
const tokenHash = p[0] != null ? String(p[0]) : null;
|
||||
const count = Array.from(resetTokens.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length;
|
||||
return result([{ count: String(count) } as unknown as T]);
|
||||
}
|
||||
|
||||
if (sql.includes("SELECT id FROM users WHERE LOWER(BTRIM(email))")) {
|
||||
const email = String(p[0] ?? "");
|
||||
const user = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email);
|
||||
return user ? result([user as T]) : emptyResult();
|
||||
}
|
||||
|
||||
if (sql.includes("SELECT id, email, password_hash, email_verified_at FROM users WHERE LOWER(BTRIM(email))")) {
|
||||
const email = String(p[0] ?? "");
|
||||
const user = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email);
|
||||
return user ? result([user as T]) : emptyResult();
|
||||
}
|
||||
|
||||
if (sql.includes("INSERT INTO users")) {
|
||||
const email = String(p[0] ?? "").trim().toLowerCase();
|
||||
const existing = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email);
|
||||
if (existing) {
|
||||
const err = new Error("duplicate key") as Error & { code?: string };
|
||||
err.code = "23505";
|
||||
throw err;
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
const row = {
|
||||
id,
|
||||
email: String(p[0] ?? ""),
|
||||
password_hash: String(p[1] ?? ""),
|
||||
email_verified_at: null,
|
||||
created_at: new Date(),
|
||||
updated_at: null,
|
||||
};
|
||||
users.set(id, row);
|
||||
return result([row as unknown as T], "INSERT");
|
||||
}
|
||||
|
||||
if (sql.includes("INSERT INTO email_verification_tokens")) {
|
||||
const id = crypto.randomUUID();
|
||||
const row = {
|
||||
id,
|
||||
user_id: String(p[0] ?? ""),
|
||||
token_hash: String(p[1] ?? ""),
|
||||
expires_at: p[2] ?? new Date(),
|
||||
used_at: null,
|
||||
created_at: new Date(),
|
||||
};
|
||||
verificationTokens.set(id, row);
|
||||
return result([row as unknown as T], "INSERT");
|
||||
}
|
||||
|
||||
if (sql.includes("UPDATE email_verification_tokens SET used_at = NOW() WHERE id")) {
|
||||
const id = String(p[0] ?? "");
|
||||
const row = verificationTokens.get(id);
|
||||
if (!row || row.used_at != null) {
|
||||
return result<T>([], "UPDATE");
|
||||
}
|
||||
row.used_at = new Date();
|
||||
return result([{ id: row.id } as unknown as T], "UPDATE");
|
||||
}
|
||||
|
||||
if (sql.includes("UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id")) {
|
||||
const userId = String(p[0] ?? "");
|
||||
const exceptId = p[1] != null ? String(p[1]) : null;
|
||||
for (const row of verificationTokens.values()) {
|
||||
if (row.user_id === userId && row.id !== exceptId && row.used_at == null) {
|
||||
row.used_at = new Date();
|
||||
}
|
||||
}
|
||||
return result<T>([], "UPDATE");
|
||||
}
|
||||
|
||||
if (sql.includes("FROM email_verification_tokens") && sql.includes("token_hash =")) {
|
||||
const tokenHash = String(p[0] ?? "");
|
||||
const now = Date.now();
|
||||
return result(
|
||||
Array.from(verificationTokens.values()).filter(
|
||||
(row) => row.token_hash === tokenHash && !row.used_at && new Date(row.expires_at).getTime() > now,
|
||||
) as T[],
|
||||
);
|
||||
}
|
||||
|
||||
if (sql.includes("FROM email_verification_tokens") && sql.includes("WHERE used_at IS NULL")) {
|
||||
const now = Date.now();
|
||||
return result(
|
||||
Array.from(verificationTokens.values()).filter((row) => !row.used_at && new Date(row.expires_at).getTime() > now) as T[],
|
||||
);
|
||||
}
|
||||
|
||||
if (sql.includes("INSERT INTO password_reset_tokens")) {
|
||||
const id = crypto.randomUUID();
|
||||
const row = {
|
||||
id,
|
||||
user_id: String(p[0] ?? ""),
|
||||
token_hash: String(p[1] ?? ""),
|
||||
expires_at: p[2] ?? new Date(),
|
||||
used_at: null,
|
||||
created_at: new Date(),
|
||||
};
|
||||
resetTokens.set(id, row);
|
||||
return result([row as unknown as T], "INSERT");
|
||||
}
|
||||
|
||||
if (sql.includes("UPDATE password_reset_tokens SET used_at = NOW() WHERE id")) {
|
||||
const id = String(p[0] ?? "");
|
||||
const row = resetTokens.get(id);
|
||||
if (!row || row.used_at != null) {
|
||||
return result<T>([], "UPDATE");
|
||||
}
|
||||
row.used_at = new Date();
|
||||
return result([{ id: row.id } as unknown as T], "UPDATE");
|
||||
}
|
||||
|
||||
if (sql.includes("UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id")) {
|
||||
const userId = String(p[0] ?? "");
|
||||
const exceptId = p[1] != null ? String(p[1]) : null;
|
||||
for (const row of resetTokens.values()) {
|
||||
if (row.user_id === userId && row.id !== exceptId && row.used_at == null) {
|
||||
row.used_at = new Date();
|
||||
}
|
||||
}
|
||||
return result<T>([], "UPDATE");
|
||||
}
|
||||
|
||||
if (sql.includes("FROM password_reset_tokens") && sql.includes("token_hash =")) {
|
||||
const tokenHash = String(p[0] ?? "");
|
||||
const now = Date.now();
|
||||
return result(
|
||||
Array.from(resetTokens.values()).filter(
|
||||
(row) => row.token_hash === tokenHash && !row.used_at && new Date(row.expires_at).getTime() > now,
|
||||
) as T[],
|
||||
);
|
||||
}
|
||||
|
||||
if (sql.includes("FROM password_reset_tokens") && sql.includes("WHERE used_at IS NULL")) {
|
||||
const now = Date.now();
|
||||
return result(
|
||||
Array.from(resetTokens.values()).filter((row) => !row.used_at && new Date(row.expires_at).getTime() > now) as T[],
|
||||
);
|
||||
}
|
||||
|
||||
if (sql.includes("UPDATE users SET email_verified_at")) {
|
||||
const user = users.get(String(p[0] ?? ""));
|
||||
if (user) {
|
||||
user.email_verified_at = user.email_verified_at ?? new Date();
|
||||
user.updated_at = new Date();
|
||||
}
|
||||
return result<T>([], "UPDATE");
|
||||
}
|
||||
|
||||
if (sql.includes("UPDATE users SET password_hash")) {
|
||||
const user = users.get(String(p[0] ?? ""));
|
||||
if (user) {
|
||||
user.password_hash = String(p[1] ?? "");
|
||||
user.updated_at = new Date();
|
||||
}
|
||||
return result<T>([], "UPDATE");
|
||||
}
|
||||
|
||||
if (sql.includes("INSERT INTO sessions")) {
|
||||
const id = crypto.randomUUID();
|
||||
const user = users.get(String(p[0] ?? ""));
|
||||
const row = {
|
||||
id,
|
||||
user_id: String(p[0] ?? ""),
|
||||
token_hash: String(p[1] ?? ""),
|
||||
csrf_token_hash: String(p[2] ?? ""),
|
||||
expires_at: p[3] ?? new Date(),
|
||||
email: user?.email ?? "",
|
||||
email_verified_at: user?.email_verified_at ?? null,
|
||||
revoked_at: null,
|
||||
created_at: new Date(),
|
||||
last_seen_at: new Date(),
|
||||
};
|
||||
sessions.set(id, row);
|
||||
return result([row as unknown as T], "INSERT");
|
||||
}
|
||||
|
||||
if (sql.includes("FROM sessions s JOIN users u")) {
|
||||
const tokenHash = String(p[0] ?? "");
|
||||
const now = Date.now();
|
||||
const row = Array.from(sessions.values()).find(
|
||||
(item) => item.token_hash === tokenHash && !item.revoked_at && new Date(item.expires_at).getTime() > now,
|
||||
);
|
||||
if (!row) {
|
||||
return emptyResult();
|
||||
}
|
||||
const user = users.get(row.user_id);
|
||||
return result([{ ...row, email: user?.email ?? "", email_verified_at: user?.email_verified_at ?? null } as unknown as T]);
|
||||
}
|
||||
|
||||
if (sql.includes("UPDATE sessions SET csrf_token_hash")) {
|
||||
const tokenHash = String(p[0] ?? "");
|
||||
const row = Array.from(sessions.values()).find((item) => item.token_hash === tokenHash && !item.revoked_at);
|
||||
if (row) {
|
||||
row.csrf_token_hash = String(p[1] ?? "");
|
||||
row.last_seen_at = new Date();
|
||||
}
|
||||
return result<T>(row ? ([{} as unknown as T]) : [], "UPDATE");
|
||||
}
|
||||
|
||||
if (sql.includes("UPDATE sessions SET last_seen_at")) {
|
||||
return result<T>([], "UPDATE");
|
||||
}
|
||||
|
||||
if (sql.includes("UPDATE sessions SET revoked_at = NOW() WHERE token_hash")) {
|
||||
const tokenHash = String(p[0] ?? "");
|
||||
for (const row of sessions.values()) {
|
||||
if (row.token_hash === tokenHash) {
|
||||
row.revoked_at = new Date();
|
||||
}
|
||||
}
|
||||
return result<T>([], "UPDATE");
|
||||
}
|
||||
|
||||
if (sql.includes("UPDATE sessions SET revoked_at = NOW() WHERE user_id")) {
|
||||
const userId = String(p[0] ?? "");
|
||||
for (const row of sessions.values()) {
|
||||
if (row.user_id === userId && !row.revoked_at) {
|
||||
row.revoked_at = new Date();
|
||||
}
|
||||
}
|
||||
return result<T>([], "UPDATE");
|
||||
}
|
||||
|
||||
if (sql.includes("DELETE FROM sessions WHERE expires_at <= NOW()")) {
|
||||
const now = Date.now();
|
||||
let deleted = 0;
|
||||
for (const [id, row] of sessions.entries()) {
|
||||
const revokedAt = row.revoked_at ? new Date(row.revoked_at).getTime() : null;
|
||||
const staleRevoked = revokedAt != null && revokedAt < now - 30 * 24 * 60 * 60 * 1000;
|
||||
if (new Date(row.expires_at).getTime() <= now || staleRevoked) {
|
||||
sessions.delete(id);
|
||||
deleted += 1;
|
||||
}
|
||||
}
|
||||
return result<T>(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE");
|
||||
}
|
||||
|
||||
if (sql.includes("DELETE FROM email_verification_tokens WHERE expires_at <= NOW()")) {
|
||||
const now = Date.now();
|
||||
let deleted = 0;
|
||||
for (const [id, row] of verificationTokens.entries()) {
|
||||
if (new Date(row.expires_at).getTime() <= now || row.used_at != null) {
|
||||
verificationTokens.delete(id);
|
||||
deleted += 1;
|
||||
}
|
||||
}
|
||||
return result<T>(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE");
|
||||
}
|
||||
|
||||
if (sql.includes("DELETE FROM password_reset_tokens WHERE expires_at <= NOW()")) {
|
||||
const now = Date.now();
|
||||
let deleted = 0;
|
||||
for (const [id, row] of resetTokens.entries()) {
|
||||
if (new Date(row.expires_at).getTime() <= now || row.used_at != null) {
|
||||
resetTokens.delete(id);
|
||||
deleted += 1;
|
||||
}
|
||||
}
|
||||
return result<T>(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE");
|
||||
}
|
||||
|
||||
if (sql.includes("SELECT value FROM app_settings")) {
|
||||
const value = appSettings.get("orphan_races_claimed_by_user_id");
|
||||
return value ? result([{ value } as unknown as T]) : emptyResult();
|
||||
}
|
||||
|
||||
if (sql.includes("INSERT INTO app_settings")) {
|
||||
appSettings.set("orphan_races_claimed_by_user_id", String(p[0] ?? ""));
|
||||
return result<T>([], "INSERT");
|
||||
}
|
||||
|
||||
if (sql.includes("UPDATE races SET owner_user_id")) {
|
||||
const userId = String(p[0] ?? "");
|
||||
for (const race of store.values()) {
|
||||
if (!race.owner_user_id) {
|
||||
race.owner_user_id = userId;
|
||||
race.updated_at = new Date();
|
||||
}
|
||||
}
|
||||
return result<T>([], "UPDATE");
|
||||
}
|
||||
|
||||
if (sql.includes("INSERT INTO races") && sql.includes("RETURNING")) {
|
||||
const row = mockRowFromInsert(text, p);
|
||||
const conflict = Array.from(store.values()).find(
|
||||
(item) => item.owner_user_id && item.owner_user_id === row.owner_user_id && item.slug === row.slug,
|
||||
);
|
||||
if (conflict) {
|
||||
const err = new Error("duplicate key") as Error & { code?: string };
|
||||
err.code = "23505";
|
||||
throw err;
|
||||
}
|
||||
store.set(row.id, row);
|
||||
return {
|
||||
rows: [row as unknown as T],
|
||||
rowCount: 1,
|
||||
command: "INSERT",
|
||||
oid: 0,
|
||||
fields: [],
|
||||
} as QueryResult<T>;
|
||||
}
|
||||
|
||||
if (sql.includes("DELETE FROM races")) {
|
||||
const id = String(p[0] ?? "");
|
||||
const ownerId = p[1] != null ? String(p[1]) : null;
|
||||
const existing = store.get(id);
|
||||
const existed = Boolean(existing && (!ownerId || existing.owner_user_id === ownerId));
|
||||
if (existed) {
|
||||
store.delete(id);
|
||||
}
|
||||
return {
|
||||
rows: [],
|
||||
rowCount: existed ? 1 : 0,
|
||||
command: "DELETE",
|
||||
oid: 0,
|
||||
fields: [],
|
||||
} as QueryResult<T>;
|
||||
}
|
||||
|
||||
if (sql.includes("UPDATE races") && sql.includes("RETURNING")) {
|
||||
const id = String(p[p.length - 2] ?? p[p.length - 1] ?? "");
|
||||
const ownerId = p[p.length - 1] != null ? String(p[p.length - 1]) : null;
|
||||
const existing = store.get(id);
|
||||
if (!existing || (ownerId && existing.owner_user_id !== ownerId)) {
|
||||
return emptyResult();
|
||||
}
|
||||
const setMatch = sql.match(/UPDATE races SET (.+) WHERE id =/);
|
||||
const updated = { ...existing, updated_at: new Date() };
|
||||
const setColumns =
|
||||
setMatch?.[1]
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => !part.startsWith("updated_at"))
|
||||
.map((part) => part.split("=")[0]?.trim())
|
||||
.filter((col): col is string => Boolean(col)) ?? [];
|
||||
|
||||
setColumns.forEach((col, index) => {
|
||||
(updated as unknown as Record<string, unknown>)[col] = p[index] ?? null;
|
||||
});
|
||||
store.set(id, updated);
|
||||
return {
|
||||
rows: [updated as unknown as T],
|
||||
rowCount: 1,
|
||||
command: "UPDATE",
|
||||
oid: 0,
|
||||
fields: [],
|
||||
} as QueryResult<T>;
|
||||
}
|
||||
|
||||
if (sql.includes("SELECT * FROM races WHERE id =")) {
|
||||
const id = String(p[0] ?? "");
|
||||
const ownerId = p[1] != null ? String(p[1]) : null;
|
||||
const row = store.get(id);
|
||||
return row && (!ownerId || row.owner_user_id === ownerId)
|
||||
? { rows: [row as unknown as T], rowCount: 1, command: "SELECT", oid: 0, fields: [] } as QueryResult<T>
|
||||
: emptyResult();
|
||||
}
|
||||
|
||||
if (sql.includes("SELECT * FROM races")) {
|
||||
const ownerParam = p.find((value) => typeof value === "string" && /^[0-9a-f-]{36}$/i.test(value));
|
||||
const rows = ownerParam
|
||||
? Array.from(store.values()).filter((row) => row.owner_user_id === ownerParam)
|
||||
: Array.from(store.values());
|
||||
return { rows: rows as unknown as T[], rowCount: rows.length, command: "SELECT", oid: 0, fields: [] } as QueryResult<T>;
|
||||
}
|
||||
|
||||
return emptyResult();
|
||||
};
|
||||
|
||||
const mockPool = {
|
||||
query: mockQuery,
|
||||
connect: async () => {
|
||||
return {
|
||||
query: mockQuery,
|
||||
release() {},
|
||||
};
|
||||
},
|
||||
end: async () => {},
|
||||
on() {
|
||||
return mockPool;
|
||||
},
|
||||
};
|
||||
|
||||
return mockPool as unknown as Pool;
|
||||
}
|
||||
|
||||
export const pool = config.useMockDb ? createMockPool() : new Pool(poolConfig);
|
||||
|
||||
if (!config.useMockDb) {
|
||||
pool.on("error", (err) => {
|
||||
console.error("[db] Unexpected pool error:", err.message);
|
||||
});
|
||||
} else {
|
||||
console.warn("[db] Mock database enabled (CALENDAR_RUN_MOCK_DB); no PostgreSQL connection is used.");
|
||||
}
|
||||
pool.on("error", (err) => {
|
||||
console.error("[db] Unexpected pool error:", err.message);
|
||||
});
|
||||
|
||||
export async function checkDbConnection(): Promise<boolean> {
|
||||
if (config.useMockDb) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
client.release();
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "fs";
|
||||
import path from "path";
|
||||
import { pool } from "./db";
|
||||
|
||||
async function migrate() {
|
||||
export async function migrate() {
|
||||
console.log("[migrate] Running migrations…");
|
||||
|
||||
const migrationsDir = path.resolve(__dirname, "../migrations");
|
||||
@@ -36,11 +36,14 @@ async function migrate() {
|
||||
console.log("[migrate] Done.");
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
migrate().catch((err) => {
|
||||
console.error("[migrate] FAILED:", err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
if (require.main === module) {
|
||||
migrate()
|
||||
.catch((err) => {
|
||||
console.error("[migrate] FAILED:", err.message);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => pool.end());
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { isIP } from "node:net";
|
||||
|
||||
const IMAGE_META_KEYS = new Set([
|
||||
"og:image",
|
||||
"og:image:url",
|
||||
@@ -6,6 +9,8 @@ const IMAGE_META_KEYS = new Set([
|
||||
]);
|
||||
|
||||
const FETCH_TIMEOUT_MS = 5_000;
|
||||
const MAX_REDIRECTS = 3;
|
||||
const MAX_HTML_BYTES = 1_000_000;
|
||||
|
||||
function getAttribute(tag: string, name: string): string | null {
|
||||
const pattern = new RegExp(`${name}\\s*=\\s*["']([^"']+)["']`, "i");
|
||||
@@ -21,6 +26,75 @@ function toHttpUrl(value: string, baseUrl: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function isPrivateIp(address: string): boolean {
|
||||
if (isIP(address) === 4) {
|
||||
const [first, second] = address.split(".").map(Number);
|
||||
return first === 0 || first === 10 || first === 127 || first >= 224 ||
|
||||
(first === 100 && second >= 64 && second <= 127) ||
|
||||
(first === 169 && second === 254) ||
|
||||
(first === 172 && second >= 16 && second <= 31) ||
|
||||
(first === 192 && (second === 0 || second === 168)) ||
|
||||
(first === 198 && (second === 18 || second === 19));
|
||||
}
|
||||
|
||||
const normalized = address.toLowerCase();
|
||||
return normalized === "::" || normalized === "::1" || normalized.startsWith("::ffff:") ||
|
||||
/^f[cd]/.test(normalized) || /^fe[89ab]/.test(normalized) || normalized.startsWith("ff");
|
||||
}
|
||||
|
||||
async function isSafePublicHttpUrl(value: string): Promise<boolean> {
|
||||
const normalized = toHttpUrl(value, value);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hostname = new URL(normalized).hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
if (hostname === "localhost" || hostname.endsWith(".localhost")) {
|
||||
return false;
|
||||
}
|
||||
if (isIP(hostname)) {
|
||||
return !isPrivateIp(hostname);
|
||||
}
|
||||
|
||||
try {
|
||||
const addresses = await lookup(hostname, { all: true, verbatim: true });
|
||||
return addresses.length > 0 && addresses.every(({ address }) => !isPrivateIp(address));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readHtml(response: Response): Promise<string | null> {
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (contentLength && Number(contentLength) > MAX_HTML_BYTES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
return new TextDecoder().decode(Buffer.concat(chunks));
|
||||
}
|
||||
size += value.byteLength;
|
||||
if (size > MAX_HTML_BYTES) {
|
||||
await reader.cancel();
|
||||
return null;
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
function isRuncRunUrl(value: string): boolean {
|
||||
try {
|
||||
const hostname = new URL(value).hostname.toLowerCase();
|
||||
@@ -84,17 +158,30 @@ export async function extractRaceCoverImage(officialUrl: string): Promise<string
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(normalizedUrl, {
|
||||
redirect: "follow",
|
||||
signal: controller.signal,
|
||||
});
|
||||
let pageUrl = normalizedUrl;
|
||||
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
|
||||
if (!(await isSafePublicHttpUrl(pageUrl))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
const response = await fetch(pageUrl, { redirect: "manual", signal: controller.signal });
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get("location");
|
||||
const nextUrl = location ? toHttpUrl(location, pageUrl) : null;
|
||||
if (!nextUrl) {
|
||||
return null;
|
||||
}
|
||||
pageUrl = nextUrl;
|
||||
continue;
|
||||
}
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const html = await readHtml(response);
|
||||
return html == null ? null : extractRaceCoverImageFromHtml(html, pageUrl);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
return extractRaceCoverImageFromHtml(html, response.url || normalizedUrl);
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { z } from "zod";
|
||||
import { pool } from "../db";
|
||||
import { rowToDto, bodyToColumns, RaceRow } from "../mappers/race";
|
||||
import { extractRaceCoverImage } from "../raceCoverImage";
|
||||
@@ -21,6 +22,87 @@ function validationError(res: Response, details: string[]) {
|
||||
res.status(400).json(body);
|
||||
}
|
||||
|
||||
const optionalText = (max: number) => z.string().trim().max(max).nullable().optional();
|
||||
const httpUrl = z.string().trim().max(2048).url().refine(
|
||||
(value) => {
|
||||
try {
|
||||
const { protocol } = new URL(value);
|
||||
return protocol === "http:" || protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
"Must be an HTTP(S) URL",
|
||||
);
|
||||
const date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD").refine((value) => {
|
||||
const parsed = new Date(`${value}T00:00:00.000Z`);
|
||||
return !Number.isNaN(parsed.valueOf()) && parsed.toISOString().slice(0, 10) === value;
|
||||
}, "Must be a valid calendar date");
|
||||
|
||||
const raceFields = {
|
||||
slug: z.string().trim().min(1).max(120).regex(/^[\p{L}\p{N}]+(?:-[\p{L}\p{N}]+)*$/u, "Must be a slug"),
|
||||
date,
|
||||
title: z.string().trim().min(1).max(200),
|
||||
distanceKm: z.number().finite().gt(0).max(999.999),
|
||||
status: z.enum(["planned", "registered", "completed"]).nullable(),
|
||||
officialUrl: httpUrl.nullable(),
|
||||
coverImageUrl: httpUrl.nullable(),
|
||||
startTime: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/, "Must be HH:MM or HH:MM:SS").nullable(),
|
||||
clusterSchedule: optionalText(1_000),
|
||||
bibPickup: optionalText(500),
|
||||
bibNumber: optionalText(100),
|
||||
finishTime: z.string().regex(/^(?:[0-5]\d|[0-5]?\d:[0-5]\d|\d+:[0-5]\d:[0-5]\d)$/, "Must be SS, MM:SS or H:MM:SS").nullable(),
|
||||
finishPlace: optionalText(100),
|
||||
notes: optionalText(5_000),
|
||||
};
|
||||
|
||||
const createRaceSchema = z.object({
|
||||
slug: raceFields.slug,
|
||||
date: raceFields.date,
|
||||
title: raceFields.title,
|
||||
distanceKm: raceFields.distanceKm,
|
||||
status: raceFields.status.optional(),
|
||||
officialUrl: raceFields.officialUrl.optional(),
|
||||
coverImageUrl: raceFields.coverImageUrl.optional(),
|
||||
startTime: raceFields.startTime.optional(),
|
||||
clusterSchedule: raceFields.clusterSchedule,
|
||||
bibPickup: raceFields.bibPickup,
|
||||
bibNumber: raceFields.bibNumber,
|
||||
finishTime: raceFields.finishTime.optional(),
|
||||
finishPlace: raceFields.finishPlace,
|
||||
notes: raceFields.notes,
|
||||
}).strict();
|
||||
|
||||
const updateRaceSchema = z.object({
|
||||
slug: raceFields.slug.optional(),
|
||||
date: raceFields.date.optional(),
|
||||
title: raceFields.title.optional(),
|
||||
distanceKm: raceFields.distanceKm.optional(),
|
||||
status: raceFields.status.optional(),
|
||||
officialUrl: raceFields.officialUrl.optional(),
|
||||
coverImageUrl: raceFields.coverImageUrl.optional(),
|
||||
startTime: raceFields.startTime.optional(),
|
||||
clusterSchedule: raceFields.clusterSchedule,
|
||||
bibPickup: raceFields.bibPickup,
|
||||
bibNumber: raceFields.bibNumber,
|
||||
finishTime: raceFields.finishTime.optional(),
|
||||
finishPlace: raceFields.finishPlace,
|
||||
notes: raceFields.notes,
|
||||
}).strict().refine((body) => Object.keys(body).length > 0, "No updatable fields provided");
|
||||
|
||||
function schemaDetails(error: z.ZodError): string[] {
|
||||
return error.issues.map((issue) => `${issue.path.join(".") || "body"}: ${issue.message}`);
|
||||
}
|
||||
|
||||
function raceIdFromRequest(req: Request, res: Response): string | null {
|
||||
const parsed = z.string().uuid().safeParse(req.params.id);
|
||||
if (parsed.success) {
|
||||
return parsed.data;
|
||||
}
|
||||
validationError(res, ["id: Must be a UUID"]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseOptionalIntegerQuery(
|
||||
value: unknown,
|
||||
fieldName: string,
|
||||
@@ -97,10 +179,14 @@ router.get("/races", async (req: Request, res: Response) => {
|
||||
/* ─── GET /races/:id ──────────────────────────────────────── */
|
||||
|
||||
router.get("/races/:id", async (req: Request, res: Response) => {
|
||||
const id = raceIdFromRequest(req, res);
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { rows } = await pool.query<RaceRow>(
|
||||
"SELECT * FROM races WHERE id = $1 AND owner_user_id = $2",
|
||||
[req.params.id, req.auth!.user.id],
|
||||
[id, req.auth!.user.id],
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
res.status(404).json({ error: "not_found", details: ["Race not found"] });
|
||||
@@ -116,31 +202,24 @@ router.get("/races/:id", async (req: Request, res: Response) => {
|
||||
/* ─── POST /races ─────────────────────────────────────────── */
|
||||
|
||||
router.post("/races", async (req: Request, res: Response) => {
|
||||
const body = req.body;
|
||||
|
||||
const slug = typeof body.slug === "string" && body.slug.trim() ? body.slug.trim() : body.id;
|
||||
|
||||
if (!slug || !body.date || !body.title || body.distanceKm == null) {
|
||||
validationError(res, ["Fields slug, date, title, distanceKm are required"]);
|
||||
const parsed = createRaceSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
validationError(res, schemaDetails(parsed.error));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { ...body, slug };
|
||||
const payload = { ...parsed.data };
|
||||
const hasManualCover = typeof payload.coverImageUrl === "string" && payload.coverImageUrl.trim() !== "";
|
||||
const hasOfficialUrl = typeof payload.officialUrl === "string" && payload.officialUrl.trim() !== "";
|
||||
const officialUrl = payload.officialUrl;
|
||||
const hasOfficialUrl = typeof officialUrl === "string" && officialUrl.trim() !== "";
|
||||
|
||||
if (!hasManualCover && hasOfficialUrl) {
|
||||
payload.coverImageUrl = await extractRaceCoverImage(payload.officialUrl);
|
||||
payload.coverImageUrl = await extractRaceCoverImage(officialUrl);
|
||||
}
|
||||
|
||||
const { columns, values } = bodyToColumns(payload);
|
||||
const { columns, values } = bodyToColumns(payload as Record<string, unknown>);
|
||||
columns.unshift("owner_user_id");
|
||||
values.unshift(req.auth!.user.id);
|
||||
if (!columns.includes("slug")) {
|
||||
columns.push("slug");
|
||||
values.push(slug);
|
||||
}
|
||||
|
||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||
const sql = `INSERT INTO races (${columns.join(", ")}) VALUES (${placeholders}) RETURNING *`;
|
||||
|
||||
@@ -163,16 +242,20 @@ router.post("/races", async (req: Request, res: Response) => {
|
||||
/* ─── PATCH /races/:id ────────────────────────────────────── */
|
||||
|
||||
router.patch("/races/:id", async (req: Request, res: Response) => {
|
||||
const { columns, values } = bodyToColumns(req.body);
|
||||
|
||||
if (columns.length === 0) {
|
||||
validationError(res, ["No updatable fields provided"]);
|
||||
const id = raceIdFromRequest(req, res);
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const parsed = updateRaceSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
validationError(res, schemaDetails(parsed.error));
|
||||
return;
|
||||
}
|
||||
const { columns, values } = bodyToColumns(parsed.data as Record<string, unknown>);
|
||||
|
||||
const sets = columns.map((col, i) => `${col} = $${i + 1}`);
|
||||
sets.push(`updated_at = NOW()`);
|
||||
values.push(req.params.id);
|
||||
values.push(id);
|
||||
values.push(req.auth!.user.id);
|
||||
const sql = `UPDATE races SET ${sets.join(", ")} WHERE id = $${values.length - 1} AND owner_user_id = $${values.length} RETURNING *`;
|
||||
|
||||
@@ -192,10 +275,14 @@ router.patch("/races/:id", async (req: Request, res: Response) => {
|
||||
/* ─── DELETE /races/:id ───────────────────────────────────── */
|
||||
|
||||
router.delete("/races/:id", async (req: Request, res: Response) => {
|
||||
const id = raceIdFromRequest(req, res);
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { rowCount } = await pool.query(
|
||||
"DELETE FROM races WHERE id = $1 AND owner_user_id = $2",
|
||||
[req.params.id, req.auth!.user.id],
|
||||
[id, req.auth!.user.id],
|
||||
);
|
||||
if (rowCount === 0) {
|
||||
res.status(404).json({ error: "not_found", details: ["Race not found"] });
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
21
backend/test/setup.ts
Normal file
21
backend/test/setup.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
process.env.NODE_ENV ??= "test";
|
||||
process.env.SECURITY_PROFILE ??= "test";
|
||||
process.env.SESSION_SECRET ??= "test-session-secret";
|
||||
process.env.TURNSTILE_BYPASS_TOKEN ??= "mock-turnstile-token";
|
||||
process.env.DB_HOST ??= "127.0.0.1";
|
||||
process.env.DB_PORT ??= "5432";
|
||||
process.env.DB_NAME ??= "calendar_run_test";
|
||||
process.env.DB_USER ??= "postgres";
|
||||
process.env.DB_PASSWORD ??= "postgres";
|
||||
|
||||
if (process.env.DB_NAME !== "calendar_run_test") {
|
||||
throw new Error("Tests require DB_NAME=calendar_run_test");
|
||||
}
|
||||
|
||||
const [{ migrate }, { pool }] = await Promise.all([
|
||||
import("../src/migrate"),
|
||||
import("../src/db"),
|
||||
]);
|
||||
|
||||
await migrate();
|
||||
await pool.query("TRUNCATE app_settings, races, users RESTART IDENTITY CASCADE");
|
||||
Reference in New Issue
Block a user