Deploy stack su Proxmox / deploy (push) Failing after 3s
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
217 lines
11 KiB
TypeScript
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)
|
|
})
|
|
})
|