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

203 lines
13 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\InvestmentCatalogue;
use App\Services\OpportunityManagement;
use App\Services\OpportunityPromotion;
use App\Services\PortalProfileAccess;
use App\Services\PortalProfileAdministration;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
use Tests\TestCase;
final class OpportunityPromotionTest extends TestCase
{
use DatabaseTransactions;
private User $actor;
private Opportunity $record;
private string $org;
protected function setUp(): void
{
parent::setUp();
$target = DB::selectOne('select current_database() as name, inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $target->name);
$this->assertSame(55441, (int) $target->port);
$this->withoutVite();
config(['portal.profile_access_enabled' => true, 'portal.managers' => []]);
$this->actor = User::factory()->create();
$this->actor->forceFill(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
$this->org = (string) Str::uuid();
DB::table('organizations')->insert(['id' => $this->org, 'name' => 'SYNTHETIC QA Tempo operator', 'external_id' => '000000000', 'organization_type' => 'private']);
DB::table('audit_events')->insert(['source_system' => 'portal-profiles-v1', 'entity_type' => 'authorization', 'action' => 'operator_organization_registered', 'data_classification' => 'restricted',
'after_data' => json_encode(['organization_id' => $this->org, 'organization' => ['name' => 'SYNTHETIC QA Tempo operator', 'nuit' => '000000000'], 'source_reference' => 'Synthetic QA designation only'])]);
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), ['user_id' => $this->actor->id,
'role' => 'promoter', 'organization_id' => $this->org, 'opportunity_types' => OpportunityManagement::CATEGORIES, 'responsibilities' => [], 'source' => 'internal-portal-designation']);
$this->record = new Opportunity;
$this->record->forceFill(['title' => 'PRIVATE TITLE', 'slug' => 'qa-promo-'.Str::uuid(), 'reference' => 'QA-'.Str::uuid(), 'status' => 'draft',
'opportunity_category' => 'investment_project', 'owner_user_id' => $this->actor->id, 'organization_id' => $this->org, 'management_version' => 1,
'promoter_name' => 'PRIVATE CONTACT', 'description' => 'PRIVATE CONTENT', 'estimated_investment_amount' => '123456.00',
'workflow_context' => ['operator_organization_id' => $this->org, 'contributors' => [$this->actor->id]],
'management_draft' => json_encode(['canonical' => ['official_title' => 'PRIVATE TITLE', 'contact' => 'PRIVATE CONTACT', 'documents' => ['PRIVATE DOCUMENT']]])])->save();
$this->login($this->actor);
}
private function login(User $actor): void
{
$this->actingAs($actor)->withSession(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $actor->portal_access_revision]);
}
private function base(): string
{
return '/minhas-oportunidades/'.$this->record->id.'/divulgacao';
}
private function data(array $overrides = []): array
{
return [...['etag' => app(OpportunityManagement::class)->etag($this->record), 'action' => 'disclose', 'title' => 'SYNTHETIC QA public summary',
'summary' => 'Synthetic QA opportunity in preparation, no real investment.', 'description' => 'Only this deliberately selected public text.',
'source_reference' => 'Synthetic source provided for QA', 'edition' => 'QA-1', 'source_date' => '2026-10-22',
'limitations' => 'Financing and implementation not established.', 'territory_ids' => []], ...$overrides];
}
private function preview(array $overrides = []): array
{
return $this->postJson($this->base().'/previa', $this->data($overrides))->assertOk()->json();
}
private function confirm(array $preview, ?string $key = null): array
{
$data = ['token' => $preview['token'], 'operation_key' => $key ?? (string) Str::uuid(), 'confirm' => true, 'reason' => 'Synthetic QA operation reviewed in preview'];
$this->postJson($this->base().'/confirmar', $data)->assertOk();
return $data;
}
public function test_preview_publish_update_withdraw_and_redisclose_preserve_private_draft_and_audit(): void
{
$file = (string) Str::uuid();
$attachment = (string) Str::uuid();
DB::table('documents')->insert(['id' => $file, 'file_name' => 'PRIVATE-QA-DOCUMENT.txt', 'storage_key' => 'private-qa/'.$file,
'media_type' => 'text/plain', 'sha256' => hash('sha256', 'private QA only'), 'size_bytes' => 15,
'uploaded_by' => $this->actor->id, 'status' => 'quarantined', 'data_classification' => 'internal']);
DB::table('opportunity_documents')->insert(['id' => $attachment, 'opportunity_id' => $this->record->id, 'document_id' => $file,
'version' => 1, 'created_by' => $this->actor->id, 'payload' => json_encode(['title' => 'PRIVATE DOCUMENT', 'visibility' => 'internal'])]);
$documentBefore = DB::table('documents')->where('id', $file)->first();
$attachmentBefore = DB::table('opportunity_documents')->where('id', $attachment)->first();
DB::table('opportunity_profiles')->insert(['opportunity_id' => $this->record->id, 'business_model' => 'PRIVATE PROFILE', 'source_reference' => 'PRIVATE SOURCE']);
$before = $this->record->refresh()->getRawOriginal();
$private = $before['management_draft'];
$preview = $this->preview();
$this->assertSame($before, $this->record->fresh()->getRawOriginal());
$this->assertFalse($preview['writes']);
$this->assertStringContainsString('Em preparação', $preview['projection']['description']);
$this->assertStringNotContainsString('PRIVATE', json_encode($preview['projection']));
$key = (string) Str::uuid();
$request = $this->confirm($preview, $key);
$this->postJson($this->base().'/confirmar', $request)->assertOk()->assertJsonPath('replayed', true);
$this->record->refresh();
$this->assertSame('draft', $this->record->workflow_context['working_state']);
$this->assertSame($private, $this->record->management_draft);
$this->assertSame('PRIVATE CONTACT', app(OpportunityManagement::class)->values($this->record)['promoter_name']);
$public = $this->getJson('/api/v1/opportunities/'.$this->record->slug)->assertOk();
$this->assertStringNotContainsString('PRIVATE', $public->getContent());
$public->assertJsonPath('data.maturity_label', 'Em preparação')->assertJsonPath('data.estimated_investment_amount', null);
$context = app(InvestmentCatalogue::class)->context($this->record->id);
$this->assertNull($context['profile']);
$this->assertSame([], $context['source_evidence']);
$this->assertStringNotContainsString('PRIVATE', json_encode($context));
$this->confirm($this->preview(['summary' => 'Updated synthetic QA summary.']));
$this->getJson('/api/v1/opportunities/'.$this->record->slug)->assertOk()->assertJsonPath('data.summary', 'Updated synthetic QA summary.');
$this->get($this->base())->assertOk();
$this->confirm($this->preview(['action' => 'withdraw']));
$this->getJson('/api/v1/opportunities/'.$this->record->slug)->assertNotFound();
$this->get($this->base())->assertOk();
$this->confirm($this->preview());
$this->getJson('/api/v1/opportunities/'.$this->record->slug)->assertOk();
$events = DB::table('audit_events')->where('source_system', 'tempo-preparation-publication')->where('after_data->record_id', $this->record->id)->get();
$this->assertCount(4, $events);
$this->assertContains('promotional_summary_withdrawn', $events->pluck('action')->all());
foreach ($events as $event) {
$this->assertSame($this->actor->id, (int) $event->actor_user_id);
$this->assertFalse(json_decode($event->after_data, true)['execution_approved']);
}
$this->assertEquals($documentBefore, DB::table('documents')->where('id', $file)->first());
$this->assertEquals($attachmentBefore, DB::table('opportunity_documents')->where('id', $attachment)->first());
}
public function test_stale_tampered_expired_and_foreign_preview_do_not_write(): void
{
$preview = $this->preview();
$payload = ['token' => $preview['token'].'broken', 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'Synthetic QA invalid confirmation'];
$this->postJson($this->base().'/confirmar', $payload)->assertUnprocessable();
$this->record->title = 'Concurrent private edit';
$this->record->save();
$this->postJson($this->base().'/confirmar', [...$payload, 'token' => $preview['token']])->assertConflict();
$fresh = $this->preview();
$this->travel(16)->minutes();
$this->withSession(['portal.mfa_at' => time()]);
$this->postJson($this->base().'/confirmar', [...$payload, 'token' => $fresh['token']])->assertConflict();
$this->travelBack();
$this->login(User::factory()->create());
$this->get($this->base())->assertForbidden();
$this->postJson($this->base().'/confirmar', [...$payload, 'token' => $fresh['token']])->assertForbidden();
$this->assertSame('draft', $this->record->fresh()->status);
}
public function test_external_promoter_and_unrelated_internal_actor_cannot_disclose_and_no_ordinary_publication_is_overwritten(): void
{
$other = User::factory()->create();
$this->login($other);
$this->postJson($this->base().'/previa', $this->data())->assertForbidden();
$this->login($this->actor);
DB::table('memberships')->insert(['id' => (string) Str::uuid(), 'organization_id' => $this->org, 'user_id' => $this->actor->id, 'role' => 'officer']);
DB::table('portal_role_assignments')->where('user_id', $this->actor->id)->update(['source' => 'approved-external-promoter']);
$this->postJson($this->base().'/previa', $this->data())->assertForbidden();
DB::table('portal_role_assignments')->where('user_id', $this->actor->id)->update(['source' => 'internal-portal-designation']);
$this->withSession(['portal.mfa_at' => time() - 901]);
$this->postJson($this->base().'/previa', $this->data())->assertForbidden();
$this->login($this->actor);
$this->record->forceFill(['status' => 'published', 'published_at' => now(), 'workflow_context' => [...$this->record->workflow_context, 'publication' => ['visibility' => 'public']]])->save();
$this->get($this->base())->assertForbidden();
}
public function test_existing_publisher_scope_can_use_the_same_action_without_new_role_or_approval(): void
{
$publisher = User::factory()->create();
$publisher->forceFill(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), $this->actor, ['user_id' => $publisher->id,
'role' => 'publisher', 'organization_id' => $this->org, 'opportunity_types' => ['investment_project'], 'responsibilities' => [], 'source' => 'synthetic-approved-designation']);
$roles = DB::table('portal_role_assignments')->count();
$this->login($publisher);
$access = app(PortalProfileAccess::class);
$this->assertTrue($access->canView($publisher, $this->record), 'Publisher read scope');
$this->assertTrue($access->scoped($publisher, 'publisher', $this->org, 'investment_project'), 'Publisher scope');
$this->assertTrue(app(OpportunityPromotion::class)->allowed($publisher, $this->record), 'Promotional action scope');
$this->confirm($this->preview());
$this->assertSame($roles, DB::table('portal_role_assignments')->count());
$this->assertArrayNotHasKey('approval', $this->record->fresh()->workflow_context);
}
public function test_profile_directory_uses_existing_accounts_without_exposing_authentication_secrets(): void
{
$other = User::factory()->create();
$missingSecret = User::factory()->create(['two_factor_secret' => null, 'two_factor_confirmed_at' => now()]);
$unconfirmed = User::factory()->create(['two_factor_secret' => Crypt::encryptString('SYNTHETIC-QA'), 'two_factor_confirmed_at' => null]);
DB::table('portal_superuser_pair')->updateOrInsert(['id' => 1], ['first_user_id' => $this->actor->id, 'second_user_id' => $other->id, 'approved_by' => $other->id]);
$this->get('/perfis/gestao')->assertOk()->assertInertia(fn (AssertableInertia $page) => $page
->has('staffAccounts')->where('staffAccounts', fn ($rows) => collect($rows)->contains(fn ($row) => $row['id'] === $this->actor->id && $row['email'] === $this->actor->email && $row['verified'] && $row['mfa'])
&& collect($rows)->contains(fn ($row) => $row['id'] === $missingSecret->id && $row['mfa'] === false)
&& collect($rows)->contains(fn ($row) => $row['id'] === $unconfirmed->id && $row['mfa'] === false)
&& collect($rows)->every(fn ($row) => ! isset($row['password']) && ! isset($row['two_factor_secret']))));
}
}