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,73 @@
<?php
namespace Tests\Feature;
use App\Livewire\Settings\ZoneIndex;
use App\Models\Territorio;
use App\Models\User;
use App\Models\Zona;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
/**
* Gli esiti delle azioni che non cambiano pagina arrivano come evento
* "avviso". Con session()->flash() il messaggio non si vedeva subito e
* compariva alla pagina successiva, fuori contesto.
*/
class AvvisiTest extends TestCase
{
use RefreshDatabase;
private function admin(): User
{
$this->seed(RolesAndPermissionsSeeder::class);
$utente = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']);
$utente->assignRole('amministratore');
return $utente;
}
public function test_un_eliminazione_rifiutata_avvisa_subito_e_non_lascia_flash(): void
{
$zona = Zona::create(['nome' => 'Nord', 'attivo' => true]);
Territorio::create(['numero' => '1', 'attivo' => true, 'zona_id' => $zona->id]);
Livewire::actingAs($this->admin())
->test(ZoneIndex::class)
->call('deleteZona', $zona->id)
->assertDispatched('avviso', tipo: 'errore', testo: "Impossibile eliminare: la zona 'Nord' ha territori associati.");
$this->assertFalse(session()->has('error'));
$this->assertNotNull($zona->fresh());
}
public function test_un_azione_riuscita_avvisa_con_un_conferma(): void
{
$zona = Zona::create(['nome' => 'Sud', 'attivo' => true]);
Livewire::actingAs($this->admin())
->test(ZoneIndex::class)
->call('deleteZona', $zona->id)
->assertDispatched('avviso', tipo: 'ok');
$this->assertFalse(session()->has('success'));
}
public function test_nessun_componente_lascia_flash_senza_cambiare_pagina(): void
{
// Un flash senza redirect subito dopo non si vede fino alla pagina
// successiva: in quei casi si usa il trait Avvisa.
foreach (glob(app_path('Livewire') . '/{,*/}*.php', GLOB_BRACE) as $file) {
$righe = file($file);
foreach ($righe as $i => $riga) {
if (! preg_match('/^\s*session\(\)->flash\(/', $riga)) {
continue;
}
$seguito = implode('', array_slice($righe, $i + 1, 5));
$this->assertStringContainsString('redirect', $seguito, basename($file) . ':' . ($i + 1) . ' usa un flash senza redirect');
}
}
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Tests\Feature;
use App\Jobs\ResetApplication;
use App\Models\Territorio;
use App\Models\TerritorioMappa;
use App\Models\TerritorioPdfVersione;
use App\Models\User;
use App\Services\BackupArchive;
use App\Services\BackupOperationState;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
/**
* L'azzeramento porta via anche le piantine: righe, storico dei PDF, area
* della città e anteprime. Prima restavano piantine orfane e l'editor
* ripartiva con la città di prima.
*/
class AzzeramentoPiantineTest extends TestCase
{
use RefreshDatabase;
public function test_l_azzeramento_non_lascia_piantine_ne_area(): void
{
Storage::fake('public');
Storage::fake('local');
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']);
$admin->assignRole('amministratore');
$territorio = Territorio::create(['numero' => '7', 'attivo' => true]);
TerritorioMappa::create(['territorio_id' => $territorio->id, 'geojson' => '{"type":"Polygon","coordinates":[]}']);
TerritorioMappa::create(['nome' => 'Scollegata', 'geojson' => '{"type":"Polygon","coordinates":[]}']);
TerritorioPdfVersione::create([
'territorio_id' => $territorio->id,
'pdf_path' => 'territori-pdf-versioni/vecchia.pdf',
'origine' => 'piantine',
'sostituito_il' => now(),
]);
Storage::disk('local')->put('piantine/area.geojson', '{}');
Storage::disk('local')->put('piantine-anteprime/abc.pdf', '%PDF');
$stato = app(BackupOperationState::class);
$stato->initialize('azzeramento-di-prova', 'azzeramento', 'In attesa');
(new ResetApplication('azzeramento-di-prova', $admin->id))->handle($stato, app(BackupArchive::class));
$this->assertSame('completed', $stato->get('azzeramento-di-prova')['status'] ?? null, json_encode($stato->get('azzeramento-di-prova')));
$this->assertSame(0, TerritorioMappa::count());
$this->assertSame(0, TerritorioPdfVersione::count());
Storage::disk('local')->assertMissing('piantine/area.geojson');
Storage::disk('local')->assertMissing('piantine-anteprime/abc.pdf');
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace Tests\Feature;
use App\Models\Setting;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
/**
* Cambio password: la password attuale si chiede a chi cambia di sua
* iniziativa, e cambiare la password chiude le sessioni aperte altrove.
*/
class CambioPasswordTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$this->seed(RolesAndPermissionsSeeder::class);
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
}
private function utente(bool $obbligato = false): User
{
$utente = User::create([
'name' => 'Mario Rossi',
'email' => 'mario@example.test',
'password' => 'vecchia-password',
]);
$utente->forceFill(['password_change_required' => $obbligato])->save();
$utente->assignRole('operatore');
return $utente;
}
public function test_chi_cambia_di_sua_iniziativa_deve_dare_la_password_attuale(): void
{
$utente = $this->utente();
$this->actingAs($utente)
->post(route('password.change.store'), [
'password' => 'nuova-password-1',
'password_confirmation' => 'nuova-password-1',
])
->assertSessionHasErrors('current_password');
$this->actingAs($utente)
->post(route('password.change.store'), [
'current_password' => 'sbagliata',
'password' => 'nuova-password-1',
'password_confirmation' => 'nuova-password-1',
])
->assertSessionHasErrors('current_password');
$this->assertTrue(Hash::check('vecchia-password', $utente->fresh()->password));
$this->actingAs($utente)
->post(route('password.change.store'), [
'current_password' => 'vecchia-password',
'password' => 'nuova-password-1',
'password_confirmation' => 'nuova-password-1',
])
->assertRedirect(route('dashboard'));
$this->assertTrue(Hash::check('nuova-password-1', $utente->fresh()->password));
}
public function test_dopo_un_ripristino_la_password_attuale_non_si_chiede(): void
{
$utente = $this->utente(obbligato: true);
$this->actingAs($utente)
->get(route('password.change'))
->assertOk()
->assertDontSee('name="current_password"', false);
$this->actingAs($utente)
->post(route('password.change.store'), [
'password' => 'nuova-password-1',
'password_confirmation' => 'nuova-password-1',
])
->assertRedirect(route('dashboard'));
$this->assertFalse((bool) $utente->fresh()->password_change_required);
}
public function test_una_sessione_aperta_altrove_si_chiude_quando_cambia_la_password(): void
{
$utente = $this->utente();
$hashVecchio = $utente->password;
// La sessione "altrove" ha registrato l'impronta della password di allora.
$utente->update(['password' => 'cambiata-da-un-altra-parte']);
$this->actingAs($utente)
->withSession(['password_hash_web' => $hashVecchio])
->get(route('dashboard'))
->assertRedirect(route('login'));
$this->assertGuest();
}
public function test_la_sessione_che_cambia_la_password_resta_aperta(): void
{
$utente = $this->utente();
$this->actingAs($utente)
->withSession(['password_hash_web' => $utente->password])
->post(route('password.change.store'), [
'current_password' => 'vecchia-password',
'password' => 'nuova-password-1',
'password_confirmation' => 'nuova-password-1',
])
->assertRedirect(route('dashboard'));
$this->get(route('dashboard'))->assertOk();
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace Tests\Feature;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\Campagna;
use App\Models\Proclamatore;
use App\Models\Territorio;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;
/**
* Campagne: l'ultimo giorno è ancora campagna, e la percentuale conta i
* territori percorsi, non le assegnazioni.
*/
class CampagnaCalcoliTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_nell_ultimo_giorno_la_campagna_e_ancora_attiva(): void
{
$campagna = Campagna::create([
'descrizione' => 'Commemorazione',
'start_date' => '2026-03-01',
'end_date' => '2026-03-31',
]);
Carbon::setTestNow('2026-03-31 18:30:00');
$this->assertTrue($campagna->fresh()->is_attiva);
$this->assertSame($campagna->id, Campagna::attiva()?->id);
$this->assertSame(0, Campagna::completate()->count());
Carbon::setTestNow('2026-04-01 08:00:00');
$this->assertNull(Campagna::attiva());
$this->assertSame(1, Campagna::completate()->count());
}
public function test_la_percentuale_conta_i_territori_attivi_una_volta_sola(): void
{
$campagna = Campagna::create([
'descrizione' => 'Commemorazione',
'start_date' => '2026-03-01',
'end_date' => '2026-03-31',
]);
$proclamatore = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]);
$utente = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']);
$anno = AnnoTeocratico::create(['label' => '2025-2026', 'start_date' => '2025-09-01', 'end_date' => '2026-08-31']);
$t1 = Territorio::create(['numero' => '1', 'attivo' => true]);
$t2 = Territorio::create(['numero' => '2', 'attivo' => true]);
Territorio::create(['numero' => '3', 'attivo' => true]);
Territorio::create(['numero' => '4', 'attivo' => true]);
$dismesso = Territorio::create(['numero' => '5', 'attivo' => true]);
$conta = fn (Territorio $t, string $uscita, string $rientro) => Assegnazione::create([
'anno_teocratico_id' => $anno->id,
'created_by' => $utente->id,
'territorio_id' => $t->id,
'proclamatore_id' => $proclamatore->id,
'assigned_at' => $uscita,
'returned_at' => $rientro,
'campaign_id' => $campagna->id,
'counted_in_campaign' => true,
]);
// Il territorio 1 percorso due volte, il 2 una, il 5 poi disattivato.
$conta($t1, '2026-03-02', '2026-03-10');
$conta($t1, '2026-03-12', '2026-03-20');
$conta($t2, '2026-03-03', '2026-03-15');
$conta($dismesso, '2026-03-04', '2026-03-16');
$dismesso->update(['attivo' => false]);
// 2 territori attivi percorsi su 4 attivi: prima diceva 4 su 4, 100%.
$this->assertSame(50.0, $campagna->fresh()->percentuale_percorrenza);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Tests\Feature;
use App\Models\Setting;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Queue\Events\JobProcessing;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Coda e worker: un job lungo non deve ripartire mentre gira ancora, e un
* worker vivo da un'ora non deve lavorare con impostazioni vecchie.
*/
class CodaTest extends TestCase
{
use RefreshDatabase;
public function test_retry_after_supera_il_job_piu_lungo(): void
{
$piuLungo = 0;
foreach (glob(app_path('Jobs') . '/*.php') as $file) {
$classe = 'App\\Jobs\\' . basename($file, '.php');
$proprieta = (new \ReflectionClass($classe))->getDefaultProperties();
$piuLungo = max($piuLungo, (int) ($proprieta['timeout'] ?? 0));
}
$this->assertGreaterThan(0, $piuLungo);
$this->assertGreaterThan($piuLungo, config('queue.connections.redis.retry_after'));
}
public function test_il_worker_rilegge_le_impostazioni_a_ogni_job(): void
{
Setting::instance()->update(['congregazione_nome' => 'Prima']);
$this->assertSame('Prima', Setting::instance()->congregazione_nome);
// Cambiata da un altro processo (il browser): la cache statica di
// questo non lo sa.
DB::table('settings')->update(['congregazione_nome' => 'Dopo']);
$this->assertSame('Prima', Setting::instance()->congregazione_nome);
event(new JobProcessing('redis', $this->createMock(\Illuminate\Contracts\Queue\Job::class)));
$this->assertSame('Dopo', Setting::instance()->congregazione_nome);
}
}

View File

@@ -0,0 +1,333 @@
<?php
namespace Tests\Feature;
use App\Jobs\ImportTerritoryPdfFolder;
use App\Livewire\Settings\XmlExchange;
use App\Models\Territorio;
use App\Models\User;
use App\Services\TerritorioPdfImportState;
use App\Services\TerritorioThumbnailService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Livewire\Livewire;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
use ZipArchive;
/**
* Import dei PDF dei territori: da cartella e da ZIP.
*
* Il difetto che costa di piu' qui non fa rumore: un PDF abbinato al territorio
* sbagliato ne sostituisce la mappa, e ce ne si accorge solo quando qualcuno va
* in un territorio con la cartina di un altro. Per questo l'abbinamento scarta
* invece di indovinare, e questi test fissano la regola caso per caso.
*/
class ImportPdfTerritoriTest 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');
// pdftoppm non serve a niente qui, e su un PDF finto fallirebbe comunque.
$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);
}
// ─── Abbinamento nome file → territorio ─────────────────────
/**
* Il caso segnalato: "1-A" conteneva "1", e senza un territorio 1A il PDF
* finiva sul territorio 1 sostituendone la mappa.
*/
public function test_un_file_1_A_non_sostituisce_il_pdf_del_territorio_1(): void
{
$uno = $this->territorioConPdf('1');
$stato = $this->importa(['1-A.pdf']);
$this->assertSame('territori-pdf/originale-1.pdf', $uno->fresh()->pdf_path);
Storage::disk('public')->assertExists('territori-pdf/originale-1.pdf');
$this->assertSame(1, $stato['stats']['skipped']);
$this->assertSame('unknown-suffix', $stato['issues'][0]['type']);
$this->assertSame(['1'], $stato['issues'][0]['matched_numbers']);
}
/**
* Prima il vecchio PDF si cancellava prima di scrivere il nuovo: un import
* sbagliato non si poteva disfare.
*/
public function test_il_pdf_sostituito_finisce_nello_storico(): void
{
$uno = $this->territorioConPdf('1');
$this->importa(['1.pdf']);
$uno->refresh();
$this->assertNotSame('territori-pdf/originale-1.pdf', $uno->pdf_path);
Storage::disk('public')->assertExists($uno->pdf_path);
$versione = \App\Models\TerritorioPdfVersione::where('territorio_id', $uno->id)->sole();
$this->assertSame('import', $versione->origine);
$this->assertSame('PDF ORIGINALE', Storage::disk('public')->get($versione->pdf_path));
}
public function test_un_file_1_A_va_al_territorio_1_A_quando_esiste(): void
{
$uno = $this->territorioConPdf('1');
$unoA = Territorio::create(['numero' => '1-A', 'attivo' => true]);
$this->importa(['1-A.pdf']);
$this->assertNotNull($unoA->fresh()->pdf_path);
$this->assertSame('territori-pdf/originale-1.pdf', $uno->fresh()->pdf_path);
}
/**
* "12 B", "12B" e "12-b" sono lo stesso numero, e non e' il 12: la lettera
* dopo il numero fa parte del numero.
*/
public function test_12_B_con_lo_spazio_e_il_territorio_12B_e_non_il_12(): void
{
$dodici = $this->territorioConPdf('12');
$dodiciB = Territorio::create(['numero' => '12B', 'attivo' => true]);
$stato = $this->importa(['12 B.pdf']);
$this->assertSame(1, $stato['stats']['updated']);
$this->assertNotNull($dodiciB->fresh()->pdf_path);
$this->assertSame('territori-pdf/originale-12.pdf', $dodici->fresh()->pdf_path);
}
/**
* Due territori che si scrivono diversi ma sono lo stesso numero: il file
* che li nomina non si puo' abbinare con certezza, quindi si scarta.
*/
public function test_due_territori_con_lo_stesso_numero_scritto_diverso_rendono_il_file_ambiguo(): void
{
$attaccato = Territorio::create(['numero' => '12B', 'attivo' => true]);
$staccato = Territorio::create(['numero' => '12 B', 'attivo' => true]);
$stato = $this->importa(['12b.pdf']);
$this->assertNull($attaccato->fresh()->pdf_path);
$this->assertNull($staccato->fresh()->pdf_path);
$this->assertSame('ambiguous', $stato['issues'][0]['type']);
$this->assertEqualsCanonicalizing(['12B', '12 B'], $stato['issues'][0]['matched_numbers']);
}
public function test_due_numeri_di_territori_esistenti_nello_stesso_nome_rendono_il_file_ambiguo(): void
{
$tre = Territorio::create(['numero' => '3', 'attivo' => true]);
$dodici = Territorio::create(['numero' => '12', 'attivo' => true]);
$stato = $this->importa(['Zona 3 - Territorio 12.pdf']);
$this->assertNull($tre->fresh()->pdf_path);
$this->assertNull($dodici->fresh()->pdf_path);
$this->assertSame('ambiguous', $stato['issues'][0]['type']);
}
/** Le forme di sempre devono continuare a funzionare. */
public function test_zeri_iniziali_testo_intorno_e_numeri_estranei_non_impediscono_l_abbinamento(): void
{
$sette = Territorio::create(['numero' => '7', 'attivo' => true]);
$dodici = Territorio::create(['numero' => '12', 'attivo' => true]);
$stato = $this->importa(['Territorio 007 - Via Roma.pdf', '2024_territorio_12.pdf']);
$this->assertSame(2, $stato['stats']['updated']);
$this->assertNotNull($sette->fresh()->pdf_path);
$this->assertNotNull($dodici->fresh()->pdf_path);
}
public function test_lo_stesso_territorio_scritto_in_due_modi_nella_stessa_cartella_e_un_doppione(): void
{
$dodiciB = Territorio::create(['numero' => '12B', 'attivo' => true]);
$stato = $this->importa(['12B.pdf', '12 B.pdf']);
$this->assertSame(1, $stato['stats']['updated']);
$this->assertSame('duplicate-in-batch', $stato['issues'][0]['type']);
$this->assertNotNull($dodiciB->fresh()->pdf_path);
}
// ─── Cartella ───────────────────────────────────────────────
/**
* Thumbs.db e simili facevano fallire la validazione `mimes:pdf` di tutta la
* cartella: nessun PDF importato per colpa di un file che Windows crea da sé.
*/
public function test_thumbs_db_e_i_file_nascosti_nella_cartella_non_bloccano_l_import(): void
{
Storage::fake('local');
$uno = Territorio::create(['numero' => '1', 'attivo' => true]);
Livewire::test(XmlExchange::class)
->set('pdfFolder', [
UploadedFile::fake()->createWithContent('1.pdf', self::PDF),
UploadedFile::fake()->createWithContent('Thumbs.db', 'non un pdf'),
UploadedFile::fake()->createWithContent('desktop.ini', '[.ShellClassInfo]'),
UploadedFile::fake()->createWithContent('.DS_Store', 'non un pdf'),
])
->call('importTerritoryPdfFolder')
->assertHasNoErrors()
->assertSet('pdfImportStatus', 'completed');
$this->assertNotNull($uno->fresh()->pdf_path);
Storage::disk('public')->assertExists($uno->fresh()->pdf_path);
}
public function test_un_pdf_non_valido_nella_cartella_si_scarta_da_solo(): void
{
Storage::fake('local');
$uno = Territorio::create(['numero' => '1', 'attivo' => true]);
$due = Territorio::create(['numero' => '2', 'attivo' => true]);
$componente = Livewire::test(XmlExchange::class)
->set('pdfFolder', [
UploadedFile::fake()->createWithContent('1.pdf', self::PDF),
// Un'immagine rinominata. Nei test Livewire prende il tipo
// dichiarato dal file finto, non dal contenuto: va detto.
UploadedFile::fake()->createWithContent('2.pdf', "\x89PNG\r\n\x1a\n" . str_repeat("\0", 64))->mimeType('image/png'),
])
->call('importTerritoryPdfFolder')
->assertHasNoErrors();
$this->assertNotNull($uno->fresh()->pdf_path);
$this->assertNull($due->fresh()->pdf_path);
$this->assertSame('invalid-file', $componente->get('pdfImportIssues')[0]['type']);
}
/**
* Con wire:model la cartella partiva in una richiesta sola, e PHP ne teneva
* i primi `max_file_uploads` (20) scartando gli altri in silenzio. Il browser
* la spedisce a lotti: la pagina deve dirgli quanto grandi, e l'input non
* deve avere wire:model.
*/
public function test_la_cartella_parte_a_lotti_dentro_il_limite_di_php(): void
{
$this->withoutVite();
$lotto = min(20, (int) ini_get('max_file_uploads'));
$this->get(route('xml.exchange'))
->assertOk()
->assertSee('data-file-per-lotto="' . $lotto . '"', false)
->assertSee('uploadMultiple(', false)
->assertDontSee('wire:model="pdfFolder"', false);
}
/** Lotti aggiunti uno dopo l'altro: il server deve importarli tutti. */
public function test_una_cartella_di_piu_di_venti_pdf_viene_importata_per_intero(): void
{
Storage::fake('local');
$file = [];
for ($numero = 1; $numero <= 25; $numero++) {
Territorio::create(['numero' => (string) $numero, 'attivo' => true]);
$file[] = UploadedFile::fake()->createWithContent($numero . '.pdf', self::PDF);
}
Livewire::test(XmlExchange::class)
->set('pdfFolder', $file)
->call('importTerritoryPdfFolder')
->assertHasNoErrors();
$this->assertSame(25, Territorio::whereNotNull('pdf_path')->count());
}
// ─── ZIP ────────────────────────────────────────────────────
/**
* Uno ZIP fatto su macOS porta con se' __MACOSX/._1.pdf: estensione giusta,
* contenuto spazzatura. Arrivando per primo, diventava la mappa del
* territorio 1 e il PDF vero veniva scartato come doppione.
*/
public function test_lo_zip_ignora_file_di_sistema_e_copie_macosx(): void
{
$uno = Territorio::create(['numero' => '1', 'attivo' => true]);
$zip = $this->zip([
'__MACOSX/._1.pdf' => 'risorse di macOS, non un PDF',
'Thumbs.db' => 'non un pdf',
'territori/1.pdf' => self::PDF,
]);
$this->postJson(route('imports.territori.pdf-zip'), ['pdfZip' => $zip])
->assertOk();
$percorso = $uno->fresh()->pdf_path;
$this->assertNotNull($percorso);
$this->assertSame(self::PDF, Storage::disk('public')->get($percorso));
}
// ─── Supporto ───────────────────────────────────────────────
private function territorioConPdf(string $numero): Territorio
{
$percorso = 'territori-pdf/originale-' . Str::slug($numero) . '.pdf';
Storage::disk('public')->put($percorso, 'PDF ORIGINALE');
return Territorio::create(['numero' => $numero, 'attivo' => true, 'pdf_path' => $percorso]);
}
/**
* Esegue il job su file con i nomi dati, come se fossero arrivati da una
* cartella, e restituisce lo stato finale dell'import.
*/
private function importa(array $nomi): array
{
Storage::fake('local');
$importId = (string) Str::uuid();
$file = [];
foreach (array_values($nomi) as $indice => $nome) {
$percorso = 'bulk-territori-imports/' . $importId . '/' . $indice . '.pdf';
Storage::disk('local')->put($percorso, self::PDF);
$file[] = ['original_name' => $nome, 'stored_path' => $percorso];
}
app(TerritorioPdfImportState::class)->initialize($importId, count($file));
ImportTerritoryPdfFolder::dispatchSync($importId, $file, $this->admin->id);
return app(TerritorioPdfImportState::class)->get($importId);
}
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);
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace Tests\Feature;
use App\Livewire\Settings\XmlExchange;
use App\Models\Territorio;
use App\Models\TerritorioPdfVersione;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
/**
* L'import XML non lascia file orfani sul disco.
*
* L'import cancella i territori e li ricrea con id nuovi, e i PDF portano l'id
* nel nome. Un XML con percorsi diversi da quelli correnti — da un'altra
* installazione, o un dump convertito della vecchia applicazione — lasciava sul
* disco i PDF e le miniature dei territori cancellati: invisibili, irraggiungibili
* da ogni cancellazione, e copiati in ogni backup successivo. Sul container di
* sviluppo erano 545 PDF per 276 territori (territorio-142-141.pdf accanto a
* territorio-142-415.pdf).
*/
class ImportXmlFileOrfaniTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
Role::create(['name' => 'amministratore', 'guard_name' => 'web']);
app(PermissionRegistrar::class)->forgetCachedPermissions();
$admin = User::create([
'name' => 'Amministratore',
'email' => 'admin@example.test',
'password' => 'password-di-prova',
]);
$admin->assignRole('amministratore');
$this->actingAs($admin);
}
public function test_l_import_elimina_i_file_dei_territori_che_sostituisce(): void
{
$disco = Storage::disk('public');
$disco->put('territori-pdf/territorio-142-141.pdf', 'vecchio');
$disco->put('territori-thumbnails/territorio-142-141.png', 'vecchia');
$disco->put('territori-pdf-versioni/territorio-141-20260101.pdf', 'storico');
$disco->put('territori-pdf/caricato-a-mano.pdf', 'di nessuno');
$territorio = Territorio::create([
'numero' => '142',
'attivo' => true,
'pdf_path' => 'territori-pdf/territorio-142-141.pdf',
'thumbnail_path' => 'territori-thumbnails/territorio-142-141.png',
]);
TerritorioPdfVersione::create([
'territorio_id' => $territorio->id,
'pdf_path' => 'territori-pdf-versioni/territorio-141-20260101.pdf',
'sostituito_il' => now(),
]);
$this->post(route('xml.import-xml'), ['xmlImport' => $this->xml('142', 'territori-pdf/territorio-142-415.pdf')])
->assertSessionHasNoErrors();
$disco->assertMissing('territori-pdf/territorio-142-141.pdf');
$disco->assertMissing('territori-thumbnails/territorio-142-141.png');
// Lo storico se ne va con il territorio (cascata sulla riga): il file
// restava sul disco.
$disco->assertMissing('territori-pdf-versioni/territorio-141-20260101.pdf');
// Un file che nessun territorio richiamava non e' affare dell'import.
$disco->assertExists('territori-pdf/caricato-a-mano.pdf');
}
/** Il caso di tutti i giorni: reimportare l'export di questa stessa installazione. */
public function test_reimportare_l_export_della_stessa_installazione_non_cancella_niente(): void
{
$disco = Storage::disk('public');
$disco->put('territori-pdf/territorio-7-3.pdf', 'pdf');
$disco->put('territori-thumbnails/territorio-7-3.png', 'png');
Territorio::create([
'numero' => '7',
'attivo' => true,
'pdf_path' => 'territori-pdf/territorio-7-3.pdf',
'thumbnail_path' => 'territori-thumbnails/territorio-7-3.png',
]);
$export = (new XmlExchange())->currentXmlBackup();
$this->post(route('xml.import-xml'), ['xmlImport' => UploadedFile::fake()->createWithContent('export.xml', $export)])
->assertSessionHasNoErrors();
$disco->assertExists('territori-pdf/territorio-7-3.pdf');
$disco->assertExists('territori-thumbnails/territorio-7-3.png');
$this->assertSame('territori-pdf/territorio-7-3.pdf', Territorio::sole()->pdf_path);
}
public function test_un_import_rifiutato_non_cancella_niente(): void
{
$disco = Storage::disk('public');
$disco->put('territori-pdf/territorio-1-1.pdf', 'pdf');
Territorio::create(['numero' => '1', 'attivo' => true, 'pdf_path' => 'territori-pdf/territorio-1-1.pdf']);
$this->post(route('xml.import-xml'), [
'xmlImport' => UploadedFile::fake()->createWithContent('estraneo.xml', '<?xml version="1.0"?><sitemap/>'),
])->assertSessionHasErrors('xmlImport');
$disco->assertExists('territori-pdf/territorio-1-1.pdf');
}
private function xml(string $numero, string $pdfPath): 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>'
. '<pdf_path>' . $pdfPath . '</pdf_path></territorio></territori>'
. '<anni_teocratici/><campagne/><assegnazioni/>'
. '</termanager2>';
return UploadedFile::fake()->createWithContent('export.xml', $xml);
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace Tests\Feature;
use App\Livewire\AuditLog;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\Proclamatore;
use App\Models\Territorio;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
use Tests\TestCase;
/**
* Nel registro attività niente nomi di proclamatori in chiaro: sono cifrati
* nel database e devono sparire con l'anonimizzazione.
*/
class LogSenzaNomiTest extends TestCase
{
use RefreshDatabase;
private User $admin;
private Assegnazione $assegnazione;
private Proclamatore $proclamatore;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolesAndPermissionsSeeder::class);
$this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']);
$this->admin->assignRole('amministratore');
$anno = AnnoTeocratico::create(['label' => '2025-2026', 'start_date' => '2025-09-01', 'end_date' => '2026-08-31']);
$this->proclamatore = Proclamatore::create(['nome' => 'Giuseppe', 'cognome' => 'Verdi', 'attivo' => true]);
$territorio = Territorio::create(['numero' => '7', 'attivo' => true]);
$this->assegnazione = Assegnazione::create([
'anno_teocratico_id' => $anno->id,
'territorio_id' => $territorio->id,
'proclamatore_id' => $this->proclamatore->id,
'assigned_at' => '2026-01-10',
'created_by' => $this->admin->id,
]);
}
public function test_le_voci_vecchie_perdono_il_nome_e_tengono_l_id(): void
{
activity()->performedOn($this->assegnazione)
->withProperties(['territorio' => '7', 'proclamatore' => 'Giuseppe Verdi'])
->log('assigned');
$migrazione = require database_path('migrations/2026_10_12_000010_nomi_proclamatori_fuori_dal_log.php');
$migrazione->up();
$proprieta = json_decode(DB::table('activity_log')->where('description', 'assigned')->value('properties'), true);
$this->assertArrayNotHasKey('proclamatore', $proprieta);
$this->assertSame($this->proclamatore->id, $proprieta['proclamatore_id']);
$this->assertStringNotContainsString('Verdi', DB::table('activity_log')->where('description', 'assigned')->value('properties'));
}
public function test_il_registro_mostra_il_nome_di_oggi_anche_dopo_l_anonimizzazione(): void
{
activity()->performedOn($this->assegnazione)
->withProperties(['territorio' => '7', 'proclamatore_id' => $this->proclamatore->id])
->log('assigned');
Livewire::actingAs($this->admin)->test(AuditLog::class)->assertSee('Verdi Giuseppe');
$this->proclamatore->anonimizza();
Livewire::actingAs($this->admin)->test(AuditLog::class)
->assertDontSee('Verdi Giuseppe')
->assertSee('Anonimo');
}
public function test_assegnare_non_scrive_il_nome_nel_log(): void
{
$sorgente = file_get_contents(app_path('Livewire/Assegnazioni/Assegna.php'))
. file_get_contents(app_path('Livewire/Assegnazioni/Rientra.php'));
$this->assertStringNotContainsString("'proclamatore' => \$proclamatore->nome_completo", $sorgente);
}
}

