Files
Investidor-V2/tests/Feature/PrivateOfferConfidentialityTest.php
T

359 lines
24 KiB
PHP

<?php
namespace Tests\Feature;
use App\Jobs\ScanDocumentForMalware;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\InvestorContext;
use App\Services\OpportunityManagement;
use App\Services\PortalProfileAdministration;
use App\Services\PrivateOfferConfidentiality;
use App\Services\PrivateOfferPreparation;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Vite;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Testing\TestResponse;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\Support\CleanScannerFixture;
use Tests\TestCase;
final class PrivateOfferConfidentialityTest extends TestCase
{
use RefreshDatabase;
private const BASE = '/minhas-oportunidades/ofertas';
private const REASON = 'Operação exclusivamente sintética na base QA isolada.';
protected function setUp(): void
{
parent::setUp();
$this->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', '<?php echo 1;'))->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);
}
}