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