Files
termanager2/tests/Feature/RipristinoBackupTest.php
Francesco Picone 426a95211d Revisione blocchi 2 e 3: file protetti, ripristino atomico, import sicuri
Blocco 2
- Esiti delle azioni Livewire come avvisi (trait Avvisa) invece di flash
  che comparivano alla pagina dopo.
- PDF, miniature e versioni dei territori non più serviti da nginx su
  /storage: TerritorioFileController con login, permessi e cache privata.
- Ripristino tutto o niente: verifica dell'archivio, una transazione con
  import, piantine, blocco account e scambio dei file annullabile.
- Backup formato 2 con piantine, storico PDF e area KML.
- Posto unico preso in un passo (Cache::lock) e rispettato anche da
  import XML e PDF.
- Import PDF da cartella: abbinamento corretto (1-A, 12 B, ambigui),
  caricamento a lotti, file non PDF ignorati, PDF sostituito nello storico.
- L'import XML elimina i file dei territori che non usa più; comando
  territori:file-orfani per quelli accumulati prima.

Blocco 3
- Password attuale richiesta al cambio volontario; AuthenticateSession
  chiude le sessioni aperte altrove.
- Niente nomi dei proclamatori in chiaro nel registro attività.
- Export XML senza remember_token; chi riceve l'hash dal file deve
  cambiare password.
- Campagne: ultimo giorno incluso, percentuale sui territori distinti.
- Coda: retry_after 960 s, stop_grace_period 16m, Redis volatile-lru,
  impostazioni rilette a ogni job.
- Azzeramento che cancella anche piantine, area KML e anteprime.
- Pulsanti profilo e menu che non si nascondevano; icona del sito.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:01:41 +02:00

352 lines
15 KiB
PHP

