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
267 lines
12 KiB
TypeScript
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("<script>alert("x")</script>&"'")
|
|
})
|
|
}
|
|
|
|
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" })
|
|
})
|
|
})
|