import { eq } from "drizzle-orm" import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { consents, contactRequests, refreshTokens, users } from "../src/db/schema/index.js" import { checkFormToken, createFormToken, FORM_TOKEN_TTL_MS } from "../src/services/form-guard.js" import { buildTestApp, createUserAndLogin, TEST_PASSWORD, type TestApp, truncateAll } from "./helpers.js" let t: TestApp let ipSeq = 0 const SECRET = "c".repeat(32) const oldToken = () => createFormToken(SECRET, new Date(Date.now() - 10_000)).token const contact = (payload: Record, headers: Record = {}) => t.app.inject({ method: "POST", url: "/contact", headers, remoteAddress: `10.1.0.${++ipSeq % 250}`, payload: { name: "Anna Bianchi", email: " Anna@Example.com ", message: "Vorrei informazioni sugli addolcitori", acceptPrivacy: true, formToken: oldToken(), ...payload, }, }) beforeAll(async () => { t = await buildTestApp() }) afterAll(() => t.app.close()) beforeEach(async () => { await truncateAll() t.email.clear() }) describe("token anti-spam", () => { it("firma, tempo minimo e scadenza", () => { const now = new Date("2026-10-03T10:00:00Z") const { token } = createFormToken(SECRET, now) expect(checkFormToken(SECRET, token, new Date(now.getTime() + 1000))).toBe("too_fast") expect(checkFormToken(SECRET, token, new Date(now.getTime() - 60_000))).toBe("too_fast") // dal futuro expect(checkFormToken(SECRET, token, new Date(now.getTime() + 5000))).toBe("ok") expect(checkFormToken(SECRET, token, new Date(now.getTime() + FORM_TOKEN_TTL_MS + 1))).toBe("expired") expect(checkFormToken("x".repeat(32), token, new Date(now.getTime() + 5000))).toBe("invalid") const [issued, sig] = token.split(".") expect(checkFormToken(SECRET, `${Number(issued) - 10_000}.${sig}`, now)).toBe("invalid") // data alterata expect(checkFormToken(SECRET, "abc", now)).toBe("invalid") }) it("GET /forms/token restituisce un token valido, non in cache", async () => { const res = await t.app.inject({ method: "GET", url: "/forms/token" }) expect(res.statusCode).toBe(200) expect(res.headers["cache-control"]).toBe("no-store") expect(res.json().minSeconds).toBe(3) expect(checkFormToken(SECRET, res.json().token, new Date(Date.now() + 5000))).toBe("ok") }) }) describe("form contatti", () => { it("salva la richiesta (email normalizzata), i consensi e avvisa lo staff", async () => { await createUserAndLogin(t.app, "admin@iti.local", "admin") t.email.clear() const res = await contact({ subject: "Addolcitore", phone: "0391234567", acceptMarketing: true }) expect(res.statusCode, res.body).toBe(200) const [saved] = await t.app.db.select().from(contactRequests) expect(saved).toMatchObject({ email: "anna@example.com", subject: "Addolcitore", status: "new", userId: null }) const c = await t.app.db.select().from(consents) expect(c.map((x) => x.type).sort()).toEqual(["marketing", "privacy"]) expect(c.every((x) => x.source === "contact" && x.version === "privacy-v1" && x.email === "anna@example.com")).toBe(true) const mail = t.email.last("staff-notification") expect(mail?.to).toBe("admin@iti.local") expect(mail?.text).toContain("Vorrei informazioni sugli addolcitori") expect(mail?.text).toContain(`/admin/contatti/${saved!.id}`) }) it("senza consenso privacy → 400, nulla salvato", async () => { for (const acceptPrivacy of [false, undefined]) { const res = await contact({ acceptPrivacy }) expect(res.statusCode).toBe(400) expect(res.json().error.code).toBe("VALIDATION_ERROR") } expect(await t.app.db.select().from(contactRequests)).toHaveLength(0) expect(await t.app.db.select().from(consents)).toHaveLength(0) }) it("honeypot compilato o invio troppo rapido → 200, nulla salvato né inviato", async () => { const honeypot = await contact({ website: "http://spam.example" }) expect(honeypot.statusCode).toBe(200) const fast = await contact({ formToken: createFormToken(SECRET).token }) expect(fast.statusCode).toBe(200) expect(fast.json()).toEqual(honeypot.json()) expect(await t.app.db.select().from(contactRequests)).toHaveLength(0) expect(await t.app.db.select().from(consents)).toHaveLength(0) expect(t.email.sent).toHaveLength(0) const expired = await contact({ formToken: createFormToken(SECRET, new Date(Date.now() - FORM_TOKEN_TTL_MS - 1000)).token }) expect(expired.statusCode).toBe(400) expect(expired.json().error.code).toBe("FORM_EXPIRED") expect((await contact({ formToken: undefined })).statusCode).toBe(400) }) it("rate limit per IP: 5 invii ogni 15 minuti", async () => { const send = () => t.app.inject({ method: "POST", url: "/contact", remoteAddress: "10.9.9.9", payload: { name: "Bot", email: "bot@example.com", message: "x".repeat(20), acceptPrivacy: true, formToken: oldToken() }, }) for (let i = 0; i < 5; i++) expect((await send()).statusCode).toBe(200) expect((await send()).statusCode).toBe(429) }) it("utente autenticato collegato; admin: elenco, ricerca e cambio stato", async () => { const admin = (await createUserAndLogin(t.app, "admin@iti.local", "admin")).headers const mario = await createUserAndLogin(t.app, "mario@example.com") await contact({ email: "mario@example.com", subject: "Fotovoltaico" }, mario.headers) await contact({}) const list = await t.app.inject({ method: "GET", url: "/admin/contact-requests?q=mario", headers: admin }) expect(list.json()).toMatchObject({ total: 1, items: [{ userId: mario.user.id, subject: "Fotovoltaico" }] }) const id = list.json().items[0].id const patched = await t.app.inject({ method: "PATCH", url: `/admin/contact-requests/${id}`, headers: admin, payload: { status: "closed" } }) expect(patched.json().status).toBe("closed") expect((await t.app.inject({ method: "GET", url: "/admin/contact-requests?status=new", headers: admin })).json().total).toBe(1) expect((await t.app.inject({ method: "GET", url: "/admin/contact-requests", headers: mario.headers })).statusCode).toBe(403) }) }) describe("consensi dell'utente", () => { it("elenco con i consensi anonimi della stessa email solo se verificata; revoca e nuovo consenso marketing", async () => { // consensi dati da anonimo prima della registrazione await contact({ email: "mario@example.com", acceptMarketing: true }) const mario = await createUserAndLogin(t.app, "mario@example.com") await t.app.db.insert(consents).values({ userId: mario.user.id, type: "privacy", version: "privacy-v1", source: "register" }) const get = () => t.app.inject({ method: "GET", url: "/me/consents", headers: mario.headers }) const before = (await get()).json() expect(before.marketing).toBe(true) expect(before.items).toHaveLength(3) expect(before.items[0]).toEqual({ id: expect.any(String), type: expect.any(String), version: "privacy-v1", source: expect.any(String), acceptedAt: expect.any(String), revokedAt: null, }) const revoked = await t.app.inject({ method: "DELETE", url: "/me/consents/marketing", headers: mario.headers }) expect(revoked.json().marketing).toBe(false) const marketing = revoked.json().items.filter((c: { type: string }) => c.type === "marketing") expect(marketing).toHaveLength(1) expect(marketing[0].revokedAt).not.toBeNull() expect((await t.app.inject({ method: "DELETE", url: "/me/consents/marketing", headers: mario.headers })).statusCode).toBe(200) const granted = await t.app.inject({ method: "POST", url: "/me/consents/marketing", headers: mario.headers }) expect(granted.json().marketing).toBe(true) await t.app.inject({ method: "POST", url: "/me/consents/marketing", headers: mario.headers }) // idempotente const rows = await t.app.db.select().from(consents).where(eq(consents.type, "marketing")) expect(rows.filter((c) => !c.revokedAt)).toHaveLength(1) expect(rows.find((c) => !c.revokedAt)).toMatchObject({ source: "account", userId: mario.user.id }) // email non verificata: i consensi anonimi della stessa email non sono suoi await contact({ email: "luigi@example.com", acceptMarketing: true }) const luigi = await createUserAndLogin(t.app, "luigi@example.com", "customer", { emailVerifiedAt: null }) const luigiConsents = (await t.app.inject({ method: "GET", url: "/me/consents", headers: luigi.headers })).json() expect(luigiConsents).toEqual({ marketing: false, items: [] }) await t.app.inject({ method: "DELETE", url: "/me/consents/marketing", headers: luigi.headers }) const anon = await t.app.db.select().from(consents).where(eq(consents.email, "luigi@example.com")) expect(anon.every((c) => c.revokedAt === null)).toBe(true) expect((await t.app.inject({ method: "GET", url: "/me/consents" })).statusCode).toBe(401) }) }) describe("richiesta di cancellazione dell'account", () => { it("disabilita l'account, chiude le sessioni, revoca il marketing e avvisa lo staff", async () => { await createUserAndLogin(t.app, "admin@iti.local", "admin") const mario = await createUserAndLogin(t.app, "mario@example.com") await t.app.inject({ method: "POST", url: "/me/consents/marketing", headers: mario.headers }) t.email.clear() const del = (password: string) => t.app.inject({ method: "POST", url: "/me/delete-account", headers: mario.headers, payload: { password, reason: "Non uso più il servizio" } }) const wrong = await del("password-sbagliata") expect(wrong.statusCode).toBe(401) expect((await t.app.db.select().from(users).where(eq(users.id, mario.user.id)))[0]!.disabledAt).toBeNull() expect((await del(TEST_PASSWORD)).statusCode).toBe(202) const [user] = await t.app.db.select().from(users).where(eq(users.id, mario.user.id)) expect(user!.disabledAt).not.toBeNull() const tokens = await t.app.db.select().from(refreshTokens).where(eq(refreshTokens.userId, mario.user.id)) expect(tokens.every((r) => r.revokedAt !== null)).toBe(true) const marketing = await t.app.db.select().from(consents).where(eq(consents.userId, mario.user.id)) expect(marketing.filter((c) => c.type === "marketing").every((c) => c.revokedAt !== null)).toBe(true) const mail = t.email.last("staff-notification") expect(mail?.to).toBe("admin@iti.local") expect(mail?.text).toContain("mario@example.com") expect(mail?.text).toContain("Non uso più il servizio") // account non più utilizzabile expect((await t.app.inject({ method: "GET", url: "/me", headers: mario.headers })).statusCode).toBe(401) const login = await t.app.inject({ method: "POST", url: "/auth/login", payload: { email: "mario@example.com", password: TEST_PASSWORD } }) expect(login.statusCode).toBe(401) }) it("un admin non può cancellarsi da solo", async () => { const admin = (await createUserAndLogin(t.app, "admin@iti.local", "admin")).headers const res = await t.app.inject({ method: "POST", url: "/me/delete-account", headers: admin, payload: { password: TEST_PASSWORD } }) expect(res.statusCode).toBe(409) }) })