Files
CRAPP/test/integration/schema-profili.test.ts
T
davideandClaude Sonnet 5 c1744d4087 Sposta avatar e documenti profilo su PocketBase (file field)
Sostituisce i due bucket Supabase Storage con campi file PocketBase:

- avatar-giocatori: nuova collection avatar_giocatori separata da
  giocatori_squadra, perché la policy originale ("chiunque autenticato
  carica/sostituisce/elimina qualsiasi avatar, nessun controllo
  proprietario") è più permissiva delle regole di giocatori_squadra —
  mescolarle avrebbe indebolito le une o bloccato l'altra. File pubblico,
  come il bucket originale;
- profili-giocatore: campi file su profili_giocatore stesso, con
  protected: true (vedi sotto).

Trovato e corretto un problema di sicurezza reale: PocketBase rende i
file pubblici di default (si affida solo alla casualità del nome file),
a meno di impostare esplicitamente protected: true sul campo — i
documenti d'identità e i certificati medici sarebbero stati raggiungibili
senza autenticazione, in violazione di DD-016 regola 4. Verificato che
ora servono un token valido (404 senza, 200 con).

La scrittura dei campi testuali del profilo e il caricamento dei file
sono due operazioni separate (PocketBase rifiuta una stringa dove si
aspetta un file): verificato che caricare un documento non cancella i
dati testuali già salvati.

Rimossi da profili-core.ts RigaProfilo/daRigaProfilo/aRigaProfilo
(shape Postgres non più usata da nessun modulo di produzione) e
rimuoviFile (mai chiamato da nessun componente). Avatar.tsx non usa più
un URL deterministico per giocatore: PocketBase genera nomi file
casuali, quindi legge la mappa avatar tramite una query React Query
condivisa tra tutte le istanze del componente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 14:58:54 +02:00

162 lines
7.3 KiB
TypeScript

/**
* 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 la migration
* M2 non è ancora applicata a quel database: sono stati dell'ambiente, non difetti.
*/
import assert from "node:assert/strict";
import { envDaFile } from "../helpers/server";
// Elenco storico delle colonne Supabase di profili_giocatore (M2/DD-016). Non più importato
// da src/lib/profili-core.ts da quando il livello dati è passato a PocketBase (Fase 4 della
// migrazione): questo test valida ancora lo schema Supabase, quindi lo tiene qui in locale.
const COLONNE_PROFILO =
"giocatore_id, data_nascita, luogo_nascita, indirizzo, telefono, email, documento_tipo, documento_numero, documento_rilasciato_da, documento_emissione, documento_scadenza, documento_fronte_path, documento_retro_path, certificato_scadenza, certificato_path, foto_path";
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<string, string> {
const base: Record<string, string> = { 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");
});
// --- M8: tracciamento tesseramento CSI --------------------------------------
const m8 = await rest(
"giocatori_squadra?select=numero_tessera,data_tessera&limit=1",
CHIAVE_SERVIZIO,
);
if (!m8.ok) {
salta(
"colonne di tesseramento in giocatori_squadra",
"M8 non applicata (npx supabase db push)",
);
} else {
await prova("giocatori_squadra espone numero e data della tessera CSI", async () => {
assert.equal(m8.status, 200);
});
}
// --- 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}`);
});
}
// --- M2: bucket privato -------------------------------------------------------
const bucketRes = await bucketProfili(CHIAVE_SERVIZIO);
if (!bucketRes.ok) {
salta("bucket dei documenti", "M2 non applicata (npx supabase db push)");
} else {
await prova("il bucket dei documenti è privato", async () => {
const bucket = (await bucketRes.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");
}
}