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:
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
try {
|
||||||
longevity.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, ?)`).run(code, dati.sesso);
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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[] = [];
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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']");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user