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'); if (! is_int($id)) { throw new \RuntimeException('O cadastro QA deve devolver um ID inteiro de oportunidade.'); } return [$user, Opportunity::findOrFail($id)]; } /** @param array $payload * @return array */ 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->get('/management/opportunities/'.$o->id.'/resources/documents/'.$r->id.'/download')->assertNotFound(); $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_foreign_country_rejects_mozambican_hierarchy_without_changing_the_saved_draft(): void { [$user, $o] = $this->setupDraft(); $province = (string) Str::uuid(); $district = (string) Str::uuid(); DB::table('geo_locations')->insert([ ['id' => $province, 'code' => $province, 'name' => 'Synthetic QA Mozambique province', 'location_type' => 'province', 'parent_id' => null, 'data_classification' => 'public'], ['id' => $district, 'code' => $district, 'name' => 'Synthetic QA Mozambique district', 'location_type' => 'district', 'parent_id' => $province, 'data_classification' => 'public'], ]); $payload = ['title' => 'Synthetic QA saved location', 'country_code' => 'MZ', 'province_id' => $province, 'district_id' => $district, 'role' => 'market']; $id = $this->postJson('/management/opportunities/'.$o->id.'/resources/locations', $this->payload($o, $payload))->assertCreated()->json('id'); $before = DB::table('opportunity_location_drafts')->where('id', $id)->first(); $auditBefore = DB::table('audit_events')->where('source_system', 'opportunity-resources')->count(); $input = [...$this->payload($o->fresh(), [...$payload, 'country_code' => 'PT']), 'version' => 1]; $this->postJson('/management/opportunities/'.$o->id.'/resources/locations/'.$id, $input) ->assertUnprocessable()->assertJsonValidationErrors('country_code'); $after = DB::table('opportunity_location_drafts')->where('id', $id)->first(); $this->assertSame($before->payload, $after->payload); $this->assertSame(1, $after->version); $this->assertSame($auditBefore, DB::table('audit_events')->where('source_system', 'opportunity-resources')->count()); $foreign = $this->postJson('/management/opportunities/'.$o->id.'/resources/locations', $this->payload($o->fresh(), ['title' => 'Synthetic QA foreign incomplete location', 'country_code' => 'PT', 'role' => 'market']))->assertCreated()->json('id'); $saved = json_decode(DB::table('opportunity_location_drafts')->where('id', $foreign)->value('payload'), true); $this->assertSame('PT', $saved['country_code']); $this->assertEmpty($saved['province_id'] ?? null); $this->assertSame(0, DB::table('opportunity_locations')->count()); $this->get('/management/opportunities/'.$o->id.'/resources')->assertInertia(fn (AssertableInertia $page) => $page ->where('assessment', fn ($issues) => collect($issues)->every(fn ($issue) => ! str_contains($issue, 'província') && ! str_contains($issue, 'distrito')))); } 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); $size = getimagesizefromstring($webp); if ($size === false) { throw new \RuntimeException('Derivada QA não contém uma imagem válida.'); } $this->assertSame('image/webp', $size['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(); } }