batchCooking/apps/api/test/auth.test.ts
kyuno053 42d094764f
API: signup/login (profile creation + JWT auth) (#4)
* Add signup/login (profile creation + JWT auth)

API:
- POST /auth/signup — creates a house + user_profile (transactional),
  hashes the password with argon2, sets a JWT in an httpOnly cookie
- POST /auth/login — verifies credentials (generic 401 for both wrong
  email and wrong password, doesn't leak which), sets the cookie
- POST /auth/logout — clears the cookie
- GET /auth/me — current profile, behind requireAuth middleware
- requireAuth verifies the JWT and re-checks tokenVersion against the
  DB, so a stateless JWT can still be invalidated (password change /
  logout-everywhere, not built yet but the field is in place)

Schema: user_profiles gets password_hash + token_version (not in the
original spec doc — required for auth). New migration, with
COMMENT ON for the new columns per the established pattern.

Decisions from the auth planning discussion: JWT in httpOnly cookie
(not server-side sessions), first profile created also creates its
house, argon2 for hashing.

argon2 pinned to 0.31.2 (not ^, deliberately): 0.45.1 segfaults at
runtime on this Windows machine — reproduced consistently across bash
(sandboxed and unsandboxed) and PowerShell, while 0.31.2 works fine
with the same API. Documented in the README as a trap for future
upgrades, since `tsc`/`prisma generate` succeeding doesn't catch a
runtime native-binding crash.

Tests: Mocha (unit-style, apps/api/test/auth.test.ts) and a Cucumber
feature (apps/api/features/auth.feature) covering the full signup →
authenticated flow, duplicate email, wrong password. Both share
test-support/reset-db.ts (TRUNCATE ... CASCADE) to start each
test/scenario from a clean slate. Test-only argon2 cost parameters
(NODE_ENV=test) keep the suite fast — argon2's real cost is
deliberately expensive, which made hashing dozens of times per run
slow and occasionally timeout-flaky at default cost.

CI: added a Postgres service container to lint-and-test (previously
none — tests didn't touch a real DB), runs `prisma migrate deploy`
before the test steps.

Verified end-to-end manually against the dev server (curl): signup,
duplicate email (409), wrong password (401), valid login (200),
validation errors (400), /me with and without cookie, logout (204) —
all behave as intended. Full suite (lint, mocha, cucumber, build) run
multiple times locally with no flakiness after the timeout/cost fixes.

* Fix CI: generate Prisma Client via postinstall

CI failed with "@prisma/client did not initialize yet" — pnpm install
never ran `prisma generate`, and `prisma migrate deploy` (unlike
`migrate dev`) doesn't do it either. Worked locally only because prior
`prisma migrate dev` runs had already generated the client as a side
effect.

Adding a postinstall script fixes it for CI and for anyone cloning the
repo fresh and running plain `pnpm install`.
2026-08-16 13:45:23 +02:00

103 lines
3.1 KiB
TypeScript

import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { resetDatabase } from "../test-support/reset-db.js";
const validSignup = {
firstName: "Nicolas",
lastName: "Lefevre",
email: "nicolas@example.com",
password: "correct-horse-battery-staple",
};
describe("Auth", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("POST /auth/signup", () => {
it("creates a profile and its house, and sets a session cookie", async () => {
const res = await request(app).post("/auth/signup").send(validSignup);
expect(res.status).to.equal(201);
expect(res.body).to.include({
firstName: "Nicolas",
lastName: "Lefevre",
email: "nicolas@example.com",
});
expect(res.body).to.not.have.property("passwordHash");
expect(res.body.houseId).to.be.a("number");
expect(res.headers["set-cookie"]?.[0]).to.include("session=");
});
it("rejects a duplicate email with 409", async () => {
await request(app).post("/auth/signup").send(validSignup);
const res = await request(app).post("/auth/signup").send(validSignup);
expect(res.status).to.equal(409);
});
it("rejects an invalid payload with 400", async () => {
const res = await request(app)
.post("/auth/signup")
.send({ firstName: "X", lastName: "Y", email: "not-an-email", password: "short" });
expect(res.status).to.equal(400);
});
});
describe("POST /auth/login", () => {
beforeEach(async () => {
await request(app).post("/auth/signup").send(validSignup);
});
it("logs in with correct credentials", async () => {
const res = await request(app)
.post("/auth/login")
.send({ email: validSignup.email, password: validSignup.password });
expect(res.status).to.equal(200);
expect(res.body.email).to.equal(validSignup.email);
});
it("rejects a wrong password with 401", async () => {
const res = await request(app)
.post("/auth/login")
.send({ email: validSignup.email, password: "wrong-password" });
expect(res.status).to.equal(401);
});
it("rejects an unknown email with 401", async () => {
const res = await request(app)
.post("/auth/login")
.send({ email: "nobody@example.com", password: validSignup.password });
expect(res.status).to.equal(401);
});
});
describe("GET /auth/me", () => {
it("rejects requests without a session cookie", async () => {
const res = await request(app).get("/auth/me");
expect(res.status).to.equal(401);
});
it("returns the current profile when authenticated", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(validSignup);
const res = await agent.get("/auth/me");
expect(res.status).to.equal(200);
expect(res.body.email).to.equal(validSignup.email);
});
});
});