++ fix e controlli
This commit is contained in:
423
tests/Unit/BackupArchiveTest.php
Normal file
423
tests/Unit/BackupArchiveTest.php
Normal file
@@ -0,0 +1,423 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\BackupArchive;
|
||||
use Illuminate\Filesystem\FilesystemAdapter;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Mockery;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use ReflectionMethod;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* Il pacchetto di backup e' l'unico file di cui non ci si accorge che era rotto
|
||||
* finche' non serve: se sbaglia, sbaglia in silenzio e lo si scopre il giorno
|
||||
* del ripristino. Da qui la scelta di cominciare i test da qui.
|
||||
*
|
||||
* Non serve database: tutto gira su Storage::fake('public').
|
||||
*/
|
||||
class BackupArchiveTest extends TestCase
|
||||
{
|
||||
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');
|
||||
|
||||
$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/<uuid>: 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('<dataset/>', $destinazione);
|
||||
|
||||
$this->assertFileExists($destinazione);
|
||||
|
||||
$esito = $this->archivio->extract($destinazione);
|
||||
|
||||
$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');
|
||||
}
|
||||
|
||||
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('<dataset/>', $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('<dataset/>', $destinazione);
|
||||
|
||||
$esito = $this->archivio->extract($destinazione);
|
||||
|
||||
$this->assertSame('<dataset/>', $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(
|
||||
'<dataset/>',
|
||||
$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('<dataset/>', $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' => '<dataset/>']);
|
||||
|
||||
$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' => '<dataset/>',
|
||||
]);
|
||||
|
||||
$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' => '<dataset/>',
|
||||
]);
|
||||
|
||||
$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' => '<dataset/>',
|
||||
'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' => '<dataset/>',
|
||||
'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' => '<dataset/>',
|
||||
'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<string, string> $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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user