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

133 lines
10 KiB
PHP

<?php
namespace Tests\Feature;
use App\Jobs\ScanDocumentForMalware;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\OpportunityManagement;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Tests\Support\CleanScannerFixture;
use Tests\TestCase;
class OpportunityResourcesTest extends TestCase
{
use RefreshDatabase;
private function setupDraft(): array
{
Storage::fake('local');
Queue::fake();
$user = User::factory()->create();
config(['portal.managers' => [$user->email]]);
$id = $this->actingAs($user)->postJson('/management/opportunities/drafts', ['creation_key' => (string) Str::uuid(), 'reason' => 'Rascunho de recursos em QA isolada'])->assertCreated()->json('id');
return [$user, Opportunity::findOrFail($id)];
}
private function payload(Opportunity $opportunity, array $payload = []): array
{
return ['etag' => app(OpportunityManagement::class)->etag($opportunity), 'creation_key' => (string) Str::uuid(), 'payload' => $payload, 'reason' => 'Preparação e conferência em QA isolada'];
}
public function test_encrypted_original_scan_pending_recovery_replacement_and_logical_lifecycle(): void
{
[$user,$o] = $this->setupDraft();
$bytes = "%PDF-1.7\nSynthetic QA document\n%%EOF";
$result = $this->post('/management/opportunities/'.$o->id.'/resources/documents', [...$this->payload($o, ['title' => 'QA source', 'classification' => 'confidential']), 'file' => UploadedFile::fake()->createWithContent('qa.pdf', $bytes)], ['Accept' => 'application/json'])->assertCreated()->json();
$r = DB::table('opportunity_documents')->where('id', $result['id'])->first();
$d = DB::table('documents')->where('id', $r->document_id)->first();
$this->assertSame('pending', $d->malware_scan_status);
$this->assertSame($bytes, Crypt::decryptString(Storage::disk('local')->get($d->storage_key)));
$this->assertNotSame($bytes, Storage::disk('local')->get($d->storage_key));
Queue::assertPushed(ScanDocumentForMalware::class);
$this->get('/management/opportunities/'.$o->id.'/resources/documents/'.$r->id.'/download')->assertStatus(423);
$this->get('/management/opportunities/'.$o->id.'/resources')->assertOk();
// Only a labelled QA fixture scans synthetic bytes; never evidence for an active file.
(new ScanDocumentForMalware($d->id))->handle(new CleanScannerFixture);
$this->assertDatabaseHas('documents', ['id' => $d->id, 'malware_engine' => 'QA-Test-Double', 'malware_scan_status' => 'clean']);
$this->get('/management/opportunities/'.$o->id.'/resources/documents/'.$r->id.'/download')->assertOk()->assertContent($bytes);
$replacement = [...$this->payload($o->fresh(), ['title' => 'QA changed', 'edition' => '2']), 'version' => 1, 'file' => UploadedFile::fake()->createWithContent('qa-v2.pdf', "%PDF-1.7\nQA second version\n%%EOF")];
$this->post('/management/opportunities/'.$o->id.'/resources/documents/'.$r->id, $replacement, ['Accept' => 'application/json'])->assertOk();
$current = DB::table('opportunity_documents')->where('id', $r->id)->first();
$this->assertNotSame($d->id, $current->document_id);
$this->assertDatabaseHas('documents', ['id' => $d->id, 'deleted_at' => null]);
$this->assertTrue(Storage::disk('local')->exists($d->storage_key));
$this->post('/management/opportunities/'.$o->id.'/resources/documents/'.$r->id, $replacement, ['Accept' => 'application/json'])->assertConflict();
$life = ['etag' => app(OpportunityManagement::class)->etag($o->fresh()), 'version' => 2, 'action' => 'delete', 'confirm' => true, 'reason' => 'Exclusão lógica de anexo apenas QA'];
$this->postJson('/management/opportunities/'.$o->id.'/resources/documents/'.$r->id.'/lifecycle', $life)->assertOk();
$this->assertSoftDeleted('opportunity_documents', ['id' => $r->id]);
$this->postJson('/management/opportunities/'.$o->id.'/resources/documents/'.$r->id.'/lifecycle', [...$life, 'etag' => app(OpportunityManagement::class)->etag($o->fresh()), 'version' => 3, 'action' => 'restore'])->assertOk();
$this->assertDatabaseHas('opportunity_documents', ['id' => $r->id, 'deleted_at' => null, 'version' => 4]);
$this->assertSame(4, DB::table('audit_events')->where('source_system', 'opportunity-resources')->count());
$this->actingAs(User::factory()->create())->get('/management/opportunities/'.$o->id.'/resources')->assertForbidden();
$this->postJson('/management/opportunities/'.$o->id.'/resources/locations', $this->payload($o->fresh(), ['title' => 'Denied']))->assertForbidden();
}
public function test_multiple_locations_without_coordinates_and_invalid_hierarchy_and_geometry(): void
{
[$user,$o] = $this->setupDraft();
$province = (string) Str::uuid();
$district = (string) Str::uuid();
DB::table('geo_locations')->insert([['id' => $province, 'code' => $province, 'name' => 'QA province', 'location_type' => 'province', 'parent_id' => null, 'data_classification' => 'public'], ['id' => $district, 'code' => $district, 'name' => 'QA district', 'location_type' => 'district', 'parent_id' => $province, 'data_classification' => 'public']]);
$p = ['title' => 'QA production', 'province_id' => $province, 'district_id' => $district, 'role' => 'production', 'is_primary' => true, 'allocation_percentage' => 60, 'territorial_purpose' => 'implementation'];
$id = $this->postJson('/management/opportunities/'.$o->id.'/resources/locations', $this->payload($o, $p))->assertCreated()->json('id');
$this->postJson('/management/opportunities/'.$o->id.'/resources/locations', $this->payload($o->fresh(), $p))->assertUnprocessable();
$this->postJson('/management/opportunities/'.$o->id.'/resources/locations', $this->payload($o->fresh(), [...$p, 'role' => 'market', 'is_primary' => false, 'allocation_percentage' => 40]))->assertCreated();
$this->assertSame(2, DB::table('opportunity_location_drafts')->count());
$this->assertSame(0, DB::table('opportunity_locations')->count());
$this->get('/management/opportunities/'.$o->id.'/resources')->assertOk();
$this->postJson('/management/opportunities/'.$o->id.'/resources/locations', $this->payload($o->fresh(), ['title' => 'Bad geo', 'geometry' => ['type' => 'Point', 'coordinates' => [300, 100]]]))->assertUnprocessable()->assertJsonValidationErrors('geometry');
$this->postJson('/management/opportunities/'.$o->id.'/resources/locations', $this->payload($o->fresh(), ['title' => 'Gap', 'district_id' => $district]))->assertUnprocessable()->assertJsonValidationErrors('district_id');
$this->assertDatabaseHas('opportunity_location_drafts', ['id' => $id, 'deleted_at' => null]);
}
public function test_invalid_upload_and_cross_opportunity_links_do_not_create_resources(): void
{
[$user,$o] = $this->setupDraft();
$this->post('/management/opportunities/'.$o->id.'/resources/images', [...$this->payload($o, ['title' => 'Bad image']), 'file' => UploadedFile::fake()->createWithContent('bad.png', 'invalid')], ['Accept' => 'application/json'])->assertUnprocessable();
$this->postJson('/management/opportunities/'.$o->id.'/resources/images', $this->payload($o, ['title' => 'QA image', 'location_id' => (string) Str::uuid()]))->assertUnprocessable()->assertJsonValidationErrors('location_id');
$this->assertSame(0, DB::table('opportunity_images')->count());
$this->actingAs(User::factory()->create())->get('/management/opportunity-resources-install')->assertForbidden();
}
public function test_image_processing_is_explicit_and_every_derivative_requires_its_own_scan(): void
{
[$user,$o] = $this->setupDraft();
$chunk = fn ($name, $bytes) => pack('N', strlen($bytes)).$name.$bytes.pack('N', crc32($name.$bytes));
$png = "\x89PNG\r\n\x1a\n".$chunk('IHDR', pack('NNCCCCC', 500, 500, 8, 2, 0, 0, 0)).$chunk('IDAT', gzcompress(str_repeat("\0", 500 * 1501))).$chunk('IEND', '');
$result = $this->post('/management/opportunities/'.$o->id.'/resources/images', [...$this->payload($o, ['title' => 'Synthetic QA image', 'alt_text' => 'Synthetic white square for QA', 'caption' => 'Synthetic image not used in active data', 'rights_status' => 'owned', 'is_cover' => true]), 'file' => UploadedFile::fake()->createWithContent('qa.png', $png)], ['Accept' => 'application/json'])->assertCreated()->json();
$image = DB::table('opportunity_images')->where('id', $result['id'])->first();
(new ScanDocumentForMalware($image->document_id))->handle(new CleanScannerFixture);
$url = '/management/opportunities/'.$o->id.'/resources/images/'.$image->id.'/prepare';
$input = ['etag' => app(OpportunityManagement::class)->etag($o->fresh()), 'version' => 1, 'reason' => 'Processamento de imagem sintética em QA'];
if (! function_exists('imagecreatefromstring')) {
$this->postJson($url, $input)->assertStatus(423);
$this->assertDatabaseHas('opportunity_images', ['id' => $image->id, 'version' => 1]);
return;
}
$this->postJson($url, $input)->assertOk();
$payload = json_decode(DB::table('opportunity_images')->where('id', $image->id)->value('payload'), true);
$this->assertCount(2, $payload['derivatives']);
foreach ($payload['derivatives'] as $derivative) {
$document = DB::table('documents')->where('id', $derivative['document_id'])->first();
$this->assertSame('pending', $document->malware_scan_status);
$webp = Crypt::decryptString(Storage::disk('local')->get($document->storage_key));
$this->assertStringNotContainsString('EXIF', $webp);
$this->assertSame('image/webp', getimagesizefromstring($webp)['mime']);
(new ScanDocumentForMalware($document->id))->handle(new CleanScannerFixture);
}
$preview = URL::temporarySignedRoute('management.opportunity-image-preview', now()->addMinutes(10), ['id' => $o->id, 'resourceId' => $image->id, 'documentId' => $payload['derivatives'][0]['document_id']]);
$this->get($preview)->assertOk()->assertHeader('Content-Type', 'image/webp');
$this->actingAs(User::factory()->create())->get($preview)->assertForbidden();
}
}