Files
termanager2/tests/Unit/BackupArchiveTest.php
Francesco Picone 426a95211d 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>
2026-10-12 15:01:41 +02:00

644 lines
24 KiB
PHP

<?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
{
/** 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. */
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/<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'));
$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('<dataset/>', self::PIANTINE_VUOTE, $destinazione);
$this->assertFileExists($destinazione);
$esito = $this->archivio->extract($destinazione);
$this->assertSame('<dataset/>', $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('<dataset/>', 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('<dataset/>', self::PIANTINE_VUOTE, $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/>',
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('<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());
$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['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', '<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
* 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<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();
$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<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([
'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);
}
}