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
358 lines
16 KiB
TypeScript
358 lines
16 KiB
TypeScript
import { eq, sql } from "drizzle-orm"
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
|
import { consents, installerProfiles, refreshTokens, users } from "../src/db/schema/index.js"
|
|
import { REFRESH_COOKIE, SESSION_HINT_COOKIE } from "../src/routes/public/auth.js"
|
|
import { hashPassword } from "../src/services/password.js"
|
|
import { hashToken } from "../src/services/tokens.js"
|
|
import { buildTestApp, cookieFrom, type TestApp, tokenFromEmail, truncateAll } from "./helpers.js"
|
|
|
|
const PASSWORD = "Password-di-test-1"
|
|
|
|
let t: TestApp
|
|
const db = () => t.app.db
|
|
|
|
const register = (body: Record<string, unknown>) =>
|
|
t.app.inject({
|
|
method: "POST",
|
|
url: "/auth/register",
|
|
payload: { password: PASSWORD, firstName: "Mario", lastName: "Rossi", acceptPrivacy: true, ...body },
|
|
})
|
|
|
|
const login = (email: string, password = PASSWORD) =>
|
|
t.app.inject({ method: "POST", url: "/auth/login", payload: { email, password } })
|
|
|
|
const refresh = (rt: string | undefined) =>
|
|
t.app.inject({ method: "POST", url: "/auth/refresh", cookies: rt ? { [REFRESH_COOKIE]: rt } : {} })
|
|
|
|
/** Crea un utente direttamente nel DB (es. admin, che non può registrarsi dalla route). */
|
|
async function createUser(email: string, role: "customer" | "admin" = "customer", extra: Partial<typeof users.$inferInsert> = {}) {
|
|
const [u] = await db()
|
|
.insert(users)
|
|
.values({ email, passwordHash: await hashPassword(PASSWORD), role, firstName: "T", lastName: "U", ...extra })
|
|
.returning()
|
|
return u!
|
|
}
|
|
|
|
/** Login e restituisce access token + refresh token (dal cookie). */
|
|
async function session(email: string) {
|
|
const res = await login(email)
|
|
expect(res.statusCode).toBe(200)
|
|
return { access: res.json().accessToken as string, rt: cookieFrom(res.headers["set-cookie"], REFRESH_COOKIE)! }
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
t = await buildTestApp({}, (app) => {
|
|
// Route di prova per requireRole
|
|
app.get("/__test/admin-only", { onRequest: [app.requireRole("admin")] }, async () => ({ ok: true }))
|
|
app.get("/__test/staff", { onRequest: [app.requireRole("admin", "installer")] }, async () => ({ ok: true }))
|
|
})
|
|
})
|
|
afterAll(() => t.app.close())
|
|
beforeEach(async () => {
|
|
await truncateAll()
|
|
t.email.clear()
|
|
})
|
|
|
|
describe("registrazione", () => {
|
|
it("crea un customer con consenso privacy, email di verifica e password hashata", async () => {
|
|
const res = await register({ email: " Mario.Rossi@Example.COM " })
|
|
expect(res.statusCode).toBe(202)
|
|
|
|
const [u] = await db().select().from(users)
|
|
expect(u).toMatchObject({ email: "mario.rossi@example.com", role: "customer", emailVerifiedAt: null })
|
|
expect(u!.passwordHash.startsWith("$argon2id$")).toBe(true)
|
|
expect(u!.passwordHash).not.toContain(PASSWORD)
|
|
|
|
const c = await db().select().from(consents).where(eq(consents.userId, u!.id))
|
|
expect(c.map((x) => x.type)).toEqual(["privacy"])
|
|
|
|
expect(t.email.last("verify-email")?.to).toBe("mario.rossi@example.com")
|
|
})
|
|
|
|
it("ignora un ruolo admin passato nel body", async () => {
|
|
await register({ email: "furbo@example.com", role: "admin", accountType: "customer" })
|
|
const [u] = await db().select().from(users)
|
|
expect(u!.role).toBe("customer")
|
|
})
|
|
|
|
it("rifiuta accountType admin", async () => {
|
|
const res = await register({ email: "furbo@example.com", accountType: "admin" })
|
|
expect(res.statusCode).toBe(400)
|
|
})
|
|
|
|
it("company con ragione sociale + P.IVA, anche senza accountType", async () => {
|
|
await register({ email: "azienda@example.com", companyName: "Rossi srl", vatNumber: "IT01234567890" })
|
|
const [u] = await db().select().from(users)
|
|
expect(u).toMatchObject({ role: "company", companyName: "Rossi srl", vatNumber: "IT01234567890" })
|
|
})
|
|
|
|
it("company senza P.IVA → 400 con dettaglio", async () => {
|
|
const res = await register({ email: "azienda@example.com", accountType: "company", companyName: "Rossi srl" })
|
|
expect(res.statusCode).toBe(400)
|
|
expect(res.json().error.details[0].path).toBe("body.vatNumber")
|
|
})
|
|
|
|
it("installer crea il profilo in stato pending", async () => {
|
|
await register({
|
|
email: "inst@example.com",
|
|
accountType: "installer",
|
|
companyName: "Impianti Rossi",
|
|
vatNumber: "IT09876543210",
|
|
acceptMarketing: true,
|
|
})
|
|
const [u] = await db().select().from(users)
|
|
expect(u!.role).toBe("installer")
|
|
const [p] = await db().select().from(installerProfiles).where(eq(installerProfiles.userId, u!.id))
|
|
expect(p).toMatchObject({ businessName: "Impianti Rossi", status: "pending" })
|
|
const c = await db().select().from(consents).where(eq(consents.userId, u!.id))
|
|
expect(c.map((x) => x.type).sort()).toEqual(["marketing", "privacy"])
|
|
})
|
|
|
|
it("senza consenso privacy → 400", async () => {
|
|
const res = await register({ email: "x@example.com", acceptPrivacy: false })
|
|
expect(res.statusCode).toBe(400)
|
|
})
|
|
|
|
it("email già registrata: stessa risposta, nessun nuovo utente, avviso al titolare", async () => {
|
|
const first = await register({ email: "dup@example.com" })
|
|
t.email.clear()
|
|
const second = await register({ email: "DUP@example.com", firstName: "Altro" })
|
|
expect(second.statusCode).toBe(first.statusCode)
|
|
expect(second.json()).toEqual(first.json())
|
|
expect(await db().select().from(users)).toHaveLength(1)
|
|
expect(t.email.last()?.tag).toBe("register-existing")
|
|
})
|
|
})
|
|
|
|
describe("login", () => {
|
|
it("ok: access token, utente e cookie di refresh con gli attributi richiesti", async () => {
|
|
await createUser("cliente@example.com")
|
|
const res = await login("Cliente@Example.com")
|
|
expect(res.statusCode).toBe(200)
|
|
const body = res.json()
|
|
expect(body.user).toMatchObject({ email: "cliente@example.com", role: "customer", emailVerified: false })
|
|
expect(body.expiresIn).toBe(900)
|
|
expect(body.accessToken.split(".")).toHaveLength(3)
|
|
|
|
const cookie = ([] as string[]).concat(res.headers["set-cookie"] ?? []).find((c) => c.startsWith(`${REFRESH_COOKIE}=`))!
|
|
expect(cookie).toMatch(/HttpOnly/i)
|
|
expect(cookie).toMatch(/Secure/i)
|
|
expect(cookie).toMatch(/SameSite=Lax/i)
|
|
expect(cookie).toMatch(/Path=\/auth/i)
|
|
|
|
// Indizio di sessione per i frontend: leggibile da JS, path "/", nessun segreto
|
|
const hint = ([] as string[]).concat(res.headers["set-cookie"] ?? []).find((c) => c.startsWith(`${SESSION_HINT_COOKIE}=`))!
|
|
expect(hint).toMatch(/^iti_session=1;/)
|
|
expect(hint).not.toMatch(/HttpOnly/i)
|
|
expect(hint).toMatch(/Path=\/;/i)
|
|
expect(hint).toMatch(/SameSite=Lax/i)
|
|
})
|
|
|
|
it("password errata, email inesistente e utente disabilitato → stesso 401", async () => {
|
|
await createUser("cliente@example.com")
|
|
await createUser("off@example.com", "customer", { disabledAt: new Date() })
|
|
const results = await Promise.all([
|
|
login("cliente@example.com", "sbagliata-123"),
|
|
login("nessuno@example.com"),
|
|
login("off@example.com"),
|
|
])
|
|
for (const r of results) {
|
|
expect(r.statusCode).toBe(401)
|
|
expect(r.json()).toEqual({ error: { code: "INVALID_CREDENTIALS", message: "Email o password non corretti" } })
|
|
}
|
|
})
|
|
|
|
it("GET /me con e senza access token", async () => {
|
|
await createUser("cliente@example.com")
|
|
const { access } = await session("cliente@example.com")
|
|
const me = await t.app.inject({ method: "GET", url: "/me", headers: { authorization: `Bearer ${access}` } })
|
|
expect(me.statusCode).toBe(200)
|
|
expect(me.json().email).toBe("cliente@example.com")
|
|
|
|
const anon = await t.app.inject({ method: "GET", url: "/me" })
|
|
expect(anon.statusCode).toBe(401)
|
|
expect(anon.json().error.code).toBe("UNAUTHORIZED")
|
|
|
|
const forged = await t.app.inject({ method: "GET", url: "/me", headers: { authorization: `Bearer ${access}x` } })
|
|
expect(forged.statusCode).toBe(401)
|
|
})
|
|
})
|
|
|
|
describe("refresh", () => {
|
|
it("ruota il token: nuovo cookie, nuovo access token, il precedente è revocato", async () => {
|
|
await createUser("cliente@example.com")
|
|
const { rt: rt1 } = await session("cliente@example.com")
|
|
|
|
const res = await refresh(rt1)
|
|
expect(res.statusCode).toBe(200)
|
|
expect(res.json().accessToken).toBeTruthy()
|
|
const rt2 = cookieFrom(res.headers["set-cookie"], REFRESH_COOKIE)!
|
|
expect(rt2).toBeTruthy()
|
|
expect(rt2).not.toBe(rt1)
|
|
|
|
const rows = await db().select().from(refreshTokens)
|
|
expect(rows).toHaveLength(2)
|
|
expect(new Set(rows.map((r) => r.familyId)).size).toBe(1)
|
|
expect(rows.find((r) => r.tokenHash === hashToken(rt1))?.revokedAt).not.toBeNull()
|
|
expect(rows.find((r) => r.tokenHash === hashToken(rt2))?.revokedAt).toBeNull()
|
|
|
|
// Il nuovo token funziona a sua volta
|
|
expect((await refresh(rt2)).statusCode).toBe(200)
|
|
})
|
|
|
|
it("riuso di un token revocato → 401 e revoca dell'intera family", async () => {
|
|
await createUser("cliente@example.com")
|
|
const { rt: rt1 } = await session("cliente@example.com")
|
|
const rt2 = cookieFrom((await refresh(rt1)).headers["set-cookie"], REFRESH_COOKIE)!
|
|
|
|
const reuse = await refresh(rt1)
|
|
expect(reuse.statusCode).toBe(401)
|
|
// Anche il token "legittimo" più recente non vale più
|
|
expect((await refresh(rt2)).statusCode).toBe(401)
|
|
const active = (await db().select().from(refreshTokens)).filter((r) => r.revokedAt === null)
|
|
expect(active).toHaveLength(0)
|
|
})
|
|
|
|
it("altre sessioni (family diverse) non sono toccate dal riuso", async () => {
|
|
await createUser("cliente@example.com")
|
|
const a = await session("cliente@example.com")
|
|
const b = await session("cliente@example.com")
|
|
await refresh(a.rt)
|
|
await refresh(a.rt) // riuso su A
|
|
expect((await refresh(b.rt)).statusCode).toBe(200)
|
|
})
|
|
|
|
it("senza cookie o con token sconosciuto → 401", async () => {
|
|
expect((await refresh(undefined)).statusCode).toBe(401)
|
|
expect((await refresh("token-inventato-abcdefghijklmnopqrstuvwxyz")).statusCode).toBe(401)
|
|
})
|
|
|
|
it("utente disabilitato dopo il login → refresh rifiutato", async () => {
|
|
const u = await createUser("cliente@example.com")
|
|
const { rt } = await session("cliente@example.com")
|
|
await db().update(users).set({ disabledAt: new Date() }).where(eq(users.id, u.id))
|
|
expect((await refresh(rt)).statusCode).toBe(401)
|
|
})
|
|
})
|
|
|
|
describe("logout", () => {
|
|
it("revoca la sessione e cancella il cookie", async () => {
|
|
await createUser("cliente@example.com")
|
|
const { rt } = await session("cliente@example.com")
|
|
|
|
const res = await t.app.inject({ method: "POST", url: "/auth/logout", cookies: { [REFRESH_COOKIE]: rt } })
|
|
expect(res.statusCode).toBe(204)
|
|
const cleared = ([] as string[]).concat(res.headers["set-cookie"] ?? []).find((c) => c.startsWith(`${REFRESH_COOKIE}=`))
|
|
expect(cleared).toMatch(/Expires=Thu, 01 Jan 1970|Max-Age=0/i)
|
|
const hint = ([] as string[]).concat(res.headers["set-cookie"] ?? []).find((c) => c.startsWith(`${SESSION_HINT_COOKIE}=`))
|
|
expect(hint).toMatch(/Expires=Thu, 01 Jan 1970|Max-Age=0/i)
|
|
|
|
expect((await refresh(rt)).statusCode).toBe(401)
|
|
})
|
|
|
|
it("senza cookie → 204 comunque", async () => {
|
|
expect((await t.app.inject({ method: "POST", url: "/auth/logout" })).statusCode).toBe(204)
|
|
})
|
|
})
|
|
|
|
describe("requireRole", () => {
|
|
it("blocca i ruoli non ammessi, consente quelli ammessi", async () => {
|
|
await createUser("cliente@example.com", "customer")
|
|
await createUser("admin@example.com", "admin")
|
|
const customer = await session("cliente@example.com")
|
|
const admin = await session("admin@example.com")
|
|
const get = (url: string, token?: string) =>
|
|
t.app.inject({ method: "GET", url, headers: token ? { authorization: `Bearer ${token}` } : {} })
|
|
|
|
const denied = await get("/__test/admin-only", customer.access)
|
|
expect(denied.statusCode).toBe(403)
|
|
expect(denied.json().error.code).toBe("FORBIDDEN")
|
|
expect((await get("/__test/admin-only", admin.access)).statusCode).toBe(200)
|
|
expect((await get("/__test/admin-only")).statusCode).toBe(401)
|
|
expect((await get("/__test/staff", admin.access)).statusCode).toBe(200)
|
|
expect((await get("/__test/staff", customer.access)).statusCode).toBe(403)
|
|
})
|
|
})
|
|
|
|
describe("reset password e verifica email", () => {
|
|
it("forgot-password: stessa risposta per email esistenti e non, link valido una sola volta", async () => {
|
|
await createUser("cliente@example.com")
|
|
const { rt } = await session("cliente@example.com")
|
|
|
|
const known = await t.app.inject({ method: "POST", url: "/auth/forgot-password", payload: { email: "cliente@example.com" } })
|
|
const unknown = await t.app.inject({ method: "POST", url: "/auth/forgot-password", payload: { email: "x@example.com" } })
|
|
expect(known.statusCode).toBe(202)
|
|
expect(unknown.json()).toEqual(known.json())
|
|
expect(t.email.sent.filter((m) => m.tag === "reset-password")).toHaveLength(1)
|
|
|
|
const token = tokenFromEmail(t.email.last("reset-password")?.text)
|
|
const reset = (password: string) =>
|
|
t.app.inject({ method: "POST", url: "/auth/reset-password", payload: { token, password } })
|
|
expect((await reset("Nuova-password-123")).statusCode).toBe(200)
|
|
expect((await reset("Altra-password-123")).json().error.code).toBe("INVALID_TOKEN")
|
|
|
|
expect((await login("cliente@example.com")).statusCode).toBe(401)
|
|
expect((await login("cliente@example.com", "Nuova-password-123")).statusCode).toBe(200)
|
|
// Le sessioni precedenti al reset sono chiuse
|
|
expect((await refresh(rt)).statusCode).toBe(401)
|
|
})
|
|
|
|
it("verify-email conferma l'indirizzo, il link vale una sola volta", async () => {
|
|
await register({ email: "nuovo@example.com" })
|
|
const token = tokenFromEmail(t.email.last("verify-email")?.text)
|
|
const verify = () => t.app.inject({ method: "POST", url: "/auth/verify-email", payload: { token } })
|
|
|
|
expect((await verify()).statusCode).toBe(200)
|
|
expect((await verify()).statusCode).toBe(400)
|
|
const { access } = await session("nuovo@example.com")
|
|
const me = await t.app.inject({ method: "GET", url: "/me", headers: { authorization: `Bearer ${access}` } })
|
|
expect(me.json().emailVerified).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe("niente segreti in chiaro", () => {
|
|
it("token e password non compaiono né nel DB né nei log", async () => {
|
|
await register({ email: "segreti@example.com" })
|
|
const verifyToken = tokenFromEmail(t.email.last("verify-email")?.text)
|
|
await t.app.inject({ method: "POST", url: "/auth/verify-email", payload: { token: verifyToken } })
|
|
const res = await login("segreti@example.com")
|
|
const { accessToken } = res.json()
|
|
const rt = cookieFrom(res.headers["set-cookie"], REFRESH_COOKIE)!
|
|
const rotated = cookieFrom((await refresh(rt)).headers["set-cookie"], REFRESH_COOKIE)!
|
|
await t.app.inject({ method: "POST", url: "/auth/forgot-password", payload: { email: "segreti@example.com" } })
|
|
const resetToken = tokenFromEmail(t.email.last("reset-password")?.text)
|
|
|
|
const secrets = [PASSWORD, verifyToken, rt, rotated, resetToken, accessToken]
|
|
|
|
// DB: dump testuale delle tabelle con token/password
|
|
const dump = JSON.stringify(
|
|
await db().execute(
|
|
sql.raw(`SELECT (SELECT json_agg(u) FROM users u) AS u, (SELECT json_agg(r) FROM refresh_tokens r) AS r,
|
|
(SELECT json_agg(p) FROM password_reset_tokens p) AS p, (SELECT json_agg(e) FROM email_verification_tokens e) AS e`),
|
|
),
|
|
)
|
|
for (const s of secrets) expect(dump).not.toContain(s)
|
|
|
|
// Log: richieste, risposte, header (authorization/cookie sono redatti)
|
|
await t.app.inject({ method: "GET", url: "/me", headers: { authorization: `Bearer ${accessToken}` } })
|
|
expect(t.logs.lines.length).toBeGreaterThan(0)
|
|
for (const s of secrets) expect(t.logs.text).not.toContain(s)
|
|
})
|
|
})
|
|
|
|
describe("rate limit", () => {
|
|
it("login: oltre il limite per IP → 429", async () => {
|
|
const limited = await buildTestApp({ AUTH_LOGIN_RATE_MAX: "3" })
|
|
try {
|
|
const codes = []
|
|
for (let i = 0; i < 4; i++) {
|
|
const r = await limited.app.inject({ method: "POST", url: "/auth/login", payload: { email: "a@example.com", password: "x" } })
|
|
codes.push(r.statusCode)
|
|
}
|
|
expect(codes).toEqual([401, 401, 401, 429])
|
|
} finally {
|
|
await limited.app.close()
|
|
}
|
|
})
|
|
})
|