S-13 sul modulo ufficiale e correzioni all'integrità dei dati

Modulo S-13
- Il PDF scrive sopra il modulo ufficiale (FPDI, matrice convertita in PDF 1.4)
  invece di ricostruirlo in HTML: 20 righe per foglio come l'originale.
- Una riga per ogni territorio attivo; riporto dell'ultimo rientro prima
  dell'anno; assegnazioni a cavallo d'anno su entrambi i fogli; scelta per
  data e non per anno registrato; nomi come "Cognome I.".
- Logica spostata in App\Services\Registro\ModuloS13.

Integrità dei dati
- Export/import XML (e quindi backup/ripristino): le date non slittano più di
  un giorno; i file vecchi in UTC tornano al giorno giusto.
- Import XML: rifiuta file estranei o incompleti senza toccare i dati, errore
  con riga e motivo, territori senza zona importati con il loro storico,
  caratteri di controllo rimossi in export, chi importa conserva password e
  ruolo di amministratore.
- Assegnazioni: assegnazioneCorrente corretta (filtro dentro ofMany), periodi
  sovrapposti rifiutati in Assegna e Registro sotto lock, anno di servizio
  ricavato dalla data, niente date future nel Registro.
- Utenti: non si declassa sé stessi né l'ultimo amministratore.
- Entrypoint: non sovrascrive più la copia persistente di APP_KEY con un
  valore diverso; serve APP_KEY_ROTATE=1 per un cambio voluto.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 16:35:51 +02:00
parent 9c37c1ad7b
commit d433c9392d
17 changed files with 1361 additions and 457 deletions

View File

