Files
CRAPP/test/integration/permessi-route.test.ts
T
davideandClaude Opus 5 847972b582 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>
2026-09-06 12:05:39 +02:00

140 lines
5.7 KiB
TypeScript

/**
* 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");
}
}