diff --git a/test/README.md b/test/README.md index 9bba501..6f2cffa 100644 --- a/test/README.md +++ b/test/README.md @@ -20,7 +20,7 @@ non può inquinare gli altri. | Cartella | Cosa verifica | Serve rete? | |---|---|---| | `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa | No | -| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input | Sì | +| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato | Sì | | `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì | | `helpers/` | Avvio del server di test e mini-harness condiviso | — | @@ -28,6 +28,13 @@ non può inquinare gli altri. - **Nessun test scrive sul database.** Integration ed e2e fanno solo letture e verifiche di validazione: si possono lanciare anche contro l'ambiente reale. + L'unica eccezione apparente è `schema-profili`, che *tenta* scritture da utente + anonimo proprio per dimostrare che la RLS le respinge, e poi rilegge la riga per + verificare che non sia cambiata: su un UPDATE a zero righe PostgREST risponde 2xx, + quindi lo stato conta più del codice di risposta. +- I test legati alle migration M2/M3 si **saltano da soli** dove quelle migration non + sono ancora applicate, indicandolo nel motivo. Per vederli tutti verdi serve un + database che le contenga: `npx supabase start` ne crea uno in locale. - `integration` ed `e2e` avviano da soli il server di sviluppo. Per usarne uno già attivo: `BASE_URL=http://localhost:8080 npm run test:e2e`. - Le variabili d'ambiente vengono lette da `.env`; i nomi senza prefisso diff --git a/test/e2e/percorsi.test.ts b/test/e2e/percorsi.test.ts index aea1fbf..f2c2331 100644 --- a/test/e2e/percorsi.test.ts +++ b/test/e2e/percorsi.test.ts @@ -48,6 +48,7 @@ try { ["/profilo", /Profilo|CrAPP/], ["/eventi", /Eventi|CrAPP/], ["/scout", /Scout|CrAPP/], + ["/admin", /Dashboard|CrAPP/], ]; for (const [percorso, atteso] of attesi) { assert.match(titolo(await pagina(percorso)), atteso, `${percorso}: titolo corretto`); diff --git a/test/integration/schema-profili.test.ts b/test/integration/schema-profili.test.ts new file mode 100644 index 0000000..28fd4b4 --- /dev/null +++ b/test/integration/schema-profili.test.ts @@ -0,0 +1,140 @@ +/** + * Schema e permessi del Profilo Giocatore: `bun test/integration/schema-profili.test.ts`. + * + * Verifica contro un database vero (locale con `npx supabase start`, oppure quello + * configurato in `.env`) le tre cose che il codice dà per scontate: le colonne della + * tabella, la chiusura verso l'utente anonimo e il bucket privato. + * + * Salta con un motivo esplicito quando mancano le credenziali o quando le migration + * M2/M3 non sono ancora applicate a quel database: sono stati dell'ambiente, non difetti. + */ +import assert from "node:assert/strict"; +import { COLONNE_PROFILO } from "@/lib/profili-core"; +import { envDaFile } from "../helpers/server"; +import { prova, riepilogo, salta } from "../helpers/prova"; + +const env = { ...envDaFile(), ...process.env }; +const URL_BASE = env["SUPABASE_URL"]; +const CHIAVE_SERVIZIO = env["SUPABASE_SERVICE_ROLE_KEY"]; +const CHIAVE_PUBBLICA = env["SUPABASE_PUBLISHABLE_KEY"]; + +/** Le chiavi nuove sono opache e viaggiano solo in `apikey`; quelle legacy sono JWT. */ +function intestazioni(chiave: string): Record { + const base: Record = { apikey: chiave, "content-type": "application/json" }; + if (chiave.startsWith("eyJ")) base["Authorization"] = `Bearer ${chiave}`; + return base; +} + +const rest = (percorso: string, chiave: string, init?: RequestInit) => + fetch(`${URL_BASE}/rest/v1/${percorso}`, { + ...init, + headers: { ...intestazioni(chiave), ...(init?.headers ?? {}) }, + }); + +const bucketProfili = (chiave: string) => + fetch(`${URL_BASE}/storage/v1/bucket/profili-giocatore`, { headers: intestazioni(chiave) }); + +console.log(`schema profili su ${URL_BASE ?? "(non configurato)"}`); + +if (!URL_BASE || !CHIAVE_SERVIZIO || !CHIAVE_PUBBLICA) { + salta("schema e permessi dei profili", "credenziali Supabase non configurate"); + riepilogo("schema profili"); +} else { + try { + // --- M1: anagrafica della squadra ------------------------------------------ + await prova("la rosa di M1 è popolata e collegabile agli account", async () => { + const res = await rest( + "giocatori_squadra?select=id,nome,cognome,auth_user_id,attivo", + CHIAVE_SERVIZIO, + ); + assert.equal(res.status, 200); + const righe = (await res.json()) as Array<{ id: string }>; + assert.ok(righe.length >= 17, `attesi almeno 17 giocatori, trovati ${righe.length}`); + assert.ok( + righe.every((r) => /^g[0-9]+$/.test(r.id)), + "gli ID restano nel formato g1..gN (DD-012)", + ); + }); + + // Attenzione al falso verde: su un UPDATE che non tocca nessuna riga PostgREST + // risponde comunque 2xx. Quello che conta è che il dato non cambi. + await prova("un anonimo non si collega a uno slot della rosa", async () => { + const res = await rest("giocatori_squadra?id=eq.g1", CHIAVE_PUBBLICA, { + method: "PATCH", + headers: { Prefer: "return=representation" }, + body: JSON.stringify({ auth_user_id: "00000000-0000-0000-0000-000000000000" }), + }); + if (res.ok) { + const aggiornate = (await res.json()) as unknown[]; + assert.equal(aggiornate.length, 0, "nessuna riga aggiornata senza sessione"); + } + const dopo = await rest("giocatori_squadra?id=eq.g1&select=auth_user_id", CHIAVE_SERVIZIO); + const righe = (await dopo.json()) as Array<{ auth_user_id: string | null }>; + assert.equal(righe[0]?.auth_user_id ?? null, null, "lo slot g1 è rimasto libero"); + }); + + // --- M2: tabella dei profili ----------------------------------------------- + const m2 = await rest("profili_giocatore?select=giocatore_id&limit=1", CHIAVE_SERVIZIO); + if (!m2.ok) { + salta("schema e permessi di profili_giocatore", "M2 non applicata (npx supabase db push)"); + } else { + await prova("profili_giocatore espone tutte le colonne che il codice legge", async () => { + const colonne = COLONNE_PROFILO.replace(/\s/g, ""); + const res = await rest(`profili_giocatore?select=${colonne}&limit=1`, CHIAVE_SERVIZIO); + const corpo = await res.text(); + assert.equal(res.status, 200, corpo); + assert.ok(Array.isArray(JSON.parse(corpo))); + }); + + await prova("il documento ha due facciate separate", async () => { + const res = await rest( + "profili_giocatore?select=documento_fronte_path,documento_retro_path&limit=1", + CHIAVE_SERVIZIO, + ); + assert.equal(res.status, 200, "fronte e retro sono colonne distinte"); + }); + + // Il punto della RLS: senza sessione non si legge e non si scrive. È la garanzia su + // cui si regge tutto il modulo, e a nessuno serve fidarsi della prosa. + await prova("un anonimo non legge i profili", async () => { + const res = await rest("profili_giocatore?select=giocatore_id", CHIAVE_PUBBLICA); + if (res.ok) { + const righe = (await res.json()) as unknown[]; + assert.equal(righe.length, 0, "nessuna riga visibile senza sessione"); + } else { + assert.ok(res.status >= 400, `accesso negato (${res.status})`); + } + }); + + await prova("un anonimo non scrive i profili", async () => { + const res = await rest("profili_giocatore", CHIAVE_PUBBLICA, { + method: "POST", + headers: { Prefer: "return=representation" }, + body: JSON.stringify({ giocatore_id: "g1", telefono: "000" }), + }); + assert.ok(!res.ok, `la scrittura anonima deve fallire, invece ha risposto ${res.status}`); + }); + } + + // --- M3: bucket privato ------------------------------------------------------ + const m3 = await bucketProfili(CHIAVE_SERVIZIO); + if (!m3.ok) { + salta("bucket dei documenti", "M3 non applicata (npx supabase db push)"); + } else { + await prova("il bucket dei documenti è privato", async () => { + const bucket = (await m3.json()) as { public?: boolean }; + assert.equal(bucket.public, false, "documenti e certificati non sono mai pubblici"); + }); + + await prova("un anonimo non scarica i file dei profili", async () => { + const res = await fetch( + `${URL_BASE}/storage/v1/object/profili-giocatore/g1/certificato.pdf`, + { headers: intestazioni(CHIAVE_PUBBLICA) }, + ); + assert.ok(!res.ok, `nessun accesso anonimo allo storage (${res.status})`); + }); + } + } finally { + riepilogo("schema profili"); + } +} diff --git a/test/unit/profili-core.test.ts b/test/unit/profili-core.test.ts new file mode 100644 index 0000000..6f4e26e --- /dev/null +++ b/test/unit/profili-core.test.ts @@ -0,0 +1,165 @@ +/** Check dei profili giocatore: `bun test/unit/profili-core.test.ts`. */ +import assert from "node:assert/strict"; +import { + aRigaProfilo, + completamento, + csvTesseramento, + daRigaProfilo, + sezioniComplete, + statoScadenza, + type Profilo, +} from "@/lib/profili-core"; +import { + dividiNome, + rosaFallback, + slotDi, + slotLiberi, + type GiocatoreSquadra, +} from "@/lib/giocatori-squadra"; + +const vuoto: Profilo = { + giocatoreId: "g1", + dataNascita: null, + luogoNascita: null, + indirizzo: null, + telefono: null, + email: null, + documentoTipo: null, + documentoNumero: null, + documentoRilasciatoDa: null, + documentoEmissione: null, + documentoScadenza: null, + documentoFrontePath: null, + documentoRetroPath: null, + certificatoScadenza: null, + certificatoPath: null, + fotoPath: null, +}; + +const completo: Profilo = { + ...vuoto, + dataNascita: "1995-05-01", + luogoNascita: "Bologna", + indirizzo: "Via Roma 1", + telefono: "3331234567", + email: "ivan@example.com", + documentoTipo: "Carta d'identità", + documentoNumero: "CA12345", + documentoRilasciatoDa: "Comune di Bologna", + documentoEmissione: "2020-01-01", + documentoScadenza: "2030-01-01", + documentoFrontePath: "g1/documento-fronte.jpg", + documentoRetroPath: "g1/documento-retro.jpg", + certificatoScadenza: "2027-06-30", + certificatoPath: "g1/certificato.pdf", + fotoPath: "g1/foto.jpg", +}; + +// --- completamento ----------------------------------------------------------- +assert.equal(completamento(null), 0, "profilo inesistente = 0%"); +assert.equal(completamento(vuoto), 0); +assert.equal(completamento(completo), 100, "tutte le sezioni piene = 100%"); +assert.equal(completamento({ ...completo, fotoPath: null }), 90, "la foto pesa 10"); +assert.equal(completamento({ ...completo, certificatoPath: null }), 70, "il certificato pesa 30"); +assert.equal( + completamento({ ...completo, email: null }), + 70, + "i dati personali sono completi solo tutti insieme", +); + +// I metadati senza file (o viceversa) non contano come sezione completa. +assert.equal(sezioniComplete({ ...completo, certificatoScadenza: null }).certificato, false); +assert.equal( + sezioniComplete({ ...completo, documentoRetroPath: null }).documento, + false, + "il documento vale solo con fronte e retro", +); +assert.equal(sezioniComplete({ ...completo, documentoFrontePath: null }).documento, false); + +// --- aRigaProfilo ------------------------------------------------------------ +const riga = aRigaProfilo({ ...completo, luogoNascita: " ", telefono: " 333 " }); +assert.equal(riga.luogo_nascita, null, "i campi solo-spazi tornano NULL, non stringa vuota"); +assert.equal(riga.telefono, "333", "il resto viene ripulito ai bordi"); +assert.equal(riga.documento_fronte_path, "g1/documento-fronte.jpg"); +assert.deepEqual( + daRigaProfilo(aRigaProfilo(completo)), + completo, + "modello -> riga -> modello non perde niente", +); + +// --- statoScadenza ----------------------------------------------------------- +const oggi = "2026-08-30"; +assert.equal(statoScadenza(null, null, oggi), "mancante"); +assert.equal(statoScadenza("2027-01-01", null, oggi), "mancante", "senza file non vale"); +assert.equal(statoScadenza(null, "g1/cert.pdf", oggi), "mancante", "senza data non vale"); +assert.equal(statoScadenza("2026-08-29", "g1/cert.pdf", oggi), "scaduto"); +assert.equal( + statoScadenza("2026-08-30", "g1/cert.pdf", oggi), + "valido", + "scade oggi = ancora valido", +); +assert.equal(statoScadenza("2026-12-31", "g1/cert.pdf", oggi), "valido"); + +// --- csvTesseramento --------------------------------------------------------- +const squadra: GiocatoreSquadra[] = [ + { + id: "g1", + nome: "Ivan", + cognome: "Cacciari", + numero: 23, + ruolo: "Banda", + authUserId: null, + attivo: true, + }, + { + id: "g2", + nome: "Anna", + cognome: 'De "Rossi"', + numero: 7, + ruolo: "Libero", + authUserId: "u2", + attivo: true, + }, +]; +const csv = csvTesseramento(squadra, { g1: completo }); +const righe = csv.split("\n"); +assert.equal(righe.length, 3, "intestazione + un giocatore per riga"); +assert.equal(righe[0]?.split(";").length, 12, "i 12 campi richiesti dal CSI"); +assert.match(righe[1] ?? "", /^Ivan;Cacciari;1995-05-01;Bologna/); +assert.match( + righe[2] ?? "", + /^Anna;"De ""Rossi""";;;/, + "chi non ha profilo esce con i campi vuoti", +); + +// --- anagrafica squadra ------------------------------------------------------ +assert.deepEqual(dividiNome("Ivan Cacciari"), { nome: "Ivan", cognome: "Cacciari" }); +assert.deepEqual( + dividiNome("Carlo Di Castelnuovo"), + { nome: "Carlo", cognome: "Di Castelnuovo" }, + "il cognome composto resta intero", +); +assert.deepEqual(dividiNome("Ivan"), { nome: "Ivan", cognome: "" }); + +assert.equal(slotDi(squadra, null), null, "senza sessione nessuno slot"); +assert.equal(slotDi(squadra, "u2")?.id, "g2"); +assert.equal(slotDi(squadra, "sconosciuto"), null); +assert.deepEqual( + slotLiberi(squadra).map((g) => g.id), + ["g1"], + "uno slot già collegato non è più libero", +); +assert.deepEqual( + slotLiberi([...squadra, { ...squadra[0]!, id: "g3", attivo: false }]).map((g) => g.id), + ["g1"], + "i giocatori non attivi restano fuori", +); + +const fallback = rosaFallback(); +assert.ok(fallback.length > 0, "il fallback da crapp-data non è mai vuoto"); +assert.ok( + fallback.every((g) => g.authUserId === null && g.attivo), + "il fallback non può collegare account", +); + +console.log("profili-core: ok"); diff --git a/test/unit/ruoli.test.ts b/test/unit/ruoli.test.ts new file mode 100644 index 0000000..90d80df --- /dev/null +++ b/test/unit/ruoli.test.ts @@ -0,0 +1,24 @@ +/** Check dei permessi di amministrazione: `bun test/unit/ruoli.test.ts`. */ +import assert from "node:assert/strict"; +import { risolviAdmin } from "@/lib/ruoli"; +import { adminNomi, giocatori } from "@/lib/crapp-data"; + +const referente = giocatori.find((g) => adminNomi.includes(g.nome))!; +const chiunque = giocatori.find((g) => !adminNomi.includes(g.nome))!; + +// Con una sessione attiva decide il database, e basta: la lista di nomi non conta più. +assert.equal(risolviAdmin(true, chiunque.id), true, "il ruolo nel database concede"); +assert.equal( + risolviAdmin(false, referente.id), + false, + "il ruolo nel database nega anche a chi è nella lista dei nomi", +); +assert.equal(risolviAdmin(true, null), true); + +// Senza sessione (null) resta il ponte temporaneo sulla lista di nomi. +assert.equal(risolviAdmin(null, referente.id), true); +assert.equal(risolviAdmin(null, chiunque.id), false); +assert.equal(risolviAdmin(null, null), false, "nessuna identità, nessun permesso"); +assert.equal(risolviAdmin(null, "gXX"), false, "un id sconosciuto non concede nulla"); + +console.log("ruoli: ok");