feat: add registration and authentication
This commit is contained in:
@@ -2,9 +2,46 @@ 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 { 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) {
|
||||
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],
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
test("GET /api/health returns ok", async () => {
|
||||
const res = await request(app).get("/api/health").expect(200);
|
||||
@@ -26,27 +63,81 @@ test("GET /api/ready succeeds with mock database", async () => {
|
||||
});
|
||||
|
||||
test("GET /api/races rejects invalid year", async () => {
|
||||
const res = await request(app).get("/api/races?year=bad").expect(400);
|
||||
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 res = await request(app).get("/api/races?month=13").expect(400);
|
||||
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 res = await request(app).get("/api/races?year=2026&month=5").expect(200);
|
||||
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 res = await request(app).get("/api/races/does-not-exist").expect(404);
|
||||
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);
|
||||
});
|
||||
|
||||
test("extractRaceCoverImageFromHtml prefers runc.run intro image", () => {
|
||||
const html = `
|
||||
<meta property="og:image" content="https://example.com/og.jpg">
|
||||
@@ -81,11 +172,13 @@ test("extractRaceCoverImageFromHtml reads Open Graph and Twitter images", () =>
|
||||
});
|
||||
|
||||
test("POST /api/races stores manual coverImageUrl", async () => {
|
||||
const { agent, csrfToken } = await authAgent();
|
||||
const coverImageUrl = "https://example.com/manual.jpg";
|
||||
const res = await request(app)
|
||||
const res = await agent
|
||||
.post("/api/races")
|
||||
.set("X-CSRF-Token", csrfToken)
|
||||
.send({
|
||||
id: "2026-06-01-manual-cover",
|
||||
slug: "2026-06-01-manual-cover",
|
||||
date: "2026-06-01",
|
||||
title: "Manual Cover",
|
||||
distanceKm: 10,
|
||||
@@ -106,10 +199,12 @@ test("POST /api/races auto extracts coverImageUrl from officialUrl", async () =>
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await request(app)
|
||||
const { agent, csrfToken } = await authAgent();
|
||||
const res = await agent
|
||||
.post("/api/races")
|
||||
.set("X-CSRF-Token", csrfToken)
|
||||
.send({
|
||||
id: "2026-06-02-auto-cover",
|
||||
slug: "2026-06-02-auto-cover",
|
||||
date: "2026-06-02",
|
||||
title: "Auto Cover",
|
||||
distanceKm: 21.1,
|
||||
@@ -130,10 +225,12 @@ test("POST /api/races succeeds when cover extraction fails", async () => {
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await request(app)
|
||||
const { agent, csrfToken } = await authAgent();
|
||||
const res = await agent
|
||||
.post("/api/races")
|
||||
.set("X-CSRF-Token", csrfToken)
|
||||
.send({
|
||||
id: "2026-06-03-cover-fail",
|
||||
slug: "2026-06-03-cover-fail",
|
||||
date: "2026-06-03",
|
||||
title: "Cover Fail",
|
||||
distanceKm: 5,
|
||||
@@ -148,11 +245,12 @@ test("POST /api/races succeeds when cover extraction fails", async () => {
|
||||
});
|
||||
|
||||
test("PATCH /api/races/:id updates coverImageUrl explicitly", async () => {
|
||||
const id = "2026-06-04-patch-cover";
|
||||
await request(app)
|
||||
const { agent, csrfToken } = await authAgent();
|
||||
const created = await agent
|
||||
.post("/api/races")
|
||||
.set("X-CSRF-Token", csrfToken)
|
||||
.send({
|
||||
id,
|
||||
slug: "2026-06-04-patch-cover",
|
||||
date: "2026-06-04",
|
||||
title: "Patch Cover",
|
||||
distanceKm: 10,
|
||||
@@ -160,8 +258,9 @@ test("PATCH /api/races/:id updates coverImageUrl explicitly", async () => {
|
||||
.expect(201);
|
||||
|
||||
const coverImageUrl = "https://example.com/patched.jpg";
|
||||
const res = await request(app)
|
||||
.patch(`/api/races/${id}`)
|
||||
const res = await agent
|
||||
.patch(`/api/races/${created.body.id}`)
|
||||
.set("X-CSRF-Token", csrfToken)
|
||||
.send({ coverImageUrl })
|
||||
.expect(200);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user