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