149 lines
9.8 KiB
PHP
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());
|
|
}
|
|
}
|