283 lines
19 KiB
PHP
283 lines
19 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 Symfony\Component\HttpKernel\Exception\HttpException;
|
|
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 initializePair(User $first, User $second): void
|
|
{
|
|
$approval = (string) Str::uuid();
|
|
DB::table('portal_security_approvals')->insert([
|
|
'id' => $approval, 'kind' => 'bootstrap', 'requested_by' => $first->id,
|
|
'subject_user_id' => $second->id, 'approved_by' => $second->id,
|
|
'scope' => '{}', 'reason' => self::REASON, 'expires_at' => now()->addMinutes(15),
|
|
'created_at' => now(), 'updated_at' => now(),
|
|
]);
|
|
$this->mfa($second);
|
|
app(PortalSuperusers::class)->initialize($first, $second, $second, $approval);
|
|
}
|
|
|
|
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);
|
|
$this->initializePair($first, $second);
|
|
$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);
|
|
$this->initializePair($first, $second);
|
|
$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);
|
|
$this->initializePair($first, $second);
|
|
try {
|
|
$this->initializePair($first, $second);
|
|
$this->fail('An existing pair must not be initialized again.');
|
|
} catch (HttpException $error) {
|
|
$this->assertSame(409, $error->getStatusCode());
|
|
}
|
|
$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());
|
|
}
|
|
}
|