Files
Investidor-V2/tests/Feature/OpportunityTechnicalIncidentsTest.php

259 lines
20 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\OpportunityManagement;
use App\Services\OpportunityTechnicalIncidents;
use App\Services\PortalProfileAccess;
use App\Services\PortalProfileAdministration;
use App\Services\ScopedPortalAdministration;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Testing\TestResponse;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
final class OpportunityTechnicalIncidentsTest extends TestCase
{
use RefreshDatabase;
private const REASON = 'SYNTHETIC technical incident preservation test';
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
Http::fake();
Mail::fake();
Storage::fake('local');
config(['portal.profile_access_enabled' => true, 'portal.opportunity_specialized_drafts_enabled' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
}
/** @param list<string> $responsibilities */
private function actor(string $role, string $org, array $responsibilities = []): User
{
$actor = User::factory()->create();
$actor->forceFill(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), ['user_id' => $actor->id, 'role' => $role, 'organization_id' => $role === 'promoter' ? $org : null, 'opportunity_types' => ['real_estate'], 'responsibilities' => $responsibilities, 'source' => 'synthetic-incident-fixture']);
return $actor;
}
private function loginFixture(User $actor): static
{
return $this->actingAs($actor)->withSession(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $actor->portal_access_revision]);
}
/** @param array<string,mixed> $extra */
private function act(User $actor, int $id, string $action, array $extra = []): TestResponse
{
$record = Opportunity::findOrFail($id);
return $this->loginFixture($actor)->postJson('/minhas-oportunidades/'.$id.'/workflow', ['etag' => app(OpportunityManagement::class)->etag($record), 'action' => $action, 'reason' => self::REASON, ...$extra]);
}
private function failAudit(string $action, bool $incidentToo = false): void
{
$source = $incidentToo ? "NEW.source_system = 'portal-opportunity-incident' OR " : '';
DB::unprepared("CREATE FUNCTION qa_incident_failure() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN IF ({$source}NEW.action = '{$action}') THEN RAISE EXCEPTION 'PRIVATE SYNTHETIC SQL TOKEN MUST NEVER REACH THE RESPONSE'; END IF; RETURN NEW; END \$\$; CREATE TRIGGER qa_incident_failure BEFORE INSERT ON audit_events FOR EACH ROW EXECUTE FUNCTION qa_incident_failure();");
}
public function test_failed_submission_rolls_back_and_records_only_minimal_private_metadata_then_allows_manual_recovery(): void
{
[$id,$promoter,$validator] = $this->fixture();
$before = Opportunity::findOrFail($id)->getRawOriginal();
$taskCount = DB::table('tasks')->count();
$this->failAudit('workflow_submit');
$response = $this->act($promoter, $id, 'submit')->assertStatus(503)->assertHeader('Cache-Control', 'no-store, private')->assertJsonPath('incident_recorded', true);
$reference = $response->json('reference');
$this->assertTrue(Str::isUuid($reference));
$this->assertStringNotContainsString('SQL TOKEN', $response->getContent());
$this->assertSame($before, Opportunity::findOrFail($id)->getRawOriginal());
$this->assertSame($taskCount, DB::table('tasks')->count());
$event = DB::table('audit_events')->where('id', $reference)->first();
$data = json_decode($event->after_data, true)['incident'];
$this->assertSame('restricted', $event->data_classification);
$this->assertSame($reference, $event->correlation_id);
$this->assertSame($id, $data['opportunity_id']);
$this->assertSame('submit', $data['operation']);
$this->assertSame('database_failure', $data['error_code']);
$this->assertArrayNotHasKey('reason', $data);
$this->assertArrayNotHasKey('exception', $data);
$this->assertArrayNotHasKey('payload', $data);
$this->get('/minhas-oportunidades/'.$id.'/view')->assertOk()->assertInertia(fn (Assert $page) => $page->has('workflowIncidents', 1)->where('workflowIncidents.0.reference', $reference)->missing('workflowIncidents.0.attempted_etag'));
$this->loginFixture($validator)->get('/minhas-oportunidades/'.$id.'/view')->assertOk()->assertInertia(fn (Assert $page) => $page->has('workflowIncidents', 0));
DB::unprepared('DROP TRIGGER qa_incident_failure ON audit_events; DROP FUNCTION qa_incident_failure();');
$this->act($promoter, $id, 'submit')->assertOk()->assertJsonPath('status', 'submitted');
$this->assertSame(1, DB::table('audit_events')->where('source_system', 'portal-opportunity-incident')->count());
$this->assertSame($taskCount + 1, DB::table('tasks')->count());
}
public function test_publication_failure_preserves_approved_content_public_projection_tasks_and_original_decisions(): void
{
[$id,$promoter,$validator,$publisher,$payload,$document] = $this->fixture();
$this->act($promoter, $id, 'submit')->assertOk();
$this->act($validator, $id, 'start_review')->assertOk();
foreach (['property_review', 'registry_urban_review', 'building_compliance_review'] as $gate) {
$this->act($validator, $id, 'review_gate', ['gate' => $gate, 'gate_decision' => 'approved', 'evidence_document_ids' => [$document]])->assertOk();
}
$this->act($validator, $id, 'approve')->assertOk();
$record = Opportunity::findOrFail($id);
$before = $record->getRawOriginal();
$tasks = DB::table('tasks')->orderBy('id')->get()->toJson();
$this->failAudit('workflow_publish');
$this->act($publisher, $id, 'publish', ['confirm_publication' => true, 'preview_hash' => $record->workflow_context['approval']['hash']])->assertStatus(503)->assertJsonPath('incident_recorded', true);
$this->assertSame($before, Opportunity::findOrFail($id)->getRawOriginal());
$this->assertSame($tasks, DB::table('tasks')->orderBy('id')->get()->toJson());
$this->assertSame('publish', app(OpportunityTechnicalIncidents::class)->recent($publisher, $record)[0]['operation']);
$this->assertSame([], app(OpportunityTechnicalIncidents::class)->recent($promoter, $record));
}
public function test_validation_conflicts_and_permission_failures_are_not_technical_incidents(): void
{
[$id,$promoter,$validator] = $this->fixture();
$this->act($promoter, $id, 'submit', ['reason' => 'short'])->assertUnprocessable();
$this->act($promoter, $id, 'submit', ['etag' => str_repeat('0', 64)])->assertConflict();
$this->act($validator, $id, 'submit')->assertForbidden();
$this->assertSame(0, DB::table('audit_events')->where('source_system', 'portal-opportunity-incident')->count());
}
public function test_a_second_storage_failure_returns_an_honest_reference_without_leaking_sql_or_claiming_a_saved_incident(): void
{
[$id,$promoter] = $this->fixture();
$before = Opportunity::findOrFail($id)->getRawOriginal();
$this->failAudit('workflow_submit', true);
Log::shouldReceive('warning')->once()->with('Opportunity technical incident could not be stored.', \Mockery::on(fn ($context) => Str::isUuid($context['reference']) && $context['error_code'] === 'database_failure'));
$response = $this->act($promoter, $id, 'submit')->assertStatus(503)->assertJsonPath('incident_recorded', false);
$this->assertStringNotContainsString('SQL TOKEN', $response->getContent());
$this->assertTrue(Str::isUuid($response->json('reference')));
$this->assertSame($before, Opportunity::findOrFail($id)->getRawOriginal());
$this->assertSame(0, DB::table('audit_events')->where('source_system', 'portal-opportunity-incident')->count());
}
public function test_incident_consultation_rechecks_current_authority_and_never_grants_other_record_or_document_access(): void
{
[$id,$promoter] = $this->fixture();
$this->failAudit('workflow_submit');
$this->act($promoter, $id, 'submit')->assertStatus(503);
$draft = $this->loginFixture($promoter)->postJson('/minhas-oportunidades/drafts', ['creation_key' => (string) Str::uuid(), 'organization_id' => Opportunity::findOrFail($id)->organization_id, 'opportunity_category' => 'real_estate', 'reason' => self::REASON])->assertCreated()->json('id');
$this->assertSame([], app(OpportunityTechnicalIncidents::class)->recent($promoter, Opportunity::findOrFail($draft)));
DB::table('portal_role_assignments')->where('user_id', $promoter->id)->update(['revoked_at' => now()]);
$this->get('/minhas-oportunidades/'.$id.'/view')->assertForbidden();
}
public function test_support_profile_receives_only_current_scope_metadata_without_operational_or_document_access(): void
{
[$id,$promoter] = $this->fixture();
$record = Opportunity::findOrFail($id);
$this->failAudit('workflow_submit');
$this->act($promoter, $id, 'submit')->assertStatus(503);
$support = $this->actor('technical_support', $record->organization_id);
DB::table('portal_role_assignments')->where('user_id', $support->id)->update(['organization_id' => $record->organization_id]);
$this->loginFixture($support)->get('/suporte-tecnico/incidentes')->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertInertia(fn (Assert $page) => $page->component('profiles/technical-support')->has('incidents.data', 1)->missing('incidents.data.0.attempted_etag')->missing('incidents.data.0.opportunity_id'));
$this->get('/minhas-oportunidades/'.$id.'/view')->assertForbidden();
$this->act($support, $id, 'publish')->assertForbidden();
$otherOrganization = (string) Str::uuid();
DB::table('organizations')->insert(['id' => $otherOrganization, 'name' => 'SYNTHETIC other organization', 'organization_type' => 'private']);
DB::table('portal_role_assignments')->where('user_id', $support->id)->update(['organization_id' => $otherOrganization]);
$this->get('/suporte-tecnico/incidentes')->assertOk()->assertInertia(fn (Assert $page) => $page->has('incidents.data', 0));
DB::table('portal_role_assignments')->where('user_id', $support->id)->update(['organization_id' => null]);
DB::table('opportunities')->where('id', $id)->update(['organization_id' => $otherOrganization]);
$this->get('/suporte-tecnico/incidentes')->assertOk()->assertInertia(fn (Assert $page) => $page->has('incidents.data', 0));
}
public function test_support_scope_revocation_expiry_and_auditor_restriction_are_effective(): void
{
[$id,$promoter] = $this->fixture();
$record = Opportunity::findOrFail($id);
$this->failAudit('workflow_submit');
$this->act($promoter, $id, 'submit')->assertStatus(503);
$support = $this->actor('technical_support', 'unused');
$this->loginFixture($support)->get('/suporte-tecnico/incidentes')->assertOk();
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), ['user_id' => $support->id, 'role' => 'auditor', 'opportunity_types' => ['real_estate']]);
$this->get('/suporte-tecnico/incidentes')->assertForbidden();
DB::table('portal_role_assignments')->where('user_id', $support->id)->where('role', 'auditor')->update(['revoked_at' => now()]);
$this->get('/suporte-tecnico/incidentes')->assertOk();
$this->travel(2)->hours();
DB::table('portal_role_assignments')->where('user_id', $support->id)->where('role', 'technical_support')->update(['expires_at' => now()->subHour()]);
$this->get('/suporte-tecnico/incidentes')->assertForbidden();
}
public function test_support_grant_uses_existing_independent_administration_and_is_audited(): void
{
$admin = User::factory()->create();
$second = User::factory()->create();
DB::table('portal_superuser_pair')->insert(['id' => 1, 'first_user_id' => $admin->id, 'second_user_id' => $second->id, 'approved_by' => $second->id, 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
$admin->forceFill(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
$subject = User::factory()->create();
$this->loginFixture($admin);
$input = ['user_id' => $subject->id, 'role' => 'technical_support', 'opportunity_types' => ['real_estate'], 'reason' => self::REASON];
$this->postJson('/perfis/atribuicoes', $input)->assertCreated();
$this->assertTrue(app(PortalProfileAccess::class)->has($subject, 'technical_support'));
$this->assertSame(1, DB::table('audit_events')->where('action', 'role_granted')->count());
$this->postJson('/perfis/atribuicoes', [...$input, 'user_id' => $admin->id])->assertForbidden();
$this->postJson('/perfis/atribuicoes', [...$input, 'responsibilities' => ['technical_reviewer']])->assertUnprocessable();
$this->assertFalse(app(PortalProfileAccess::class)->canCreate($subject, null, 'real_estate'));
$this->assertFalse(app(ScopedPortalAdministration::class)->administrative($subject));
}
/** @return array{int,User,User,User,array<string,mixed>,string} */
private function fixture(): array
{
$org = (string) Str::uuid();
DB::table('organizations')->insert(['id' => $org, 'name' => 'Synthetic QA organization', 'organization_type' => 'private']);
$promoter = $this->actor('promoter', $org);
DB::table('memberships')->insert(['id' => (string) Str::uuid(), 'user_id' => $promoter->id, 'organization_id' => $org, 'role' => 'officer']);
$validator = $this->actor('validator', $org, ['technical_reviewer', 'legal_reviewer', 'validation_coordinator']);
$publisher = $this->actor('publisher', $org, ['publication_coordinator']);
$version = (string) Str::uuid();
DB::table('reference_catalog_versions')->insert(['id' => $version, 'catalog_code' => 'qa-only-'.$org, 'version_no' => 1, 'source_system' => 'synthetic-policy-fixture', 'source_version' => 'qa', 'source_reference' => 'Not official', 'status' => 'published']);
$ids = [];
$parent = null;
foreach (range(1, 3) as $i) {
$sector = DB::table('sectors')->insertGetId(['name' => 'QA '.$i, 'slug' => 'qa-'.$org.'-'.$i, 'is_active' => true]);
$id = (string) Str::uuid();
DB::table('economic_activities')->insert(['id' => $id, 'parent_id' => $parent, 'sector_id' => $sector, 'catalog_version_id' => $version, 'code' => 'qa-'.$i, 'label_pt_mz' => 'QA '.$i, 'status' => 'published', 'valid_from' => today()]);
$ids[] = [$id, $sector];
$parent = $id;
}
$contact = (string) Str::uuid();
$investor = (string) Str::uuid();
DB::table('contacts')->insert(['id' => $contact, 'full_name' => 'Synthetic QA contact']);
DB::table('investors')->insert(['id' => $investor, 'organization_id' => $org, 'legal_name' => 'Synthetic QA entity', 'investor_type' => 'institutional', 'status' => 'active']);
DB::table('investor_contacts')->insert(['investor_id' => $investor, 'contact_id' => $contact]);
$draft = $this->loginFixture($promoter)->postJson('/minhas-oportunidades/drafts', ['creation_key' => (string) Str::uuid(), 'organization_id' => $org, 'opportunity_category' => 'real_estate', 'reason' => self::REASON])->assertCreated()->json();
$id = $draft['id'];
$location = (string) Str::uuid();
$provinceId = (string) Str::uuid();
$districtId = (string) Str::uuid();
DB::table('geo_locations')->insert([['id' => $provinceId, 'code' => $provinceId, 'name' => 'Synthetic QA province', 'location_type' => 'province', 'parent_id' => null, 'data_classification' => 'internal'], ['id' => $districtId, 'code' => $districtId, 'name' => 'Synthetic QA district', 'location_type' => 'district', 'parent_id' => $provinceId, 'data_classification' => 'internal']]);
$province = DB::table('geo_locations')->where('id', $provinceId)->first();
$district = DB::table('geo_locations')->where('id', $districtId)->first();
DB::table('opportunity_location_drafts')->insert(['id' => $location, 'opportunity_id' => $id, 'version' => 1, 'created_by' => $promoter->id, 'payload' => json_encode(['country_code' => 'MZ', 'province_id' => $province->id, 'district_id' => $district->id, 'role' => 'headquarters', 'is_primary' => true])]);
$document = (string) Str::uuid();
$file = (string) Str::uuid();
$bytes = 'Synthetic policy fixture, not a real scanner result.';
$key = 'opportunity-contract-test/'.$file;
Storage::disk('local')->put($key, Crypt::encryptString($bytes));
DB::table('documents')->insert(['id' => $file, 'file_name' => 'qa-fixture.txt', 'storage_key' => $key, 'storage_encryption' => 'laravel', 'media_type' => 'text/plain', 'sha256' => hash('sha256', $bytes), 'status' => 'active', 'malware_scan_status' => 'clean', 'malware_engine' => 'synthetic-policy-fixture', 'malware_scanned_sha256' => hash('sha256', $bytes), 'uploaded_by' => $promoter->id]);
DB::table('opportunity_documents')->insert(['id' => $document, 'opportunity_id' => $id, 'document_id' => $file, 'version' => 1, 'created_by' => $promoter->id, 'payload' => json_encode(['title' => 'QA', 'category' => 'qa', 'visibility' => 'internal'])]);
$payload = ['opportunity_category' => 'real_estate', 'official_title' => 'Imóvel sintético para ensaio QA', 'promoter_id' => $org, 'promoter_type' => 'private', 'internal_contact_id' => $contact, 'sector_id' => $ids[0][1], 'subsector_id' => $ids[1][0], 'economic_activity_id' => $ids[2][0], 'location_mode' => 'single', 'geographic_scope' => 'district', 'locations' => [$location], 'executive_summary' => trim(str_repeat('Resumo sintético QA. ', 20)), 'demand_statement' => trim(str_repeat('Necessidade sintética QA. ', 6)), 'documents' => [$document], 'evidences' => [['title' => 'QA', 'evidence_type' => 'qa', 'document_id' => $document]], 'publication_languages' => [['language_code' => 'pt-MZ', 'title' => 'QA', 'summary' => 'QA']], 'visibility' => 'internal', 'currency' => 'MZN', 'truth_declaration' => true, 'real_estate' => ['transaction_type' => 'sale', 'property_type' => 'warehouse', 'construction_status' => 'completed', 'property_condition' => 'good', 'land_area' => 1000, 'built_area' => 500, 'area_unit' => 'sqm', 'utilities' => [['type' => 'water', 'status' => 'existing']], 'occupancy_status' => 'vacant', 'title_type' => 'QA', 'registration_number' => 'QA-'.$id, 'encumbrances' => [['type' => 'none']], 'valuation' => ['value' => 1000, 'date' => '2026-10-10', 'method' => 'qa', 'appraiser' => 'QA', 'document_id' => $document], 'price' => 1000, 'use_license_document_id' => $document]];
$authority = (string) Str::uuid();
DB::table('authorities')->insert(['id' => $authority, 'catalog_version_id' => $version, 'organization_id' => $org, 'code' => 'QA-'.$id, 'authority_type' => 'qa-synthetic', 'label_pt_mz' => 'Entidade sintética QA', 'source_reference' => 'Synthetic policy fixture; not official', 'valid_from' => '2026-01-01', 'status' => 'published']);
$payload['real_estate']['utilities'] = array_map(fn ($type) => ['type' => $type, 'status' => 'existing'], ['electricity', 'water', 'sanitation', 'telecommunications', 'access']);
$payload['real_estate'] += ['title_document_id' => $document, 'title_holder_name' => 'Synthetic QA organization', 'issuing_authority_id' => $authority];
$this->loginFixture($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => $draft['etag'], 'canonical' => $payload, 'reason' => self::REASON])->assertOk();
return [$id, $promoter, $validator, $publisher, $payload, $document];
}
}