++ consolidamento funzionalità Backup

This commit is contained in:
2026-07-29 16:34:11 +02:00
parent fd15903c9c
commit 7ac454cd25
16 changed files with 585 additions and 30 deletions

View File

@@ -324,6 +324,35 @@ Scelte non ovvie:
sarebbe solo un rallentamento. Il blocco è sull'operazione, non sull'account:
nessuno resta chiuso fuori dall'applicazione.
- **Una sola operazione alla volta, con blocco che si autoripara.** Backup,
ripristino e azzeramento lavorano tutti sullo stesso database e sullo stesso
disco: due in parallelo — due schede, due amministratori — non si rallentano
soltanto, si sovrascrivono. `BackupOperationState::corrente()` fa da posto
unico; lo controllano sia il componente Livewire sia
`BackupController::upload()`, che sono due porte d'ingresso separate (la
seconda risponde 409). Il blocco non è eterno: ha un TTL di un'ora, e
`corrente()` lo considera libero se l'operazione puntata non esiste più o è
terminata. In più i tre job hanno un `failed()` — Laravel lo chiama quando il
worker uccide il job per timeout o errore fatale, cioè proprio i casi che
`handle()` non può intercettare — che chiude l'operazione e libera il posto
invece di lasciare una barra ferma per un'ora.
- **La pagina si riaggancia all'operazione in corso.** `BackupManager::mount()`
legge `corrente()`. Senza, ricaricare la pagina durante un backup lungo
faceva sparire la barra, e — più grave — un azzeramento fermo in attesa di
conferma diventava irraggiungibile: nessuno poteva più né confermarlo né
annullarlo, e il posto restava occupato fino alla scadenza.
- **L'esito del ripristino si legge senza consumarlo.** Il job non ha una
sessione in cui lasciare un flash, quindi deposita il messaggio in cache a
nome di chi ha avviato il ripristino e la schermata di cambio password lo
mostra. Deve essere un `Cache::get` + `forget` a password cambiata, **non** un
`pull`: quando è il middleware a dirottare una richiesta di Livewire, il
client segue il 302 via `fetch` — ed è quella `fetch` a bruciare il messaggio
— per poi rifare la stessa GET con `window.location`, che troverebbe la cache
già vuota. Con il `pull` l'amministratore non vedeva **mai** il riepilogo del
proprio ripristino.
- **Nell'azzeramento i file si cancellano prima del database.** Svuotare il
disco è l'unico passo che può fallire per cause esterne (permessi sul
volume), e fallire lì non ha ancora toccato niente. Nell'ordine inverso un
@@ -335,6 +364,32 @@ Scelte non ovvie:
worker da www-data) perché l'operazione si dichiari riuscita lasciando il
disco popolato.
- **L'archivio si controlla prima di consegnarlo.** Un backup è l'unico file di
cui non ci si accorge che era rotto finché non serve, quindi
`BackupArchive::create()` non si fida di aver scritto bene: controlla il
valore di ritorno di ogni `addFile()` (che torna `false`, senza sollevare, se
il file è sparito o è illeggibile — l'archivio finirebbe con un buco
silenzioso), e alla fine riapre l'archivio e verifica che contenga
`data.xml`, `manifest.json` e *esattamente* i file attesi. Se qualcosa non
torna, l'archivio viene cancellato e l'operazione fallisce: meglio nessun
backup che un backup che sembra buono.
Attenzione a una trappola di `ZipArchive`: dopo un `close()` fallito
l'oggetto resta inutilizzabile e ogni metodo successivo solleva `Invalid or
uninitialized Zip object`. Una chiusura di cortesia dentro un `catch`
sostituiva il messaggio vero con quello, e l'amministratore leggeva una
stringa incomprensibile al posto di «permesso negato su questo file». Da qui
`chiudi()`, che cattura `getStatusString()` prima che vada perso, e i
`close()` di pulizia avvolti in un try/catch che non può mascherare l'errore
originale.
- **Eliminare definitivamente un territorio porta via anche i suoi file.**
`TerritorioCestino::forceDelete()` cancellava la riga e lasciava mappa e
miniatura sul volume: invisibili in ogni schermata, non più raggiungibili da
nessuna cancellazione, e dentro ogni backup successivo. I file si rimuovono
*prima* della riga, così se la cancellazione fallisce il territorio resta nel
cestino e l'operazione si può ritentare.
- **L'azzeramento tiene in vita l'amministratore che l'ha chiesto.** Un'installazione
appena creata ha un amministratore; cancellarli tutti lascerebbe
l'applicazione senza nessuno che possa accedere fino al riavvio del container
@@ -459,13 +514,37 @@ In ordine di priorità.
### 1. Nessun test automatico
`composer.json` richiede PHPUnit ma **la cartella `tests/` non esiste**. Ogni
verifica è manuale. È il debito più rilevante: le regole di business (anno
teocratico, stato territorio, priorità automatica, percentuale campagne) sono
esattamente il tipo di logica che va protetta da test.
`composer.json` richiede PHPUnit ma **la cartella `tests/` non esiste** e non
c'è `phpunit.xml`. Ogni verifica è manuale. È il debito più rilevante: le regole
di business (anno teocratico, stato territorio, priorità automatica, percentuale
campagne) sono esattamente il tipo di logica che va protetta da test — e a
maggior ragione backup, ripristino e azzeramento, dove un errore non si vede
finché non è troppo tardi.
Nota: `config/database.php` ridefinisce `connections` e lascia solo `mysql`.
Per usare SQLite in memoria nei test va riaggiunta la connessione `sqlite`.
Tre blocchi concreti da rimuovere prima di poter scrivere il primo test:
1. **PHPUnit non è nell'immagine.** `docker/php/Dockerfile` installa con
`--no-dev` (giusto, per la produzione), quindi `vendor/phpunit` nel container
non c'è e `docker compose exec app ./vendor/bin/phpunit` non funziona.
Servono o un `composer install` locale sull'host, o uno stage `dev` nel
Dockerfile che tenga le dipendenze di sviluppo.
2. **Manca la connessione SQLite.** `config/database.php` ridefinisce
`connections` e lascia solo `mysql`. Per usare SQLite in memoria va
riaggiunta la connessione `sqlite`.
3. **Manca `phpunit.xml`.** L'autoload `Tests\` in `composer.json` c'è già, il
resto no.
Dove conviene cominciare, in ordine di rapporto valore/attrito — i primi due
non hanno bisogno del database e quindi si sbloccano risolvendo solo il punto 1:
- `Services/BackupArchive` con `Storage::fake('public')`: round-trip
crea → verifica → estrai, rifiuto dello zip slip, rifiuto del formato futuro,
fallimento su archivio incompleto.
- `Services/BackupOperationState` con il driver di cache `array`: il posto
unico, il rilascio alla chiusura, il puntatore orfano, il non-rilascio da
parte di un'operazione vecchia.
- `Livewire/Registro::righeS13()`: il ciclo da quattro e la colonna del
riporto, con i casi 2/3/4/5/8/9 assegnazioni (questi sì, con database).
### 2. Race su doppia assegnazione