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
209 lines
8.8 KiB
TypeScript
209 lines
8.8 KiB
TypeScript
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()
|
|
}
|
|
})
|
|
})
|