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("") let t: TestApp const tokens: Record = {} 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 = { 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 { 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("") }, { 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) }) })