import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; import { resetDb, closeDb } from "./setup"; import { requestCode, verifyCode } from "../../src/lib/auth/otp"; import { lastMockMail, setTransportForTests, mockTransport } from "../../src/lib/auth/email"; import { createSession } from "../../src/lib/auth/session"; import { db } from "../../src/db/client"; import { sessions, otpChallenges, users } from "../../src/db/schema"; import { eq } from "drizzle-orm"; import { normalizeEmail, generateOtp, otpDigest, sessionDigest } from "../../src/lib/auth/crypto"; const codeFromMail = () => lastMockMail.set?.text.match(/\b(\d{6})\b/)?.[1] ?? ""; describe("connexion par code courriel (Resend simulé)", () => { beforeAll(() => setTransportForTests(mockTransport)); afterAll(closeDb); beforeEach(resetDb); it("normalise les adresses sans supprimer points ni +", () => { expect(normalizeEmail(" Marie.T+toveli@Exemple.CA ")).toBe("marie.t+toveli@exemple.ca"); expect(normalizeEmail("pas une adresse")).toBeNull(); }); it("génère un code à 6 chiffres et n'en stocke que l'empreinte HMAC", async () => { const r = await requestCode("test1@exemple.ca", "1.2.3.4"); expect(r.ok).toBe(true); if (!r.ok) return; const code = codeFromMail(); expect(code).toMatch(/^\d{6}$/); const [ch] = await db().select().from(otpChallenges).where(eq(otpChallenges.id, r.challengeId)); expect(ch.secretDigest).not.toContain(code); expect(ch.secretDigest).toBe(otpDigest(code, ch.id)); expect(generateOtp()).toMatch(/^\d{6}$/); }); it("crée le compte au premier succès, ouvre une session, refuse la réutilisation du code", async () => { const r = await requestCode("test2@exemple.ca", "1.2.3.4"); if (!r.ok) throw new Error(); const code = codeFromMail(); const bad = await verifyCode(r.challengeId, code, "1.2.3.4", false); expect(bad.ok).toBe(false); if (!bad.ok) expect(bad.reason).toBe("terms_required"); // nouveau compte sans acceptation : code non consommé const ok = await verifyCode(r.challengeId, code, "1.2.3.4", true); expect(ok.ok).toBe(true); if (!ok.ok) return; expect(ok.created).toBe(true); const s = await createSession(ok.userId, { ip: "1.2.3.4", device: "test" }); const [row] = await db().select().from(sessions).where(eq(sessions.tokenDigest, sessionDigest(s.token))); expect(row).toBeTruthy(); expect(row.tokenDigest).not.toBe(s.token); const again = await verifyCode(r.challengeId, code, "1.2.3.4", true); expect(again.ok).toBe(false); // usage unique }); it("refuse code faux (×5 → verrou), code expiré et renvoi avant 60 s", async () => { const r = await requestCode("test3@exemple.ca", "1.2.3.4"); if (!r.ok) throw new Error(); for (let i = 0; i < 5; i++) { const v = await verifyCode(r.challengeId, "000000", "1.2.3.4", true); expect(v.ok).toBe(false); } const sixth = await verifyCode(r.challengeId, codeFromMail(), "1.2.3.4", true); expect(sixth.ok).toBe(false); if (!sixth.ok) expect(["too_many_attempts", "invalid"]).toContain(sixth.reason); const cd = await requestCode("test3@exemple.ca", "1.2.3.4"); expect(cd.ok).toBe(false); if (!cd.ok) expect(cd.reason).toBe("cooldown"); const r2 = await requestCode("test4@exemple.ca", "1.2.3.4"); if (!r2.ok) throw new Error(); await db().update(otpChallenges).set({ expiresAt: new Date(Date.now() - 1000) }).where(eq(otpChallenges.id, r2.challengeId)); const exp = await verifyCode(r2.challengeId, codeFromMail(), "1.2.3.4", true); expect(exp.ok).toBe(false); if (!exp.ok) expect(exp.reason).toBe("expired"); }); it("double soumission concurrente : une seule réussit", async () => { const r = await requestCode("test5@exemple.ca", "1.2.3.4"); if (!r.ok) throw new Error(); const code = codeFromMail(); const results = await Promise.all([verifyCode(r.challengeId, code, "1.2.3.4", true), verifyCode(r.challengeId, code, "1.2.3.4", true), verifyCode(r.challengeId, code, "1.2.3.4", true)]); expect(results.filter((x) => x.ok).length).toBe(1); expect((await db().select().from(users)).length).toBe(1); }); it("limite les demandes par adresse (5/h) sans révéler l'existence du compte", async () => { let limited = 0; for (let i = 0; i < 7; i++) { await db().update(otpChallenges).set({ createdAt: new Date(Date.now() - 120000) }); const r = await requestCode("spam@exemple.ca", `9.9.9.${i}`); if (!r.ok && r.reason === "rate_limited") limited++; } expect(limited).toBeGreaterThan(0); }); });