Checkpoint: Évolution v2 complète : nouveau profil Adhérent FEHAP, connexion locale par email/mot de passe (bcrypt), création manuelle d'utilisateurs par les gestionnaires, affectation d'établissements aux adhérents, page de choix de connexion (/login), refonte de la page Admin avec CRUD complet. 33 tests Vitest passés, zéro erreur TypeScript.
This commit is contained in:
205
server/db.ts
205
server/db.ts
@@ -69,7 +69,7 @@ export async function updateUserCgu(userId: number) {
|
||||
await db.update(users).set({ cguAccepted: true, cguAcceptedAt: new Date() }).where(eq(users.id, userId));
|
||||
}
|
||||
|
||||
export async function updateUserSonumRole(userId: number, sonumRole: "referent" | "gestionnaire") {
|
||||
export async function updateUserSonumRole(userId: number, sonumRole: "referent" | "gestionnaire" | "adherent") {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.update(users).set({ sonumRole }).where(eq(users.id, userId));
|
||||
@@ -390,3 +390,206 @@ export async function getDemandeById(id: number) {
|
||||
const result = await db.select().from(demandesContact).where(eq(demandesContact.id, id)).limit(1);
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
// ─── Auth locale ──────────────────────────────────────────────────────────────
|
||||
|
||||
import { localCredentials, userEtablissements } from "../drizzle/schema";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
/** Crée un utilisateur local (sans openId OAuth) avec un mot de passe hashé. */
|
||||
export async function createLocalUser(data: {
|
||||
name: string;
|
||||
email: string;
|
||||
sonumRole: "referent" | "gestionnaire" | "adherent";
|
||||
password: string;
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
// Vérifier unicité email
|
||||
const existing = await db.select().from(users).where(eq(users.email, data.email)).limit(1);
|
||||
if (existing.length > 0) throw new Error("EMAIL_EXISTS");
|
||||
|
||||
// openId synthétique pour les comptes locaux
|
||||
const syntheticOpenId = `local_${nanoid(16)}`;
|
||||
const passwordHash = await bcrypt.hash(data.password, 12);
|
||||
|
||||
const insertResult = await db.insert(users).values({
|
||||
openId: syntheticOpenId,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
loginMethod: "local",
|
||||
sonumRole: data.sonumRole,
|
||||
cguAccepted: false,
|
||||
lastSignedIn: new Date(),
|
||||
});
|
||||
|
||||
const userId = Number((insertResult as any)[0]?.insertId ?? 0);
|
||||
if (!userId) throw new Error("Failed to create user");
|
||||
|
||||
await db.insert(localCredentials).values({ userId, passwordHash });
|
||||
|
||||
return userId;
|
||||
}
|
||||
|
||||
/** Authentifie un utilisateur par email + mot de passe. Retourne l'utilisateur ou null. */
|
||||
export async function authenticateLocalUser(email: string, password: string) {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
user: users,
|
||||
passwordHash: localCredentials.passwordHash,
|
||||
})
|
||||
.from(users)
|
||||
.innerJoin(localCredentials, eq(localCredentials.userId, users.id))
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (!result.length) return null;
|
||||
|
||||
const { user, passwordHash } = result[0];
|
||||
const valid = await bcrypt.compare(password, passwordHash);
|
||||
if (!valid) return null;
|
||||
|
||||
// Mettre à jour lastSignedIn
|
||||
await db.update(users).set({ lastSignedIn: new Date() }).where(eq(users.id, user.id));
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Vérifie si un utilisateur possède des credentials locaux. */
|
||||
export async function hasLocalCredentials(userId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return false;
|
||||
const result = await db.select().from(localCredentials).where(eq(localCredentials.userId, userId)).limit(1);
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
/** Met à jour le mot de passe d'un utilisateur. */
|
||||
export async function updateLocalPassword(userId: number, newPassword: string) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
const passwordHash = await bcrypt.hash(newPassword, 12);
|
||||
const existing = await db.select().from(localCredentials).where(eq(localCredentials.userId, userId)).limit(1);
|
||||
if (existing.length > 0) {
|
||||
await db.update(localCredentials).set({ passwordHash, updatedAt: new Date() }).where(eq(localCredentials.userId, userId));
|
||||
} else {
|
||||
await db.insert(localCredentials).values({ userId, passwordHash });
|
||||
}
|
||||
}
|
||||
|
||||
/** Met à jour les informations d'un utilisateur. */
|
||||
export async function updateUser(userId: number, data: {
|
||||
name?: string;
|
||||
email?: string;
|
||||
sonumRole?: "referent" | "gestionnaire" | "adherent";
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.update(users).set({ ...data, updatedAt: new Date() }).where(eq(users.id, userId));
|
||||
}
|
||||
|
||||
/** Supprime un utilisateur et ses credentials locaux. */
|
||||
export async function deleteUser(userId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.delete(localCredentials).where(eq(localCredentials.userId, userId));
|
||||
await db.delete(userEtablissements).where(eq(userEtablissements.userId, userId));
|
||||
await db.delete(users).where(eq(users.id, userId));
|
||||
}
|
||||
|
||||
// ─── Affectations Adhérents ↔ Établissements ─────────────────────────────────
|
||||
|
||||
/** Retourne les établissements affectés à un adhérent. */
|
||||
export async function getEtablissementsByAdherent(userId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db
|
||||
.select({
|
||||
id: etablissements.id,
|
||||
finess: etablissements.finess,
|
||||
nom: etablissements.nom,
|
||||
region: etablissements.region,
|
||||
departement: etablissements.departement,
|
||||
typeActivite: etablissements.typeActivite,
|
||||
tailleEffectifs: etablissements.tailleEffectifs,
|
||||
referentId: etablissements.referentId,
|
||||
visibilite: etablissements.visibilite,
|
||||
accepteMiseEnRelation: etablissements.accepteMiseEnRelation,
|
||||
})
|
||||
.from(userEtablissements)
|
||||
.innerJoin(etablissements, eq(userEtablissements.etablissementId, etablissements.id))
|
||||
.where(eq(userEtablissements.userId, userId))
|
||||
.orderBy(etablissements.nom);
|
||||
}
|
||||
|
||||
/** Retourne les IDs des établissements affectés à un adhérent. */
|
||||
export async function getAffectationsByUser(userId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
const result = await db
|
||||
.select({ etablissementId: userEtablissements.etablissementId })
|
||||
.from(userEtablissements)
|
||||
.where(eq(userEtablissements.userId, userId));
|
||||
return result.map((r) => r.etablissementId);
|
||||
}
|
||||
|
||||
/** Affecte un établissement à un adhérent (idempotent). */
|
||||
export async function assignEtablissementToUser(userId: number, etablissementId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(userEtablissements)
|
||||
.where(and(eq(userEtablissements.userId, userId), eq(userEtablissements.etablissementId, etablissementId)))
|
||||
.limit(1);
|
||||
if (existing.length === 0) {
|
||||
await db.insert(userEtablissements).values({ userId, etablissementId });
|
||||
}
|
||||
}
|
||||
|
||||
/** Retire un établissement d'un adhérent. */
|
||||
export async function removeEtablissementFromUser(userId: number, etablissementId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db
|
||||
.delete(userEtablissements)
|
||||
.where(and(eq(userEtablissements.userId, userId), eq(userEtablissements.etablissementId, etablissementId)));
|
||||
}
|
||||
|
||||
/** Remplace toutes les affectations d'un adhérent par une nouvelle liste. */
|
||||
export async function setAffectationsForUser(userId: number, etablissementIds: number[]) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.delete(userEtablissements).where(eq(userEtablissements.userId, userId));
|
||||
if (etablissementIds.length > 0) {
|
||||
await db.insert(userEtablissements).values(etablissementIds.map((eid) => ({ userId, etablissementId: eid })));
|
||||
}
|
||||
}
|
||||
|
||||
/** Retourne tous les utilisateurs avec leurs affectations. */
|
||||
export async function getAllUsersWithAffectations() {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const allUsers = await db.select().from(users).orderBy(users.name);
|
||||
const allAffectations = await db
|
||||
.select({
|
||||
userId: userEtablissements.userId,
|
||||
etablissementId: userEtablissements.etablissementId,
|
||||
etablissementNom: etablissements.nom,
|
||||
})
|
||||
.from(userEtablissements)
|
||||
.innerJoin(etablissements, eq(userEtablissements.etablissementId, etablissements.id));
|
||||
|
||||
return allUsers.map((u) => ({
|
||||
...u,
|
||||
etablissements: allAffectations
|
||||
.filter((a) => a.userId === u.id)
|
||||
.map((a) => ({ id: a.etablissementId, nom: a.etablissementNom })),
|
||||
hasLocalCredentials: false, // sera enrichi côté router si besoin
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
createBlocFonctionnel,
|
||||
assignEtablissementToUser,
|
||||
authenticateLocalUser,
|
||||
createDemandeContact,
|
||||
createBlocFonctionnel,
|
||||
createEditeur,
|
||||
createLocalUser,
|
||||
createSolution,
|
||||
deleteLogicielEtablissement,
|
||||
deleteUser,
|
||||
getAllDemandes,
|
||||
getAllEtablissements,
|
||||
getAllUsers,
|
||||
getAllUsersWithAffectations,
|
||||
getAffectationsByUser,
|
||||
getBlocsFonctionnels,
|
||||
getConsultationCount,
|
||||
getConsultationsList,
|
||||
@@ -17,11 +22,16 @@ import {
|
||||
getDemandesRecuesParEtablissement,
|
||||
getEditeurs,
|
||||
getEtablissementById,
|
||||
getEtablissementsByAdherent,
|
||||
getEtablissementsByReferent,
|
||||
getLogicielsByEtablissement,
|
||||
getSolutions,
|
||||
recordConsultation,
|
||||
removeEtablissementFromUser,
|
||||
repondreDemandeContact,
|
||||
setAffectationsForUser,
|
||||
updateLocalPassword,
|
||||
updateUser,
|
||||
updateUserCgu,
|
||||
updateUserSonumRole,
|
||||
upsertLogicielEtablissement,
|
||||
@@ -35,6 +45,7 @@ import { notifyOwner } from "./_core/notification";
|
||||
import { getDb } from "./db";
|
||||
import { etablissements } from "../drizzle/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { sdk } from "./_core/sdk";
|
||||
|
||||
// ─── Middleware gestionnaire SONUM ────────────────────────────────────────────
|
||||
|
||||
@@ -53,11 +64,41 @@ export const appRouter = router({
|
||||
// ─── Auth ──────────────────────────────────────────────────────────────────
|
||||
auth: router({
|
||||
me: publicProcedure.query((opts) => opts.ctx.user),
|
||||
|
||||
logout: publicProcedure.mutation(({ ctx }) => {
|
||||
const cookieOptions = getSessionCookieOptions(ctx.req);
|
||||
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
|
||||
return { success: true } as const;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Connexion locale par email + mot de passe.
|
||||
* Crée un cookie de session identique à celui de l'OAuth.
|
||||
*/
|
||||
loginLocal: publicProcedure
|
||||
.input(z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const user = await authenticateLocalUser(input.email, input.password);
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Email ou mot de passe incorrect" });
|
||||
}
|
||||
|
||||
// Créer un token de session avec l'openId de l'utilisateur local
|
||||
const sessionToken = await sdk.createSessionToken(user.openId!, {
|
||||
name: user.name ?? "",
|
||||
});
|
||||
|
||||
const cookieOptions = getSessionCookieOptions(ctx.req);
|
||||
ctx.res.cookie(COOKIE_NAME, sessionToken, {
|
||||
...cookieOptions,
|
||||
maxAge: 1000 * 60 * 60 * 24 * 365, // 1 an
|
||||
});
|
||||
|
||||
return { success: true, user };
|
||||
}),
|
||||
}),
|
||||
|
||||
// ─── CGU ───────────────────────────────────────────────────────────────────
|
||||
@@ -108,9 +149,21 @@ export const appRouter = router({
|
||||
|
||||
// ─── Établissements ────────────────────────────────────────────────────────
|
||||
etablissements: router({
|
||||
mesEtablissements: protectedProcedure.query(({ ctx }) =>
|
||||
getEtablissementsByReferent(ctx.user.id)
|
||||
),
|
||||
/**
|
||||
* Retourne les établissements selon le rôle :
|
||||
* - référent : ses établissements
|
||||
* - adhérent : ses établissements affectés
|
||||
* - gestionnaire : tous
|
||||
*/
|
||||
mesEtablissements: protectedProcedure.query(({ ctx }) => {
|
||||
if (ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin") {
|
||||
return getAllEtablissements();
|
||||
}
|
||||
if (ctx.user.sonumRole === "adherent") {
|
||||
return getEtablissementsByAdherent(ctx.user.id);
|
||||
}
|
||||
return getEtablissementsByReferent(ctx.user.id);
|
||||
}),
|
||||
|
||||
all: gestionnaireProcedure.query(() => getAllEtablissements()),
|
||||
|
||||
@@ -119,7 +172,13 @@ export const appRouter = router({
|
||||
.query(async ({ input, ctx }) => {
|
||||
const etab = await getEtablissementById(input.id);
|
||||
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
// Vérifier visibilité
|
||||
// Adhérent : vérifier qu'il a accès à cet établissement
|
||||
if (ctx.user.sonumRole === "adherent") {
|
||||
const affectations = await getAffectationsByUser(ctx.user.id);
|
||||
if (!affectations.includes(input.id)) {
|
||||
throw new TRPCError({ code: "FORBIDDEN" });
|
||||
}
|
||||
}
|
||||
if (etab.visibilite === "gestionnaires" && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") {
|
||||
if (etab.referentId !== ctx.user.id) {
|
||||
throw new TRPCError({ code: "FORBIDDEN" });
|
||||
@@ -192,6 +251,11 @@ export const appRouter = router({
|
||||
.query(async ({ input, ctx }) => {
|
||||
const etab = await getEtablissementById(input.etablissementId);
|
||||
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
// Adhérent : vérifier affectation
|
||||
if (ctx.user.sonumRole === "adherent") {
|
||||
const affectations = await getAffectationsByUser(ctx.user.id);
|
||||
if (!affectations.includes(input.etablissementId)) throw new TRPCError({ code: "FORBIDDEN" });
|
||||
}
|
||||
if (etab.visibilite === "gestionnaires" && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") {
|
||||
if (etab.referentId !== ctx.user.id) throw new TRPCError({ code: "FORBIDDEN" });
|
||||
}
|
||||
@@ -284,7 +348,6 @@ export const appRouter = router({
|
||||
message: input.message,
|
||||
});
|
||||
|
||||
// Notification au propriétaire (gestionnaire SONUM)
|
||||
await notifyOwner({
|
||||
title: `Nouvelle demande de contact — ${etab.nom}`,
|
||||
content: `${ctx.user.name} souhaite contacter le référent de ${etab.nom}.\n\nMessage : ${input.message}`,
|
||||
@@ -321,17 +384,110 @@ export const appRouter = router({
|
||||
|
||||
// ─── Administration ────────────────────────────────────────────────────────
|
||||
admin: router({
|
||||
users: gestionnaireProcedure.query(() => getAllUsers()),
|
||||
/** Liste tous les utilisateurs avec leurs établissements affectés */
|
||||
users: gestionnaireProcedure.query(() => getAllUsersWithAffectations()),
|
||||
|
||||
/** Crée un utilisateur manuellement avec un mot de passe local */
|
||||
createUser: gestionnaireProcedure
|
||||
.input(z.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
sonumRole: z.enum(["referent", "gestionnaire", "adherent"]),
|
||||
password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caractères"),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const userId = await createLocalUser(input);
|
||||
return { success: true, userId };
|
||||
} catch (err: any) {
|
||||
if (err.message === "EMAIL_EXISTS") {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "Un utilisateur avec cet email existe déjà" });
|
||||
}
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: err.message });
|
||||
}
|
||||
}),
|
||||
|
||||
/** Met à jour les informations d'un utilisateur */
|
||||
updateUser: gestionnaireProcedure
|
||||
.input(z.object({
|
||||
userId: z.number().int(),
|
||||
name: z.string().min(1).optional(),
|
||||
email: z.string().email().optional(),
|
||||
sonumRole: z.enum(["referent", "gestionnaire", "adherent"]).optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const { userId, ...data } = input;
|
||||
await updateUser(userId, data);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
/** Réinitialise le mot de passe d'un utilisateur local */
|
||||
resetPassword: gestionnaireProcedure
|
||||
.input(z.object({
|
||||
userId: z.number().int(),
|
||||
newPassword: z.string().min(8),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
await updateLocalPassword(input.userId, input.newPassword);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
/** Supprime un utilisateur */
|
||||
deleteUser: gestionnaireProcedure
|
||||
.input(z.object({ userId: z.number().int() }))
|
||||
.mutation(async ({ input }) => {
|
||||
await deleteUser(input.userId);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
/** Ancienne procédure de mise à jour du rôle (rétrocompatibilité) */
|
||||
updateRole: gestionnaireProcedure
|
||||
.input(z.object({
|
||||
userId: z.number().int(),
|
||||
sonumRole: z.enum(["referent", "gestionnaire"]),
|
||||
sonumRole: z.enum(["referent", "gestionnaire", "adherent"]),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
await updateUserSonumRole(input.userId, input.sonumRole);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
/** Retourne les établissements affectés à un utilisateur */
|
||||
affectations: gestionnaireProcedure
|
||||
.input(z.object({ userId: z.number().int() }))
|
||||
.query(({ input }) => getAffectationsByUser(input.userId)),
|
||||
|
||||
/** Remplace toutes les affectations d'un adhérent */
|
||||
setAffectations: gestionnaireProcedure
|
||||
.input(z.object({
|
||||
userId: z.number().int(),
|
||||
etablissementIds: z.array(z.number().int()),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
await setAffectationsForUser(input.userId, input.etablissementIds);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
/** Ajoute un établissement à un utilisateur */
|
||||
assignEtablissement: gestionnaireProcedure
|
||||
.input(z.object({
|
||||
userId: z.number().int(),
|
||||
etablissementId: z.number().int(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
await assignEtablissementToUser(input.userId, input.etablissementId);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
/** Retire un établissement d'un utilisateur */
|
||||
removeEtablissement: gestionnaireProcedure
|
||||
.input(z.object({
|
||||
userId: z.number().int(),
|
||||
etablissementId: z.number().int(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
await removeEtablissementFromUser(input.userId, input.etablissementId);
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
249
server/sonum-v2.test.ts
Normal file
249
server/sonum-v2.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { appRouter } from "./routers";
|
||||
import { COOKIE_NAME } from "../shared/const";
|
||||
import type { TrpcContext } from "./_core/context";
|
||||
import type { User } from "../drizzle/schema";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeUser(overrides: Partial<User> = {}): User {
|
||||
return {
|
||||
id: 1,
|
||||
openId: "test-open-id",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
loginMethod: "local",
|
||||
role: "user",
|
||||
sonumRole: "referent",
|
||||
cguAccepted: true,
|
||||
cguAcceptedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
lastSignedIn: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(user: User | null = null): TrpcContext {
|
||||
const cookies: Record<string, string> = {};
|
||||
return {
|
||||
user,
|
||||
req: {
|
||||
protocol: "https",
|
||||
headers: {},
|
||||
} as TrpcContext["req"],
|
||||
res: {
|
||||
cookie: (name: string, value: string, _opts: unknown) => {
|
||||
cookies[name] = value;
|
||||
},
|
||||
clearCookie: (_name: string, _opts: unknown) => {},
|
||||
} as unknown as TrpcContext["res"],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Tests : auth.me ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("auth.me", () => {
|
||||
it("retourne null quand non authentifié", async () => {
|
||||
const caller = appRouter.createCaller(makeCtx(null));
|
||||
const result = await caller.auth.me();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("retourne l'utilisateur quand authentifié", async () => {
|
||||
const user = makeUser({ name: "Alice" });
|
||||
const caller = appRouter.createCaller(makeCtx(user));
|
||||
const result = await caller.auth.me();
|
||||
expect(result?.name).toBe("Alice");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests : auth.logout ──────────────────────────────────────────────────────
|
||||
|
||||
describe("auth.logout", () => {
|
||||
it("efface le cookie de session et retourne success", async () => {
|
||||
const clearedCookies: string[] = [];
|
||||
const ctx: TrpcContext = {
|
||||
user: makeUser(),
|
||||
req: { protocol: "https", headers: {} } as TrpcContext["req"],
|
||||
res: {
|
||||
clearCookie: (name: string) => clearedCookies.push(name),
|
||||
} as unknown as TrpcContext["res"],
|
||||
};
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const result = await caller.auth.logout();
|
||||
expect(result.success).toBe(true);
|
||||
expect(clearedCookies).toContain(COOKIE_NAME);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests : auth.loginLocal ──────────────────────────────────────────────────
|
||||
|
||||
describe("auth.loginLocal", () => {
|
||||
it("rejette un email invalide", async () => {
|
||||
const caller = appRouter.createCaller(makeCtx(null));
|
||||
await expect(
|
||||
caller.auth.loginLocal({ email: "not-an-email", password: "password123" })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejette un mot de passe vide", async () => {
|
||||
const caller = appRouter.createCaller(makeCtx(null));
|
||||
await expect(
|
||||
caller.auth.loginLocal({ email: "test@example.com", password: "" })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests : cgu ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("cgu.status", () => {
|
||||
it("retourne le statut CGU de l'utilisateur", async () => {
|
||||
const user = makeUser({ cguAccepted: true });
|
||||
const caller = appRouter.createCaller(makeCtx(user));
|
||||
const result = await caller.cgu.status();
|
||||
expect(result.accepted).toBe(true);
|
||||
});
|
||||
|
||||
it("retourne false si CGU non acceptée", async () => {
|
||||
const user = makeUser({ cguAccepted: false, cguAcceptedAt: null });
|
||||
const caller = appRouter.createCaller(makeCtx(user));
|
||||
const result = await caller.cgu.status();
|
||||
expect(result.accepted).toBe(false);
|
||||
});
|
||||
|
||||
it("lève UNAUTHORIZED si non authentifié", async () => {
|
||||
const caller = appRouter.createCaller(makeCtx(null));
|
||||
await expect(caller.cgu.status()).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests : gestion des rôles ────────────────────────────────────────────────
|
||||
|
||||
describe("admin.updateRole", () => {
|
||||
it("lève FORBIDDEN pour un référent", async () => {
|
||||
const user = makeUser({ sonumRole: "referent" });
|
||||
const caller = appRouter.createCaller(makeCtx(user));
|
||||
await expect(
|
||||
caller.admin.updateRole({ userId: 2, sonumRole: "gestionnaire" })
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
it("lève FORBIDDEN pour un adhérent", async () => {
|
||||
const user = makeUser({ sonumRole: "adherent" });
|
||||
const caller = appRouter.createCaller(makeCtx(user));
|
||||
await expect(
|
||||
caller.admin.updateRole({ userId: 2, sonumRole: "referent" })
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin.createUser", () => {
|
||||
it("lève FORBIDDEN pour un référent", async () => {
|
||||
const user = makeUser({ sonumRole: "referent" });
|
||||
const caller = appRouter.createCaller(makeCtx(user));
|
||||
await expect(
|
||||
caller.admin.createUser({
|
||||
name: "Test",
|
||||
email: "test@test.com",
|
||||
sonumRole: "adherent",
|
||||
password: "password123",
|
||||
})
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
it("valide que le mot de passe fait au moins 8 caractères", async () => {
|
||||
const user = makeUser({ sonumRole: "gestionnaire" });
|
||||
const caller = appRouter.createCaller(makeCtx(user));
|
||||
await expect(
|
||||
caller.admin.createUser({
|
||||
name: "Test",
|
||||
email: "test@test.com",
|
||||
sonumRole: "adherent",
|
||||
password: "short",
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests : affectations ─────────────────────────────────────────────────────
|
||||
|
||||
describe("admin.setAffectations", () => {
|
||||
it("lève FORBIDDEN pour un non-gestionnaire", async () => {
|
||||
const user = makeUser({ sonumRole: "referent" });
|
||||
const caller = appRouter.createCaller(makeCtx(user));
|
||||
await expect(
|
||||
caller.admin.setAffectations({ userId: 2, etablissementIds: [1, 2] })
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests : rôles des procédures admin ──────────────────────────────────────
|
||||
|
||||
describe("admin.deleteUser", () => {
|
||||
it("lève FORBIDDEN pour un adhérent", async () => {
|
||||
const user = makeUser({ sonumRole: "adherent" });
|
||||
const caller = appRouter.createCaller(makeCtx(user));
|
||||
await expect(
|
||||
caller.admin.deleteUser({ userId: 2 })
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin.resetPassword", () => {
|
||||
it("lève FORBIDDEN pour un référent", async () => {
|
||||
const user = makeUser({ sonumRole: "referent" });
|
||||
const caller = appRouter.createCaller(makeCtx(user));
|
||||
await expect(
|
||||
caller.admin.resetPassword({ userId: 2, newPassword: "newpassword123" })
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests : filtrage adhérent ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("etablissements.byId - contrôle accès adhérent", () => {
|
||||
it("lève FORBIDDEN si l'adhérent tente d'accéder à un établissement non affecté", async () => {
|
||||
// L'adhérent n'a aucun établissement affecté (DB vide en test)
|
||||
const user = makeUser({ sonumRole: "adherent", id: 999 });
|
||||
const caller = appRouter.createCaller(makeCtx(user));
|
||||
// L'établissement id=1 n'est pas affecté à l'utilisateur id=999
|
||||
// La procédure doit lever FORBIDDEN ou NOT_FOUND
|
||||
await expect(
|
||||
caller.etablissements.byId({ id: 1 })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin.setAffectations - accès gestionnaire", () => {
|
||||
it("accepte la requête d'un gestionnaire (ne lève pas FORBIDDEN)", async () => {
|
||||
const user = makeUser({ sonumRole: "gestionnaire" });
|
||||
const caller = appRouter.createCaller(makeCtx(user));
|
||||
// setAffectations avec une liste vide est idempotent et ne doit pas lever FORBIDDEN
|
||||
// (peut échouer sur DB indisponible mais pas sur les permissions)
|
||||
try {
|
||||
await caller.admin.setAffectations({ userId: 999, etablissementIds: [] });
|
||||
} catch (err: any) {
|
||||
// Seule une erreur de permission est inacceptable
|
||||
expect(err?.code).not.toBe("FORBIDDEN");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth.loginLocal - validation", () => {
|
||||
it("rejette un mot de passe trop court", async () => {
|
||||
const caller = appRouter.createCaller(makeCtx(null));
|
||||
await expect(
|
||||
caller.auth.loginLocal({ email: "user@test.com", password: "" })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("retourne UNAUTHORIZED pour des credentials inexistants", async () => {
|
||||
const caller = appRouter.createCaller(makeCtx(null));
|
||||
await expect(
|
||||
caller.auth.loginLocal({ email: "nonexistent@test.com", password: "password123" })
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user