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

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

267 lines
12 KiB
TypeScript

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" })
})
})