Files
iti-technology/apps/api/test/blog.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

228 lines
12 KiB
TypeScript

import { eq } from "drizzle-orm"
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import { blogPosts, users } from "../src/db/schema/index.js"
import { renderMarkdown, slugify } from "../src/services/blog.js"
import { hashPassword } from "../src/services/password.js"
import { buildTestApp, type TestApp, truncateAll } from "./helpers.js"
const PASSWORD = "Password-di-test-1"
let t: TestApp
let admin: string
let customer: string
async function login(email: string, role: "admin" | "customer") {
await t.app.db.insert(users).values({ email, passwordHash: await hashPassword(PASSWORD), role, firstName: "A", lastName: "B" })
const res = await t.app.inject({ method: "POST", url: "/auth/login", payload: { email, password: PASSWORD } })
return res.json().accessToken as string
}
const asAdmin = (method: "GET" | "POST" | "PATCH" | "DELETE", url: string, payload?: object) =>
t.app.inject({ method, url, headers: { authorization: `Bearer ${admin}` }, ...(payload ? { payload } : {}) })
const createPost = async (body: Record<string, unknown>) => {
const res = await asAdmin("POST", "/admin/blog/posts", body)
expect(res.statusCode, res.body).toBe(201)
return res.json()
}
const publicList = async (query = "") => (await t.app.inject({ method: "GET", url: `/blog/posts${query}` })).json()
beforeAll(async () => {
t = await buildTestApp()
})
afterAll(() => t.app.close())
beforeEach(async () => {
await truncateAll()
admin = await login("admin@iti.local", "admin")
customer = await login("cliente@example.com", "customer")
})
describe("ricerca full-text", () => {
it('"pompa calore" trova "Pompe di calore: guida" (stemming italiano + unaccent), ordinato per rilevanza', async () => {
await createPost({ title: "Pompe di calore: guida", contentMd: "Come scegliere la pompa di calore.", status: "published" })
await createPost({ title: "Fotovoltaico e accumulo", contentMd: "Con una pompa di calore il fotovoltaico rende di più.", status: "published" })
await createPost({ title: "Addolcitori per l'acqua", contentMd: "Calcare e durezza.", status: "published" })
const res = await publicList("?q=pompa%20calore")
expect(res.items.map((p: { title: string }) => p.title)).toEqual(["Pompe di calore: guida", "Fotovoltaico e accumulo"])
expect(res.total).toBe(2)
})
it("ricerca insensibile agli accenti e sintassi websearch (frasi, esclusioni)", async () => {
await createPost({ title: "Perché l'efficienza è importante", contentMd: "Risparmio energetico.", status: "published" })
await createPost({ title: "Efficienza della caldaia", contentMd: "Manutenzione.", status: "published" })
expect((await publicList("?q=perche")).total).toBe(1)
expect((await publicList("?q=efficienza%20-caldaia")).items.map((p: { title: string }) => p.title)).toEqual([
"Perché l'efficienza è importante",
])
expect((await publicList("?q=%22")).total).toBe(0) // query vuota per tsquery: nessun errore
})
})
describe("visibilità pubblica", () => {
it("bozze e post programmati nel futuro non sono visibili via API pubblica", async () => {
const draft = await createPost({ title: "Bozza segreta", contentMd: "x" })
const future = await createPost({
title: "Programmato",
contentMd: "x",
status: "published",
publishedAt: new Date(Date.now() + 86_400_000).toISOString(),
})
const live = await createPost({ title: "Pubblicato", contentMd: "x", status: "published" })
const list = await publicList()
expect(list.items.map((p: { slug: string }) => p.slug)).toEqual([live.slug])
expect((await publicList("?q=bozza")).total).toBe(0)
for (const slug of [draft.slug, future.slug]) {
expect((await t.app.inject({ method: "GET", url: `/blog/posts/${slug}` })).statusCode).toBe(404)
}
expect((await t.app.inject({ method: "GET", url: `/blog/posts/${live.slug}` })).statusCode).toBe(200)
const sitemap = (await t.app.inject({ method: "GET", url: "/sitemap-data" })).json()
expect(sitemap.posts.map((p: { slug: string }) => p.slug)).toEqual([live.slug])
})
it("filtro per categoria e categorie pubbliche", async () => {
const cat = (await asAdmin("POST", "/admin/blog/categories", { name: "Energia rinnovabile" })).json()
expect(cat.slug).toBe("energia-rinnovabile")
await createPost({ title: "Con categoria", contentMd: "x", status: "published", categoryId: cat.id })
await createPost({ title: "Senza categoria", contentMd: "x", status: "published" })
const res = await publicList("?category=energia-rinnovabile")
expect(res.items).toHaveLength(1)
expect(res.items[0].category).toMatchObject({ slug: "energia-rinnovabile", name: "Energia rinnovabile" })
expect((await t.app.inject({ method: "GET", url: "/blog/categories" })).json()).toHaveLength(1)
})
it("dettaglio: markdown + HTML, copertina con URL pubblico", async () => {
const p = await createPost({
title: "Guida",
contentMd: "## Titolo\n\nTesto **forte**.",
coverPath: "images/2026/10/abc.png",
status: "published",
})
const detail = (await t.app.inject({ method: "GET", url: `/blog/posts/${p.slug}` })).json()
expect(detail.contentMd).toBe("## Titolo\n\nTesto **forte**.")
expect(detail.contentHtml).toContain("<h2>Titolo</h2>")
expect(detail.contentHtml).toContain("<strong>forte</strong>")
expect(detail.coverUrl).toBe("http://localhost:3000/uploads/images/2026/10/abc.png")
})
})
describe("sanitizzazione", () => {
it("script, handler on*, javascript:, iframe, style rimossi", () => {
const html = renderMarkdown(
[
"Testo <script>alert(1)</script>",
'<img src="x" onerror="alert(1)">',
'<img src="https://cdn.example/a.png" onerror="alert(2)" alt="ok">',
"[link](javascript:alert(3))",
'<a href="https://example.com" onclick="x()">esterno</a>',
'<iframe src="https://evil.example"></iframe>',
'<p style="color:red">stile</p>',
"![data](data:image/png;base64,AAAA)",
].join("\n\n"),
)
expect(html).not.toMatch(/<script|onerror|onclick|javascript:|<iframe|style=|data:image/i)
expect(html).toContain('<img src="https://cdn.example/a.png" alt="ok" loading="lazy" />')
expect(html).toContain('<a href="https://example.com" rel="noopener noreferrer" target="_blank">esterno</a>')
})
it("il dettaglio pubblico e la preview restituiscono HTML ripulito", async () => {
const md = 'Ciao <script>alert("x")</script> <img src=x onerror=alert(1)>'
const p = await createPost({ title: "XSS", contentMd: md, status: "published" })
const detail = (await t.app.inject({ method: "GET", url: `/blog/posts/${p.slug}` })).json()
expect(detail.contentHtml).not.toMatch(/<script|onerror/)
expect(detail.contentMd).toBe(md) // il sorgente resta invariato
const preview = await asAdmin("POST", "/admin/blog/preview", { markdown: md })
expect(preview.json().html).not.toMatch(/<script|onerror/)
expect(preview.json().html).toContain("Ciao")
})
it("markdown standard mantenuto: tabelle, codice, liste", () => {
const html = renderMarkdown("| a | b |\n|---|---|\n| 1 | 2 |\n\n```ts\nconst x = 1\n```\n\n- uno\n- due")
expect(html).toContain("<table>")
expect(html).toContain('<code class="language-ts">')
expect(html).toContain("<li>uno</li>")
})
})
describe("admin", () => {
it("permessi: customer 403, anonimo 401 su tutte le route admin del blog", async () => {
const routes: ["GET" | "POST", string][] = [
["GET", "/admin/blog/posts"],
["POST", "/admin/blog/posts"],
["POST", "/admin/blog/preview"],
["POST", "/admin/blog/categories"],
]
for (const [method, url] of routes) {
const asCustomer = await t.app.inject({ method, url, headers: { authorization: `Bearer ${customer}` }, payload: {} })
expect(asCustomer.statusCode, `${method} ${url}`).toBe(403)
expect((await t.app.inject({ method, url, payload: {} })).statusCode, `${method} ${url}`).toBe(401)
}
})
it("slug dal titolo, univoco, stabile al cambio titolo; slug esplicito duplicato → 409", async () => {
expect(slugify("Pompe di calore: guida 2026!")).toBe("pompe-di-calore-guida-2026")
expect(slugify("Perché l'acqua è dura?")).toBe("perche-lacqua-e-dura")
const a = await createPost({ title: "Pompe di calore: guida" })
const b = await createPost({ title: "Pompe di calore: guida" })
const c = await createPost({ title: "Pompe di calore guida" })
expect([a.slug, b.slug, c.slug]).toEqual(["pompe-di-calore-guida", "pompe-di-calore-guida-2", "pompe-di-calore-guida-3"])
const renamed = (await asAdmin("PATCH", `/admin/blog/posts/${a.id}`, { title: "Titolo nuovo" })).json()
expect(renamed.slug).toBe("pompe-di-calore-guida")
const dup = await asAdmin("PATCH", `/admin/blog/posts/${b.id}`, { slug: "pompe-di-calore-guida" })
expect(dup.statusCode).toBe(409)
expect(dup.json().error.code).toBe("SLUG_TAKEN")
expect((await asAdmin("POST", "/admin/blog/posts", { title: "X", slug: "Non Valido!" })).statusCode).toBe(400)
})
it("stato: pubblicazione imposta published_at, PATCH parziale non tocca stato e contenuto", async () => {
const p = await createPost({ title: "Stato", contentMd: "Contenuto originale" })
expect(p).toMatchObject({ status: "draft", publishedAt: null })
const pub = (await asAdmin("PATCH", `/admin/blog/posts/${p.id}`, { status: "published" })).json()
expect(pub.status).toBe("published")
expect(Date.now() - Date.parse(pub.publishedAt)).toBeLessThan(10_000)
// Regressione: un PATCH del solo titolo non deve rimettere in bozza né svuotare il contenuto
const titled = (await asAdmin("PATCH", `/admin/blog/posts/${p.id}`, { title: "Stato aggiornato" })).json()
expect(titled).toMatchObject({ status: "published", contentMd: "Contenuto originale", publishedAt: pub.publishedAt })
const back = (await asAdmin("PATCH", `/admin/blog/posts/${p.id}`, { status: "draft" })).json()
expect(back.status).toBe("draft")
expect((await publicList()).total).toBe(0)
})
it("lista admin con bozze e filtro stato; DELETE; autore salvato", async () => {
await createPost({ title: "Bozza" })
const live = await createPost({ title: "Live", status: "published" })
expect((await asAdmin("GET", "/admin/blog/posts")).json().total).toBe(2)
expect((await asAdmin("GET", "/admin/blog/posts?status=draft")).json().items.map((p: { title: string }) => p.title)).toEqual(["Bozza"])
const [row] = await t.app.db.select().from(blogPosts).where(eq(blogPosts.id, live.id))
expect(row!.authorId).not.toBeNull()
expect((await asAdmin("DELETE", `/admin/blog/posts/${live.id}`)).statusCode).toBe(204)
expect((await asAdmin("GET", `/admin/blog/posts/${live.id}`)).statusCode).toBe(404)
})
it("categoria inesistente → 400; eliminare una categoria lascia i post senza categoria", async () => {
const bad = await asAdmin("POST", "/admin/blog/posts", { title: "X", categoryId: "00000000-0000-4000-8000-000000000000" })
expect(bad.json().error.code).toBe("CATEGORY_NOT_FOUND")
const cat = (await asAdmin("POST", "/admin/blog/categories", { name: "Acqua" })).json()
const p = await createPost({ title: "Con cat", categoryId: cat.id })
expect((await asAdmin("DELETE", `/admin/blog/categories/${cat.id}`)).statusCode).toBe(204)
expect((await asAdmin("GET", `/admin/blog/posts/${p.id}`)).json().category).toBeNull()
})
it("copertina: solo percorsi dell'area immagini", async () => {
expect((await asAdmin("POST", "/admin/blog/posts", { title: "X", coverPath: "../private/documents/x.pdf" })).statusCode).toBe(400)
expect((await asAdmin("POST", "/admin/blog/posts", { title: "X", coverPath: "documents/2026/10/x.pdf" })).statusCode).toBe(400)
})
})