Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
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
This commit is contained in:
Lorenzo Sanesi
2026-10-20 09:30:48 +02:00
co-authored by Claude Opus 5.5
commit b83ce039fb
505 changed files with 84544 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import { buildTestApp, cookieFrom, createUserAndLogin, TEST_PASSWORD, type TestApp, truncateAll } from "./helpers.js"
let t: TestApp
beforeAll(async () => {
t = await buildTestApp()
})
afterAll(async () => {
await t.app.close()
})
beforeEach(async () => {
await truncateAll()
t.medusa.reset()
})
describe("PATCH /me", () => {
it("aggiorna nome e cognome, senza toccare il resto", async () => {
const { headers } = await createUserAndLogin(t.app, "mario@example.com")
const res = await t.app.inject({ method: "PATCH", url: "/me", headers, payload: { firstName: " Mario ", lastName: "Bianchi" } })
expect(res.statusCode, res.body).toBe(200)
expect(res.json()).toMatchObject({ firstName: "Mario", lastName: "Bianchi", email: "mario@example.com", role: "customer" })
expect((await t.app.inject({ method: "GET", url: "/me", headers })).json().lastName).toBe("Bianchi")
})
it("un privato non ha dati aziendali; email e ruolo non si cambiano", async () => {
const { headers } = await createUserAndLogin(t.app, "mario@example.com")
const res = await t.app.inject({ method: "PATCH", url: "/me", headers, payload: { companyName: "Mario Srl" } })
expect(res.statusCode).toBe(400)
expect(res.json().error.details[0].path).toBe("body.companyName")
const sneaky = await t.app.inject({ method: "PATCH", url: "/me", headers, payload: { role: "admin", email: "x@example.com", firstName: "M" } })
expect(sneaky.statusCode).toBe(200)
expect(sneaky.json()).toMatchObject({ role: "customer", email: "mario@example.com" })
})
it("azienda: ragione sociale e P.IVA modificabili ma non svuotabili", async () => {
const { headers } = await createUserAndLogin(t.app, "srl@example.com", "company", { companyName: "Vecchia Srl", vatNumber: "12345678903" })
const ok = await t.app.inject({ method: "PATCH", url: "/me", headers, payload: { companyName: "Nuova Srl" } })
expect(ok.json()).toMatchObject({ companyName: "Nuova Srl", vatNumber: "12345678903" })
const empty = await t.app.inject({ method: "PATCH", url: "/me", headers, payload: { vatNumber: "" } })
expect(empty.statusCode).toBe(400)
expect(empty.json().error.details[0].path).toBe("body.vatNumber")
})
it("installatore: P.IVA facoltativa, ragione sociale obbligatoria", async () => {
const { headers } = await createUserAndLogin(t.app, "inst@example.com", "installer", { companyName: "Impianti Srl", vatNumber: "12345678903" })
expect((await t.app.inject({ method: "PATCH", url: "/me", headers, payload: { vatNumber: null } })).json().vatNumber).toBeNull()
expect((await t.app.inject({ method: "PATCH", url: "/me", headers, payload: { companyName: " " } })).statusCode).toBe(400)
})
it("serve l'autenticazione", async () => {
expect((await t.app.inject({ method: "PATCH", url: "/me", payload: { firstName: "X" } })).statusCode).toBe(401)
})
})
describe("POST /auth/change-password", () => {
async function loginCookie(email: string, password = TEST_PASSWORD) {
const res = await t.app.inject({ method: "POST", url: "/auth/login", payload: { email, password } })
return { status: res.statusCode, cookie: cookieFrom(res.headers["set-cookie"], "iti_rt") }
}
it("con la password attuale: nuova sessione qui, le altre chiuse", async () => {
const { headers } = await createUserAndLogin(t.app, "mario@example.com")
const other = await loginCookie("mario@example.com")
const res = await t.app.inject({
method: "POST",
url: "/auth/change-password",
headers,
payload: { currentPassword: TEST_PASSWORD, newPassword: "Nuova-password-123" },
})
expect(res.statusCode, res.body).toBe(200)
expect(res.json()).toMatchObject({ accessToken: expect.any(String), user: { email: "mario@example.com" } })
const fresh = cookieFrom(res.headers["set-cookie"], "iti_rt")
expect(fresh).toBeTruthy()
// l'altro dispositivo perde la sessione, questo no
const otherRefresh = await t.app.inject({ method: "POST", url: "/auth/refresh", cookies: { iti_rt: other.cookie! } })
expect(otherRefresh.statusCode).toBe(401)
const thisRefresh = await t.app.inject({ method: "POST", url: "/auth/refresh", cookies: { iti_rt: fresh! } })
expect(thisRefresh.statusCode).toBe(200)
expect((await loginCookie("mario@example.com")).status).toBe(401)
expect((await loginCookie("mario@example.com", "Nuova-password-123")).status).toBe(200)
})
it("password attuale errata → 400 sul campo, nulla cambia", async () => {
const { headers } = await createUserAndLogin(t.app, "mario@example.com")
const res = await t.app.inject({
method: "POST",
url: "/auth/change-password",
headers,
payload: { currentPassword: "sbagliata-123", newPassword: "Nuova-password-123" },
})
expect(res.statusCode).toBe(400)
expect(res.json().error).toMatchObject({ code: "INVALID_CURRENT_PASSWORD", details: [{ path: "body.currentPassword" }] })
expect((await loginCookie("mario@example.com")).status).toBe(200)
})
it("nuova password troppo corta o uguale all'attuale → 400", async () => {
const { headers } = await createUserAndLogin(t.app, "mario@example.com")
for (const newPassword of ["corta", TEST_PASSWORD]) {
const res = await t.app.inject({ method: "POST", url: "/auth/change-password", headers, payload: { currentPassword: TEST_PASSWORD, newPassword } })
expect(res.statusCode).toBe(400)
}
})
})
describe("GET /me/orders/:number", () => {
const summary = (id: string, displayId: number) => ({
id,
display_id: displayId,
status: "pending",
created_at: "2026-10-01T10:00:00.000Z",
payment_status: "captured",
fulfillment_status: "shipped",
item_total: 1190,
shipping_total: 15,
tax_total: 217.29,
total: 1205,
items: [],
})
it("dettaglio del proprio ordine con indirizzo, spedizione e tracking; 404 per gli altri", async () => {
const mario = await createUserAndLogin(t.app, "mario@example.com")
const luigi = await createUserAndLogin(t.app, "luigi@example.com")
await t.app.inject({ method: "GET", url: "/me/orders", headers: mario.headers })
const customerId = t.medusa.accountByEmail("mario@example.com")!.id
t.medusa.customerOrderList.set(customerId, [summary("order_1", 12)])
t.medusa.orderDetails.set("order_1", {
...summary("order_1", 12),
email: "mario@example.com",
customer_id: customerId,
currency_code: "eur",
discount_total: 0,
items: [
{ title: "Standard", product_title: "Addolcitore", variant_title: "Standard", variant_sku: "ADD-20", thumbnail: null, quantity: 1, unit_price: 1190, total: 1190 },
],
shipping_address: { first_name: "Mario", last_name: "Rossi", address_1: "Via Roma 1", city: "Milano", postal_code: "20100", province: "MI" },
shipping_methods: [{ name: "Spedizione standard" }],
fulfillments: [{ id: "ful_1", labels: [{ tracking_number: "BRT123", tracking_url: "https://track.example.com/BRT123" }] }],
})
const res = await t.app.inject({ method: "GET", url: "/me/orders/12", headers: mario.headers })
expect(res.statusCode, res.body).toBe(200)
expect(res.json()).toMatchObject({
number: 12,
fulfillmentStatus: "shipped",
totals: { total: 120500, shipping: 1500, discount: 0 },
items: [{ title: "Addolcitore - Standard", sku: "ADD-20", unitPrice: 119000 }],
shippingAddress: ["Mario Rossi", "Via Roma 1", "20100 Milano (MI)"],
shippingMethods: ["Spedizione standard"],
tracking: [{ number: "BRT123", url: "https://track.example.com/BRT123" }],
})
expect(res.body).not.toContain("order_1")
expect(res.body).not.toContain(customerId)
expect((await t.app.inject({ method: "GET", url: "/me/orders/12", headers: luigi.headers })).statusCode).toBe(404)
expect((await t.app.inject({ method: "GET", url: "/me/orders/99", headers: mario.headers })).statusCode).toBe(404)
expect((await t.app.inject({ method: "GET", url: "/me/orders/abc", headers: mario.headers })).statusCode).toBe(400)
})
it("Medusa giù → 503", async () => {
const mario = await createUserAndLogin(t.app, "mario@example.com")
t.medusa.down = true
expect((await t.app.inject({ method: "GET", url: "/me/orders/1", headers: mario.headers })).statusCode).toBe(503)
})
})
+148
View File
@@ -0,0 +1,148 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import { eq } from "drizzle-orm"
import { contactRequests, installerProfiles, quoteRequests, quotes, showroomBookings, tickets, users } from "../src/db/schema/index.js"
import { buildTestApp, createUserAndLogin, TEST_PASSWORD, type TestApp, truncateAll } from "./helpers.js"
let t: TestApp
let admin: { user: { id: string }; headers: Record<string, string> }
beforeAll(async () => {
t = await buildTestApp()
})
afterAll(() => t.app.close())
beforeEach(async () => {
await truncateAll()
t.medusa.reset()
t.email.clear()
admin = await createUserAndLogin(t.app, "admin@iti.local", "admin")
})
const call = (method: "GET" | "POST", url: string, headers = admin.headers) =>
t.app.inject({ method, url, headers, ...(method === "POST" ? { payload: {} } : {}) })
const login = (email: string) => t.app.inject({ method: "POST", url: "/auth/login", payload: { email, password: TEST_PASSWORD } })
describe("accesso all'area admin", () => {
it("admin disabilitato o declassato: rifiutato subito, anche con un access token ancora valido", async () => {
expect((await call("GET", "/admin/summary")).statusCode).toBe(200)
await t.app.db.update(users).set({ role: "customer" }).where(eq(users.id, admin.user.id))
expect((await call("GET", "/admin/summary")).statusCode).toBe(403)
await t.app.db.update(users).set({ role: "admin", disabledAt: new Date() }).where(eq(users.id, admin.user.id))
expect((await call("GET", "/admin/summary")).statusCode).toBe(401)
})
})
describe("admin utenti", () => {
it("ricerca per testo, ruolo e stato; solo admin", async () => {
const mario = await createUserAndLogin(t.app, "mario@example.com", "customer", { firstName: "Mario", lastName: "Rossi" })
await createUserAndLogin(t.app, "acme@example.com", "company", { companyName: "Acme Srl", vatNumber: "01234567890" })
const inst = await createUserAndLogin(t.app, "inst@example.com", "installer")
await t.app.db.insert(installerProfiles).values({ userId: inst.user.id, businessName: "Impianti Srl" })
const all = await call("GET", "/admin/users")
expect(all.statusCode).toBe(200)
expect(all.json().total).toBe(4)
const item = all.json().items.find((u: { email: string }) => u.email === "inst@example.com")
expect(item).toMatchObject({ role: "installer", installerStatus: "pending", disabledAt: null })
expect(item).not.toHaveProperty("passwordHash")
expect(item).not.toHaveProperty("medusaCustomerId")
expect((await call("GET", "/admin/users?q=mario%20rossi")).json().items.map((u: { email: string }) => u.email)).toEqual(["mario@example.com"])
expect((await call("GET", "/admin/users?q=acme")).json().total).toBe(1)
expect((await call("GET", "/admin/users?role=company")).json().items[0].companyName).toBe("Acme Srl")
expect((await call("GET", "/admin/users?status=disabled")).json().total).toBe(0)
// % e _ sono letterali
expect((await call("GET", "/admin/users?q=%25")).json().total).toBe(0)
expect((await call("GET", `/admin/users/${mario.user.id}`)).json()).toMatchObject({ firstName: "Mario", installerStatus: null })
expect((await call("GET", "/admin/users/00000000-0000-4000-8000-000000000000")).statusCode).toBe(404)
expect((await call("GET", "/admin/users", mario.headers)).statusCode).toBe(403)
expect((await t.app.inject({ method: "GET", url: "/admin/users" })).statusCode).toBe(401)
})
it("disabilita: sessioni chiuse e login rifiutato; riabilita: login di nuovo possibile", async () => {
const mario = await createUserAndLogin(t.app, "mario@example.com")
const res = await call("POST", `/admin/users/${mario.user.id}/disable`)
expect(res.statusCode).toBe(200)
expect(res.json()).toMatchObject({ medusaSynced: null })
expect(res.json().disabledAt).not.toBeNull()
expect((await login("mario@example.com")).statusCode).toBe(401)
expect((await call("GET", "/me/tickets", mario.headers)).statusCode).toBe(401)
expect((await call("GET", "/admin/users?status=disabled")).json().total).toBe(1)
const enabled = await call("POST", `/admin/users/${mario.user.id}/enable`)
expect(enabled.json().disabledAt).toBeNull()
expect((await login("mario@example.com")).statusCode).toBe(200)
})
it("installatore disabilitato esce dal gruppo del listino riservato, riabilitato rientra", async () => {
const inst = await createUserAndLogin(t.app, "inst@example.com", "installer")
await t.app.db.insert(installerProfiles).values({ userId: inst.user.id, businessName: "Impianti Srl", status: "approved", approvedAt: new Date() })
expect((await call("POST", `/admin/installers/${inst.user.id}/sync`)).json()).toEqual({ member: true })
const customerId = t.medusa.accountByEmail("inst@example.com")!.id
const off = await call("POST", `/admin/users/${inst.user.id}/disable`)
expect(off.json()).toMatchObject({ medusaSynced: true, installerStatus: "approved" })
expect(t.medusa.installerGroup.has(customerId)).toBe(false)
const on = await call("POST", `/admin/users/${inst.user.id}/enable`)
expect(on.json()).toMatchObject({ medusaSynced: true })
expect(t.medusa.installerGroup.has(customerId)).toBe(true)
// Medusa giù: l'operazione riesce comunque
t.medusa.down = true
expect((await call("POST", `/admin/users/${inst.user.id}/disable`)).json()).toMatchObject({ medusaSynced: false })
})
it("un admin non può disabilitare se stesso", async () => {
const res = await call("POST", `/admin/users/${admin.user.id}/disable`)
expect(res.statusCode).toBe(409)
expect(res.json().error.code).toBe("SELF_DISABLE")
})
})
describe("admin dashboard", () => {
it("contatori", async () => {
const empty = await call("GET", "/admin/summary")
expect(empty.statusCode).toBe(200)
expect(Object.values(empty.json()).every((v) => v === 0)).toBe(true)
const mario = await createUserAndLogin(t.app, "mario@example.com")
const inst = await createUserAndLogin(t.app, "inst@example.com", "installer")
await t.app.db.insert(installerProfiles).values({ userId: inst.user.id, businessName: "Impianti Srl" })
const base = { email: "x@example.com", name: "X", configuratorInput: {} }
await t.app.db.insert(quoteRequests).values([base, base, { ...base, status: "closed" }])
await t.app.db.insert(tickets).values([
{ userId: mario.user.id, subject: "a", status: "open" },
{ userId: mario.user.id, subject: "b", status: "waiting_customer" },
{ userId: mario.user.id, subject: "c", status: "closed" },
])
const contact = { name: "X", email: "x@example.com", message: "Messaggio di prova" }
await t.app.db.insert(contactRequests).values([contact, { ...contact, status: "closed" }])
const day = 24 * 3600 * 1000
const booking = { name: "X", email: "x@example.com", people: 2 }
await t.app.db.insert(showroomBookings).values([
{ ...booking, requestedAt: new Date(Date.now() + 2 * day), status: "confirmed" },
{ ...booking, requestedAt: new Date(Date.now() + 3 * day), status: "cancelled" },
{ ...booking, requestedAt: new Date(Date.now() - 2 * day), status: "confirmed" },
])
await t.app.db.insert(quotes).values([
{ userId: mario.user.id, year: 2026, seq: 1, number: "2026-0001", title: "A", amountCents: 1, status: "sent" },
{ userId: mario.user.id, year: 2026, seq: 2, number: "2026-0002", title: "B", amountCents: 1, status: "sent", validUntil: "2020-01-01" },
])
expect((await call("GET", "/admin/summary")).json()).toEqual({
newQuoteRequests: 2,
openTickets: 1,
activeTickets: 2,
pendingInstallers: 1,
upcomingShowroomBookings: 1,
newContactRequests: 1,
sentQuotes: 1,
})
expect((await call("GET", "/admin/summary", mario.headers)).statusCode).toBe(403)
})
})
+181
View File
@@ -0,0 +1,181 @@
import { tmpdir } from "node:os"
import { join } from "node:path"
import { afterAll, beforeAll, describe, expect, it } from "vitest"
import { z } from "zod"
import { buildApp, type App } from "../src/app.js"
import { ConfigError, loadConfig } from "../src/config/env.js"
import { AppError } from "../src/errors.js"
const baseEnv = {
NODE_ENV: "test",
DATABASE_URL: "postgres://user:pass@localhost:5432/test",
CORS_ORIGINS: "http://localhost:4200,http://localhost:4300",
COOKIE_SECRET: "x".repeat(32),
MEDUSA_PUBLISHABLE_KEY: "pk_test",
INTERNAL_API_SECRET: "i".repeat(32),
WEBHOOK_SECRET: "w".repeat(32),
JWT_SECRET: "j".repeat(32),
PRIVATE_APP_URL: "http://localhost:4300",
}
describe("config", () => {
it("elenca tutte le variabili mancanti", () => {
try {
loadConfig({ NODE_ENV: "test" })
expect.unreachable()
} catch (err) {
expect(err).toBeInstanceOf(ConfigError)
const vars = (err as ConfigError).problems.map((p) => p.split(":")[0])
expect(vars).toEqual(
expect.arrayContaining([
"CORS_ORIGINS",
"COOKIE_SECRET",
"MEDUSA_PUBLISHABLE_KEY",
"INTERNAL_API_SECRET",
"WEBHOOK_SECRET",
]),
)
}
})
it("richiede LOG_DIR in produzione", () => {
expect(() => loadConfig({ ...baseEnv, NODE_ENV: "production" })).toThrow(/LOG_DIR/)
})
it("in produzione: cookie Secure, origini https e niente outbox delle email", () => {
const prod = {
...baseEnv,
NODE_ENV: "production",
LOG_DIR: "/var/log/iti",
UPLOAD_DIR: "/data/uploads",
PUBLIC_UPLOADS_URL: "https://media.example.com",
CORS_ORIGINS: "https://example.com,https://area.example.com",
}
expect(loadConfig(prod).AUTH_COOKIE_SECURE).toBe(true)
expect(() => loadConfig({ ...prod, AUTH_COOKIE_SECURE: "false" })).toThrow(/AUTH_COOKIE_SECURE/)
expect(() => loadConfig({ ...prod, CORS_ORIGINS: "https://example.com,http://area.example.com" })).toThrow(/CORS_ORIGINS/)
expect(() => loadConfig({ ...prod, EMAIL_OUTBOX_DIR: ".outbox" })).toThrow(/EMAIL_OUTBOX_DIR/)
})
it("TRUST_PROXY: booleano o elenco di indirizzi fidati", () => {
expect(loadConfig(baseEnv).TRUST_PROXY).toBe(false)
expect(loadConfig({ ...baseEnv, TRUST_PROXY: "true" }).TRUST_PROXY).toBe(true)
expect(loadConfig({ ...baseEnv, TRUST_PROXY: "false" }).TRUST_PROXY).toBe(false)
expect(loadConfig({ ...baseEnv, TRUST_PROXY: "loopback,uniquelocal" }).TRUST_PROXY).toBe("loopback,uniquelocal")
})
it("separa le origini CORS", () => {
expect(loadConfig(baseEnv).CORS_ORIGINS).toEqual(["http://localhost:4200", "http://localhost:4300"])
})
})
describe("app", () => {
let app: App
beforeAll(async () => {
app = await buildApp(loadConfig(baseEnv))
// Route di prova per il formato degli errori
app.post(
"/__test/validate",
{ schema: { body: z.object({ email: z.email() }) } },
async () => ({ ok: true }),
)
app.get("/__test/app-error", async () => {
throw new AppError(409, "EMAIL_TAKEN", "Email già registrata")
})
app.get("/__test/crash", async () => {
throw new Error("dettaglio interno")
})
await app.ready()
})
afterAll(() => app.close())
it("GET /health -> 200", async () => {
const res = await app.inject({ method: "GET", url: "/health" })
expect(res.statusCode).toBe(200)
expect(res.json()).toMatchObject({ status: "ok" })
})
it("404 in formato standard", async () => {
const res = await app.inject({ method: "GET", url: "/non-esiste" })
expect(res.statusCode).toBe(404)
expect(res.json()).toEqual({
error: { code: "NOT_FOUND", message: "Route GET /non-esiste non trovata" },
})
})
it("validazione -> 400 VALIDATION_ERROR con details", async () => {
const res = await app.inject({ method: "POST", url: "/__test/validate", payload: { email: "x" } })
expect(res.statusCode).toBe(400)
const body = res.json()
expect(body.error.code).toBe("VALIDATION_ERROR")
expect(body.error.details[0].path).toBe("body.email")
})
it("AppError -> status e codice applicativi", async () => {
const res = await app.inject({ method: "GET", url: "/__test/app-error" })
expect(res.statusCode).toBe(409)
expect(res.json()).toEqual({ error: { code: "EMAIL_TAKEN", message: "Email già registrata" } })
})
it("errore inatteso -> 500 senza stack", async () => {
const res = await app.inject({ method: "GET", url: "/__test/crash" })
expect(res.statusCode).toBe(500)
expect(res.json()).toEqual({ error: { code: "INTERNAL_ERROR", message: "dettaglio interno" } })
expect(res.body).not.toContain("at ")
})
it("CORS: origine consentita con credenziali, altre no", async () => {
const ok = await app.inject({
method: "OPTIONS",
url: "/health",
headers: { origin: "http://localhost:4200", "access-control-request-method": "GET" },
})
expect(ok.headers["access-control-allow-origin"]).toBe("http://localhost:4200")
expect(ok.headers["access-control-allow-credentials"]).toBe("true")
const ko = await app.inject({ method: "GET", url: "/health", headers: { origin: "https://evil.example" } })
expect(ko.headers["access-control-allow-origin"]).toBeUndefined()
})
it("header di sicurezza helmet", async () => {
const res = await app.inject({ method: "GET", url: "/health" })
expect(res.headers["x-content-type-options"]).toBe("nosniff")
expect(res.headers["cross-origin-resource-policy"]).toBe("same-site")
})
})
describe("produzione", () => {
it("errore 5xx con messaggio generico", async () => {
const app = await buildApp({ ...loadConfig({ ...baseEnv, LOG_DIR: join(tmpdir(), "iti-api-test-logs") }), NODE_ENV: "production", LOG_LEVEL: "silent" })
app.get("/__test/crash", async () => {
throw new Error("dettaglio interno")
})
const res = await app.inject({ method: "GET", url: "/__test/crash" })
expect(res.json()).toEqual({ error: { code: "INTERNAL_ERROR", message: "Errore interno" } })
await app.close()
})
it("TRUST_PROXY con indirizzi fidati: IP del client da X-Forwarded-For solo se arriva da un proxy fidato", async () => {
const app = await buildApp({ ...loadConfig(baseEnv), TRUST_PROXY: "loopback,uniquelocal" })
app.get("/ip", async (request) => ({ ip: request.ip }))
await app.ready()
const ipFrom = async (remoteAddress: string) =>
(await app.inject({ method: "GET", url: "/ip", remoteAddress, headers: { "x-forwarded-for": "203.0.113.7" } })).json().ip
// Caddy o server SSR sulla rete Docker → IP del visitatore
expect(await ipFrom("172.18.0.5")).toBe("203.0.113.7")
// Client diretto (non fidato): l'intestazione viene ignorata
expect(await ipFrom("198.51.100.20")).toBe("198.51.100.20")
await app.close()
})
it("rate limit -> 429 RATE_LIMITED", async () => {
const app = await buildApp(loadConfig({ ...baseEnv, RATE_LIMIT_MAX: "2" }))
app.get("/__test/limited", async () => ({ ok: true }))
for (let i = 0; i < 2; i++) await app.inject({ method: "GET", url: "/__test/limited" })
const res = await app.inject({ method: "GET", url: "/__test/limited" })
expect(res.statusCode).toBe(429)
expect(res.json().error.code).toBe("RATE_LIMITED")
await app.close()
})
})
+357
View File
@@ -0,0 +1,357 @@
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<string, unknown>) =>
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<typeof users.$inferInsert> = {}) {
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()
}
})
})
+227
View File
@@ -0,0 +1,227 @@
import { eq } from "drizzle-orm"
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import { blogPosts, users } from "../src/db/schema/index.js"
import { renderMarkdown, slugify } from "../src/services/blog.js"
import { hashPassword } from "../src/services/password.js"
import { buildTestApp, type TestApp, truncateAll } from "./helpers.js"
const PASSWORD = "Password-di-test-1"
let t: TestApp
let admin: string
let customer: string
async function login(email: string, role: "admin" | "customer") {
await t.app.db.insert(users).values({ email, passwordHash: await hashPassword(PASSWORD), role, firstName: "A", lastName: "B" })
const res = await t.app.inject({ method: "POST", url: "/auth/login", payload: { email, password: PASSWORD } })
return res.json().accessToken as string
}
const asAdmin = (method: "GET" | "POST" | "PATCH" | "DELETE", url: string, payload?: object) =>
t.app.inject({ method, url, headers: { authorization: `Bearer ${admin}` }, ...(payload ? { payload } : {}) })
const createPost = async (body: Record<string, unknown>) => {
const res = await asAdmin("POST", "/admin/blog/posts", body)
expect(res.statusCode, res.body).toBe(201)
return res.json()
}
const publicList = async (query = "") => (await t.app.inject({ method: "GET", url: `/blog/posts${query}` })).json()
beforeAll(async () => {
t = await buildTestApp()
})
afterAll(() => t.app.close())
beforeEach(async () => {
await truncateAll()
admin = await login("admin@iti.local", "admin")
customer = await login("cliente@example.com", "customer")
})
describe("ricerca full-text", () => {
it('"pompa calore" trova "Pompe di calore: guida" (stemming italiano + unaccent), ordinato per rilevanza', async () => {
await createPost({ title: "Pompe di calore: guida", contentMd: "Come scegliere la pompa di calore.", status: "published" })
await createPost({ title: "Fotovoltaico e accumulo", contentMd: "Con una pompa di calore il fotovoltaico rende di più.", status: "published" })
await createPost({ title: "Addolcitori per l'acqua", contentMd: "Calcare e durezza.", status: "published" })
const res = await publicList("?q=pompa%20calore")
expect(res.items.map((p: { title: string }) => p.title)).toEqual(["Pompe di calore: guida", "Fotovoltaico e accumulo"])
expect(res.total).toBe(2)
})
it("ricerca insensibile agli accenti e sintassi websearch (frasi, esclusioni)", async () => {
await createPost({ title: "Perché l'efficienza è importante", contentMd: "Risparmio energetico.", status: "published" })
await createPost({ title: "Efficienza della caldaia", contentMd: "Manutenzione.", status: "published" })
expect((await publicList("?q=perche")).total).toBe(1)
expect((await publicList("?q=efficienza%20-caldaia")).items.map((p: { title: string }) => p.title)).toEqual([
"Perché l'efficienza è importante",
])
expect((await publicList("?q=%22")).total).toBe(0) // query vuota per tsquery: nessun errore
})
})
describe("visibilità pubblica", () => {
it("bozze e post programmati nel futuro non sono visibili via API pubblica", async () => {
const draft = await createPost({ title: "Bozza segreta", contentMd: "x" })
const future = await createPost({
title: "Programmato",
contentMd: "x",
status: "published",
publishedAt: new Date(Date.now() + 86_400_000).toISOString(),
})
const live = await createPost({ title: "Pubblicato", contentMd: "x", status: "published" })
const list = await publicList()
expect(list.items.map((p: { slug: string }) => p.slug)).toEqual([live.slug])
expect((await publicList("?q=bozza")).total).toBe(0)
for (const slug of [draft.slug, future.slug]) {
expect((await t.app.inject({ method: "GET", url: `/blog/posts/${slug}` })).statusCode).toBe(404)
}
expect((await t.app.inject({ method: "GET", url: `/blog/posts/${live.slug}` })).statusCode).toBe(200)
const sitemap = (await t.app.inject({ method: "GET", url: "/sitemap-data" })).json()
expect(sitemap.posts.map((p: { slug: string }) => p.slug)).toEqual([live.slug])
})
it("filtro per categoria e categorie pubbliche", async () => {
const cat = (await asAdmin("POST", "/admin/blog/categories", { name: "Energia rinnovabile" })).json()
expect(cat.slug).toBe("energia-rinnovabile")
await createPost({ title: "Con categoria", contentMd: "x", status: "published", categoryId: cat.id })
await createPost({ title: "Senza categoria", contentMd: "x", status: "published" })
const res = await publicList("?category=energia-rinnovabile")
expect(res.items).toHaveLength(1)
expect(res.items[0].category).toMatchObject({ slug: "energia-rinnovabile", name: "Energia rinnovabile" })
expect((await t.app.inject({ method: "GET", url: "/blog/categories" })).json()).toHaveLength(1)
})
it("dettaglio: markdown + HTML, copertina con URL pubblico", async () => {
const p = await createPost({
title: "Guida",
contentMd: "## Titolo\n\nTesto **forte**.",
coverPath: "images/2026/10/abc.png",
status: "published",
})
const detail = (await t.app.inject({ method: "GET", url: `/blog/posts/${p.slug}` })).json()
expect(detail.contentMd).toBe("## Titolo\n\nTesto **forte**.")
expect(detail.contentHtml).toContain("<h2>Titolo</h2>")
expect(detail.contentHtml).toContain("<strong>forte</strong>")
expect(detail.coverUrl).toBe("http://localhost:3000/uploads/images/2026/10/abc.png")
})
})
describe("sanitizzazione", () => {
it("script, handler on*, javascript:, iframe, style rimossi", () => {
const html = renderMarkdown(
[
"Testo <script>alert(1)</script>",
'<img src="x" onerror="alert(1)">',
'<img src="https://cdn.example/a.png" onerror="alert(2)" alt="ok">',
"[link](javascript:alert(3))",
'<a href="https://example.com" onclick="x()">esterno</a>',
'<iframe src="https://evil.example"></iframe>',
'<p style="color:red">stile</p>',
"![data](data:image/png;base64,AAAA)",
].join("\n\n"),
)
expect(html).not.toMatch(/<script|onerror|onclick|javascript:|<iframe|style=|data:image/i)
expect(html).toContain('<img src="https://cdn.example/a.png" alt="ok" loading="lazy" />')
expect(html).toContain('<a href="https://example.com" rel="noopener noreferrer" target="_blank">esterno</a>')
})
it("il dettaglio pubblico e la preview restituiscono HTML ripulito", async () => {
const md = 'Ciao <script>alert("x")</script> <img src=x onerror=alert(1)>'
const p = await createPost({ title: "XSS", contentMd: md, status: "published" })
const detail = (await t.app.inject({ method: "GET", url: `/blog/posts/${p.slug}` })).json()
expect(detail.contentHtml).not.toMatch(/<script|onerror/)
expect(detail.contentMd).toBe(md) // il sorgente resta invariato
const preview = await asAdmin("POST", "/admin/blog/preview", { markdown: md })
expect(preview.json().html).not.toMatch(/<script|onerror/)
expect(preview.json().html).toContain("Ciao")
})
it("markdown standard mantenuto: tabelle, codice, liste", () => {
const html = renderMarkdown("| a | b |\n|---|---|\n| 1 | 2 |\n\n```ts\nconst x = 1\n```\n\n- uno\n- due")
expect(html).toContain("<table>")
expect(html).toContain('<code class="language-ts">')
expect(html).toContain("<li>uno</li>")
})
})
describe("admin", () => {
it("permessi: customer 403, anonimo 401 su tutte le route admin del blog", async () => {
const routes: ["GET" | "POST", string][] = [
["GET", "/admin/blog/posts"],
["POST", "/admin/blog/posts"],
["POST", "/admin/blog/preview"],
["POST", "/admin/blog/categories"],
]
for (const [method, url] of routes) {
const asCustomer = await t.app.inject({ method, url, headers: { authorization: `Bearer ${customer}` }, payload: {} })
expect(asCustomer.statusCode, `${method} ${url}`).toBe(403)
expect((await t.app.inject({ method, url, payload: {} })).statusCode, `${method} ${url}`).toBe(401)
}
})
it("slug dal titolo, univoco, stabile al cambio titolo; slug esplicito duplicato → 409", async () => {
expect(slugify("Pompe di calore: guida 2026!")).toBe("pompe-di-calore-guida-2026")
expect(slugify("Perché l'acqua è dura?")).toBe("perche-lacqua-e-dura")
const a = await createPost({ title: "Pompe di calore: guida" })
const b = await createPost({ title: "Pompe di calore: guida" })
const c = await createPost({ title: "Pompe di calore guida" })
expect([a.slug, b.slug, c.slug]).toEqual(["pompe-di-calore-guida", "pompe-di-calore-guida-2", "pompe-di-calore-guida-3"])
const renamed = (await asAdmin("PATCH", `/admin/blog/posts/${a.id}`, { title: "Titolo nuovo" })).json()
expect(renamed.slug).toBe("pompe-di-calore-guida")
const dup = await asAdmin("PATCH", `/admin/blog/posts/${b.id}`, { slug: "pompe-di-calore-guida" })
expect(dup.statusCode).toBe(409)
expect(dup.json().error.code).toBe("SLUG_TAKEN")
expect((await asAdmin("POST", "/admin/blog/posts", { title: "X", slug: "Non Valido!" })).statusCode).toBe(400)
})
it("stato: pubblicazione imposta published_at, PATCH parziale non tocca stato e contenuto", async () => {
const p = await createPost({ title: "Stato", contentMd: "Contenuto originale" })
expect(p).toMatchObject({ status: "draft", publishedAt: null })
const pub = (await asAdmin("PATCH", `/admin/blog/posts/${p.id}`, { status: "published" })).json()
expect(pub.status).toBe("published")
expect(Date.now() - Date.parse(pub.publishedAt)).toBeLessThan(10_000)
// Regressione: un PATCH del solo titolo non deve rimettere in bozza né svuotare il contenuto
const titled = (await asAdmin("PATCH", `/admin/blog/posts/${p.id}`, { title: "Stato aggiornato" })).json()
expect(titled).toMatchObject({ status: "published", contentMd: "Contenuto originale", publishedAt: pub.publishedAt })
const back = (await asAdmin("PATCH", `/admin/blog/posts/${p.id}`, { status: "draft" })).json()
expect(back.status).toBe("draft")
expect((await publicList()).total).toBe(0)
})
it("lista admin con bozze e filtro stato; DELETE; autore salvato", async () => {
await createPost({ title: "Bozza" })
const live = await createPost({ title: "Live", status: "published" })
expect((await asAdmin("GET", "/admin/blog/posts")).json().total).toBe(2)
expect((await asAdmin("GET", "/admin/blog/posts?status=draft")).json().items.map((p: { title: string }) => p.title)).toEqual(["Bozza"])
const [row] = await t.app.db.select().from(blogPosts).where(eq(blogPosts.id, live.id))
expect(row!.authorId).not.toBeNull()
expect((await asAdmin("DELETE", `/admin/blog/posts/${live.id}`)).statusCode).toBe(204)
expect((await asAdmin("GET", `/admin/blog/posts/${live.id}`)).statusCode).toBe(404)
})
it("categoria inesistente → 400; eliminare una categoria lascia i post senza categoria", async () => {
const bad = await asAdmin("POST", "/admin/blog/posts", { title: "X", categoryId: "00000000-0000-4000-8000-000000000000" })
expect(bad.json().error.code).toBe("CATEGORY_NOT_FOUND")
const cat = (await asAdmin("POST", "/admin/blog/categories", { name: "Acqua" })).json()
const p = await createPost({ title: "Con cat", categoryId: cat.id })
expect((await asAdmin("DELETE", `/admin/blog/categories/${cat.id}`)).statusCode).toBe(204)
expect((await asAdmin("GET", `/admin/blog/posts/${p.id}`)).json().category).toBeNull()
})
it("copertina: solo percorsi dell'area immagini", async () => {
expect((await asAdmin("POST", "/admin/blog/posts", { title: "X", coverPath: "../private/documents/x.pdf" })).statusCode).toBe(400)
expect((await asAdmin("POST", "/admin/blog/posts", { title: "X", coverPath: "documents/2026/10/x.pdf" })).statusCode).toBe(400)
})
})
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest"
import { parseEwkbPoint } from "../src/db/schema/columns.js"
describe("parseEwkbPoint", () => {
it("EWKB little-endian con SRID (formato restituito da Postgres)", () => {
expect(parseEwkbPoint("0101000020E6100000E17A14AE47612240CF66D5E76ABB4640")).toEqual({ lng: 9.19, lat: 45.4642 })
})
it("EWKB big-endian con SRID", () => {
expect(parseEwkbPoint("0020000001000010e6c05280624dd2f1aa40445b3d07c84b5e")).toEqual({ lng: -74.006, lat: 40.7128 })
})
it("rifiuta tipi diversi da Point", () => {
// LineString (tipo 2), little-endian, senza SRID
expect(() => parseEwkbPoint("010200000000000000")).toThrow(/Point/)
})
})
+240
View File
@@ -0,0 +1,240 @@
import { eq } from "drizzle-orm"
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import { definition } from "../src/configurator/definition.js"
import { evaluate, validateAnswers } from "../src/configurator/engine.js"
import { PROVINCES, RULES_VERSION } from "../src/configurator/rules.js"
import { consents, quoteRequests, users } from "../src/db/schema/index.js"
import { hashPassword } from "../src/services/password.js"
import { buildTestApp, type TestApp, truncateAll } from "./helpers.js"
type Row = {
answers: Record<string, unknown>
code: string
size: number | null
investment: number | null
savings: number | null
payback: number | null
products: string[]
warnings: number
}
/**
* Casi tabellari (valori verificati a mano dalle formule di engine.ts con le regole di rules.ts).
* Cambiando una soglia in rules.ts questi valori vanno riallineati.
*/
const CASES: Record<string, Row[]> = {
acqua: [
// 4 persone, 35 °f: carico 4×0,2×20 = 16 °f·m³/g, resina 16×4/5 = 12,8 L → 20 L; 1.190+300 → 1.500 €; risparmio 4×45×0,8 = 144 €
{ answers: { percorso: "acqua", acqua_persone: 4, acqua_durezza_nota: "si", acqua_durezza: 35, acqua_abitazione: "casa" }, code: "addolcitore", size: 20, investment: 1500_00, savings: 144_00, payback: 10.4, products: ["addolcitore-domestico-20l"], warnings: 0 },
// 2 persone, durezza ignota (30 °f): resina 4,8 L → 10 L; risparmio 2×45×0,6 = 54 €; avvisi durezza + appartamento
{ answers: { percorso: "acqua", acqua_persone: 2, acqua_durezza_nota: "no", acqua_abitazione: "appartamento" }, code: "addolcitore", size: 10, investment: 1500_00, savings: 54_00, payback: 27.8, products: ["addolcitore-domestico-20l"], warnings: 2 },
// 12 °f ≤ 15 °f: addolcitore non necessario
{ answers: { percorso: "acqua", acqua_persone: 3, acqua_durezza_nota: "si", acqua_durezza: 12, acqua_abitazione: "casa" }, code: "filtrazione", size: null, investment: null, savings: null, payback: null, products: [], warnings: 0 },
// 8 persone, 60 °f: resina 57,6 L > 40 L → sopralluogo
{ answers: { percorso: "acqua", acqua_persone: 8, acqua_durezza_nota: "si", acqua_durezza: 60, acqua_abitazione: "casa" }, code: "addolcitore_sopralluogo", size: 57.6, investment: null, savings: null, payback: null, products: [], warnings: 0 },
],
fotovoltaico: [
// MI (1150), 4.000 kWh, sud: 3,48 → 3,5 kWp; prod. 4.025; autoc. 40% → 1.610×0,30 + 2.415×0,10 = 724,5 €; 3,5×1.700 → 6.000 €
{ answers: { percorso: "fotovoltaico", provincia: "MI", fv_input: "kwh", fv_consumo_kwh: 4000, fv_orientamento: "sud", fv_accumulo: "no", fv_uso_diurno: "medio" }, code: "fotovoltaico", size: 3.5, investment: 6000_00, savings: 725_00, payback: 8.3, products: [], warnings: 0 },
// NA (1450), bolletta 150 €/mese → 6.000 kWh: 4,5 kWp, batteria 10 kWh, autoc. 85%; 4,5×1.700 + 10×600 = 13.650 → 13.700 €
{ answers: { percorso: "fotovoltaico", provincia: "NA", fv_input: "bolletta", fv_bolletta_mensile: 150, fv_orientamento: "sud", fv_accumulo: "si", fv_uso_diurno: "alto" }, code: "fotovoltaico_accumulo", size: 4.5, investment: 13700_00, savings: 1762_00, payback: 7.8, products: ["kit-fotovoltaico-6kw-accumulo"], warnings: 0 },
// RM (1300×0,85), 9.000 kWh: 8,5 kWp limitati dal tetto (30 m² / 5,5 → 5 kWp); batteria 15 kWh; autoc. 60%
{ answers: { percorso: "fotovoltaico", provincia: "RM", fv_input: "kwh", fv_consumo_kwh: 9000, fv_orientamento: "est_ovest", fv_superficie_tetto: 30, fv_accumulo: "si", fv_uso_diurno: "basso" }, code: "fotovoltaico_accumulo", size: 5, investment: 17500_00, savings: 1216_00, payback: 14.4, products: ["kit-fotovoltaico-6kw-accumulo"], warnings: 1 },
// AO (1150×0,75 = 862,5), 500 kWh: 0,58 kWp → minimo 2 kWp; prod. 1.725, autoc. limitato al consumo (500):
// 500×0,30 + 1.225×0,10 = 272,5 €; 2×2.000 = 4.000 €; avviso orientamento
{ answers: { percorso: "fotovoltaico", provincia: "AO", fv_input: "kwh", fv_consumo_kwh: 500, fv_orientamento: "altro", fv_accumulo: "no", fv_uso_diurno: "basso" }, code: "fotovoltaico", size: 2, investment: 4000_00, savings: 273_00, payback: 14.7, products: [], warnings: 1 },
],
pompa_calore: [
// TO (×1,15), 100 m² ristrutturata + ACS: 6,9 + 1,5 = 8,4 kW → 10 kW; 12.350 kWh; metano 1.572 € vs PdC 1.235 €
{ answers: { percorso: "pompa_calore", provincia: "TO", pdc_superficie: 100, pdc_isolamento: "ristrutturato", pdc_combustibile: "metano", pdc_terminali: "radiatori", pdc_acs: "si" }, code: "pompa_calore", size: 10, investment: 9500_00, savings: 337_00, payback: 28.2, products: [], warnings: 0 },
// FI (×1), 120 m² nuova, pavimento: 4,8 kW → 6 kW; 6.000 kWh; gasolio 1.164,7 € vs 450 €
{ answers: { percorso: "pompa_calore", provincia: "FI", pdc_superficie: 120, pdc_isolamento: "nuovo", pdc_combustibile: "gasolio", pdc_terminali: "pavimento", pdc_acs: "no" }, code: "pompa_calore", size: 6, investment: 7500_00, savings: 715_00, payback: 10.5, products: [], warnings: 0 },
// PA (×0,8), 90 m² ristrutturata + ACS: 4,32 + 1,5 = 5,82 kW → 6 kW; 8.480 kWh; elettrico 2.544 € vs fancoil 726,9 €
{ answers: { percorso: "pompa_calore", provincia: "PA", pdc_superficie: 90, pdc_isolamento: "ristrutturato", pdc_combustibile: "elettrico", pdc_terminali: "fancoil", pdc_acs: "si" }, code: "pompa_calore", size: 6, investment: 7500_00, savings: 1817_00, payback: 4.1, products: [], warnings: 0 },
// BZ, 250 m² non isolata, termosifoni: 27,4 kW → sopralluogo + avviso isolamento
{ answers: { percorso: "pompa_calore", provincia: "BZ", pdc_superficie: 250, pdc_isolamento: "vecchio", pdc_combustibile: "gpl", pdc_terminali: "radiatori", pdc_acs: "si" }, code: "pompa_calore_sopralluogo", size: 27.4, investment: null, savings: 1755_00, payback: null, products: [], warnings: 1 },
],
azienda: [
// BA (1450), 200.000 kWh: 96,6 → 97 kWp; prod. 140.650, autoc. 70%; 97×1.150 → 111.600 €
{ answers: { percorso: "azienda", provincia: "BA", az_settore: "industria", az_interessi: ["fotovoltaico", "mobilita"], az_consumo_kwh: 200000 }, code: "consulenza_aziendale", size: 97, investment: 111600_00, savings: 33756_00, payback: 3.3, products: [], warnings: 0 },
// Senza fotovoltaico: solo consulenza, nessuna stima
{ answers: { percorso: "azienda", provincia: "VR", az_settore: "agricoltura", az_interessi: ["acqua"] }, code: "consulenza_aziendale", size: null, investment: null, savings: null, payback: null, products: [], warnings: 0 },
// PA, 5.000 kWh: 2,3 kWp → minimo aziendale 6 kWp; autoconsumo limitato al consumo
{ answers: { percorso: "azienda", provincia: "PA", az_settore: "ricettivo", az_interessi: ["fotovoltaico"], az_consumo_kwh: 5000 }, code: "consulenza_aziendale", size: 6, investment: 10200_00, savings: 1900_00, payback: 5.4, products: [], warnings: 0 },
],
}
describe("calcolo (funzioni pure, casi tabellari)", () => {
for (const [path, rows] of Object.entries(CASES)) {
describe(path, () => {
it.each(rows.map((r, i) => [i + 1, r] as const))("caso %i", (_i, row) => {
const v = validateAnswers(definition, row.answers)
expect(v.ok, JSON.stringify(v)).toBe(true)
if (!v.ok) return
const r = evaluate(v.answers)
expect(r.path).toBe(path)
expect(r.solution.code).toBe(row.code)
expect(r.size?.value ?? null).toBe(row.size)
expect(r.estimates).toEqual({ investmentCents: row.investment, annualSavingsCents: row.savings, paybackYears: row.payback })
expect(r.products.map((p) => p.handle)).toEqual(row.products)
expect(r.warnings).toHaveLength(row.warnings)
expect(r.rulesVersion).toBe(RULES_VERSION)
expect(r.disclaimer).toMatch(/indicativa/)
expect(r.incentivesNote.length).toBeGreaterThan(20)
})
})
}
})
describe("validazione risposte", () => {
const ok = (a: Record<string, unknown>) => validateAnswers(definition, a)
const errorsOf = (a: Record<string, unknown>) => {
const r = ok(a)
return r.ok ? [] : r.errors
}
it("percorso mancante o non valido", () => {
expect(errorsOf({})).toEqual([{ path: "answers.percorso", message: "Risposta obbligatoria" }])
expect(errorsOf({ percorso: "nucleare" })[0]?.message).toBe("Opzione non valida")
})
it("obbligatorie mancanti nel percorso scelto", () => {
const errs = errorsOf({ percorso: "fotovoltaico", provincia: "MI" })
expect(errs.map((e) => e.path).sort()).toEqual(["answers.fv_accumulo", "answers.fv_input", "answers.fv_orientamento", "answers.fv_uso_diurno"])
})
it("risposte incoerenti con il branching: domande di altri percorsi o condizioni non soddisfatte", () => {
const water = { percorso: "acqua", acqua_persone: 3, acqua_durezza_nota: "no", acqua_abitazione: "casa" }
expect(errorsOf({ ...water, acqua_durezza: 30 })).toEqual([{ path: "answers.acqua_durezza", message: "Risposta non prevista per le scelte fatte" }])
expect(errorsOf({ ...water, pdc_superficie: 100 })[0]?.path).toBe("answers.pdc_superficie")
expect(errorsOf({ ...water, provincia: "MI" })[0]?.path).toBe("answers.provincia")
expect(
errorsOf({ percorso: "azienda", provincia: "MI", az_settore: "altro", az_interessi: ["acqua"], az_consumo_kwh: 10000 })[0]?.path,
).toBe("answers.az_consumo_kwh")
expect(errorsOf({ ...water, inventata: 1 })).toEqual([{ path: "answers.inventata", message: "Domanda inesistente" }])
})
it("tipi, range, opzioni, province", () => {
const pv = { percorso: "fotovoltaico", fv_input: "kwh", fv_orientamento: "sud", fv_accumulo: "no", fv_uso_diurno: "medio" }
expect(errorsOf({ ...pv, provincia: "XX", fv_consumo_kwh: 4000 })[0]?.message).toBe("Provincia non valida")
expect(errorsOf({ ...pv, provincia: "MI", fv_consumo_kwh: 100 })[0]?.message).toMatch(/tra 500 e 30000/)
expect(errorsOf({ ...pv, provincia: "MI", fv_consumo_kwh: "4000" })[0]?.message).toBe("Numero atteso")
expect(errorsOf({ ...pv, provincia: "MI", fv_consumo_kwh: 4000.5 })[0]?.message).toMatch(/passi/)
expect(errorsOf({ percorso: "azienda", provincia: "MI", az_settore: "altro", az_interessi: [] })[0]?.message).toMatch(/almeno 1/)
expect(errorsOf({ percorso: "azienda", provincia: "MI", az_settore: "altro", az_interessi: ["acqua", "acqua"] })[0]?.message).toBe("Opzioni ripetute")
expect(errorsOf({ percorso: "azienda", provincia: "MI", az_settore: "altro", az_interessi: "acqua" })[0]?.message).toMatch(/Elenco/)
})
it("la definizione è coerente: id unici, condizioni su domande precedenti, province con zona", () => {
const ids = definition.steps.flatMap((s) => s.questions.map((q) => q.id))
expect(new Set(ids).size).toBe(ids.length)
const seen = new Set<string>()
for (const step of definition.steps) {
for (const c of step.visibleIf ?? []) expect(seen.has(c.question), `${step.id} → ${c.question}`).toBe(true)
for (const q of step.questions) {
for (const c of q.visibleIf ?? []) expect(seen.has(c.question), `${q.id} → ${c.question}`).toBe(true)
seen.add(q.id)
}
}
expect(PROVINCES).toHaveLength(107)
expect(new Set(PROVINCES.map((p) => p.code)).size).toBe(107)
})
})
describe("route", () => {
let t: TestApp
const water = { percorso: "acqua", acqua_persone: 4, acqua_durezza_nota: "si", acqua_durezza: 35, acqua_abitazione: "casa" }
const contact = { name: "Mario Rossi", email: " Mario@Example.com ", phone: "3331234567" }
beforeAll(async () => {
t = await buildTestApp()
})
afterAll(() => t.app.close())
beforeEach(async () => {
await truncateAll()
t.email.clear()
await t.app.db
.insert(users)
.values({ email: "admin@iti.local", passwordHash: await hashPassword("x".repeat(12)), role: "admin", firstName: "A", lastName: "D" })
})
it("GET /configurator/definition", async () => {
const res = await t.app.inject({ method: "GET", url: "/configurator/definition" })
expect(res.statusCode).toBe(200)
expect(res.json().steps[0].questions[0].id).toBe("percorso")
expect(res.json().provinces).toHaveLength(107)
})
it("POST /configurator/evaluate calcola senza salvare; risposte incoerenti → 400 con dettagli", async () => {
const res = await t.app.inject({ method: "POST", url: "/configurator/evaluate", payload: { answers: water } })
expect(res.statusCode).toBe(200)
expect(res.json()).toMatchObject({ path: "acqua", solution: { code: "addolcitore" }, estimates: { investmentCents: 150000 } })
expect(await t.app.db.select().from(quoteRequests)).toHaveLength(0)
const bad = await t.app.inject({ method: "POST", url: "/configurator/evaluate", payload: { answers: { ...water, pdc_superficie: 80 } } })
expect(bad.statusCode).toBe(400)
expect(bad.json().error).toMatchObject({
code: "VALIDATION_ERROR",
details: [{ path: "answers.pdc_superficie", message: "Risposta non prevista per le scelte fatte" }],
})
})
it("submit senza consenso privacy → 400, niente salvato", async () => {
for (const acceptPrivacy of [undefined, false]) {
const res = await t.app.inject({ method: "POST", url: "/configurator/submit", payload: { answers: water, contact, acceptPrivacy } })
expect(res.statusCode).toBe(400)
}
expect(await t.app.db.select().from(quoteRequests)).toHaveLength(0)
expect(t.email.sent).toHaveLength(0)
})
it("submit salva input/output e consensi, email all'admin e conferma all'utente", async () => {
const res = await t.app.inject({
method: "POST",
url: "/configurator/submit",
payload: { answers: water, contact, message: "Preferisco essere contattato la mattina", acceptPrivacy: true, acceptMarketing: true },
})
expect(res.statusCode).toBe(201)
const { id, result } = res.json()
expect(result.solution.code).toBe("addolcitore")
const [row] = await t.app.db.select().from(quoteRequests).where(eq(quoteRequests.id, id))
expect(row).toMatchObject({ email: "mario@example.com", name: "Mario Rossi", phone: "3331234567", province: null, status: "new", userId: null })
expect(row!.configuratorInput).toMatchObject({ answers: water, message: "Preferisco essere contattato la mattina" })
expect(row!.configuratorOutput).toEqual(result)
const c = await t.app.db.select().from(consents)
expect(c.map((x) => x.type).sort()).toEqual(["marketing", "privacy"])
expect(c[0]).toMatchObject({ email: "mario@example.com", source: "configurator" })
const admin = t.email.last("quote-request-admin")
expect(admin?.to).toBe("admin@iti.local")
expect(admin?.text).toContain("Addolcitore 20 litri")
expect(admin?.text).toContain(`/admin/richieste/${id}`)
expect(admin?.text).toContain("Preferisco essere contattato la mattina")
const user = t.email.last("quote-request-received")
expect(user?.to).toBe("mario@example.com")
expect(user?.text).toMatch(/Investimento indicativo: 1\.500\s€/)
})
it("submit da utente autenticato collega la richiesta; provincia dalle risposte", async () => {
await t.app.db
.insert(users)
.values({ email: "cliente@example.com", passwordHash: await hashPassword("Password-di-test-1"), role: "customer", firstName: "C", lastName: "L" })
const login = await t.app.inject({ method: "POST", url: "/auth/login", payload: { email: "cliente@example.com", password: "Password-di-test-1" } })
const res = await t.app.inject({
method: "POST",
url: "/configurator/submit",
headers: { authorization: `Bearer ${login.json().accessToken}` },
payload: {
answers: { percorso: "azienda", provincia: "VR", az_settore: "agricoltura", az_interessi: ["acqua"] },
contact: { name: "Cliente", email: "cliente@example.com" },
acceptPrivacy: true,
},
})
expect(res.statusCode).toBe(201)
const [row] = await t.app.db.select().from(quoteRequests)
expect(row!.province).toBe("VR")
expect(row!.userId).not.toBeNull()
})
})
+216
View File
@@ -0,0 +1,216 @@
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)
})
})
+44
View File
@@ -0,0 +1,44 @@
import { getTableColumns } from "drizzle-orm"
import { describe, expect, it } from "vitest"
import { emailVerificationTokens, passwordResetTokens, refreshTokens, users } from "../src/db/schema/index.js"
import { hashPassword, verifyPassword } from "../src/services/password.js"
import { generateToken, hashToken } from "../src/services/tokens.js"
describe("token opachi", () => {
it("256 bit casuali, hash SHA-256 diverso dal token", () => {
const { token, hash } = generateToken()
expect(Buffer.from(token, "base64url")).toHaveLength(32)
expect(hash).toMatch(/^[0-9a-f]{64}$/)
expect(hash).not.toContain(token)
expect(hashToken(token)).toBe(hash)
expect(generateToken().token).not.toBe(token)
})
})
describe("password", () => {
it("argon2id, verifica ok/ko, hash malformato -> false", async () => {
const h = await hashPassword("una password lunga")
expect(h.startsWith("$argon2id$")).toBe(true)
expect(await verifyPassword(h, "una password lunga")).toBe(true)
expect(await verifyPassword(h, "sbagliata")).toBe(false)
expect(await verifyPassword("non-un-hash", "x")).toBe(false)
})
})
describe("schema: niente segreti in chiaro", () => {
it.each([
["refresh_tokens", refreshTokens],
["password_reset_tokens", passwordResetTokens],
["email_verification_tokens", emailVerificationTokens],
])("%s ha solo token_hash", (_name, table) => {
const columns = Object.values(getTableColumns(table)).map((c) => c.name)
expect(columns).toContain("token_hash")
expect(columns.filter((c) => /token|secret/.test(c) && c !== "token_hash")).toEqual([])
})
it("users ha password_hash e nessuna colonna password in chiaro", () => {
const columns = Object.values(getTableColumns(users)).map((c) => c.name)
expect(columns).toContain("password_hash")
expect(columns).not.toContain("password")
})
})
+296
View File
@@ -0,0 +1,296 @@
import { existsSync } from "node:fs"
import { readdir, rm } from "node:fs/promises"
import { join } from "node:path"
import { Readable } from "node:stream"
import { eq } from "drizzle-orm"
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import { documents, installerProfiles, users } from "../src/db/schema/index.js"
import { hashPassword } from "../src/services/password.js"
import { checkFileKind, InvalidStorageKeyError, LocalDiskStorage, sniffKind } from "../src/services/storage.js"
import { buildTestApp, multipartBody, TEST_UPLOAD_DIR, type TestApp, truncateAll } from "./helpers.js"
const PASSWORD = "Password-di-test-1"
const PDF = Buffer.from("%PDF-1.7\n1 0 obj << >> endobj\ntrailer << >>\n%%EOF\n")
const PNG = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(32, 1)])
const ZIP = Buffer.concat([Buffer.from("PK\x03\x04", "latin1"), Buffer.alloc(30)])
const DWG = Buffer.concat([Buffer.from("AC1032"), Buffer.alloc(30)])
const DXF = Buffer.from(" 0\r\nSECTION\r\n 2\r\nHEADER\r\n")
const HTML = Buffer.from("<!doctype html><script>alert(1)</script>")
let t: TestApp
const tokens: Record<string, string> = {}
async function user(email: string, role: "customer" | "company" | "installer" | "admin", installerStatus?: "pending" | "approved") {
const [u] = await t.app.db
.insert(users)
.values({ email, passwordHash: await hashPassword(PASSWORD), role, firstName: "T", lastName: "U" })
.returning()
if (installerStatus) await t.app.db.insert(installerProfiles).values({ userId: u!.id, businessName: "Srl", status: installerStatus })
const res = await t.app.inject({ method: "POST", url: "/auth/login", payload: { email, password: PASSWORD } })
tokens[email] = res.json().accessToken
return u!
}
const auth = (email?: string) => (email ? { authorization: `Bearer ${tokens[email]}` } : {})
function upload(
file: { filename: string; contentType: string; content: Buffer | string } | null,
fields: Record<string, string> = { title: "Scheda tecnica", visibility: "installer" },
as = "admin@iti.local",
url = "/admin/documents",
) {
const body = multipartBody(fields, file ? [file] : [])
return t.app.inject({ method: "POST", url, payload: body.payload, headers: { ...body.headers, ...auth(as) } })
}
async function filesOnDisk(): Promise<string[]> {
const out: string[] = []
const walk = async (dir: string) => {
if (!existsSync(dir)) return
for (const e of await readdir(dir, { withFileTypes: true })) {
const p = join(dir, e.name)
if (e.isDirectory()) await walk(p)
else out.push(p)
}
}
await walk(TEST_UPLOAD_DIR)
return out
}
beforeAll(async () => {
t = await buildTestApp()
})
afterAll(async () => {
await t.app.close()
await rm(TEST_UPLOAD_DIR, { recursive: true, force: true })
})
beforeEach(async () => {
await truncateAll()
await rm(TEST_UPLOAD_DIR, { recursive: true, force: true })
await user("admin@iti.local", "admin")
await user("cliente@example.com", "customer")
await user("inst@example.com", "installer", "approved")
await user("attesa@example.com", "installer", "pending")
})
describe("upload e download per ruolo", () => {
it("un installatore approvato scarica i documenti installer; customer 403, installatore in attesa 403, anonimo 401", async () => {
const res = await upload({ filename: "Scheda PDC 8kW.pdf", contentType: "application/pdf", content: PDF })
expect(res.statusCode).toBe(201)
const doc = res.json()
expect(doc).toMatchObject({ title: "Scheda tecnica", visibility: "installer", mimeType: "application/pdf", sizeBytes: PDF.length })
expect(JSON.stringify(doc)).not.toMatch(/file_?path|documents\//i)
const dl = await t.app.inject({ method: "GET", url: `/documents/${doc.id}/download`, headers: auth("inst@example.com") })
expect(dl.statusCode).toBe(200)
expect(dl.rawPayload.equals(PDF)).toBe(true)
expect(dl.headers["content-type"]).toBe("application/pdf")
expect(dl.headers["content-disposition"]).toBe(`attachment; filename="Scheda tecnica.pdf"; filename*=UTF-8''Scheda%20tecnica.pdf`)
expect(dl.headers["cache-control"]).toBe("private, no-store")
const forbidden = await t.app.inject({ method: "GET", url: `/documents/${doc.id}/download`, headers: auth("cliente@example.com") })
expect(forbidden.statusCode).toBe(403)
expect((await t.app.inject({ method: "GET", url: `/documents/${doc.id}/download`, headers: auth("attesa@example.com") })).statusCode).toBe(403)
expect((await t.app.inject({ method: "GET", url: `/documents/${doc.id}/download` })).statusCode).toBe(401)
expect((await t.app.inject({ method: "GET", url: `/documents/${doc.id}/download`, headers: auth("admin@iti.local") })).statusCode).toBe(200)
})
it("documento pubblico scaricabile da anonimo; documento inesistente 404", async () => {
const doc = (await upload({ filename: "catalogo.pdf", contentType: "application/pdf", content: PDF }, { title: "Catalogo", visibility: "public" })).json()
const dl = await t.app.inject({ method: "GET", url: `/documents/${doc.id}/download` })
expect(dl.statusCode).toBe(200)
expect(dl.headers["cache-control"]).toBe("public, max-age=300")
expect((await t.app.inject({ method: "GET", url: "/documents/00000000-0000-4000-8000-000000000000/download" })).statusCode).toBe(404)
})
it("GET /documents elenca solo i livelli visibili al ruolo", async () => {
for (const visibility of ["public", "customer", "installer", "admin"]) {
await upload({ filename: "x.pdf", contentType: "application/pdf", content: PDF }, { title: visibility, visibility, category: "schede" })
}
const titles = async (email?: string) =>
((await t.app.inject({ method: "GET", url: "/documents", headers: auth(email) })).json().items as { title: string }[])
.map((d) => d.title)
.sort()
expect(await titles()).toEqual(["public"])
expect(await titles("cliente@example.com")).toEqual(["customer", "public"])
expect(await titles("attesa@example.com")).toEqual(["customer", "public"])
expect(await titles("inst@example.com")).toEqual(["customer", "installer", "public"])
expect(await titles("admin@iti.local")).toEqual(["admin", "customer", "installer", "public"])
})
it("tutti i tipi ammessi: immagini, PDF, ZIP, DWG, DXF (MIME canonico)", async () => {
const cases = [
{ filename: "foto.PNG", contentType: "image/png", content: PNG, mime: "image/png" },
{ filename: "progetto.zip", contentType: "application/x-zip-compressed", content: ZIP, mime: "application/zip" },
{ filename: "pianta.dwg", contentType: "application/octet-stream", content: DWG, mime: "image/vnd.dwg" },
{ filename: "pianta.dxf", contentType: "application/dxf", content: DXF, mime: "image/vnd.dxf" },
]
for (const c of cases) {
const res = await upload(c, { title: c.filename, visibility: "admin" })
expect(res.statusCode, c.filename).toBe(201)
expect(res.json().mimeType).toBe(c.mime)
}
})
})
describe("rifiuti", () => {
it("estensione o MIME non consentiti, contenuto non coerente → 415 e niente su disco", async () => {
const rejected = [
{ filename: "virus.exe", contentType: "application/octet-stream", content: Buffer.from("MZ...") },
{ filename: "logo.svg", contentType: "image/svg+xml", content: Buffer.from("<svg onload=alert(1)/>") },
{ filename: "pagina.html", contentType: "text/html", content: HTML },
{ filename: "finto.pdf", contentType: "application/pdf", content: HTML },
{ filename: "finta.jpg", contentType: "image/jpeg", content: PNG },
{ filename: "doc.pdf", contentType: "text/html", content: PDF },
{ filename: "senza-estensione", contentType: "application/pdf", content: PDF },
{ filename: "vuoto.pdf", contentType: "application/pdf", content: Buffer.alloc(0) },
]
for (const file of rejected) {
const res = await upload(file)
expect(res.statusCode, file.filename).toBe(415)
expect(res.json().error.code).toBe("UNSUPPORTED_FILE_TYPE")
}
expect(await filesOnDisk()).toEqual([])
expect(await t.app.db.select().from(documents)).toHaveLength(0)
})
it("file oltre UPLOAD_MAX_BYTES → 413 e file parziale cancellato", async () => {
const small = await buildTestApp({ UPLOAD_MAX_BYTES: "100" })
try {
const login = await small.app.inject({ method: "POST", url: "/auth/login", payload: { email: "admin@iti.local", password: PASSWORD } })
const body = multipartBody({ title: "Grande", visibility: "admin" }, [
{ filename: "grande.pdf", contentType: "application/pdf", content: Buffer.concat([PDF, Buffer.alloc(500)]) },
])
const res = await small.app.inject({
method: "POST",
url: "/admin/documents",
payload: body.payload,
headers: { ...body.headers, authorization: `Bearer ${login.json().accessToken}` },
})
expect(res.statusCode).toBe(413)
expect(res.json().error.code).toBe("FILE_TOO_LARGE")
} finally {
await small.app.close()
}
expect(await filesOnDisk()).toEqual([])
})
it("nessun file, due file, metadati non validi → 400 senza file residui", async () => {
expect((await upload(null)).json().error.code).toBe("FILE_REQUIRED")
const two = multipartBody({ title: "Due" }, [
{ filename: "a.pdf", contentType: "application/pdf", content: PDF },
{ filename: "b.pdf", contentType: "application/pdf", content: PDF },
])
const res = await t.app.inject({ method: "POST", url: "/admin/documents", payload: two.payload, headers: { ...two.headers, ...auth("admin@iti.local") } })
expect(res.statusCode).toBe(400)
const bad = await upload({ filename: "a.pdf", contentType: "application/pdf", content: PDF }, { title: "", visibility: "segreto" })
expect(bad.statusCode).toBe(400)
expect(bad.json().error.code).toBe("VALIDATION_ERROR")
expect(await filesOnDisk()).toEqual([])
})
it("solo admin: customer 403, anonimo 401", async () => {
expect((await upload({ filename: "a.pdf", contentType: "application/pdf", content: PDF }, undefined, "cliente@example.com")).statusCode).toBe(403)
expect((await t.app.inject({ method: "GET", url: "/admin/documents" })).statusCode).toBe(401)
})
})
describe("path traversal e accesso statico", () => {
it("il nome file del client non entra nel percorso: chiave generata dal server", async () => {
const res = await upload({ filename: "../../../../etc/passwd.pdf", contentType: "application/pdf", content: PDF })
expect(res.statusCode).toBe(201)
const [row] = await t.app.db.select().from(documents).where(eq(documents.id, res.json().id))
expect(row!.filePath).toMatch(/^documents\/\d{4}\/\d{2}\/[0-9a-f-]{36}\.pdf$/)
const files = await filesOnDisk()
expect(files).toHaveLength(1)
expect(files[0]!.startsWith(join(TEST_UPLOAD_DIR, "private", "documents"))).toBe(true)
})
it("LocalDiskStorage rifiuta chiavi con .., assolute o con caratteri non ammessi", async () => {
const storage = new LocalDiskStorage(TEST_UPLOAD_DIR, "http://x")
for (const key of ["../fuori.pdf", "a/../../fuori.pdf", "/etc/passwd", "C:\\Windows\\x", "a//b.pdf", "a/./b.pdf", "A.PDF", "%2e%2e/x"]) {
await expect(storage.put("private", key, Readable.from([PDF])), key).rejects.toBeInstanceOf(InvalidStorageKeyError)
await expect(storage.get("private", key), key).rejects.toBeInstanceOf(InvalidStorageKeyError)
}
})
it("i file privati non sono raggiungibili via /uploads; le immagini pubbliche sì", async () => {
const doc = await upload({ filename: "riservato.pdf", contentType: "application/pdf", content: PDF })
const [row] = await t.app.db.select().from(documents).where(eq(documents.id, doc.json().id))
for (const url of [
`/uploads/${row!.filePath}`,
`/uploads/../private/${row!.filePath}`,
`/uploads/%2e%2e/private/${row!.filePath}`,
`/uploads/..%2fprivate/${row!.filePath}`,
`/uploads/%2e%2e%2fprivate%2f${row!.filePath.replaceAll("/", "%2f")}`,
]) {
const res = await t.app.inject({ method: "GET", url })
expect(res.statusCode, url).not.toBe(200)
expect(res.rawPayload.includes(Buffer.from("%PDF"))).toBe(false)
}
const img = await upload({ filename: "copertina.png", contentType: "image/png", content: PNG }, {}, "admin@iti.local", "/admin/uploads/image")
expect(img.statusCode).toBe(201)
const { url, path } = img.json()
expect(path).toMatch(/^images\/\d{4}\/\d{2}\/[0-9a-f-]{36}\.png$/)
expect(url).toBe(`http://localhost:3000/uploads/${path}`)
const served = await t.app.inject({ method: "GET", url: `/uploads/${path}` })
expect(served.statusCode).toBe(200)
expect(served.rawPayload.equals(PNG)).toBe(true)
})
it("upload immagine pubblica: solo immagini", async () => {
const res = await upload({ filename: "doc.pdf", contentType: "application/pdf", content: PDF }, {}, "admin@iti.local", "/admin/uploads/image")
expect(res.statusCode).toBe(415)
})
})
describe("gestione admin", () => {
it("elenco con filtri, PATCH metadati, DELETE cancella anche il file", async () => {
const a = (await upload({ filename: "a.pdf", contentType: "application/pdf", content: PDF }, { title: "Manuale addolcitore", visibility: "customer", category: "manuali", productRef: "addolcitore-30l" })).json()
await upload({ filename: "b.pdf", contentType: "application/pdf", content: PDF }, { title: "Listino 2026", visibility: "installer" })
const list = await t.app.inject({ method: "GET", url: "/admin/documents?q=manuale", headers: auth("admin@iti.local") })
expect(list.json()).toMatchObject({ total: 1, items: [{ title: "Manuale addolcitore", productRef: "addolcitore-30l", category: "manuali" }] })
expect((await t.app.inject({ method: "GET", url: "/admin/documents?visibility=installer", headers: auth("admin@iti.local") })).json().total).toBe(1)
const patched = await t.app.inject({
method: "PATCH",
url: `/admin/documents/${a.id}`,
headers: auth("admin@iti.local"),
payload: { visibility: "public", category: null },
})
expect(patched.json()).toMatchObject({ visibility: "public", category: null, title: "Manuale addolcitore" })
expect((await t.app.inject({ method: "GET", url: `/documents/${a.id}/download` })).statusCode).toBe(200)
// Regressione: un PATCH senza visibility non deve riportarla al default "admin"
const onlyTitle = await t.app.inject({
method: "PATCH",
url: `/admin/documents/${a.id}`,
headers: auth("admin@iti.local"),
payload: { title: "Manuale addolcitore v2" },
})
expect(onlyTitle.json()).toMatchObject({ title: "Manuale addolcitore v2", visibility: "public" })
expect(await filesOnDisk()).toHaveLength(2)
expect((await t.app.inject({ method: "DELETE", url: `/admin/documents/${a.id}`, headers: auth("admin@iti.local") })).statusCode).toBe(204)
expect(await filesOnDisk()).toHaveLength(1)
expect((await t.app.inject({ method: "DELETE", url: `/admin/documents/${a.id}`, headers: auth("admin@iti.local") })).statusCode).toBe(404)
})
})
describe("riconoscimento tipo file", () => {
it("sniffKind e checkFileKind", () => {
expect(sniffKind(PDF)).toBe("pdf")
expect(sniffKind(PNG)).toBe("png")
expect(sniffKind(Buffer.from([0xff, 0xd8, 0xff, 0xe0]))).toBe("jpeg")
expect(sniffKind(Buffer.from("RIFF\0\0\0\0WEBPVP8 "))).toBe("webp")
expect(sniffKind(DWG)).toBe("dwg")
expect(sniffKind(DXF)).toBe("dxf")
expect(sniffKind(HTML)).toBeNull()
expect(checkFileKind("a.pdf", "application/pdf", PDF, ["pdf"])).toEqual({ ok: true, kind: "pdf", ext: ".pdf" })
expect(checkFileKind("a.pdf", "application/pdf", PDF, ["png"]).ok).toBe(false)
})
})
+266
View File
@@ -0,0 +1,266 @@
import { mkdtemp, readdir, readFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import pino from "pino"
import { describe, expect, it, vi } from "vitest"
import { loadConfig } from "../src/config/env.js"
import { EMAIL_TEMPLATES, type EmailTemplate, type EmailTemplates, type OrderEmailData, renderEmail } from "../src/emails/index.js"
import {
BrevoTransport,
ConsoleTransport,
EmailDeliveryError,
EmailSender,
type EmailTransport,
renderMessage,
ResendTransport,
} from "../src/services/email.js"
import { LogCapture, testConfig } from "./helpers.js"
const XSS = `<script>alert("x")</script>&"'`
const EVIL_URL = "javascript:alert(1)"
const order: OrderEmailData = {
number: 12,
email: "cliente@example.com",
customerName: `Mario ${XSS}`,
guest: false,
items: [{ name: `Addolcitore ${XSS}`, quantity: 2, total: 238000 }],
itemTotal: 238000,
shippingTotal: 1500,
discountTotal: 0,
taxTotal: 43188,
total: 239500,
shippingAddress: ["Mario Rossi", `Via Roma 1 ${XSS}`, "20100 Milano (MI)"],
}
/** Dati di esempio per ogni template, con contenuti ostili nei campi testuali. */
const samples: { [K in EmailTemplate]: EmailTemplates[K] } = {
"verify-email": { firstName: `Mario ${XSS}`, link: "https://area.example.it/verify-email?token=abc" },
"reset-password": { firstName: `Mario ${XSS}`, link: "https://area.example.it/reset-password?token=abc" },
"register-existing": { firstName: `Mario ${XSS}`, forgotPasswordUrl: "https://area.example.it/forgot-password" },
"order-confirmation": { order, ordersUrl: "https://area.example.it/ordini" },
"order-placed-admin": { order },
"order-canceled": { order },
"order-shipped": { order, tracking: [{ number: `BRT${XSS}`, url: "https://track.example/BRT1" }] },
"quote-request-admin": {
name: `Ugo ${XSS}`,
email: "ugo@example.com",
phone: "333 1234567",
province: "TO",
summary: [{ label: "Soluzione", value: `Fotovoltaico ${XSS}` }],
adminUrl: "https://area.example.it/admin/preventivi/1",
},
"quote-request-received": {
name: `Ugo ${XSS}`,
solutionTitle: `Addolcitore ${XSS}`,
summary: [{ label: "Investimento", value: `1.500 € ${XSS}` }],
disclaimer: "Stima indicativa",
},
"quote-sent": {
firstName: `Ugo ${XSS}`,
quoteNumber: "2026-0007",
title: `Impianto FV 6 kW ${XSS}`,
amountCents: 990000,
validUntil: "2026-11-30",
link: "https://area.example.it/preventivi/7",
},
"ticket-reply": {
firstName: `Ugo ${XSS}`,
ticketSubject: `Perdita ${XSS}`,
message: `Riga 1\nRiga 2 ${XSS}`,
link: "https://area.example.it/ticket/3",
},
"staff-notification": {
title: `Nuovo ticket ${XSS}`,
intro: `Ticket da ${XSS}`,
rows: [{ label: "Oggetto", value: `Perdita ${XSS}` }],
url: "https://area.example.it/admin/ticket/1",
},
"installer-approved": { firstName: `Paolo ${XSS}`, businessName: `Impianti ${XSS}`, link: "https://area.example.it/installatori" },
"installer-rejected": { firstName: `Paolo ${XSS}`, businessName: `Impianti ${XSS}`, reason: `Motivo ${XSS}` },
"showroom-booking-confirmed": {
name: `Ugo ${XSS}`,
requestedAt: "2026-10-10T13:00:00.000Z",
people: 3,
notes: `Parcheggio ${XSS}`,
},
}
describe("template", () => {
it("c'è un esempio per ogni template registrato", () => {
expect(Object.keys(samples).sort()).toEqual([...EMAIL_TEMPLATES].sort())
})
for (const template of EMAIL_TEMPLATES) {
it(`${template}: oggetto, HTML con layout, testo; dati escapati`, () => {
const r = renderEmail(template, samples[template] as never)
expect(r.subject.length).toBeGreaterThan(5)
expect(r.subject).not.toMatch(/[\r\n]/)
expect(r.html).toMatch(/^<!doctype html>/)
expect(r.html).toContain("ITI TECHNOLOGY")
expect(r.text).toContain("ITI TECHNOLOGY")
// Nessun contenuto ostile non escapato nell'HTML
expect(r.html).not.toContain("<script>")
expect(r.html).not.toContain(`"x")`)
expect(r.html).toContain("&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;&amp;&quot;&#39;")
})
}
it("contenuti specifici: link, importi, date", () => {
const verify = renderEmail("verify-email", samples["verify-email"])
expect(verify.html).toContain('href="https://area.example.it/verify-email?token=abc"')
expect(verify.text).toContain("https://area.example.it/verify-email?token=abc")
const conf = renderEmail("order-confirmation", samples["order-confirmation"])
expect(conf.subject).toBe("Conferma ordine n. 12 - ITI TECHNOLOGY")
expect(conf.text).toMatch(/2 x Addolcitore .*: 2\.380,00\s€/)
expect(conf.html).toMatch(/2\.395,00\s€/)
expect(conf.html).toContain("https://area.example.it/ordini")
expect(renderEmail("order-confirmation", { ...samples["order-confirmation"], order: { ...order, guest: true } }).html).not.toContain(
"/ordini",
)
expect(renderEmail("order-shipped", samples["order-shipped"]).html).toContain('href="https://track.example/BRT1"')
expect(renderEmail("quote-sent", samples["quote-sent"]).text).toMatch(/9\.900,00\s€/)
expect(renderEmail("quote-sent", samples["quote-sent"]).text).toContain("30 novembre 2026")
// 13:00 UTC = 15:00 a Roma (ora legale)
expect(renderEmail("showroom-booking-confirmed", samples["showroom-booking-confirmed"]).text).toContain("15:00")
})
it("URL non http(s) neutralizzati; a capo rimossi dall'oggetto", () => {
const r = renderEmail("verify-email", { firstName: "A", link: EVIL_URL })
expect(r.html).not.toContain("javascript:")
expect(r.html).toContain('href="#"')
const t = renderEmail("ticket-reply", { ...samples["ticket-reply"], ticketSubject: "a\r\nBcc: x@evil.com" })
expect(t.subject).not.toMatch(/[\r\n]/)
})
})
describe("trasporti", () => {
const message = renderMessage({ to: "a@example.com", template: "verify-email", data: samples["verify-email"] })
const sender = { email: "noreply@iti.it", name: "ITI TECHNOLOGY", replyTo: "info@iti.it" }
it("Brevo: endpoint, api-key, corpo", async () => {
const fetchFn = vi.fn(async () => new Response("{}", { status: 201 }))
await new BrevoTransport("brevo-key", sender, fetchFn).deliver(message)
const [url, init] = fetchFn.mock.calls[0] as unknown as [string, RequestInit]
expect(url).toBe("https://api.brevo.com/v3/smtp/email")
expect((init.headers as Record<string, string>)["api-key"]).toBe("brevo-key")
expect(JSON.parse(init.body as string)).toMatchObject({
sender: { email: "noreply@iti.it", name: "ITI TECHNOLOGY" },
to: [{ email: "a@example.com" }],
subject: message.subject,
htmlContent: message.html,
textContent: message.text,
tags: ["verify-email"],
replyTo: { email: "info@iti.it" },
})
})
it("Resend: endpoint, Bearer, corpo", async () => {
const fetchFn = vi.fn(async () => new Response("{}", { status: 200 }))
await new ResendTransport("re_key", sender, fetchFn).deliver(message)
const [url, init] = fetchFn.mock.calls[0] as unknown as [string, RequestInit]
expect(url).toBe("https://api.resend.com/emails")
expect((init.headers as Record<string, string>).authorization).toBe("Bearer re_key")
expect(JSON.parse(init.body as string)).toMatchObject({
from: "ITI TECHNOLOGY <noreply@iti.it>",
to: ["a@example.com"],
html: message.html,
text: message.text,
tags: [{ name: "template", value: "verify-email" }],
reply_to: "info@iti.it",
})
})
it("errori provider: 429/5xx/rete ritentabili, 4xx no", async () => {
const fail = (status: number) => new ResendTransport("k", sender, async () => new Response("no", { status })).deliver(message)
await expect(fail(500)).rejects.toMatchObject({ retryable: true })
await expect(fail(429)).rejects.toMatchObject({ retryable: true })
await expect(fail(422)).rejects.toMatchObject({ retryable: false })
const network = new BrevoTransport("k", sender, async () => {
throw new TypeError("fetch failed")
})
await expect(network.deliver(message)).rejects.toBeInstanceOf(EmailDeliveryError)
})
it("console: log senza corpo, outbox con .txt e .html", async () => {
const logs = new LogCapture()
const dir = await mkdtemp(join(tmpdir(), "iti-outbox-"))
await new ConsoleTransport(pino({}, logs), dir).deliver(message)
expect(logs.text).toContain("verify-email")
expect(logs.text).not.toContain("token=abc")
const files = await readdir(dir)
expect(files.some((f) => f.endsWith("-verify-email.txt"))).toBe(true)
const htmlFile = files.find((f) => f.endsWith(".html"))!
expect(await readFile(join(dir, htmlFile), "utf8")).toContain("<!doctype html>")
})
})
describe("EmailSender", () => {
const request = { to: "a@example.com", template: "verify-email" as const, data: samples["verify-email"] }
it("send non fallisce se il provider fallisce: ritenta e logga l'errore", async () => {
const logs = new LogCapture()
const deliver = vi.fn(async () => {
throw new EmailDeliveryError("Brevo: HTTP 503", true)
})
const transport: EmailTransport = { name: "fake", deliver }
const email = new EmailSender(transport, pino({}, logs), { attempts: 3, retryBaseMs: 1 })
await expect(email.send(request)).resolves.toBeUndefined()
await email.flush()
expect(deliver).toHaveBeenCalledTimes(3)
expect(logs.text).toContain("invio email non riuscito")
})
it("errore non ritentabile: un solo tentativo; successo al secondo tentativo dopo un 5xx", async () => {
const logs = new LogCapture()
const once = vi.fn(async () => {
throw new EmailDeliveryError("HTTP 401", false)
})
const a = new EmailSender({ name: "fake", deliver: once }, pino({}, logs), { retryBaseMs: 1 })
await a.send(request)
await a.flush()
expect(once).toHaveBeenCalledTimes(1)
let n = 0
const flaky = vi.fn(async () => {
if (n++ === 0) throw new EmailDeliveryError("HTTP 502", true)
})
const b = new EmailSender({ name: "fake", deliver: flaky }, pino({}, logs), { retryBaseMs: 1 })
await b.send(request)
await b.flush()
expect(flaky).toHaveBeenCalledTimes(2)
expect(logs.text).toContain("email inviata")
})
it("un errore di template è un bug: send lancia subito", async () => {
const email = new EmailSender({ name: "fake", deliver: async () => {} }, pino({ level: "silent" }))
await expect(email.send({ to: "a@example.com", template: "order-confirmation", data: {} as never })).rejects.toThrow(TypeError)
})
})
describe("configurazione", () => {
const base = () => {
const c = testConfig()
return {
NODE_ENV: "test",
DATABASE_URL: c.DATABASE_URL,
CORS_ORIGINS: "http://localhost:4200",
COOKIE_SECRET: "c".repeat(32),
JWT_SECRET: "j".repeat(32),
MEDUSA_PUBLISHABLE_KEY: "pk",
INTERNAL_API_SECRET: "i".repeat(32),
WEBHOOK_SECRET: "w".repeat(32),
PRIVATE_APP_URL: "http://localhost:4300",
}
}
it("cambiare provider richiede solo variabili d'ambiente (con validazione)", () => {
expect(loadConfig(base()).EMAIL_PROVIDER).toBe("console")
expect(() => loadConfig({ ...base(), EMAIL_PROVIDER: "brevo" })).toThrow(/EMAIL_FROM.*\n.*BREVO_API_KEY|BREVO_API_KEY/s)
expect(() => loadConfig({ ...base(), EMAIL_PROVIDER: "resend", EMAIL_FROM: "noreply@iti.it" })).toThrow(/RESEND_API_KEY/)
const ok = loadConfig({ ...base(), EMAIL_PROVIDER: "resend", EMAIL_FROM: "NoReply@ITI.it", RESEND_API_KEY: "re_x" })
expect(ok).toMatchObject({ EMAIL_PROVIDER: "resend", EMAIL_FROM: "noreply@iti.it", EMAIL_FROM_NAME: "ITI TECHNOLOGY" })
})
})
+14
View File
@@ -0,0 +1,14 @@
import { fileURLToPath } from "node:url"
import { migrate } from "drizzle-orm/postgres-js/migrator"
import { createDb } from "../src/db/client.js"
import { testDatabaseUrl } from "./helpers.js"
/** Applica le migrazioni al DB di test prima di tutti i test. */
export default async function setup() {
const { db, close } = createDb(testDatabaseUrl(), { max: 1 })
try {
await migrate(db, { migrationsFolder: fileURLToPath(new URL("../src/db/migrations", import.meta.url)) })
} finally {
await close()
}
}
+163
View File
@@ -0,0 +1,163 @@
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Writable } from "node:stream"
import { sql } from "drizzle-orm"
import { buildApp, type App } from "../src/app.js"
import { dbEnvSchema, loadConfig, parseEnv, type Config } from "../src/config/env.js"
import { createDb } from "../src/db/client.js"
import { users } from "../src/db/schema/index.js"
import { MemoryEmailService } from "../src/services/email.js"
import { hashPassword } from "../src/services/password.js"
import { MedusaMock } from "./medusa-mock.js"
/**
* URL del DB di test: TEST_DATABASE_URL oppure DATABASE_URL con suffisso "_test" sul nome del DB.
* Rifiuta qualunque database il cui nome non finisca con "_test" (i test lo svuotano).
*/
export function testDatabaseUrl(): string {
const explicit = process.env.TEST_DATABASE_URL
const url = new URL(explicit ?? parseEnv(dbEnvSchema).DATABASE_URL)
if (!explicit) url.pathname = `${url.pathname}_test`
if (!url.pathname.endsWith("_test")) {
throw new Error(`Il DB dei test deve terminare con "_test" (trovato ${url.pathname.slice(1)})`)
}
return url.toString()
}
/** Radice dello storage nei test (per processo). */
export const TEST_UPLOAD_DIR = join(tmpdir(), `iti-api-test-uploads-${process.pid}`)
/** Config completa per i test: valori fissi, indipendenti dal .env (tranne il DB). */
export function testConfig(overrides: Record<string, string> = {}): Config {
return loadConfig({
NODE_ENV: "test",
DATABASE_URL: testDatabaseUrl(),
CORS_ORIGINS: "http://localhost:4200",
COOKIE_SECRET: "c".repeat(32),
JWT_SECRET: "j".repeat(32),
MEDUSA_PUBLISHABLE_KEY: "pk_test",
INTERNAL_API_SECRET: "i".repeat(32),
WEBHOOK_SECRET: "w".repeat(32),
PRIVATE_APP_URL: "http://localhost:4300",
AUTH_LOGIN_RATE_MAX: "1000",
AUTH_EMAIL_RATE_MAX: "1000",
RATE_LIMIT_MAX: "10000",
// Storage dei test in una cartella temporanea (mai la uploads/ di sviluppo)
UPLOAD_DIR: TEST_UPLOAD_DIR,
PUBLIC_UPLOADS_URL: "http://localhost:3000/uploads",
...overrides,
})
}
/** Stream che accumula le righe di log (per verificare che non contengano segreti). */
export class LogCapture extends Writable {
readonly lines: string[] = []
override _write(chunk: Buffer, _enc: BufferEncoding, cb: () => void) {
this.lines.push(chunk.toString())
cb()
}
get text() {
return this.lines.join("")
}
}
export type TestApp = { app: App; email: MemoryEmailService; logs: LogCapture; medusa: MedusaMock }
/**
* App di test con email in memoria, log catturati e Medusa mockato (nessuna chiamata di rete);
* `setup` aggiunge route di prova prima di ready().
*/
export async function buildTestApp(
overrides: Record<string, string> = {},
setup?: (app: App) => void,
): Promise<TestApp> {
const email = new MemoryEmailService()
const logs = new LogCapture()
const config = testConfig(overrides)
const medusa = new MedusaMock(config.INTERNAL_API_SECRET)
const app = await buildApp(config, { email, medusaFetch: medusa.fetch, logger: { level: "trace", stream: logs } })
setup?.(app)
await app.ready()
return { app, email, logs, medusa }
}
/** Svuota tutte le tabelle applicative del DB di test (non la tabella delle migrazioni). */
export async function truncateAll(): Promise<void> {
const { db, close } = createDb(testDatabaseUrl(), { max: 1 })
try {
await db.execute(sql`
DO $$ DECLARE t text; BEGIN
SELECT string_agg(format('%I', tablename), ', ') INTO t
FROM pg_tables WHERE schemaname = 'public' AND tablename <> 'spatial_ref_sys';
IF t IS NOT NULL THEN EXECUTE 'TRUNCATE ' || t || ' RESTART IDENTITY CASCADE'; END IF;
END $$`)
} finally {
await close()
}
}
/** Estrae il token da un link presente nel testo di un'email (?token=...). */
export function tokenFromEmail(text: string | undefined): string {
const match = text?.match(/[?&]token=([^\s&]+)/)
if (!match?.[1]) throw new Error("token non trovato nell'email")
return decodeURIComponent(match[1])
}
/** Valore del cookie indicato dalla risposta (header set-cookie). */
export function cookieFrom(setCookie: string | string[] | undefined, name: string): string | undefined {
const all = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : []
const raw = all.find((c) => c.startsWith(`${name}=`))
return raw?.split(";")[0]?.slice(name.length + 1)
}
export const TEST_PASSWORD = "Password-di-test-1"
/** Crea un utente (email verificata salvo indicazione) e fa login: restituisce utente e header Authorization. */
export async function createUserAndLogin(
app: App,
email: string,
role: "customer" | "company" | "installer" | "admin" = "customer",
extra: Partial<typeof users.$inferInsert> = {},
) {
const [user] = await app.db
.insert(users)
.values({
email,
passwordHash: await hashPassword(TEST_PASSWORD),
role,
firstName: "Nome",
lastName: "Cognome",
emailVerifiedAt: new Date(),
...extra,
})
.returning()
const res = await app.inject({ method: "POST", url: "/auth/login", payload: { email, password: TEST_PASSWORD } })
if (res.statusCode !== 200) throw new Error(`login fallito per ${email}: ${res.body}`)
return { user: user!, headers: { authorization: `Bearer ${res.json().accessToken as string}` } }
}
/** Body multipart/form-data per app.inject: campi testuali e file ({ filename, contentType, content }). */
export function multipartBody(
fields: Record<string, string>,
files: { field?: string; filename: string; contentType: string; content: Buffer | string }[],
) {
const boundary = `----iti${Math.random().toString(16).slice(2)}`
const CRLF = "\r\n"
const chunks: Buffer[] = []
for (const [name, value] of Object.entries(fields)) {
chunks.push(Buffer.from(`--${boundary}${CRLF}Content-Disposition: form-data; name="${name}"${CRLF}${CRLF}${value}${CRLF}`))
}
for (const f of files) {
chunks.push(
Buffer.from(
`--${boundary}${CRLF}Content-Disposition: form-data; name="${f.field ?? "file"}"; filename="${f.filename}"${CRLF}` +
`Content-Type: ${f.contentType}${CRLF}${CRLF}`,
),
Buffer.isBuffer(f.content) ? f.content : Buffer.from(f.content),
Buffer.from(CRLF),
)
}
chunks.push(Buffer.from(`--${boundary}--${CRLF}`))
return { payload: Buffer.concat(chunks), headers: { "content-type": `multipart/form-data; boundary=${boundary}` } }
}
+210
View File
@@ -0,0 +1,210 @@
import { rm } from "node:fs/promises"
import { eq } from "drizzle-orm"
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import { PROVINCES } from "../src/configurator/rules.js"
import { installerProfiles, quoteRequests, users } from "../src/db/schema/index.js"
import { PROVINCE_CENTROIDS } from "../src/services/geo.js"
import { buildTestApp, createUserAndLogin, multipartBody, TEST_UPLOAD_DIR, type TestApp, truncateAll } from "./helpers.js"
import { MOCK_PRODUCT, MOCK_VARIANT } from "./medusa-mock.js"
const MONZA = { lat: 45.585, lng: 9.274 }
let t: TestApp
let admin: Record<string, string>
type Seed = { email: string; city: { lat: number; lng: number } | null; radius: number; status?: "pending" | "approved" | "rejected"; disabled?: boolean }
async function seedInstaller(s: Seed) {
const [u] = await t.app.db
.insert(users)
.values({
email: s.email,
passwordHash: "x",
role: "installer",
firstName: "I",
lastName: s.email,
disabledAt: s.disabled ? new Date() : null,
})
.returning()
await t.app.db.insert(installerProfiles).values({
userId: u!.id,
businessName: `Impianti ${s.email.split("@")[0]}`,
location: s.city,
serviceRadiusKm: s.radius,
status: s.status ?? "approved",
})
return u!
}
beforeAll(async () => {
t = await buildTestApp()
})
afterAll(async () => {
await t.app.close()
await rm(TEST_UPLOAD_DIR, { recursive: true, force: true })
})
beforeEach(async () => {
await t.app.flushSyncs()
await truncateAll()
t.medusa.reset()
t.email.clear()
t.app.shop.clearCache()
admin = (await createUserAndLogin(t.app, "admin@iti.local", "admin")).headers
})
describe("ricerca per zona (PostGIS)", () => {
beforeEach(async () => {
await seedInstaller({ email: "torino@example.com", city: { lat: 45.07, lng: 7.687 }, radius: 20 }) // ~135 km
await seedInstaller({ email: "bergamo@example.com", city: { lat: 45.698, lng: 9.677 }, radius: 40 }) // ~34 km
await seedInstaller({ email: "lecco@example.com", city: { lat: 45.856, lng: 9.398 }, radius: 10 }) // ~31 km, raggio piccolo
await seedInstaller({ email: "milano@example.com", city: { lat: 45.464, lng: 9.19 }, radius: 50 }) // ~15 km
await seedInstaller({ email: "attesa@example.com", city: MONZA, radius: 50, status: "pending" })
await seedInstaller({ email: "disabilitato@example.com", city: MONZA, radius: 50, disabled: true })
await seedInstaller({ email: "senzasede@example.com", city: null, radius: 300 })
})
it("solo gli installatori approvati entro il proprio raggio, in ordine di distanza", async () => {
const res = await t.app.inject({ method: "GET", url: `/admin/installers/nearby?lat=${MONZA.lat}&lng=${MONZA.lng}`, headers: admin })
expect(res.statusCode).toBe(200)
const list = res.json() as { email: string; distanceKm: number }[]
expect(list.map((i) => i.email)).toEqual(["milano@example.com", "bergamo@example.com"])
expect(list[0]!.distanceKm).toBeGreaterThan(13)
expect(list[0]!.distanceKm).toBeLessThan(17)
expect(list[1]!.distanceKm).toBeGreaterThan(30)
expect(list[1]!.distanceKm).toBeLessThan(37)
})
it("da una richiesta di preventivo: provincia → capoluogo → installatori", async () => {
const [r] = await t.app.db
.insert(quoteRequests)
.values({ email: "c@example.com", name: "C", province: "MB", configuratorInput: {} })
.returning()
const res = await t.app.inject({ method: "GET", url: `/admin/quote-requests/${r!.id}/installers`, headers: admin })
expect(res.json()).toMatchObject({ province: "MB", point: PROVINCE_CENTROIDS.MB })
expect(res.json().installers.map((i: { email: string }) => i.email)).toEqual(["milano@example.com", "bergamo@example.com"])
const [noProv] = await t.app.db.insert(quoteRequests).values({ email: "d@example.com", name: "D", configuratorInput: {} }).returning()
expect((await t.app.inject({ method: "GET", url: `/admin/quote-requests/${noProv!.id}/installers`, headers: admin })).json().error.code).toBe(
"PROVINCE_MISSING",
)
})
it("coordinate non valide o fuori dall'Italia → 400; solo admin", async () => {
expect((await t.app.inject({ method: "GET", url: "/admin/installers/nearby?lat=48.85&lng=2.35", headers: admin })).statusCode).toBe(400)
expect((await t.app.inject({ method: "GET", url: "/admin/installers/nearby?lat=abc&lng=9", headers: admin })).statusCode).toBe(400)
const customer = (await createUserAndLogin(t.app, "c@example.com")).headers
expect((await t.app.inject({ method: "GET", url: "/admin/installers/nearby?lat=45&lng=9", headers: customer })).statusCode).toBe(403)
})
it("ogni provincia ha le coordinate del capoluogo, in Italia", () => {
expect(PROVINCES.filter((p) => !PROVINCE_CENTROIDS[p.code])).toEqual([])
expect(Object.keys(PROVINCE_CENTROIDS)).toHaveLength(PROVINCES.length)
})
})
describe("profilo installatore", () => {
it("GET/PUT del proprio profilo; coordinate validate; stato e note non modificabili", async () => {
const inst = await createUserAndLogin(t.app, "inst@example.com", "installer")
await t.app.db.insert(installerProfiles).values({ userId: inst.user.id, businessName: "Impianti Srl", notes: "nota interna" })
const got = await t.app.inject({ method: "GET", url: "/installer/profile", headers: inst.headers })
expect(got.json()).toMatchObject({ businessName: "Impianti Srl", status: "pending", location: null, serviceRadiusKm: 50 })
expect(got.body).not.toContain("nota interna")
const body = {
businessName: "Impianti Rossi Srl",
vatNumber: "IT01234567890",
phone: "0391234567",
address: "Via Italia 1, Monza",
location: MONZA,
serviceRadiusKm: 35,
certifications: ["FER", "F-GAS", "FER"],
status: "approved",
}
const put = await t.app.inject({ method: "PUT", url: "/installer/profile", headers: inst.headers, payload: body })
expect(put.statusCode).toBe(200)
expect(put.json()).toMatchObject({ businessName: "Impianti Rossi Srl", location: MONZA, serviceRadiusKm: 35, certifications: ["FER", "F-GAS"], status: "pending" })
for (const bad of [{ location: { lat: 48.85, lng: 2.35 } }, { location: { lat: 95, lng: 9 } }, { serviceRadiusKm: 0 }]) {
expect((await t.app.inject({ method: "PUT", url: "/installer/profile", headers: inst.headers, payload: { ...body, ...bad } })).statusCode).toBe(400)
}
const customer = (await createUserAndLogin(t.app, "c@example.com")).headers
expect((await t.app.inject({ method: "GET", url: "/installer/profile", headers: customer })).statusCode).toBe(403)
})
})
describe("approvazione", () => {
const PDF = Buffer.from("%PDF-1.7\n%%EOF\n")
async function installerDoc() {
const body = multipartBody({ title: "Listino tecnico", visibility: "installer" }, [{ filename: "l.pdf", contentType: "application/pdf", content: PDF }])
const res = await t.app.inject({ method: "POST", url: "/admin/documents", payload: body.payload, headers: { ...body.headers, ...admin } })
return res.json().id as string
}
const price = async (headers: Record<string, string>) =>
(await t.app.inject({ method: "GET", url: `/shop/products/${MOCK_PRODUCT.handle}`, headers })).json().variants[0].price
it("pending: niente documenti installer né prezzi riservati; dopo l'approvazione sì (stesso token); dopo il rifiuto no", async () => {
const inst = await createUserAndLogin(t.app, "inst@example.com", "installer")
await t.app.db.insert(installerProfiles).values({ userId: inst.user.id, businessName: "Impianti Srl" })
const docId = await installerDoc()
await t.app.flushSyncs()
expect((await t.app.inject({ method: "GET", url: `/documents/${docId}/download`, headers: inst.headers })).statusCode).toBe(403)
expect(await price(inst.headers)).toBe(MOCK_VARIANT.price * 100)
const approved = await t.app.inject({ method: "POST", url: `/admin/installers/${inst.user.id}/approve`, headers: admin, payload: {} })
expect(approved.statusCode).toBe(200)
expect(approved.json()).toMatchObject({ status: "approved", medusaSynced: true })
expect(approved.json().approvedAt).not.toBeNull()
const customerId = t.medusa.accountByEmail("inst@example.com")!.id
expect(t.medusa.installerGroup.has(customerId)).toBe(true)
expect(t.email.last("installer-approved")?.to).toBe("inst@example.com")
t.app.shop.clearCache()
expect((await t.app.inject({ method: "GET", url: `/documents/${docId}/download`, headers: inst.headers })).statusCode).toBe(200)
expect(await price(inst.headers)).toBe(MOCK_VARIANT.installerPrice * 100)
const rejected = await t.app.inject({
method: "POST",
url: `/admin/installers/${inst.user.id}/reject`,
headers: admin,
payload: { reason: "Certificazioni scadute" },
})
expect(rejected.json()).toMatchObject({ status: "rejected", approvedAt: null, medusaSynced: true })
expect(t.medusa.installerGroup.has(customerId)).toBe(false)
expect(t.email.last("installer-rejected")?.text).toContain("Certificazioni scadute")
t.app.shop.clearCache()
expect((await t.app.inject({ method: "GET", url: `/documents/${docId}/download`, headers: inst.headers })).statusCode).toBe(403)
expect(await price(inst.headers)).toBe(MOCK_VARIANT.price * 100)
})
it("Medusa giù all'approvazione: approvato comunque (medusaSynced false), gruppo allineato al login successivo", async () => {
const inst = await createUserAndLogin(t.app, "inst@example.com", "installer")
await t.app.db.insert(installerProfiles).values({ userId: inst.user.id, businessName: "Impianti Srl" })
await t.app.flushSyncs()
t.medusa.down = true
const res = await t.app.inject({ method: "POST", url: `/admin/installers/${inst.user.id}/approve`, headers: admin, payload: {} })
expect(res.json()).toMatchObject({ status: "approved", medusaSynced: false })
t.medusa.down = false
await t.app.inject({ method: "POST", url: "/auth/login", payload: { email: "inst@example.com", password: "Password-di-test-1" } })
await t.app.flushSyncs()
const customerId = t.medusa.accountByEmail("inst@example.com")!.id
expect(t.medusa.installerGroup.has(customerId)).toBe(true)
})
it("approvare un customer che ha un profilo lo rende installer; elenco per stato e note interne", async () => {
const c = await createUserAndLogin(t.app, "ex-cliente@example.com", "customer")
await t.app.db.insert(installerProfiles).values({ userId: c.user.id, businessName: "Nuova Ditta" })
expect((await t.app.inject({ method: "GET", url: "/admin/installers?status=pending", headers: admin })).json().total).toBe(1)
await t.app.inject({ method: "POST", url: `/admin/installers/${c.user.id}/approve`, headers: admin, payload: { notes: "Verificata visura" } })
const [u] = await t.app.db.select().from(users).where(eq(users.id, c.user.id))
expect(u!.role).toBe("installer")
const detail = (await t.app.inject({ method: "GET", url: `/admin/installers/${c.user.id}`, headers: admin })).json()
expect(detail).toMatchObject({ status: "approved", notes: "Verificata visura", email: "ex-cliente@example.com" })
expect((await t.app.inject({ method: "GET", url: "/admin/installers?status=pending", headers: admin })).json().total).toBe(0)
})
})
+398
View File
@@ -0,0 +1,398 @@
import type { FetchFn } from "../src/services/medusa-client.js"
type Customer = { id: string; email: string; first_name: string; last_name: string; company_name: string | null; has_account: boolean }
type Order = { id: string; email: string; customer_id: string }
type Cart = {
id: string
email: string | null
customer_id: string | null
items: { id: string; variant_id: string; quantity: number }[]
shipping_address: Record<string, string> | null
billing_address: Record<string, string> | null
shipping_option_id: string | null
completed_at: string | null
payment_session: boolean
metadata: Record<string, unknown> | null
}
/** Catalogo finto: un prodotto con una variante. Importi in euro IVA inclusa, come Medusa. */
export const MOCK_PRODUCT = { handle: "pompa-di-calore", title: "Pompa di calore" }
export const MOCK_VARIANT = { id: "variant_1", price: 5490, installerPrice: 4650, stock: 5 }
export const MOCK_SHIPPING = { id: "so_1", name: "Spedizione standard", amount: 15 }
const REGION = "reg_it"
/**
* Finto Medusa per i test, con stato in memoria: route /internal/* usate da apps/api e la parte di
* Store API usata dal proxy shop (regione, catalogo, carrello, spedizione, pagamento, completamento).
* `down = true` simula Medusa spento (errore di rete, come fetch su una porta chiusa).
*/
export class MedusaMock {
down = false
/** Se impostato, ogni richiesta risponde con questo status (es. 500). */
failStatus: number | undefined
/** Se false, il completamento risponde type "cart" (pagamento non autorizzato). */
paymentAuthorized = true
readonly customers = new Map<string, Customer>()
readonly orders: Order[] = []
readonly installerGroup = new Set<string>()
readonly carts = new Map<string, Cart>()
/** Ordini restituiti da GET /internal/customers/:id/orders, per customer. */
readonly customerOrderList = new Map<string, Record<string, unknown>[]>()
/** Dettagli ordine restituiti da GET /internal/orders/:id (per i webhook). */
readonly orderDetails = new Map<string, Record<string, unknown>>()
readonly calls: { method: string; path: string; headers: Record<string, string>; body: unknown }[] = []
private seq = 0
constructor(readonly internalSecret: string) {}
reset() {
this.down = false
this.failStatus = undefined
this.paymentAuthorized = true
this.customers.clear()
this.orders.length = 0
this.installerGroup.clear()
this.carts.clear()
this.orderDetails.clear()
this.customerOrderList.clear()
this.calls.length = 0
}
/** Ordine guest (fatto senza account) con l'email indicata. */
addGuestOrder(email: string): string {
const guest: Customer = { id: `cus_guest_${++this.seq}`, email, first_name: "G", last_name: "G", company_name: null, has_account: false }
this.customers.set(guest.id, guest)
const order = { id: `order_${++this.seq}`, email, customer_id: guest.id }
this.orders.push(order)
return order.id
}
ordersOf(customerId: string) {
return this.orders.filter((o) => o.customer_id === customerId).map((o) => o.id)
}
accountByEmail(email: string) {
return [...this.customers.values()].find((c) => c.has_account && c.email === email)
}
calledPaths() {
return this.calls.map((c) => `${c.method} ${c.path}`)
}
readonly fetch: FetchFn = async (input, init) => {
const url = new URL(input instanceof Request ? input.url : String(input))
const method = init?.method ?? "GET"
const headers = Object.fromEntries(Object.entries((init?.headers ?? {}) as Record<string, string>))
const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined
this.calls.push({ method, path: url.pathname, headers, body })
if (this.down) throw new TypeError("fetch failed")
if (this.failStatus) return json(this.failStatus, { type: "unexpected_state", message: "errore simulato" })
if (url.pathname.startsWith("/internal")) {
if (headers["x-internal-secret"] !== this.internalSecret) return json(401, { type: "unauthorized" })
return this.internal(method, url.pathname, body, url.searchParams)
}
if (url.pathname.startsWith("/store")) {
if (!headers["x-publishable-api-key"]) return json(400, { type: "not_allowed", message: "publishable key mancante" })
return this.store(method, url, body)
}
return json(404, { type: "not_found", message: "route non simulata" })
}
// --- /internal/* ---
private internal(method: string, path: string, body: any, query: URLSearchParams = new URLSearchParams()): Response {
const customerOrders = path.match(/^\/internal\/customers\/([^/]+)\/orders$/)
if (method === "GET" && customerOrders?.[1]) {
let all = this.customerOrderList.get(decodeURIComponent(customerOrders[1])) ?? []
const displayId = query.get("display_id")
if (displayId) all = all.filter((o) => String(o["display_id"]) === displayId)
return json(200, { orders: all, count: all.length, offset: 0, limit: 20 })
}
const orderDetail = path.match(/^\/internal\/orders\/([^/]+)$/)
if (method === "GET" && orderDetail?.[1]) {
const order = this.orderDetails.get(decodeURIComponent(orderDetail[1]))
return order ? json(200, { order }) : json(404, { type: "not_found", message: "Order not found" })
}
if (method === "POST" && path === "/internal/customers") {
const existing = this.accountByEmail(body.email)
if (existing) return json(200, { customer: existing, created: false })
const customer: Customer = { id: `cus_${++this.seq}`, has_account: true, company_name: null, ...body }
this.customers.set(customer.id, customer)
return json(201, { customer, created: true })
}
const group = path.match(/^\/internal\/customers\/([^/]+)\/groups$/)
if (method === "POST" && group?.[1]) {
const id = decodeURIComponent(group[1])
if (!this.customers.has(id)) return json(404, { type: "not_found", message: "customer non trovato" })
if (body.action === "add") this.installerGroup.add(id)
else this.installerGroup.delete(id)
return json(200, { customer_id: id, group: { id: "cusgroup_1", name: "installatori" }, member: body.action === "add" })
}
const cartCustomer = path.match(/^\/internal\/carts\/([^/]+)\/customer$/)
if (method === "POST" && cartCustomer?.[1]) {
const cart = this.carts.get(cartCustomer[1])
const customer = this.customers.get(body.customer_id)
if (!cart || !customer) return json(404, { type: "not_found", message: "non trovato" })
cart.customer_id = customer.id
cart.email = customer.email
return json(200, { cart: { id: cart.id } })
}
const cartGuest = path.match(/^\/internal\/carts\/([^/]+)\/guest$/)
if (method === "POST" && cartGuest?.[1]) {
const cart = this.carts.get(cartGuest[1])
if (!cart) return json(404, { type: "not_found", message: "non trovato" })
if (cart.customer_id && this.customers.get(cart.customer_id)?.has_account) {
return json(400, { type: "not_allowed", message: "carrello di un account" })
}
let guest = [...this.customers.values()].find((c) => !c.has_account && c.email === body.email)
if (!guest) {
guest = { id: `cus_guest_${++this.seq}`, email: body.email, first_name: "", last_name: "", company_name: null, has_account: false }
this.customers.set(guest.id, guest)
}
cart.customer_id = guest.id
cart.email = guest.email
return json(200, { cart_id: cart.id, email: guest.email })
}
if (method === "POST" && path === "/internal/prices") {
const installer = this.installerGroup.has(body.customer_id)
const prices: Record<string, unknown> = {}
for (const id of body.variant_ids as string[]) {
if (id !== MOCK_VARIANT.id) continue
// Come Medusa con un listino "override": original_amount = prezzo del listino
prices[id] = installer
? { amount: MOCK_VARIANT.installerPrice, original_amount: MOCK_VARIANT.installerPrice, price_list_type: "override" }
: { amount: MOCK_VARIANT.price, original_amount: MOCK_VARIANT.price, price_list_type: null }
}
return json(200, { prices })
}
if (method === "POST" && path === "/internal/orders/claim") {
const customer = this.customers.get(body.customer_id)
if (!customer) return json(404, { type: "not_found", message: "customer non trovato" })
if (!customer.has_account || customer.email !== body.email) return json(400, { type: "invalid_data", message: "email diversa" })
const claimed: string[] = []
for (const order of this.orders) {
const owner = this.customers.get(order.customer_id)
if (order.email === body.email && !owner?.has_account) {
order.customer_id = customer.id
claimed.push(order.id)
}
}
return json(200, { claimed, failed: [] })
}
return json(404, { type: "not_found", message: "route non simulata" })
}
// --- Store API ---
private unitPrice(cart: Cart) {
return cart.customer_id && this.installerGroup.has(cart.customer_id) ? MOCK_VARIANT.installerPrice : MOCK_VARIANT.price
}
private cartBody(cart: Cart) {
const price = this.unitPrice(cart)
const items = cart.items.map((i) => ({
id: i.id,
variant_id: i.variant_id,
product_handle: MOCK_PRODUCT.handle,
product_title: MOCK_PRODUCT.title,
title: "Standard",
variant_title: "Standard",
variant_sku: "SKU-1",
thumbnail: null,
quantity: i.quantity,
unit_price: price,
total: price * i.quantity,
}))
const itemTotal = items.reduce((n, i) => n + i.total, 0)
const shipping = cart.shipping_option_id ? MOCK_SHIPPING.amount : 0
const total = itemTotal + shipping
return {
id: cart.id,
email: cart.email,
customer_id: cart.customer_id,
completed_at: cart.completed_at,
region_id: REGION,
item_total: itemTotal,
shipping_total: shipping,
discount_total: 0,
tax_total: Math.round(((total * 22) / 122) * 100) / 100,
total,
items,
shipping_address: cart.shipping_address,
billing_address: cart.billing_address,
metadata: cart.metadata,
shipping_methods: cart.shipping_option_id
? [{ shipping_option_id: cart.shipping_option_id, name: MOCK_SHIPPING.name, amount: MOCK_SHIPPING.amount }]
: [],
}
}
private store(method: string, url: URL, body: any): Response {
const path = url.pathname
if (method === "GET" && path === "/store/regions") {
return json(200, { regions: [{ id: REGION, countries: [{ iso_2: "it" }] }] })
}
if (method === "GET" && path === "/store/product-categories") {
return json(200, {
product_categories: [{ id: "pcat_1", handle: "energia", name: "Energia", description: "", parent_category_id: null }],
})
}
if (method === "GET" && path === "/store/products") {
const handle = url.searchParams.get("handle")
const category = url.searchParams.get("category_id[]")
const product = {
id: "prod_1",
...MOCK_PRODUCT,
subtitle: null,
description: "Descrizione",
thumbnail: null,
images: [],
categories: [{ handle: "energia", name: "Energia" }],
variants: [
{
id: MOCK_VARIANT.id,
title: "Standard",
sku: "SKU-1",
manage_inventory: true,
allow_backorder: false,
inventory_quantity: MOCK_VARIANT.stock,
calculated_price: { calculated_amount: MOCK_VARIANT.price, original_amount: MOCK_VARIANT.price },
options: [{ value: "Standard", option: { title: "Modello" } }],
},
],
}
const match = (!handle || handle === MOCK_PRODUCT.handle) && (!category || category === "pcat_1")
const products = match ? [product] : []
return json(200, { products, count: products.length, offset: 0, limit: 12 })
}
if (method === "POST" && path === "/store/carts") {
const cart: Cart = {
id: `cart_${++this.seq}`,
email: null,
customer_id: null,
items: [],
shipping_address: null,
billing_address: null,
shipping_option_id: null,
completed_at: null,
payment_session: false,
metadata: null,
}
this.carts.set(cart.id, cart)
return json(200, { cart: this.cartBody(cart) })
}
const m = path.match(/^\/store\/carts\/([^/]+)(\/.*)?$/)
if (m?.[1]) {
const cart = this.carts.get(m[1])
if (!cart) return json(404, { type: "not_found", message: `Cart with id '${m[1]}' not found` })
return this.cartRoute(cart, method, m[2] ?? "", body)
}
if (method === "GET" && path === "/store/shipping-options") {
return json(200, {
shipping_options: [{ id: MOCK_SHIPPING.id, name: MOCK_SHIPPING.name, amount: MOCK_SHIPPING.amount, insufficient_inventory: false }],
})
}
if (method === "POST" && path === "/store/payment-collections") {
const cart = this.carts.get(body.cart_id)
if (!cart) return json(404, { type: "not_found", message: "cart" })
return json(200, { payment_collection: { id: `paycol_${cart.id}`, amount: this.cartBody(cart).total, payment_sessions: [] } })
}
const ps = path.match(/^\/store\/payment-collections\/paycol_([^/]+)\/payment-sessions$/)
if (method === "POST" && ps?.[1]) {
const cart = this.carts.get(ps[1])
if (!cart) return json(404, { type: "not_found", message: "collection" })
cart.payment_session = true
return json(200, {
payment_collection: {
id: `paycol_${cart.id}`,
amount: this.cartBody(cart).total,
payment_sessions: [{ provider_id: body.provider_id, data: { client_secret: `pi_secret_${cart.id}` } }],
},
})
}
return json(404, { type: "not_found", message: "route non simulata" })
}
private cartRoute(cart: Cart, method: string, rest: string, body: any): Response {
if (method === "GET" && rest === "") return json(200, { cart: this.cartBody(cart) })
if (method === "POST" && rest === "") {
if (body.shipping_address) cart.shipping_address = body.shipping_address
if (body.billing_address) cart.billing_address = body.billing_address
// Come Medusa: i metadata inviati si fondono con quelli esistenti
if (body.metadata) cart.metadata = { ...cart.metadata, ...body.metadata }
return json(200, { cart: this.cartBody(cart) })
}
if (method === "POST" && rest === "/line-items") {
if (body.variant_id !== MOCK_VARIANT.id) return json(404, { type: "not_found", message: "Variant not found" })
const existing = cart.items.find((i) => i.variant_id === body.variant_id)
const quantity = (existing?.quantity ?? 0) + body.quantity
if (quantity > MOCK_VARIANT.stock) {
return json(400, { type: "not_allowed", message: "Some variant does not have the required inventory" })
}
if (existing) existing.quantity = quantity
else cart.items.push({ id: `cali_${++this.seq}`, variant_id: body.variant_id, quantity: body.quantity })
return json(200, { cart: this.cartBody(cart) })
}
const li = rest.match(/^\/line-items\/([^/]+)$/)
if (li?.[1]) {
const item = cart.items.find((i) => i.id === li[1])
if (!item) return json(404, { type: "not_found", message: "Line item not found" })
if (method === "DELETE") {
cart.items = cart.items.filter((i) => i !== item)
return json(200, { id: item.id, deleted: true, parent: this.cartBody(cart) })
}
item.quantity = body.quantity
return json(200, { cart: this.cartBody(cart) })
}
if (method === "POST" && rest === "/shipping-methods") {
if (body.option_id !== MOCK_SHIPPING.id) return json(400, { type: "invalid_data", message: "Shipping option not valid" })
cart.shipping_option_id = body.option_id
return json(200, { cart: this.cartBody(cart) })
}
if (method === "POST" && rest === "/complete") {
if (!cart.payment_session) return json(400, { type: "invalid_data", message: "Payment sessions are required to complete cart" })
if (!this.paymentAuthorized) {
return json(200, {
type: "cart",
cart: this.cartBody(cart),
error: { message: "Payment authorization failed", type: "payment_authorization_error" },
})
}
cart.completed_at = new Date().toISOString()
const b = this.cartBody(cart)
return json(200, {
type: "order",
order: {
id: `order_${++this.seq}`,
display_id: 42,
email: cart.email,
created_at: cart.completed_at,
item_total: b.item_total,
shipping_total: b.shipping_total,
tax_total: b.tax_total,
total: b.total,
items: b.items,
metadata: cart.metadata,
},
})
}
return json(404, { type: "not_found", message: "route non simulata" })
}
}
const json = (status: number, data: unknown) =>
new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } })
+245
View File
@@ -0,0 +1,245 @@
import { eq } from "drizzle-orm"
import { z } from "zod"
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import { installerProfiles, users } from "../src/db/schema/index.js"
import { type FetchFn, MedusaClient, MedusaError } from "../src/services/medusa-client.js"
import { ensureMedusaCustomer, syncInstallerGroup } from "../src/services/medusa-sync.js"
import { hashPassword } from "../src/services/password.js"
import { buildTestApp, 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<string, unknown>) =>
t.app.inject({
method: "POST",
url: "/auth/register",
payload: { password: PASSWORD, firstName: "Mario", lastName: "Rossi", acceptPrivacy: true, ...body },
})
const login = (email: string) => t.app.inject({ method: "POST", url: "/auth/login", payload: { email, password: PASSWORD } })
const userByEmail = async (email: string) => (await db().select().from(users).where(eq(users.email, email)))[0]!
/** Registra e conferma l'email con il link ricevuto. */
async function verifyEmailOf(to: string) {
const mail = t.email.sent.filter((m) => m.to === to && m.tag === "verify-email").at(-1)
const res = await t.app.inject({ method: "POST", url: "/auth/verify-email", payload: { token: tokenFromEmail(mail?.text) } })
expect(res.statusCode).toBe(200)
await t.app.flushSyncs()
}
beforeAll(async () => {
t = await buildTestApp({}, (app) => {
app.get("/__test/medusa-down", async () => {
throw new MedusaError("unavailable", undefined, undefined, "Medusa non raggiungibile (GET /segreto)")
})
})
})
afterAll(() => t.app.close())
beforeEach(async () => {
await truncateAll()
t.email.clear()
t.medusa.reset()
})
describe("client Medusa", () => {
const ok = z.object({ ok: z.boolean() })
const client = (fetch: FetchFn, timeoutMs = 1000) =>
new MedusaClient({ baseUrl: "http://medusa.test/", publishableKey: "pk_x", internalSecret: "s".repeat(32), timeoutMs, fetch })
it("usa la publishable key per la Store API e il segreto per le route interne", async () => {
const seen: { url: string; headers: Record<string, string> }[] = []
const c = client(async (input, init) => {
seen.push({ url: String(input), headers: init?.headers as Record<string, string> })
return Response.json({ ok: true })
})
await c.store.request("/store/products", ok, { query: { limit: 5, q: undefined } })
await c.internal.request("/internal/x", ok, { body: { a: 1 } })
expect(seen[0]!.url).toBe("http://medusa.test/store/products?limit=5")
expect(seen[0]!.headers["x-publishable-api-key"]).toBe("pk_x")
expect(seen[0]!.headers["x-internal-secret"]).toBeUndefined()
expect(seen[1]!.headers["x-internal-secret"]).toBe("s".repeat(32))
expect(seen[1]!.headers["x-publishable-api-key"]).toBeUndefined()
expect(seen[1]!.headers["content-type"]).toBe("application/json")
})
it("errori uniformi: rete, timeout, 5xx → unavailable; 4xx → rejected; formato errato → invalid_response", async () => {
const fails = async (fetch: FetchFn, timeoutMs?: number) => {
const err = await client(fetch, timeoutMs).store.request("/store/x", ok).catch((e: unknown) => e)
expect(err).toBeInstanceOf(MedusaError)
return err as MedusaError
}
expect((await fails(async () => { throw new TypeError("fetch failed") })).kind).toBe("unavailable")
const hanging: FetchFn = (_input, init) =>
new Promise((_resolve, reject) => init?.signal?.addEventListener("abort", () => reject(init.signal!.reason)))
const timeout = await fails(hanging, 50)
expect(timeout.kind).toBe("unavailable")
expect(timeout.message).toContain("timeout")
const e500 = await fails(async () => Response.json({ type: "unknown_error", message: "boom" }, { status: 500 }))
expect([e500.kind, e500.status]).toEqual(["unavailable", 500])
const e404 = await fails(async () => Response.json({ type: "not_found", message: "Customer non trovato" }, { status: 404 }))
expect([e404.kind, e404.status, e404.type]).toEqual(["rejected", 404, "not_found"])
expect((await fails(async () => Response.json({ altro: 1 }))).kind).toBe("invalid_response")
})
it("un MedusaError non gestito dalla route diventa 503 senza dettagli interni", async () => {
const res = await t.app.inject({ method: "GET", url: "/__test/medusa-down" })
expect(res.statusCode).toBe(503)
expect(res.json().error.code).toBe("SHOP_UNAVAILABLE")
expect(res.body).not.toContain("segreto")
})
})
describe("sincronizzazione account", () => {
it("la registrazione crea il customer Medusa e ne salva l'id", async () => {
const res = await register({ email: "Mario@Example.com", accountType: "company", companyName: "Rossi Srl", vatNumber: "IT01234567890" })
expect(res.statusCode).toBe(202)
await t.app.flushSyncs()
const customer = t.medusa.accountByEmail("mario@example.com")
expect(customer).toMatchObject({ first_name: "Mario", last_name: "Rossi", company_name: "Rossi Srl" })
expect((await userByEmail("mario@example.com")).medusaCustomerId).toBe(customer!.id)
expect(t.medusa.calls[0]!.headers["x-internal-secret"]).toBe(t.app.config.INTERNAL_API_SECRET)
})
it("con Medusa spento la registrazione riesce e il customer viene recuperato al login", async () => {
t.medusa.down = true
expect((await register({ email: "spento@example.com" })).statusCode).toBe(202)
await t.app.flushSyncs()
expect((await userByEmail("spento@example.com")).medusaCustomerId).toBeNull()
expect(t.logs.text).toContain("sincronizzazione Medusa non riuscita")
// Ancora spento: il login non fallisce
expect((await login("spento@example.com")).statusCode).toBe(200)
await t.app.flushSyncs()
expect((await userByEmail("spento@example.com")).medusaCustomerId).toBeNull()
t.medusa.down = false
expect((await login("spento@example.com")).statusCode).toBe(200)
await t.app.flushSyncs()
const customer = t.medusa.accountByEmail("spento@example.com")
expect((await userByEmail("spento@example.com")).medusaCustomerId).toBe(customer!.id)
})
it("ensureMedusaCustomer è idempotente e non richiama Medusa se l'id c'è già", async () => {
await register({ email: "idem@example.com" })
await t.app.flushSyncs()
const user = await userByEmail("idem@example.com")
const calls = t.medusa.calls.length
expect(await ensureMedusaCustomer(t.app, user)).toBe(user.medusaCustomerId)
expect(t.medusa.calls.length).toBe(calls)
// Id perso in DB: l'upsert per email restituisce lo stesso customer
await db().update(users).set({ medusaCustomerId: null }).where(eq(users.id, user.id))
expect(await ensureMedusaCustomer(t.app, { ...user, medusaCustomerId: null })).toBe(user.medusaCustomerId)
expect(t.medusa.customers.size).toBe(1)
})
it("ensureMedusaCustomer propaga l'errore se Medusa non risponde (per le operazioni shop)", async () => {
await register({ email: "shop@example.com" })
await t.app.flushSyncs()
const user = { ...(await userByEmail("shop@example.com")), medusaCustomerId: null }
t.medusa.down = true
await expect(ensureMedusaCustomer(t.app, user)).rejects.toMatchObject({ kind: "unavailable" })
})
})
describe("ordini guest", () => {
it("un ordine guest precedente viene collegato solo dopo la verifica dell'email", async () => {
const orderId = t.medusa.addGuestOrder("cliente@example.com")
const other = t.medusa.addGuestOrder("altro@example.com")
await register({ email: "Cliente@Example.com" })
await t.app.flushSyncs()
const customerId = (await userByEmail("cliente@example.com")).medusaCustomerId!
// Email non verificata: il login non fa il claim
await login("cliente@example.com")
await t.app.flushSyncs()
expect(t.medusa.ordersOf(customerId)).toEqual([])
expect(t.medusa.calledPaths()).not.toContain("POST /internal/orders/claim")
await verifyEmailOf("cliente@example.com")
expect(t.medusa.ordersOf(customerId)).toEqual([orderId])
expect(t.medusa.ordersOf(customerId)).not.toContain(other)
const claim = t.medusa.calls.find((c) => c.path === "/internal/orders/claim")
expect(claim?.body).toEqual({ customer_id: customerId, email: "cliente@example.com" })
})
it("se Medusa è spento alla verifica, il claim viene recuperato al login successivo", async () => {
const orderId = t.medusa.addGuestOrder("tardi@example.com")
t.medusa.down = true
await register({ email: "tardi@example.com" })
await verifyEmailOf("tardi@example.com")
expect((await userByEmail("tardi@example.com")).medusaCustomerId).toBeNull()
t.medusa.down = false
await login("tardi@example.com")
await t.app.flushSyncs()
const customerId = (await userByEmail("tardi@example.com")).medusaCustomerId!
expect(t.medusa.ordersOf(customerId)).toEqual([orderId])
})
it("il reset password (che verifica l'email) collega gli ordini guest", async () => {
const orderId = t.medusa.addGuestOrder("reset@example.com")
await register({ email: "reset@example.com" })
await t.app.inject({ method: "POST", url: "/auth/forgot-password", payload: { email: "reset@example.com" } })
const res = await t.app.inject({
method: "POST",
url: "/auth/reset-password",
payload: { token: tokenFromEmail(t.email.last("reset-password")?.text), password: "Nuova-password-1" },
})
expect(res.statusCode).toBe(200)
await t.app.flushSyncs()
const customerId = (await userByEmail("reset@example.com")).medusaCustomerId!
expect(t.medusa.ordersOf(customerId)).toEqual([orderId])
})
})
describe("syncInstallerGroup", () => {
async function installer(status: "pending" | "approved" | "rejected", extra: Partial<typeof users.$inferInsert> = {}) {
const [u] = await db()
.insert(users)
.values({ email: "inst@example.com", passwordHash: await hashPassword(PASSWORD), role: "installer", firstName: "I", lastName: "N", ...extra })
.returning()
await db().insert(installerProfiles).values({ userId: u!.id, businessName: "Impianti Srl", status })
return u!
}
it("approvato → aggiunto al gruppo (creando il customer se manca); poi rifiutato → rimosso", async () => {
const user = await installer("approved")
expect(await syncInstallerGroup(t.app, user)).toEqual({ member: true })
const customerId = (await userByEmail("inst@example.com")).medusaCustomerId!
expect(t.medusa.installerGroup.has(customerId)).toBe(true)
await db().update(installerProfiles).set({ status: "rejected" }).where(eq(installerProfiles.userId, user.id))
expect(await syncInstallerGroup(t.app, await userByEmail("inst@example.com"))).toEqual({ member: false })
expect(t.medusa.installerGroup.has(customerId)).toBe(false)
})
it("in attesa o disabilitato → non membro", async () => {
const user = await installer("pending")
expect(await syncInstallerGroup(t.app, user)).toEqual({ member: false })
await db().update(installerProfiles).set({ status: "approved" }).where(eq(installerProfiles.userId, user.id))
await db().update(users).set({ disabledAt: new Date() }).where(eq(users.id, user.id))
expect(await syncInstallerGroup(t.app, await userByEmail("inst@example.com"))).toEqual({ member: false })
expect(t.medusa.installerGroup.size).toBe(0)
})
it("propaga l'errore se Medusa non risponde", async () => {
const user = await installer("approved")
t.medusa.failStatus = 500
await expect(syncInstallerGroup(t.app, user)).rejects.toBeInstanceOf(MedusaError)
})
})
+239
View File
@@ -0,0 +1,239 @@
import { rm } from "node:fs/promises"
import { eq } from "drizzle-orm"
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import { documents, quoteRequests, quotes } from "../src/db/schema/index.js"
import { todayRome } from "../src/services/quotes.js"
import { buildTestApp, createUserAndLogin, multipartBody, TEST_UPLOAD_DIR, type TestApp, tokenFromEmail, truncateAll } from "./helpers.js"
const PDF = Buffer.from("%PDF-1.7\n1 0 obj << >> endobj\n%%EOF\n")
const YEAR = todayRome().slice(0, 4)
let t: TestApp
let admin: Record<string, string>
let mario: { user: { id: string; email: string }; headers: Record<string, string> }
let luigi: { user: { id: string; email: string }; headers: Record<string, string> }
const call = (method: "GET" | "POST" | "PATCH", url: string, headers: Record<string, string>, payload?: object) =>
t.app.inject({ method, url, headers, ...(payload ? { payload } : {}) })
async function newQuote(userId: string, extra: Record<string, unknown> = {}) {
const res = await call("POST", "/admin/quotes", admin, { userId, title: "Impianto FV 6 kW", amountCents: 990000, validUntil: null, ...extra })
expect(res.statusCode, res.body).toBe(201)
return res.json()
}
async function sentQuote(userId: string, extra: Record<string, unknown> = {}) {
const q = await newQuote(userId, extra)
const sent = await call("POST", `/admin/quotes/${q.id}/send`, admin)
expect(sent.statusCode, sent.body).toBe(200)
return sent.json()
}
beforeAll(async () => {
t = await buildTestApp()
})
afterAll(async () => {
await t.app.close()
await rm(TEST_UPLOAD_DIR, { recursive: true, force: true })
})
beforeEach(async () => {
await truncateAll()
t.email.clear()
admin = (await createUserAndLogin(t.app, "admin@iti.local", "admin")).headers
mario = await createUserAndLogin(t.app, "mario@example.com")
luigi = await createUserAndLogin(t.app, "luigi@example.com")
})
describe("numerazione", () => {
it("progressiva per anno: 2026-0001, 2026-0002…", async () => {
const a = await newQuote(mario.user.id)
const b = await newQuote(luigi.user.id)
expect([a.number, b.number]).toEqual([`${YEAR}-0001`, `${YEAR}-0002`])
expect(a.status).toBe("draft")
})
it("nessun duplicato con 15 creazioni concorrenti", async () => {
const results = await Promise.all(
Array.from({ length: 15 }, (_, i) =>
call("POST", "/admin/quotes", admin, { userId: i % 2 ? mario.user.id : luigi.user.id, title: `Preventivo ${i}`, amountCents: 1000 * i, validUntil: null }),
),
)
expect(results.map((r) => r.statusCode)).toEqual(Array(15).fill(201))
const numbers = results.map((r) => r.json().number).sort()
expect(new Set(numbers).size).toBe(15)
expect(numbers).toEqual(Array.from({ length: 15 }, (_, i) => `${YEAR}-${String(i + 1).padStart(4, "0")}`))
})
})
describe("ownership", () => {
it("un cliente non può leggere né rispondere ai preventivi di un altro (404); le bozze non sono visibili", async () => {
const draft = await newQuote(mario.user.id)
const sent = await sentQuote(mario.user.id)
const own = await call("GET", "/me/quotes", mario.headers)
expect(own.json().items.map((q: { id: string }) => q.id)).toEqual([sent.id])
expect((await call("GET", `/me/quotes/${draft.id}`, mario.headers)).statusCode).toBe(404)
expect((await call("GET", `/me/quotes/${sent.id}`, mario.headers)).statusCode).toBe(200)
expect((await call("GET", "/me/quotes", luigi.headers)).json().total).toBe(0)
for (const url of [`/me/quotes/${sent.id}`, `/me/quotes/${sent.id}/pdf`]) {
expect((await call("GET", url, luigi.headers)).statusCode, url).toBe(404)
}
for (const action of ["accept", "reject"]) {
expect((await call("POST", `/me/quotes/${sent.id}/${action}`, luigi.headers)).statusCode).toBe(404)
}
expect((await call("GET", `/me/quotes/${sent.id}`, {})).statusCode).toBe(401)
})
it("le route admin non sono accessibili ai clienti", async () => {
expect((await call("GET", "/admin/quotes", mario.headers)).statusCode).toBe(403)
expect((await call("GET", "/admin/quote-requests", mario.headers)).statusCode).toBe(403)
})
})
describe("ciclo di vita", () => {
it("invio con email, accettazione una sola volta, notifica allo staff", async () => {
const q = await sentQuote(mario.user.id, { validUntil: "2099-12-31" })
expect(q.status).toBe("sent")
expect(q.sentAt).not.toBeNull()
const mail = t.email.last("quote-sent")
expect(mail?.to).toBe("mario@example.com")
expect(mail?.text).toContain(q.number)
expect(mail?.text).toMatch(/9\.900,00\s€/)
expect(mail?.text).toContain(`/preventivi/${q.id}`)
const accepted = await call("POST", `/me/quotes/${q.id}/accept`, mario.headers)
expect(accepted.json().status).toBe("accepted")
expect(t.email.last("staff-notification")?.subject).toContain("accettato")
expect((await call("POST", `/me/quotes/${q.id}/reject`, mario.headers)).json().error.code).toBe("QUOTE_NOT_PENDING")
expect((await call("POST", `/admin/quotes/${q.id}/send`, admin)).statusCode).toBe(409)
})
it("preventivo scaduto: mostrato come expired, non accettabile", async () => {
const q = await sentQuote(mario.user.id)
await t.app.db.update(quotes).set({ validUntil: "2020-01-01" }).where(eq(quotes.id, q.id))
expect((await call("GET", `/me/quotes/${q.id}`, mario.headers)).json().status).toBe("expired")
const res = await call("POST", `/me/quotes/${q.id}/accept`, mario.headers)
expect(res.statusCode).toBe(409)
expect(res.json().error.code).toBe("QUOTE_EXPIRED")
const [row] = await t.app.db.select().from(quotes).where(eq(quotes.id, q.id))
expect(row!.status).toBe("expired")
})
it("modificabile solo in bozza; PATCH parziale non tocca gli altri campi", async () => {
const q = await newQuote(mario.user.id, { validUntil: "2099-01-31" })
const patched = await call("PATCH", `/admin/quotes/${q.id}`, admin, { amountCents: 1000000 })
expect(patched.json()).toMatchObject({ amountCents: 1000000, title: "Impianto FV 6 kW", validUntil: "2099-01-31" })
await call("POST", `/admin/quotes/${q.id}/send`, admin)
expect((await call("PATCH", `/admin/quotes/${q.id}`, admin, { title: "Nuovo" })).json().error.code).toBe("QUOTE_NOT_DRAFT")
})
it("PDF: allegato come documento privato admin, scaricabile solo dal proprietario", async () => {
const q = await newQuote(mario.user.id)
const upload = (content: Buffer) => {
const body = multipartBody({}, [{ filename: "preventivo.pdf", contentType: "application/pdf", content }])
return t.app.inject({ method: "POST", url: `/admin/quotes/${q.id}/pdf`, payload: body.payload, headers: { ...body.headers, ...admin } })
}
const first = await upload(PDF)
expect(first.statusCode, first.body).toBe(200)
expect(first.json().hasPdf).toBe(true)
const firstDoc = first.json().pdfDocumentId
// Sostituzione: il documento precedente viene eliminato
const second = await upload(Buffer.concat([PDF, Buffer.from("% v2\n")]))
expect(second.json().pdfDocumentId).not.toBe(firstDoc)
const docs = await t.app.db.select().from(documents)
expect(docs).toHaveLength(1)
expect(docs[0]).toMatchObject({ visibility: "admin", category: "preventivi", title: `Preventivo ${q.number}` })
await call("POST", `/admin/quotes/${q.id}/send`, admin)
const dl = await call("GET", `/me/quotes/${q.id}/pdf`, mario.headers)
expect(dl.statusCode).toBe(200)
expect(dl.headers["content-disposition"]).toContain(`Preventivo ${q.number}.pdf`)
expect(dl.headers["cache-control"]).toBe("private, no-store")
// Il documento non è raggiungibile dal download generico per un cliente
expect((await call("GET", `/documents/${docs[0]!.id}/download`, mario.headers)).statusCode).toBe(403)
expect((await call("GET", `/me/quotes/${q.id}/pdf`, luigi.headers)).statusCode).toBe(404)
})
})
describe("richieste di preventivo", () => {
const answers = { percorso: "acqua", acqua_persone: 3, acqua_durezza_nota: "no", acqua_abitazione: "casa" }
const submit = (email: string) =>
t.app.inject({
method: "POST",
url: "/configurator/submit",
payload: { answers, contact: { name: "Anonimo", email }, acceptPrivacy: true },
})
it("elenco, filtro, cambio stato; preventivo da richiesta di un account esistente", async () => {
const req = (await submit("mario@example.com")).json()
const list = await call("GET", "/admin/quote-requests?status=new&q=mario", admin)
expect(list.json().items.map((r: { id: string }) => r.id)).toEqual([req.id])
const patched = await call("PATCH", `/admin/quote-requests/${req.id}`, admin, { status: "contacted" })
expect(patched.json().status).toBe("contacted")
const q = await newQuote(undefined as never, { userId: undefined, quoteRequestId: req.id })
expect(q.userId).toBe(mario.user.id) // trovato per email; la richiesta viene collegata
await call("POST", `/admin/quotes/${q.id}/send`, admin)
const detail = (await call("GET", `/admin/quote-requests/${req.id}`, admin)).json()
expect(detail).toMatchObject({ userId: mario.user.id, status: "converted" })
expect(detail.quotes.map((x: { id: string }) => x.id)).toEqual([q.id])
})
it("richiesta anonima senza account → 409 CUSTOMER_ACCOUNT_REQUIRED", async () => {
const req = (await submit("nessuno@example.com")).json()
const res = await call("POST", "/admin/quotes", admin, { quoteRequestId: req.id, title: "Addolcitore", amountCents: 150000, validUntil: null })
expect(res.statusCode).toBe(409)
expect(res.json().error.code).toBe("CUSTOMER_ACCOUNT_REQUIRED")
})
it("le richieste anonime vengono collegate all'account alla verifica dell'email, non alla sola registrazione", async () => {
await submit("nuovo@example.com")
await t.app.inject({
method: "POST",
url: "/auth/register",
payload: { email: "nuovo@example.com", password: "Password-di-test-1", firstName: "N", lastName: "U", acceptPrivacy: true },
})
const [before] = await t.app.db.select().from(quoteRequests)
expect(before!.userId).toBeNull()
const token = tokenFromEmail(t.email.last("verify-email")?.text)
await t.app.inject({ method: "POST", url: "/auth/verify-email", payload: { token } })
const [after] = await t.app.db.select().from(quoteRequests)
expect(after!.userId).not.toBeNull()
await t.app.flushSyncs()
})
})
describe("ordini", () => {
it("GET /me/orders legge gli ordini del proprio customer Medusa (centesimi)", async () => {
// Il customer Medusa viene creato al primo accesso agli ordini
await t.app.inject({ method: "GET", url: "/me/orders", headers: mario.headers })
const customerId = t.medusa.accountByEmail("mario@example.com")!.id
t.medusa.customerOrderList.set(customerId, [
{
id: "order_1",
display_id: 12,
status: "pending",
created_at: "2026-10-01T10:00:00.000Z",
payment_status: "captured",
fulfillment_status: "not_fulfilled",
item_total: 1190,
shipping_total: 15,
tax_total: 217.29,
total: 1205,
items: [{ title: "Addolcitore", variant_sku: "ADD-20", thumbnail: null, quantity: 1, unit_price: 1190, total: 1190 }],
},
])
const res = await t.app.inject({ method: "GET", url: "/me/orders", headers: mario.headers })
expect(res.json()).toMatchObject({
total: 1,
items: [{ number: 12, paymentStatus: "captured", totals: { total: 120500, tax: 21729 }, items: [{ sku: "ADD-20", unitPrice: 119000 }] }],
})
expect(res.body).not.toContain("order_1")
// Luigi non vede gli ordini di Mario
expect((await t.app.inject({ method: "GET", url: "/me/orders", headers: luigi.headers })).json().total).toBe(0)
})
})
+324
View File
@@ -0,0 +1,324 @@
import { eq } from "drizzle-orm"
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import { consents, installerProfiles, users } from "../src/db/schema/index.js"
import { CART_COOKIE } from "../src/services/shop.js"
import { hashPassword } from "../src/services/password.js"
import { buildTestApp, type TestApp, truncateAll } from "./helpers.js"
import { MOCK_PRODUCT, MOCK_SHIPPING, MOCK_VARIANT } from "./medusa-mock.js"
const PASSWORD = "Password-di-test-1"
const PRICE = MOCK_VARIANT.price * 100
const INSTALLER_PRICE = MOCK_VARIANT.installerPrice * 100
let t: TestApp
/** Client di test con cookie jar e access token facoltativo. */
class Browser {
jar: Record<string, string> = {}
token: string | undefined
async call(method: "GET" | "POST" | "PATCH" | "DELETE", url: string, payload?: Record<string, unknown>) {
const res = await t.app.inject({
method,
url,
cookies: this.jar,
headers: this.token ? { authorization: `Bearer ${this.token}` } : {},
...(payload ? { payload } : {}),
})
for (const c of res.cookies as { name: string; value: string; maxAge?: number; expires?: Date }[]) {
const expired = c.maxAge === 0 || (c.expires !== undefined && c.expires.getTime() <= Date.now()) || c.value === ""
if (expired) delete this.jar[c.name]
else this.jar[c.name] = c.value
}
return res
}
async login(email: string) {
const res = await this.call("POST", "/auth/login", { email, password: PASSWORD })
expect(res.statusCode).toBe(200)
this.token = res.json().accessToken
return res
}
}
async function createUser(email: string, role: "customer" | "installer" = "customer", installerStatus?: "pending" | "approved") {
const [u] = await t.app.db
.insert(users)
.values({ email, passwordHash: await hashPassword(PASSWORD), role, firstName: "Ina", lastName: "Stall", emailVerifiedAt: new Date() })
.returning()
if (installerStatus) {
await t.app.db.insert(installerProfiles).values({ userId: u!.id, businessName: "Impianti Srl", status: installerStatus })
}
return u!
}
/** Installatore approvato con customer Medusa già nel gruppo installatori. */
async function approvedInstaller(email = "inst@example.com") {
const u = await createUser(email, "installer", "approved")
const res = await t.medusa.fetch(`${t.app.config.MEDUSA_URL}/internal/customers`, {
method: "POST",
headers: { "x-internal-secret": t.app.config.INTERNAL_API_SECRET },
body: JSON.stringify({ email, first_name: "Ina", last_name: "Stall" }),
})
const customerId = ((await res.json()) as { customer: { id: string } }).customer.id
await t.app.db.update(users).set({ medusaCustomerId: customerId }).where(eq(users.id, u.id))
t.medusa.installerGroup.add(customerId)
return { user: u, customerId }
}
const address = { firstName: "Mario", lastName: "Rossi", address1: "Via Roma 1", city: "Milano", postalCode: "20100", province: "MI" }
beforeAll(async () => {
t = await buildTestApp()
})
afterAll(() => t.app.close())
beforeEach(async () => {
await truncateAll()
t.medusa.reset()
t.app.shop.clearCache()
})
describe("catalogo", () => {
it("lista e dettaglio con prezzi standard in centesimi, senza id prodotto Medusa", async () => {
const b = new Browser()
const list = await b.call("GET", "/shop/products?limit=5")
expect(list.statusCode).toBe(200)
expect(list.json()).toMatchObject({ total: 1, limit: 5, offset: 0 })
expect(list.json().items[0]).toEqual({
handle: MOCK_PRODUCT.handle,
title: MOCK_PRODUCT.title,
subtitle: null,
thumbnail: null,
fromPrice: PRICE,
fromOriginalPrice: PRICE,
priceType: "standard",
categories: [{ handle: "energia", name: "Energia" }],
})
expect(list.body).not.toContain("prod_1")
const detail = await b.call("GET", `/shop/products/${MOCK_PRODUCT.handle}`)
expect(detail.json().variants[0]).toMatchObject({ id: MOCK_VARIANT.id, price: PRICE, originalPrice: PRICE, inStock: true })
expect((await b.call("GET", "/shop/products/inesistente")).json().error.code).toBe("PRODUCT_NOT_FOUND")
})
it("filtra per categoria (handle) e restituisce le categorie", async () => {
const b = new Browser()
expect((await b.call("GET", "/shop/products?category=energia")).json().total).toBe(1)
expect((await b.call("GET", "/shop/products?category=altro")).json()).toMatchObject({ items: [], total: 0 })
expect((await b.call("GET", "/shop/categories")).json()).toEqual([
{ handle: "energia", name: "Energia", description: null, parentHandle: null },
])
})
it("le liste sono in cache (una sola chiamata a Medusa per pagina)", async () => {
const b = new Browser()
await b.call("GET", "/shop/products?limit=5")
await b.call("GET", "/shop/products?limit=5")
expect(t.medusa.calledPaths().filter((p) => p === "GET /store/products")).toHaveLength(1)
})
it("installatore approvato vede il listino riservato; cliente e installatore in attesa no", async () => {
await approvedInstaller()
const inst = new Browser()
await inst.login("inst@example.com")
const detail = (await inst.call("GET", `/shop/products/${MOCK_PRODUCT.handle}`)).json()
expect(detail.priceType).toBe("installer")
expect(detail.variants[0]).toMatchObject({ price: INSTALLER_PRICE, originalPrice: PRICE })
expect((await inst.call("GET", "/shop/products")).json().items[0]).toMatchObject({
fromPrice: INSTALLER_PRICE,
fromOriginalPrice: PRICE,
priceType: "installer",
})
await createUser("cliente@example.com")
await createUser("attesa@example.com", "installer", "pending")
for (const email of ["cliente@example.com", "attesa@example.com"]) {
const b = new Browser()
await b.login(email)
expect((await b.call("GET", `/shop/products/${MOCK_PRODUCT.handle}`)).json().variants[0].price).toBe(PRICE)
}
// Anonimo dopo l'installatore: la cache del catalogo non contiene prezzi riservati
expect((await new Browser().call("GET", "/shop/products")).json().items[0].fromPrice).toBe(PRICE)
})
it("token non valido → 401 (niente prezzi da anonimo in silenzio)", async () => {
const b = new Browser()
b.token = "non.valido.token"
expect((await b.call("GET", "/shop/products")).statusCode).toBe(401)
})
})
describe("carrello guest e checkout", () => {
it("flusso completo: righe, indirizzo con email, spedizione, pagamento, ordine", async () => {
const b = new Browser()
expect((await b.call("GET", "/shop/cart")).statusCode).toBe(404)
const created = await b.call("POST", "/shop/cart")
expect(created.statusCode).toBe(201)
expect(b.jar[CART_COOKIE]).toBeDefined()
expect(created.headers["set-cookie"]).toMatch(/HttpOnly/i)
expect((await b.call("POST", "/shop/cart")).statusCode).toBe(200) // stesso carrello
let cart = (await b.call("POST", "/shop/cart/items", { variantId: MOCK_VARIANT.id, quantity: 2 })).json()
expect(cart).toMatchObject({ itemCount: 2, readyForPayment: false })
expect(cart.items[0]).toMatchObject({ unitPrice: PRICE, total: 2 * PRICE, productHandle: MOCK_PRODUCT.handle })
expect(JSON.stringify(cart)).not.toMatch(/cart_\d|cus_|reg_it/)
cart = (await b.call("PATCH", `/shop/cart/items/${cart.items[0].id}`, { quantity: 1 })).json()
expect(cart.itemCount).toBe(1)
expect((await b.call("POST", "/shop/cart/payment")).json().error.code).toBe("CART_NOT_READY")
const noEmail = await b.call("POST", "/shop/cart/address", { shippingAddress: address, acceptPrivacy: true })
expect(noEmail.json().error.code).toBe("EMAIL_REQUIRED")
expect((await b.call("POST", "/shop/cart/address", { email: "x@example.com", shippingAddress: { ...address, postalCode: "abc" }, acceptPrivacy: true })).statusCode).toBe(400)
cart = (await b.call("POST", "/shop/cart/address", { email: " Guest@Example.com ", shippingAddress: address, acceptPrivacy: true })).json()
expect(cart.email).toBe("guest@example.com")
expect(cart.shippingAddress).toMatchObject({ address1: "Via Roma 1", countryCode: "it" })
expect(cart.billingAddress).toMatchObject({ address1: "Via Roma 1" })
expect(t.medusa.calledPaths()).toContain(`POST /internal/carts/${[...t.medusa.carts.keys()][0]}/guest`)
const options = (await b.call("GET", "/shop/cart/shipping-options")).json()
expect(options).toEqual([{ id: MOCK_SHIPPING.id, name: MOCK_SHIPPING.name, amount: MOCK_SHIPPING.amount * 100 }])
cart = (await b.call("POST", "/shop/cart/shipping", { optionId: MOCK_SHIPPING.id })).json()
expect(cart).toMatchObject({ readyForPayment: true, totals: { total: PRICE + 1500, shipping: 1500 } })
const pay = await b.call("POST", "/shop/cart/payment")
expect(pay.json()).toEqual({ provider: "stripe", clientSecret: expect.stringMatching(/^pi_secret_/), amount: PRICE + 1500 })
expect(t.medusa.calls.find((c) => c.path.endsWith("/payment-sessions"))?.body).toEqual({ provider_id: "pp_stripe_stripe" })
const order = await b.call("POST", "/shop/cart/complete")
expect(order.statusCode).toBe(200)
expect(order.json()).toMatchObject({ number: 42, email: "guest@example.com", totals: { total: PRICE + 1500 } })
expect(b.jar[CART_COOKIE]).toBeUndefined()
})
it("pagamento non autorizzato → 402 e il carrello resta", async () => {
const b = new Browser()
await b.call("POST", "/shop/cart/items", { variantId: MOCK_VARIANT.id, quantity: 1 })
await b.call("POST", "/shop/cart/address", { email: "g@example.com", shippingAddress: address, acceptPrivacy: true })
await b.call("POST", "/shop/cart/shipping", { optionId: MOCK_SHIPPING.id })
await b.call("POST", "/shop/cart/payment")
t.medusa.paymentAuthorized = false
const res = await b.call("POST", "/shop/cart/complete")
expect(res.statusCode).toBe(402)
expect(res.json().error.code).toBe("PAYMENT_NOT_COMPLETED")
expect(b.jar[CART_COOKIE]).toBeDefined()
})
it("errori Medusa tradotti: giacenza, riga inesistente, Medusa giù", async () => {
const b = new Browser()
const stock = await b.call("POST", "/shop/cart/items", { variantId: MOCK_VARIANT.id, quantity: 99 })
expect(stock.statusCode).toBe(409)
expect(stock.json().error.code).toBe("OUT_OF_STOCK")
expect(stock.body).not.toContain("variant")
expect((await b.call("DELETE", "/shop/cart/items/cali_inesistente")).json().error.code).toBe("CART_ITEM_NOT_FOUND")
t.medusa.down = true
expect((await b.call("GET", "/shop/cart")).json().error.code).toBe("SHOP_UNAVAILABLE")
})
it("cookie manomesso o carrello inesistente → nessun carrello e cookie cancellato", async () => {
const b = new Browser()
b.jar[CART_COOKIE] = "cart_falso"
expect((await b.call("GET", "/shop/cart")).statusCode).toBe(404)
expect(t.medusa.calledPaths().some((p) => p.startsWith("GET /store/carts/"))).toBe(false)
expect(b.jar[CART_COOKIE]).toBeUndefined()
})
it("checkout: consenso privacy obbligatorio, dati per la fattura nei metadata, consensi senza duplicati", async () => {
const b = new Browser()
await b.call("POST", "/shop/cart/items", { variantId: MOCK_VARIANT.id, quantity: 1 })
const body = { email: "azienda@example.com", shippingAddress: address }
const noPrivacy = await b.call("POST", "/shop/cart/address", body)
expect(noPrivacy.statusCode).toBe(400)
expect(noPrivacy.json().error.details[0].path).toBe("body.acceptPrivacy")
const badVat = await b.call("POST", "/shop/cart/address", {
...body,
acceptPrivacy: true,
invoice: { companyName: "Impianti Srl", vatNumber: "01234567890" },
})
expect(badVat.json().error.details[0].path).toBe("body.invoice.vatNumber")
const invoice = { companyName: "Impianti Srl", vatNumber: "IT 00743110157", sdiCode: "abc1234", pec: " Fatture@PEC.it " }
let cart = (await b.call("POST", "/shop/cart/address", { ...body, acceptPrivacy: true, acceptMarketing: true, invoice })).json()
expect(cart.invoice).toEqual({ companyName: "Impianti Srl", vatNumber: "00743110157", fiscalCode: null, sdiCode: "ABC1234", pec: "fatture@pec.it" })
const cartId = [...t.medusa.carts.keys()][0]!
expect(t.medusa.carts.get(cartId)?.metadata).toMatchObject({ invoice: { vatNumber: "00743110157" } })
// Reinvio senza fattura: i dati vengono tolti, i consensi non si duplicano
cart = (await b.call("POST", "/shop/cart/address", { ...body, acceptPrivacy: true, acceptMarketing: true })).json()
expect(cart.invoice).toBeNull()
const rows = await t.app.db.select().from(consents).where(eq(consents.email, "azienda@example.com"))
expect(rows.map((r) => `${r.type}:${r.source}`).sort()).toEqual(["marketing:checkout", "privacy:checkout"])
})
})
describe("carrello e account", () => {
it("al login il carrello guest viene associato e riprezzato col listino installatori", async () => {
const { customerId } = await approvedInstaller()
const b = new Browser()
const guest = (await b.call("POST", "/shop/cart/items", { variantId: MOCK_VARIANT.id, quantity: 1 })).json()
expect(guest.items[0].unitPrice).toBe(PRICE)
await b.login("inst@example.com")
const cartId = [...t.medusa.carts.keys()][0]!
expect(t.medusa.carts.get(cartId)?.customer_id).toBe(customerId)
const cart = (await b.call("GET", "/shop/cart")).json()
expect(cart).toMatchObject({ email: "inst@example.com" })
expect(cart.items[0].unitPrice).toBe(INSTALLER_PRICE)
})
it("utente autenticato: nuovo carrello già suo, email dell'account, niente email nel body", async () => {
await createUser("cliente@example.com")
const b = new Browser()
await b.login("cliente@example.com")
await b.call("POST", "/shop/cart/items", { variantId: MOCK_VARIANT.id, quantity: 1 })
const cart = (await b.call("POST", "/shop/cart/address", { shippingAddress: address, acceptPrivacy: true })).json()
expect(cart.email).toBe("cliente@example.com")
expect(t.medusa.calledPaths().some((p) => p.endsWith("/guest"))).toBe(false)
})
it("il carrello di un account non è accessibile da anonimo né da un altro utente", async () => {
await createUser("a@example.com")
await createUser("b@example.com")
const a = new Browser()
await a.login("a@example.com")
await a.call("POST", "/shop/cart/items", { variantId: MOCK_VARIANT.id, quantity: 1 })
const stolen = a.jar[CART_COOKIE]!
const anon = new Browser()
anon.jar[CART_COOKIE] = stolen
expect((await anon.call("GET", "/shop/cart")).statusCode).toBe(404)
const other = new Browser()
await other.login("b@example.com")
other.jar[CART_COOKIE] = stolen
expect((await other.call("GET", "/shop/cart")).statusCode).toBe(404)
// B non ha "preso" il carrello di A
expect((await a.call("GET", "/shop/cart")).json().itemCount).toBe(1)
})
it("il logout cancella il cookie del carrello", async () => {
await createUser("cliente@example.com")
const b = new Browser()
await b.login("cliente@example.com")
await b.call("POST", "/shop/cart")
expect(b.jar[CART_COOKIE]).toBeDefined()
await b.call("POST", "/auth/logout")
expect(b.jar[CART_COOKIE]).toBeUndefined()
})
it("login con Medusa giù: il login riesce comunque", async () => {
await createUser("cliente@example.com")
const b = new Browser()
await b.call("POST", "/shop/cart")
t.medusa.down = true
expect((await b.call("POST", "/auth/login", { email: "cliente@example.com", password: PASSWORD })).statusCode).toBe(200)
await t.app.flushSyncs()
})
})
+190
View File
@@ -0,0 +1,190 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import { consents, showroomBookings } from "../src/db/schema/index.js"
import { createFormToken } from "../src/services/form-guard.js"
import { SHOWROOM, type ShowroomConfig } from "../src/showroom/config.js"
import { bookableSlots, easterSunday, findBookableSlot, slotsOfDay, zonedTime } from "../src/showroom/slots.js"
import { buildTestApp, createUserAndLogin, type TestApp, truncateAll } from "./helpers.js"
let t: TestApp
let ipSeq = 0
/** Token emesso 10 secondi fa (supera il tempo minimo di compilazione). */
const formToken = () => createFormToken("c".repeat(32), new Date(Date.now() - 10_000)).token
const book = (payload: Record<string, unknown>, headers: Record<string, string> = {}) =>
t.app.inject({
method: "POST",
url: "/showroom/bookings",
headers,
// IP diverso a ogni richiesta: il rate limit della route (5 ogni 15 minuti) non interferisce
remoteAddress: `10.0.0.${++ipSeq % 250}`,
payload: { name: "Mario Rossi", email: "Mario@Example.com", acceptPrivacy: true, formToken: formToken(), people: 1, ...payload },
})
/** Primo slot prenotabile secondo GET /showroom/slots. */
async function firstSlot() {
const res = await t.app.inject({ method: "GET", url: "/showroom/slots?days=31" })
expect(res.statusCode).toBe(200)
const slot = res.json().days[0]?.slots[0]
expect(slot).toBeDefined()
return slot as { start: string; end: string; available: number }
}
beforeAll(async () => {
t = await buildTestApp()
})
afterAll(() => t.app.close())
beforeEach(async () => {
await truncateAll()
t.email.clear()
})
describe("slot dello showroom (funzioni pure)", () => {
const cfg: ShowroomConfig = { ...SHOWROOM, closedDates: ["2026-10-09"] }
it("orari di un giorno feriale, del sabato e della domenica (ora legale e solare)", () => {
// lunedì 5 ottobre 2026 (ora legale, UTC+2): 9-12 e 14-18 → 3 + 4 slot da un'ora
const monday = slotsOfDay(cfg, "2026-10-05")
expect(monday).toHaveLength(7)
expect(monday[0]!.start.toISOString()).toBe("2026-10-05T07:00:00.000Z")
expect(monday[3]!.start.toISOString()).toBe("2026-10-05T12:00:00.000Z")
expect(monday[6]!.end.toISOString()).toBe("2026-10-05T16:00:00.000Z")
// sabato 7 novembre 2026 (ora solare, UTC+1): solo mattina
const saturday = slotsOfDay(cfg, "2026-11-07")
expect(saturday.map((s) => s.start.toISOString())).toEqual([
"2026-11-07T08:00:00.000Z",
"2026-11-07T09:00:00.000Z",
"2026-11-07T10:00:00.000Z",
])
expect(slotsOfDay(cfg, "2026-10-04")).toEqual([]) // domenica
})
it("cambio d'ora: lunedì 26 ottobre 2026 è già in ora solare", () => {
expect(zonedTime("2026-10-24", "09:00", "Europe/Rome").toISOString()).toBe("2026-10-24T07:00:00.000Z")
expect(zonedTime("2026-10-26", "09:00", "Europe/Rome").toISOString()).toBe("2026-10-26T08:00:00.000Z")
expect(zonedTime("2026-03-30", "09:00", "Europe/Rome").toISOString()).toBe("2026-03-30T07:00:00.000Z")
})
it("chiusure: festività fisse, Pasquetta, date straordinarie", () => {
expect(easterSunday(2026)).toBe("2026-04-05")
expect(easterSunday(2027)).toBe("2027-03-28")
expect(slotsOfDay(cfg, "2026-04-06")).toEqual([]) // Pasquetta
expect(slotsOfDay(cfg, "2026-12-08")).toEqual([]) // Immacolata (martedì)
expect(slotsOfDay(cfg, "2026-10-09")).toEqual([]) // chiusura straordinaria (venerdì)
expect(slotsOfDay(cfg, "2026-10-08")).toHaveLength(7)
})
it("preavviso minimo e orizzonte di prenotazione", () => {
const now = new Date("2026-10-05T10:30:00Z") // lunedì 12:30 a Roma
const days = bookableSlots(cfg, now, "2026-10-05", 3)
// lunedì escluso (preavviso 24 h); martedì dalle 14:00 (12:30 + 24 h → primo slot alle 14)
expect(days.map((d) => d.date)).toEqual(["2026-10-06", "2026-10-07"])
expect(days[0]!.slots[0]!.start.toISOString()).toBe("2026-10-06T12:00:00.000Z")
expect(findBookableSlot(cfg, now, new Date("2026-10-06T12:00:00Z"))).not.toBeNull()
expect(findBookableSlot(cfg, now, new Date("2026-10-06T09:00:00Z"))).toBeNull() // entro il preavviso
expect(findBookableSlot(cfg, now, new Date("2026-10-06T12:30:00Z"))).toBeNull() // non è un inizio di slot
expect(findBookableSlot(cfg, now, new Date("2026-10-06T17:00:00Z"))).toBeNull() // dopo la chiusura
expect(findBookableSlot(cfg, now, new Date("2026-12-07T08:00:00Z"))).toBeNull() // oltre 60 giorni
})
})
describe("prenotazioni showroom", () => {
it("prenotazione confermata con email, consensi e notifica allo staff; posti scalati", async () => {
await createUserAndLogin(t.app, "admin@iti.local", "admin")
t.email.clear()
const slot = await firstSlot()
expect(slot.available).toBe(SHOWROOM.capacity)
const res = await book({ requestedAt: slot.start, people: 2, acceptMarketing: true, notes: "Interessati alle pompe di calore" })
expect(res.statusCode, res.body).toBe(201)
expect(res.json()).toMatchObject({ email: "mario@example.com", people: 2, status: "confirmed", requestedAt: slot.start })
expect(t.email.last("showroom-booking-confirmed")?.to).toBe("mario@example.com")
expect(t.email.last("staff-notification")?.to).toBe("admin@iti.local")
const saved = await t.app.db.select().from(consents)
expect(saved.map((c) => [c.type, c.source, c.email]).sort()).toEqual([
["marketing", "showroom", "mario@example.com"],
["privacy", "showroom", "mario@example.com"],
])
expect((await firstSlot()).available).toBe(SHOWROOM.capacity - 2)
})
it("oltre la capienza la seconda prenotazione è rifiutata (409 SLOT_FULL)", async () => {
const slot = await firstSlot()
expect((await book({ requestedAt: slot.start, people: 4 })).statusCode).toBe(201)
const second = await book({ requestedAt: slot.start, people: 3 })
expect(second.statusCode).toBe(409)
expect(second.json().error.code).toBe("SLOT_FULL")
expect((await book({ requestedAt: slot.start, people: 2 })).statusCode).toBe(201)
expect((await book({ requestedAt: slot.start, people: 1 })).statusCode).toBe(409)
expect((await firstSlot()).start).toBe(slot.start) // lo slot pieno resta elencato…
const res = await t.app.inject({ method: "GET", url: "/showroom/slots?days=31" })
expect(res.json().days[0].slots[0].available).toBe(0) // …con 0 posti
})
it("prenotazioni concorrenti sullo stesso slot non superano la capienza", async () => {
const slot = await firstSlot()
const results = await Promise.all(Array.from({ length: 5 }, () => book({ requestedAt: slot.start, people: 4 })))
expect(results.map((r) => r.statusCode).sort()).toEqual([201, 409, 409, 409, 409])
expect(await t.app.db.select().from(showroomBookings)).toHaveLength(1)
})
it("slot non valido, persone oltre il massimo, consenso privacy mancante → 400", async () => {
const slot = await firstSlot()
const offSlot = new Date(new Date(slot.start).getTime() + 30 * 60_000).toISOString()
const bad = await book({ requestedAt: offSlot })
expect(bad.statusCode).toBe(400)
expect(bad.json().error.code).toBe("INVALID_SLOT")
expect((await book({ requestedAt: new Date(Date.now() + 3_600_000).toISOString() })).json().error.code).toBe("INVALID_SLOT")
expect((await book({ requestedAt: slot.start, people: SHOWROOM.maxPeoplePerBooking + 1 })).statusCode).toBe(400)
const noPrivacy = await book({ requestedAt: slot.start, acceptPrivacy: false })
expect(noPrivacy.statusCode).toBe(400)
expect(noPrivacy.json().error.code).toBe("VALIDATION_ERROR")
expect((await book({ requestedAt: slot.start, acceptPrivacy: undefined })).statusCode).toBe(400)
expect(await t.app.db.select().from(showroomBookings)).toHaveLength(0)
expect(await t.app.db.select().from(consents)).toHaveLength(0)
})
it("anti-spam: honeypot e invio troppo rapido scartati in silenzio, token falso → 400", async () => {
const slot = await firstSlot()
const honeypot = await book({ requestedAt: slot.start, website: "http://spam.example" })
expect(honeypot.statusCode).toBe(200)
const fast = await book({ requestedAt: slot.start, formToken: createFormToken("c".repeat(32)).token })
expect(fast.statusCode).toBe(200)
expect(await t.app.db.select().from(showroomBookings)).toHaveLength(0)
expect(t.email.sent).toHaveLength(0)
const forged = await book({ requestedAt: slot.start, formToken: createFormToken("x".repeat(32), new Date(Date.now() - 10_000)).token })
expect(forged.statusCode).toBe(400)
expect(forged.json().error.code).toBe("FORM_EXPIRED")
})
it("admin: elenco, annullamento libera i posti, riattivazione ricontrolla la capienza e conferma via email", async () => {
const admin = (await createUserAndLogin(t.app, "admin@iti.local", "admin")).headers
const user = await createUserAndLogin(t.app, "luigi@example.com")
const slot = await firstSlot()
const first = (await book({ requestedAt: slot.start, people: 4 }, user.headers)).json()
expect(first.email).toBe("mario@example.com")
const list = await t.app.inject({ method: "GET", url: "/admin/showroom/bookings?status=confirmed", headers: admin })
expect(list.json()).toMatchObject({ total: 1, items: [{ id: first.id, userId: user.user.id }] })
expect((await t.app.inject({ method: "GET", url: "/admin/showroom/bookings", headers: user.headers })).statusCode).toBe(403)
const patch = (id: string, status: string) =>
t.app.inject({ method: "PATCH", url: `/admin/showroom/bookings/${id}`, headers: admin, payload: { status } })
expect((await patch(first.id, "cancelled")).json().status).toBe("cancelled")
expect((await firstSlot()).available).toBe(SHOWROOM.capacity)
expect((await book({ requestedAt: slot.start, people: 3 })).statusCode).toBe(201)
const reactivate = await patch(first.id, "confirmed")
expect(reactivate.statusCode).toBe(409)
expect(reactivate.json().error.code).toBe("SLOT_FULL")
t.email.clear()
const second = (await t.app.inject({ method: "GET", url: "/admin/showroom/bookings?status=confirmed", headers: admin })).json().items[0]
await patch(second.id, "cancelled")
expect((await patch(first.id, "confirmed")).json().status).toBe("confirmed")
expect(t.email.last("showroom-booking-confirmed")?.to).toBe("mario@example.com")
expect((await patch(first.id, "completed")).json().status).toBe("completed")
})
})
+81
View File
@@ -0,0 +1,81 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import { buildTestApp, createUserAndLogin, type TestApp, truncateAll } from "./helpers.js"
let t: TestApp
let admin: Record<string, string>
let mario: Record<string, string>
let luigi: Record<string, string>
const call = (method: "GET" | "POST" | "PATCH", url: string, headers: Record<string, string>, payload?: object) =>
t.app.inject({ method, url, headers, ...(payload ? { payload } : {}) })
const openTicket = async (headers = mario) => {
const res = await call("POST", "/me/tickets", headers, { subject: "Addolcitore in allarme", body: "Lampeggia la spia rossa" })
expect(res.statusCode, res.body).toBe(201)
return res.json()
}
beforeAll(async () => {
t = await buildTestApp()
})
afterAll(() => t.app.close())
beforeEach(async () => {
await truncateAll()
t.email.clear()
admin = (await createUserAndLogin(t.app, "admin@iti.local", "admin")).headers
mario = (await createUserAndLogin(t.app, "mario@example.com")).headers
luigi = (await createUserAndLogin(t.app, "luigi@example.com")).headers
})
describe("ticket", () => {
it("apertura con primo messaggio e notifica allo staff", async () => {
const tk = await openTicket()
expect(tk).toMatchObject({ subject: "Addolcitore in allarme", status: "open", priority: "normal" })
expect(tk.messages).toHaveLength(1)
expect(tk.messages[0]).toMatchObject({ body: "Lampeggia la spia rossa", fromStaff: false })
expect(t.email.last("staff-notification")?.to).toBe("admin@iti.local")
// priorità urgent riservata all'assistenza
expect((await call("POST", "/me/tickets", mario, { subject: "Urgente!", body: "x", priority: "urgent" })).statusCode).toBe(400)
})
it("un cliente non può leggere né scrivere nei ticket di un altro (404)", async () => {
const tk = await openTicket()
expect((await call("GET", `/me/tickets/${tk.id}`, luigi)).statusCode).toBe(404)
expect((await call("POST", `/me/tickets/${tk.id}/messages`, luigi, { body: "intrusione" })).statusCode).toBe(404)
expect((await call("GET", "/me/tickets", luigi)).json().total).toBe(0)
expect((await call("GET", "/me/tickets", mario)).json().total).toBe(1)
expect((await call("GET", "/admin/tickets", mario)).statusCode).toBe(403)
})
it("risposta admin: email al cliente, stato in attesa del cliente; la risposta del cliente riapre", async () => {
const tk = await openTicket()
t.email.clear()
const reply = await call("POST", `/admin/tickets/${tk.id}/messages`, admin, { body: "Il tecnico passa giovedì" })
expect(reply.statusCode).toBe(201)
expect(reply.json()).toMatchObject({ status: "waiting_customer", customer: { email: "mario@example.com" } })
expect(reply.json().messages.map((m: { fromStaff: boolean }) => m.fromStaff)).toEqual([false, true])
const mail = t.email.last("ticket-reply")
expect(mail?.to).toBe("mario@example.com")
expect(mail?.text).toContain("Il tecnico passa giovedì")
expect(mail?.text).toContain(`/assistenza/${tk.id}`)
const back = await call("POST", `/me/tickets/${tk.id}/messages`, mario, { body: "Va bene, grazie" })
expect(back.json().status).toBe("open")
expect(back.json().messages).toHaveLength(3)
})
it("admin: elenco con filtri, cambio stato e priorità; chiuso e riaperto dal cliente", async () => {
const tk = await openTicket()
await openTicket(luigi)
expect((await call("GET", "/admin/tickets?q=luigi", admin)).json().total).toBe(1)
const patched = await call("PATCH", `/admin/tickets/${tk.id}`, admin, { priority: "urgent" })
expect(patched.json()).toMatchObject({ priority: "urgent", status: "open" })
await call("PATCH", `/admin/tickets/${tk.id}`, admin, { status: "closed" })
expect((await call("GET", "/admin/tickets?status=closed", admin)).json().items.map((x: { id: string }) => x.id)).toEqual([tk.id])
const reopened = await call("POST", `/me/tickets/${tk.id}/messages`, mario, { body: "Il problema si è ripresentato" })
expect(reopened.json().status).toBe("open")
})
})
+208
View File
@@ -0,0 +1,208 @@
import { createHmac, randomUUID } from "node:crypto"
import { eq } from "drizzle-orm"
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import { users, webhookEvents } from "../src/db/schema/index.js"
import { hashPassword } from "../src/services/password.js"
import { buildTestApp, type TestApp, truncateAll } from "./helpers.js"
const SECRET = "w".repeat(32) // WEBHOOK_SECRET di testConfig
let t: TestApp
const sign = (body: string, secret = SECRET) => createHmac("sha256", secret).update(body).digest("hex")
function payload(event: string, data: Record<string, unknown>, id: string = randomUUID()) {
return JSON.stringify({ id, event, occurred_at: new Date().toISOString(), data })
}
const deliver = (body: string, signature: string | undefined = sign(body), app = t.app) =>
app.inject({
method: "POST",
url: "/webhooks/medusa",
headers: { "content-type": "application/json", ...(signature ? { "x-signature": signature } : {}) },
payload: body,
})
const ORDER = {
id: "order_1",
display_id: 7,
status: "pending",
email: "cliente@example.com",
customer_id: null,
currency_code: "eur",
created_at: "2026-10-02T10:00:00.000Z",
item_total: 1190,
shipping_total: 15,
tax_total: 217.29,
discount_total: 0,
total: 1205,
items: [{ title: "Standard", product_title: "Addolcitore", variant_title: "Standard", quantity: 1, unit_price: 1190, total: 1190 }],
shipping_address: { first_name: "Mario", last_name: "Rossi", address_1: "Via Roma 1", city: "Milano", postal_code: "20100", province: "MI" },
shipping_methods: [{ name: "Spedizione standard" }],
fulfillments: [{ id: "ful_1", labels: [{ tracking_number: "TRK123", tracking_url: "https://track.example/TRK123" }] }],
}
const eventRow = async (id: string) => (await t.app.db.select().from(webhookEvents).where(eq(webhookEvents.id, id)))[0]
beforeAll(async () => {
t = await buildTestApp()
})
afterAll(() => t.app.close())
beforeEach(async () => {
await truncateAll()
t.medusa.reset()
t.email.clear()
t.medusa.orderDetails.set(ORDER.id, ORDER)
await t.app.db
.insert(users)
.values({ email: "admin@iti.local", passwordHash: await hashPassword("x".repeat(12)), role: "admin", firstName: "A", lastName: "D" })
})
describe("firma", () => {
it("firma valida → elaborato, evento salvato", async () => {
const id = randomUUID()
const res = await deliver(payload("order.placed", { id: ORDER.id }, id))
expect(res.statusCode).toBe(200)
expect(res.json()).toEqual({ status: "processed" })
const row = await eventRow(id)
expect(row).toMatchObject({ event: "order.placed", error: null })
expect(row?.processedAt).toBeInstanceOf(Date)
})
it("firma errata, assente, con altra chiave o body alterato → 401 e niente in DB", async () => {
const body = payload("order.placed", { id: ORDER.id })
for (const res of [
await deliver(body, "0".repeat(64)),
await deliver(body, ""), // header assente
await deliver(body, "non-esadecimale"),
await deliver(body, sign(body, "z".repeat(32))),
await deliver(body.replace("order_1", "order_2"), sign(body)),
]) {
expect(res.statusCode).toBe(401)
expect(res.json().error.code).toBe("INVALID_SIGNATURE")
}
expect(await t.app.db.select().from(webhookEvents)).toHaveLength(0)
expect(t.email.sent).toHaveLength(0)
})
it("firma valida su JSON non valido → 400; payload senza id uuid → 400", async () => {
expect((await deliver("{non json")).statusCode).toBe(400)
const noId = JSON.stringify({ id: "x", event: "order.placed", occurred_at: "", data: {} })
expect((await deliver(noId)).json().error.code).toBe("VALIDATION_ERROR")
})
})
describe("idempotenza", () => {
it("stesso evento due volte → elaborato una sola volta", async () => {
const body = payload("order.placed", { id: ORDER.id })
expect((await deliver(body)).json().status).toBe("processed")
expect((await deliver(body)).json().status).toBe("duplicate")
expect(t.email.sent.filter((m) => m.tag === "order-confirmation")).toHaveLength(1)
expect(await t.app.db.select().from(webhookEvents)).toHaveLength(1)
})
it("consegne concorrenti dello stesso evento → una sola elaborazione", async () => {
const body = payload("order.placed", { id: ORDER.id })
const results = await Promise.all([deliver(body), deliver(body), deliver(body)])
expect(results.map((r) => r.statusCode)).toEqual([200, 200, 200])
expect(results.map((r) => r.json().status).sort()).toEqual(["duplicate", "duplicate", "processed"])
expect(t.email.sent.filter((m) => m.tag === "order-confirmation")).toHaveLength(1)
})
it("errore di elaborazione → 500, errore salvato; il retry di Medusa lo rielabora", async () => {
const id = randomUUID()
const body = payload("order.placed", { id: ORDER.id }, id)
t.medusa.down = true
const failed = await deliver(body)
expect(failed.statusCode).toBe(500)
expect(failed.json().error.code).toBe("WEBHOOK_PROCESSING_FAILED")
const row = await eventRow(id)
expect(row?.processedAt).toBeNull()
expect(row?.error).toContain("Medusa non raggiungibile")
t.medusa.down = false
expect((await deliver(body)).json().status).toBe("processed")
expect(await eventRow(id)).toMatchObject({ error: null })
expect(t.email.sent.filter((m) => m.tag === "order-confirmation")).toHaveLength(1)
})
})
describe("dispatcher", () => {
it("order.placed → conferma al cliente con righe e totali, notifica agli admin", async () => {
await deliver(payload("order.placed", { id: ORDER.id }))
const mail = t.email.last("order-confirmation")
expect(mail?.to).toBe("cliente@example.com")
expect(mail?.subject).toContain("n. 7")
expect(mail?.text).toContain("1 x Addolcitore - Standard")
expect(mail?.text).toMatch(/Totale \(di cui IVA 217,29\s€\): 1\.205,00\s€/)
expect(mail?.html).toContain("1.205,00")
expect(mail?.text).toContain("Via Roma 1")
expect(t.email.last("order-placed-admin")?.to).toBe("admin@iti.local")
})
it("ADMIN_NOTIFY_EMAILS sostituisce gli admin del DB", async () => {
const other = await buildTestApp({ ADMIN_NOTIFY_EMAILS: "Ordini@ITI.it, magazzino@iti.it" })
other.medusa.orderDetails.set(ORDER.id, ORDER)
try {
const body = payload("order.placed", { id: ORDER.id })
expect((await deliver(body, sign(body), other.app)).statusCode).toBe(200)
} finally {
await other.app.close()
}
expect(other.email.sent.filter((m) => m.tag === "order-placed-admin").map((m) => m.to)).toEqual([
"ordini@iti.it",
"magazzino@iti.it",
])
})
it("order.canceled → email di annullamento", async () => {
await deliver(payload("order.canceled", { id: ORDER.id }))
expect(t.email.last("order-canceled")).toMatchObject({ to: "cliente@example.com" })
})
it("order.fulfillment_created → email di spedizione con tracking; no_notification → nessuna email", async () => {
await deliver(payload("order.fulfillment_created", { order_id: ORDER.id, fulfillment_id: "ful_1", no_notification: false }))
expect(t.email.last("order-shipped")?.text).toContain("TRK123")
t.email.clear()
const res = await deliver(payload("order.fulfillment_created", { order_id: ORDER.id, fulfillment_id: "ful_1", no_notification: true }))
expect(res.json().status).toBe("processed")
expect(t.email.sent).toHaveLength(0)
})
it("evento non gestito → ignored, ma registrato come elaborato", async () => {
const id = randomUUID()
expect((await deliver(payload("order.updated", { id: ORDER.id }, id))).json().status).toBe("ignored")
expect((await eventRow(id))?.processedAt).toBeInstanceOf(Date)
})
it("ordine inesistente in Medusa → 500 (Medusa ritenta)", async () => {
expect((await deliver(payload("order.placed", { id: "order_x" }))).statusCode).toBe(500)
})
})
describe("esclusioni", () => {
it("niente rate limit globale né header CORS sull'endpoint", async () => {
const limited = await buildTestApp({ RATE_LIMIT_MAX: "2" })
limited.medusa.orderDetails.set(ORDER.id, ORDER)
try {
const body = payload("order.placed", { id: ORDER.id })
for (let i = 0; i < 5; i++) {
const res = await limited.app.inject({
method: "POST",
url: "/webhooks/medusa",
headers: { "content-type": "application/json", "x-signature": sign(body), origin: "http://localhost:4200" },
payload: body,
})
expect(res.statusCode).toBe(200)
expect(res.headers["access-control-allow-origin"]).toBeUndefined()
}
// Controllo: le altre route sono limitate
const statuses = []
for (let i = 0; i < 3; i++) statuses.push((await limited.app.inject({ method: "GET", url: "/shop/categories" })).statusCode)
expect(statuses).toContain(429)
} finally {
await limited.app.close()
}
})
})