feature/registration-auth #37
@@ -38,10 +38,11 @@ SESSION_SECRET=replace_with_32plus_char_random_secret
|
||||
# SESSION_COOKIE_NAME=__Host-sid
|
||||
# SESSION_COOKIE_SECURE=true
|
||||
# SESSION_TTL_DAYS=30
|
||||
# AUTH_CLEANUP_INTERVAL_HOURS=24
|
||||
|
||||
# ─── Cloudflare Turnstile ────────────────────────────────────
|
||||
TURNSTILE_SECRET_KEY=replace_with_turnstile_secret
|
||||
# Local tests/dev only, never production:
|
||||
# Local tests/dev only, rejected in production:
|
||||
# TURNSTILE_BYPASS_TOKEN=mock-turnstile-token
|
||||
|
||||
# ─── SMTP email ──────────────────────────────────────────────
|
||||
|
||||
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "calendar-run-backend",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "calendar-run-backend",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.1",
|
||||
"dependencies": {
|
||||
"argon2": "^0.44.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "calendar-run-backend",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
|
||||
@@ -8,32 +8,37 @@ import authRouter from "./routes/auth";
|
||||
import healthRouter from "./routes/health";
|
||||
import racesRouter from "./routes/races";
|
||||
|
||||
export function createApp(): express.Express {
|
||||
const app = express();
|
||||
const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com";
|
||||
|
||||
app.use(
|
||||
helmet({
|
||||
export function buildHelmetOptions(securityProfile: string) {
|
||||
return {
|
||||
contentSecurityPolicy:
|
||||
config.securityProfile === "production"
|
||||
securityProfile === "production"
|
||||
? {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", TURNSTILE_ORIGIN],
|
||||
styleSrc: ["'self'"],
|
||||
imgSrc: ["'self'", "data:", "https:"],
|
||||
connectSrc: ["'self'"],
|
||||
connectSrc: ["'self'", TURNSTILE_ORIGIN],
|
||||
frameSrc: [TURNSTILE_ORIGIN],
|
||||
objectSrc: ["'none'"],
|
||||
frameAncestors: ["'none'"],
|
||||
},
|
||||
}
|
||||
: false,
|
||||
hsts:
|
||||
config.securityProfile === "production"
|
||||
securityProfile === "production"
|
||||
? { maxAge: 31_536_000, includeSubDomains: true }
|
||||
: false,
|
||||
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
|
||||
}),
|
||||
);
|
||||
referrerPolicy: { policy: "strict-origin-when-cross-origin" as const },
|
||||
};
|
||||
}
|
||||
|
||||
export function createApp(): express.Express {
|
||||
const app = express();
|
||||
|
||||
app.use(helmet(buildHelmetOptions(config.securityProfile)));
|
||||
app.use(
|
||||
cors({
|
||||
origin: config.corsOrigin,
|
||||
|
||||
@@ -58,6 +58,10 @@ function appUrl(path: string, token: string): string {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return typeof error === "object" && error !== null && (error as { code?: unknown }).code === "23505";
|
||||
}
|
||||
|
||||
export async function findUserByEmail(email: string): Promise<UserRow | null> {
|
||||
const normalized = normalizeEmail(email);
|
||||
const { rows } = await pool.query<UserRow>(
|
||||
@@ -114,20 +118,25 @@ export async function sendResetEmail(email: string, token: string): Promise<void
|
||||
export async function registerUser(email: string, password: string): Promise<void> {
|
||||
const normalized = normalizeEmail(email);
|
||||
const passwordHash = await hashPassword(password);
|
||||
const existing = await findUserByEmail(normalized);
|
||||
if (existing) {
|
||||
securityLog("register.existing", { emailHash: anonymizeEmail(normalized) });
|
||||
return;
|
||||
}
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const { rows } = await client.query<UserRow>(
|
||||
let rows: UserRow[];
|
||||
try {
|
||||
({ rows } = await client.query<UserRow>(
|
||||
`INSERT INTO users (email, password_hash)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, email, password_hash, email_verified_at`,
|
||||
[normalized, passwordHash],
|
||||
);
|
||||
));
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
await client.query("ROLLBACK");
|
||||
securityLog("register.existing", { emailHash: anonymizeEmail(normalized) });
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const user = rows[0];
|
||||
if (user) {
|
||||
const token = await createVerificationToken(client, user.id);
|
||||
@@ -239,14 +248,27 @@ export async function verifyEmailToken(token: string): Promise<boolean> {
|
||||
const { rows } = await client.query<{ id: string; user_id: string; token_hash: string }>(
|
||||
`SELECT id, user_id, token_hash
|
||||
FROM email_verification_tokens
|
||||
WHERE used_at IS NULL AND expires_at > NOW()`,
|
||||
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > NOW()
|
||||
FOR UPDATE`,
|
||||
[tokenHash],
|
||||
);
|
||||
const row = rows.find((candidate) => timingSafeEqualHex(candidate.token_hash, tokenHash));
|
||||
if (!row) {
|
||||
await client.query("ROLLBACK");
|
||||
return false;
|
||||
}
|
||||
await client.query("UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL", [row.user_id]);
|
||||
const consumed = await client.query(
|
||||
"UPDATE email_verification_tokens SET used_at = NOW() WHERE id = $1 AND used_at IS NULL RETURNING id",
|
||||
[row.id],
|
||||
);
|
||||
if (consumed.rowCount === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return false;
|
||||
}
|
||||
await client.query(
|
||||
"UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id = $1 AND id <> $2 AND used_at IS NULL",
|
||||
[row.user_id, row.id],
|
||||
);
|
||||
await client.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()), updated_at = NOW() WHERE id = $1", [row.user_id]);
|
||||
await client.query("SELECT pg_advisory_xact_lock(706365)");
|
||||
const claimed = await client.query("SELECT value FROM app_settings WHERE key = 'orphan_races_claimed_by_user_id' FOR UPDATE");
|
||||
@@ -300,14 +322,27 @@ export async function resetPassword(token: string, password: string): Promise<bo
|
||||
const { rows } = await client.query<{ id: string; user_id: string; token_hash: string }>(
|
||||
`SELECT id, user_id, token_hash
|
||||
FROM password_reset_tokens
|
||||
WHERE used_at IS NULL AND expires_at > NOW()`,
|
||||
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > NOW()
|
||||
FOR UPDATE`,
|
||||
[tokenHash],
|
||||
);
|
||||
const row = rows.find((candidate) => timingSafeEqualHex(candidate.token_hash, tokenHash));
|
||||
if (!row) {
|
||||
await client.query("ROLLBACK");
|
||||
return false;
|
||||
}
|
||||
await client.query("UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL", [row.user_id]);
|
||||
const consumed = await client.query(
|
||||
"UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1 AND used_at IS NULL RETURNING id",
|
||||
[row.id],
|
||||
);
|
||||
if (consumed.rowCount === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return false;
|
||||
}
|
||||
await client.query(
|
||||
"UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id = $1 AND id <> $2 AND used_at IS NULL",
|
||||
[row.user_id, row.id],
|
||||
);
|
||||
await client.query("UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1", [row.user_id, passwordHash]);
|
||||
await client.query("UPDATE sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL", [row.user_id]);
|
||||
await client.query("COMMIT");
|
||||
@@ -346,3 +381,16 @@ export async function cleanupExpiredAuthRows(): Promise<void> {
|
||||
await pool.query("DELETE FROM email_verification_tokens WHERE expires_at <= NOW() OR used_at IS NOT NULL");
|
||||
await pool.query("DELETE FROM password_reset_tokens WHERE expires_at <= NOW() OR used_at IS NOT NULL");
|
||||
}
|
||||
|
||||
export function startAuthCleanupSchedule(intervalHours = config.authCleanupIntervalHours): NodeJS.Timeout {
|
||||
const runCleanup = () => {
|
||||
void cleanupExpiredAuthRows().catch((error) => {
|
||||
console.error("[auth-cleanup] Failed:", error);
|
||||
});
|
||||
};
|
||||
runCleanup();
|
||||
const safeHours = Number.isFinite(intervalHours) && intervalHours > 0 ? intervalHours : 24;
|
||||
const timer = setInterval(runCleanup, safeHours * 60 * 60 * 1000);
|
||||
timer.unref?.();
|
||||
return timer;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,23 @@ 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) {
|
||||
throw new Error("TURNSTILE_BYPASS_TOKEN is not allowed in production");
|
||||
}
|
||||
if (raw) {
|
||||
return raw;
|
||||
}
|
||||
return params.useMockDb ? "mock-turnstile-token" : "";
|
||||
}
|
||||
|
||||
export const config = {
|
||||
useMockDb,
|
||||
db: useMockDb
|
||||
@@ -73,9 +90,14 @@ export const config = {
|
||||
},
|
||||
turnstile: {
|
||||
secretKey: process.env.TURNSTILE_SECRET_KEY?.trim() || (useMockDb ? "mock-turnstile-secret" : ""),
|
||||
bypassToken: process.env.TURNSTILE_BYPASS_TOKEN?.trim() || (useMockDb ? "mock-turnstile-token" : ""),
|
||||
bypassToken: resolveTurnstileBypassToken({
|
||||
rawBypassToken: process.env.TURNSTILE_BYPASS_TOKEN,
|
||||
securityProfile,
|
||||
useMockDb,
|
||||
}),
|
||||
},
|
||||
securityProfile: process.env.SECURITY_PROFILE?.trim() || process.env.NODE_ENV || "development",
|
||||
authCleanupIntervalHours: parseInt(process.env.AUTH_CLEANUP_INTERVAL_HOURS || "24", 10),
|
||||
securityProfile,
|
||||
};
|
||||
|
||||
function parseCorsOrigins(): string | string[] {
|
||||
|
||||
@@ -101,6 +101,24 @@ function createMockPool(): Pool {
|
||||
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);
|
||||
@@ -114,6 +132,13 @@ function createMockPool(): Pool {
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -141,16 +166,37 @@ function createMockPool(): Pool {
|
||||
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.used_at == null) {
|
||||
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(
|
||||
@@ -172,16 +218,37 @@ function createMockPool(): Pool {
|
||||
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.used_at == null) {
|
||||
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(
|
||||
@@ -273,6 +340,44 @@ function createMockPool(): Pool {
|
||||
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();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { config } from "./config";
|
||||
import { createApp } from "./app";
|
||||
import { startAuthCleanupSchedule } from "./authService";
|
||||
|
||||
const app = createApp();
|
||||
startAuthCleanupSchedule();
|
||||
|
||||
app.listen(config.apiPort, () => {
|
||||
console.log(`[api] Listening on http://localhost:${config.apiPort}`);
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../src/app";
|
||||
import { createResetToken, resetPassword } from "../src/authService";
|
||||
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";
|
||||
@@ -30,6 +37,14 @@ async function authAgent() {
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -37,12 +52,22 @@ async function createVerifiedUser(email: string, password: string) {
|
||||
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");
|
||||
@@ -62,6 +87,61 @@ test("GET /api/ready succeeds with mock database", async () => {
|
||||
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);
|
||||
@@ -136,6 +216,63 @@ test("new password reset token invalidates previous token", async () => {
|
||||
|
||||
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", () => {
|
||||
|
||||
Reference in New Issue
Block a user