longevity: revisione finale - codice cliente non riusabile, ruoli allineati, middleware verificato

- creaCliente: il codice si deduce dal massimo fra identity E longevity (non piu' solo
  identity), cosi' un client_code cancellato dall'anagrafica non torna mai disponibile
  e non si attribuiscono le misure di un vecchio cliente a uno nuovo. Compensazione se
  la scrittura su longevity fallisce dopo quella su identity.
- ROLES unica fonte in auth.ts (Role e isRole derivati); users.astro e users/new.astro
  usano quella lista invece di array scritti a mano che dimenticavano cliente/trainer.
- Commento falso su cosa protegge il middleware, riscritto: rimanda a isProtectedPath.
- vitest.config.ts: alias per astro:middleware (stesso bersaglio della pipeline vite di
  Astro), cosi' src/middleware.ts e' finalmente importabile e testabile in isolamento -
  prima nessun test lo caricava davvero.
- export.test.ts: asserzione posizionale per colonna, non piu' solo intestazioni scritte
  a mano; intercetta un riordino della SELECT che disallinea etichette e valori.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 20:30:23 +02:00
parent e6c2ac9148
commit 763e5611e6
10 changed files with 260 additions and 17 deletions
+42
View File
@@ -46,6 +46,48 @@ describe('anagrafica pseudonimizzata', () => {
expect(etaAllaData('1988-12-31', '2026-08-21')).toBe(37); // compleanno non ancora passato
});
it('un codice cancellato dall anagrafica non torna mai disponibile: le misure del vecchio non si attribuiscono al nuovo', () => {
const id = createIdentityDb(':memory:');
const lg = createLongevityDb(':memory:');
const a = creaCliente(id, lg, { nome: 'A', cognome: 'A', sesso: 'F' });
const b = creaCliente(id, lg, { nome: 'B', cognome: 'B', sesso: 'M' });
// Il secondo cliente viene cancellato dall'anagrafica (disdetta, richiesta di
// cancellazione, errore): le sue misure restano in longevity, pseudonimizzate.
id.prepare(`DELETE FROM clienti WHERE client_code = ?`).run(b);
lg.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES (?, '2026-08-01', 'checkup')`).run(b);
const c = creaCliente(id, lg, { nome: 'C', cognome: 'C', sesso: 'F' });
// Il terzo cliente riceve un codice nuovo, mai il codice riciclato del secondo.
expect(c).not.toBe(b);
expect(c).not.toBe(a);
// Le sessioni del vecchio cliente (b) restano leggibili sotto il suo codice...
const sessioniDiB = lg.prepare(`SELECT * FROM sessioni WHERE client_code = ?`).all(b);
expect(sessioniDiB.length).toBe(1);
// ...e non risultano in nessun modo attribuite al nuovo cliente (c).
const sessioniDiC = lg.prepare(`SELECT * FROM sessioni WHERE client_code = ?`).all(c);
expect(sessioniDiC.length).toBe(0);
});
it('se la scrittura su longevity fallisce, quella gia fatta su identity viene annullata', () => {
const id = createIdentityDb(':memory:');
const lg = createLongevityDb(':memory:');
const primaDelTentativo = (id.prepare(`SELECT COUNT(*) AS n FROM clienti`).get() as { n: number }).n;
// 'Z' non è un sesso valido (CHECK IN ('M','F') su longevity.soggetti): la scrittura
// su longevity fallisce per costruzione, dopo che quella su identity è già avvenuta.
expect(() =>
creaCliente(id, lg, { nome: 'Errato', cognome: 'Errato', sesso: 'Z' as unknown as 'M' | 'F' })
).toThrow();
const dopoIlTentativo = (id.prepare(`SELECT COUNT(*) AS n FROM clienti`).get() as { n: number }).n;
expect(dopoIlTentativo).toBe(primaDelTentativo); // nessun cliente orfano rimasto in identity
expect(id.prepare(`SELECT * FROM clienti WHERE nome = 'Errato'`).get()).toBeUndefined();
});
it('nessun file in src/ (escluso anagrafica.ts e db.ts) apre entrambe le connessioni', () => {
const srcDir = join(process.cwd(), 'src');
const colpevoli: string[] = [];
+36
View File
@@ -46,4 +46,40 @@ describe('export per le statistiche', () => {
expect(righe.length).toBe(3); // intestazione + 2 misure
expect(righe[0]).toBe('client_code,sesso,eta,data,tipo_sessione,quest_version,test_id,valore,unita,fonte,fuori_range');
});
it('ogni valore sta sotto l intestazione giusta, colonna per colonna (asserzione posizionale)', () => {
// Non un indice scritto a mano: l'indice si cerca per NOME nell'intestazione prodotta.
// Se un domani la SELECT in export.ts viene riordinata senza riallineare INTESTAZIONI,
// il valore letto a quell'indice smette di corrispondere e l'asserzione fallisce -
// invece di restare verde con l'età etichettata come sesso.
const db = dbConDati();
const { intestazioni, righe } = esportaMisure(db);
const idx = (col: string) => {
const i = intestazioni.indexOf(col);
if (i < 0) throw new Error(`intestazione mancante: ${col}`);
return i;
};
const rigaSonno = righe.find((r) => r[idx('test_id')] === 'q_ore_sonno')!;
expect(rigaSonno).toBeDefined();
expect(rigaSonno[idx('client_code')]).toBe('ISL-0001');
expect(rigaSonno[idx('sesso')]).toBe('F');
expect(rigaSonno[idx('eta')]).toBe(35);
expect(rigaSonno[idx('data')]).toBe('2026-08-21');
expect(rigaSonno[idx('tipo_sessione')]).toBe('questionario');
expect(rigaSonno[idx('quest_version')]).toBe('v1.0');
expect(rigaSonno[idx('valore')]).toBe(7);
// salvaCompilazione non passa 'unita' per le risposte del questionario: resta null.
expect(rigaSonno[idx('unita')]).toBeNull();
expect(rigaSonno[idx('fonte')]).toBe('questionario');
expect(rigaSonno[idx('fuori_range')]).toBe(0);
const rigaRiposato = righe.find((r) => r[idx('test_id')] === 'q_riposato')!;
expect(rigaRiposato).toBeDefined();
expect(rigaRiposato[idx('client_code')]).toBe('ISL-0001');
expect(rigaRiposato[idx('sesso')]).toBe('F');
expect(rigaRiposato[idx('eta')]).toBe(35);
expect(rigaRiposato[idx('valore')]).toBe(8);
expect(rigaRiposato[idx('unita')]).toBeNull();
});
});
+83
View File
@@ -0,0 +1,83 @@
// 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');
onRequest = mod.onRequest 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();
});
it('una rotta pubblica non passa da nessun controllo: next() viene chiamata', async () => {
const ctx = contesto('/blog');
const n = next();
const res = await onRequest(ctx, n.fn);
expect(n.fuChiamata()).toBe(true);
expect(await (res as Response).text()).toBe('PAGINA-SERVITA');
});
});
+29 -1
View File
@@ -1,5 +1,7 @@
import { describe, it, expect } from 'vitest';
import { canAccessAdminPath, isProtectedPath, landingFor, isRole } from '../../src/lib/auth';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { canAccessAdminPath, isProtectedPath, landingFor, isRole, ROLES } from '../../src/lib/auth';
describe('accesso alle rotte longevity', () => {
it('il cliente entra nel proprio spazio', () => {
@@ -93,3 +95,29 @@ describe('isRole — validazione dei ruoli', () => {
expect(isRole('qualsiasi-cosa')).toBe(false);
});
});
describe('ROLES — fonte unica dei ruoli, usata dal pannello utenti', () => {
it('contiene tutti e sei i ruoli, cliente e trainer inclusi', () => {
expect(ROLES).toContain('cliente');
expect(ROLES).toContain('trainer');
expect(ROLES).toContain('admin');
expect(ROLES).toContain('superuser');
expect(ROLES).toContain('user');
expect(ROLES).toContain('piattaforme');
expect(ROLES.length).toBe(6);
});
it('isRole accetta esattamente i ruoli di ROLES, nessuno in piu o in meno', () => {
for (const r of ROLES) expect(isRole(r)).toBe(true);
expect(isRole('non-un-ruolo')).toBe(false);
});
});
describe('pannello utenti — la tendina dei ruoli non e piu scritta a mano', () => {
it('users.astro pesca i ruoli da ROLES, non da una lista propria che ne dimentica due', () => {
const src = readFileSync(join(process.cwd(), 'src/pages/admin/users.astro'), 'utf8');
expect(src).toContain('ROLES');
// La vecchia lista a quattro (dimenticava cliente e trainer) non deve piu comparire.
expect(src).not.toContain("['user', 'superuser', 'piattaforme', 'admin']");
});
});