longevity: la connessione a identity non e' piu' raggiungibile fuori da anagrafica.ts

Il revisore ha dimostrato che il test anti-giunzione si aggira senza malizia:
tre file (uno riesporta la connessione a identity, uno quella a longevity, un
terzo le importa da entrambi e le usa insieme) e nessuno dei tre contiene
singolarmente le due parole cercate. Un controllo testuale file-per-file non
puo' vedere una giunzione che attraversa piu' file - quindi la risposta non
e' un test piu' furbo, e' rendere la cosa impossibile invece di sorvegliarla.

- db.ts: rimossa l'esportazione di getIdentityDb. Resta solo getLongevityDb
  (il database senza nomi, puo' usarlo chiunque) e createIdentityDb (serve ai
  test per aprire database in memoria - li' la giunzione e' voluta ed
  evidente, il problema era il singleton condiviso, non la fabbrica).
- anagrafica.ts: getIdentityDb torna dentro, privata (non esportata) e usata
  solo da codicePerUtenteLoggato. Verificato che il bypass a tre file del
  revisore ora fallisce gia' a tsc --noEmit ('has no exported member named
  getIdentityDb'), non solo a un test che lo sorveglia.
- tests/longevity/anagrafica.test.ts: il test anti-giunzione resta come rete
  secondaria, con un commento che dichiara onestamente cosa non vede (una
  giunzione via re-export su piu' file) invece di promettere una garanzia che
  non mantiene.

Due correzioni minori dalla stessa revisione:
- api/longevity/questionario.ts: salvaDalForm (via salvaCompilazione) lancia
  se una risposta porta un test_id sconosciuto al registro; ora e' avvolta in
  try/catch e risponde 400 pulito invece di un 500 generico, come gia' fa
  src/pages/api/contact.ts per l'invio email.
- components/longevity/Questionario.tsx: un campo con tipo_valore='txt' nel
  registro ora rende un input testuale (non piu' sempre "number"), e in
  invio i campi testuali non vengono convertiti in NaN e scartati in
  silenzio - restano fuori esplicitamente, con un commento che dice perche'.
This commit is contained in:
2026-08-22 14:12:43 +02:00
parent f0629db3c4
commit 8c980750c0
5 changed files with 75 additions and 28 deletions
+26 -8
View File
@@ -45,6 +45,11 @@ export default function Questionario({ campi }: Props) {
[campi]
);
// Il tipo dichiarato nel registro per ciascun campo: serve sia per scegliere il tipo di
// input (numero o testo) sia, all'invio, per non trattare una risposta testuale come un
// numero da scartare in silenzio.
const tipoDiCampo = useMemo(() => new Map(campi.map((c) => [c.id, c.tipo])), [campi]);
const totalePassi = gruppi.length + 1; // + il blocco note/consenso
const ultimo = passo === totalePassi - 1;
@@ -54,6 +59,11 @@ export default function Questionario({ campi }: Props) {
const risposteNumeriche: Record<string, number> = {};
for (const [id, v] of Object.entries(risposte)) {
// Le domande testuali (tipo_valore='txt' nel registro) non sono ancora agganciate
// al salvataggio numerico: oggi non esistono nel questionario (i 20 campi attivi
// sono tutti 'num'), ma se un domani ne comparisse una non va convertita in NaN e
// scartata in silenzio — resta fuori esplicitamente.
if (tipoDiCampo.get(id) === 'txt') continue;
const n = Number(v);
if (v !== '' && Number.isFinite(n)) risposteNumeriche[id] = n;
}
@@ -97,14 +107,22 @@ export default function Questionario({ campi }: Props) {
{gruppi[passo].campi.map((c) => (
<label className="lg-field" key={c.id}>
<span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
<input
type="number"
min={c.min}
max={c.max}
step="any"
value={risposte[c.id] ?? ''}
onChange={(e) => setRisposte((r) => ({ ...r, [c.id]: e.target.value }))}
/>
{c.tipo === 'txt' ? (
<input
type="text"
value={risposte[c.id] ?? ''}
onChange={(e) => setRisposte((r) => ({ ...r, [c.id]: e.target.value }))}
/>
) : (
<input
type="number"
min={c.min}
max={c.max}
step="any"
value={risposte[c.id] ?? ''}
onChange={(e) => setRisposte((r) => ({ ...r, [c.id]: e.target.value }))}
/>
)}
</label>
))}
</section>
+12 -1
View File
@@ -4,7 +4,18 @@
// risalga alla persona (richiesta del cliente, 21/08): un secondo punto di giunzione
// la annullerebbe in silenzio. Un test in tests/longevity/anagrafica.test.ts lo verifica.
import type Database from 'better-sqlite3';
import { getIdentityDb } from './db';
import { createIdentityDb } from './db';
// Connessione singola a identity, privata di questo modulo: non è esportata, quindi non
// è raggiungibile da nessun'altra parte del codice, nemmeno con un re-export su più file
// (un controllo testuale file-per-file non lo vedrebbe, ma qui il problema non si pone:
// non esiste un nome da importare). QUESTO è il modo in cui il vincolo si rende vero,
// non solo sorvegliato.
let identitySingleton: Database.Database | null = null;
function getIdentityDb(): Database.Database {
if (!identitySingleton) identitySingleton = createIdentityDb();
return identitySingleton;
}
/**
* Il prossimo codice libero: il massimo fra quelli già usati in identity.clienti E in
+6 -7
View File
@@ -135,17 +135,16 @@ export function createIdentityDb(path?: string): Database.Database {
return apri(path ?? process.env.IDENTITY_DB_PATH ?? 'data/identity.db', SCHEMA_IDENTITY);
}
// Connessioni singole per processo, come getDb() in src/lib/db.ts: gli endpoint chiedono
// Connessione singola per processo, come getDb() in src/lib/db.ts: gli endpoint chiedono
// una connessione già aperta invece di aprirne una nuova a ogni richiesta.
// ⚠️ Qui c'è SOLO longevity (il database senza nomi: può usarlo chiunque). Un singleton
// analogo per identity non si esporta da qui — vive privato dentro anagrafica.ts, l'unico
// modulo autorizzato a tenerlo: un controllo testuale file-per-file non vede una giunzione
// che passa da un re-export su piu' file, quindi la connessione condivisa a identity non
// deve essere raggiungibile da fuori quel modulo, punto.
let longevitySingleton: Database.Database | null = null;
let identitySingleton: Database.Database | null = null;
export function getLongevityDb(): Database.Database {
if (!longevitySingleton) longevitySingleton = createLongevityDb();
return longevitySingleton;
}
export function getIdentityDb(): Database.Database {
if (!identitySingleton) identitySingleton = createIdentityDb();
return identitySingleton;
}
+14 -3
View File
@@ -46,9 +46,20 @@ export const POST: APIRoute = async ({ request, locals }) => {
// La data della compilazione è quella del server, non quella (manomettibile) del client.
const oggi = new Date().toISOString().slice(0, 10);
const esito = salvaDalForm(getLongevityDb(), {
client_code: codice, data: oggi, eta, risposte, consensoSanitario, liberi,
});
// salvaDalForm delega a salvaCompilazione, che lancia se una risposta porta un test_id
// sconosciuto al registro (client disallineato, non serve malizia): un'eccezione qui
// non deve diventare un 500 generico, come in src/pages/api/contact.ts per l'invio email.
let esito;
try {
esito = salvaDalForm(getLongevityDb(), {
client_code: codice, data: oggi, eta, risposte, consensoSanitario, liberi,
});
} catch (err) {
console.error('Salvataggio questionario fallito:', err);
return json(400, {
error: 'Impossibile salvare: alcune risposte non sono riconosciute. Ricarica la pagina e riprova.',
});
}
if (!esito.ok) return json(422, { error: esito.errore });
return json(201, { sessioneId: esito.sessioneId });
+17 -9
View File
@@ -88,15 +88,23 @@ describe('anagrafica pseudonimizzata', () => {
expect(id.prepare(`SELECT * FROM clienti WHERE nome = 'Errato'`).get()).toBeUndefined();
});
it('nessun file in src/ (escluso anagrafica.ts e db.ts) fa riferimento sia a identity sia a longevity', () => {
// Non cerchiamo due nomi di funzione precisi (createIdentityDb/createLongevityDb): un
// domani qualcuno aggiunge getIdentityDb, o un terzo accessore con un nome diverso, e
// l'elenco invecchia senza che nessuno se ne accorga (è già successo: getIdentityDb e
// getLongevityDb sono nati DOPO questo test e sono passati indisturbati). Il verso
// giusto è strutturale: qualunque accesso alle due connessioni passa comunque per una
// funzione esportata da db.ts il cui nome contiene "Identity" o "Longevity" — quindi
// basta cercare quei due riferimenti letterali, in QUALUNQUE forma compaiano, e
// segnalare i file che li hanno tutti e due.
it('rete secondaria: nessun file in src/ (escluso anagrafica.ts e db.ts) fa riferimento sia a identity sia a longevity', () => {
// ⚠️ QUESTO TEST NON È LA GARANZIA. La garanzia vera è che getIdentityDb non è più
// esportata da db.ts (vive privata dentro anagrafica.ts): non esiste, da nessuna
// parte del codice fuori da lì, un nome da importare per ottenere quella connessione.
// Un controllo testuale come questo non può bastare da solo — è stato dimostrato:
// tre file, uno che riesporta la connessione a identity, uno che riesporta quella a
// longevity, un terzo che importa da entrambi e le usa insieme, e NESSUNO dei tre
// contiene singolarmente le due parole. Un controllo file-per-file non vede una
// giunzione che attraversa più file, e non c'è formulazione di questo test che lo
// possa vedere: la risposta era rendere la cosa impossibile, non sorvegliarla meglio.
// Questo test resta come rete secondaria — cattura il caso comune (una giunzione
// dentro un solo file) e protegge contro un domani in cui getIdentityDb tornasse
// esportata per errore — non come prova che la giunzione non possa esistere.
// Cerca i due riferimenti letterali "Identity"/"Longevity" (con l'iniziale maiuscola,
// come compaiono in qualunque accessore camelCase) invece di un elenco chiuso di nomi:
// un elenco di nomi vietati invecchia (è già successo qui: getIdentityDb/getLongevityDb
// sono nati dopo la prima versione di questo test e sono passati indisturbati).
const srcDir = join(process.cwd(), 'src');
const colpevoli: string[] = [];