[], 'territorio_pdf_versioni' => []]; private BackupArchive $archivio; /** Cartella dei file .zip prodotti dai test, ripulita a ogni giro. */ private string $lavoro; protected function setUp(): void { 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'); $this->archivio->deleteDirectory($this->lavoro); $this->archivio->deleteDirectory(storage_path('app/backup-restore')); mkdir($this->lavoro, 0775, true); } protected function tearDown(): void { // extract() estrae in storage/app/backup-restore/: e' un percorso // 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(); } // ─── create() ─────────────────────────────────────────────── public function test_il_pacchetto_creato_si_riestrae_identico(): void { Storage::disk('public')->put('territori-pdf/1.pdf', 'contenuto-uno'); Storage::disk('public')->put('territori-thumbnails/1.png', 'miniatura-uno'); Storage::disk('public')->put('logo.svg', 'logo'); $destinazione = $this->lavoro . '/backup.zip'; $this->archivio->create('', self::PIANTINE_VUOTE, $destinazione); $this->assertFileExists($destinazione); $esito = $this->archivio->extract($destinazione); $this->assertSame('', $esito['xml']); $this->assertSame(3, $esito['files']); $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 { Storage::disk('public')->put('territori-pdf/1.pdf', 'x'); Storage::disk('public')->put('territori-pdf/2.pdf', 'y'); $destinazione = $this->lavoro . '/backup.zip'; $this->archivio->create('', self::PIANTINE_VUOTE, $destinazione); $zip = new ZipArchive(); $this->assertTrue($zip->open($destinazione) === true); $manifest = json_decode($zip->getFromName('manifest.json'), true); $zip->close(); $this->assertSame(BackupArchive::FORMAT_VERSION, $manifest['format_version']); $this->assertSame('TerManager2', $manifest['application']); $this->assertSame(2, $manifest['files_count']); } public function test_il_pacchetto_di_un_installazione_senza_file_contiene_comunque_i_dati(): void { $destinazione = $this->lavoro . '/backup.zip'; $this->archivio->create('', self::PIANTINE_VUOTE, $destinazione); $esito = $this->archivio->extract($destinazione); $this->assertSame('', $esito['xml']); $this->assertSame(0, $esito['files']); } /** * La barra conta unita' di lavoro finite, non tempo: l'ultimo avanzamento * deve coincidere col totale, altrimenti resterebbe sotto il 100% a * operazione conclusa. */ public function test_l_avanzamento_arriva_al_totale(): void { for ($i = 1; $i <= 25; $i++) { Storage::disk('public')->put("territori-pdf/{$i}.pdf", "file-{$i}"); } $avanzamenti = []; $this->archivio->create( '', self::PIANTINE_VUOTE, $this->lavoro . '/backup.zip', function (int $fatti, int $totale) use (&$avanzamenti) { $avanzamenti[] = [$fatti, $totale]; } ); $this->assertNotEmpty($avanzamenti); $this->assertSame([25, 25], end($avanzamenti)); $precedente = 0; foreach ($avanzamenti as [$fatti, $totale]) { $this->assertSame(25, $totale); $this->assertGreaterThanOrEqual($precedente, $fatti); $precedente = $fatti; } } /** * Il bug che questo test presidia: addFile() torna false se il file e' * sparito o e' illeggibile, e l'archivio finirebbe con un buco di cui * nessuno si accorge fino al ripristino. Meglio nessun backup che un backup * che sembra buono, quindi il file va anche rimosso. * * Il messaggio si verifica per esteso perche' e' la parte che si perde per * prima: addFile() emette un warning di PHP prima di restituire false, e * senza la soppressione in BackupArchive l'amministratore leggerebbe * "ZipArchive::addFile(): No such file or directory" al posto della frase * che dice quale file e cosa controllare. */ public function test_un_file_illeggibile_fa_fallire_il_backup_senza_lasciare_l_archivio(): void { $disco = Mockery::mock(FilesystemAdapter::class); $disco->shouldReceive('allFiles')->andReturn(['territori-pdf/sparito.pdf']); $disco->shouldReceive('path')->andReturn($this->lavoro . '/questo-file-non-esiste.pdf'); Storage::set('public', $disco); $destinazione = $this->lavoro . '/backup.zip'; try { $this->archivio->create('', 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()); $this->assertStringContainsString('non e\' leggibile', $e->getMessage()); } $this->assertFileDoesNotExist( $destinazione, 'Un backup incompleto non deve restare sul disco: sembrerebbe utilizzabile.' ); } // ─── extract(): rifiuti ───────────────────────────────────── public function test_l_archivio_senza_manifest_non_e_riconosciuto(): void { $zip = $this->zipCon(['data.xml' => '']); $this->expectException(RuntimeException::class); $this->expectExceptionMessageMatches('/manifest\.json/'); $this->archivio->extract($zip); } public function test_l_archivio_senza_data_xml_e_incompleto(): void { $zip = $this->zipCon(['manifest.json' => $this->manifest()]); $this->expectException(RuntimeException::class); $this->expectExceptionMessageMatches('/data\.xml/'); $this->archivio->extract($zip); } public function test_un_data_xml_vuoto_vale_come_mancante(): void { $zip = $this->zipCon([ 'manifest.json' => $this->manifest(), 'data.xml' => " \n ", ]); $this->expectException(RuntimeException::class); $this->expectExceptionMessageMatches('/data\.xml/'); $this->archivio->extract($zip); } /** * Un pacchetto prodotto da una versione futura si rifiuta invece di * ripristinarlo a meta': i campi che non si sanno leggere andrebbero persi * in silenzio. */ public function test_un_formato_piu_recente_viene_rifiutato(): void { $zip = $this->zipCon([ 'manifest.json' => $this->manifest(BackupArchive::FORMAT_VERSION + 1), 'data.xml' => '', ]); $this->expectException(RuntimeException::class); $this->expectExceptionMessageMatches('/versione piu\'? recente/'); $this->archivio->extract($zip); } public function test_un_manifest_illeggibile_viene_rifiutato(): void { $zip = $this->zipCon([ 'manifest.json' => 'non-e-json', 'data.xml' => '', ]); $this->expectException(RuntimeException::class); $this->expectExceptionMessageMatches('/manifest/'); $this->archivio->extract($zip); } public function test_un_entry_con_percorso_assoluto_viene_rifiutata(): void { $zip = $this->zipCon([ 'manifest.json' => $this->manifest(), 'data.xml' => '', 'storage//tmp/malevolo.txt' => 'fuori', ]); $this->expectException(RuntimeException::class); $this->expectExceptionMessageMatches('/non ammesso/'); $this->archivio->extract($zip); } /** * Lo staging va rimosso anche quando l'estrazione fallisce a meta': i file * gia' estratti resterebbero altrimenti sul volume, senza che nulla li * mostri e senza che nulla li cancelli. */ public function test_lo_staging_non_sopravvive_a_un_estrazione_fallita(): void { $zip = $this->zipCon([ 'manifest.json' => $this->manifest(), 'data.xml' => '', 'storage/territori-pdf/buono.pdf' => 'ok', 'storage//tmp/malevolo.txt' => 'fuori', ]); try { $this->archivio->extract($zip); $this->fail('extract() avrebbe dovuto sollevare sul percorso non ammesso.'); } catch (RuntimeException) { // atteso } $radice = storage_path('app/backup-restore'); $this->assertTrue( ! is_dir($radice) || glob($radice . '/*') === [], 'La cartella di staging e\' rimasta popolata dopo un ripristino fallito.' ); } public function test_le_entry_fuori_da_storage_vengono_ignorate(): void { $zip = $this->zipCon([ 'manifest.json' => $this->manifest(), 'data.xml' => '', 'storage/territori-pdf/1.pdf' => 'buono', 'appunti/README.txt' => 'roba estranea al pacchetto', ]); $esito = $this->archivio->extract($zip); $this->assertSame(1, $esito['files']); $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 ─────────── #[DataProvider('percorsiNonAmmessi')] public function test_i_percorsi_che_escono_dalla_destinazione_sono_rifiutati(string $percorso): void { $this->assertNull($this->safeRelativePath($percorso)); } public static function percorsiNonAmmessi(): array { return [ 'risalita semplice' => ['../fuori.txt'], 'risalita in mezzo' => ['territori-pdf/../../fuori.txt'], 'risalita con backslash' => ['..\\fuori.txt'], 'percorso assoluto' => ['/etc/passwd'], 'unita windows' => ['C:/Windows/system.ini'], 'vuoto' => [''], 'solo spazi' => [' '], 'solo separatori' => ['///'], 'solo punto' => ['.'], ]; } #[DataProvider('percorsiAmmessi')] public function test_i_percorsi_interni_vengono_normalizzati(string $percorso, string $atteso): void { $this->assertSame($atteso, $this->safeRelativePath($percorso)); } public static function percorsiAmmessi(): array { return [ 'semplice' => ['logo.svg', 'logo.svg'], 'annidato' => ['territori-pdf/1.pdf', 'territori-pdf/1.pdf'], 'backslash convertiti' => ['territori-pdf\\1.pdf', 'territori-pdf/1.pdf'], 'separatori doppi' => ['territori-pdf//1.pdf', 'territori-pdf/1.pdf'], 'punto corrente' => ['./territori-pdf/./1.pdf', 'territori-pdf/1.pdf'], ]; } /** * 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', ''); $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', ''); 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('', $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', ''); $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' => '', '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' => '', 'piantine.json' => null, 'storage/territori-pdf/1.pdf' => 'pdf', 'piantine/area.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 * nelle righe che il ripristino ha appena riscritto, quindi un file * precedente non piu' citato resterebbe orfano per sempre. */ public function test_il_ripristino_sostituisce_il_disco_invece_di_fondersi(): void { 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'); $estratto = $this->staging([ 'pubblico/territori-pdf/nuovo.pdf' => 'da tenere', 'pubblico/logo.svg' => 'logo nuovo', ]); $scambio = $this->archivio->scambiaFile($estratto); $this->archivio->confermaScambio($scambio); $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 { Storage::disk('public')->put('territori-pdf/1.pdf', 'a'); Storage::disk('public')->put('territori-pdf/2.pdf', 'b'); Storage::disk('public')->put('logo.svg', 'c'); $rimossi = $this->archivio->svuotaDiscoPubblico(); $this->assertSame(3, $rimossi); $this->assertSame([], Storage::disk('public')->allFiles()); } public function test_lo_svuotamento_di_un_disco_gia_vuoto_non_e_un_errore(): void { $this->assertSame(0, $this->archivio->svuotaDiscoPubblico()); } // ─── 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 $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(); $this->assertTrue( $zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true, 'Impossibile preparare lo zip di prova.' ); foreach ($entries as $nome => $contenuto) { // null: l'entry non c'e' (serve a togliere piantine.json). if ($contenuto !== null) { $zip->addFromString($nome, $contenuto); } } $zip->close(); return $path; } /** * Uno staging come lo lascia extract(), costruito a mano. * * @param array $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([ 'format_version' => $formatVersion ?? BackupArchive::FORMAT_VERSION, 'application' => 'TerManager2', 'generated_at' => '2026-07-30T12:00:00+02:00', 'app_url' => 'http://localhost', 'files_count' => 0, ]); } private function safeRelativePath(string $percorso): ?string { $metodo = new ReflectionMethod(BackupArchive::class, 'safeRelativePath'); return $metodo->invoke($this->archivio, $percorso); } }