Files
iti-technology/apps/api/test/app.test.ts
T
Lorenzo SanesiandClaude Opus 5.5 b83ce039fb
Deploy stack su Proxmox / deploy (push) Failing after 3s
Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
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
2026-10-20 09:30:48 +02:00

182 lines
7.2 KiB
TypeScript

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