All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 2m45s
513 lines
19 KiB
TypeScript
513 lines
19 KiB
TypeScript
/**
|
|
* 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> = {}): 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<string, unknown> };
|
|
|
|
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<string, unknown>) => setCookies.push({ name, options }),
|
|
clearCookie: (name: string, options: Record<string, unknown>) => clearedCookies.push({ name, options }),
|
|
} as unknown as TrpcContext["res"],
|
|
};
|
|
return { ctx, setCookies, clearedCookies };
|
|
}
|
|
|
|
function createAuthCtx(userOverrides: Partial<User> = {}): { 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<string, unknown>) => clearedCookies.push({ name, options }),
|
|
} as unknown as TrpcContext["res"],
|
|
};
|
|
return { ctx, clearedCookies };
|
|
}
|
|
|
|
// ─── Mock db ─────────────────────────────────────────────────────────────────
|
|
|
|
vi.mock("./db", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("./db")>();
|
|
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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();
|
|
});
|
|
});
|