Verifica i permessi per ruolo contro il database Supabase locale.

Le policy RLS scritte su auth.uid() e il trigger di DD-016 non erano coperti da
nessun test: schema-profili prova solo l'utente anonimo, e sul database di .env
non si può scrivere perché è quello di produzione.

Il nuovo test/integration/permessi.test.ts crea utenti veri sullo stack locale
(npx supabase start) e interroga il database come loro: un giocatore vede e
modifica solo il proprio profilo, non ne cancella, non cambia numero e ruolo
mentre reclama uno slot, non prende lo slot di un altro, non si assegna il ruolo
admin e non vede i ruoli altrui. Un controllo positivo sull'admin evita il falso
verde di un database completamente chiuso.

Prende URL e chiavi da `supabase status` invece che da .env e si ferma se l'URL
non è locale: un .env puntato alla produzione non deve poter trasformare un test
in una scrittura sul database vero. Senza stack locale si salta con il motivo,
quindi la suite resta verde su una macchina senza Docker. Ogni test ripristina
in un finally lo stato che tocca, così si rilancia senza db reset.

test/README.md documenta il flusso Docker e corregge la convenzione: non è più
«nessun test scrive sul database» ma «sul database di .env non scrive nessuno».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 11:22:45 +02:00
co-authored by Claude Opus 5
parent 50a712c276
commit 94673090f1
2 changed files with 310 additions and 7 deletions
+46 -7
View File
@@ -20,18 +20,57 @@ 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. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar) | No |
| `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ì |
| `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, e i permessi per ruolo (`permessi`) sul database locale | 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 | — |
## Database locale in Docker
Alcune cose non si possono verificare senza scrivere: le policy RLS scritte su
`auth.uid()`, i trigger, i vincoli. Contro il progetto cloud quelle scritture non
si fanno, quindi servono un database usa e getta e utenti veri.
```bash
npx supabase start # avvia lo stack in Docker (migration applicate + seed.sql)
npx supabase status # URL e chiavi locali; Studio su http://127.0.0.1:54323
npx supabase db reset # ricrea il database da zero se i dati si sporcano
npx supabase stop # spegne tutto
bun test/integration/permessi.test.ts # i test che richiedono lo stack locale
```
Il primo `start` scarica le immagini (qualche minuto), i successivi partono in
una decina di secondi. Le mail finiscono in Mailpit (http://127.0.0.1:54324),
non escono dalla macchina.
I test che scrivono **non leggono `.env`**: prendono URL e chiavi da
`supabase status` e si fermano se l'URL non è `127.0.0.1`. È una cintura di
sicurezza, non una comodità: così un `.env` puntato alla produzione non può
trasformare un test in una scrittura sul database vero.
Ognuno ripristina lo stato che tocca (utenti creati, slot della rosa, colonne
modificate) in un `finally`, così la suite si può rilanciare all'infinito senza
un `db reset` in mezzo.
Due cose scoperte scrivendo questi test, utili a chi ne aggiunge:
- la **service key non è un amministratore**: per il trigger
`enforce_giocatori_squadra_update` (DD-016) `auth.uid()` è NULL, quindi ogni
UPDATE su `giocatori_squadra` fatto con la service key viene rifiutato. Per
collegare uno slot a un account serve il JWT di un utente con ruolo `admin`;
- su un UPDATE o un DELETE che non tocca nessuna riga PostgREST risponde 2xx.
Il codice di stato non basta: serve `Prefer: return=representation` e contare
le righe, oppure rileggere il dato.
## Convenzioni
- **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.
- **Sul database configurato in `.env` nessun test scrive.** 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 che scrivono davvero girano solo sul database locale (vedi sopra).
- I test legati alla migration M2 si **saltano da soli** dove quella migration non è
ancora applicata, indicandolo nel motivo. Per vederli tutti verdi serve un
database che la contenga: `npx supabase start` ne crea uno in locale.
+264
View File
@@ -0,0 +1,264 @@
/**
* Permessi per ruolo sul database locale: `bun test/integration/permessi.test.ts`.
*
* A differenza di `schema-profili`, che si limita a provare l'utente anonimo, qui si
* creano utenti veri e si interroga il database *come loro*: è l'unico modo per
* verificare le policy scritte su `auth.uid()` e la funzione `mio_giocatore_id()`.
*
* Il test **scrive**, quindi gira solo contro l'istanza locale di `npx supabase start`:
* le credenziali le legge da `supabase status`, non da `.env`, così non può puntare per
* sbaglio alla produzione. Senza stack locale si salta con il motivo.
*
* Stato toccato e ripristinato alla fine: gli utenti creati (cancellati), lo slot
* reclamato in `giocatori_squadra` e il telefono del profilo g1.
*/
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { prova, riepilogo, salta } from "../helpers/prova";
/** Credenziali dello stack locale. `null` se non è in esecuzione. */
function statoLocale(): { url: string; anon: string; servizio: string } | null {
const esito = spawnSync("npx", ["supabase", "status", "-o", "env"], {
encoding: "utf8",
timeout: 60_000,
});
if (esito.status !== 0) return null;
const leggi = (nome: string) =>
new RegExp(`^${nome}="?([^"\\n]+)"?$`, "m").exec(esito.stdout)?.[1] ?? "";
const url = leggi("API_URL");
const anon = leggi("ANON_KEY");
const servizio = leggi("SERVICE_ROLE_KEY");
if (!url || !anon || !servizio) return null;
// Cintura di sicurezza: questo file scrive, e su un database non locale non deve girare.
if (!/^https?:\/\/(127\.0\.0\.1|localhost)/.test(url)) return null;
return { url, anon, servizio };
}
const locale = statoLocale();
if (!locale) {
salta("permessi per ruolo", "stack locale non attivo (npx supabase start)");
riepilogo("permessi");
} else {
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
console.log(`permessi su ${URL_BASE}`);
const rest = (percorso: string, token: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: token === SERVIZIO ? SERVIZIO : ANON,
Authorization: `Bearer ${token}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
/** Numero di righe toccate da una scrittura: con `return=representation` è il corpo. */
const righeToccate = async (res: Response): Promise<number> => {
if (!res.ok) return 0;
const corpo = (await res.json()) as unknown[];
return Array.isArray(corpo) ? corpo.length : 0;
};
async function creaUtente(email: string, password: string): Promise<string> {
const res = await fetch(`${URL_BASE}/auth/v1/admin/users`, {
method: "POST",
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
},
body: JSON.stringify({ email, password, email_confirm: true }),
});
const corpo = (await res.json()) as { id?: string; msg?: string };
if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`);
return corpo.id;
}
async function accedi(email: string, password: string): Promise<string> {
const res = await fetch(`${URL_BASE}/auth/v1/token?grant_type=password`, {
method: "POST",
headers: { apikey: ANON, "content-type": "application/json" },
body: JSON.stringify({ email, password }),
});
const corpo = (await res.json()) as { access_token?: string };
if (!corpo.access_token) throw new Error(`accesso fallito: ${JSON.stringify(corpo)}`);
return corpo.access_token;
}
const eliminaUtente = (id: string) =>
fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, {
method: "DELETE",
headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` },
});
const PASSWORD = "prova-permessi-123";
const idUtenti: string[] = [];
let telefonoOriginale: string | null = null;
let tokenAdmin = "";
try {
// --- preparazione: un giocatore collegato a g1, un amministratore --------------
const emailGiocatore = `test-giocatore-${Date.now()}@example.test`;
const emailAdmin = `test-admin-${Date.now()}@example.test`;
const idGiocatore = await creaUtente(emailGiocatore, PASSWORD);
const idAdmin = await creaUtente(emailAdmin, PASSWORD);
idUtenti.push(idGiocatore, idAdmin);
await rest("user_roles", SERVIZIO, {
method: "POST",
body: JSON.stringify({ user_id: idAdmin, role: "admin" }),
});
const tokenGiocatore = await accedi(emailGiocatore, PASSWORD);
tokenAdmin = await accedi(emailAdmin, PASSWORD);
// La rosa si tocca con il JWT dell'admin, non con la service key: per il trigger
// `enforce_giocatori_squadra_update` la service key non è un amministratore
// (`auth.uid()` è NULL) e vede rifiutato qualsiasi UPDATE.
const collega = (id: string, utente: string | null) =>
rest(`giocatori_squadra?id=eq.${id}`, tokenAdmin, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ auth_user_id: utente }),
});
assert.equal(await righeToccate(await collega("g1", idGiocatore)), 1, "g1 collegato al test");
assert.equal(await righeToccate(await collega("g2", idAdmin)), 1, "g2 collegato all'admin");
const primaProfilo = await rest(
"profili_giocatore?giocatore_id=eq.g1&select=telefono",
SERVIZIO,
);
telefonoOriginale =
((await primaProfilo.json()) as Array<{ telefono: string | null }>)[0]?.telefono ?? null;
// --- profili: dati personali, la RLS è l'unica barriera -----------------------
// Senza queste due il documento d'identità di un compagno sarebbe leggibile da
// chiunque abbia un account.
await prova("il giocatore vede solo il proprio profilo", async () => {
const res = await rest("profili_giocatore?select=giocatore_id", tokenGiocatore);
assert.equal(res.status, 200);
const righe = (await res.json()) as Array<{ giocatore_id: string }>;
assert.deepEqual(
righe.map((r) => r.giocatore_id),
["g1"],
"solo il proprio profilo, mai quello degli altri",
);
});
await prova("il giocatore non modifica il profilo di un altro", async () => {
const res = await rest("profili_giocatore?giocatore_id=eq.g2", tokenGiocatore, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ telefono: "999" }),
});
assert.equal(await righeToccate(res), 0, "nessuna riga altrui aggiornata");
const dopo = await rest("profili_giocatore?giocatore_id=eq.g2&select=telefono", SERVIZIO);
const righe = (await dopo.json()) as Array<{ telefono: string | null }>;
assert.notEqual(righe[0]?.telefono, "999", "il telefono di g2 è rimasto quello di prima");
});
// Controllo positivo: senza questo, un database completamente rotto passerebbe
// tutti i test di negazione qui sopra.
await prova("il giocatore aggiorna il proprio profilo", async () => {
const res = await rest("profili_giocatore?giocatore_id=eq.g1", tokenGiocatore, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ telefono: "3331234567" }),
});
assert.equal(await righeToccate(res), 1, "il proprio profilo si aggiorna");
});
await prova("il giocatore non cancella profili", async () => {
const res = await rest("profili_giocatore?giocatore_id=eq.g2", tokenGiocatore, {
method: "DELETE",
headers: { Prefer: "return=representation" },
});
assert.equal(await righeToccate(res), 0, "la cancellazione è riservata agli admin");
const dopo = await rest("profili_giocatore?giocatore_id=eq.g2&select=giocatore_id", SERVIZIO);
assert.equal(((await dopo.json()) as unknown[]).length, 1, "il profilo g2 esiste ancora");
});
await prova("l'amministratore vede tutti i profili", async () => {
const res = await rest("profili_giocatore?select=giocatore_id", tokenAdmin);
assert.equal(res.status, 200);
const righe = (await res.json()) as unknown[];
assert.ok(righe.length > 1, `l'admin vede l'intero elenco, ne ha visti ${righe.length}`);
});
// --- rosa: il trigger di DD-016 ------------------------------------------------
// La policy da sola lascerebbe passare un UPDATE che cambia anche numero e ruolo:
// a chiudere il buco è il trigger `enforce_giocatori_squadra_update`.
await prova(
"reclamando uno slot libero il giocatore non cambia anche i suoi dati",
async () => {
const res = await rest("giocatori_squadra?id=eq.g3", tokenGiocatore, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ auth_user_id: idGiocatore, numero: 99 }),
});
assert.ok(!res.ok, `il trigger deve rifiutare, invece ha risposto ${res.status}`);
const dopo = await rest("giocatori_squadra?id=eq.g3&select=numero,auth_user_id", SERVIZIO);
const righe = (await dopo.json()) as Array<{ numero: number; auth_user_id: string | null }>;
assert.notEqual(righe[0]?.numero, 99, "il numero di maglia non è cambiato");
assert.equal(righe[0]?.auth_user_id ?? null, null, "lo slot g3 è rimasto libero");
},
);
await prova("il giocatore non prende lo slot già assegnato a un altro", async () => {
const res = await rest("giocatori_squadra?id=eq.g2", tokenGiocatore, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ auth_user_id: idGiocatore }),
});
assert.equal(await righeToccate(res), 0, "gli slot già occupati non si rivendicano");
const dopo = await rest("giocatori_squadra?id=eq.g2&select=auth_user_id", SERVIZIO);
const righe = (await dopo.json()) as Array<{ auth_user_id: string | null }>;
assert.equal(righe[0]?.auth_user_id, idAdmin, "g2 è rimasto del suo proprietario");
});
// --- ruoli: la scalata di privilegi --------------------------------------------
await prova("il giocatore non si assegna il ruolo admin", async () => {
const res = await rest("user_roles", tokenGiocatore, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ user_id: idGiocatore, role: "admin" }),
});
assert.ok(!res.ok, `l'auto-promozione deve fallire, invece ha risposto ${res.status}`);
const dopo = await rest(`user_roles?user_id=eq.${idGiocatore}&select=role`, SERVIZIO);
assert.equal(((await dopo.json()) as unknown[]).length, 0, "nessun ruolo assegnato");
});
await prova("il giocatore non vede i ruoli degli altri", async () => {
const res = await rest("user_roles?select=user_id,role", tokenGiocatore);
assert.equal(res.status, 200);
const righe = (await res.json()) as Array<{ user_id: string }>;
assert.ok(
righe.every((r) => r.user_id === idGiocatore),
"l'elenco degli amministratori non è pubblico",
);
});
} finally {
// Ripristino: prima gli slot (serve il JWT admin, il trigger rifiuta la service key),
// poi il telefono, infine gli utenti.
for (const id of ["g1", "g2"]) {
if (tokenAdmin) {
await rest(`giocatori_squadra?id=eq.${id}`, tokenAdmin, {
method: "PATCH",
body: JSON.stringify({ auth_user_id: null }),
});
}
}
await rest("profili_giocatore?giocatore_id=eq.g1", SERVIZIO, {
method: "PATCH",
body: JSON.stringify({ telefono: telefonoOriginale }),
});
for (const id of idUtenti) {
await rest(`user_roles?user_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
await eliminaUtente(id);
}
riepilogo("permessi");
}
}