View File

@@ -57,6 +57,8 @@ class PiantineEditorTest extends TestCase
$utente->assignRole('utente');
$this->actingAs($utente)->get(route('piantine.editor'))->assertForbidden();
// Due utenti nella stessa sessione: AuthenticateSession butterebbe fuori il secondo.
$this->flushSession();
$this->actingAs($this->admin())->get(route('piantine.editor'))->assertOk();
}

View File

@@ -44,6 +44,8 @@ class PiantineManagerTest extends TestCase
$utente->assignRole('utente');
$this->actingAs($utente)->get(route('piantine.index'))->assertForbidden();
// Due utenti nella stessa sessione: AuthenticateSession butterebbe fuori il secondo.
$this->flushSession();
$this->actingAs($this->admin())->get(route('piantine.index'))->assertOk();
}

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);
}
}

View File

@@ -0,0 +1,351 @@
<?php
namespace Tests\Feature;
use App\Jobs\RestoreBackupArchive;
use App\Livewire\Settings\XmlExchange;
use App\Models\Territorio;
use App\Models\TerritorioMappa;
use App\Models\TerritorioPdfVersione;
use App\Models\User;
use App\Models\Zona;
use App\Services\BackupArchive;
use App\Services\BackupOperationState;
use App\Services\Piantine\PiantineBackup;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
use ZipArchive;
/**
* Il ripristino da .zip, job intero: tutto o niente, piantine comprese.
*
* Prima import, file e blocco degli account erano tre passi separati e il
* disco si svuotava prima di ricopiarlo: un errore a meta' lasciava il
* database del backup con i file a meta'. E il backup non conteneva ne'
* le piantine ne' lo storico dei PDF ne' l'area KML.
*
* Ogni test fa un backup "vero" (stessi servizi del job di backup), cambia
* qualcosa, e ripristina.
*/
class RipristinoBackupTest extends TestCase
{
use RefreshDatabase;
private User $admin;
private User $altro;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
Storage::fake('local');
Role::create(['name' => 'amministratore', 'guard_name' => 'web']);
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'admin-al-backup']);
$this->admin->assignRole('amministratore');
$this->altro = User::create(['name' => 'Altro', 'email' => 'altro@example.test', 'password' => 'altro-al-backup']);
}
protected function tearDown(): void
{
$archivio = new BackupArchive();
foreach (['backup-restore', 'backup-precedenti', 'testing-ripristino'] as $cartella) {
$archivio->deleteDirectory(storage_path('app/' . $cartella));
}
parent::tearDown();
}
// ─── Piantine nel backup ────────────────────────────────────
public function test_piantine_storico_e_area_kml_sopravvivono_al_ripristino(): void
{
[$territorio] = $this->installazioneConPiantine();
$zip = $this->backup();
// Dopo il backup cambia tutto: piantine e storico spariscono, l'area
// e' un'altra, compare un file estraneo.
TerritorioMappa::query()->delete();
TerritorioPdfVersione::query()->delete();
Storage::disk('local')->put('piantine/area.kml', 'area successiva');
Storage::disk('public')->put('territori-pdf/estraneo.pdf', 'dopo il backup');
$stato = $this->ripristina($zip);
$this->assertSame('completed', $stato['status'], (string) $stato['error']);
// L'import ricrea i territori con id nuovi: il collegamento si ritrova
// dal numero, non dall'id del backup.
$nuovo = Territorio::where('numero', '12')->firstOrFail();
$this->assertNotSame($territorio->id, $nuovo->id);
$mappa = TerritorioMappa::sole();
$this->assertSame($nuovo->id, $mappa->territorio_id);
$this->assertSame('{"type":"Polygon","coordinates":[[[13.3,38.1],[13.4,38.1],[13.4,38.2],[13.3,38.1]]]}', $mappa->geojson);
$this->assertSame('{"type":"Polygon"}', $mappa->geojson_pubblicato);
$this->assertSame(str_repeat('a', 64), $mappa->impronta_pubblicata);
$this->assertSame('2026-10-01 10:00:00', $mappa->pubblicata_at->format('Y-m-d H:i:s'));
$this->assertSame('territori-mappe/piantina-1.jpg', $mappa->image_path);
$versione = TerritorioPdfVersione::sole();
$this->assertSame($nuovo->id, $versione->territorio_id);
$this->assertTrue(Storage::disk('public')->exists($versione->pdf_path));
$this->assertSame('<kml>area al backup</kml>', Storage::disk('local')->get('piantine/area.kml'));
$this->assertFalse(Storage::disk('public')->exists('territori-pdf/estraneo.pdf'));
$this->assertTrue(Storage::disk('public')->exists('territori-mappe/piantina-1.jpg'));
}
/**
* Compatibilita' all'indietro: un archivio di formato 1 si ripristina.
* Non contiene piantine e storico, che restano vuoti — tenere quelle
* correnti vorrebbe dire righe che citano immagini del disco appena
* sostituito. L'area KML non e' legata a nessuna riga e non si tocca.
*/
public function test_un_archivio_di_formato_1_si_ripristina_con_piantine_vuote(): void
{
$this->installazioneConPiantine();
$zip = $this->archivioFormato1();
$stato = $this->ripristina($zip);
$this->assertSame('completed', $stato['status'], (string) $stato['error']);
$this->assertTrue(Territorio::where('numero', '12')->exists());
$this->assertSame(0, TerritorioMappa::count());
$this->assertSame(0, TerritorioPdfVersione::count());
$this->assertSame('<kml>area al backup</kml>', Storage::disk('local')->get('piantine/area.kml'));
$this->assertSame(['territori-pdf/dal-formato-1.pdf'], Storage::disk('public')->allFiles());
}
// ─── Tutto o niente ─────────────────────────────────────────
/**
* Lo scambio dei file fallisce (la cartella dell'area e' occupata da un
* file e non si puo' creare) dopo che l'import ha gia' riscritto le
* tabelle. Prima l'import era gia' confermato a quel punto e il disco gia'
* svuotato: database del backup, file a meta'. Adesso torna tutto com'era,
* database e file.
*/
public function test_un_errore_sui_file_annulla_anche_il_database(): void
{
$this->installazioneConPiantine();
$zip = $this->backup();
$this->cambiaDopoIlBackup();
Storage::disk('local')->deleteDirectory('piantine');
Storage::disk('local')->put('piantine', 'un file al posto della cartella');
$stato = $this->ripristina($zip);
$this->assertSame('failed', $stato['status']);
$this->assertSituazioneDopoIlBackupIntatta();
}
/**
* Un dato delle piantine che il database rifiuta arriva dopo l'import
* XML, nella stessa transazione: deve trascinare indietro anche l'import,
* e i file non devono essere stati toccati.
*/
public function test_un_errore_sulle_piantine_annulla_l_import_e_non_tocca_i_file(): void
{
$this->installazioneConPiantine();
$zip = $this->backup(function (array $piantine) {
$piantine['territorio_mappe'][0]['geojson'] = null;
return $piantine;
});
$this->cambiaDopoIlBackup();
$stato = $this->ripristina($zip);
$this->assertSame('failed', $stato['status']);
$this->assertSituazioneDopoIlBackupIntatta();
}
/**
* Un archivio troncato o alterato si ferma all'estrazione: niente e'
* stato ancora toccato.
*/
public function test_un_archivio_corrotto_non_tocca_niente(): void
{
$this->installazioneConPiantine();
$zip = $this->backup();
$this->cambiaDopoIlBackup();
// Tronca l'archivio: la directory centrale sta in fondo, e senza
// quella non si apre.
$contenuto = file_get_contents($zip);
file_put_contents($zip, substr($contenuto, 0, (int) (strlen($contenuto) * 0.6)));
$stato = $this->ripristina($zip);
$this->assertSame('failed', $stato['status']);
$this->assertSituazioneDopoIlBackupIntatta();
}
// ─── Chi ripristina resta dentro ────────────────────────────
/**
* Con AuthenticateSession attivo, un hash della password cambiato chiude
* la sessione: se il ripristino desse all'amministratore che lo esegue
* l'hash del backup, verrebbe buttato fuori e dovrebbe rientrare con una
* password di mesi fa. Gli altri prendono quella del backup — e devono
* comunque cambiarla, attore compreso.
*/
public function test_chi_ripristina_conserva_la_sua_password_gli_altri_prendono_quella_del_backup(): void
{
$hashAltroAlBackup = $this->hash($this->altro);
$zip = $this->backup();
$this->admin->update(['password' => 'admin-attuale']);
$this->altro->update(['password' => 'altro-attuale']);
$hashAdminAttuale = $this->hash($this->admin);
$stato = $this->ripristina($zip);
$this->assertSame('completed', $stato['status'], (string) $stato['error']);
$this->assertSame($hashAdminAttuale, $this->hash($this->admin));
$this->assertSame($hashAltroAlBackup, $this->hash($this->altro));
$this->assertTrue($this->admin->fresh()->hasRole('amministratore'));
$this->assertSame(2, User::where('password_change_required', true)->count());
}
/**
* L'attore non c'e' nel backup (account creato dopo): l'import fa upsert
* per email e non cancella nessuno, quindi il suo account resta com'e',
* password e ruolo compresi.
*/
public function test_chi_ripristina_e_non_e_nel_backup_resta_con_password_e_ruolo(): void
{
$zip = $this->backup();
$nuovoAdmin = User::create(['name' => 'Nuovo', 'email' => 'nuovo@example.test', 'password' => 'nuovo-admin']);
$nuovoAdmin->assignRole('amministratore');
$hashNuovo = $this->hash($nuovoAdmin);
$stato = $this->ripristina($zip, $nuovoAdmin);
$this->assertSame('completed', $stato['status'], (string) $stato['error']);
$this->assertSame($hashNuovo, $this->hash($nuovoAdmin));
$this->assertTrue($nuovoAdmin->fresh()->hasRole('amministratore'));
$this->assertTrue((bool) $nuovoAdmin->fresh()->password_change_required);
}
// ─── helper ─────────────────────────────────────────────────
/** @return array{0: Territorio} */
private function installazioneConPiantine(): array
{
$zona = Zona::create(['nome' => 'Centro', 'attivo' => true]);
Storage::disk('public')->put('territori-pdf/piantina-12.pdf', 'pdf in vigore');
Storage::disk('public')->put('territori-pdf-versioni/territorio-12-vecchio.pdf', 'pdf sostituito');
Storage::disk('public')->put('territori-mappe/piantina-1.jpg', 'immagine');
Storage::disk('local')->put('piantine/area.kml', '<kml>area al backup</kml>');
$territorio = Territorio::create([
'numero' => '12',
'zona_id' => $zona->id,
'attivo' => true,
'pdf_path' => 'territori-pdf/piantina-12.pdf',
]);
TerritorioMappa::create([
'territorio_id' => $territorio->id,
'geojson' => '{"type":"Polygon","coordinates":[[[13.3,38.1],[13.4,38.1],[13.4,38.2],[13.3,38.1]]]}',
'geojson_pubblicato' => '{"type":"Polygon"}',
'impronta_pubblicata' => str_repeat('a', 64),
'pubblicata_at' => '2026-10-01 10:00:00',
'image_path' => 'territori-mappe/piantina-1.jpg',
]);
TerritorioPdfVersione::create([
'territorio_id' => $territorio->id,
'pdf_path' => 'territori-pdf-versioni/territorio-12-vecchio.pdf',
'origine' => 'piantine',
'sostituito_il' => '2026-09-01 09:00:00',
]);
return [$territorio];
}
/** Come CreateBackupArchive, con la possibilita' di alterare piantine.json. */
private function backup(?callable $altera = null): string
{
$piantine = app(PiantineBackup::class)->esporta();
if ($altera) {
$piantine = $altera($piantine);
}
$destinazione = storage_path('app/testing-ripristino/backup.zip');
(new BackupArchive())->create((new XmlExchange())->currentXmlBackup(), $piantine, $destinazione);
return $destinazione;
}
/** Un pacchetto come li produceva l'applicazione prima delle piantine. */
private function archivioFormato1(): string
{
$destinazione = storage_path('app/testing-ripristino/formato-1.zip');
@mkdir(dirname($destinazione), 0775, true);
$zip = new ZipArchive();
$zip->open($destinazione, ZipArchive::CREATE | ZipArchive::OVERWRITE);
$zip->addFromString('manifest.json', json_encode(['format_version' => 1, 'application' => 'TerManager2', 'files_count' => 1]));
$zip->addFromString('data.xml', (new XmlExchange())->currentXmlBackup());
$zip->addFromString('storage/territori-pdf/dal-formato-1.pdf', 'pdf');
$zip->close();
return $destinazione;
}
private function cambiaDopoIlBackup(): void
{
Territorio::create(['numero' => '99', 'attivo' => true]);
TerritorioMappa::create(['nome' => 'Disegnata dopo', 'geojson' => '{}']);
$this->altro->update(['password' => 'altro-attuale']);
Storage::disk('public')->put('territori-pdf/estraneo.pdf', 'dopo il backup');
}
/** Lo stato lasciato da cambiaDopoIlBackup(), che un ripristino fallito deve conservare. */
private function assertSituazioneDopoIlBackupIntatta(): void
{
$this->assertTrue(Territorio::where('numero', '99')->exists(), 'L\'import non e\' stato annullato.');
$this->assertTrue(TerritorioMappa::where('nome', 'Disegnata dopo')->exists());
$this->assertSame(2, TerritorioMappa::count());
$this->assertSame(1, TerritorioPdfVersione::count());
$this->assertTrue(password_verify('altro-attuale', $this->hash($this->altro)));
$this->assertSame(0, User::where('password_change_required', true)->count(), 'Gli account non vanno bloccati se il ripristino non avviene.');
$this->assertTrue(Storage::disk('public')->exists('territori-pdf/estraneo.pdf'), 'I file sono stati sostituiti nonostante il fallimento.');
$this->assertTrue(Storage::disk('public')->exists('territori-pdf/piantina-12.pdf'));
$this->assertTrue(Storage::disk('public')->exists('territori-mappe/piantina-1.jpg'));
$this->assertSame([], glob(storage_path('app/backup-precedenti/*')) ?: []);
}
private function ripristina(string $zip, ?User $attore = null): array
{
$operazione = 'ripristino-di-prova';
app(BackupOperationState::class)->initialize($operazione, BackupOperationState::TIPO_RESTORE, 'In coda');
RestoreBackupArchive::dispatchSync($operazione, $zip, ($attore ?? $this->admin)->id);
return app(BackupOperationState::class)->get($operazione);
}
private function hash(User $utente): string
{
return (string) DB::table('users')->where('id', $utente->id)->value('password');
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Tests\Feature;
use App\Models\Territorio;
use App\Models\TerritorioPdfVersione;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class TerritoriFileOrfaniTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
$disco = Storage::disk('public');
$disco->put('territori-pdf/usato.pdf', 'x');
$disco->put('territori-pdf/orfano.pdf', 'x');
$disco->put('territori-thumbnails/usata.jpg', 'x');
$disco->put('territori-pdf-versioni/versione.pdf', 'x');
$disco->put('logo.png', 'x');
$territorio = Territorio::create([
'numero' => '1',
'attivo' => true,
'pdf_path' => 'territori-pdf/usato.pdf',
'thumbnail_path' => 'territori-thumbnails/usata.jpg',
]);
TerritorioPdfVersione::create([
'territorio_id' => $territorio->id,
'pdf_path' => 'territori-pdf-versioni/versione.pdf',
'origine' => 'import',
'sostituito_il' => now(),
]);
}
public function test_senza_elimina_elenca_e_non_tocca_niente(): void
{
$this->artisan('territori:file-orfani')
->expectsOutputToContain('territori-pdf/orfano.pdf')
->assertSuccessful();
Storage::disk('public')->assertExists('territori-pdf/orfano.pdf');
}
public function test_con_elimina_cancella_solo_gli_orfani(): void
{
$this->artisan('territori:file-orfani --elimina')->assertSuccessful();
$disco = Storage::disk('public');
$disco->assertMissing('territori-pdf/orfano.pdf');
$disco->assertExists('territori-pdf/usato.pdf');
$disco->assertExists('territori-thumbnails/usata.jpg');
$disco->assertExists('territori-pdf-versioni/versione.pdf');
$disco->assertExists('logo.png');
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace Tests\Feature;
use App\Models\Territorio;
use App\Models\TerritorioPdfVersione;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
/**
* I file dei territori si scaricano solo da dentro, con i permessi della
* pagina che li mostra. Prima nginx li serviva a chiunque da /storage.
*/
class TerritorioFileTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
$this->seed(RolesAndPermissionsSeeder::class);
}
private function utente(string $ruolo): User
{
$utente = User::create([
'name' => ucfirst($ruolo),
'email' => $ruolo . '@example.test',
'password' => 'password-di-prova',
]);
$utente->assignRole($ruolo);
return $utente;
}
private function territorioConFile(): Territorio
{
Storage::disk('public')->put('territori-pdf/territorio-1-1.pdf', '%PDF-1.4 prova');
Storage::disk('public')->put('territori-thumbnails/territorio-1.jpg', "\xFF\xD8\xFFprova");
return Territorio::create([
'numero' => '12',
'attivo' => true,
'pdf_path' => 'territori-pdf/territorio-1-1.pdf',
'thumbnail_path' => 'territori-thumbnails/territorio-1.jpg',
]);
}
public function test_senza_login_i_file_non_si_scaricano(): void
{
$territorio = $this->territorioConFile();
$this->get($territorio->pdfUrl())->assertRedirect(route('login'));
$this->get($territorio->miniaturaUrl())->assertRedirect(route('login'));
}
public function test_un_operatore_scarica_pdf_e_miniatura_in_cache_privata(): void
{
$territorio = $this->territorioConFile();
$operatore = $this->utente('operatore');
$risposta = $this->actingAs($operatore)->get($territorio->pdfUrl());
$risposta->assertOk();
$risposta->assertHeader('Content-Type', 'application/pdf');
$this->assertStringContainsString('private', $risposta->headers->get('Cache-Control'));
$this->assertStringContainsString('territorio-12.pdf', $risposta->headers->get('Content-Disposition'));
$this->actingAs($operatore)->get($territorio->miniaturaUrl())->assertOk();
}
public function test_un_territorio_nel_cestino_resta_raggiungibile(): void
{
$territorio = $this->territorioConFile();
$territorio->delete();
$this->actingAs($this->utente('assistente'))
->get(route('territori.file.miniatura', $territorio->id))
->assertOk();
}
public function test_un_file_mancante_risponde_404(): void
{
$territorio = Territorio::create(['numero' => '13', 'attivo' => true, 'pdf_path' => 'territori-pdf/sparito.pdf']);
$this->actingAs($this->utente('operatore'))
->get(route('territori.file.pdf', $territorio->id))
->assertNotFound();
}
public function test_le_versioni_archiviate_sono_solo_dell_amministratore(): void
{
$territorio = $this->territorioConFile();
Storage::disk('public')->put('territori-pdf-versioni/vecchia.pdf', '%PDF-1.4 vecchia');
$versione = TerritorioPdfVersione::create([
'territorio_id' => $territorio->id,
'pdf_path' => 'territori-pdf-versioni/vecchia.pdf',
'origine' => 'piantine',
'sostituito_il' => now(),
]);
$this->actingAs($this->utente('operatore'))
->get(route('territori.file.versione', $versione))
->assertForbidden();
// Due utenti nella stessa sessione: AuthenticateSession butterebbe fuori il secondo.
$this->flushSession();
$this->actingAs($this->utente('amministratore'))
->get(route('territori.file.versione', $versione))
->assertOk();
}
public function test_l_indirizzo_cambia_quando_cambia_il_file(): void
{
$territorio = $this->territorioConFile();
$prima = $territorio->pdfUrl();
$territorio->update(['pdf_path' => 'territori-pdf/territorio-1-2.pdf']);
$this->assertNotSame($prima, $territorio->pdfUrl());
$this->assertNull(Territorio::create(['numero' => '99', 'attivo' => true])->pdfUrl());
}
/** nginx non deve servire le cartelle dei territori (il logo sì). */
public function test_nginx_blocca_le_cartelle_dei_territori(): void
{
$conf = file_get_contents(base_path('docker/nginx/default.conf'));
$this->assertMatchesRegularExpression('#location \^~ /storage/territori- \{\s*return 404;#', $conf);
}
}

