'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('area al backup', 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')); } /** * Una piantina pubblicata resta "Pubblicata" anche se il ripristino ricrea * il suo territorio con un id nuovo: prima lo stato dipendeva dall'id. */ public function test_una_piantina_pubblicata_resta_pubblicata_dopo_il_ripristino(): void { $this->installazioneConPiantine(); $mappa = TerritorioMappa::with('territorio.zona')->sole(); $mappa->update([ 'impronta_pubblicata' => $mappa->impronta(), 'pdf_pubblicato' => $mappa->territorio->pdf_path, ]); $this->assertSame(TerritorioMappa::STATO_PUBBLICATA, $mappa->fresh()->stato()); $stato = $this->ripristina($this->backup()); $this->assertSame('completed', $stato['status'], (string) $stato['error']); $this->assertSame(TerritorioMappa::STATO_PUBBLICATA, TerritorioMappa::sole()->stato()); } /** * 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('area al backup', 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', 'area al backup'); $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'); } }