Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
Deploy stack su Proxmox / deploy (push) Failing after 3s
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:
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user