Separazione: le aree riservate diventano un'app propria (apps/platforms)
Campus, Stress Index e Longevity escono dal sito e diventano apps.insanitylab.it, con utenti, sessioni, login e database propri. Deciso da Adriano il 03/09/2026: una repo sola, due applicazioni autosufficienti, cosi' che ognuna resti estraibile. Misura che ha guidato il taglio: le tre aree condividevano col sito SOLO l'identita' - ~170 righe fra auth.ts, middleware.ts e safe-next.ts. Layout, librerie e dati erano gia' disgiunti. Lato piattaforme: - ruoli piattaforme/cliente/trainer/admin, senza quelli redazionali - canAccessPath con default CHIUSO (nel sito era aperto: li' le rotte non elencate erano di chiunque fosse loggato, qui ogni sezione e' di qualcuno) - tutta l'app protetta, si elencano le eccezioni invece dei prefissi protetti - /api risponde 401/403, non un redirect verso una pagina di login - tre database distinti: utenti, dati clinici senza nomi, mappatura coi nomi Lato sito: - ROLES tornano i tre redazionali; via le regole e il login pubblico - il login del pannello RESPINGE un account emigrato invece di aprirgli una sessione: senza, landingFor lo mandava su /admin, il middleware glielo negava, e si formava un ciclo di redirect che sembra un guasto del sito - l'header non mostra piu' un menu delle piattaforme: un link che esce Verificato: sito 20 file / 146 test, piattaforme 26 file / 197 test, entrambi i build passano, e l'app girata davvero in locale - il ruolo piattaforme entra in Campus e viene rimbalzato da Longevity, il cliente il contrario. Non ancora in produzione: manca il record DNS di apps.insanitylab.it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
// Il difetto più serio di questo ramo era "una funzione di autorizzazione corretta che
|
||||
// nessuno chiama": isProtectedPath e canAccessAdminPath erano giuste, ma finché niente le
|
||||
// collegava dentro src/middleware.ts le pagine sanitarie restavano pubbliche. Nessun altro
|
||||
// test in tutto il repo importa src/middleware.ts: qui lo si carica davvero (vedi l'alias
|
||||
// 'astro:middleware' in vitest.config.ts, necessario perché quel modulo è virtuale e
|
||||
// risolto solo dentro la pipeline vite di Astro) e si esercita la funzione vera, non una
|
||||
// sua reimplementazione.
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
|
||||
// getDb() (usato da src/middleware.ts) apre 'data/insanitylab.db' su disco per default:
|
||||
// DB_PATH va puntato a :memory: PRIMA che qualcosa lo chiami (il singleton è pigro).
|
||||
process.env.DB_PATH = ':memory:';
|
||||
|
||||
type FakeContext = {
|
||||
url: URL;
|
||||
cookies: { get: (name: string) => { value: string } | undefined };
|
||||
redirect: (path: string, status?: number) => Response;
|
||||
locals: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function contesto(pathname: string, cookieValue?: string): FakeContext {
|
||||
return {
|
||||
url: new URL(`http://localhost${pathname}`),
|
||||
cookies: { get: () => (cookieValue !== undefined ? { value: cookieValue } : undefined) },
|
||||
redirect: (path, status) => new Response(null, { status: status ?? 302, headers: { Location: path } }),
|
||||
locals: {},
|
||||
};
|
||||
}
|
||||
|
||||
function next() {
|
||||
let chiamata = false;
|
||||
const fn = async () => { chiamata = true; return new Response('PAGINA-SERVITA'); };
|
||||
return { fn, fuChiamata: () => chiamata };
|
||||
}
|
||||
|
||||
describe('src/middleware.ts — protezione reale delle rotte longevity', () => {
|
||||
let onRequest: (context: FakeContext, next: () => Promise<Response>) => Response | Promise<Response>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('../../src/middleware.ts');
|
||||
// FakeContext è un doppio minimale di APIContext (solo cio' che onRequest usa
|
||||
// davvero: url, cookies, redirect, locals), non un APIContext completo: il cast
|
||||
// diretto viene rifiutato dal typecheck perche' i due tipi non si sovrappongono
|
||||
// abbastanza. Si passa da 'unknown' perche' e' un cast intenzionale, non un bug.
|
||||
onRequest = mod.onRequest as unknown as typeof onRequest;
|
||||
});
|
||||
|
||||
it('una rotta longevity senza sessione (nessun cookie) redirige al login e non serve la pagina', async () => {
|
||||
const ctx = contesto('/longevity/io');
|
||||
const n = next();
|
||||
const res = await onRequest(ctx, n.fn);
|
||||
|
||||
expect(n.fuChiamata()).toBe(false);
|
||||
expect(res).toBeInstanceOf(Response);
|
||||
expect([301, 302, 303, 307, 308]).toContain((res as Response).status);
|
||||
expect((res as Response).headers.get('Location')).toMatch(/^\/login/);
|
||||
});
|
||||
|
||||
it('una rotta longevity con un token di sessione inesistente redirige comunque al login', async () => {
|
||||
const ctx = contesto('/longevity/gestionale', 'token-che-non-esiste-in-nessuna-sessione');
|
||||
const n = next();
|
||||
const res = await onRequest(ctx, n.fn);
|
||||
|
||||
expect(n.fuChiamata()).toBe(false);
|
||||
expect((res as Response).headers.get('Location')).toMatch(/^\/login/);
|
||||
});
|
||||
|
||||
it('una API longevity senza sessione risponde 401, non serve la richiesta', async () => {
|
||||
const ctx = contesto('/api/longevity/qualcosa');
|
||||
const n = next();
|
||||
const res = await onRequest(ctx, n.fn);
|
||||
|
||||
expect(n.fuChiamata()).toBe(false);
|
||||
expect((res as Response).status).toBe(401);
|
||||
const body = await (res as Response).json();
|
||||
expect(body.error).toBeTruthy();
|
||||
});
|
||||
|
||||
// ⚠️ In quest'app le rotte pubbliche sono l'eccezione, non la regola: /blog non esiste
|
||||
// piu' (e' rimasto sul sito) e la sola pagina raggiungibile senza sessione e' il login.
|
||||
it('il login non passa da nessun controllo: next() viene chiamata', async () => {
|
||||
const ctx = contesto('/login');
|
||||
const n = next();
|
||||
const res = await onRequest(ctx, n.fn);
|
||||
|
||||
expect(n.fuChiamata()).toBe(true);
|
||||
expect(await (res as Response).text()).toBe('PAGINA-SERVITA');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user