Files
Investidor-V2/tests/Feature/PortalProfileAccessTest.php
T
nala 01405cbf8a
tests / ci (push) Canceled after 0s
oportunidades 5
2026-10-10 14:15:28 +02:00

264 lines
18 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\OpportunityManagement;
use App\Services\PortalProfileAccess;
use App\Services\PortalProfileAdministration;
use App\Services\PortalSuperusers;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class PortalProfileAccessTest extends TestCase
{
use RefreshDatabase;
private const REASON = 'Verificação sintética de permissões na base QA isolada';
protected function setUp(): void
{
parent::setUp();
config(['portal.profile_access_enabled' => true]);
}
private function actor(): User
{
$user = User::factory()->create();
$user->forceFill(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
return $user;
}
private function organization(User ...$users): string
{
$id = (string) Str::uuid();
DB::table('organizations')->insert(['id' => $id, 'name' => 'QA synthetic organization', 'organization_type' => 'private']);
foreach ($users as $user) {
DB::table('memberships')->insert(['id' => (string) Str::uuid(), 'user_id' => $user->id, 'organization_id' => $id, 'role' => 'officer']);
}
return $id;
}
private function grant(User $user, string $role, ?string $organization = null, array $types = ['investment_project']): string
{
$id = (string) Str::uuid();
app(PortalProfileAdministration::class)->insertGrant($id, $this->actor(), ['user_id' => $user->id, 'role' => $role, 'organization_id' => $organization,
'opportunity_types' => $types, 'source' => 'synthetic-test-fixture']);
return $id;
}
private function mfa(User $user): static
{
return $this->actingAs($user)->withSession(['portal.mfa_actor' => $user->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $user->fresh()->portal_access_revision]);
}
private function draft(User $user, ?string $organization = null): array
{
return $this->actingAs($user)->postJson('/minhas-oportunidades/drafts', ['creation_key' => (string) Str::uuid(), 'organization_id' => $organization,
'opportunity_category' => 'investment_project', 'title' => 'QA private opportunity', 'reason' => self::REASON])->assertCreated()->json();
}
public function test_public_profile_requests_are_owned_idempotent_and_not_self_activating(): void
{
$user = User::factory()->create();
$other = User::factory()->create();
$this->actingAs($user)->get('/dashboard')->assertInertia(fn (Assert $p) => $p->where('profileAccess.mine', null)->where('profileAccess.request', '/perfil/promotor'));
$this->postJson('/minhas-oportunidades/drafts', ['creation_key' => (string) Str::uuid(), 'opportunity_category' => 'investment_project'])->assertForbidden();
$doc = (string) Str::uuid();
// This is an authorization fixture, not evidence of a real antimalware scan.
DB::table('documents')->insert(['id' => $doc, 'file_name' => 'synthetic.txt', 'storage_key' => 'synthetic/'.$doc, 'media_type' => 'text/plain',
'sha256' => str_repeat('a', 64), 'malware_scanned_sha256' => str_repeat('a', 64), 'status' => 'active', 'malware_scan_status' => 'clean', 'uploaded_by' => $user->id]);
$payload = ['creation_key' => (string) Str::uuid(), 'representation_type' => 'individual', 'opportunity_types' => ['investment_project'], 'document_ids' => [$doc], 'reason' => self::REASON];
$id = $this->postJson('/perfil/promotor', $payload)->assertCreated()->json('id');
$this->postJson('/perfil/promotor', $payload)->assertCreated()->assertJsonPath('id', $id);
$this->postJson('/perfil/promotor', [...$payload, 'reason' => self::REASON.' changed'])->assertConflict();
$this->getJson('/perfil/promotor/'.$id)->assertOk()->assertJsonPath('status', 'pending');
$this->assertFalse(app(PortalProfileAccess::class)->has($user, 'promoter'));
$this->getJson('/minhas-oportunidades/territory-options?type=province')->assertForbidden();
$this->actingAs($other)->getJson('/perfil/promotor/'.$id)->assertForbidden();
$this->postJson('/perfil/promotor', [...$payload, 'creation_key' => (string) Str::uuid()])->assertUnprocessable();
}
public function test_promoters_share_authorized_organization_drafts_but_not_other_scopes_or_types(): void
{
$author = $this->actor();
$colleague = $this->actor();
$outsider = $this->actor();
$organization = $this->organization($author, $colleague);
$otherOrganization = $this->organization($outsider);
$this->grant($author, 'promoter', $organization);
$this->grant($colleague, 'promoter', $organization);
$this->grant($outsider, 'promoter', $otherOrganization);
$draft = $this->draft($author, $organization);
$id = $draft['id'];
$this->get('/dashboard')->assertInertia(fn (Assert $p) => $p->where('profileAccess.mine', '/minhas-oportunidades')->where('profileAccess.create', '/minhas-oportunidades/create'));
$this->actingAs($colleague)->get('/minhas-oportunidades/'.$id.'/edit')->assertInertia(fn (Assert $p) => $p->where('values.title', 'QA private opportunity')->where('readOnly', false));
$update = $this->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => $draft['etag'], 'title' => 'QA shared edit', 'reason' => self::REASON])->assertOk()->json();
$this->assertSame($author->id, (int) Opportunity::findOrFail($id)->owner_user_id);
$this->assertSame([$author->id, $colleague->id], Opportunity::findOrFail($id)->workflow_context['contributors']);
$this->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => $update['etag'], 'organization_id' => $otherOrganization, 'reason' => self::REASON])->assertForbidden();
$this->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => $update['etag'], 'opportunity_category' => 'financing', 'reason' => self::REASON])->assertForbidden();
$this->actingAs($outsider)->get('/minhas-oportunidades/'.$id.'/view')->assertForbidden();
$this->postJson('/minhas-oportunidades/drafts', ['creation_key' => (string) Str::uuid(), 'organization_id' => $organization, 'opportunity_category' => 'investment_project', 'reason' => self::REASON])->assertForbidden();
$this->get('/minhas-oportunidades')->assertInertia(fn (Assert $p) => $p->where('records.total', 0));
DB::table('memberships')->where('user_id', $colleague->id)->update(['deleted_at' => now()]);
$this->actingAs($colleague)->get('/minhas-oportunidades/'.$id.'/view')->assertForbidden();
}
public function test_profile_decisions_do_not_fabricate_checks_and_privileged_grants_require_confirmed_mfa(): void
{
$first = $this->actor();
$second = $this->actor();
$bootstrap = $this->actor();
config(['portal_profiles.bootstrap_authorized' => true]);
$this->mfa($bootstrap);
app(PortalSuperusers::class)->initialize($first, $second, $bootstrap);
$doc = (string) Str::uuid();
DB::table('documents')->insert(['id' => $doc, 'file_name' => 'synthetic-authorization-fixture.txt', 'storage_key' => 'synthetic/'.$doc,
'media_type' => 'text/plain', 'sha256' => str_repeat('b', 64), 'malware_scanned_sha256' => str_repeat('b', 64),
'status' => 'active', 'malware_scan_status' => 'clean', 'uploaded_by' => $first->id]);
$request = $this->mfa($first)->postJson('/perfil/promotor', ['creation_key' => (string) Str::uuid(), 'representation_type' => 'individual',
'opportunity_types' => ['investment_project'], 'document_ids' => [$doc], 'reason' => self::REASON])->assertCreated()->json('id');
$decision = ['version' => 1, 'decision' => 'rejected', 'reason' => self::REASON, 'approved_types' => [],
'verified_identity' => false, 'verified_representation' => false, 'verified_documents' => false];
$this->postJson('/perfis/pedidos/'.$request.'/decisao', $decision)->assertForbidden();
$this->mfa($second)->postJson('/perfis/pedidos/'.$request.'/decisao', $decision)->assertOk();
$this->assertDatabaseHas('portal_profile_requests', ['id' => $request, 'status' => 'rejected']);
$this->assertFalse(app(PortalProfileAccess::class)->has($first, 'promoter'));
$event = json_decode(DB::table('audit_events')->where('action', 'promoter_request_decided')->value('after_data'), true);
$this->assertFalse($event['checks']['verified_documents']);
$unenrolled = User::factory()->create();
$this->postJson('/perfis/atribuicoes', ['user_id' => $unenrolled->id, 'role' => 'validator',
'opportunity_types' => ['investment_project'], 'reason' => self::REASON])->assertUnprocessable();
$this->postJson('/perfis/atribuicoes', ['user_id' => $bootstrap->id, 'role' => 'auditor', 'responsibilities' => ['validation_coordinator'],
'opportunity_types' => ['investment_project'], 'reason' => self::REASON])->assertUnprocessable();
$expired = $this->grant($bootstrap, 'validator');
DB::table('portal_role_assignments')->where('id', $expired)->update(['starts_at' => now()->subDays(2), 'expires_at' => now()->subDay()]);
$this->assertFalse(app(PortalProfileAccess::class)->has($bootstrap, 'validator'));
}
public function test_role_accumulation_does_not_override_author_auditor_or_state_denials(): void
{
$user = $this->actor();
$this->grant($user, 'promoter');
$draft = $this->draft($user);
$id = $draft['id'];
$this->grant($user, 'validator');
$this->grant($user, 'publisher');
DB::table('opportunities')->where('id', $id)->update(['status' => 'submitted']);
$etag = app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id));
$this->mfa($user)->postJson('/minhas-oportunidades/'.$id.'/workflow', ['etag' => $etag, 'action' => 'approve', 'reason' => self::REASON])->assertForbidden();
$this->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => $etag, 'reason' => self::REASON])->assertForbidden();
DB::table('opportunities')->where('id', $id)->update(['status' => 'draft']);
config(['portal.managers' => [$user->email], 'portal.opportunity_dashboard_access_enabled' => true]);
$this->get('/management/opportunities/create')->assertForbidden();
$this->postJson('/management/opportunities/drafts', ['creation_key' => (string) Str::uuid(), 'title' => 'Legacy bypass'])->assertForbidden();
$this->get('/dashboard')->assertInertia(fn (Assert $p) => $p->where('opportunityManagement', null)->where('summary.canCreateOpportunity', false));
$this->grant($user, 'auditor');
$etag = app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id));
$this->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => $etag, 'title' => 'forbidden', 'reason' => self::REASON])->assertForbidden();
$this->get('/minhas-oportunidades/'.$id.'/view')->assertOk();
$this->postJson('/management/sectors', ['name' => 'Auditor bypass'])->assertForbidden();
$this->assertSame('QA private opportunity', Opportunity::findOrFail($id)->title);
}
public function test_independent_review_preserves_versions_and_normative_gates_remain_closed(): void
{
$promoter = $this->actor();
$validator = $this->actor();
$publisher = $this->actor();
$this->grant($promoter, 'promoter');
$this->grant($validator, 'validator');
$this->grant($publisher, 'publisher');
$draft = $this->draft($promoter);
$id = $draft['id'];
$this->postJson('/minhas-oportunidades/'.$id.'/workflow', ['etag' => $draft['etag'], 'action' => 'submit', 'reason' => self::REASON])->assertUnprocessable();
$this->postJson('/minhas-oportunidades/'.$id.'/workflow', ['etag' => $draft['etag'], 'action' => 'request_archive', 'reason' => self::REASON])->assertOk()->assertJsonPath('status', 'draft');
$this->assertNotNull(Opportunity::findOrFail($id)->workflow_context['request_archive']);
// Explicit review-state fixture exercises the developing workflow, not a complete submission.
$context = Opportunity::findOrFail($id)->workflow_context;
$context['working_state'] = 'submitted';
DB::table('opportunities')->where('id', $id)->update(['status' => 'submitted', 'workflow_context' => json_encode($context)]);
$etag = app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id));
$this->actingAs($validator)->withSession(['portal.mfa_actor' => null])->postJson('/minhas-oportunidades/'.$id.'/workflow', ['etag' => $etag, 'action' => 'start_review', 'reason' => self::REASON])->assertForbidden();
$review = $this->mfa($validator)->postJson('/minhas-oportunidades/'.$id.'/workflow', ['etag' => $etag, 'action' => 'start_review', 'reason' => self::REASON])->assertOk()->json();
$this->postJson('/minhas-oportunidades/'.$id.'/workflow', ['etag' => $review['etag'], 'action' => 'approve', 'reason' => self::REASON])->assertUnprocessable();
$correction = $this->postJson('/minhas-oportunidades/'.$id.'/workflow', ['etag' => $review['etag'], 'action' => 'request_correction', 'reason' => self::REASON, 'correction_items' => ['summary']])->assertOk()->json();
$this->actingAs($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => $correction['etag'], 'summary' => 'QA correction', 'reason' => self::REASON])->assertOk();
$etag = app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id));
$this->mfa($publisher)->postJson('/minhas-oportunidades/'.$id.'/workflow', ['etag' => $etag, 'action' => 'publish', 'reason' => self::REASON])->assertConflict();
$this->assertSame(0, Opportunity::published()->count());
}
public function test_revocation_invalidates_existing_session_and_legacy_manager_cannot_bypass_scope(): void
{
$first = $this->actor();
$second = $this->actor();
$approver = $this->actor();
config(['portal_profiles.bootstrap_authorized' => true]);
$this->mfa($approver);
app(PortalSuperusers::class)->initialize($first, $second, $approver);
$promoter = $this->actor();
$assignment = $this->grant($promoter, 'promoter');
$draft = $this->draft($promoter);
$manager = $this->actor();
config(['portal.managers' => [$manager->email]]);
$this->actingAs($manager)->get('/management/opportunities/'.$draft['id'].'/edit')->assertForbidden();
$this->get('/management/opportunities')->assertInertia(fn (Assert $p) => $p->where('records.total', 0));
$this->putJson('/api/v1/investment/admin/opportunities/'.$draft['id'].'/context', [])->assertForbidden();
$this->getJson('/management/territories/opportunities/'.$draft['id'])->assertForbidden();
$this->getJson('/management/territory-subjects?kind=opportunities')->assertJsonCount(0, 'data');
$this->postJson('/management/opportunity-origin/preview', ['id' => $draft['id'], 'classification' => 'real', 'reason' => self::REASON])->assertForbidden();
$this->mfa($first)->postJson('/perfis/atribuicoes/'.$assignment.'/revogar', ['reason' => self::REASON])->assertOk();
$this->actingAs($promoter)->withSession(['portal.access_revision' => 0])->getJson('/minhas-oportunidades/'.$draft['id'].'/view')->assertUnauthorized();
$this->assertDatabaseHas('portal_role_assignments', ['id' => $assignment]);
$this->assertFalse(app(PortalProfileAccess::class)->has($promoter, 'promoter'));
}
public function test_superuser_pair_is_initialized_once_and_replaced_atomically_with_independent_approval(): void
{
$first = $this->actor();
$second = $this->actor();
$approver = $this->actor();
$candidate = $this->actor();
config(['portal_profiles.bootstrap_authorized' => true]);
$this->mfa($approver);
$service = app(PortalSuperusers::class);
$service->initialize($first, $second, $approver);
$service->initialize($first, $second, $approver);
$this->assertSame(1, DB::table('portal_superuser_pair')->count());
$this->mfa($first)->deleteJson('/user/two-factor-authentication')->assertForbidden();
$this->deleteJson('/settings/profile')->assertForbidden();
$this->patchJson('/settings/profile', ['email' => 'changed-qa@example.invalid'])->assertForbidden();
$this->assertNotNull($first->fresh()->two_factor_secret);
try {
DB::transaction(fn () => DB::table('portal_superuser_pair')->delete());
$this->fail('Removal of the pair must be blocked by PostgreSQL.');
} catch (QueryException $error) {
$this->assertStringContainsString('Superuser pair cannot be removed', $error->getMessage());
}
$this->assertSame(1, DB::table('portal_superuser_pair')->count());
$this->mfa($first)->postJson('/perfis/atribuicoes', ['user_id' => $candidate->id, 'role' => 'superuser', 'opportunity_types' => ['investment_project'], 'reason' => self::REASON])->assertUnprocessable();
$id = $this->postJson('/perfis/substituicoes', ['old_user_id' => $second->id, 'new_user_id' => $candidate->id, 'reason' => self::REASON])->assertCreated()->json('id');
$this->postJson('/perfis/seguranca/'.$id.'/aprovar')->assertForbidden();
$this->postJson('/perfis/substituicoes/'.$id.'/aplicar')->assertConflict();
$this->mfa($second)->postJson('/perfis/seguranca/'.$id.'/aprovar')->assertOk();
$this->mfa($first)->postJson('/perfis/substituicoes/'.$id.'/aplicar')->assertOk();
$pair = DB::table('portal_superuser_pair')->first();
$this->assertSame([$first->id, $candidate->id], [(int) $pair->first_user_id, (int) $pair->second_user_id]);
$this->assertFalse(app(PortalProfileAccess::class)->superuser($second));
$this->assertSame(1, (int) $second->fresh()->portal_access_revision);
$this->postJson('/perfis/substituicoes/'.$id.'/aplicar')->assertConflict();
$this->assertSame(1, DB::table('audit_events')->where('action', 'superuser_replaced')->count());
}
}