View File

@@ -186,6 +186,41 @@ class XmlExchangeImportTest extends TestCase
$this->assertTrue($altro->hasRole('assistente'));
}
/**
* Le credenziali che arrivano da un file non sono più segrete: chi riceve
* l'hash dal file deve cambiare password, e il "ricordami" non viaggia.
*/
public function test_le_credenziali_del_file_obbligano_a_cambiare_password(): void
{
$this->admin->forceFill(['remember_token' => 'token-attuale'])->save();
$hash = Hash::make('password-del-file');
$xml = $this->xmlMinimo(
utenti: '<user legacy_id="9"><name>Amministratore</name><email>admin@example.test</email><password>' . $hash . '</password><remember_token>vecchio</remember_token><roles></roles></user>'
. '<user legacy_id="10"><name>Altro</name><email>altro@example.test</email><password>' . $hash . '</password><remember_token>rubato</remember_token><roles><role>assistente</role></roles></user>',
);
$this->assertArrayNotHasKey('error', $this->importa($xml));
$altro = User::where('email', 'altro@example.test')->sole();
$this->assertTrue((bool) $altro->password_change_required);
$this->assertNull($altro->remember_token);
// Chi importa resta com'era.
$admin = $this->admin->fresh();
$this->assertFalse((bool) $admin->password_change_required);
$this->assertSame('token-attuale', $admin->getRememberToken());
}
public function test_l_export_non_contiene_i_remember_token(): void
{
$this->admin->forceFill(['remember_token' => 'token-segreto'])->save();
$xml = (new XmlExchange())->currentXmlBackup();
$this->assertStringNotContainsString('remember_token', $xml);
$this->assertStringNotContainsString('token-segreto', $xml);
}
// ─── helper ─────────────────────────────────────────────────
private function reimporta(?string $xml = null): void