Files
iti-technology/apps/api/test/contact.test.ts
T
Lorenzo SanesiandClaude Opus 5.5 b83ce039fb
Deploy stack su Proxmox / deploy (push) Failing after 3s
Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
Monorepo npm con apps/public (Angular SSR), apps/private (Angular SPA),
apps/api (Fastify), packages/shared e packages/styles; Medusa 2.21 in medusa/;
infrastruttura Docker (Postgres PostGIS+pgvector, Redis, Caddy, backup) e
workflow Gitea Actions per il deploy via SSH a ogni push su master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jQyT6dR9czzGwqb7FkG6u
2026-10-20 09:30:48 +02:00

217 lines
11 KiB
TypeScript

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<string, unknown>, headers: Record<string, string> = {}) =>
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)
})
})