/** * Tests Vitest — Authentification locale Itinova Budget SI * Couvre : login, logout, me, users.create, etablissements.list */ 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 { return { id: 1, login: "admin", email: "admin@itinova.fr", passwordHash: "$2a$10$hashedpassword", firstName: "Admin", lastName: "Itinova", role: "admin", isActive: true, createdAt: new Date(), updatedAt: new Date(), lastSignedIn: null, ...overrides, }; } type CookieCall = { name: string; options: Record }; function createPublicCtx(): { ctx: TrpcContext; setCookies: CookieCall[]; clearedCookies: CookieCall[] } { const setCookies: CookieCall[] = []; const clearedCookies: CookieCall[] = []; const ctx: TrpcContext = { user: null, req: { protocol: "https", headers: {} } as TrpcContext["req"], res: { cookie: (name: string, _val: string, options: Record) => setCookies.push({ name, options }), clearCookie: (name: string, options: Record) => clearedCookies.push({ name, options }), } as unknown as TrpcContext["res"], }; return { ctx, setCookies, clearedCookies }; } function createAuthCtx(userOverrides: Partial = {}): { ctx: TrpcContext; clearedCookies: CookieCall[] } { const clearedCookies: CookieCall[] = []; const ctx: TrpcContext = { user: makeUser(userOverrides), req: { protocol: "https", headers: {} } as TrpcContext["req"], res: { cookie: () => {}, clearCookie: (name: string, options: Record) => clearedCookies.push({ name, options }), } as unknown as TrpcContext["res"], }; return { ctx, clearedCookies }; } // ─── Mock db ───────────────────────────────────────────────────────────────── vi.mock("./db", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getUserByLogin: vi.fn(), updateLastSignedIn: vi.fn(), createUser: vi.fn(), listUsers: vi.fn(), listEtablissements: vi.fn(), getParametres: vi.fn(), upsertOpexBaseRepartition: vi.fn(), setOpexMontantsEtabBatch: vi.fn(), deleteOpexPoste: vi.fn(), getMasseSalariale: vi.fn(), importMasseSalariale: vi.fn(), setMasseSalarialeEvolution: vi.fn(), getSalairesMensuels: vi.fn(), }; }); vi.mock("./_core/sdk", () => ({ sdk: { createSessionToken: vi.fn().mockResolvedValue("mock-jwt-token"), authenticateRequest: vi.fn(), }, })); // ─── Tests auth.logout ──────────────────────────────────────────────────────── describe("auth.logout", () => { it("efface le cookie de session et retourne success:true", async () => { const { ctx, clearedCookies } = createAuthCtx(); const caller = appRouter.createCaller(ctx); const result = await caller.auth.logout(); expect(result).toEqual({ success: true }); expect(clearedCookies).toHaveLength(1); expect(clearedCookies[0]?.name).toBe(COOKIE_NAME); expect(clearedCookies[0]?.options).toMatchObject({ maxAge: -1, httpOnly: true, path: "/", }); }); it("fonctionne aussi sans utilisateur connecté (public procedure)", async () => { const { ctx, clearedCookies } = createPublicCtx(); const caller = appRouter.createCaller(ctx); const result = await caller.auth.logout(); expect(result).toEqual({ success: true }); expect(clearedCookies).toHaveLength(1); }); }); // ─── Tests auth.me ──────────────────────────────────────────────────────────── describe("auth.me", () => { it("retourne null si non authentifié", async () => { const { ctx } = createPublicCtx(); const caller = appRouter.createCaller(ctx); const result = await caller.auth.me(); expect(result).toBeNull(); }); it("retourne les infos utilisateur si authentifié", async () => { const { ctx } = createAuthCtx({ login: "jdupont", email: "j.dupont@itinova.fr", role: "standard" }); const caller = appRouter.createCaller(ctx); const result = await caller.auth.me(); expect(result).not.toBeNull(); expect(result?.login).toBe("jdupont"); expect(result?.email).toBe("j.dupont@itinova.fr"); expect(result?.role).toBe("standard"); }); it("ne retourne pas le hash du mot de passe", async () => { const { ctx } = createAuthCtx(); const caller = appRouter.createCaller(ctx); const result = await caller.auth.me(); expect(result).not.toHaveProperty("passwordHash"); }); }); // ─── Tests auth.login ──────────────────────────────────────────────────────── describe("auth.login", () => { beforeEach(() => { vi.clearAllMocks(); }); it("refuse un login avec identifiants invalides (utilisateur inexistant)", async () => { const { db } = await import("./db").then(m => ({ db: m })); (db.getUserByLogin as ReturnType).mockResolvedValue(null); const { ctx } = createPublicCtx(); const caller = appRouter.createCaller(ctx); await expect(caller.auth.login({ login: "inexistant", password: "mauvais" })) .rejects.toThrow("Identifiants invalides"); }); it("refuse un compte inactif", async () => { const { db } = await import("./db").then(m => ({ db: m })); (db.getUserByLogin as ReturnType).mockResolvedValue( makeUser({ isActive: false }) ); const { ctx } = createPublicCtx(); const caller = appRouter.createCaller(ctx); await expect(caller.auth.login({ login: "admin", password: "password" })) .rejects.toThrow("Identifiants invalides"); }); }); // ─── Tests etablissements.list ──────────────────────────────────────────────── describe("etablissements.list", () => { beforeEach(() => { vi.clearAllMocks(); }); it("retourne la liste des établissements pour un utilisateur connecté", async () => { const { db } = await import("./db").then(m => ({ db: m })); const mockEtabs = [ { id: 1, code: "ETB001", nom: "EHPAD Les Pins", groupe: "Itinova", ville: "Lyon", actif: true, createdAt: new Date(), updatedAt: new Date() }, { id: 2, code: "ETB002", nom: "Résidence Soleil", groupe: "Itinova", ville: "Grenoble", actif: true, createdAt: new Date(), updatedAt: new Date() }, ]; (db.listEtablissements as ReturnType).mockResolvedValue(mockEtabs); const { ctx } = createAuthCtx(); const caller = appRouter.createCaller(ctx); const result = await caller.etablissements.list(); expect(result).toHaveLength(2); expect(result[0]?.code).toBe("ETB001"); expect(result[1]?.code).toBe("ETB002"); }); it("lève une erreur UNAUTHORIZED si non authentifié", async () => { const { ctx } = createPublicCtx(); const caller = appRouter.createCaller(ctx); await expect(caller.etablissements.list()).rejects.toThrow(); }); }); // ─── Tests users.list (admin only) ─────────────────────────────────────────── describe("users.list", () => { beforeEach(() => { vi.clearAllMocks(); }); it("retourne la liste des utilisateurs pour un admin", async () => { const { db } = await import("./db").then(m => ({ db: m })); const mockUsers = [ makeUser({ id: 1, login: "admin", role: "admin" }), makeUser({ id: 2, login: "jdupont", role: "standard" }), ]; (db.listUsers as ReturnType).mockResolvedValue(mockUsers); const { ctx } = createAuthCtx({ role: "admin" }); const caller = appRouter.createCaller(ctx); const result = await caller.users.list(); expect(result).toHaveLength(2); expect(result[0]?.role).toBe("admin"); }); it("lève FORBIDDEN pour un utilisateur standard", async () => { const { ctx } = createAuthCtx({ role: "standard" }); const caller = appRouter.createCaller(ctx); await expect(caller.users.list()).rejects.toThrow(); }); it("lève FORBIDDEN pour un utilisateur readonly", async () => { const { ctx } = createAuthCtx({ role: "readonly" }); const caller = appRouter.createCaller(ctx); await expect(caller.users.list()).rejects.toThrow(); }); }); // ─── Tests parametres.get ───────────────────────────────────────────────────── describe("parametres.get", () => { beforeEach(() => { vi.clearAllMocks(); }); it("retourne les paramètres sous forme d'objet clé-valeur", async () => { const { db } = await import("./db").then(m => ({ db: m })); (db.getParametres as ReturnType).mockResolvedValue([ { id: 1, cle: "seuil_fixes_ans", valeur: "5", updatedAt: new Date() }, { id: 2, cle: "cout_fixe", valeur: "850", updatedAt: new Date() }, ]); const { ctx } = createAuthCtx(); const caller = appRouter.createCaller(ctx); const result = await caller.parametres.get(); expect(result).toEqual({ seuil_fixes_ans: "5", cout_fixe: "850" }); }); }); // ─── Tests robustesse OPEX ──────────────────────────────────────────────────── describe("opex.setBaseRepartition", () => { beforeEach(() => { vi.clearAllMocks(); }); it("normalise le code établissement avant de l'enregistrer", async () => { const { db } = await import("./db").then(m => ({ db: m })); const { ctx } = createAuthCtx({ role: "standard" }); const caller = appRouter.createCaller(ctx); await caller.opex.setBaseRepartition({ annee: 2026, etablissementCode: " 1001bpt ", etablissementNom: "FAM Saint Joseph", baseRepartition: 100, baseRepartitionHep: 0, modeManuel: false, }); expect(db.upsertOpexBaseRepartition).toHaveBeenCalledWith(expect.objectContaining({ etablissementCode: "1001BPT", })); }); it("rejette les lignes de synthèse avant tout accès à la base", async () => { const { db } = await import("./db").then(m => ({ db: m })); const { ctx } = createAuthCtx({ role: "standard" }); const caller = appRouter.createCaller(ctx); await expect(caller.opex.setBaseRepartition({ annee: 2026, etablissementCode: "TOTAL", baseRepartition: 1, baseRepartitionHep: 0, modeManuel: false, })).rejects.toThrow("Code établissement OPEX invalide"); expect(db.upsertOpexBaseRepartition).not.toHaveBeenCalled(); }); it("interdit toute écriture au profil readonly", async () => { const { ctx } = createAuthCtx({ role: "readonly" }); const caller = appRouter.createCaller(ctx); await expect(caller.opex.setBaseRepartition({ annee: 2026, etablissementCode: "1001BPT", baseRepartition: 1, baseRepartitionHep: 0, modeManuel: false, })).rejects.toThrow("Accès en lecture seule"); }); }); describe("opex.deletePoste", () => { beforeEach(() => { vi.clearAllMocks(); }); it("supprime un poste et renvoie le résultat de la transaction métier", async () => { const { db } = await import("./db").then(m => ({ db: m })); (db.deleteOpexPoste as ReturnType).mockResolvedValue(true); const { ctx } = createAuthCtx({ role: "standard" }); const caller = appRouter.createCaller(ctx); await expect(caller.opex.deletePoste({ annee: 2027, id: 42 })).resolves.toEqual({ success: true }); expect(db.deleteOpexPoste).toHaveBeenCalledWith(2027, 42); }); }); // ─── Tests Masse salariale (données sensibles, administrateur uniquement) ───── describe("masseSalariale", () => { beforeEach(() => { vi.clearAllMocks(); }); it("retourne les données uniquement pour un administrateur", async () => { const { db } = await import("./db").then(m => ({ db: m })); const source = { salaries: [], remunerations: [], evolutions: [] }; (db.getMasseSalariale as ReturnType).mockResolvedValue(source); const { ctx } = createAuthCtx({ role: "admin" }); await expect(appRouter.createCaller(ctx).masseSalariale.get()).resolves.toEqual(source); }); it("interdit la consultation à un profil standard", async () => { const { ctx } = createAuthCtx({ role: "standard" }); await expect(appRouter.createCaller(ctx).masseSalariale.get()).rejects.toThrow(); }); it("importe les données validées uniquement pour un administrateur", async () => { const { db } = await import("./db").then(m => ({ db: m })); (db.importMasseSalariale as ReturnType).mockResolvedValue({ salaries: 1, remunerations: 1 }); const { ctx } = createAuthCtx({ role: "admin" }); const salaries = [{ matricule: "000001", nom: "DUPONT", prenom: "Alice", poste: "Responsable administratif", dateEmbauche: "2024-01-01", }]; const remunerations = [{ matricule: "000001", annee: 2026, salaireAnnuelBrutHorsPrimesCents: 36_000_00, salaireAnnuelBrutAvecPrimesCents: 40_000_00, salaireMensuelBrutHorsPrimesCents: 3_000_00, tauxChargeBps: 5_500, periodeReference: "2026-08", statut: "cumul_provisoire" as const, source: "bulletin", }]; await expect(appRouter.createCaller(ctx).masseSalariale.import({ salaries, remunerations })) .resolves.toEqual({ salaries: 1, remunerations: 1 }); expect(db.importMasseSalariale).toHaveBeenCalledWith(salaries, remunerations); }); it("interdit l'import de rémunérations à un profil standard", async () => { const { db } = await import("./db").then(m => ({ db: m })); const { ctx } = createAuthCtx({ role: "standard" }); await expect(appRouter.createCaller(ctx).masseSalariale.import({ salaries: [], remunerations: [] })).rejects.toThrow(); expect(db.importMasseSalariale).not.toHaveBeenCalled(); }); it("enregistre l'évolution manuelle avec l'identifiant de l'administrateur", async () => { const { db } = await import("./db").then(m => ({ db: m })); const { ctx } = createAuthCtx({ id: 42, role: "admin" }); await expect(appRouter.createCaller(ctx).masseSalariale.setEvolution({ salarieId: 7, annee: 2027, evolutionSalarialeBps: 250, })).resolves.toEqual({ success: true }); expect(db.setMasseSalarialeEvolution).toHaveBeenCalledWith({ salarieId: 7, annee: 2027, evolutionSalarialeBps: 250, createdBy: 42, }); }); it("rejette un taux d'évolution hors plage avant l'accès à la base", async () => { const { db } = await import("./db").then(m => ({ db: m })); const { ctx } = createAuthCtx({ role: "admin" }); await expect(appRouter.createCaller(ctx).masseSalariale.setEvolution({ salarieId: 7, annee: 2027, evolutionSalarialeBps: 10_001, })).rejects.toThrow(); expect(db.setMasseSalarialeEvolution).not.toHaveBeenCalled(); }); }); // ─── Tests Salaires : sonde de chiffrement et confidentialité ───────────────── describe("salaires.getSecurityStatus", () => { it("valide la clé de chiffrement configurée via une procédure administrateur", async () => { const { ctx } = createAuthCtx({ role: "admin" }); const previousKey = process.env.PAYROLL_ENCRYPTION_KEY; process.env.PAYROLL_ENCRYPTION_KEY = Buffer.alloc(32, 9).toString("base64"); try { await expect(appRouter.createCaller(ctx).salaires.getSecurityStatus()) .resolves.toEqual({ ready: true, algorithm: "AES-256-GCM" }); } finally { if (previousKey === undefined) delete process.env.PAYROLL_ENCRYPTION_KEY; else process.env.PAYROLL_ENCRYPTION_KEY = previousKey; } }); it("interdit la sonde de sécurité aux profils non administrateurs", async () => { const { ctx } = createAuthCtx({ role: "standard" }); await expect(appRouter.createCaller(ctx).salaires.getSecurityStatus()).rejects.toThrow(); }); }); describe("salaires.list", () => { beforeEach(() => { vi.clearAllMocks(); }); it("retourne l'index métier et explique la variation uniquement à l'administrateur", async () => { const { db } = await import("./db").then(m => ({ db: m })); const mayLiasse = { id: 9, annee: 2026, mois: 5, nomFichier: "mai.pdf", stockageKey: "secret-key", empreinteSha256: "a".repeat(64), tailleOctets: 1200, ivBase64: "iv", authTagBase64: "tag", versionCle: 1, statutExtraction: "ready", erreurExtraction: null, importePar: 1, importeLe: new Date(), }; const juneLiasse = { ...mayLiasse, id: 10, mois: 6, nomFichier: "juin.pdf", stockageKey: "secret-key-2" }; const mayBulletin = { id: 1, liasseId: 9, matricule: "000001", nom: "DUPONT", prenom: "TEST", poste: "Responsable", brutMensuelCents: 300000, brutAvecPrimesCents: 320000, primeAstreinteCents: 20000, explicationEcart: null, numeroPage: 1, createdAt: new Date(), updatedAt: new Date(), }; const juneBulletin = { ...mayBulletin, id: 2, liasseId: 10, brutAvecPrimesCents: 350000, primeAstreinteCents: 40000 }; (db.getSalairesMensuels as ReturnType).mockResolvedValue({ liasses: [juneLiasse, mayLiasse], rows: [{ liasse: juneLiasse, bulletin: juneBulletin }, { liasse: mayLiasse, bulletin: mayBulletin }], }); const { ctx } = createAuthCtx({ role: "admin" }); const result = await appRouter.createCaller(ctx).salaires.list({ annee: 2026, mois: 6 }); expect(result.indicateurs).toEqual({ nombreSalaries: 1, totalBrutCents: 350000, totalEcartCents: 30000 }); expect(result.bulletins[0]).toMatchObject({ ecartBrutCents: 30000, brutPrecedentCents: 320000 }); expect(result.bulletins[0]?.explication).toContain("astreintes"); expect(JSON.stringify(result)).not.toContain("secret-key"); expect(JSON.stringify(result)).not.toContain("empreinteSha256"); expect(result.liasses[0]?.pdfPath).toBe("/api/salaires/liasses/10/pdf"); }); it("interdit l'index salarial aux profils standard et lecture seule", async () => { const standard = createAuthCtx({ role: "standard" }); const readonly = createAuthCtx({ role: "readonly" }); await expect(appRouter.createCaller(standard.ctx).salaires.list({})).rejects.toThrow(); await expect(appRouter.createCaller(readonly.ctx).salaires.list({})).rejects.toThrow(); }); it("rejette un mois invalide avant tout accès aux données", async () => { const { db } = await import("./db").then(m => ({ db: m })); const { ctx } = createAuthCtx({ role: "admin" }); await expect(appRouter.createCaller(ctx).salaires.list({ mois: 13 })).rejects.toThrow(); expect(db.getSalairesMensuels).not.toHaveBeenCalled(); }); });