Chiede le credenziali alle route che avvisano tutta la squadra (DD-024).
Le route in src/routes/api/public/ girano con la service role e saltano la RLS, quindi DD-023 non le copre. Nessuna faceva un controllo di accesso: cercando "authorization" in quella cartella l'unico header era lo User-Agent con cui csi.ts chiama il portale CSI. Chiunque conoscesse l'URL poteva far suonare i telefoni della squadra, e promemoria-palloni accetta perfino una POST con il corpo vuoto. La difesa apparente delle altre due — serve un id evento valido — non è una difesa: l'id è "e" più il timestamp in base 36, compare negli URL che la squadra si scambia ed è elencabile da qualsiasi utente loggato. auth-route.server.ts porta i due controlli, diversi perché i chiamanti sono diversi. apri-sondaggio e sollecita-presenze usano richiediAdmin: token della sessione verificato con auth.getUser, poi ruolo admin da user_roles, la stessa fonte di ruoli.ts. Il controllo precede la validazione dell'input, così la risposta non rivela nemmeno se un evento esiste. promemoria-palloni usa richiediSegreto, perché la chiama un cron che una sessione non ce l'ha: se CRON_SEGRETO non è configurata la route resta chiusa con 503, perché una porta che si riapre da sola quando manca una variabile non se ne accorge nessuno. csi, push-config, push-subscribe e push-messaggio restano aperte: le chiamano il browser prima del login e il service worker, dove qualsiasi segreto finirebbe nel bundle. Lato client i due pulsanti admin mandano il token con intestazioniAutenticate(), letto al momento della chiamata e non da uno stato React. permessi-route.test.ts copre il giro intero — nessun token, giocatore, admin — avviando il server di sviluppo puntato al database locale, perché servono utenti veri. Il controllo positivo è il 404: l'admin supera l'accesso e arriva alla validazione. In api.test.ts restano i rifiuti che non richiedono un utente e sparisce la verifica della validazione di sollecita-presenze, che ora sta dietro all'accesso. I limiti noti di palloni.md sono aggiornati: il secret che il piano originale prevedeva ora c'è. Resta vero che nessun cron chiama la route, quindi il promemoria quotidiano non parte da solo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+13
-8
@@ -17,12 +17,12 @@ non può inquinare gli altri.
|
||||
|
||||
## Struttura
|
||||
|
||||
| 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; permessi per ruolo (`permessi`) e semantica degli upsert (`scritture`) 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 | — |
|
||||
| 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; permessi per ruolo (`permessi`), accesso alle route di notifica (`permessi-route`) e semantica degli upsert (`scritture`) 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
|
||||
|
||||
@@ -36,10 +36,15 @@ 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 # permessi per ruolo
|
||||
bun test/integration/scritture.test.ts # semantica degli upsert
|
||||
bun test/integration/permessi.test.ts # permessi per ruolo sulle tabelle
|
||||
bun test/integration/permessi-route.test.ts # chi può far partire le notifiche
|
||||
bun test/integration/scritture.test.ts # semantica degli upsert
|
||||
```
|
||||
|
||||
`permessi-route` avvia il server di sviluppo **puntato al database locale** invece che al
|
||||
progetto di `.env`: gli serve creare utenti veri per provare i tre casi (nessun token,
|
||||
giocatore, amministratore).
|
||||
|
||||
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.
|
||||
|
||||
@@ -109,16 +109,46 @@ try {
|
||||
assert.equal(res.status, 400, "anche la cancellazione valida l'input");
|
||||
});
|
||||
|
||||
await prova("push-messaggio e sollecita-presenze rifiutano i payload non validi", async () => {
|
||||
await prova("push-messaggio rifiuta i payload non validi", async () => {
|
||||
assert.equal((await postJson("/api/public/push-messaggio", {})).status, 400);
|
||||
assert.equal((await postJson("/api/public/sollecita-presenze", {})).status, 400);
|
||||
});
|
||||
|
||||
// --- le route che mandano notifiche a tutti (DD-024) ------------------------
|
||||
// Il controllo di accesso viene prima della validazione: senza credenziali la
|
||||
// risposta non deve nemmeno dire se l'evento esiste.
|
||||
await prova("le route che avvisano la squadra chiedono le credenziali", async () => {
|
||||
for (const percorso of ["/api/public/sollecita-presenze", "/api/public/apri-sondaggio"]) {
|
||||
assert.equal(
|
||||
(await postJson(percorso, { eventoId: "non-esiste" })).status,
|
||||
401,
|
||||
`${percorso} senza token`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
(await postJson("/api/public/sollecita-presenze", { eventoId: "" })).status,
|
||||
400,
|
||||
"eventoId vuoto non è valido",
|
||||
(await postJson("/api/public/promemoria-palloni", {})).status < 400,
|
||||
false,
|
||||
"promemoria-palloni senza segreto non parte",
|
||||
);
|
||||
});
|
||||
|
||||
await prova("un token malformato non passa", async () => {
|
||||
const res = await fetch(url("/api/public/sollecita-presenze"), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: "Bearer non-un-jwt" },
|
||||
body: JSON.stringify({ eventoId: "non-esiste" }),
|
||||
});
|
||||
assert.equal(res.status, 401, "tre segmenti separati da punto, o niente");
|
||||
});
|
||||
|
||||
await prova("uno schema diverso da Bearer non passa", async () => {
|
||||
const res = await fetch(url("/api/public/sollecita-presenze"), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: "Basic aGVsbG86d29ybGQ=" },
|
||||
body: JSON.stringify({ eventoId: "non-esiste" }),
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
// --- endpoint che leggono dal database -------------------------------------
|
||||
if (haSupabase()) {
|
||||
await prova("push-messaggio risponde con il messaggio di default", async () => {
|
||||
@@ -130,10 +160,10 @@ try {
|
||||
assert.ok(dati.title && dati.body, "un endpoint sconosciuto riceve comunque un testo");
|
||||
});
|
||||
|
||||
await prova("sollecita-presenze su un evento inesistente risponde 404", async () => {
|
||||
const res = await postJson("/api/public/sollecita-presenze", { eventoId: "non-esiste" });
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
// La validazione dell'input di sollecita-presenze (400 sul corpo vuoto, 404
|
||||
// sull'evento inesistente) ora sta dietro al controllo di accesso: serve un
|
||||
// token di amministratore, che questo file non ha. La copre
|
||||
// `permessi-route.test.ts` sullo stack locale.
|
||||
} else {
|
||||
salta("endpoint con database", "SUPABASE_URL/SERVICE_ROLE_KEY non configurate");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Chi può far partire una notifica a tutta la squadra: `bun test/integration/permessi-route.test.ts`.
|
||||
*
|
||||
* Le route in `src/routes/api/public/` girano con la service role e saltano la RLS, quindi
|
||||
* il permesso deve stare nella route (DD-024). `api.test.ts` verifica solo i rifiuti che
|
||||
* non richiedono un utente; qui si prova il giro completo — nessun token, token di un
|
||||
* giocatore normale, token di un amministratore — e per farlo serve un database dove si
|
||||
* possano creare utenti veri.
|
||||
*
|
||||
* Quindi: solo stack locale (`npx supabase start`), e il server di sviluppo viene avviato
|
||||
* puntato lì invece che al progetto cloud di `.env`.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { statoLocale } from "../helpers/locale";
|
||||
import { avviaServer } from "../helpers/server";
|
||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||
|
||||
const locale = statoLocale();
|
||||
|
||||
if (!locale) {
|
||||
salta("permessi delle route", "stack locale non attivo (npx supabase start)");
|
||||
riepilogo("permessi route");
|
||||
} else {
|
||||
const { url: SUPABASE, anon: ANON, servizio: SERVIZIO } = locale;
|
||||
const SEGRETO = "segreto-di-prova";
|
||||
|
||||
// Il server di sviluppo eredita queste: le route leggono i nomi senza prefisso.
|
||||
process.env["SUPABASE_URL"] = SUPABASE;
|
||||
process.env["SUPABASE_PUBLISHABLE_KEY"] = ANON;
|
||||
process.env["SUPABASE_SERVICE_ROLE_KEY"] = SERVIZIO;
|
||||
process.env["CRON_SEGRETO"] = SEGRETO;
|
||||
|
||||
const PASSWORD = "prova-route-123";
|
||||
const idUtenti: string[] = [];
|
||||
|
||||
const authAdmin = { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` };
|
||||
|
||||
async function creaUtente(email: string): Promise<string> {
|
||||
const res = await fetch(`${SUPABASE}/auth/v1/admin/users`, {
|
||||
method: "POST",
|
||||
headers: { ...authAdmin, "content-type": "application/json" },
|
||||
body: JSON.stringify({ email, password: PASSWORD, email_confirm: true }),
|
||||
});
|
||||
const corpo = (await res.json()) as { id?: string };
|
||||
if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`);
|
||||
idUtenti.push(corpo.id);
|
||||
return corpo.id;
|
||||
}
|
||||
|
||||
async function accedi(email: string): Promise<string> {
|
||||
const res = await fetch(`${SUPABASE}/auth/v1/token?grant_type=password`, {
|
||||
method: "POST",
|
||||
headers: { apikey: ANON, "content-type": "application/json" },
|
||||
body: JSON.stringify({ email, password: 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 emailGiocatore = `test-route-giocatore-${Date.now()}@example.test`;
|
||||
const emailAdmin = `test-route-admin-${Date.now()}@example.test`;
|
||||
await creaUtente(emailGiocatore);
|
||||
const idAdmin = await creaUtente(emailAdmin);
|
||||
await fetch(`${SUPABASE}/rest/v1/user_roles`, {
|
||||
method: "POST",
|
||||
headers: { ...authAdmin, "content-type": "application/json" },
|
||||
body: JSON.stringify({ user_id: idAdmin, role: "admin" }),
|
||||
});
|
||||
|
||||
const tokenGiocatore = await accedi(emailGiocatore);
|
||||
const tokenAdmin = await accedi(emailAdmin);
|
||||
|
||||
const server = await avviaServer();
|
||||
console.log(`permessi route su ${server.baseUrl} (database ${SUPABASE})`);
|
||||
|
||||
const chiama = (percorso: string, intestazioni: Record<string, string> = {}) =>
|
||||
fetch(`${server.baseUrl}${percorso}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...intestazioni },
|
||||
body: JSON.stringify({ eventoId: "non-esiste" }),
|
||||
});
|
||||
|
||||
try {
|
||||
await prova("senza token la route non risponde nemmeno se l'evento esiste", async () => {
|
||||
assert.equal((await chiama("/api/public/sollecita-presenze")).status, 401);
|
||||
assert.equal((await chiama("/api/public/apri-sondaggio")).status, 401);
|
||||
});
|
||||
|
||||
await prova("un giocatore autenticato non avvisa la squadra", async () => {
|
||||
for (const percorso of ["/api/public/sollecita-presenze", "/api/public/apri-sondaggio"]) {
|
||||
const res = await chiama(percorso, { authorization: `Bearer ${tokenGiocatore}` });
|
||||
assert.equal(res.status, 403, `${percorso}: token valido ma senza ruolo`);
|
||||
}
|
||||
});
|
||||
|
||||
// Controllo positivo: l'admin deve superare il controllo di accesso e arrivare alla
|
||||
// validazione dell'input. Il 404 dice esattamente questo — è passato, e l'evento
|
||||
// inventato non esiste.
|
||||
await prova("un amministratore passa e arriva alla validazione", async () => {
|
||||
for (const percorso of ["/api/public/sollecita-presenze", "/api/public/apri-sondaggio"]) {
|
||||
const res = await chiama(percorso, { authorization: `Bearer ${tokenAdmin}` });
|
||||
assert.equal(res.status, 404, `${percorso}: superato l'accesso, evento inesistente`);
|
||||
}
|
||||
});
|
||||
|
||||
await prova("il promemoria palloni chiede il segreto del cron", async () => {
|
||||
const senza = await fetch(`${server.baseUrl}/api/public/promemoria-palloni`, {
|
||||
method: "POST",
|
||||
});
|
||||
assert.equal(senza.status, 401, "senza segreto non parte");
|
||||
|
||||
const sbagliato = await fetch(`${server.baseUrl}/api/public/promemoria-palloni`, {
|
||||
method: "POST",
|
||||
headers: { "x-cron-segreto": "non-e-questo" },
|
||||
});
|
||||
assert.equal(sbagliato.status, 401, "un segreto sbagliato vale come nessun segreto");
|
||||
|
||||
const giusto = await fetch(`${server.baseUrl}/api/public/promemoria-palloni`, {
|
||||
method: "POST",
|
||||
headers: { "x-cron-segreto": SEGRETO },
|
||||
});
|
||||
assert.equal(giusto.status, 200, "con il segreto giusto il job parte");
|
||||
});
|
||||
} finally {
|
||||
server.stop();
|
||||
for (const id of idUtenti) {
|
||||
await fetch(`${SUPABASE}/rest/v1/user_roles?user_id=eq.${id}`, {
|
||||
method: "DELETE",
|
||||
headers: authAdmin,
|
||||
});
|
||||
await fetch(`${SUPABASE}/auth/v1/admin/users/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: authAdmin,
|
||||
});
|
||||
}
|
||||
riepilogo("permessi route");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user