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
+11 -4
View File
@@ -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 {
+32 -6
View File
@@ -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;
}
+2 -2
View File
@@ -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!;
<td>{u.username}{u.id === me.id && ' (tu)'}</td>
<td>
<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>
</td>
<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
// 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<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">
<h1>Nuovo utente</h1>
@@ -17,10 +29,7 @@ export const prerender = false;
<label class="alabel" for="role">Ruolo</label>
<select class="afield" id="role" name="role">
<option value="user">user — scrive i propri articoli</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>
{ROLES.map((r) => <option value={r}>{r}{DESCRIZIONI[r] ? ` — ${DESCRIZIONI[r]}` : ''}</option>)}
</select>
<div class="aactions">