`GET /admin/api/users` (liste, plus récent d'abord, plafonnée à 200) et `POST /admin/api/users` (création à la main), tous deux derrière `requireAdmin`. La création génère un mot de passe temporaire aléatoire (`randomInt`, charset sans caractères ambigus, comme `generateInviteCode`) renvoyé une seule fois ; le compte démarre sans foyer, comme une inscription self-service. Évènement `admin.user_created` (fire-and-forget) pour distinguer les comptes provisionnés à la main dans le dashboard. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
160 lines
5.8 KiB
TypeScript
160 lines
5.8 KiB
TypeScript
import { ErrorCode } 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 { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
|
|
import { resetDatabase } from "../test-support/reset-db.js";
|
|
|
|
async function seedAdmin(): Promise<{ email: string; password: string }> {
|
|
const email = faker.internet.email().toLowerCase();
|
|
const password = faker.internet.password({ length: 16 });
|
|
await prisma.adminUser.create({
|
|
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
|
|
});
|
|
return { email, password };
|
|
}
|
|
|
|
/** A fresh, fake (never real-looking) create-user payload — same rationale as `auth.test.ts`'s `buildSignupPayload`. */
|
|
function buildCreateUserPayload() {
|
|
const firstName = faker.person.firstName();
|
|
const lastName = faker.person.lastName();
|
|
return {
|
|
firstName,
|
|
lastName,
|
|
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* `/admin/api/users/*` — the admin surface for provisioning end-user
|
|
* accounts by hand (`/admin/utilisateurs` page). Every route behind
|
|
* `requireAdmin`. Creation generates a one-time temporary password and
|
|
* returns it in the response; the account starts with no household, exactly
|
|
* like a self-service signup.
|
|
*/
|
|
describe("Admin users — account provisioning", () => {
|
|
const app = createApp();
|
|
|
|
beforeEach(async () => {
|
|
await resetDatabase();
|
|
});
|
|
|
|
after(async () => {
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
async function adminAgent() {
|
|
const { email, password } = await seedAdmin();
|
|
const agent = request.agent(app);
|
|
await agent.post("/admin/api/auth/login").send({ email, password });
|
|
return agent;
|
|
}
|
|
|
|
it("rejects every route without an admin session", async () => {
|
|
expect((await request(app).get("/admin/api/users")).status).to.equal(401);
|
|
expect(
|
|
(await request(app).post("/admin/api/users").send(buildCreateUserPayload())).status,
|
|
).to.equal(401);
|
|
});
|
|
|
|
it("creates a profile with no household and returns a one-time temporary password", async () => {
|
|
const agent = await adminAgent();
|
|
const payload = buildCreateUserPayload();
|
|
|
|
const res = await agent.post("/admin/api/users").send(payload);
|
|
|
|
expect(res.status).to.equal(201);
|
|
expect(res.body.user).to.include({
|
|
firstName: payload.firstName,
|
|
lastName: payload.lastName,
|
|
email: payload.email,
|
|
householdName: null,
|
|
});
|
|
expect(res.body.user).to.not.have.property("passwordHash");
|
|
expect(res.body.user).to.not.have.property("tokenVersion");
|
|
expect(res.body.temporaryPassword).to.be.a("string").with.length.greaterThan(8);
|
|
|
|
const stored = await prisma.userProfile.findUnique({ where: { email: payload.email } });
|
|
expect(stored?.houseId).to.equal(null);
|
|
expect(stored?.dietId).to.equal(null);
|
|
});
|
|
|
|
it("issues a password the new account can actually log in with", async () => {
|
|
const agent = await adminAgent();
|
|
const payload = buildCreateUserPayload();
|
|
|
|
const created = await agent.post("/admin/api/users").send(payload);
|
|
const login = await request(app)
|
|
.post("/auth/login")
|
|
.send({ email: payload.email, password: created.body.temporaryPassword });
|
|
|
|
expect(login.status).to.equal(200);
|
|
expect(login.body.email).to.equal(payload.email);
|
|
});
|
|
|
|
it("rejects a duplicate email with 409 EMAIL_ALREADY_IN_USE", async () => {
|
|
const agent = await adminAgent();
|
|
const payload = buildCreateUserPayload();
|
|
await agent.post("/admin/api/users").send(payload);
|
|
|
|
const res = await agent
|
|
.post("/admin/api/users")
|
|
.send({ ...buildCreateUserPayload(), email: payload.email });
|
|
|
|
expect(res.status).to.equal(409);
|
|
expect(res.body.code).to.equal(ErrorCode.EMAIL_ALREADY_IN_USE);
|
|
});
|
|
|
|
it("rejects an invalid body with 400 VALIDATION_ERROR", async () => {
|
|
const agent = await adminAgent();
|
|
|
|
const badEmail = await agent
|
|
.post("/admin/api/users")
|
|
.send({ ...buildCreateUserPayload(), email: "not-an-email" });
|
|
expect(badEmail.status).to.equal(400);
|
|
expect(badEmail.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
|
|
const noFirstName = await agent
|
|
.post("/admin/api/users")
|
|
.send({ ...buildCreateUserPayload(), firstName: "" });
|
|
expect(noFirstName.status).to.equal(400);
|
|
});
|
|
|
|
it("lists accounts newest-first with their household name resolved, never the password", async () => {
|
|
const agent = await adminAgent();
|
|
const first = buildCreateUserPayload();
|
|
const second = buildCreateUserPayload();
|
|
const firstRes = await agent.post("/admin/api/users").send(first);
|
|
await agent.post("/admin/api/users").send(second);
|
|
|
|
// Put the first account in a household so `householdName` has something to
|
|
// resolve — `House.adminId` is required, so the account is its own admin.
|
|
const house = await prisma.house.create({
|
|
data: {
|
|
name: "Le Foyer Test",
|
|
adminId: firstRes.body.user.id,
|
|
inviteCode: faker.string.alphanumeric(8).toUpperCase(),
|
|
},
|
|
});
|
|
await prisma.userProfile.update({
|
|
where: { email: first.email },
|
|
data: { houseId: house.id },
|
|
});
|
|
|
|
const res = await agent.get("/admin/api/users");
|
|
|
|
expect(res.status).to.equal(200);
|
|
expect(res.body).to.have.length(2);
|
|
// Newest first: `second` was created after `first`.
|
|
expect(res.body[0].email).to.equal(second.email);
|
|
expect(res.body[1].email).to.equal(first.email);
|
|
expect(res.body[1].householdName).to.equal("Le Foyer Test");
|
|
expect(res.body[0].householdName).to.equal(null);
|
|
for (const row of res.body) {
|
|
expect(row).to.not.have.property("temporaryPassword");
|
|
expect(row).to.not.have.property("passwordHash");
|
|
}
|
|
});
|
|
});
|