Merge branch 'lavoro/goldrake-i8' into lavoro/goldrake
This commit is contained in:
@@ -0,0 +1,88 @@
|
|||||||
|
//
|
||||||
|
// Crea l'account di accesso al sito per un cliente che ha GIA' un fascicolo aperto
|
||||||
|
// (il percorso inverso di crea-fascicolo.ts, che parte da un utente esistente e apre il
|
||||||
|
// fascicolo). Qui si parte dal fascicolo — creato dal trainer al check-up, spesso prima
|
||||||
|
// che il cliente abbia un account — e si crea l'utente che gli permette di entrare da
|
||||||
|
// /longevity a leggere il proprio referto e compilare il questionario.
|
||||||
|
//
|
||||||
|
// ⚠️ Non apre identity da sé: passa dal solo modulo autorizzato a farlo (anagrafica.ts),
|
||||||
|
// che è anche l'unico a sapere cosa sia "un accesso" per un fascicolo. Questo file tocca
|
||||||
|
// due database (users in piattaforme.db, il collegamento in identity.db) ma non li apre
|
||||||
|
// mai insieme: riceve `utenti` da fuori e delega identity alle funzioni di anagrafica.ts.
|
||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import { getDb } from '../db';
|
||||||
|
import { createUser, deleteUser, randomPassword } from '../auth';
|
||||||
|
import { accessoDelCliente, collegaAccessoCliente } from './anagrafica';
|
||||||
|
|
||||||
|
/** Solo lettere minuscole, cifre e ".", "_", "@", "-": stesso alfabeto permissivo di create-user.mjs. */
|
||||||
|
const USERNAME_VALIDO = /^[a-z0-9._@-]{3,64}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le dipendenze di creaAccessoCliente, iniettabili per i test (DB in memoria, doppi finti
|
||||||
|
* per accessoDi/collega) invece del singleton reale. In produzione si passa `undefined` e
|
||||||
|
* si prende il default qui sotto — lo stesso schema del singleton di getDb(): una
|
||||||
|
* connessione per processo, non riaperta a ogni chiamata.
|
||||||
|
*/
|
||||||
|
export interface DipendenzeAccesso {
|
||||||
|
utenti: Database.Database;
|
||||||
|
accessoDi: (code: string) => { userId: number | null; email: string | null } | null;
|
||||||
|
collega: (code: string, userId: number) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dipendenzeDiDefault(): DipendenzeAccesso {
|
||||||
|
return { utenti: getDb(), accessoDi: accessoDelCliente, collega: collegaAccessoCliente };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EsitoAccesso =
|
||||||
|
| { ok: true; username: string; password: string }
|
||||||
|
| { ok: false; errore: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea l'utente `cliente` e lo collega al fascicolo `code`. Ordine dei controlli: prima
|
||||||
|
* tutto ciò che si può verificare SENZA scrivere (username valido, fascicolo esistente e
|
||||||
|
* ancora senza accesso, username libero), poi si crea l'utente, poi si collega.
|
||||||
|
*
|
||||||
|
* Se il collegamento fallisce (fascicolo sparito o già collegato nel frattempo, o
|
||||||
|
* l'update lancia per un motivo diverso) l'utente appena creato si cancella: due database
|
||||||
|
* distinti, quindi nessuna transazione unica possibile — stessa compensazione di
|
||||||
|
* creaCliente in anagrafica.ts. Un utente `cliente` senza fascicolo collegato non
|
||||||
|
* servirebbe a niente e resterebbe un accesso fantasma.
|
||||||
|
*
|
||||||
|
* La password è generata qui (randomPassword, mai scelta da chi chiama) e torna in
|
||||||
|
* chiaro UNA volta sola nell'esito: non viene salvata da nessuna parte se non nel suo
|
||||||
|
* hash dentro users.password_hash. Chi chiama non la deve loggare.
|
||||||
|
*/
|
||||||
|
export function creaAccessoCliente(
|
||||||
|
deps: DipendenzeAccesso = dipendenzeDiDefault(),
|
||||||
|
{ code, username }: { code: string; username: string }
|
||||||
|
): EsitoAccesso {
|
||||||
|
const nome = username.trim().toLowerCase();
|
||||||
|
if (!USERNAME_VALIDO.test(nome)) {
|
||||||
|
return { ok: false, errore: 'Username non valido: 3-64 caratteri fra lettere minuscole, cifre, ". _ @ -".' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fascicolo = deps.accessoDi(code);
|
||||||
|
if (!fascicolo) return { ok: false, errore: `Nessun fascicolo con codice ${code}.` };
|
||||||
|
if (fascicolo.userId !== null) {
|
||||||
|
return { ok: false, errore: 'Questo fascicolo ha già un accesso collegato: non se ne crea un secondo.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const usernameOccupato = deps.utenti.prepare('SELECT 1 FROM users WHERE username = ?').get(nome);
|
||||||
|
if (usernameOccupato) return { ok: false, errore: `L'username "${nome}" è già in uso.` };
|
||||||
|
|
||||||
|
const password = randomPassword();
|
||||||
|
const userId = createUser(deps.utenti, nome, password, 'cliente');
|
||||||
|
|
||||||
|
let collegato: boolean;
|
||||||
|
try {
|
||||||
|
collegato = deps.collega(code, userId);
|
||||||
|
} catch {
|
||||||
|
collegato = false;
|
||||||
|
}
|
||||||
|
if (!collegato) {
|
||||||
|
deleteUser(deps.utenti, userId);
|
||||||
|
return { ok: false, errore: 'Il fascicolo non è più collegabile (cancellato o già collegato nel frattempo).' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, username: nome, password };
|
||||||
|
}
|
||||||
@@ -85,6 +85,46 @@ export function codicePerUtenteLoggato(userId: number): string | null {
|
|||||||
return codicePerUtente(getIdentityDb(), userId);
|
return codicePerUtente(getIdentityDb(), userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lo stato di accesso di un fascicolo: chi (se qualcuno) lo può aprire da sito, e l'email
|
||||||
|
* già in anagrafica (serve solo a precompilare l'username in fase di creazione, non è
|
||||||
|
* usata per altro). `null` vuol dire codice inesistente — diverso da "esiste ma senza
|
||||||
|
* accesso", che è `{ userId: null, email }`.
|
||||||
|
*/
|
||||||
|
export function accessoDi(
|
||||||
|
identity: Database.Database,
|
||||||
|
code: string
|
||||||
|
): { userId: number | null; email: string | null } | null {
|
||||||
|
const r = identity.prepare(`SELECT user_id, email FROM clienti WHERE client_code = ?`).get(code) as
|
||||||
|
{ user_id: number | null; email: string | null } | undefined;
|
||||||
|
if (!r) return null;
|
||||||
|
return { userId: r.user_id, email: r.email };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Come accessoDi, ma sulla connessione singleton: per il gestionale, che identity non la apre mai da sé. */
|
||||||
|
export function accessoDelCliente(code: string): { userId: number | null; email: string | null } | null {
|
||||||
|
return accessoDi(getIdentityDb(), code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lega un utente già creato al fascicolo: vero solo se il fascicolo esiste ed era ancora
|
||||||
|
* senza accesso (`user_id IS NULL` nel WHERE, non un controllo prima separato) — così due
|
||||||
|
* chiamate concorrenti sullo stesso fascicolo non possono collegare due utenti diversi,
|
||||||
|
* la seconda trova zero righe e torna false. Chi chiama con false deve considerare
|
||||||
|
* l'utente appena creato orfano e cancellarlo (vedi accesso.ts).
|
||||||
|
*/
|
||||||
|
export function collegaAccesso(identity: Database.Database, code: string, userId: number): boolean {
|
||||||
|
const res = identity.prepare(
|
||||||
|
`UPDATE clienti SET user_id = ? WHERE client_code = ? AND user_id IS NULL`
|
||||||
|
).run(userId, code);
|
||||||
|
return res.changes === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Come collegaAccesso, ma sulla connessione singleton. */
|
||||||
|
export function collegaAccessoCliente(code: string, userId: number): boolean {
|
||||||
|
return collegaAccesso(getIdentityDb(), code, userId);
|
||||||
|
}
|
||||||
|
|
||||||
/** L'eta' al momento della misura: è ciò che serve al motore, e non è un quasi-identificatore. */
|
/** L'eta' al momento della misura: è ciò che serve al motore, e non è un quasi-identificatore. */
|
||||||
export function etaAllaData(dataNascita: string, alla: string): number {
|
export function etaAllaData(dataNascita: string, alla: string): number {
|
||||||
const n = new Date(dataNascita);
|
const n = new Date(dataNascita);
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
// Crea l'accesso al sito per un cliente che ha già il fascicolo: il trainer sceglie
|
||||||
|
// l'username, la password la genera il sistema e si mostra UNA volta sola, qui, mai
|
||||||
|
// via redirect (finirebbe nella query string dell'URL e nella cronologia del browser).
|
||||||
|
// ⚠️ Questa pagina non apre mai il database anagrafico da sé: passa solo da
|
||||||
|
// anagrafica.ts e accesso.ts, gli unici moduli autorizzati (li sorveglia un test).
|
||||||
|
import Longevity from '../../../../../layouts/Longevity.astro';
|
||||||
|
import { accessoDelCliente } from '../../../../../lib/longevity/anagrafica';
|
||||||
|
import { creaAccessoCliente } from '../../../../../lib/longevity/accesso';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
const { code } = Astro.params as { code: string };
|
||||||
|
|
||||||
|
// La password passa da qui una volta sola: niente cache, né del browser né di un proxy
|
||||||
|
// davanti a Node, che altrimenti la servirebbe di nuovo a chi ripete la stessa GET.
|
||||||
|
Astro.response.headers.set('Cache-Control', 'no-store');
|
||||||
|
|
||||||
|
const fascicolo = accessoDelCliente(code);
|
||||||
|
if (!fascicolo) Astro.response.status = 404;
|
||||||
|
|
||||||
|
let errore = '';
|
||||||
|
let creato: { username: string; password: string } | null = null;
|
||||||
|
|
||||||
|
if (fascicolo && fascicolo.userId === null && Astro.request.method === 'POST') {
|
||||||
|
const form = await Astro.request.formData();
|
||||||
|
const username = String(form.get('username') ?? '');
|
||||||
|
const esito = creaAccessoCliente(undefined, { code, username });
|
||||||
|
if (esito.ok) creato = { username: esito.username, password: esito.password };
|
||||||
|
else errore = esito.errore;
|
||||||
|
}
|
||||||
|
---
|
||||||
|
<Longevity title="Crea accesso" crumbs={['Gestionale', code, 'Accesso']} variante="gestionale">
|
||||||
|
{!fascicolo && (
|
||||||
|
<div class="lg-card">
|
||||||
|
<p>Nessun fascicolo con codice <strong>{code}</strong>.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fascicolo && fascicolo.userId !== null && (
|
||||||
|
<div class="lg-card">
|
||||||
|
<p>Questo fascicolo ha già un accesso collegato: non se ne crea un secondo.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fascicolo && fascicolo.userId === null && creato && (
|
||||||
|
<div class="lg-card">
|
||||||
|
<p><strong>Accesso creato.</strong> Comunicala al cliente: non verrà mostrata di nuovo.</p>
|
||||||
|
<p>Utente: <strong>{creato.username}</strong></p>
|
||||||
|
<p>Password: <strong>{creato.password}</strong></p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fascicolo && fascicolo.userId === null && !creato && (
|
||||||
|
<form method="post" class="lg-form lg-form--accesso">
|
||||||
|
{errore && <p class="lg-form__errore">{errore}</p>}
|
||||||
|
<div class="lg-field">
|
||||||
|
<span>Nome utente</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
required
|
||||||
|
minlength="3"
|
||||||
|
maxlength="64"
|
||||||
|
pattern="[a-z0-9._@-]+"
|
||||||
|
value={fascicolo.email ?? ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="lg-form__nav">
|
||||||
|
<button type="submit">Crea accesso</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* .lg-field in longevity.css stila solo input[type="number"] e textarea (li usa il
|
||||||
|
questionario): qui serve un campo di testo, quindi lo stile arriva da qui. */
|
||||||
|
.lg-form--accesso .lg-field input[type="text"] {
|
||||||
|
border: 1px solid var(--lg-line); padding: 9px 11px; font: inherit; background: var(--lg-surface);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</Longevity>
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { createDb } from '../../src/lib/db';
|
||||||
|
import { login } from '../../src/lib/auth';
|
||||||
|
import { createIdentityDb, createLongevityDb } from '../../src/lib/longevity/db';
|
||||||
|
import { creaCliente, accessoDi, collegaAccesso } from '../../src/lib/longevity/anagrafica';
|
||||||
|
import { creaAccessoCliente, type DipendenzeAccesso } from '../../src/lib/longevity/accesso';
|
||||||
|
|
||||||
|
// Doppio delle dipendenze su DB in memoria: come in anagrafica.test.ts, connessioni proprie
|
||||||
|
// passate a mano, mai il singleton reale — questo è l'unico modo in cui accesso.ts si
|
||||||
|
// prova senza toccare data/identity.db o data/piattaforme.db.
|
||||||
|
function dipendenzeDiTest(identity: ReturnType<typeof createIdentityDb>, utenti: ReturnType<typeof createDb>): DipendenzeAccesso {
|
||||||
|
return {
|
||||||
|
utenti,
|
||||||
|
accessoDi: (code) => accessoDi(identity, code),
|
||||||
|
collega: (code, userId) => collegaAccesso(identity, code, userId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fascicoloPronto() {
|
||||||
|
const identity = createIdentityDb(':memory:');
|
||||||
|
const longevity = createLongevityDb(':memory:');
|
||||||
|
const utenti = createDb(':memory:');
|
||||||
|
const code = creaCliente(identity, longevity, { nome: 'Mario', cognome: 'Rossi', sesso: 'M' });
|
||||||
|
return { identity, longevity, utenti, code };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('creaAccessoCliente', () => {
|
||||||
|
it('crea l utente cliente, lo collega, e la password permette il login', () => {
|
||||||
|
const { identity, utenti, code } = fascicoloPronto();
|
||||||
|
const deps = dipendenzeDiTest(identity, utenti);
|
||||||
|
|
||||||
|
const esito = creaAccessoCliente(deps, { code, username: 'MarioRossi' });
|
||||||
|
expect(esito.ok).toBe(true);
|
||||||
|
if (!esito.ok) return;
|
||||||
|
|
||||||
|
expect(esito.username).toBe('mariorossi'); // normalizzato minuscolo
|
||||||
|
|
||||||
|
const riga = utenti.prepare('SELECT id, password_hash, role FROM users WHERE username = ?').get('mariorossi') as
|
||||||
|
{ id: number; password_hash: string; role: string };
|
||||||
|
expect(riga.role).toBe('cliente');
|
||||||
|
// la password torna in chiaro nell'esito, ma non è quella salvata: quella è l'hash.
|
||||||
|
expect(riga.password_hash).not.toBe(esito.password);
|
||||||
|
|
||||||
|
// il collegamento è avvenuto davvero: accessoDi lo ritrova.
|
||||||
|
const dopo = accessoDi(identity, code);
|
||||||
|
expect(dopo?.userId).toBe(riga.id);
|
||||||
|
|
||||||
|
// e la password restituita fa davvero login.
|
||||||
|
const token = login(utenti, 'mariorossi', esito.password);
|
||||||
|
expect(token).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fascicolo già con un accesso: errore, nessun utente creato', () => {
|
||||||
|
const { identity, utenti, code } = fascicoloPronto();
|
||||||
|
const deps = dipendenzeDiTest(identity, utenti);
|
||||||
|
|
||||||
|
const primo = creaAccessoCliente(deps, { code, username: 'uno' });
|
||||||
|
expect(primo.ok).toBe(true);
|
||||||
|
|
||||||
|
const secondo = creaAccessoCliente(deps, { code, username: 'due' });
|
||||||
|
expect(secondo.ok).toBe(false);
|
||||||
|
|
||||||
|
const utentiDopo = utenti.prepare('SELECT COUNT(*) n FROM users').get() as { n: number };
|
||||||
|
expect(utentiDopo.n).toBe(1); // solo il primo, "due" non è mai stato creato
|
||||||
|
});
|
||||||
|
|
||||||
|
it('codice inesistente: errore', () => {
|
||||||
|
const { identity, utenti } = fascicoloPronto();
|
||||||
|
const deps = dipendenzeDiTest(identity, utenti);
|
||||||
|
|
||||||
|
const esito = creaAccessoCliente(deps, { code: 'ISL-9999', username: 'fantasma' });
|
||||||
|
expect(esito.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('username già esistente: errore, niente collegato', () => {
|
||||||
|
const { identity, utenti, code } = fascicoloPronto();
|
||||||
|
const deps = dipendenzeDiTest(identity, utenti);
|
||||||
|
utenti.prepare("INSERT INTO users (username, password_hash, role) VALUES ('occupato', 'x', 'admin')").run();
|
||||||
|
|
||||||
|
const esito = creaAccessoCliente(deps, { code, username: 'occupato' });
|
||||||
|
expect(esito.ok).toBe(false);
|
||||||
|
|
||||||
|
const fascicolo = accessoDi(identity, code);
|
||||||
|
expect(fascicolo?.userId).toBeNull(); // resta senza accesso
|
||||||
|
});
|
||||||
|
|
||||||
|
it('username non valido (maiuscole non ammesse dopo la normalizzazione, o troppo corto): errore', () => {
|
||||||
|
const { identity, utenti, code } = fascicoloPronto();
|
||||||
|
const deps = dipendenzeDiTest(identity, utenti);
|
||||||
|
|
||||||
|
const corto = creaAccessoCliente(deps, { code, username: 'ab' });
|
||||||
|
expect(corto.ok).toBe(false);
|
||||||
|
|
||||||
|
const conSpazio = creaAccessoCliente(deps, { code, username: 'ha spazi' });
|
||||||
|
expect(conSpazio.ok).toBe(false);
|
||||||
|
|
||||||
|
const utentiDopo = utenti.prepare('SELECT COUNT(*) n FROM users').get() as { n: number };
|
||||||
|
expect(utentiDopo.n).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('se il collegamento fallisce, l utente appena creato viene cancellato (compensazione)', () => {
|
||||||
|
const { identity, utenti, code } = fascicoloPronto();
|
||||||
|
const deps: DipendenzeAccesso = {
|
||||||
|
utenti,
|
||||||
|
accessoDi: (c) => accessoDi(identity, c),
|
||||||
|
collega: () => false, // simula un collegamento rifiutato (es. fascicolo cambiato nel frattempo)
|
||||||
|
};
|
||||||
|
|
||||||
|
const esito = creaAccessoCliente(deps, { code, username: 'sfortunato' });
|
||||||
|
expect(esito.ok).toBe(false);
|
||||||
|
|
||||||
|
const rimasto = utenti.prepare('SELECT * FROM users WHERE username = ?').get('sfortunato');
|
||||||
|
expect(rimasto).toBeUndefined(); // nessun utente orfano
|
||||||
|
});
|
||||||
|
|
||||||
|
it('se il collegamento lancia, l utente appena creato viene comunque cancellato', () => {
|
||||||
|
const { identity, utenti, code } = fascicoloPronto();
|
||||||
|
const deps: DipendenzeAccesso = {
|
||||||
|
utenti,
|
||||||
|
accessoDi: (c) => accessoDi(identity, c),
|
||||||
|
collega: () => { throw new Error('identity irraggiungibile'); },
|
||||||
|
};
|
||||||
|
|
||||||
|
const esito = creaAccessoCliente(deps, { code, username: 'sfortunato2' });
|
||||||
|
expect(esito.ok).toBe(false);
|
||||||
|
|
||||||
|
const rimasto = utenti.prepare('SELECT * FROM users WHERE username = ?').get('sfortunato2');
|
||||||
|
expect(rimasto).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rete secondaria: la pagina del gestionale non mette la password in cache e non nomina identity', () => {
|
||||||
|
const path = join(process.cwd(), 'src/pages/longevity/gestionale/cliente/[code]/accesso.astro');
|
||||||
|
const src = readFileSync(path, 'utf8');
|
||||||
|
expect(src).toContain('no-store');
|
||||||
|
expect(src).not.toContain('Identity');
|
||||||
|
expect(src).not.toContain('.prepare(');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user