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,181 @@
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
import { z } from "zod"
|
||||
import { buildApp, type App } from "../src/app.js"
|
||||
import { ConfigError, loadConfig } from "../src/config/env.js"
|
||||
import { AppError } from "../src/errors.js"
|
||||
|
||||
const baseEnv = {
|
||||
NODE_ENV: "test",
|
||||
DATABASE_URL: "postgres://user:pass@localhost:5432/test",
|
||||
CORS_ORIGINS: "http://localhost:4200,http://localhost:4300",
|
||||
COOKIE_SECRET: "x".repeat(32),
|
||||
MEDUSA_PUBLISHABLE_KEY: "pk_test",
|
||||
INTERNAL_API_SECRET: "i".repeat(32),
|
||||
WEBHOOK_SECRET: "w".repeat(32),
|
||||
JWT_SECRET: "j".repeat(32),
|
||||
PRIVATE_APP_URL: "http://localhost:4300",
|
||||
}
|
||||
|
||||
describe("config", () => {
|
||||
it("elenca tutte le variabili mancanti", () => {
|
||||
try {
|
||||
loadConfig({ NODE_ENV: "test" })
|
||||
expect.unreachable()
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ConfigError)
|
||||
const vars = (err as ConfigError).problems.map((p) => p.split(":")[0])
|
||||
expect(vars).toEqual(
|
||||
expect.arrayContaining([
|
||||
"CORS_ORIGINS",
|
||||
"COOKIE_SECRET",
|
||||
"MEDUSA_PUBLISHABLE_KEY",
|
||||
"INTERNAL_API_SECRET",
|
||||
"WEBHOOK_SECRET",
|
||||
]),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("richiede LOG_DIR in produzione", () => {
|
||||
expect(() => loadConfig({ ...baseEnv, NODE_ENV: "production" })).toThrow(/LOG_DIR/)
|
||||
})
|
||||
|
||||
it("in produzione: cookie Secure, origini https e niente outbox delle email", () => {
|
||||
const prod = {
|
||||
...baseEnv,
|
||||
NODE_ENV: "production",
|
||||
LOG_DIR: "/var/log/iti",
|
||||
UPLOAD_DIR: "/data/uploads",
|
||||
PUBLIC_UPLOADS_URL: "https://media.example.com",
|
||||
CORS_ORIGINS: "https://example.com,https://area.example.com",
|
||||
}
|
||||
expect(loadConfig(prod).AUTH_COOKIE_SECURE).toBe(true)
|
||||
expect(() => loadConfig({ ...prod, AUTH_COOKIE_SECURE: "false" })).toThrow(/AUTH_COOKIE_SECURE/)
|
||||
expect(() => loadConfig({ ...prod, CORS_ORIGINS: "https://example.com,http://area.example.com" })).toThrow(/CORS_ORIGINS/)
|
||||
expect(() => loadConfig({ ...prod, EMAIL_OUTBOX_DIR: ".outbox" })).toThrow(/EMAIL_OUTBOX_DIR/)
|
||||
})
|
||||
|
||||
it("TRUST_PROXY: booleano o elenco di indirizzi fidati", () => {
|
||||
expect(loadConfig(baseEnv).TRUST_PROXY).toBe(false)
|
||||
expect(loadConfig({ ...baseEnv, TRUST_PROXY: "true" }).TRUST_PROXY).toBe(true)
|
||||
expect(loadConfig({ ...baseEnv, TRUST_PROXY: "false" }).TRUST_PROXY).toBe(false)
|
||||
expect(loadConfig({ ...baseEnv, TRUST_PROXY: "loopback,uniquelocal" }).TRUST_PROXY).toBe("loopback,uniquelocal")
|
||||
})
|
||||
|
||||
it("separa le origini CORS", () => {
|
||||
expect(loadConfig(baseEnv).CORS_ORIGINS).toEqual(["http://localhost:4200", "http://localhost:4300"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("app", () => {
|
||||
let app: App
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp(loadConfig(baseEnv))
|
||||
// Route di prova per il formato degli errori
|
||||
app.post(
|
||||
"/__test/validate",
|
||||
{ schema: { body: z.object({ email: z.email() }) } },
|
||||
async () => ({ ok: true }),
|
||||
)
|
||||
app.get("/__test/app-error", async () => {
|
||||
throw new AppError(409, "EMAIL_TAKEN", "Email già registrata")
|
||||
})
|
||||
app.get("/__test/crash", async () => {
|
||||
throw new Error("dettaglio interno")
|
||||
})
|
||||
await app.ready()
|
||||
})
|
||||
afterAll(() => app.close())
|
||||
|
||||
it("GET /health -> 200", async () => {
|
||||
const res = await app.inject({ method: "GET", url: "/health" })
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.json()).toMatchObject({ status: "ok" })
|
||||
})
|
||||
|
||||
it("404 in formato standard", async () => {
|
||||
const res = await app.inject({ method: "GET", url: "/non-esiste" })
|
||||
expect(res.statusCode).toBe(404)
|
||||
expect(res.json()).toEqual({
|
||||
error: { code: "NOT_FOUND", message: "Route GET /non-esiste non trovata" },
|
||||
})
|
||||
})
|
||||
|
||||
it("validazione -> 400 VALIDATION_ERROR con details", async () => {
|
||||
const res = await app.inject({ method: "POST", url: "/__test/validate", payload: { email: "x" } })
|
||||
expect(res.statusCode).toBe(400)
|
||||
const body = res.json()
|
||||
expect(body.error.code).toBe("VALIDATION_ERROR")
|
||||
expect(body.error.details[0].path).toBe("body.email")
|
||||
})
|
||||
|
||||
it("AppError -> status e codice applicativi", async () => {
|
||||
const res = await app.inject({ method: "GET", url: "/__test/app-error" })
|
||||
expect(res.statusCode).toBe(409)
|
||||
expect(res.json()).toEqual({ error: { code: "EMAIL_TAKEN", message: "Email già registrata" } })
|
||||
})
|
||||
|
||||
it("errore inatteso -> 500 senza stack", async () => {
|
||||
const res = await app.inject({ method: "GET", url: "/__test/crash" })
|
||||
expect(res.statusCode).toBe(500)
|
||||
expect(res.json()).toEqual({ error: { code: "INTERNAL_ERROR", message: "dettaglio interno" } })
|
||||
expect(res.body).not.toContain("at ")
|
||||
})
|
||||
|
||||
it("CORS: origine consentita con credenziali, altre no", async () => {
|
||||
const ok = await app.inject({
|
||||
method: "OPTIONS",
|
||||
url: "/health",
|
||||
headers: { origin: "http://localhost:4200", "access-control-request-method": "GET" },
|
||||
})
|
||||
expect(ok.headers["access-control-allow-origin"]).toBe("http://localhost:4200")
|
||||
expect(ok.headers["access-control-allow-credentials"]).toBe("true")
|
||||
|
||||
const ko = await app.inject({ method: "GET", url: "/health", headers: { origin: "https://evil.example" } })
|
||||
expect(ko.headers["access-control-allow-origin"]).toBeUndefined()
|
||||
})
|
||||
|
||||
it("header di sicurezza helmet", async () => {
|
||||
const res = await app.inject({ method: "GET", url: "/health" })
|
||||
expect(res.headers["x-content-type-options"]).toBe("nosniff")
|
||||
expect(res.headers["cross-origin-resource-policy"]).toBe("same-site")
|
||||
})
|
||||
})
|
||||
|
||||
describe("produzione", () => {
|
||||
it("errore 5xx con messaggio generico", async () => {
|
||||
const app = await buildApp({ ...loadConfig({ ...baseEnv, LOG_DIR: join(tmpdir(), "iti-api-test-logs") }), NODE_ENV: "production", LOG_LEVEL: "silent" })
|
||||
app.get("/__test/crash", async () => {
|
||||
throw new Error("dettaglio interno")
|
||||
})
|
||||
const res = await app.inject({ method: "GET", url: "/__test/crash" })
|
||||
expect(res.json()).toEqual({ error: { code: "INTERNAL_ERROR", message: "Errore interno" } })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it("TRUST_PROXY con indirizzi fidati: IP del client da X-Forwarded-For solo se arriva da un proxy fidato", async () => {
|
||||
const app = await buildApp({ ...loadConfig(baseEnv), TRUST_PROXY: "loopback,uniquelocal" })
|
||||
app.get("/ip", async (request) => ({ ip: request.ip }))
|
||||
await app.ready()
|
||||
const ipFrom = async (remoteAddress: string) =>
|
||||
(await app.inject({ method: "GET", url: "/ip", remoteAddress, headers: { "x-forwarded-for": "203.0.113.7" } })).json().ip
|
||||
// Caddy o server SSR sulla rete Docker → IP del visitatore
|
||||
expect(await ipFrom("172.18.0.5")).toBe("203.0.113.7")
|
||||
// Client diretto (non fidato): l'intestazione viene ignorata
|
||||
expect(await ipFrom("198.51.100.20")).toBe("198.51.100.20")
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it("rate limit -> 429 RATE_LIMITED", async () => {
|
||||
const app = await buildApp(loadConfig({ ...baseEnv, RATE_LIMIT_MAX: "2" }))
|
||||
app.get("/__test/limited", async () => ({ ok: true }))
|
||||
for (let i = 0; i < 2; i++) await app.inject({ method: "GET", url: "/__test/limited" })
|
||||
const res = await app.inject({ method: "GET", url: "/__test/limited" })
|
||||
expect(res.statusCode).toBe(429)
|
||||
expect(res.json().error.code).toBe("RATE_LIMITED")
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user