import { ErrorCode, type SignupInput } from "@batch-cooking/shared"; import { faker } from "@faker-js/faker"; 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"; /** * Builds a fresh, fake (never real-looking) signup payload. Called anew per * test rather than sharing one module-level constant, so tests never * accidentally depend on a specific fixture value and each run exercises * different data — closer to how the app actually gets used. */ function buildSignupPayload(): SignupInput { const firstName = faker.person.firstName(); const lastName = faker.person.lastName(); return { firstName, lastName, // Lowercased to match what signupSchema/loginSchema normalize the email // to (`.toLowerCase()`) — faker sometimes capitalizes parts of it, and // without this the fixture value stops matching what's actually stored. email: faker.internet.email({ firstName, lastName }).toLowerCase(), password: faker.internet.password({ length: 16 }), }; } describe("Auth", () => { const app = createApp(); beforeEach(async () => { await resetDatabase(); }); after(async () => { await prisma.$disconnect(); }); describe("POST /auth/signup", () => { it("creates a profile without a household yet, and sets a session cookie", async () => { const payload = buildSignupPayload(); const res = await request(app).post("/auth/signup").send(payload); expect(res.status).to.equal(201); expect(res.body).to.include({ firstName: payload.firstName, lastName: payload.lastName, email: payload.email, }); expect(res.body).to.not.have.property("passwordHash"); // No household is created at signup anymore — it's an optional // onboarding step (create/join/skip), see house.test.ts. expect(res.body.houseId).to.equal(null); expect(res.headers["set-cookie"]?.[0]).to.include("session="); }); it("also echoes the session JWT in the response body (for cookie-less clients)", async () => { const res = await request(app).post("/auth/signup").send(buildSignupPayload()); expect(res.status).to.equal(201); // Same token that's set as the cookie — see `AuthResponse` // (packages/shared). Lets a Capacitor WebView, whose origin can't // receive the SameSite cookie, store it and send it as a Bearer. expect(res.body.token).to.be.a("string").with.length.greaterThan(0); expect(res.headers["set-cookie"]?.[0]).to.include(`session=${res.body.token}`); }); it("rejects a duplicate email with 409 EMAIL_ALREADY_IN_USE", async () => { const payload = buildSignupPayload(); await request(app).post("/auth/signup").send(payload); const res = await request(app).post("/auth/signup").send(payload); expect(res.status).to.equal(409); expect(res.body.code).to.equal(ErrorCode.EMAIL_ALREADY_IN_USE); }); it("rejects an invalid payload with 400 VALIDATION_ERROR", async () => { const res = await request(app).post("/auth/signup").send({ firstName: faker.person.firstName(), lastName: faker.person.lastName(), email: "not-an-email", password: "short", }); expect(res.status).to.equal(400); expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); expect(res.body.details).to.have.keys(["email", "password"]); }); }); describe("POST /auth/login", () => { let payload: SignupInput; beforeEach(async () => { payload = buildSignupPayload(); await request(app).post("/auth/signup").send(payload); }); it("logs in with correct credentials", async () => { const res = await request(app) .post("/auth/login") .send({ email: payload.email, password: payload.password }); expect(res.status).to.equal(200); expect(res.body.email).to.equal(payload.email); }); it("echoes the session JWT in the response body too", async () => { const res = await request(app) .post("/auth/login") .send({ email: payload.email, password: payload.password }); expect(res.status).to.equal(200); expect(res.body.token).to.be.a("string").with.length.greaterThan(0); expect(res.headers["set-cookie"]?.[0]).to.include(`session=${res.body.token}`); }); it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => { const res = await request(app) .post("/auth/login") .send({ email: payload.email, password: faker.internet.password({ length: 16 }) }); expect(res.status).to.equal(401); expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS); }); it("rejects an unknown email with 401 INVALID_CREDENTIALS", async () => { const res = await request(app) .post("/auth/login") .send({ email: faker.internet.email(), password: payload.password }); expect(res.status).to.equal(401); expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS); }); }); describe("GET /auth/me", () => { it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { const res = await request(app).get("/auth/me"); expect(res.status).to.equal(401); expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); }); it("returns the current profile when authenticated", async () => { const payload = buildSignupPayload(); const agent = request.agent(app); await agent.post("/auth/signup").send(payload); const res = await agent.get("/auth/me"); expect(res.status).to.equal(200); expect(res.body.email).to.equal(payload.email); }); }); // The Bearer path exists for clients whose origin can't carry the // `SameSite=Lax` session cookie (a Capacitor WebView — issue #39). The // cookie stays the primary mechanism; the header is only consulted when // no cookie is present. `request(app)` (no `.agent`) sends no cookie, so // these requests exercise the fallback in isolation. describe("Authorization: Bearer fallback (cookie-less clients)", () => { it("authenticates a protected route with a valid Bearer token and no cookie", async () => { const payload = buildSignupPayload(); const signupRes = await request(app).post("/auth/signup").send(payload); const res = await request(app) .get("/auth/me") .set("Authorization", `Bearer ${signupRes.body.token}`); expect(res.status).to.equal(200); expect(res.body.email).to.equal(payload.email); }); it("rejects a malformed Bearer token with 401 NOT_AUTHENTICATED", async () => { const res = await request(app).get("/auth/me").set("Authorization", "Bearer not-a-real-jwt"); expect(res.status).to.equal(401); expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); }); it("ignores a non-Bearer Authorization scheme and 401s", async () => { const res = await request(app) .get("/auth/me") .set("Authorization", "Basic dXNlcjpwYXNzd29yZA=="); expect(res.status).to.equal(401); expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); }); }); describe("DELETE /auth/me", () => { it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { const res = await request(app).delete("/auth/me").send({ password: "whatever" }); expect(res.status).to.equal(401); expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); }); it("rejects a wrong password with 401 INVALID_CREDENTIALS, without deleting the profile", async () => { const payload = buildSignupPayload(); const agent = request.agent(app); const signupRes = await agent.post("/auth/signup").send(payload); const res = await agent.delete("/auth/me").send({ password: "wrong-password" }); expect(res.status).to.equal(401); expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS); expect( await prisma.userProfile.findUnique({ where: { id: signupRes.body.id } }), ).to.not.equal(null); }); it("deletes the profile and clears the session cookie", async () => { const payload = buildSignupPayload(); const agent = request.agent(app); const signupRes = await agent.post("/auth/signup").send(payload); const res = await agent.delete("/auth/me").send({ password: payload.password }); expect(res.status).to.equal(204); expect(await prisma.userProfile.findUnique({ where: { id: signupRes.body.id } })).to.equal( null, ); const meRes = await agent.get("/auth/me"); expect(meRes.status).to.equal(401); }); it("deletes the household along with the account when it's the sole member", async () => { const payload = buildSignupPayload(); const agent = request.agent(app); await agent.post("/auth/signup").send(payload); const houseRes = await agent.post("/house").send({ name: "Chez moi" }); const res = await agent.delete("/auth/me").send({ password: payload.password }); expect(res.status).to.equal(204); expect(await prisma.house.findUnique({ where: { id: houseRes.body.id } })).to.equal(null); }); it("transfers adminship to another member before deleting an admin's account", async () => { const adminPayload = buildSignupPayload(); const adminAgent = request.agent(app); const houseRes = await adminAgent .post("/auth/signup") .send(adminPayload) .then(() => adminAgent.post("/house").send({ name: "Chez nous" })); const memberPayload = buildSignupPayload(); const memberAgent = request.agent(app); const memberSignupRes = await memberAgent.post("/auth/signup").send(memberPayload); await memberAgent.post("/house/join").send({ inviteCode: houseRes.body.inviteCode }); const res = await adminAgent.delete("/auth/me").send({ password: adminPayload.password }); expect(res.status).to.equal(204); const house = await prisma.house.findUnique({ where: { id: houseRes.body.id } }); expect(house?.adminId).to.equal(memberSignupRes.body.id); }); }); });