From 847972b582c7f06311c475ede30fa725b9f830e7 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Sun, 6 Sep 2026 12:05:39 +0200 Subject: [PATCH] Chiede le credenziali alle route che avvisano tutta la squadra (DD-024). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/DESIGN_DECISIONS.md | 63 +++++++++ docs/modules/notifiche.md | 22 +++- docs/modules/palloni.md | 10 +- src/components/crapp/RosaPresenze.tsx | 3 +- src/components/crapp/SondaggioCacche.tsx | 3 +- src/lib/auth-route.server.ts | 69 ++++++++++ src/lib/auth.ts | 11 ++ src/routes/api/public/apri-sondaggio.ts | 4 + src/routes/api/public/promemoria-palloni.ts | 7 +- src/routes/api/public/sollecita-presenze.ts | 4 + test/README.md | 21 +-- test/integration/api.test.ts | 48 +++++-- test/integration/permessi-route.test.ts | 139 ++++++++++++++++++++ 13 files changed, 377 insertions(+), 27 deletions(-) create mode 100644 src/lib/auth-route.server.ts create mode 100644 test/integration/permessi-route.test.ts diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md index 4270f20..046c30c 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -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-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-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** @@ -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 chiusura della lettura riga per riga), o se turni e scout acquistano un gate 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. diff --git a/docs/modules/notifiche.md b/docs/modules/notifiche.md index e89edf7..11fec89 100644 --- a/docs/modules/notifiche.md +++ b/docs/modules/notifiche.md @@ -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 `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 @@ -77,8 +92,11 @@ ripetersi — deduplica puramente locale al dispositivo, non sincronizzata. è iscritto o no. Separare i canali richiederebbe schema e UI dedicati. - `promemoria_push` è descritta altrove come "storico" ma nel codice è una coda che si autocancella alla lettura: non conserva nulla. -- Nessuna verifica di autenticazione su `push-messaggio` (chiunque conosca un endpoint push - valido può leggerne il messaggio) né su `promemoria-palloni`. +- Nessuna verifica di autenticazione su `push-messaggio`: chiunque conosca un endpoint push + 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): serve l'installazione da schermata Home per funzionare, ma l'app non lo segnala esplicitamente. diff --git a/docs/modules/palloni.md b/docs/modules/palloni.md index af674b0..4a30183 100644 --- a/docs/modules/palloni.md +++ b/docs/modules/palloni.md @@ -53,11 +53,12 @@ testo effettivo viene calcolato al volo dal service worker interrogando ## 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 - 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 possono contare turni mai effettivamente convalidati da nessuno. - 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 -- Aggiungere un secret/header di autorizzazione alla route pubblica. - Versionare il cron (es. una migration con `cron.schedule`) invece di configurarlo solo lato dashboard. - Escludere dalla rotazione chi ha già dichiarato assenza per l'evento. diff --git a/src/components/crapp/RosaPresenze.tsx b/src/components/crapp/RosaPresenze.tsx index d651f31..0cd1ad5 100644 --- a/src/components/crapp/RosaPresenze.tsx +++ b/src/components/crapp/RosaPresenze.tsx @@ -9,6 +9,7 @@ import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data"; import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze"; import { useRosa } from "@/lib/rosa"; import { useGiocatoreCorrente } from "@/lib/user-store"; +import { intestazioniAutenticate } from "@/lib/auth"; import { useIsAdmin } from "@/lib/ruoli"; import { dataOggi } from "@/lib/scout-live"; @@ -33,7 +34,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin try { const res = await fetch("/api/public/sollecita-presenze", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) }, body: JSON.stringify({ eventoId, da: io?.nome }), }); if (!res.ok) throw new Error(); diff --git a/src/components/crapp/SondaggioCacche.tsx b/src/components/crapp/SondaggioCacche.tsx index 1de6982..b617814 100644 --- a/src/components/crapp/SondaggioCacche.tsx +++ b/src/components/crapp/SondaggioCacche.tsx @@ -5,6 +5,7 @@ import { cn } from "@/lib/utils"; import { Card } from "@/components/crapp/ui-bits"; import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra"; import { useGiocatoreCorrente } from "@/lib/user-store"; +import { intestazioniAutenticate } from "@/lib/auth"; import { useIsAdmin } from "@/lib/ruoli"; import { mediaPartita, sondaggioAperto, useCacche, useSalvaCacche } from "@/lib/cacche"; @@ -51,7 +52,7 @@ export function SondaggioCacche({ try { const res = await fetch("/api/public/apri-sondaggio", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) }, body: JSON.stringify({ eventoId }), }); if (!res.ok) throw new Error(); diff --git a/src/lib/auth-route.server.ts b/src/lib/auth-route.server.ts new file mode 100644 index 0000000..728f9d1 --- /dev/null +++ b/src/lib/auth-route.server.ts @@ -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 { + 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; +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 4eb14f1..e67b694 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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> { + const { data } = await supabase.auth.getSession(); + const token = data.session?.access_token; + return token ? { Authorization: `Bearer ${token}` } : {}; +} + export async function accediConGoogle(): Promise { const { error } = await supabase.auth.signInWithOAuth({ provider: "google", diff --git a/src/routes/api/public/apri-sondaggio.ts b/src/routes/api/public/apri-sondaggio.ts index e614fb1..bfefa40 100644 --- a/src/routes/api/public/apri-sondaggio.ts +++ b/src/routes/api/public/apri-sondaggio.ts @@ -1,5 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { z } from "zod"; +import { richiediAdmin } from "@/lib/auth-route.server"; import { leggiEventi } from "@/lib/eventi.server"; import { inviaPush } from "@/lib/webpush.server"; @@ -10,6 +11,9 @@ export const Route = createFileRoute("/api/public/apri-sondaggio")({ server: { handlers: { POST: async ({ request }) => { + const negato = await richiediAdmin(request); + if (negato) return negato; + const parsed = schema.safeParse(await request.json()); if (!parsed.success) return new Response("Dati non validi", { status: 400 }); diff --git a/src/routes/api/public/promemoria-palloni.ts b/src/routes/api/public/promemoria-palloni.ts index 6e0b5b4..e581430 100644 --- a/src/routes/api/public/promemoria-palloni.ts +++ b/src/routes/api/public/promemoria-palloni.ts @@ -1,4 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; +import { richiediSegreto } from "@/lib/auth-route.server"; import { nomeCompleto } from "@/lib/giocatori-squadra"; import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server"; 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")({ server: { 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 { data: righe } = await supabaseAdmin diff --git a/src/routes/api/public/sollecita-presenze.ts b/src/routes/api/public/sollecita-presenze.ts index ad38505..c5f0804 100644 --- a/src/routes/api/public/sollecita-presenze.ts +++ b/src/routes/api/public/sollecita-presenze.ts @@ -1,5 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { z } from "zod"; +import { richiediAdmin } from "@/lib/auth-route.server"; import { formatData } from "@/lib/crapp-data"; import { leggiEventi } from "@/lib/eventi.server"; import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server"; @@ -14,6 +15,9 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({ server: { handlers: { POST: async ({ request }) => { + const negato = await richiediAdmin(request); + if (negato) return negato; + const parsed = schema.safeParse(await request.json()); if (!parsed.success) return new Response("Dati non validi", { status: 400 }); diff --git a/test/README.md b/test/README.md index 3b9fa5e..0b065dd 100644 --- a/test/README.md +++ b/test/README.md @@ -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. diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 09b055f..c37529c 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -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"); } diff --git a/test/integration/permessi-route.test.ts b/test/integration/permessi-route.test.ts new file mode 100644 index 0000000..421588b --- /dev/null +++ b/test/integration/permessi-route.test.ts @@ -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 { + 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 { + 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 = {}) => + 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"); + } +}