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>
352 lines
15 KiB
PHP
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');
|
|
}
|
|
}
|