import { eq, sql } from "drizzle-orm" import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { consents, installerProfiles, refreshTokens, users } from "../src/db/schema/index.js" import { REFRESH_COOKIE, SESSION_HINT_COOKIE } from "../src/routes/public/auth.js" import { hashPassword } from "../src/services/password.js" import { hashToken } from "../src/services/tokens.js" import { buildTestApp, cookieFrom, type TestApp, tokenFromEmail, truncateAll } from "./helpers.js" const PASSWORD = "Password-di-test-1" let t: TestApp const db = () => t.app.db const register = (body: Record) => t.app.inject({ method: "POST", url: "/auth/register", payload: { password: PASSWORD, firstName: "Mario", lastName: "Rossi", acceptPrivacy: true, ...body }, }) const login = (email: string, password = PASSWORD) => t.app.inject({ method: "POST", url: "/auth/login", payload: { email, password } }) const refresh = (rt: string | undefined) => t.app.inject({ method: "POST", url: "/auth/refresh", cookies: rt ? { [REFRESH_COOKIE]: rt } : {} }) /** Crea un utente direttamente nel DB (es. admin, che non può registrarsi dalla route). */ async function createUser(email: string, role: "customer" | "admin" = "customer", extra: Partial = {}) { const [u] = await db() .insert(users) .values({ email, passwordHash: await hashPassword(PASSWORD), role, firstName: "T", lastName: "U", ...extra }) .returning() return u! } /** Login e restituisce access token + refresh token (dal cookie). */ async function session(email: string) { const res = await login(email) expect(res.statusCode).toBe(200) return { access: res.json().accessToken as string, rt: cookieFrom(res.headers["set-cookie"], REFRESH_COOKIE)! } } beforeAll(async () => { t = await buildTestApp({}, (app) => { // Route di prova per requireRole app.get("/__test/admin-only", { onRequest: [app.requireRole("admin")] }, async () => ({ ok: true })) app.get("/__test/staff", { onRequest: [app.requireRole("admin", "installer")] }, async () => ({ ok: true })) }) }) afterAll(() => t.app.close()) beforeEach(async () => { await truncateAll() t.email.clear() }) describe("registrazione", () => { it("crea un customer con consenso privacy, email di verifica e password hashata", async () => { const res = await register({ email: " Mario.Rossi@Example.COM " }) expect(res.statusCode).toBe(202) const [u] = await db().select().from(users) expect(u).toMatchObject({ email: "mario.rossi@example.com", role: "customer", emailVerifiedAt: null }) expect(u!.passwordHash.startsWith("$argon2id$")).toBe(true) expect(u!.passwordHash).not.toContain(PASSWORD) const c = await db().select().from(consents).where(eq(consents.userId, u!.id)) expect(c.map((x) => x.type)).toEqual(["privacy"]) expect(t.email.last("verify-email")?.to).toBe("mario.rossi@example.com") }) it("ignora un ruolo admin passato nel body", async () => { await register({ email: "furbo@example.com", role: "admin", accountType: "customer" }) const [u] = await db().select().from(users) expect(u!.role).toBe("customer") }) it("rifiuta accountType admin", async () => { const res = await register({ email: "furbo@example.com", accountType: "admin" }) expect(res.statusCode).toBe(400) }) it("company con ragione sociale + P.IVA, anche senza accountType", async () => { await register({ email: "azienda@example.com", companyName: "Rossi srl", vatNumber: "IT01234567890" }) const [u] = await db().select().from(users) expect(u).toMatchObject({ role: "company", companyName: "Rossi srl", vatNumber: "IT01234567890" }) }) it("company senza P.IVA → 400 con dettaglio", async () => { const res = await register({ email: "azienda@example.com", accountType: "company", companyName: "Rossi srl" }) expect(res.statusCode).toBe(400) expect(res.json().error.details[0].path).toBe("body.vatNumber") }) it("installer crea il profilo in stato pending", async () => { await register({ email: "inst@example.com", accountType: "installer", companyName: "Impianti Rossi", vatNumber: "IT09876543210", acceptMarketing: true, }) const [u] = await db().select().from(users) expect(u!.role).toBe("installer") const [p] = await db().select().from(installerProfiles).where(eq(installerProfiles.userId, u!.id)) expect(p).toMatchObject({ businessName: "Impianti Rossi", status: "pending" }) const c = await db().select().from(consents).where(eq(consents.userId, u!.id)) expect(c.map((x) => x.type).sort()).toEqual(["marketing", "privacy"]) }) it("senza consenso privacy → 400", async () => { const res = await register({ email: "x@example.com", acceptPrivacy: false }) expect(res.statusCode).toBe(400) }) it("email già registrata: stessa risposta, nessun nuovo utente, avviso al titolare", async () => { const first = await register({ email: "dup@example.com" }) t.email.clear() const second = await register({ email: "DUP@example.com", firstName: "Altro" }) expect(second.statusCode).toBe(first.statusCode) expect(second.json()).toEqual(first.json()) expect(await db().select().from(users)).toHaveLength(1) expect(t.email.last()?.tag).toBe("register-existing") }) }) describe("login", () => { it("ok: access token, utente e cookie di refresh con gli attributi richiesti", async () => { await createUser("cliente@example.com") const res = await login("Cliente@Example.com") expect(res.statusCode).toBe(200) const body = res.json() expect(body.user).toMatchObject({ email: "cliente@example.com", role: "customer", emailVerified: false }) expect(body.expiresIn).toBe(900) expect(body.accessToken.split(".")).toHaveLength(3) const cookie = ([] as string[]).concat(res.headers["set-cookie"] ?? []).find((c) => c.startsWith(`${REFRESH_COOKIE}=`))! expect(cookie).toMatch(/HttpOnly/i) expect(cookie).toMatch(/Secure/i) expect(cookie).toMatch(/SameSite=Lax/i) expect(cookie).toMatch(/Path=\/auth/i) // Indizio di sessione per i frontend: leggibile da JS, path "/", nessun segreto const hint = ([] as string[]).concat(res.headers["set-cookie"] ?? []).find((c) => c.startsWith(`${SESSION_HINT_COOKIE}=`))! expect(hint).toMatch(/^iti_session=1;/) expect(hint).not.toMatch(/HttpOnly/i) expect(hint).toMatch(/Path=\/;/i) expect(hint).toMatch(/SameSite=Lax/i) }) it("password errata, email inesistente e utente disabilitato → stesso 401", async () => { await createUser("cliente@example.com") await createUser("off@example.com", "customer", { disabledAt: new Date() }) const results = await Promise.all([ login("cliente@example.com", "sbagliata-123"), login("nessuno@example.com"), login("off@example.com"), ]) for (const r of results) { expect(r.statusCode).toBe(401) expect(r.json()).toEqual({ error: { code: "INVALID_CREDENTIALS", message: "Email o password non corretti" } }) } }) it("GET /me con e senza access token", async () => { await createUser("cliente@example.com") const { access } = await session("cliente@example.com") const me = await t.app.inject({ method: "GET", url: "/me", headers: { authorization: `Bearer ${access}` } }) expect(me.statusCode).toBe(200) expect(me.json().email).toBe("cliente@example.com") const anon = await t.app.inject({ method: "GET", url: "/me" }) expect(anon.statusCode).toBe(401) expect(anon.json().error.code).toBe("UNAUTHORIZED") const forged = await t.app.inject({ method: "GET", url: "/me", headers: { authorization: `Bearer ${access}x` } }) expect(forged.statusCode).toBe(401) }) }) describe("refresh", () => { it("ruota il token: nuovo cookie, nuovo access token, il precedente è revocato", async () => { await createUser("cliente@example.com") const { rt: rt1 } = await session("cliente@example.com") const res = await refresh(rt1) expect(res.statusCode).toBe(200) expect(res.json().accessToken).toBeTruthy() const rt2 = cookieFrom(res.headers["set-cookie"], REFRESH_COOKIE)! expect(rt2).toBeTruthy() expect(rt2).not.toBe(rt1) const rows = await db().select().from(refreshTokens) expect(rows).toHaveLength(2) expect(new Set(rows.map((r) => r.familyId)).size).toBe(1) expect(rows.find((r) => r.tokenHash === hashToken(rt1))?.revokedAt).not.toBeNull() expect(rows.find((r) => r.tokenHash === hashToken(rt2))?.revokedAt).toBeNull() // Il nuovo token funziona a sua volta expect((await refresh(rt2)).statusCode).toBe(200) }) it("riuso di un token revocato → 401 e revoca dell'intera family", async () => { await createUser("cliente@example.com") const { rt: rt1 } = await session("cliente@example.com") const rt2 = cookieFrom((await refresh(rt1)).headers["set-cookie"], REFRESH_COOKIE)! const reuse = await refresh(rt1) expect(reuse.statusCode).toBe(401) // Anche il token "legittimo" più recente non vale più expect((await refresh(rt2)).statusCode).toBe(401) const active = (await db().select().from(refreshTokens)).filter((r) => r.revokedAt === null) expect(active).toHaveLength(0) }) it("altre sessioni (family diverse) non sono toccate dal riuso", async () => { await createUser("cliente@example.com") const a = await session("cliente@example.com") const b = await session("cliente@example.com") await refresh(a.rt) await refresh(a.rt) // riuso su A expect((await refresh(b.rt)).statusCode).toBe(200) }) it("senza cookie o con token sconosciuto → 401", async () => { expect((await refresh(undefined)).statusCode).toBe(401) expect((await refresh("token-inventato-abcdefghijklmnopqrstuvwxyz")).statusCode).toBe(401) }) it("utente disabilitato dopo il login → refresh rifiutato", async () => { const u = await createUser("cliente@example.com") const { rt } = await session("cliente@example.com") await db().update(users).set({ disabledAt: new Date() }).where(eq(users.id, u.id)) expect((await refresh(rt)).statusCode).toBe(401) }) }) describe("logout", () => { it("revoca la sessione e cancella il cookie", async () => { await createUser("cliente@example.com") const { rt } = await session("cliente@example.com") const res = await t.app.inject({ method: "POST", url: "/auth/logout", cookies: { [REFRESH_COOKIE]: rt } }) expect(res.statusCode).toBe(204) const cleared = ([] as string[]).concat(res.headers["set-cookie"] ?? []).find((c) => c.startsWith(`${REFRESH_COOKIE}=`)) expect(cleared).toMatch(/Expires=Thu, 01 Jan 1970|Max-Age=0/i) const hint = ([] as string[]).concat(res.headers["set-cookie"] ?? []).find((c) => c.startsWith(`${SESSION_HINT_COOKIE}=`)) expect(hint).toMatch(/Expires=Thu, 01 Jan 1970|Max-Age=0/i) expect((await refresh(rt)).statusCode).toBe(401) }) it("senza cookie → 204 comunque", async () => { expect((await t.app.inject({ method: "POST", url: "/auth/logout" })).statusCode).toBe(204) }) }) describe("requireRole", () => { it("blocca i ruoli non ammessi, consente quelli ammessi", async () => { await createUser("cliente@example.com", "customer") await createUser("admin@example.com", "admin") const customer = await session("cliente@example.com") const admin = await session("admin@example.com") const get = (url: string, token?: string) => t.app.inject({ method: "GET", url, headers: token ? { authorization: `Bearer ${token}` } : {} }) const denied = await get("/__test/admin-only", customer.access) expect(denied.statusCode).toBe(403) expect(denied.json().error.code).toBe("FORBIDDEN") expect((await get("/__test/admin-only", admin.access)).statusCode).toBe(200) expect((await get("/__test/admin-only")).statusCode).toBe(401) expect((await get("/__test/staff", admin.access)).statusCode).toBe(200) expect((await get("/__test/staff", customer.access)).statusCode).toBe(403) }) }) describe("reset password e verifica email", () => { it("forgot-password: stessa risposta per email esistenti e non, link valido una sola volta", async () => { await createUser("cliente@example.com") const { rt } = await session("cliente@example.com") const known = await t.app.inject({ method: "POST", url: "/auth/forgot-password", payload: { email: "cliente@example.com" } }) const unknown = await t.app.inject({ method: "POST", url: "/auth/forgot-password", payload: { email: "x@example.com" } }) expect(known.statusCode).toBe(202) expect(unknown.json()).toEqual(known.json()) expect(t.email.sent.filter((m) => m.tag === "reset-password")).toHaveLength(1) const token = tokenFromEmail(t.email.last("reset-password")?.text) const reset = (password: string) => t.app.inject({ method: "POST", url: "/auth/reset-password", payload: { token, password } }) expect((await reset("Nuova-password-123")).statusCode).toBe(200) expect((await reset("Altra-password-123")).json().error.code).toBe("INVALID_TOKEN") expect((await login("cliente@example.com")).statusCode).toBe(401) expect((await login("cliente@example.com", "Nuova-password-123")).statusCode).toBe(200) // Le sessioni precedenti al reset sono chiuse expect((await refresh(rt)).statusCode).toBe(401) }) it("verify-email conferma l'indirizzo, il link vale una sola volta", async () => { await register({ email: "nuovo@example.com" }) const token = tokenFromEmail(t.email.last("verify-email")?.text) const verify = () => t.app.inject({ method: "POST", url: "/auth/verify-email", payload: { token } }) expect((await verify()).statusCode).toBe(200) expect((await verify()).statusCode).toBe(400) const { access } = await session("nuovo@example.com") const me = await t.app.inject({ method: "GET", url: "/me", headers: { authorization: `Bearer ${access}` } }) expect(me.json().emailVerified).toBe(true) }) }) describe("niente segreti in chiaro", () => { it("token e password non compaiono né nel DB né nei log", async () => { await register({ email: "segreti@example.com" }) const verifyToken = tokenFromEmail(t.email.last("verify-email")?.text) await t.app.inject({ method: "POST", url: "/auth/verify-email", payload: { token: verifyToken } }) const res = await login("segreti@example.com") const { accessToken } = res.json() const rt = cookieFrom(res.headers["set-cookie"], REFRESH_COOKIE)! const rotated = cookieFrom((await refresh(rt)).headers["set-cookie"], REFRESH_COOKIE)! await t.app.inject({ method: "POST", url: "/auth/forgot-password", payload: { email: "segreti@example.com" } }) const resetToken = tokenFromEmail(t.email.last("reset-password")?.text) const secrets = [PASSWORD, verifyToken, rt, rotated, resetToken, accessToken] // DB: dump testuale delle tabelle con token/password const dump = JSON.stringify( await db().execute( sql.raw(`SELECT (SELECT json_agg(u) FROM users u) AS u, (SELECT json_agg(r) FROM refresh_tokens r) AS r, (SELECT json_agg(p) FROM password_reset_tokens p) AS p, (SELECT json_agg(e) FROM email_verification_tokens e) AS e`), ), ) for (const s of secrets) expect(dump).not.toContain(s) // Log: richieste, risposte, header (authorization/cookie sono redatti) await t.app.inject({ method: "GET", url: "/me", headers: { authorization: `Bearer ${accessToken}` } }) expect(t.logs.lines.length).toBeGreaterThan(0) for (const s of secrets) expect(t.logs.text).not.toContain(s) }) }) describe("rate limit", () => { it("login: oltre il limite per IP → 429", async () => { const limited = await buildTestApp({ AUTH_LOGIN_RATE_MAX: "3" }) try { const codes = [] for (let i = 0; i < 4; i++) { const r = await limited.app.inject({ method: "POST", url: "/auth/login", payload: { email: "a@example.com", password: "x" } }) codes.push(r.statusCode) } expect(codes).toEqual([401, 401, 401, 429]) } finally { await limited.app.close() } }) })