Files
Investidor-V2/tests/Feature/TerritoryGeometryImportTest.php
nala 01405cbf8a
tests / ci (push) Canceled after 0s
oportunidades 5
2026-10-10 14:15:28 +02:00

224 lines
16 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());
}
public function test_explicit_maputo_correspondences_preserve_the_catalogue_and_are_bound_to_parent_and_exemplar(): void
{
$this->setupImport();
$country = DB::table('geo_locations')->where('code', 'MZ')->value('id');
$hash = config('geometry-correspondences.catalogue_sha256');
foreach (config('geometry-correspondences.rows.1') as $entry) {
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'code' => 'QA-'.$id, 'source_system' => 'cartografia-workbook', 'external_id' => $hash.':'.$id,
'source_code' => $entry['target_source_code'], 'name' => $entry['target_name'], 'location_type' => 'province', 'parent_id' => $country, 'data_classification' => 'internal']);
}
$before = DB::table('geo_locations')->orderBy('id')->get()->toJson();
$plan = $this->getJson('/management/territory-geometry-import?level=1')->assertOk()->assertJsonPath('resolved', 4)->json();
$rows = collect($plan['rows'])->filter(fn ($row) => $row['correspondence'] !== null);
$this->assertCount(2, $rows);
$this->assertSame(2, $rows->where('match_basis', 'explicit_versioned_correspondence')->count());
// Change the mapping, not the source: the original preview can no longer be applied.
config(['geometry-correspondences.version' => 'changed-qa-version']);
$this->postJson('/management/territory-geometry-import', $this->input($plan))->assertConflict();
config(['geometry-correspondences.version' => $plan['correspondence_version']]);
$result = $this->postJson('/management/territory-geometry-import', $this->input($plan))->assertOk()->assertJsonPath('inserted', 4)->json();
$this->assertSame($before, DB::table('geo_locations')->orderBy('id')->get()->toJson());
foreach ($rows as $row) {
$geometry = DB::table('geo_geometries')->where('external_id', $row['external_id'])->first();
$properties = json_decode($geometry->properties, true);
$this->assertSame('internal', $geometry->data_classification);
$this->assertSame($row['location_id'], $geometry->geo_location_id);
$this->assertEquals($row['correspondence'], $properties['correspondence']);
$this->assertSame($plan['correspondence_sha256'], $properties['correspondence_sha256']);
}
$this->assertCount(4, $result['created']);
// A matching display name under a foreign parent must never acquire MZ06.
DB::table('geo_locations')->where('source_code', '11')->update(['parent_id' => DB::table('geo_locations')->where('code', 'QA-FOREIGN')->value('id')]);
$new = $this->getJson('/management/territory-geometry-import?level=1')->assertOk()->json();
$city = collect($new['rows'])->firstWhere('external_id', '2019-06-07:adm1:MZ06');
$this->assertNull($city['location_id']);
$this->assertSame('name_unresolved', $city['status']);
config(['geometry-correspondences.source_sha256.1' => str_repeat('a', 64)]);
$this->getJson('/management/territory-geometry-import?level=1')->assertStatus(422);
}
public function test_ambiguous_names_remain_pending_without_blocking_independent_unique_matches(): void
{
$this->setupImport();
$location = DB::table('geo_locations')->where('location_type', 'province')->where('parent_id', DB::table('geo_locations')->where('code', 'MZ')->value('id'))->first();
DB::table('geo_locations')->insert(['id' => (string) Str::uuid(), 'code' => 'QA-DUPLICATE-NAME', 'name' => $location->name,
'parent_id' => $location->parent_id, 'location_type' => 'province', 'data_classification' => 'internal']);
$before = DB::table('geo_locations')->orderBy('id')->get()->toJson();
$plan = $this->getJson('/management/territory-geometry-import?level=1')->assertOk()->assertJsonPath('resolved', 1)->json();
$ambiguous = collect($plan['rows'])->firstWhere('status', 'ambiguous_name');
$this->assertNull($ambiguous['location_id']);
$this->assertCount(2, $ambiguous['candidates']);
$this->postJson('/management/territory-geometry-import', $this->input($plan))->assertOk()->assertJsonPath('inserted', 1)->assertJsonPath('pending', 10);
$this->assertSame($before, DB::table('geo_locations')->orderBy('id')->get()->toJson());
$this->assertFalse(DB::table('geo_geometries')->where('geo_location_id', $location->id)->exists());
}
public function test_reversal_preserves_parent_correspondences_needed_by_active_descendant_boundaries(): void
{
$this->setupImport();
$plan = $this->getJson('/management/territory-geometry-import?level=1')->assertOk()->json();
$result = $this->postJson('/management/territory-geometry-import', $this->input($plan))->assertOk()->json();
$parent = DB::table('geo_geometries')->where('id', $result['created'][0]['id'])->first();
$properties = json_decode($parent->properties, true);
$provinceCode = $properties['source_properties']['ADM1_PCODE'];
$child = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $child, 'code' => 'QA-'.$child, 'name' => 'Synthetic QA district', 'location_type' => 'district', 'parent_id' => $parent->geo_location_id, 'data_classification' => 'internal']);
DB::insert('INSERT INTO geo_geometries(id,source_system,external_id,geo_location_id,geometry_type,geom,properties,data_classification) SELECT ?,?,?,?,geometry_type,geom,?::jsonb,? FROM geo_geometries WHERE id=?',
[(string) Str::uuid(), 'ine-cod-ab', '2019-06-07:adm2:'.$provinceCode.'99', $child,
json_encode(['source_version' => '2019-06-07', 'source_properties' => ['ADM1_PCODE' => $provinceCode]], JSON_THROW_ON_ERROR), 'internal', $parent->id]);
$this->postJson('/management/territory-geometry-import/'.$result['batch_id'].'/reverse',
['idempotency_key' => (string) Str::uuid(), 'reason' => 'Recusar reversão do pai enquanto descendentes QA dependem dele', 'confirm' => true])->assertConflict();
$this->assertSame(3, DB::table('geo_geometries')->whereNull('deleted_at')->count());
$this->assertSame(0, DB::table('audit_events')->where('action', 'geometry_import_reversed')->count());
}
}