Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
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:
Lorenzo Sanesi
2026-10-20 09:30:48 +02:00
co-authored by Claude Opus 5.5
commit b83ce039fb
505 changed files with 84544 additions and 0 deletions
+296
View File
@@ -0,0 +1,296 @@
import { existsSync } from "node:fs"
import { readdir, rm } from "node:fs/promises"
import { join } from "node:path"
import { Readable } from "node:stream"
import { eq } from "drizzle-orm"
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import { documents, installerProfiles, users } from "../src/db/schema/index.js"
import { hashPassword } from "../src/services/password.js"
import { checkFileKind, InvalidStorageKeyError, LocalDiskStorage, sniffKind } from "../src/services/storage.js"
import { buildTestApp, multipartBody, TEST_UPLOAD_DIR, type TestApp, truncateAll } from "./helpers.js"
const PASSWORD = "Password-di-test-1"
const PDF = Buffer.from("%PDF-1.7\n1 0 obj << >> endobj\ntrailer << >>\n%%EOF\n")
const PNG = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(32, 1)])
const ZIP = Buffer.concat([Buffer.from("PK\x03\x04", "latin1"), Buffer.alloc(30)])
const DWG = Buffer.concat([Buffer.from("AC1032"), Buffer.alloc(30)])
const DXF = Buffer.from(" 0\r\nSECTION\r\n 2\r\nHEADER\r\n")
const HTML = Buffer.from("<!doctype html><script>alert(1)</script>")
let t: TestApp
const tokens: Record<string, string> = {}
async function user(email: string, role: "customer" | "company" | "installer" | "admin", installerStatus?: "pending" | "approved") {
const [u] = await t.app.db
.insert(users)
.values({ email, passwordHash: await hashPassword(PASSWORD), role, firstName: "T", lastName: "U" })
.returning()
if (installerStatus) await t.app.db.insert(installerProfiles).values({ userId: u!.id, businessName: "Srl", status: installerStatus })
const res = await t.app.inject({ method: "POST", url: "/auth/login", payload: { email, password: PASSWORD } })
tokens[email] = res.json().accessToken
return u!
}
const auth = (email?: string) => (email ? { authorization: `Bearer ${tokens[email]}` } : {})
function upload(
file: { filename: string; contentType: string; content: Buffer | string } | null,
fields: Record<string, string> = { title: "Scheda tecnica", visibility: "installer" },
as = "admin@iti.local",
url = "/admin/documents",
) {
const body = multipartBody(fields, file ? [file] : [])
return t.app.inject({ method: "POST", url, payload: body.payload, headers: { ...body.headers, ...auth(as) } })
}
async function filesOnDisk(): Promise<string[]> {
const out: string[] = []
const walk = async (dir: string) => {
if (!existsSync(dir)) return
for (const e of await readdir(dir, { withFileTypes: true })) {
const p = join(dir, e.name)
if (e.isDirectory()) await walk(p)
else out.push(p)
}
}
await walk(TEST_UPLOAD_DIR)
return out
}
beforeAll(async () => {
t = await buildTestApp()
})
afterAll(async () => {
await t.app.close()
await rm(TEST_UPLOAD_DIR, { recursive: true, force: true })
})
beforeEach(async () => {
await truncateAll()
await rm(TEST_UPLOAD_DIR, { recursive: true, force: true })
await user("admin@iti.local", "admin")
await user("cliente@example.com", "customer")
await user("inst@example.com", "installer", "approved")
await user("attesa@example.com", "installer", "pending")
})
describe("upload e download per ruolo", () => {
it("un installatore approvato scarica i documenti installer; customer 403, installatore in attesa 403, anonimo 401", async () => {
const res = await upload({ filename: "Scheda PDC 8kW.pdf", contentType: "application/pdf", content: PDF })
expect(res.statusCode).toBe(201)
const doc = res.json()
expect(doc).toMatchObject({ title: "Scheda tecnica", visibility: "installer", mimeType: "application/pdf", sizeBytes: PDF.length })
expect(JSON.stringify(doc)).not.toMatch(/file_?path|documents\//i)
const dl = await t.app.inject({ method: "GET", url: `/documents/${doc.id}/download`, headers: auth("inst@example.com") })
expect(dl.statusCode).toBe(200)
expect(dl.rawPayload.equals(PDF)).toBe(true)
expect(dl.headers["content-type"]).toBe("application/pdf")
expect(dl.headers["content-disposition"]).toBe(`attachment; filename="Scheda tecnica.pdf"; filename*=UTF-8''Scheda%20tecnica.pdf`)
expect(dl.headers["cache-control"]).toBe("private, no-store")
const forbidden = await t.app.inject({ method: "GET", url: `/documents/${doc.id}/download`, headers: auth("cliente@example.com") })
expect(forbidden.statusCode).toBe(403)
expect((await t.app.inject({ method: "GET", url: `/documents/${doc.id}/download`, headers: auth("attesa@example.com") })).statusCode).toBe(403)
expect((await t.app.inject({ method: "GET", url: `/documents/${doc.id}/download` })).statusCode).toBe(401)
expect((await t.app.inject({ method: "GET", url: `/documents/${doc.id}/download`, headers: auth("admin@iti.local") })).statusCode).toBe(200)
})
it("documento pubblico scaricabile da anonimo; documento inesistente 404", async () => {
const doc = (await upload({ filename: "catalogo.pdf", contentType: "application/pdf", content: PDF }, { title: "Catalogo", visibility: "public" })).json()
const dl = await t.app.inject({ method: "GET", url: `/documents/${doc.id}/download` })
expect(dl.statusCode).toBe(200)
expect(dl.headers["cache-control"]).toBe("public, max-age=300")
expect((await t.app.inject({ method: "GET", url: "/documents/00000000-0000-4000-8000-000000000000/download" })).statusCode).toBe(404)
})
it("GET /documents elenca solo i livelli visibili al ruolo", async () => {
for (const visibility of ["public", "customer", "installer", "admin"]) {
await upload({ filename: "x.pdf", contentType: "application/pdf", content: PDF }, { title: visibility, visibility, category: "schede" })
}
const titles = async (email?: string) =>
((await t.app.inject({ method: "GET", url: "/documents", headers: auth(email) })).json().items as { title: string }[])
.map((d) => d.title)
.sort()
expect(await titles()).toEqual(["public"])
expect(await titles("cliente@example.com")).toEqual(["customer", "public"])
expect(await titles("attesa@example.com")).toEqual(["customer", "public"])
expect(await titles("inst@example.com")).toEqual(["customer", "installer", "public"])
expect(await titles("admin@iti.local")).toEqual(["admin", "customer", "installer", "public"])
})
it("tutti i tipi ammessi: immagini, PDF, ZIP, DWG, DXF (MIME canonico)", async () => {
const cases = [
{ filename: "foto.PNG", contentType: "image/png", content: PNG, mime: "image/png" },
{ filename: "progetto.zip", contentType: "application/x-zip-compressed", content: ZIP, mime: "application/zip" },
{ filename: "pianta.dwg", contentType: "application/octet-stream", content: DWG, mime: "image/vnd.dwg" },
{ filename: "pianta.dxf", contentType: "application/dxf", content: DXF, mime: "image/vnd.dxf" },
]
for (const c of cases) {
const res = await upload(c, { title: c.filename, visibility: "admin" })
expect(res.statusCode, c.filename).toBe(201)
expect(res.json().mimeType).toBe(c.mime)
}
})
})
describe("rifiuti", () => {
it("estensione o MIME non consentiti, contenuto non coerente → 415 e niente su disco", async () => {
const rejected = [
{ filename: "virus.exe", contentType: "application/octet-stream", content: Buffer.from("MZ...") },
{ filename: "logo.svg", contentType: "image/svg+xml", content: Buffer.from("<svg onload=alert(1)/>") },
{ filename: "pagina.html", contentType: "text/html", content: HTML },
{ filename: "finto.pdf", contentType: "application/pdf", content: HTML },
{ filename: "finta.jpg", contentType: "image/jpeg", content: PNG },
{ filename: "doc.pdf", contentType: "text/html", content: PDF },
{ filename: "senza-estensione", contentType: "application/pdf", content: PDF },
{ filename: "vuoto.pdf", contentType: "application/pdf", content: Buffer.alloc(0) },
]
for (const file of rejected) {
const res = await upload(file)
expect(res.statusCode, file.filename).toBe(415)
expect(res.json().error.code).toBe("UNSUPPORTED_FILE_TYPE")
}
expect(await filesOnDisk()).toEqual([])
expect(await t.app.db.select().from(documents)).toHaveLength(0)
})
it("file oltre UPLOAD_MAX_BYTES → 413 e file parziale cancellato", async () => {
const small = await buildTestApp({ UPLOAD_MAX_BYTES: "100" })
try {
const login = await small.app.inject({ method: "POST", url: "/auth/login", payload: { email: "admin@iti.local", password: PASSWORD } })
const body = multipartBody({ title: "Grande", visibility: "admin" }, [
{ filename: "grande.pdf", contentType: "application/pdf", content: Buffer.concat([PDF, Buffer.alloc(500)]) },
])
const res = await small.app.inject({
method: "POST",
url: "/admin/documents",
payload: body.payload,
headers: { ...body.headers, authorization: `Bearer ${login.json().accessToken}` },
})
expect(res.statusCode).toBe(413)
expect(res.json().error.code).toBe("FILE_TOO_LARGE")
} finally {
await small.app.close()
}
expect(await filesOnDisk()).toEqual([])
})
it("nessun file, due file, metadati non validi → 400 senza file residui", async () => {
expect((await upload(null)).json().error.code).toBe("FILE_REQUIRED")
const two = multipartBody({ title: "Due" }, [
{ filename: "a.pdf", contentType: "application/pdf", content: PDF },
{ filename: "b.pdf", contentType: "application/pdf", content: PDF },
])
const res = await t.app.inject({ method: "POST", url: "/admin/documents", payload: two.payload, headers: { ...two.headers, ...auth("admin@iti.local") } })
expect(res.statusCode).toBe(400)
const bad = await upload({ filename: "a.pdf", contentType: "application/pdf", content: PDF }, { title: "", visibility: "segreto" })
expect(bad.statusCode).toBe(400)
expect(bad.json().error.code).toBe("VALIDATION_ERROR")
expect(await filesOnDisk()).toEqual([])
})
it("solo admin: customer 403, anonimo 401", async () => {
expect((await upload({ filename: "a.pdf", contentType: "application/pdf", content: PDF }, undefined, "cliente@example.com")).statusCode).toBe(403)
expect((await t.app.inject({ method: "GET", url: "/admin/documents" })).statusCode).toBe(401)
})
})
describe("path traversal e accesso statico", () => {
it("il nome file del client non entra nel percorso: chiave generata dal server", async () => {
const res = await upload({ filename: "../../../../etc/passwd.pdf", contentType: "application/pdf", content: PDF })
expect(res.statusCode).toBe(201)
const [row] = await t.app.db.select().from(documents).where(eq(documents.id, res.json().id))
expect(row!.filePath).toMatch(/^documents\/\d{4}\/\d{2}\/[0-9a-f-]{36}\.pdf$/)
const files = await filesOnDisk()
expect(files).toHaveLength(1)
expect(files[0]!.startsWith(join(TEST_UPLOAD_DIR, "private", "documents"))).toBe(true)
})
it("LocalDiskStorage rifiuta chiavi con .., assolute o con caratteri non ammessi", async () => {
const storage = new LocalDiskStorage(TEST_UPLOAD_DIR, "http://x")
for (const key of ["../fuori.pdf", "a/../../fuori.pdf", "/etc/passwd", "C:\\Windows\\x", "a//b.pdf", "a/./b.pdf", "A.PDF", "%2e%2e/x"]) {
await expect(storage.put("private", key, Readable.from([PDF])), key).rejects.toBeInstanceOf(InvalidStorageKeyError)
await expect(storage.get("private", key), key).rejects.toBeInstanceOf(InvalidStorageKeyError)
}
})
it("i file privati non sono raggiungibili via /uploads; le immagini pubbliche sì", async () => {
const doc = await upload({ filename: "riservato.pdf", contentType: "application/pdf", content: PDF })
const [row] = await t.app.db.select().from(documents).where(eq(documents.id, doc.json().id))
for (const url of [
`/uploads/${row!.filePath}`,
`/uploads/../private/${row!.filePath}`,
`/uploads/%2e%2e/private/${row!.filePath}`,
`/uploads/..%2fprivate/${row!.filePath}`,
`/uploads/%2e%2e%2fprivate%2f${row!.filePath.replaceAll("/", "%2f")}`,
]) {
const res = await t.app.inject({ method: "GET", url })
expect(res.statusCode, url).not.toBe(200)
expect(res.rawPayload.includes(Buffer.from("%PDF"))).toBe(false)
}
const img = await upload({ filename: "copertina.png", contentType: "image/png", content: PNG }, {}, "admin@iti.local", "/admin/uploads/image")
expect(img.statusCode).toBe(201)
const { url, path } = img.json()
expect(path).toMatch(/^images\/\d{4}\/\d{2}\/[0-9a-f-]{36}\.png$/)
expect(url).toBe(`http://localhost:3000/uploads/${path}`)
const served = await t.app.inject({ method: "GET", url: `/uploads/${path}` })
expect(served.statusCode).toBe(200)
expect(served.rawPayload.equals(PNG)).toBe(true)
})
it("upload immagine pubblica: solo immagini", async () => {
const res = await upload({ filename: "doc.pdf", contentType: "application/pdf", content: PDF }, {}, "admin@iti.local", "/admin/uploads/image")
expect(res.statusCode).toBe(415)
})
})
describe("gestione admin", () => {
it("elenco con filtri, PATCH metadati, DELETE cancella anche il file", async () => {
const a = (await upload({ filename: "a.pdf", contentType: "application/pdf", content: PDF }, { title: "Manuale addolcitore", visibility: "customer", category: "manuali", productRef: "addolcitore-30l" })).json()
await upload({ filename: "b.pdf", contentType: "application/pdf", content: PDF }, { title: "Listino 2026", visibility: "installer" })
const list = await t.app.inject({ method: "GET", url: "/admin/documents?q=manuale", headers: auth("admin@iti.local") })
expect(list.json()).toMatchObject({ total: 1, items: [{ title: "Manuale addolcitore", productRef: "addolcitore-30l", category: "manuali" }] })
expect((await t.app.inject({ method: "GET", url: "/admin/documents?visibility=installer", headers: auth("admin@iti.local") })).json().total).toBe(1)
const patched = await t.app.inject({
method: "PATCH",
url: `/admin/documents/${a.id}`,
headers: auth("admin@iti.local"),
payload: { visibility: "public", category: null },
})
expect(patched.json()).toMatchObject({ visibility: "public", category: null, title: "Manuale addolcitore" })
expect((await t.app.inject({ method: "GET", url: `/documents/${a.id}/download` })).statusCode).toBe(200)
// Regressione: un PATCH senza visibility non deve riportarla al default "admin"
const onlyTitle = await t.app.inject({
method: "PATCH",
url: `/admin/documents/${a.id}`,
headers: auth("admin@iti.local"),
payload: { title: "Manuale addolcitore v2" },
})
expect(onlyTitle.json()).toMatchObject({ title: "Manuale addolcitore v2", visibility: "public" })
expect(await filesOnDisk()).toHaveLength(2)
expect((await t.app.inject({ method: "DELETE", url: `/admin/documents/${a.id}`, headers: auth("admin@iti.local") })).statusCode).toBe(204)
expect(await filesOnDisk()).toHaveLength(1)
expect((await t.app.inject({ method: "DELETE", url: `/admin/documents/${a.id}`, headers: auth("admin@iti.local") })).statusCode).toBe(404)
})
})
describe("riconoscimento tipo file", () => {
it("sniffKind e checkFileKind", () => {
expect(sniffKind(PDF)).toBe("pdf")
expect(sniffKind(PNG)).toBe("png")
expect(sniffKind(Buffer.from([0xff, 0xd8, 0xff, 0xe0]))).toBe("jpeg")
expect(sniffKind(Buffer.from("RIFF\0\0\0\0WEBPVP8 "))).toBe("webp")
expect(sniffKind(DWG)).toBe("dwg")
expect(sniffKind(DXF)).toBe("dxf")
expect(sniffKind(HTML)).toBeNull()
expect(checkFileKind("a.pdf", "application/pdf", PDF, ["pdf"])).toEqual({ ok: true, kind: "pdf", ext: ".pdf" })
expect(checkFileKind("a.pdf", "application/pdf", PDF, ["png"]).ok).toBe(false)
})
})