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')); 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('', $destinazione); $this->assertFileExists($destinazione); $esito = $this->archivio->extract($destinazione); $this->assertSame('', $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'); } 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('', $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('', $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( '', $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('', $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['staging'] . '/territori-pdf/1.pdf'); $this->assertFileDoesNotExist($esito['staging'] . '/README.txt'); $this->assertDirectoryDoesNotExist($esito['staging'] . '/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'], ]; } // ─── restoreFiles() / 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'); $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'); $ripristinati = $this->archivio->restoreFiles($staging); $this->assertSame(2, $ripristinati); $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')); } 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 ───────────────────────────────────────────────── /** * @param array $entries */ private function zipCon(array $entries): string { $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) { $zip->addFromString($nome, $contenuto); } $zip->close(); return $path; } 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); } }