withoutVite(); Storage::fake('local'); Queue::fake(); Mail::fake(); config(['portal.profile_access_enabled' => true, 'editorial_workflow.enabled' => true, 'private_offers.nda' => null]); } private function actor(?string $role = null, string $category = 'business_sale'): User { $actor = User::factory()->create(['two_factor_secret' => Crypt::encryptString('SYNTHETIC-QA'), 'two_factor_confirmed_at' => now()]); if ($role) { app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), [ 'user_id' => $actor->id, 'role' => $role, 'organization_id' => null, 'responsibilities' => [], 'opportunity_types' => [$category], 'source' => 'synthetic-test-fixture']); } return $actor; } private function login(User $actor): static { $actor = $actor->fresh(); return $this->actingAs($actor)->withSession(['login.id' => null, 'portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $actor->fresh()->portal_access_revision]); } private function draft(User $owner, string $category = 'business_sale', array $details = []): Opportunity { $contact = (string) Str::uuid(); DB::table('contacts')->insert(['id' => $contact, 'full_name' => 'PRIVATE-CONTACT-CANARY', 'source_system' => 'portal-user-contact', 'external_id' => 'user:'.$owner->id]); $result = $this->login($owner)->postJson(self::BASE.'/rascunhos', ['creation_key' => (string) Str::uuid(), 'title' => 'Synthetic QA private offer', 'description' => 'PRIVATE-DESCRIPTION-CANARY', 'opportunity_category' => $category, 'private_offer' => ['price_mode' => 'amount', 'amount' => '100.01', 'currency' => 'MZN', 'contact_id' => $contact, ...$details], 'reason' => self::REASON])->assertCreated(); return Opportunity::findOrFail($result->json('record.id')); } private function etag(Opportunity $record): string { return app(OpportunityManagement::class)->etag($record->fresh()); } private function approve(User $owner, Opportunity $record): void { $this->login($owner)->postJson(self::BASE.'/'.$record->id.'/submeter', ['etag' => $this->etag($record), 'confirm' => true])->assertOk(); $reviewer = $this->actor('validator', $record->opportunity_category); foreach (['start_review', 'approve'] as $action) { $this->login($reviewer)->postJson(self::BASE.'/'.$record->id.'/revisao', ['etag' => $this->etag($record), 'action' => $action, 'reason' => self::REASON])->assertOk(); } } private function nda(): array { // Deliberately non-legal bytes, never registered as an operational NDA. $bytes = "%PDF-1.7\nSYNTHETIC QA - NOT A LEGAL AGREEMENT\n%%EOF"; $id = (string) Str::uuid(); $sha = hash('sha256', $bytes); Storage::disk('local')->put('qa-nda/'.$id, Crypt::encryptString($bytes)); DB::table('documents')->insert(['id' => $id, 'file_name' => 'synthetic-qa.pdf', 'media_type' => 'application/pdf', 'storage_key' => 'qa-nda/'.$id, 'storage_encryption' => 'laravel', 'sha256' => $sha, 'status' => 'active', 'malware_scan_status' => 'clean', 'malware_scanned_sha256' => $sha, 'malware_engine' => 'QA-Test-Double', 'version_no' => 1, 'data_classification' => 'restricted']); config(['private_offers.nda' => ['document_id' => $id, 'version' => 1, 'sha256' => $sha, 'approval_reference' => 'SYNTHETIC-QA-NOT-LEGAL']]); return app(PrivateOfferConfidentiality::class)->nda(); } private function command(Opportunity $offer, array $nda, string $action, array $extra = []): array { return ['action' => $action, 'version_hash' => $offer->fresh()->workflow_context['approval']['hash'], 'nda_sha256' => $nda['sha256'], 'nda_fingerprint' => $nda['fingerprint'], 'confirm' => true, 'reason' => self::REASON, ...$extra]; } public function test_confirmed_specific_fields_save_recover_and_bind_the_editorial_version(): void { $owner = $this->actor('promoter'); $record = $this->draft($owner, details: ['business' => ['revenue' => '10000000000000.01', 'cash_flow' => '-123.45', 'currency' => 'USD', 'reason' => 'PRIVATE-FINANCIAL-CANARY']]); $this->login($owner)->getJson(self::BASE.'/'.$record->id)->assertOk()->assertJsonPath('record.values.private_offer.business.cash_flow', '-123.45'); $this->approve($owner, $record); $this->assertSame('private-offer-editorial-v3', $record->fresh()->workflow_context['submission']['contract_version']); $this->assertSame('-123.45', $record->fresh()->workflow_context['submission']['payload']['private_offer']['business']['cash_flow']); $this->login($owner)->postJson(self::BASE.'/'.$record->id.'/guardar', ['etag' => $this->etag($record), 'private_offer' => ['business' => ['reason' => 'Changed']], 'reason' => self::REASON])->assertForbidden(); } public function test_property_fields_are_optional_and_categories_and_unknown_fields_are_enforced(): void { $owner = $this->actor('promoter', 'real_estate'); $record = $this->draft($owner, 'real_estate', ['property' => ['transaction_type' => 'lease', 'property_type' => 'house', 'built_area' => '120.50', 'area_unit' => 'sqm', 'bedrooms' => 3, 'bathrooms' => 2, 'amenities' => ['Água', 'Acesso']]]); $this->login($owner)->getJson(self::BASE.'/'.$record->id)->assertOk()->assertJsonPath('record.values.private_offer.property.bedrooms', 3); foreach ([['business' => ['revenue' => '5', 'currency' => 'USD']], ['property' => ['title_certified' => true]], ['property' => ['property_type' => 'invented']]] as $invalid) { $this->postJson(self::BASE.'/'.$record->id.'/guardar', ['etag' => $this->etag($record), 'private_offer' => $invalid, 'reason' => self::REASON])->assertUnprocessable(); } $this->assertSame(3, app(OpportunityManagement::class)->values($record->fresh())['private_offer']['property']['bedrooms']); } public function test_six_photos_limit_includes_restoration_and_alternate_routes_and_cleans_failed_uploads(): void { $owner = $this->actor('promoter'); $record = $this->draft($owner); $ids = []; for ($index = 0; $index < 6; $index++) { $response = $this->upload($owner, $record, 'images', $this->png('qa-'.$index.'.png', 400 + $index, 400)); $ids[] = $response->assertCreated()->json('id'); } $this->upload($owner, $record, 'images', $this->png('seventh.png', 420, 420))->assertUnprocessable(); $this->assertDatabaseCount('opportunity_images', 6); $this->assertCount(6, Storage::disk('local')->allFiles('opportunity-resources')); $life = ['etag' => $this->etag($record), 'version' => 1, 'confirm' => true, 'action' => 'delete', 'reason' => self::REASON]; $this->postJson(self::BASE.'/'.$record->id.'/recursos/images/'.$ids[0], $life)->assertOk(); $this->upload($owner, $record, 'images', $this->png('new.png', 430, 430))->assertCreated(); $restore = [...$life, 'etag' => $this->etag($record), 'version' => 2, 'action' => 'restore']; $this->postJson(self::BASE.'/'.$record->id.'/recursos/images/'.$ids[0], $restore)->assertUnprocessable(); // The accepted F1 guard closes the native promoter route for private offers. $this->postJson('/minhas-oportunidades/'.$record->id.'/resources/images/'.$ids[0].'/lifecycle', $restore)->assertForbidden(); $this->assertSame(6, DB::table('opportunity_images')->whereNull('deleted_at')->count()); } private function upload(User $owner, Opportunity $record, string $kind, UploadedFile $file, array $override = []): TestResponse { $payload = $kind === 'images' ? ['visibility' => 'internal', 'caption' => 'Synthetic QA', 'digitally_edited' => false] : ['visibility' => 'internal', 'classification' => 'restricted', 'category' => 'real_estate_proof']; return $this->login($owner)->post(self::BASE.'/'.$record->id.'/recursos/'.$kind, ['etag' => $this->etag($record), 'creation_key' => (string) Str::uuid(), 'reason' => self::REASON, 'payload' => json_encode([...$payload, ...$override]), 'file' => $file], ['Accept' => 'application/json']); } /** Valid synthetic PNG bytes, independent of the optional GD extension. */ private function png(string $name, int $width, int $height): UploadedFile { $chunk = static fn (string $type, string $bytes): string => pack('N', strlen($bytes)).$type.$bytes.pack('N', crc32($type.$bytes)); $pixels = gzcompress(str_repeat("\0".str_repeat("\0", $width * 3), $height)); if ($pixels === false) { throw new \RuntimeException('Synthetic PNG compression failed.'); } return UploadedFile::fake()->createWithContent($name, "\x89PNG\r\n\x1a\n".$chunk('IHDR', pack('NNCCCCC', $width, $height, 8, 2, 0, 0, 0)).$chunk('IDAT', $pixels).$chunk('IEND', '')); } public function test_upload_validation_and_quarantine_do_not_grant_publication_or_read_access(): void { $owner = $this->actor('promoter'); $record = $this->draft($owner); $this->upload($owner, $record, 'images', UploadedFile::fake()->createWithContent('fake.jpg', 'assertUnprocessable(); $this->upload($owner, $record, 'images', $this->png('small.png', 20, 20))->assertUnprocessable(); $this->upload($owner, $record, 'images', $this->png('edited.png', 400, 400), ['digitally_edited' => true])->assertUnprocessable(); $this->upload($owner, $record, 'images', $this->png('public.png', 400, 400), ['visibility' => 'public'])->assertUnprocessable(); $id = $this->upload($owner, $record, 'images', $this->png('good.png', 400, 400))->assertCreated()->json('id'); $this->getJson(self::BASE.'/'.$record->id.'/recursos/images/'.$id)->assertNotFound(); $this->postJson(self::BASE.'/'.$record->id.'/submeter', ['etag' => $this->etag($record), 'confirm' => true])->assertUnprocessable(); $this->assertSame('draft', $record->fresh()->status); } public function test_proof_bytes_are_encrypted_and_only_a_scoped_internal_validator_can_read_them(): void { $owner = $this->actor('promoter', 'real_estate'); $record = $this->draft($owner, 'real_estate'); $bytes = "%PDF-1.7\nSYNTHETIC QA PROPERTY PROOF - NOT A TITLE\n%%EOF"; $resourceId = $this->upload($owner, $record, 'documents', UploadedFile::fake()->createWithContent('qa.pdf', $bytes))->assertCreated()->json('id'); $link = DB::table('opportunity_documents')->where('id', $resourceId)->first(); $doc = DB::table('documents')->where('id', $link->document_id)->first(); $this->assertNotSame($bytes, Storage::disk('local')->get($doc->storage_key)); (new ScanDocumentForMalware($doc->id))->handle(new CleanScannerFixture); $url = self::BASE.'/'.$record->id.'/recursos/documents/'.$resourceId; $this->login($owner)->get($url)->assertForbidden(); $this->get('/minhas-oportunidades/'.$record->id.'/resources/documents/'.$resourceId.'/download')->assertForbidden(); $reviewer = $this->actor('validator', 'real_estate'); $this->login($reviewer)->get($url)->assertOk()->assertContent($bytes)->assertHeader('Cache-Control', 'no-store, private'); $this->login($this->actor('validator', 'business_sale'))->get($url)->assertForbidden(); $this->login($this->actor('auditor', 'real_estate'))->get($url)->assertForbidden(); $this->login($reviewer)->withSession(['portal.mfa_at' => time() - 1000])->get($url)->assertForbidden(); $this->assertGreaterThan(0, DB::table('audit_events')->where('action', 'offer_proof_access_authorized')->count()); $this->assertGreaterThan(0, DB::table('audit_events')->where('action', 'offer_proof_access_denied')->count()); } public function test_nda_absence_keeps_access_closed_and_acceptance_alone_never_grants_access(): void { $owner = $this->actor('promoter'); $record = $this->draft($owner); $this->approve($owner, $record); $interested = $this->actor(); $this->login($interested)->getJson(self::BASE.'/'.$record->id.'/confidencialidade')->assertOk()->assertJsonPath('nda_pending', true)->assertJsonPath('requests', []); $this->getJson(self::BASE.'/'.$record->id.'/confidencial')->assertForbidden(); $this->get(self::BASE.'/'.$record->id.'/nda')->assertConflict(); $this->postJson(self::BASE.'/'.$record->id.'/confidencialidade', $this->command($record, ['sha256' => str_repeat('0', 64), 'fingerprint' => str_repeat('0', 64)], 'accept'))->assertConflict(); $nda = $this->nda(); $command = $this->command($record, $nda, 'accept'); unset($command['reason']); // Accepting an identified NDA does not require invented justification. $this->postJson(self::BASE.'/'.$record->id.'/confidencialidade', $command)->assertOk(); $this->getJson(self::BASE.'/'.$record->id.'/confidencial')->assertForbidden(); $this->login($owner)->postJson(self::BASE.'/'.$record->id.'/confidencialidade', $this->command($record, $nda, 'authorize', ['interested_user_id' => $interested->id, 'acceptance_id' => (string) Str::uuid()]))->assertConflict(); } public function test_both_conditions_exact_versions_revocation_and_audit_are_required_on_every_read(): void { $owner = $this->actor('promoter'); $record = $this->draft($owner, details: ['business' => ['revenue' => '789.01', 'currency' => 'MZN', 'reason' => 'PRIVATE-FINANCIAL-CANARY']]); $this->approve($owner, $record); $nda = $this->nda(); $interested = $this->actor(); $url = self::BASE.'/'.$record->id; $this->login($interested)->postJson($url.'/confidencialidade', $this->command($record, $nda, 'accept'))->assertOk(); $accept = $this->getJson($url.'/confidencialidade')->json('acceptance'); $grant = $this->command($record, $nda, 'authorize', ['interested_user_id' => $interested->id, 'acceptance_id' => $accept['id']]); $this->login($this->actor('validator'))->postJson($url.'/confidencialidade', $grant)->assertForbidden(); $this->login($owner)->postJson($url.'/confidencialidade', $grant)->assertOk(); $data = $this->login($interested)->getJson($url.'/confidencial')->assertOk()->assertJsonPath('business.revenue', '789.01')->getContent(); $this->assertStringNotContainsString('PRIVATE-CONTACT-CANARY', $data); $this->assertStringNotContainsString('PRIVATE-DESCRIPTION-CANARY', $data); // NDA permission is a bounded read, not permission to copy the private // Opportunity into another investment context or its downstream reports. try { app(InvestorContext::class)->reference('opportunity:'.$record->id); $this->fail('An NDA grant must not grant private investment-context access.'); } catch (HttpException $error) { $this->assertSame(404, $error->getStatusCode()); } $this->login($this->actor())->getJson($url.'/confidencial')->assertForbidden(); $this->login($owner)->postJson($url.'/confidencialidade', $this->command($record, $nda, 'revoke', ['interested_user_id' => $interested->id]))->assertOk(); $this->login($interested)->getJson($url.'/confidencial')->assertForbidden(); $this->assertSame(1, DB::table('audit_events')->where('action', 'offer_confidential_access')->count()); $this->assertSame(1, DB::table('audit_events')->where('action', 'offer_confidentiality_revoke')->count()); $this->assertNull($record->fresh()->published_at); } public function test_nda_version_changes_withdrawal_session_revocation_and_private_revision_close_access(): void { $owner = $this->actor('promoter'); $record = $this->draft($owner, details: ['business' => ['revenue' => '900.00', 'currency' => 'USD', 'reason' => 'PRIVATE-VERSION-CANARY']]); $this->approve($owner, $record); $nda = $this->nda(); $interested = $this->actor(); $url = self::BASE.'/'.$record->id; $authorize = function (array $currentNda) use ($owner, $record, $interested, $url) { $this->login($interested)->postJson($url.'/confidencialidade', $this->command($record, $currentNda, 'accept'))->assertOk(); $acceptance = $this->getJson($url.'/confidencialidade')->json('acceptance'); $this->login($owner)->postJson($url.'/confidencialidade', $this->command($record, $currentNda, 'authorize', ['interested_user_id' => $interested->id, 'acceptance_id' => $acceptance['id']]))->assertOk(); $this->login($interested)->getJson($url.'/confidencial')->assertOk(); }; $authorize($nda); // Identical document bytes in another approved version do not reuse the acceptance. DB::table('documents')->where('id', $nda['document_id'])->update(['version_no' => 2]); config(['private_offers.nda.version' => 2]); $this->getJson($url.'/confidencial')->assertForbidden(); $this->postJson($url.'/confidencialidade', $this->command($record, $nda, 'accept'))->assertConflict(); $nda = app(PrivateOfferConfidentiality::class)->nda(); $authorize($nda); $this->postJson($url.'/confidencialidade', $this->command($record, $nda, 'withdraw'))->assertOk(); $this->getJson($url.'/confidencial')->assertForbidden(); $authorize($nda); DB::table('users')->where('id', $interested->id)->increment('portal_access_revision'); $this->login($interested->fresh())->getJson($url.'/confidencial')->assertForbidden(); $authorize($nda); $withdraw = $this->command($record, $nda, 'withdraw'); $this->login($owner)->postJson($url.'/revisao', ['etag' => $this->etag($record), 'action' => 'request_revision', 'reason' => self::REASON])->assertOk(); $this->login($interested)->getJson($url.'/confidencial')->assertConflict(); $this->getJson($url.'/confidencialidade')->assertOk()->assertJsonPath('can_accept', false)->assertJsonPath('etag', null); $this->postJson($url.'/confidencialidade', $withdraw)->assertOk(); $this->getJson('/api/v1/offers')->assertOk()->assertJsonPath('results.total', 0); $this->getJson('/api/v1/offers/'.$record->id)->assertNotFound(); $this->assertStringNotContainsString('PRIVATE-VERSION-CANARY', $this->get('/api/v1/offers/export')->assertOk()->streamedContent()); $this->getJson('/api/v1/offers/map')->assertOk()->assertJsonPath('features', []); $this->assertSame(2, DB::table('audit_events')->where('action', 'offer_confidentiality_withdraw')->count()); } public function test_nda_integrity_and_authority_loss_are_rechecked_without_exposing_the_company(): void { $owner = $this->actor('promoter'); $record = $this->draft($owner); $this->approve($owner, $record); $interested = $this->actor(); $nda = $this->nda(); $url = self::BASE.'/'.$record->id; $this->login($interested)->get($url.'/nda')->assertOk()->assertSee('SYNTHETIC QA - NOT A LEGAL AGREEMENT'); $this->postJson($url.'/confidencialidade', $this->command($record, $nda, 'accept'))->assertOk(); $accept = $this->getJson($url.'/confidencialidade')->json('acceptance'); $this->login($owner)->postJson($url.'/confidencialidade', $this->command($record, $nda, 'authorize', ['interested_user_id' => $interested->id, 'acceptance_id' => $accept['id']]))->assertOk(); $this->login($interested)->getJson($url.'/confidencial')->assertOk(); DB::table('portal_role_assignments')->where('user_id', $owner->id)->where('role', 'promoter')->update(['revoked_at' => now()]); $this->getJson($url.'/confidencial')->assertForbidden(); // Restore this synthetic fixture only, to isolate the byte-integrity check below. DB::table('portal_role_assignments')->where('user_id', $owner->id)->where('role', 'promoter')->update(['revoked_at' => null]); $doc = DB::table('documents')->where('id', $nda['document_id'])->first(); Storage::disk('local')->put($doc->storage_key, Crypt::encryptString('TAMPERED SYNTHETIC BYTES')); $this->getJson($url.'/confidencial')->assertForbidden(); $this->getJson($url.'/confidencialidade')->assertOk()->assertJsonPath('nda_pending', true); $this->assertNull($record->fresh()->published_at); } public function test_compiled_confidential_page_has_no_company_data_before_both_conditions(): void { $owner = $this->actor('promoter'); $record = $this->draft($owner, details: ['business' => ['reason' => 'PRIVATE-HTML-CANARY']]); $this->approve($owner, $record); $directory = getenv('EDITORIAL_QA_BUILD_DIRECTORY') ?: 'build'; $manifest = json_decode(file_get_contents(public_path($directory.'/manifest.json')), true, 512, JSON_THROW_ON_ERROR); $this->app->instance(Vite::class, (new Vite)->useBuildDirectory($directory)->useHotFile(storage_path('phpunit-offers-no-hot'))); $entry = 'resources/js/pages/offers/confidentiality.tsx'; $this->assertArrayHasKey($entry, $manifest); $this->login($this->actor())->get(self::BASE.'/'.$record->id.'/confidencialidade', ['Accept' => 'text/html'])->assertOk() ->assertHeader('Cache-Control', 'no-store, private')->assertSee($manifest[$entry]['file'], false)->assertDontSee('PRIVATE-HTML-CANARY')->assertDontSee('PRIVATE-CONTACT-CANARY'); } public function test_photo_metadata_and_original_are_bound_to_the_editorial_fingerprint(): void { $owner = $this->actor('promoter'); $record = $this->draft($owner); $resourceId = $this->upload($owner, $record, 'images', $this->png('metadata.png', 400, 400))->assertCreated()->json('id'); $resource = DB::table('opportunity_images')->where('id', $resourceId)->first(); (new ScanDocumentForMalware($resource->document_id))->handle(new CleanScannerFixture); $before = app(PrivateOfferPreparation::class)->snapshot($record->fresh()); $this->postJson(self::BASE.'/'.$record->id.'/recursos/images/'.$resourceId.'/guardar', ['etag' => $this->etag($record), 'version' => 1, 'payload' => ['visibility' => 'internal', 'caption' => 'Changed synthetic caption', 'digitally_edited' => true, 'edit_description' => 'Synthetic QA declared edit'], 'reason' => self::REASON])->assertOk(); $after = app(PrivateOfferPreparation::class)->snapshot($record->fresh()); $this->assertNotSame($before['hash'], $after['hash']); $this->assertSame(1, $before['resources']['images'][$resourceId]['version']); $this->assertSame(2, $after['resources']['images'][$resourceId]['version']); $this->assertSame($resource->document_id, $after['resources']['images'][$resourceId]['document_id']); $this->assertTrue($after['resources']['images'][$resourceId]['payload']['digitally_edited']); $this->approve($owner, $record); DB::table('documents')->where('id', $resource->document_id)->update(['status' => 'quarantined', 'malware_scan_status' => 'pending']); $this->login($owner)->getJson(self::BASE.'/'.$record->id)->assertOk()->assertJsonPath('record.approvalFresh', false); $this->postJson(self::BASE.'/'.$record->id.'/revisao', ['etag' => $this->etag($record), 'action' => 'request_revision', 'reason' => self::REASON])->assertOk()->assertJsonPath('record.state', 'needs_information')->assertJsonPath('record.canEdit', true); } }