Checkpoint: Migration complète vers authentification locale (JWT + bcrypt) : schéma DB (10 tables), routes tRPC (auth, users, etablissements, inventaire, capex, opex, parametres), page de login avec branding Itinova/Santinova, protection des routes, onglets Établissements et Utilisateurs branchés sur tRPC, import inventaire/établissements/utilisateurs via tRPC, 14 tests Vitest passants.
This commit is contained in:
270
server/auth.local.test.ts
Normal file
270
server/auth.local.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* 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(),
|
||||
};
|
||||
});
|
||||
|
||||
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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user