<?php
namespace Tests\Feature;
use App\Jobs\RestoreBackupArchive;
use App\Livewire\Settings\XmlExchange;
use App\Models\Territorio;
use App\Models\TerritorioMappa;
use App\Models\TerritorioPdfVersione;
use App\Models\User;
use App\Models\Zona;
use App\Services\BackupArchive;
use App\Services\BackupOperationState;
use App\Services\Piantine\PiantineBackup;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
use ZipArchive;
/**
* Il ripristino da .zip, job intero: tutto o niente, piantine comprese.
*
* Prima import, file e blocco degli account erano tre passi separati e il
* disco si svuotava prima di ricopiarlo: un errore a meta' lasciava il
* database del backup con i file a meta'. E il backup non conteneva ne'
* le piantine ne' lo storico dei PDF ne' l'area KML.
*
* Ogni test fa un backup "vero" (stessi servizi del job di backup), cambia
* qualcosa, e ripristina.
*/
class RipristinoBackupTest extends TestCase
{
use RefreshDatabase;
private User $admin;
private User $altro;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
Storage::fake('local');
Role::create(['name' => 'amministratore', 'guard_name' => 'web']);
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'admin-al-backup']);
$this->admin->assignRole('amministratore');
$this->altro = User::create(['name' => 'Altro', 'email' => 'altro@example.test', 'password' => 'altro-al-backup']);
}
protected function tearDown(): void
{
$archivio = new BackupArchive();
foreach (['backup-restore', 'backup-precedenti', 'testing-ripristino'] as $cartella) {
$archivio->deleteDirectory(storage_path('app/' . $cartella));
}
parent::tearDown();
}
// ─── Piantine nel backup ────────────────────────────────────
public function test_piantine_storico_e_area_kml_sopravvivono_al_ripristino(): void
{
[$territorio] = $this->installazioneConPiantine();
$zip = $this->backup();
// Dopo il backup cambia tutto: piantine e storico spariscono, l'area
// e' un'altra, compare un file estraneo.
TerritorioMappa::query()->delete();
TerritorioPdfVersione::query()->delete();
Storage::disk('local')->put('piantine/area.kml', 'area successiva');
Storage::disk('public')->put('territori-pdf/estraneo.pdf', 'dopo il backup');
$stato = $this->ripristina($zip);
$this->assertSame('completed', $stato['status'], (string) $stato['error']);
// L'import ricrea i territori con id nuovi: il collegamento si ritrova
// dal numero, non dall'id del backup.
$nuovo = Territorio::where('numero', '12')->firstOrFail();
$this->assertNotSame($territorio->id, $nuovo->id);
$mappa = TerritorioMappa::sole();
$this->assertSame($nuovo->id, $mappa->territorio_id);
$this->assertSame('{"type":"Polygon","coordinates":[[[13.3,38.1],[13.4,38.1],[13.4,38.2],[13.3,38.1]]]}', $mappa->geojson);
$this->assertSame('{"type":"Polygon"}', $mappa->geojson_pubblicato);
$this->assertSame(str_repeat('a', 64), $mappa->impronta_pubblicata);
$this->assertSame('2026-10-01 10:00:00', $mappa->pubblicata_at->format('Y-m-d H:i:s'));
$this->assertSame('territori-mappe/piantina-1.jpg', $mappa->image_path);
$versione = TerritorioPdfVersione::sole();
$this->assertSame($nuovo->id, $versione->territorio_id);
$this->assertTrue(Storage::disk('public')->exists($versione->pdf_path));
$this->assertSame('<kml>area al backup</kml>', Storage::disk('local')->get('piantine/area.kml'));
$this->assertFalse(Storage::disk('public')->exists('territori-pdf/estraneo.pdf'));
$this->assertTrue(Storage::disk('public')->exists('territori-mappe/piantina-1.jpg'));
}
/**
* Compatibilita' all'indietro: un archivio di formato 1 si ripristina.
* Non contiene piantine e storico, che restano vuoti — tenere quelle
* correnti vorrebbe dire righe che citano immagini del disco appena
* sostituito. L'area KML non e' legata a nessuna riga e non si tocca.
*/
public function test_un_archivio_di_formato_1_si_ripristina_con_piantine_vuote(): void
{
$this->installazioneConPiantine();
$zip = $this->archivioFormato1();
$stato = $this->ripristina($zip);
$this->assertSame('completed', $stato['status'], (string) $stato['error']);
$this->assertTrue(Territorio::where('numero', '12')->exists());
$this->assertSame(0, TerritorioMappa::count());
$this->assertSame(0, TerritorioPdfVersione::count());
$this->assertSame('<kml>area al backup</kml>', Storage::disk('local')->get('piantine/area.kml'));
$this->assertSame(['territori-pdf/dal-formato-1.pdf'], Storage::disk('public')->allFiles());
}
// ─── Tutto o niente ─────────────────────────────────────────
/**
* Lo scambio dei file fallisce (la cartella dell'area e' occupata da un
* file e non si puo' creare) dopo che l'import ha gia' riscritto le
* tabelle. Prima l'import era gia' confermato a quel punto e il disco gia'
* svuotato: database del backup, file a meta'. Adesso torna tutto com'era,
* database e file.
*/
public function test_un_errore_sui_file_annulla_anche_il_database(): void
{
$this->installazioneConPiantine();
$zip = $this->backup();
$this->cambiaDopoIlBackup();
Storage::disk('local')->deleteDirectory('piantine');
Storage::disk('local')->put('piantine', 'un file al posto della cartella');
$stato = $this->ripristina($zip);
$this->assertSame('failed', $stato['status']);
$this->assertSituazioneDopoIlBackupIntatta();
}
/**
* Un dato delle piantine che il database rifiuta arriva dopo l'import
* XML, nella stessa transazione: deve trascinare indietro anche l'import,
* e i file non devono essere stati toccati.
*/
public function test_un_errore_sulle_piantine_annulla_l_import_e_non_tocca_i_file(): void
{
$this->installazioneConPiantine();
$zip = $this->backup(function (array $piantine) {
$piantine['territorio_mappe'][0]['geojson'] = null;
return $piantine;
});
$this->cambiaDopoIlBackup();
$stato = $this->ripristina($zip);
$this->assertSame('failed', $stato['status']);
$this->assertSituazioneDopoIlBackupIntatta();
}
/**
* Un archivio troncato o alterato si ferma all'estrazione: niente e'
* stato ancora toccato.
*/
public function test_un_archivio_corrotto_non_tocca_niente(): void
{
$this->installazioneConPiantine();
$zip = $this->backup();
$this->cambiaDopoIlBackup();
// Tronca l'archivio: la directory centrale sta in fondo, e senza
// quella non si apre.
$contenuto = file_get_contents($zip);
file_put_contents($zip, substr($contenuto, 0, (int) (strlen($contenuto) * 0.6)));
$stato = $this->ripristina($zip);
$this->assertSame('failed', $stato['status']);
$this->assertSituazioneDopoIlBackupIntatta();
}
// ─── Chi ripristina resta dentro ────────────────────────────
/**
* Con AuthenticateSession attivo, un hash della password cambiato chiude
* la sessione: se il ripristino desse all'amministratore che lo esegue
* l'hash del backup, verrebbe buttato fuori e dovrebbe rientrare con una
* password di mesi fa. Gli altri prendono quella del backup — e devono
* comunque cambiarla, attore compreso.
*/
public function test_chi_ripristina_conserva_la_sua_password_gli_altri_prendono_quella_del_backup(): void
{
$hashAltroAlBackup = $this->hash($this->altro);
$zip = $this->backup();
$this->admin->update(['password' => 'admin-attuale']);
$this->altro->update(['password' => 'altro-attuale']);
$hashAdminAttuale = $this->hash($this->admin);
$stato = $this->ripristina($zip);
$this->assertSame('completed', $stato['status'], (string) $stato['error']);
$this->assertSame($hashAdminAttuale, $this->hash($this->admin));
$this->assertSame($hashAltroAlBackup, $this->hash($this->altro));
$this->assertTrue($this->admin->fresh()->hasRole('amministratore'));
$this->assertSame(2, User::where('password_change_required', true)->count());
}
/**
* L'attore non c'e' nel backup (account creato dopo): l'import fa upsert
* per email e non cancella nessuno, quindi il suo account resta com'e',
* password e ruolo compresi.
*/
public function test_chi_ripristina_e_non_e_nel_backup_resta_con_password_e_ruolo(): void
{
$zip = $this->backup();
$nuovoAdmin = User::create(['name' => 'Nuovo', 'email' => 'nuovo@example.test', 'password' => 'nuovo-admin']);
$nuovoAdmin->assignRole('amministratore');
$hashNuovo = $this->hash($nuovoAdmin);
$stato = $this->ripristina($zip, $nuovoAdmin);
$this->assertSame('completed', $stato['status'], (string) $stato['error']);
$this->assertSame($hashNuovo, $this->hash($nuovoAdmin));
$this->assertTrue($nuovoAdmin->fresh()->hasRole('amministratore'));
$this->assertTrue((bool) $nuovoAdmin->fresh()->password_change_required);
}
// ─── helper ─────────────────────────────────────────────────
/** @return array{0: Territorio} */
private function installazioneConPiantine(): array
{
$zona = Zona::create(['nome' => 'Centro', 'attivo' => true]);
Storage::disk('public')->put('territori-pdf/piantina-12.pdf', 'pdf in vigore');
Storage::disk('public')->put('territori-pdf-versioni/territorio-12-vecchio.pdf', 'pdf sostituito');
Storage::disk('public')->put('territori-mappe/piantina-1.jpg', 'immagine');
Storage::disk('local')->put('piantine/area.kml', '<kml>area al backup</kml>');
$territorio = Territorio::create([
'numero' => '12',
'zona_id' => $zona->id,
'attivo' => true,
'pdf_path' => 'territori-pdf/piantina-12.pdf',
]);
TerritorioMappa::create([
'territorio_id' => $territorio->id,
'geojson' => '{"type":"Polygon","coordinates":[[[13.3,38.1],[13.4,38.1],[13.4,38.2],[13.3,38.1]]]}',
'geojson_pubblicato' => '{"type":"Polygon"}',
'impronta_pubblicata' => str_repeat('a', 64),
'pubblicata_at' => '2026-10-01 10:00:00',
'image_path' => 'territori-mappe/piantina-1.jpg',
]);
TerritorioPdfVersione::create([
'territorio_id' => $territorio->id,
'pdf_path' => 'territori-pdf-versioni/territorio-12-vecchio.pdf',
'origine' => 'piantine',
'sostituito_il' => '2026-09-01 09:00:00',
]);
return [$territorio];
}
/** Come CreateBackupArchive, con la possibilita' di alterare piantine.json. */
private function backup(?callable $altera = null): string
{
$piantine = app(PiantineBackup::class)->esporta();
if ($altera) {
$piantine = $altera($piantine);
}
$destinazione = storage_path('app/testing-ripristino/backup.zip');
(new BackupArchive())->create((new XmlExchange())->currentXmlBackup(), $piantine, $destinazione);
return $destinazione;
}
/** Un pacchetto come li produceva l'applicazione prima delle piantine. */
private function archivioFormato1(): string
{
$destinazione = storage_path('app/testing-ripristino/formato-1.zip');
@mkdir(dirname($destinazione), 0775, true);
$zip = new ZipArchive();
$zip->open($destinazione, ZipArchive::CREATE | ZipArchive::OVERWRITE);
$zip->addFromString('manifest.json', json_encode(['format_version' => 1, 'application' => 'TerManager2', 'files_count' => 1]));
$zip->addFromString('data.xml', (new XmlExchange())->currentXmlBackup());
$zip->addFromString('storage/territori-pdf/dal-formato-1.pdf', 'pdf');
$zip->close();
return $destinazione;
}
private function cambiaDopoIlBackup(): void
{
Territorio::create(['numero' => '99', 'attivo' => true]);
TerritorioMappa::create(['nome' => 'Disegnata dopo', 'geojson' => '{}']);
$this->altro->update(['password' => 'altro-attuale']);
Storage::disk('public')->put('territori-pdf/estraneo.pdf', 'dopo il backup');
}
/** Lo stato lasciato da cambiaDopoIlBackup(), che un ripristino fallito deve conservare. */
private function assertSituazioneDopoIlBackupIntatta(): void
{
$this->assertTrue(Territorio::where('numero', '99')->exists(), 'L\'import non e\' stato annullato.');
$this->assertTrue(TerritorioMappa::where('nome', 'Disegnata dopo')->exists());
$this->assertSame(2, TerritorioMappa::count());
$this->assertSame(1, TerritorioPdfVersione::count());
$this->assertTrue(password_verify('altro-attuale', $this->hash($this->altro)));
$this->assertSame(0, User::where('password_change_required', true)->count(), 'Gli account non vanno bloccati se il ripristino non avviene.');
$this->assertTrue(Storage::disk('public')->exists('territori-pdf/estraneo.pdf'), 'I file sono stati sostituiti nonostante il fallimento.');
$this->assertTrue(Storage::disk('public')->exists('territori-pdf/piantina-12.pdf'));
$this->assertTrue(Storage::disk('public')->exists('territori-mappe/piantina-1.jpg'));
$this->assertSame([], glob(storage_path('app/backup-precedenti/*')) ?: []);
}
private function ripristina(string $zip, ?User $attore = null): array
{
$operazione = 'ripristino-di-prova';
app(BackupOperationState::class)->initialize($operazione, BackupOperationState::TIPO_RESTORE, 'In coda');
RestoreBackupArchive::dispatchSync($operazione, $zip, ($attore ?? $this->admin)->id);
return app(BackupOperationState::class)->get($operazione);
}
private function hash(User $utente): string
{
return (string) DB::table('users')->where('id', $utente->id)->value('password');
}
}