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>
This commit is contained in:
@@ -21,6 +21,9 @@ use ZipArchive;
|
||||
*/
|
||||
class BackupArchiveTest extends TestCase
|
||||
{
|
||||
/** piantine.json di un'installazione senza piantine. */
|
||||
private const PIANTINE_VUOTE = ['territorio_mappe' => [], 'territorio_pdf_versioni' => []];
|
||||
|
||||
private BackupArchive $archivio;
|
||||
|
||||
/** Cartella dei file .zip prodotti dai test, ripulita a ogni giro. */
|
||||
@@ -31,6 +34,9 @@ class BackupArchiveTest extends TestCase
|
||||
parent::setUp();
|
||||
|
||||
Storage::fake('public');
|
||||
// Anche il disco `local`: l'archivio ci legge l'area KML delle
|
||||
// piantine, e senza finto prenderebbe quella vera della macchina.
|
||||
Storage::fake('local');
|
||||
|
||||
$this->archivio = new BackupArchive();
|
||||
$this->lavoro = storage_path('app/testing-backup');
|
||||
@@ -47,6 +53,7 @@ class BackupArchiveTest extends TestCase
|
||||
// reale, non il disco finto, quindi va ripulito a mano.
|
||||
$this->archivio->deleteDirectory($this->lavoro);
|
||||
$this->archivio->deleteDirectory(storage_path('app/backup-restore'));
|
||||
$this->archivio->deleteDirectory(storage_path('app/backup-precedenti'));
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
@@ -60,7 +67,7 @@ class BackupArchiveTest extends TestCase
|
||||
Storage::disk('public')->put('logo.svg', 'logo');
|
||||
|
||||
$destinazione = $this->lavoro . '/backup.zip';
|
||||
$this->archivio->create('<dataset/>', $destinazione);
|
||||
$this->archivio->create('<dataset/>', self::PIANTINE_VUOTE, $destinazione);
|
||||
|
||||
$this->assertFileExists($destinazione);
|
||||
|
||||
@@ -68,9 +75,9 @@ class BackupArchiveTest extends TestCase
|
||||
|
||||
$this->assertSame('<dataset/>', $esito['xml']);
|
||||
$this->assertSame(3, $esito['files']);
|
||||
$this->assertStringEqualsFile($esito['staging'] . '/territori-pdf/1.pdf', 'contenuto-uno');
|
||||
$this->assertStringEqualsFile($esito['staging'] . '/territori-thumbnails/1.png', 'miniatura-uno');
|
||||
$this->assertStringEqualsFile($esito['staging'] . '/logo.svg', 'logo');
|
||||
$this->assertStringEqualsFile($esito['pubblico'] . '/territori-pdf/1.pdf', 'contenuto-uno');
|
||||
$this->assertStringEqualsFile($esito['pubblico'] . '/territori-thumbnails/1.png', 'miniatura-uno');
|
||||
$this->assertStringEqualsFile($esito['pubblico'] . '/logo.svg', 'logo');
|
||||
}
|
||||
|
||||
public function test_il_manifest_dichiara_versione_di_formato_e_conteggio(): void
|
||||
@@ -79,7 +86,7 @@ class BackupArchiveTest extends TestCase
|
||||
Storage::disk('public')->put('territori-pdf/2.pdf', 'y');
|
||||
|
||||
$destinazione = $this->lavoro . '/backup.zip';
|
||||
$this->archivio->create('<dataset/>', $destinazione);
|
||||
$this->archivio->create('<dataset/>', self::PIANTINE_VUOTE, $destinazione);
|
||||
|
||||
$zip = new ZipArchive();
|
||||
$this->assertTrue($zip->open($destinazione) === true);
|
||||
@@ -94,7 +101,7 @@ class BackupArchiveTest extends TestCase
|
||||
public function test_il_pacchetto_di_un_installazione_senza_file_contiene_comunque_i_dati(): void
|
||||
{
|
||||
$destinazione = $this->lavoro . '/backup.zip';
|
||||
$this->archivio->create('<dataset/>', $destinazione);
|
||||
$this->archivio->create('<dataset/>', self::PIANTINE_VUOTE, $destinazione);
|
||||
|
||||
$esito = $this->archivio->extract($destinazione);
|
||||
|
||||
@@ -116,6 +123,7 @@ class BackupArchiveTest extends TestCase
|
||||
$avanzamenti = [];
|
||||
$this->archivio->create(
|
||||
'<dataset/>',
|
||||
self::PIANTINE_VUOTE,
|
||||
$this->lavoro . '/backup.zip',
|
||||
function (int $fatti, int $totale) use (&$avanzamenti) {
|
||||
$avanzamenti[] = [$fatti, $totale];
|
||||
@@ -156,7 +164,7 @@ class BackupArchiveTest extends TestCase
|
||||
$destinazione = $this->lavoro . '/backup.zip';
|
||||
|
||||
try {
|
||||
$this->archivio->create('<dataset/>', $destinazione);
|
||||
$this->archivio->create('<dataset/>', self::PIANTINE_VUOTE, $destinazione);
|
||||
$this->fail('create() avrebbe dovuto sollevare su un file non leggibile.');
|
||||
} catch (RuntimeException $e) {
|
||||
$this->assertStringContainsString('territori-pdf/sparito.pdf', $e->getMessage());
|
||||
@@ -290,9 +298,9 @@ class BackupArchiveTest extends TestCase
|
||||
$esito = $this->archivio->extract($zip);
|
||||
|
||||
$this->assertSame(1, $esito['files']);
|
||||
$this->assertFileExists($esito['staging'] . '/territori-pdf/1.pdf');
|
||||
$this->assertFileDoesNotExist($esito['staging'] . '/README.txt');
|
||||
$this->assertDirectoryDoesNotExist($esito['staging'] . '/appunti');
|
||||
$this->assertFileExists($esito['pubblico'] . '/territori-pdf/1.pdf');
|
||||
$this->assertFileDoesNotExist($esito['pubblico'] . '/README.txt');
|
||||
$this->assertDirectoryDoesNotExist($esito['pubblico'] . '/appunti');
|
||||
}
|
||||
|
||||
// ─── safeRelativePath(): la regola dello zip slip ───────────
|
||||
@@ -335,7 +343,109 @@ class BackupArchiveTest extends TestCase
|
||||
];
|
||||
}
|
||||
|
||||
// ─── restoreFiles() / svuotaDiscoPubblico() ─────────────────
|
||||
/**
|
||||
* Un'entry alterata dentro l'archivio deve fermare il ripristino durante
|
||||
* l'estrazione, a installazione intatta. Prima il risultato della copia
|
||||
* non si guardava: un file troncato o corrotto finiva nello staging e da
|
||||
* li' al posto del PDF buono, con il ripristino dichiarato riuscito.
|
||||
*/
|
||||
public function test_un_entry_corrotta_ferma_l_estrazione(): void
|
||||
{
|
||||
$path = $this->lavoro . '/corrotto.zip';
|
||||
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
||||
$zip->addFromString('manifest.json', $this->manifest());
|
||||
$zip->addFromString('data.xml', '<dataset/>');
|
||||
$zip->addFromString('piantine.json', json_encode(self::PIANTINE_VUOTE));
|
||||
$zip->addFromString('storage/territori-pdf/1.pdf', 'contenuto-originale-del-pdf');
|
||||
// Senza compressione il contenuto sta in chiaro nel file: lo si puo'
|
||||
// alterare a mano lasciando intatti intestazioni e CRC dichiarato.
|
||||
$zip->setCompressionName('storage/territori-pdf/1.pdf', ZipArchive::CM_STORE);
|
||||
$zip->close();
|
||||
|
||||
file_put_contents($path, str_replace(
|
||||
'contenuto-originale-del-pdf',
|
||||
'contenuto-ALTERATO--del-pdf',
|
||||
file_get_contents($path)
|
||||
));
|
||||
|
||||
try {
|
||||
$this->archivio->extract($path);
|
||||
$this->fail('extract() avrebbe dovuto accorgersi dell\'entry alterata.');
|
||||
} catch (RuntimeException $e) {
|
||||
$this->assertStringContainsString('territori-pdf/1.pdf', $e->getMessage());
|
||||
}
|
||||
|
||||
$radice = storage_path('app/backup-restore');
|
||||
$this->assertTrue(! is_dir($radice) || glob($radice . '/*') === []);
|
||||
}
|
||||
|
||||
// ─── piantine nel pacchetto ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* L'area KML sta in storage/app/piantine, accanto al disco `public` e non
|
||||
* dentro: prima nessun backup la conteneva, e dopo un ripristino su volumi
|
||||
* vuoti andava ricaricata a mano.
|
||||
*/
|
||||
public function test_il_pacchetto_porta_l_area_kml_e_i_dati_delle_piantine(): void
|
||||
{
|
||||
Storage::disk('local')->put('piantine/area.kml', '<kml/>');
|
||||
Storage::disk('local')->put('piantine/config.json', '{"kml_nome":"citta.kml"}');
|
||||
Storage::disk('public')->put('territori-mappe/piantina-1.jpg', 'immagine');
|
||||
|
||||
$dati = ['territorio_mappe' => [['id' => 1, 'geojson' => '{}']], 'territorio_pdf_versioni' => []];
|
||||
|
||||
$destinazione = $this->lavoro . '/backup.zip';
|
||||
$this->archivio->create('<dataset/>', $dati, $destinazione);
|
||||
|
||||
$esito = $this->archivio->extract($destinazione);
|
||||
|
||||
$this->assertSame($dati, $esito['piantine']);
|
||||
$this->assertSame(1, $esito['files']);
|
||||
$this->assertSame(2, $esito['file_piantine']);
|
||||
$this->assertStringEqualsFile($esito['staging'] . '/piantine/area.kml', '<kml/>');
|
||||
$this->assertStringEqualsFile($esito['pubblico'] . '/territori-mappe/piantina-1.jpg', 'immagine');
|
||||
}
|
||||
|
||||
public function test_dal_formato_2_piantine_json_e_obbligatorio(): void
|
||||
{
|
||||
$zip = $this->zipCon([
|
||||
'manifest.json' => $this->manifest(),
|
||||
'data.xml' => '<dataset/>',
|
||||
'piantine.json' => null,
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/piantine\.json/');
|
||||
|
||||
$this->archivio->extract($zip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibilita' all'indietro: un archivio di formato 1 non ha
|
||||
* piantine.json, e si estrae lo stesso. Le entry piantine/ non si
|
||||
* prendono: quel formato non le prevedeva.
|
||||
*/
|
||||
public function test_un_archivio_di_formato_1_si_estrae_senza_piantine(): void
|
||||
{
|
||||
$zip = $this->zipCon([
|
||||
'manifest.json' => $this->manifest(1),
|
||||
'data.xml' => '<dataset/>',
|
||||
'piantine.json' => null,
|
||||
'storage/territori-pdf/1.pdf' => 'pdf',
|
||||
'piantine/area.kml' => '<kml/>',
|
||||
]);
|
||||
|
||||
$esito = $this->archivio->extract($zip);
|
||||
|
||||
$this->assertNull($esito['piantine']);
|
||||
$this->assertSame(1, $esito['formato']);
|
||||
$this->assertSame(0, $esito['file_piantine']);
|
||||
$this->assertDirectoryDoesNotExist($esito['staging'] . '/piantine');
|
||||
}
|
||||
|
||||
// ─── scambiaFile() / svuotaDiscoPubblico() ──────────────────
|
||||
|
||||
/**
|
||||
* La sostituzione del disco e' totale, non un merge: i percorsi vivono
|
||||
@@ -346,20 +456,89 @@ class BackupArchiveTest extends TestCase
|
||||
{
|
||||
Storage::disk('public')->put('territori-pdf/vecchio.pdf', 'da buttare');
|
||||
Storage::disk('public')->put('logo.svg', 'logo vecchio');
|
||||
Storage::disk('local')->put('piantine/area.kml', 'area vecchia');
|
||||
|
||||
$staging = $this->lavoro . '/staging';
|
||||
mkdir($staging . '/territori-pdf', 0775, true);
|
||||
file_put_contents($staging . '/territori-pdf/nuovo.pdf', 'da tenere');
|
||||
file_put_contents($staging . '/logo.svg', 'logo nuovo');
|
||||
$estratto = $this->staging([
|
||||
'pubblico/territori-pdf/nuovo.pdf' => 'da tenere',
|
||||
'pubblico/logo.svg' => 'logo nuovo',
|
||||
]);
|
||||
|
||||
$ripristinati = $this->archivio->restoreFiles($staging);
|
||||
$scambio = $this->archivio->scambiaFile($estratto);
|
||||
$this->archivio->confermaScambio($scambio);
|
||||
|
||||
$this->assertSame(2, $ripristinati);
|
||||
$this->assertSame(2, $scambio['file']);
|
||||
$this->assertSame(
|
||||
['logo.svg', 'territori-pdf/nuovo.pdf'],
|
||||
collect(Storage::disk('public')->allFiles())->sort()->values()->all()
|
||||
);
|
||||
$this->assertSame('logo nuovo', Storage::disk('public')->get('logo.svg'));
|
||||
|
||||
// Formato 2 senza area nell'archivio: l'installazione salvata non ne
|
||||
// aveva, e il ripristino la rispecchia.
|
||||
$this->assertSame([], Storage::disk('local')->allFiles('piantine'));
|
||||
$this->assertDirectoryDoesNotExist($scambio['accantonati'], 'A ripristino confermato i file vecchi non servono piu\'.');
|
||||
}
|
||||
|
||||
public function test_un_archivio_di_formato_1_non_tocca_l_area_kml(): void
|
||||
{
|
||||
Storage::disk('local')->put('piantine/area.kml', 'area in uso');
|
||||
|
||||
$this->archivio->confermaScambio($this->archivio->scambiaFile($this->staging([], 1)));
|
||||
|
||||
$this->assertSame('area in uso', Storage::disk('local')->get('piantine/area.kml'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Il cuore della correzione: prima il disco si svuotava e poi si
|
||||
* ricopiava file per file, quindi un errore a meta' lasciava una parte
|
||||
* dei file nuovi e nessuno dei vecchi. Qui lo scambio fallisce dopo aver
|
||||
* gia' sostituito il disco `public` (la cartella dell'area e' occupata da
|
||||
* un file, mkdir non puo' crearla) e deve rimettere tutto com'era.
|
||||
*/
|
||||
public function test_uno_scambio_fallito_a_meta_rimette_i_file_vecchi(): void
|
||||
{
|
||||
Storage::disk('public')->put('territori-pdf/vecchio.pdf', 'pdf in uso');
|
||||
Storage::disk('local')->put('piantine', 'un file al posto della cartella');
|
||||
|
||||
$estratto = $this->staging([
|
||||
'pubblico/territori-pdf/nuovo.pdf' => 'pdf del backup',
|
||||
'piantine/area.kml' => 'area del backup',
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->archivio->scambiaFile($estratto);
|
||||
$this->fail('scambiaFile() avrebbe dovuto sollevare.');
|
||||
} catch (RuntimeException) {
|
||||
// atteso
|
||||
}
|
||||
|
||||
$this->assertSame(['territori-pdf/vecchio.pdf'], Storage::disk('public')->allFiles());
|
||||
$this->assertSame('pdf in uso', Storage::disk('public')->get('territori-pdf/vecchio.pdf'));
|
||||
$this->assertFileExists($estratto['staging'] . '/pubblico/territori-pdf/nuovo.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lo scambio e' riuscito ma la transazione non va in porto (commit
|
||||
* fallito): il job chiama annullaScambio() e i file devono seguire il
|
||||
* database, cioe' tornare quelli di prima.
|
||||
*/
|
||||
public function test_l_annullamento_dopo_uno_scambio_riuscito_rimette_i_file_vecchi(): void
|
||||
{
|
||||
Storage::disk('public')->put('territori-pdf/vecchio.pdf', 'pdf in uso');
|
||||
Storage::disk('local')->put('piantine/area.kml', 'area in uso');
|
||||
|
||||
$scambio = $this->archivio->scambiaFile($this->staging([
|
||||
'pubblico/territori-pdf/nuovo.pdf' => 'pdf del backup',
|
||||
'piantine/area.kml' => 'area del backup',
|
||||
]));
|
||||
|
||||
$this->assertSame(['territori-pdf/nuovo.pdf'], Storage::disk('public')->allFiles());
|
||||
|
||||
$this->archivio->annullaScambio($scambio);
|
||||
|
||||
$this->assertSame(['territori-pdf/vecchio.pdf'], Storage::disk('public')->allFiles());
|
||||
$this->assertSame('area in uso', Storage::disk('local')->get('piantine/area.kml'));
|
||||
$this->assertDirectoryDoesNotExist($scambio['accantonati']);
|
||||
}
|
||||
|
||||
public function test_lo_svuotamento_conta_i_file_rimossi_e_lascia_il_disco_vuoto(): void
|
||||
@@ -382,10 +561,18 @@ class BackupArchiveTest extends TestCase
|
||||
// ─── helper ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Un archivio con data.xml riceve anche un piantine.json vuoto, se non
|
||||
* ne ha uno: dal formato 2 e' obbligatorio, e senza ogni test sui rifiuti
|
||||
* si fermerebbe su quello invece che sul difetto che vuole provare.
|
||||
*
|
||||
* @param array<string, string> $entries
|
||||
*/
|
||||
private function zipCon(array $entries): string
|
||||
{
|
||||
if (isset($entries['data.xml']) && ! array_key_exists('piantine.json', $entries)) {
|
||||
$entries['piantine.json'] = json_encode(self::PIANTINE_VUOTE);
|
||||
}
|
||||
|
||||
$path = $this->lavoro . '/costruito-' . count($entries) . '-' . md5(serialize($entries)) . '.zip';
|
||||
|
||||
$zip = new ZipArchive();
|
||||
@@ -395,7 +582,10 @@ class BackupArchiveTest extends TestCase
|
||||
);
|
||||
|
||||
foreach ($entries as $nome => $contenuto) {
|
||||
$zip->addFromString($nome, $contenuto);
|
||||
// null: l'entry non c'e' (serve a togliere piantine.json).
|
||||
if ($contenuto !== null) {
|
||||
$zip->addFromString($nome, $contenuto);
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
@@ -403,6 +593,36 @@ class BackupArchiveTest extends TestCase
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uno staging come lo lascia extract(), costruito a mano.
|
||||
*
|
||||
* @param array<string, string> $file percorso nello staging => contenuto
|
||||
*/
|
||||
private function staging(array $file, int $formato = BackupArchive::FORMAT_VERSION): array
|
||||
{
|
||||
$staging = storage_path('app/backup-restore/prova-' . md5(serialize($file) . $formato));
|
||||
|
||||
$pubblici = 0;
|
||||
$piantine = 0;
|
||||
foreach ($file as $percorso => $contenuto) {
|
||||
@mkdir(dirname($staging . '/' . $percorso), 0775, true);
|
||||
file_put_contents($staging . '/' . $percorso, $contenuto);
|
||||
if (str_starts_with($percorso, 'piantine/')) {
|
||||
$piantine++;
|
||||
} else {
|
||||
$pubblici++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'formato' => $formato,
|
||||
'staging' => $staging,
|
||||
'pubblico' => $staging . '/pubblico',
|
||||
'files' => $pubblici,
|
||||
'file_piantine' => $piantine,
|
||||
];
|
||||
}
|
||||
|
||||
private function manifest(?int $formatVersion = null): string
|
||||
{
|
||||
return json_encode([
|
||||
|
||||
Reference in New Issue
Block a user