@@ -0,0 +1,216 @@
<?php
namespace Tests\Feature;
use App\Livewire\Settings\XmlExchange;
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 App\Models\Zona;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
/**
* Export e import XML: lo stesso percorso del data.xml dei backup, quindi
* ogni difetto qui è un difetto del ripristino.
*
* L'applicazione gira su Europe/Rome (default di config/app.php, nessun
* override in phpunit.xml): è la condizione in cui le date slittavano.
*/
class XmlExchangeImportTest extends TestCase
{
use RefreshDatabase;
private User $admin;
protected function setUp(): void
{
parent::setUp();
Role::create(['name' => 'amministratore', 'guard_name' => 'web']);
Role::create(['name' => 'assistente', 'guard_name' => 'web']);
$this->admin = User::create([
'name' => 'Amministratore',
'email' => 'admin@example.test',
'password' => 'password-attuale',
]);
$this->admin->assignRole('amministratore');
$this->actingAs($this->admin);
}
// ─── Round-trip ─────────────────────────────────────────────
/**
* Il difetto che spostava ogni data indietro di un giorno a ogni giro di
* backup e ripristino: toArray() le serializzava in UTC.
*/
public function test_export_e_reimport_conservano_le_date_esatte(): void
{
$anno = AnnoTeocratico::create(['label' => '2025-2026', 'start_date' => '2025-09-01', 'end_date' => '2026-08-31']);
Campagna::create(['descrizione' => 'Commemorazione', 'start_date' => '2026-03-01', 'end_date' => '2026-03-31']);
$territorio = Territorio::create(['numero' => '7', 'attivo' => true]);
$proclamatore = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]);
Assegnazione::create([
'territorio_id' => $territorio->id,
'proclamatore_id' => $proclamatore->id,
'anno_teocratico_id' => $anno->id,
'assigned_at' => '2026-03-15',
'returned_at' => '2026-04-01',
'created_by' => $this->admin->id,
]);
$this->reimporta();
$assegnazione = Assegnazione::sole();
$this->assertSame('2026-03-15', $assegnazione->assigned_at->toDateString());
$this->assertSame('2026-04-01', $assegnazione->returned_at->toDateString());
$anno = AnnoTeocratico::sole();
$this->assertSame('2025-09-01', $anno->start_date->toDateString());
$this->assertSame('2026-08-31', $anno->end_date->toDateString());
$campagna = Campagna::sole();
$this->assertSame('2026-03-01', $campagna->start_date->toDateString());
$this->assertSame('2026-03-31', $campagna->end_date->toDateString());
}
/** I backup fatti prima della correzione hanno le date in UTC: tornano giuste. */
public function test_un_backup_vecchio_con_date_in_utc_viene_riportato_al_giorno_giusto(): void
{
$xml = $this->xmlMinimo(
anni: '<anno legacy_id="1"><label>2025-2026</label><start_date>2025-08-31T22:00:00.000000Z</start_date><end_date>2026-08-30T22:00:00.000000Z</end_date></anno>',
assegnazioni: '<assegnazione legacy_id="1"><legacy_territorio_id>1</legacy_territorio_id><legacy_proclamatore_id>1</legacy_proclamatore_id><legacy_anno_id>1</legacy_anno_id><assigned_at>2026-03-14T23:00:00.000000Z</assigned_at><returned_at></returned_at></assegnazione>',
);
$this->assertArrayNotHasKey('error', $this->importa($xml));
$this->assertSame('2026-03-15', Assegnazione::sole()->assigned_at->toDateString());
$this->assertSame('2025-09-01', AnnoTeocratico::sole()->start_date->toDateString());
$this->assertSame('2026-08-31', AnnoTeocratico::sole()->end_date->toDateString());
}
/** Un carattere di controllo in una nota rendeva l'intero file illeggibile. */
public function test_un_carattere_di_controllo_in_una_nota_non_rompe_il_file(): void
{
Territorio::create(['numero' => '1', 'attivo' => true, 'note' => "Citofono\x0Brotto\x01"]);
$xml = (new XmlExchange())->currentXmlBackup();
$this->assertNotFalse(simplexml_load_string($xml));
$this->reimporta($xml);
$this->assertSame('Citofonorotto', Territorio::sole()->note);
}
/**
* Un territorio resta senza zona quando la sua zona viene eliminata: al
* reimport veniva scartato insieme a tutte le sue assegnazioni.
*/
public function test_un_territorio_senza_zona_viene_importato_con_il_suo_storico(): void
{
$anno = AnnoTeocratico::create(['label' => '2025-2026', 'start_date' => '2025-09-01', 'end_date' => '2026-08-31']);
$territorio = Territorio::create(['numero' => '3', 'attivo' => true, 'zona_id' => null]);
$proclamatore = Proclamatore::create(['nome' => 'Anna', 'cognome' => 'Bianchi', 'attivo' => true]);
Assegnazione::create([
'territorio_id' => $territorio->id,
'proclamatore_id' => $proclamatore->id,
'anno_teocratico_id' => $anno->id,
'assigned_at' => '2025-10-01',
'created_by' => $this->admin->id,
]);
$this->reimporta();
$this->assertNull(Territorio::sole()->zona_id);
$this->assertSame(1, Assegnazione::count());
}
// ─── File da rifiutare ──────────────────────────────────────
public function test_un_xml_estraneo_viene_rifiutato_senza_toccare_i_dati(): void
{
Territorio::create(['numero' => '1', 'attivo' => true]);
$esito = $this->importa('<?xml version="1.0"?><urlset><url><loc>https://example.org</loc></url></urlset>');
$this->assertStringContainsString('non è un export di TerManager2', $esito['error']);
$this->assertSame(1, Territorio::count());
}
public function test_un_export_senza_le_sezioni_principali_viene_rifiutato(): void
{
Territorio::create(['numero' => '1', 'attivo' => true]);
$esito = $this->importa('<?xml version="1.0"?><termanager2><settings/></termanager2>');
$this->assertStringContainsString('mancano le sezioni', $esito['error']);
$this->assertSame(1, Territorio::count());
}
public function test_un_file_malformato_indica_riga_e_motivo(): void
{
$esito = $this->importa("<?xml version=\"1.0\"?>\n<termanager2>\n<territori>\n</termanager2>");
$this->assertStringContainsString('riga', $esito['error']);
}
// ─── Chi importa non resta fuori ────────────────────────────
/**
* Il file può contenere l'account di chi importa con una password vecchia
* e senza ruoli: l'amministratore che lo carica non deve perdere né l'una
* né gli altri.
*/
public function test_chi_importa_conserva_password_e_ruolo_di_amministratore(): void
{
$hashVecchio = Hash::make('password-di-un-anno-fa');
$xml = $this->xmlMinimo(
utenti: '<user legacy_id="9"><name>Amministratore</name><email>admin@example.test</email><password>' . $hashVecchio . '</password><roles></roles></user>'
. '<user legacy_id="10"><name>Altro</name><email>altro@example.test</email><password>' . $hashVecchio . '</password><roles><role>assistente</role></roles></user>',
);
$this->assertArrayNotHasKey('error', $this->importa($xml));
$admin = $this->admin->fresh();
$this->assertTrue(Hash::check('password-attuale', $admin->password));
$this->assertTrue($admin->hasRole('amministratore'));
// Gli altri account invece prendono i dati del file.
$altro = User::where('email', 'altro@example.test')->sole();
$this->assertTrue(Hash::check('password-di-un-anno-fa', $altro->password));
$this->assertTrue($altro->hasRole('assistente'));
}
// ─── helper ─────────────────────────────────────────────────
private function reimporta(?string $xml = null): void
{
$xml ??= (new XmlExchange())->currentXmlBackup();
$esito = $this->importa($xml);
$this->assertArrayNotHasKey('error', $esito, $esito['error'] ?? '');
$this->assertSame([], $esito['issues'], json_encode($esito['issues']));
}
private function importa(string $xml): array
{
return (new XmlExchange())->importXmlFromContent($xml);
}
private function xmlMinimo(string $utenti = '', string $anni = '', string $assegnazioni = ''): string
{
return '<?xml version="1.0" encoding="UTF-8"?><termanager2 version="1.0">'
. '<settings/><users>' . $utenti . '</users><zones/><tipologie/>'
. '<proclamatori><proclamatore legacy_id="1"><nome>Mario</nome><cognome>Rossi</cognome><attivo>1</attivo></proclamatore></proclamatori>'
. '<territori><territorio legacy_id="1"><numero>1</numero><legacy_zona_id></legacy_zona_id><legacy_tipologia_id></legacy_tipologia_id><attivo>1</attivo></territorio></territori>'
. '<anni_teocratici>' . $anni . '</anni_teocratici><campagne/>'
. '<assegnazioni>' . $assegnazioni . '</assegnazioni>'
. '</termanager2>';
}
}