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
+2
View File
@@ -8,6 +8,8 @@ if (!username || !password) {
console.error('Uso: npm run create-user -- <username> <password> [role]'); console.error('Uso: npm run create-user -- <username> <password> [role]');
process.exit(1); 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)) { if (!['admin', 'superuser', 'user', 'piattaforme', 'cliente', 'trainer'].includes(role)) {
console.error('Ruolo non valido. Ammessi: admin, superuser, user, piattaforme, cliente, trainer'); console.error('Ruolo non valido. Ammessi: admin, superuser, user, piattaforme, cliente, trainer');
process.exit(1); process.exit(1);
+11 -4
View File
@@ -5,7 +5,13 @@ import { randomBytes } from 'node:crypto';
export const SESSION_COOKIE = 'session'; export const SESSION_COOKIE = 'session';
const SESSION_DAYS = 7; 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 { export function hashPassword(plain: string): string {
return bcrypt.hashSync(plain, 12); 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 // 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 // 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 // qualsiasi loggato. L'elenco vero di cosa il middleware protegge (non solo /admin e
// utenti già autenticati). // /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[]][] = [ const RULES: [RegExp, Role[]][] = [
[/^\/admin\/users(\/|$)/, ['admin']], [/^\/admin\/users(\/|$)/, ['admin']],
[/^\/api\/admin\/users(\/|$)/, ['admin']], [/^\/api\/admin\/users(\/|$)/, ['admin']],
@@ -118,7 +125,7 @@ export function randomPassword(): string {
} }
export function isRole(v: unknown): v is Role { 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 { export function landingFor(role: string): string {
+32 -6
View File
@@ -5,6 +5,28 @@
// la annullerebbe in silenzio. Un test in tests/longevity/anagrafica.test.ts lo verifica. // la annullerebbe in silenzio. Un test in tests/longevity/anagrafica.test.ts lo verifica.
import type Database from 'better-sqlite3'; 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( export function creaCliente(
identity: Database.Database, identity: Database.Database,
longevity: Database.Database, longevity: Database.Database,
@@ -13,11 +35,7 @@ export function creaCliente(
data_nascita?: string; email?: string; telefono?: string; user_id?: number; data_nascita?: string; email?: string; telefono?: string; user_id?: number;
} }
): string { ): string {
const ultimo = identity.prepare( const code = prossimoCodice(identity, longevity);
`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')}`;
identity.prepare( identity.prepare(
`INSERT INTO clienti (client_code, user_id, nome, cognome, data_nascita, email, telefono) `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, ).run(code, dati.user_id ?? null, dati.nome, dati.cognome,
dati.data_nascita ?? null, dati.email ?? null, dati.telefono ?? null); 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; return code;
} }
+2 -2
View File
@@ -1,7 +1,7 @@
--- ---
import Admin from '../../layouts/Admin.astro'; import Admin from '../../layouts/Admin.astro';
import { getDb } from '../../lib/db'; import { getDb } from '../../lib/db';
import { listUsers } from '../../lib/auth'; import { listUsers, ROLES } from '../../lib/auth';
export const prerender = false; export const prerender = false;
const users = listUsers(getDb()); const users = listUsers(getDb());
const me = Astro.locals.user!; const me = Astro.locals.user!;
@@ -45,7 +45,7 @@ const me = Astro.locals.user!;
<td>{u.username}{u.id === me.id && ' (tu)'}</td> <td>{u.username}{u.id === me.id && ' (tu)'}</td>
<td> <td>
<select class="role-sel arole" data-id={u.id} data-role={u.role} aria-label={`Ruolo di ${u.username}`}> <select class="role-sel arole" data-id={u.id} data-role={u.role} aria-label={`Ruolo di ${u.username}`}>
{['user', 'superuser', 'piattaforme', 'admin'].map((r) => <option value={r} selected={u.role === r}>{r}</option>)} {ROLES.map((r) => <option value={r} selected={u.role === r}>{r}</option>)}
</select> </select>
</td> </td>
<td class="uactions"> <td class="uactions">
+13 -4
View File
@@ -2,7 +2,19 @@
// Creazione di un utente, su pagina propria come per gli articoli: l'elenco resta pulito e // Creazione di un utente, su pagina propria come per gli articoli: l'elenco resta pulito e
// il modulo ha spazio per le sue spiegazioni. // il modulo ha spazio per le sue spiegazioni.
import Admin from '../../../layouts/Admin.astro'; import Admin from '../../../layouts/Admin.astro';
import { ROLES } from '../../../lib/auth';
export const prerender = false; 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<Record<(typeof ROLES)[number], string>> = {
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',
};
--- ---
<Admin title="Nuovo utente"> <Admin title="Nuovo utente">
<h1>Nuovo utente</h1> <h1>Nuovo utente</h1>
@@ -17,10 +29,7 @@ export const prerender = false;
<label class="alabel" for="role">Ruolo</label> <label class="alabel" for="role">Ruolo</label>
<select class="afield" id="role" name="role"> <select class="afield" id="role" name="role">
<option value="user">user — scrive i propri articoli</option> {ROLES.map((r) => <option value={r}>{r}{DESCRIZIONI[r] ? ` — ${DESCRIZIONI[r]}` : ''}</option>)}
<option value="superuser">superuser — articoli e contenuti del sito</option>
<option value="piattaforme">piattaforme — solo Campus e Stress Index</option>
<option value="admin">admin — accesso completo</option>
</select> </select>
<div class="aactions"> <div class="aactions">
+42
View File
@@ -46,6 +46,48 @@ describe('anagrafica pseudonimizzata', () => {
expect(etaAllaData('1988-12-31', '2026-08-21')).toBe(37); // compleanno non ancora passato 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', () => { it('nessun file in src/ (escluso anagrafica.ts e db.ts) apre entrambe le connessioni', () => {
const srcDir = join(process.cwd(), 'src'); const srcDir = join(process.cwd(), 'src');
const colpevoli: string[] = []; 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.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'); 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 { 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', () => { describe('accesso alle rotte longevity', () => {
it('il cliente entra nel proprio spazio', () => { it('il cliente entra nel proprio spazio', () => {
@@ -93,3 +95,29 @@ describe('isRole — validazione dei ruoli', () => {
expect(isRole('qualsiasi-cosa')).toBe(false); 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']");
});
});
+10
View File
@@ -2,4 +2,14 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({
test: { include: ['tests/**/*.test.ts'] }, 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' },
],
},
}); });