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:
2026-10-12 15:01:41 +02:00
parent 056d95cfcb
commit 426a95211d
78 changed files with 3971 additions and 429 deletions

View File

@@ -0,0 +1,268 @@
<?php
namespace Tests\Feature;
use App\Jobs\CreateBackupArchive;
use App\Jobs\ImportTerritoryPdfFolder;
use App\Jobs\RestoreBackupArchive;
use App\Livewire\Settings\BackupManager;
use App\Livewire\Settings\XmlExchange;
use App\Livewire\Settings\ZonaPericolosa;
use App\Models\Territorio;
use App\Models\User;
use App\Services\BackupOperationState;
use App\Services\TerritorioPdfImportDispatcher;
use App\Services\TerritorioThumbnailService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
use ZipArchive;
/**
* Il posto unico vale anche per gli import.
*
* Backup, ripristino e azzeramento si escludevano a vicenda, ma l'import XML e
* l'import PDF passavano accanto al blocco: un import XML durante un ripristino
* riscriveva le stesse tabelle, un import PDF durante un backup cambiava i file
* mentre l'archivio li copiava. Qui si verifica il blocco nelle due direzioni e
* che il posto si liberi anche quando l'import fallisce.
*/
class PostoUnicoImportTest extends TestCase
{
use RefreshDatabase;
private const PDF = "%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n";
private User $admin;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
Storage::fake('local');
$this->mock(TerritorioThumbnailService::class, function ($mock) {
$mock->shouldReceive('generate')->andReturn(null);
$mock->shouldReceive('delete');
});
Role::create(['name' => 'amministratore', 'guard_name' => 'web']);
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->admin = User::create([
'name' => 'Amministratore',
'email' => 'admin@example.test',
'password' => 'password-di-prova',
]);
$this->admin->assignRole('amministratore');
$this->actingAs($this->admin);
}
// ─── Un'operazione di backup blocca gli import ──────────────
public function test_l_import_pdf_da_cartella_non_parte_durante_un_ripristino(): void
{
Queue::fake();
app(BackupOperationState::class)->initialize('op-restore', BackupOperationState::TIPO_RESTORE, 'Estrazione', 10);
Territorio::create(['numero' => '1', 'attivo' => true]);
Livewire::test(XmlExchange::class)
->set('pdfFolder', [UploadedFile::fake()->createWithContent('1.pdf', self::PDF)])
->call('importTerritoryPdfFolder')
->assertDispatched('avviso', fn ($nome, $parametri) => $parametri['tipo'] === 'errore'
&& str_contains($parametri['testo'], 'ripristino'));
Queue::assertNotPushed(ImportTerritoryPdfFolder::class);
$this->assertSame('op-restore', app(BackupOperationState::class)->corrente());
}
public function test_il_controllo_prima_di_caricare_la_cartella_avvisa_se_il_posto_e_occupato(): void
{
app(BackupOperationState::class)->initialize('op-backup', BackupOperationState::TIPO_BACKUP, 'Compressione', 10);
Livewire::test(XmlExchange::class)
->call('cartellaPuoPartire')
->assertReturned(false)
->assertDispatched('avviso', fn ($nome, $parametri) => $parametri['tipo'] === 'errore');
}
public function test_l_import_zip_non_parte_durante_un_azzeramento(): void
{
Queue::fake();
app(BackupOperationState::class)->initialize('op-reset', BackupOperationState::TIPO_RESET, 'Backup preventivo', 3);
$this->postJson(route('imports.territori.pdf-zip'), ['pdfZip' => $this->zip(['1.pdf' => self::PDF])])
->assertStatus(422)
->assertJsonFragment(['message' => app(BackupOperationState::class)->messaggioOccupato('op-reset')]);
Queue::assertNotPushed(ImportTerritoryPdfFolder::class);
$this->assertSame('op-reset', app(BackupOperationState::class)->corrente());
}
public function test_l_import_xml_non_parte_durante_un_backup_e_non_tocca_i_dati(): void
{
app(BackupOperationState::class)->initialize('op-backup', BackupOperationState::TIPO_BACKUP, 'Compressione', 10);
Territorio::create(['numero' => '99', 'attivo' => true]);
$this->post(route('xml.import-xml'), ['xmlImport' => $this->xml('1')])
->assertSessionHasErrors('xmlImport');
$this->assertSame(['99'], Territorio::pluck('numero')->all());
$this->assertSame('op-backup', app(BackupOperationState::class)->corrente());
}
// ─── Un import blocca le operazioni di backup ───────────────
public function test_un_import_pdf_in_coda_blocca_backup_ripristino_e_azzeramento(): void
{
Queue::fake();
Territorio::create(['numero' => '1', 'attivo' => true]);
Livewire::test(XmlExchange::class)
->set('pdfFolder', [UploadedFile::fake()->createWithContent('1.pdf', self::PDF)])
->call('importTerritoryPdfFolder');
Queue::assertPushed(ImportTerritoryPdfFolder::class);
$stato = app(BackupOperationState::class);
$import = $stato->corrente();
$this->assertNotNull($import, 'L\'import PDF deve occupare il posto unico finche\' il job non finisce.');
$this->assertSame(BackupOperationState::TIPO_IMPORT_PDF, $stato->get($import)['tipo']);
Livewire::test(BackupManager::class)
->call('avviaBackup')
->assertSet('erroreOperazione', $stato->messaggioOccupato($import));
Queue::assertNotPushed(CreateBackupArchive::class);
Livewire::test(ZonaPericolosa::class)
->set('confermaReset', ZonaPericolosa::CONFERMA_AZZERAMENTO)
->call('avviaAzzeramento');
Queue::assertNotPushed(CreateBackupArchive::class);
$this->postJson(route('backup.restore'), [
'backupZip' => $this->zip(['manifest.json' => '{}']),
])->assertStatus(409);
Queue::assertNotPushed(RestoreBackupArchive::class);
$this->assertSame($import, $stato->corrente());
}
public function test_un_secondo_import_non_parte_mentre_il_primo_e_in_coda(): void
{
Queue::fake();
Territorio::create(['numero' => '1', 'attivo' => true]);
Livewire::test(XmlExchange::class)
->set('pdfFolder', [UploadedFile::fake()->createWithContent('1.pdf', self::PDF)])
->call('importTerritoryPdfFolder');
$this->post(route('xml.import-xml'), ['xmlImport' => $this->xml('1')])
->assertSessionHasErrors('xmlImport');
Queue::assertPushed(ImportTerritoryPdfFolder::class, 1);
}
// ─── Il posto si libera ─────────────────────────────────────
public function test_l_import_pdf_libera_il_posto_quando_finisce(): void
{
$uno = Territorio::create(['numero' => '1', 'attivo' => true]);
Livewire::test(XmlExchange::class)
->set('pdfFolder', [UploadedFile::fake()->createWithContent('1.pdf', self::PDF)])
->call('importTerritoryPdfFolder')
->assertSet('pdfImportStatus', 'completed');
$this->assertNotNull($uno->fresh()->pdf_path);
$this->assertNull(app(BackupOperationState::class)->corrente());
}
/**
* failed() e' quello che Laravel chiama quando il worker uccide il job per
* timeout: handle() non arriva a liberare niente.
*/
public function test_un_import_pdf_ucciso_dal_worker_libera_il_posto(): void
{
app(TerritorioPdfImportDispatcher::class)->occupaPosto('import-1');
$this->assertSame('import-1', app(BackupOperationState::class)->corrente());
(new ImportTerritoryPdfFolder('import-1', [], $this->admin->id))
->failed(new \RuntimeException('Job has timed out.'));
$this->assertNull(app(BackupOperationState::class)->corrente());
}
public function test_l_import_xml_libera_il_posto_quando_riesce_e_quando_fallisce(): void
{
$this->post(route('xml.import-xml'), ['xmlImport' => $this->xml('1')])
->assertSessionHasNoErrors();
$this->assertNull(app(BackupOperationState::class)->corrente());
$this->post(route('xml.import-xml'), [
'xmlImport' => UploadedFile::fake()->createWithContent('rotto.xml', '<?xml version="1.0"?><sitemap/>'),
])->assertSessionHasErrors('xmlImport');
$this->assertNull(app(BackupOperationState::class)->corrente());
}
public function test_uno_zip_senza_pdf_libera_il_posto(): void
{
// L'estrazione legge lo ZIP da storage_path(), non dal disco finto.
Storage::forgetDisk('local');
$this->postJson(route('imports.territori.pdf-zip'), ['pdfZip' => $this->zip(['Thumbs.db' => 'x', '.DS_Store' => 'x'])])
->assertStatus(422)
->assertJsonFragment(['message' => 'Lo ZIP non contiene file PDF validi.']);
$this->assertNull(app(BackupOperationState::class)->corrente());
}
// ─── Presa del posto ────────────────────────────────────────
/**
* La presa e' un passo solo: chi arriva secondo trova il posto occupato
* anche se il primo non ha ancora finito di avviare il proprio lavoro.
*/
public function test_chi_arriva_secondo_trova_il_posto_occupato(): void
{
$stato = app(BackupOperationState::class);
$this->assertNull($stato->occupa('primo', BackupOperationState::TIPO_IMPORT_PDF, 'In coda'));
$this->assertSame('primo', $stato->occupa('secondo', BackupOperationState::TIPO_RESTORE, 'In coda'));
$this->assertNull($stato->get('secondo'), 'Chi non prende il posto non deve lasciare un\'operazione fantasma.');
$stato->markCompleted('primo');
$this->assertNull($stato->occupa('terzo', BackupOperationState::TIPO_BACKUP, 'In coda'));
}
// ─── Supporto ───────────────────────────────────────────────
private function xml(string $numero): UploadedFile
{
$xml = '<?xml version="1.0" encoding="UTF-8"?><termanager2 version="1.0">'
. '<settings/><users/><zones/><tipologie/>'
. '<proclamatori/>'
. '<territori><territorio legacy_id="1"><numero>' . $numero . '</numero><attivo>1</attivo></territorio></territori>'
. '<anni_teocratici/><campagne/><assegnazioni/>'
. '</termanager2>';
return UploadedFile::fake()->createWithContent('export.xml', $xml);
}
private function zip(array $voci): UploadedFile
{
$percorso = tempnam(sys_get_temp_dir(), 'zip-pdf-');
$zip = new ZipArchive();
$zip->open($percorso, ZipArchive::OVERWRITE);
foreach ($voci as $nome => $contenuto) {
$zip->addFromString($nome, $contenuto);
}
$zip->close();
return new UploadedFile($percorso, 'territori.zip', 'application/zip', null, true);
}
}