From 763e5611e6556b132d70802eac7282ce26458122 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 21 Aug 2026 20:30:23 +0200 Subject: [PATCH] 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) --- scripts/create-user.mjs | 2 + src/lib/auth.ts | 15 ++++-- src/lib/longevity/anagrafica.ts | 38 +++++++++++--- src/pages/admin/users.astro | 4 +- src/pages/admin/users/new.astro | 17 ++++-- tests/longevity/anagrafica.test.ts | 42 +++++++++++++++ tests/longevity/export.test.ts | 36 +++++++++++++ tests/longevity/middleware.test.ts | 83 ++++++++++++++++++++++++++++++ tests/longevity/ruoli.test.ts | 30 ++++++++++- vitest.config.ts | 10 ++++ 10 files changed, 260 insertions(+), 17 deletions(-) create mode 100644 tests/longevity/middleware.test.ts diff --git a/scripts/create-user.mjs b/scripts/create-user.mjs index 3621a1a..7721995 100644 --- a/scripts/create-user.mjs +++ b/scripts/create-user.mjs @@ -8,6 +8,8 @@ if (!username || !password) { console.error('Uso: npm run create-user -- [role]'); process.exit(1); } +// Lista duplicata di proposito: questo file è JavaScript e non può importare da +// src/lib/auth.ts. La fonte vera è ROLES in src/lib/auth.ts - se cambia là, va allineata qui. if (!['admin', 'superuser', 'user', 'piattaforme', 'cliente', 'trainer'].includes(role)) { console.error('Ruolo non valido. Ammessi: admin, superuser, user, piattaforme, cliente, trainer'); process.exit(1); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index e98a2f0..8d37399 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -5,7 +5,13 @@ import { randomBytes } from 'node:crypto'; export const SESSION_COOKIE = 'session'; const SESSION_DAYS = 7; -export type Role = 'admin' | 'superuser' | 'user' | 'piattaforme' | 'cliente' | 'trainer'; +// Unica fonte dei ruoli validi: da qui si derivano il tipo Role e la validazione isRole, +// così aggiungere un ruolo in futuro è una modifica sola. Prima erano quattro punti +// indipendenti - questo tipo, isRole, la tendina di admin/users.astro e create-user.mjs - +// ed è già andato fuori sincrono due volte in questo stesso ramo (create-user.mjs resta +// duplicato: è JavaScript, non può importare da qui, vedi il commento lì). +export const ROLES = ['user', 'superuser', 'piattaforme', 'admin', 'cliente', 'trainer'] as const; +export type Role = typeof ROLES[number]; export function hashPassword(plain: string): string { return bcrypt.hashSync(plain, 12); @@ -51,8 +57,9 @@ export function logout(db: Database.Database, token: string): void { // Autorizzazione per prefisso di rotta. L'admin passa sempre; per gli altri, la prima regola // che matcha decide; se nessuna regola matcha la rotta è "blog/upload/logout" → consentita a -// qualsiasi loggato (il middleware protegge solo /admin e /api/admin, quindi qui arrivano solo -// utenti già autenticati). +// qualsiasi loggato. L'elenco vero di cosa il middleware protegge (non solo /admin e +// /api/admin: anche /campus, /piattaforme e /longevity) vive in isProtectedPath, più sotto: +// qui arrivano solo utenti già autenticati su una di quelle rotte. const RULES: [RegExp, Role[]][] = [ [/^\/admin\/users(\/|$)/, ['admin']], [/^\/api\/admin\/users(\/|$)/, ['admin']], @@ -118,7 +125,7 @@ export function randomPassword(): string { } export function isRole(v: unknown): v is Role { - return v === 'admin' || v === 'superuser' || v === 'user' || v === 'piattaforme' || v === 'cliente' || v === 'trainer'; + return (ROLES as readonly unknown[]).includes(v); } export function landingFor(role: string): string { diff --git a/src/lib/longevity/anagrafica.ts b/src/lib/longevity/anagrafica.ts index 4e26c63..5d60a0e 100644 --- a/src/lib/longevity/anagrafica.ts +++ b/src/lib/longevity/anagrafica.ts @@ -5,6 +5,28 @@ // la annullerebbe in silenzio. Un test in tests/longevity/anagrafica.test.ts lo verifica. import type Database from 'better-sqlite3'; +/** + * Il prossimo codice libero: il massimo fra quelli già usati in identity.clienti E in + * longevity.soggetti. Dedurlo da una sola metà è il bug che permette di riassegnare il + * codice di un cliente cancellato dall'anagrafica (le sue misure restano in longevity) + * a una persona nuova: qui non torna mai disponibile. + */ +function prossimoCodice(identity: Database.Database, longevity: Database.Database): string { + const numero = (code: string) => Number(code.slice(4)); + const ultimoIdentity = identity.prepare( + `SELECT client_code FROM clienti ORDER BY client_code DESC LIMIT 1` + ).get() as { client_code: string } | undefined; + const ultimoLongevity = longevity.prepare( + `SELECT client_code FROM soggetti ORDER BY client_code DESC LIMIT 1` + ).get() as { client_code: string } | undefined; + + const max = Math.max( + ultimoIdentity ? numero(ultimoIdentity.client_code) : 0, + ultimoLongevity ? numero(ultimoLongevity.client_code) : 0, + ); + return `ISL-${String(max + 1).padStart(4, '0')}`; +} + export function creaCliente( identity: Database.Database, longevity: Database.Database, @@ -13,11 +35,7 @@ export function creaCliente( data_nascita?: string; email?: string; telefono?: string; user_id?: number; } ): string { - const ultimo = identity.prepare( - `SELECT client_code FROM clienti ORDER BY client_code DESC LIMIT 1` - ).get() as { client_code: string } | undefined; - const n = ultimo ? Number(ultimo.client_code.slice(4)) + 1 : 1; - const code = `ISL-${String(n).padStart(4, '0')}`; + const code = prossimoCodice(identity, longevity); identity.prepare( `INSERT INTO clienti (client_code, user_id, nome, cognome, data_nascita, email, telefono) @@ -25,7 +43,15 @@ export function creaCliente( ).run(code, dati.user_id ?? null, dati.nome, dati.cognome, dati.data_nascita ?? null, dati.email ?? null, dati.telefono ?? null); - longevity.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, ?)`).run(code, dati.sesso); + try { + longevity.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, ?)`).run(code, dati.sesso); + } catch (err) { + // Compensazione: due database distinti, quindi nessuna transazione unica possibile. + // Se longevity rifiuta il cliente non deve restarne uno orfano in identity, senza + // corrispettivo nell'altro database. + identity.prepare(`DELETE FROM clienti WHERE client_code = ?`).run(code); + throw err; + } return code; } diff --git a/src/pages/admin/users.astro b/src/pages/admin/users.astro index bd4426c..0e6d4f5 100644 --- a/src/pages/admin/users.astro +++ b/src/pages/admin/users.astro @@ -1,7 +1,7 @@ --- import Admin from '../../layouts/Admin.astro'; import { getDb } from '../../lib/db'; -import { listUsers } from '../../lib/auth'; +import { listUsers, ROLES } from '../../lib/auth'; export const prerender = false; const users = listUsers(getDb()); const me = Astro.locals.user!; @@ -45,7 +45,7 @@ const me = Astro.locals.user!; {u.username}{u.id === me.id && ' (tu)'} diff --git a/src/pages/admin/users/new.astro b/src/pages/admin/users/new.astro index 92d32bb..d495d73 100644 --- a/src/pages/admin/users/new.astro +++ b/src/pages/admin/users/new.astro @@ -2,7 +2,19 @@ // Creazione di un utente, su pagina propria come per gli articoli: l'elenco resta pulito e // il modulo ha spazio per le sue spiegazioni. import Admin from '../../../layouts/Admin.astro'; +import { ROLES } from '../../../lib/auth'; export const prerender = false; + +// Descrizioni solo per la UI: se un ruolo nuovo non è qui, compare comunque nella tendina +// (fonte di validità unica: ROLES), semplicemente senza spiegazione accanto. +const DESCRIZIONI: Partial> = { + user: 'scrive i propri articoli', + superuser: 'articoli e contenuti del sito', + piattaforme: 'solo Campus e Stress Index', + admin: 'accesso completo', + cliente: 'solo il proprio fascicolo Longevity', + trainer: 'gestionale Longevity', +}; ---

