Phase 1 du portage mobile (#39) : purement additif côté navigateur, qui continue d'utiliser le cookie `session` httpOnly inchangé. - `POST /auth/login` et `POST /auth/signup` renvoient aussi le JWT dans le corps de la réponse (`AuthResponse` = `SafeUserProfile` + `token`), en plus de poser le cookie. Un client dont l'origine ne peut pas recevoir un cookie `SameSite=Lax` (WebView Capacitor) le stocke et le renvoie. - `requireAuth` lit le cookie en priorité, puis se rabat sur un en-tête `Authorization: Bearer <jwt>` quand le cookie est absent. Les deux mécanismes coexistent, aucun n'est retiré. - `apps/web` : `ApiClient` gagne une abstraction `TokenStore` (`get`/`set`/`clear`) ; l'implémentation par défaut est un no-op navigateur (le cookie fait tout). `_request` ajoute l'en-tête `Authorization` quand un jeton existe ; `login`/`signup` le stockent, `logout`/`deleteAccount` l'effacent. `setTokenStore` est le point d'injection de la version Capacitor (`@capacitor/preferences`), branchée en phase 2 quand la dépendance sera ajoutée. Tests Mocha : `requireAuth` authentifie via l'en-tête `Authorization` sans cookie, rejette un jeton Bearer invalide ou un schéma non-Bearer ; `login`/`signup` exposent bien le jeton dans le corps. Refs #39 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
263 lines
10 KiB
TypeScript
263 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|