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:
2026-09-06 12:05:39 +02:00
co-authored by Claude Opus 5
parent 93b61d422a
commit 847972b582
13 changed files with 377 additions and 27 deletions
+63
View File
@@ -38,6 +38,8 @@ Serve a rispondere a domande del tipo:
| [DD-020](#dd-020--una-funzione-modificata-senza-test-non-è-finita) | Test obbligatori e verdi | | [DD-020](#dd-020--una-funzione-modificata-senza-test-non-è-finita) | Test obbligatori e verdi |
| [DD-021](#dd-021--molle-interrompibili-al-posto-delle-animazioni-a-durata-fissa) | Molle interrompibili con motion | | [DD-021](#dd-021--molle-interrompibili-al-posto-delle-animazioni-a-durata-fissa) | Molle interrompibili con motion |
| [DD-022](#dd-022--lapp-è-solo-chiara) | App solo chiara | | [DD-022](#dd-022--lapp-è-solo-chiara) | App solo chiara |
| [DD-023](#dd-023--ogni-scrittura-è-limitata-a-chi-la-fa) | Scritture limitate per ruolo |
| [DD-024](#dd-024--le-route-che-avvisano-la-squadra-chiedono-le-credenziali) | Route di notifica autenticate |
**In valutazione** **In valutazione**
@@ -808,3 +810,64 @@ all'account (`benvenuto.tsx`), e senza slot non si entra.
Se l'anonimato delle pagelle deve diventare reale (servirebbe una vista aggregata e la Se l'anonimato delle pagelle deve diventare reale (servirebbe una vista aggregata e la
chiusura della lettura riga per riga), o se turni e scout acquistano un gate chiusura della lettura riga per riga), o se turni e scout acquistano un gate
nell'interfaccia: allora le loro policy devono seguirlo. nell'interfaccia: allora le loro policy devono seguirlo.
### DD-024 — Le route che avvisano la squadra chiedono le credenziali
**Data:** 6 settembre 2026
**Stato:** Accettata
**Contesto**
Le route in `src/routes/api/public/` girano con la service role e saltano la RLS: DD-023 non
le tocca. Nessuna di loro 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 quindi far suonare i telefoni di tutta la squadra:
`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` + il timestamp in base 36 (`nuovoIdEvento()`), compare negli URL che la squadra si
scambia, ed è elencabile da qualsiasi utente loggato.
Il danno non è furto di dati: i testi delle notifiche li costruisce il server. È molestia e
consumo della quota push. Non è però una ragione per lasciare la porta aperta.
**Decisione**
Le tre route che inviano notifiche chiedono le credenziali, con due controlli diversi perché
i chiamanti sono diversi (`src/lib/auth-route.server.ts`):
- `apri-sondaggio` e `sollecita-presenze``richiediAdmin`: token della sessione Supabase
verificato con `auth.getUser`, poi ruolo `admin` letto da `user_roles`, la stessa fonte di
`src/lib/ruoli.ts` (DD-011). `401` senza token valido, `403` con token ma senza ruolo. Il
controllo viene **prima** della validazione dell'input, così la risposta non rivela
nemmeno se un evento esiste.
- `promemoria-palloni``richiediSegreto`: intestazione `x-cron-segreto` confrontata con la
variabile `CRON_SEGRETO`. La chiama un cron, che una sessione non ce l'ha.
`csi`, `push-config`, `push-subscribe` e `push-messaggio` restano aperte: le chiamano il
browser prima del login e il service worker, dove qualsiasi segreto sarebbe pubblico.
**Alternative scartate**
- Un segreto condiviso anche per le due route dell'app → finirebbe nel bundle JavaScript,
cioè pubblico.
- Il middleware `requireSupabaseAuth` già presente nel repository → è
`createMiddleware({ type: "function" })`, protegge le server function di TanStack Start. In
CrAPP `createServerFn` non compare da nessuna parte: quel file non è mai stato eseguito,
e non si applica comunque alle route in `src/routes/api/`.
- Fidarsi dell'id evento come credenziale → è un timestamp in un URL condiviso.
**Conseguenze**
- Se `CRON_SEGRETO` non è configurata, `promemoria-palloni` risponde `503` e il promemoria
non parte. È voluto: una porta che si riapre da sola quando manca una variabile
d'ambiente non se ne accorge nessuno. Va impostata negli ambienti di deploy e nel cron.
- I due pulsanti dell'app mandano ora il token con `intestazioniAutenticate()`
(`src/lib/auth.ts`), letto al momento della chiamata e non da uno stato React, così non si
spedisce un token scaduto.
- `api.test.ts` non può più verificare la validazione dell'input di `sollecita-presenze`,
che ora sta dietro all'accesso: quel pezzo si è spostato in
`test/integration/permessi-route.test.ts`, che gira sullo stack locale perché ha bisogno
di utenti veri.
**Riesame**
Se un giorno l'app userà `createServerFn`, il middleware già presente diventa la strada
naturale e questi controlli vanno riletti alla sua luce.
+20 -2
View File
@@ -59,6 +59,21 @@ L'invio effettivo (`src/lib/webpush.server.ts`, funzione `inviaPush`) firma un J
(ECDSA P-256) e fa una POST senza corpo all'endpoint push del browser; è riusato identico da (ECDSA P-256) e fa una POST senza corpo all'endpoint push del browser; è riusato identico da
`sollecita-presenze.ts` e `promemoria-palloni.ts`. `sollecita-presenze.ts` e `promemoria-palloni.ts`.
### Chi può farle partire (DD-024)
Queste route usano la service role e saltano la RLS, quindi il permesso deve stare nella
route. `src/lib/auth-route.server.ts` fornisce i due controlli:
| Route | Controllo | Chi la chiama |
| -------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `apri-sondaggio`, `sollecita-presenze` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, dal pulsante riservato agli admin |
| `promemoria-palloni` | `richiediSegreto` — intestazione `x-cron-segreto` uguale alla variabile `CRON_SEGRETO` | un cron, senza sessione |
| `csi`, `push-config`, `push-subscribe`, `push-messaggio` | nessuno | il browser prima del login e il service worker, che una sessione non ce l'hanno |
**`CRON_SEGRETO` va configurata negli ambienti**: se manca, `promemoria-palloni` risponde
503 e il promemoria non parte. È voluto — una porta che si riapre da sola quando manca una
configurazione non se ne accorge nessuno.
--- ---
## Notifiche smart ## Notifiche smart
@@ -77,8 +92,11 @@ ripetersi — deduplica puramente locale al dispositivo, non sincronizzata.
è iscritto o no. Separare i canali richiederebbe schema e UI dedicati. è iscritto o no. Separare i canali richiederebbe schema e UI dedicati.
- `promemoria_push` è descritta altrove come "storico" ma nel codice è una coda che si - `promemoria_push` è descritta altrove come "storico" ma nel codice è una coda che si
autocancella alla lettura: non conserva nulla. autocancella alla lettura: non conserva nulla.
- Nessuna verifica di autenticazione su `push-messaggio` (chiunque conosca un endpoint push - Nessuna verifica di autenticazione su `push-messaggio`: chiunque conosca un endpoint push
valido può leggerne il messaggio) né su `promemoria-palloni`. valido può leggerne il messaggio. Non è chiudibile con un segreto, perché a chiamarla è il
service worker, dove qualsiasi segreto sarebbe pubblico; di fatto la protegge il dover
conoscere l'endpoint, che è un URL segreto per dispositivo. `promemoria-palloni` invece è
chiusa da DD-024.
- Compatibilità iOS/Safari non gestita esplicitamente nel codice (nessun branch dedicato): - Compatibilità iOS/Safari non gestita esplicitamente nel codice (nessun branch dedicato):
serve l'installazione da schermata Home per funzionare, ma l'app non lo segnala serve l'installazione da schermata Home per funzionare, ma l'app non lo segnala
esplicitamente. esplicitamente.
+5 -5
View File
@@ -53,11 +53,12 @@ testo effettivo viene calcolato al volo dal service worker interrogando
## Limiti noti ## Limiti noti
- **Nessuna verifica di autenticazione/secret** sulla route `promemoria-palloni`: chiunque
può invocarla via POST diretto, nonostante il piano originale prevedesse una protezione
con secret.
- **Nessun cron nel repository**: lo scheduling effettivo (se esiste) è configurato fuori dal - **Nessun cron nel repository**: lo scheduling effettivo (se esiste) è configurato fuori dal
codice versionato — da verificare lato Supabase/hosting. codice versionato — da verificare lato Supabase/hosting. Finché non esiste, il promemoria
quotidiano non parte da solo.
- La route è protetta dal segreto previsto dal piano originale (DD-024): chi la chiama deve
mandare `x-cron-segreto` uguale alla variabile `CRON_SEGRETO`. Se la variabile non è
configurata nell'ambiente la route risponde `503`.
- Il conteggio dei turni include anche le proposte non confermate: badge e statistiche - Il conteggio dei turni include anche le proposte non confermate: badge e statistiche
possono contare turni mai effettivamente convalidati da nessuno. possono contare turni mai effettivamente convalidati da nessuno.
- La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto - La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto
@@ -67,7 +68,6 @@ testo effettivo viene calcolato al volo dal service worker interrogando
## Evoluzioni possibili ## Evoluzioni possibili
- Aggiungere un secret/header di autorizzazione alla route pubblica.
- Versionare il cron (es. una migration con `cron.schedule`) invece di configurarlo solo - Versionare il cron (es. una migration con `cron.schedule`) invece di configurarlo solo
lato dashboard. lato dashboard.
- Escludere dalla rotazione chi ha già dichiarato assenza per l'evento. - Escludere dalla rotazione chi ha già dichiarato assenza per l'evento.
+2 -1
View File
@@ -9,6 +9,7 @@ import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze"; import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useRosa } from "@/lib/rosa"; import { useRosa } from "@/lib/rosa";
import { useGiocatoreCorrente } from "@/lib/user-store"; import { useGiocatoreCorrente } from "@/lib/user-store";
import { intestazioniAutenticate } from "@/lib/auth";
import { useIsAdmin } from "@/lib/ruoli"; import { useIsAdmin } from "@/lib/ruoli";
import { dataOggi } from "@/lib/scout-live"; import { dataOggi } from "@/lib/scout-live";
@@ -33,7 +34,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
try { try {
const res = await fetch("/api/public/sollecita-presenze", { const res = await fetch("/api/public/sollecita-presenze", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) },
body: JSON.stringify({ eventoId, da: io?.nome }), body: JSON.stringify({ eventoId, da: io?.nome }),
}); });
if (!res.ok) throw new Error(); if (!res.ok) throw new Error();
+2 -1
View File
@@ -5,6 +5,7 @@ import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits"; import { Card } from "@/components/crapp/ui-bits";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra"; import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreCorrente } from "@/lib/user-store"; import { useGiocatoreCorrente } from "@/lib/user-store";
import { intestazioniAutenticate } from "@/lib/auth";
import { useIsAdmin } from "@/lib/ruoli"; import { useIsAdmin } from "@/lib/ruoli";
import { mediaPartita, sondaggioAperto, useCacche, useSalvaCacche } from "@/lib/cacche"; import { mediaPartita, sondaggioAperto, useCacche, useSalvaCacche } from "@/lib/cacche";
@@ -51,7 +52,7 @@ export function SondaggioCacche({
try { try {
const res = await fetch("/api/public/apri-sondaggio", { const res = await fetch("/api/public/apri-sondaggio", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) },
body: JSON.stringify({ eventoId }), body: JSON.stringify({ eventoId }),
}); });
if (!res.ok) throw new Error(); if (!res.ok) throw new Error();
+69
View File
@@ -0,0 +1,69 @@
/**
* Controllo di accesso per le route in `src/routes/api/public/` che inviano notifiche
* a tutta la squadra (DD-024).
*
* Quelle route usano la service role e saltano la RLS: senza un controllo qui, chiunque
* conosca l'URL può far suonare i telefoni di tutti. L'id di un evento non è un segreto —
* è un timestamp in base 36 e compare negli URL che la squadra si scambia — quindi non
* può fare da credenziale.
*
* Due tipi di chiamante, due controlli:
* - dall'app, per un'azione da amministratore -> `richiediAdmin`, che verifica il token
* della sessione Supabase e poi il ruolo in `user_roles`;
* - da una macchina (cron), dove nessuna sessione esiste -> `richiediSegreto`.
*
* Entrambe tornano `null` quando la richiesta può proseguire, altrimenti la `Response` di
* rifiuto già pronta.
*/
/** Il token della sessione Supabase, se la richiesta ne porta uno ben formato. */
function tokenDaRichiesta(request: Request): string | null {
const intestazione = request.headers.get("authorization");
if (!intestazione?.startsWith("Bearer ")) return null;
const token = intestazione.slice("Bearer ".length).trim();
// Un JWT ha tre segmenti: scartarlo qui evita una chiamata di rete per ogni rumore.
return token && token.split(".").length === 3 ? token : null;
}
/**
* Lascia passare solo un amministratore autenticato. `401` se manca o non vale il token,
* `403` se il token è buono ma l'utente non è admin.
*/
export async function richiediAdmin(request: Request): Promise<Response | null> {
const token = tokenDaRichiesta(request);
if (!token) return new Response("Autenticazione richiesta", { status: 401 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const { data: utente, error } = await supabaseAdmin.auth.getUser(token);
if (error || !utente?.user) return new Response("Sessione non valida", { status: 401 });
// Stessa fonte di `src/lib/ruoli.ts`: i permessi stanno solo in `user_roles` (DD-011).
const { data: ruolo } = await supabaseAdmin
.from("user_roles")
.select("role")
.eq("user_id", utente.user.id)
.eq("role", "admin")
.maybeSingle();
if (!ruolo) return new Response("Riservato agli amministratori", { status: 403 });
return null;
}
/**
* Lascia passare solo chi conosce `CRON_SEGRETO`, per le chiamate senza sessione.
*
* Se la variabile non è configurata la route resta chiusa: una porta che si apre da sola
* quando manca una configurazione è peggio di una porta che non funziona, perché nessuno
* se ne accorge.
*/
export function richiediSegreto(request: Request): Response | null {
const atteso = process.env["CRON_SEGRETO"];
if (!atteso) {
console.error("[auth] CRON_SEGRETO non configurato: la route resta chiusa");
return new Response("Route non configurata", { status: 503 });
}
const fornito = request.headers.get("x-cron-segreto");
if (fornito !== atteso) return new Response("Segreto non valido", { status: 401 });
return null;
}
+11
View File
@@ -47,6 +47,17 @@ export function useSessione() {
}; };
} }
/**
* Intestazioni con il token della sessione, per le route server che verificano il ruolo
* (DD-024). Letta al momento della chiamata e non da uno stato React, così non si spedisce
* un token già scaduto.
*/
export async function intestazioniAutenticate(): Promise<Record<string, string>> {
const { data } = await supabase.auth.getSession();
const token = data.session?.access_token;
return token ? { Authorization: `Bearer ${token}` } : {};
}
export async function accediConGoogle(): Promise<void> { export async function accediConGoogle(): Promise<void> {
const { error } = await supabase.auth.signInWithOAuth({ const { error } = await supabase.auth.signInWithOAuth({
provider: "google", provider: "google",
+4
View File
@@ -1,5 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod"; import { z } from "zod";
import { richiediAdmin } from "@/lib/auth-route.server";
import { leggiEventi } from "@/lib/eventi.server"; import { leggiEventi } from "@/lib/eventi.server";
import { inviaPush } from "@/lib/webpush.server"; import { inviaPush } from "@/lib/webpush.server";
@@ -10,6 +11,9 @@ export const Route = createFileRoute("/api/public/apri-sondaggio")({
server: { server: {
handlers: { handlers: {
POST: async ({ request }) => { POST: async ({ request }) => {
const negato = await richiediAdmin(request);
if (negato) return negato;
const parsed = schema.safeParse(await request.json()); const parsed = schema.safeParse(await request.json());
if (!parsed.success) return new Response("Dati non validi", { status: 400 }); if (!parsed.success) return new Response("Dati non validi", { status: 400 });
+6 -1
View File
@@ -1,4 +1,5 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { richiediSegreto } from "@/lib/auth-route.server";
import { nomeCompleto } from "@/lib/giocatori-squadra"; import { nomeCompleto } from "@/lib/giocatori-squadra";
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server"; import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
import { completaTurni, destinatariPromemoriaPalloni, oggiISO } from "@/lib/palloni-core"; import { completaTurni, destinatariPromemoriaPalloni, oggiISO } from "@/lib/palloni-core";
@@ -8,7 +9,11 @@ import { leggiEventi } from "@/lib/eventi.server";
export const Route = createFileRoute("/api/public/promemoria-palloni")({ export const Route = createFileRoute("/api/public/promemoria-palloni")({
server: { server: {
handlers: { handlers: {
POST: async () => { POST: async ({ request }) => {
// Chiamata da un cron, senza sessione: qui vale il segreto condiviso.
const negato = richiediSegreto(request);
if (negato) return negato;
const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const { data: righe } = await supabaseAdmin const { data: righe } = await supabaseAdmin
@@ -1,5 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod"; import { z } from "zod";
import { richiediAdmin } from "@/lib/auth-route.server";
import { formatData } from "@/lib/crapp-data"; import { formatData } from "@/lib/crapp-data";
import { leggiEventi } from "@/lib/eventi.server"; import { leggiEventi } from "@/lib/eventi.server";
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server"; import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
@@ -14,6 +15,9 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
server: { server: {
handlers: { handlers: {
POST: async ({ request }) => { POST: async ({ request }) => {
const negato = await richiediAdmin(request);
if (negato) return negato;
const parsed = schema.safeParse(await request.json()); const parsed = schema.safeParse(await request.json());
if (!parsed.success) return new Response("Dati non validi", { status: 400 }); if (!parsed.success) return new Response("Dati non validi", { status: 400 });
+13 -8
View File
@@ -17,12 +17,12 @@ non può inquinare gli altri.
## Struttura ## Struttura
| Cartella | Cosa verifica | Serve rete? | | 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 | | `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ì | | `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ì | | `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 | — | | `helpers/` | Avvio del server di test e mini-harness condiviso | — |
## Database locale in Docker ## 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 db reset # ricrea il database da zero se i dati si sporcano
npx supabase stop # spegne tutto npx supabase stop # spegne tutto
bun test/integration/permessi.test.ts # permessi per ruolo bun test/integration/permessi.test.ts # permessi per ruolo sulle tabelle
bun test/integration/scritture.test.ts # semantica degli upsert 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 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), una decina di secondi. Le mail finiscono in Mailpit (http://127.0.0.1:54324),
non escono dalla macchina. non escono dalla macchina.
+39 -9
View File
@@ -109,16 +109,46 @@ try {
assert.equal(res.status, 400, "anche la cancellazione valida l'input"); 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/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( assert.equal(
(await postJson("/api/public/sollecita-presenze", { eventoId: "" })).status, (await postJson("/api/public/promemoria-palloni", {})).status < 400,
400, false,
"eventoId vuoto non è valido", "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 ------------------------------------- // --- endpoint che leggono dal database -------------------------------------
if (haSupabase()) { if (haSupabase()) {
await prova("push-messaggio risponde con il messaggio di default", async () => { 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"); assert.ok(dati.title && dati.body, "un endpoint sconosciuto riceve comunque un testo");
}); });
await prova("sollecita-presenze su un evento inesistente risponde 404", async () => { // La validazione dell'input di sollecita-presenze (400 sul corpo vuoto, 404
const res = await postJson("/api/public/sollecita-presenze", { eventoId: "non-esiste" }); // sull'evento inesistente) ora sta dietro al controllo di accesso: serve un
assert.equal(res.status, 404); // token di amministratore, che questo file non ha. La copre
}); // `permessi-route.test.ts` sullo stack locale.
} else { } else {
salta("endpoint con database", "SUPABASE_URL/SERVICE_ROLE_KEY non configurate"); salta("endpoint con database", "SUPABASE_URL/SERVICE_ROLE_KEY non configurate");
} }
+139
View File
@@ -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");
}
}