Files
Investidor-V2/tests/Feature/TerritoryGeometryImportTest.php
T
nala 6ddafc95ba
tests / ci (push) Canceled after 0s
oportunidades 4
2026-10-10 07:03:59 +02:00

149 lines
9.8 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\Malware\MalwareScanner;
use App\Services\TerritoryGeometryImport;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\TestCase;
class TerritoryGeometryImportTest extends TestCase
{
use RefreshDatabase;
private string $directory;
private function setupImport(): User
{
$this->directory = storage_path('app/private/testing/geometry-import-'.Str::uuid());
mkdir($this->directory, 0700, true);
$originals = storage_path('app/private/investmoz-v2/portal-audit');
foreach (['moz-adm1-ine-20190607.geojson', 'moz-adm1-ine-metadata.html'] as $file) {
copy($originals.'/'.$file, $this->directory.'/'.$file);
}
$file = $this->directory.'/moz-adm1-ine-20190607.geojson';
config(['services.malware.scanner' => 'clamd', 'services.malware.host' => '127.0.0.1', 'services.malware.port' => 3310]);
$scanner = app(MalwareScanner::class);
if (! $scanner->health()['available']) {
$this->markTestSkipped('Real local ClamD required; no fake clean evidence.');
}
$scan = $scanner->scan($file, filesize($file), storage_path('app/private'));
$this->assertSame('clean', $scan['result']);
$scan['signatures_at'] = $scan['signatures_at']?->format(DATE_ATOM);
file_put_contents($this->directory.'/geometry-source-scans.json', json_encode(['captured_at' => gmdate('c'), 'files' => [['file' => basename($file), 'sha256' => hash_file('sha256', $file), 'scan' => $scan]]], JSON_THROW_ON_ERROR));
$features = json_decode(file_get_contents($file), true)['features'];
$country = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $country, 'code' => 'MZ', 'name' => 'Moçambique', 'location_type' => 'country', 'data_classification' => 'internal']);
foreach (array_slice($features, 0, 2) as $f) {
DB::table('geo_locations')->insert(['id' => (string) Str::uuid(), 'code' => (string) Str::uuid(), 'name' => $f['properties']['ADM1_PT'], 'location_type' => 'province', 'parent_id' => $country, 'data_classification' => 'internal']);
}
$foreignCountry = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $foreignCountry, 'code' => 'QA-FOREIGN', 'name' => 'Synthetic foreign QA only', 'location_type' => 'country', 'data_classification' => 'internal']);
DB::table('geo_locations')->insert(['id' => (string) Str::uuid(), 'code' => (string) Str::uuid(), 'name' => $features[0]['properties']['ADM1_PT'], 'location_type' => 'province', 'parent_id' => $foreignCountry, 'data_classification' => 'internal']);
$actor = User::factory()->create();
$actor->forceFill(['two_factor_secret' => Crypt::encryptString('QA-TEST-ONLY'), 'two_factor_confirmed_at' => now()])->save();
config(['portal.managers' => [$actor->email], 'geometry-import.enabled' => true, 'geometry-import.directory' => $this->directory]);
$this->actingAs($actor);
return $actor;
}
protected function tearDown(): void
{
if (isset($this->directory) && is_dir($this->directory)) {
foreach (glob($this->directory.'/*') as $file) {
unlink($file);
}
rmdir($this->directory);
}
parent::tearDown();
}
private function input(array $plan): array
{
return ['level' => 1, 'hash' => $plan['hash'], 'idempotency_key' => (string) Str::uuid(), 'reason' => 'Ensaio autenticado de cartografia apenas em QA', 'confirm' => true];
}
public function test_preview_apply_replay_reuse_and_reverse_preserve_history(): void
{
$actor = $this->setupImport();
$plan = $this->getJson('/management/territory-geometry-import?level=1')->assertOk()->assertJsonPath('expected', 11)->assertJsonPath('resolved', 2)->json();
$input = $this->input($plan);
$result = $this->postJson('/management/territory-geometry-import', $input)->assertOk()->assertJsonPath('inserted', 2)->assertJsonPath('published', false)->json();
$this->postJson('/management/territory-geometry-import', $input)->assertOk()->assertExactJson($result);
$this->postJson('/management/territory-geometry-import', [...$input, 'reason' => 'Chave com outro conteúdo: recusar sem escrever'])->assertConflict();
$this->assertSame(2, DB::table('geo_geometries')->count());
$reusePlan = $this->getJson('/management/territory-geometry-import?level=1')->json();
$reuseResponse = $this->postJson('/management/territory-geometry-import', $this->input($reusePlan));
$this->assertSame(200, $reuseResponse->status(), $reuseResponse->getContent());
$reuse = $reuseResponse->assertJsonPath('inserted', 0)->assertJsonPath('reused', 2)->json();
$reverse = ['idempotency_key' => (string) Str::uuid(), 'reason' => 'Reversão do lote QA mantendo todo o histórico', 'confirm' => true];
$this->postJson('/management/territory-geometry-import/'.$reuse['batch_id'].'/reverse', $reverse)->assertOk()->assertJsonPath('reversed', 0)->assertJsonPath('reused_preserved', 2);
$this->assertSame(2, DB::table('geo_geometries')->whereNull('deleted_at')->count());
$url = '/management/territory-geometry-import/'.$result['batch_id'].'/reverse';
$reverse['idempotency_key'] = (string) Str::uuid();
$this->postJson($url, $reverse)->assertOk()->assertJsonPath('reversed', 2);
$this->postJson($url, $reverse)->assertOk()->assertJsonPath('reversed', 2);
$this->assertSame(0, DB::table('geo_geometries')->whereNull('deleted_at')->count());
$this->assertSame(2, DB::table('geo_geometries')->count());
$this->assertSame(4, DB::table('audit_events')->where('source_system', 'geometry-import')->where('actor_user_id', $actor->id)->count());
// Reversed identities stay reserved; do not silently resurrect or duplicate.
$this->postJson('/management/territory-geometry-import', $this->input($this->getJson('/management/territory-geometry-import?level=1')->json()))->assertConflict();
}
public function test_stale_preview_changed_geometry_mfa_and_foreign_actor_are_rejected(): void
{
$actor = $this->setupImport();
$plan = $this->getJson('/management/territory-geometry-import?level=1')->json();
DB::table('geo_locations')->where('name', $plan['rows'][0]['name'])->update(['name' => 'Changed QA only']);
$this->postJson('/management/territory-geometry-import', $this->input($plan))->assertConflict();
$plan = $this->getJson('/management/territory-geometry-import?level=1')->json();
$result = $this->postJson('/management/territory-geometry-import', $this->input($plan))->assertOk()->assertJsonPath('inserted', 1)->json();
DB::table('geo_geometries')->where('id', $result['created'][0]['id'])->update(['data_classification' => 'public']);
$this->postJson('/management/territory-geometry-import/'.$result['batch_id'].'/reverse', ['idempotency_key' => (string) Str::uuid(), 'reason' => 'Recusar reversão de geometria posteriormente publicada', 'confirm' => true])->assertConflict();
$actor->forceFill(['two_factor_confirmed_at' => null])->save();
$this->postJson('/management/territory-geometry-import', $this->input($plan))->assertForbidden();
$this->actingAs(User::factory()->create())->getJson('/management/territory-geometry-import?level=1')->assertForbidden();
$this->postJson('/management/territory-geometry-import', $this->input($plan))->assertForbidden();
}
public function test_expired_scan_and_non_qa_target_cannot_write(): void
{
$this->setupImport();
$path = $this->directory.'/geometry-source-scans.json';
$proof = json_decode(file_get_contents($path), true);
$proof['captured_at'] = '2000-01-01T00:00:00Z';
file_put_contents($path, json_encode($proof));
$plan = $this->getJson('/management/territory-geometry-import?level=1')->assertOk()->assertJsonPath('scan_ready', false)->json();
$this->postJson('/management/territory-geometry-import', $this->input($plan))->assertStatus(423);
$this->app->detectEnvironment(fn () => 'production');
try {
app(TerritoryGeometryImport::class)->apply(auth()->user(), $this->input($plan));
$this->fail('An active target cannot bypass the backup and evidence guard.');
} catch (HttpException $e) {
$this->assertSame(422, $e->getStatusCode());
} finally {
$this->app->detectEnvironment(fn () => 'testing');
}
$this->assertSame(0, DB::table('geo_geometries')->count());
}
public function test_reversal_refuses_a_real_foreign_key_consumer_and_is_atomic(): void
{
$this->setupImport();
$plan = $this->getJson('/management/territory-geometry-import?level=1')->json();
$result = $this->postJson('/management/territory-geometry-import', $this->input($plan))->assertOk()->json();
// Transactional QA-only fixture, rolled back by RefreshDatabase; never a product migration.
DB::statement('CREATE TABLE geometry_import_qa_consumer (geometry_id uuid REFERENCES geo_geometries(id))');
DB::table('geometry_import_qa_consumer')->insert(['geometry_id' => $result['created'][1]['id']]);
$this->postJson('/management/territory-geometry-import/'.$result['batch_id'].'/reverse', ['idempotency_key' => (string) Str::uuid(), 'reason' => 'Reversão deve recusar qualquer consumidor ligado ao lote', 'confirm' => true])->assertConflict();
$this->assertSame(2, DB::table('geo_geometries')->whereNull('deleted_at')->count());
$this->assertSame(0, DB::table('audit_events')->where('action', 'geometry_import_reversed')->count());
}
}