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

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()
}
})
})