Nuovo utente

@@ -17,10 +29,7 @@ export const prerender = false;
diff --git a/tests/longevity/anagrafica.test.ts b/tests/longevity/anagrafica.test.ts index 83ac0ce..bf232be 100644 --- a/tests/longevity/anagrafica.test.ts +++ b/tests/longevity/anagrafica.test.ts @@ -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[] = []; diff --git a/tests/longevity/export.test.ts b/tests/longevity/export.test.ts index 577c76d..98244e5 100644 --- a/tests/longevity/export.test.ts +++ b/tests/longevity/export.test.ts @@ -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(); + }); }); diff --git a/tests/longevity/middleware.test.ts b/tests/longevity/middleware.test.ts new file mode 100644 index 0000000..1430b9c --- /dev/null +++ b/tests/longevity/middleware.test.ts @@ -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; +}; + +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 | Promise; + + 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'); + }); +}); diff --git a/tests/longevity/ruoli.test.ts b/tests/longevity/ruoli.test.ts index fd5c3be..5b057f9 100644 --- a/tests/longevity/ruoli.test.ts +++ b/tests/longevity/ruoli.test.ts @@ -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']"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index aefccb5..67ab3e7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,4 +2,14 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { include: ['tests/**/*.test.ts'] }, + resolve: { + alias: [ + // src/middleware.ts importa da 'astro:middleware', un modulo virtuale che Astro + // risolve solo dentro la propria pipeline vite (dev/build), non sotto vitest puro. + // Stesso bersaglio che Astro stesso usa (node_modules/astro/dist/core/create-vite.js): + // defineMiddleware è identità (fn => fn), sequence è reale. Senza questo alias il + // middleware non è caricabile in isolamento e nessun test può importarlo davvero. + { find: 'astro:middleware', replacement: 'astro/virtual-modules/middleware.js' }, + ], + }, });