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,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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user