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, 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() } }) })