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:
73
tests/Feature/AvvisiTest.php
Normal file
73
tests/Feature/AvvisiTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
58
tests/Feature/AzzeramentoPiantineTest.php
Normal file
58
tests/Feature/AzzeramentoPiantineTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
123
tests/Feature/CambioPasswordTest.php
Normal file
123
tests/Feature/CambioPasswordTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
87
tests/Feature/CampagnaCalcoliTest.php
Normal file
87
tests/Feature/CampagnaCalcoliTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
46
tests/Feature/CodaTest.php
Normal file
46
tests/Feature/CodaTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
333
tests/Feature/ImportPdfTerritoriTest.php
Normal file
333
tests/Feature/ImportPdfTerritoriTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
129
tests/Feature/ImportXmlFileOrfaniTest.php
Normal file
129
tests/Feature/ImportXmlFileOrfaniTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
88
tests/Feature/LogSenzaNomiTest.php
Normal file
88
tests/Feature/LogSenzaNomiTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
268
tests/Feature/PostoUnicoImportTest.php
Normal file
268
tests/Feature/PostoUnicoImportTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
351
tests/Feature/RipristinoBackupTest.php
Normal file
351
tests/Feature/RipristinoBackupTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
61
tests/Feature/TerritoriFileOrfaniTest.php
Normal file
61
tests/Feature/TerritoriFileOrfaniTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
134
tests/Feature/TerritorioFileTest.php
Normal file
134
tests/Feature/TerritorioFileTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user