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,227 @@
|
||||
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>',
|
||||
"",
|
||||
].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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user