@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Http\Kernel;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
|
||||
use Laravel\Fortify\Fortify;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
use Tests\TestCase;
|
||||
|
||||
class QaAccountSelectionTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
require_once base_path('tools/portal-qa-account-selection.php');
|
||||
config(['portal.qa_synthetic_accounts_only' => true]);
|
||||
$kernel = app(Kernel::class);
|
||||
$kernel->appendMiddlewareToGroup('web', \PortalQaAccountSelection::class);
|
||||
$kernel->addToMiddlewarePriorityAfter(StartSession::class, \PortalQaAccountSelection::class);
|
||||
}
|
||||
|
||||
private function restored(): User
|
||||
{
|
||||
$user = User::factory()->create(['email' => 'restored-source@source.invalid']);
|
||||
$user->forceFill(['two_factor_secret' => (new Encrypter(random_bytes(32), 'AES-256-CBC'))->encrypt('SYNTHETICONLY'),
|
||||
'two_factor_confirmed_at' => now()])->save();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function test_restored_account_is_stopped_before_mfa_without_changing_its_row(): void
|
||||
{
|
||||
$user = $this->restored();
|
||||
$original = hash('sha256', json_encode($user->fresh()->getRawOriginal(), JSON_THROW_ON_ERROR));
|
||||
$this->post('/login', ['email' => $user->email, 'password' => 'password'])
|
||||
->assertRedirect('/login')->assertSessionHasErrors('email')->assertSessionMissing('login.id');
|
||||
$this->assertGuest();
|
||||
$this->assertSame($original, hash('sha256', json_encode($user->fresh()->getRawOriginal(), JSON_THROW_ON_ERROR)));
|
||||
}
|
||||
|
||||
public function test_json_restored_login_and_stale_challenge_remain_unauthenticated(): void
|
||||
{
|
||||
$user = $this->restored();
|
||||
$this->postJson('/login', ['email' => $user->email, 'password' => 'password'])
|
||||
->assertUnprocessable()->assertJsonValidationErrors('email')->assertSessionMissing('login.id');
|
||||
$this->withSession(['login.id' => $user->id])->get('/two-factor-challenge')
|
||||
->assertRedirect('/login')->assertSessionHasErrors('email')->assertSessionMissing('login.id');
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
public function test_synthetic_login_requires_valid_mfa_then_recovers_after_logout(): void
|
||||
{
|
||||
$user = User::factory()->create(['email' => 'synthetic-investor@example.invalid']);
|
||||
app(EnableTwoFactorAuthentication::class)($user);
|
||||
$user->forceFill(['two_factor_confirmed_at' => now()])->save();
|
||||
$secret = Fortify::currentEncrypter()->decrypt($user->two_factor_secret);
|
||||
$credentials = ['email' => $user->email, 'password' => 'password'];
|
||||
$this->post('/login', $credentials)->assertRedirect('/two-factor-challenge');
|
||||
$this->assertGuest();
|
||||
$this->post('/two-factor-challenge', ['code' => 'invalid'])->assertRedirect('/two-factor-challenge');
|
||||
$this->assertGuest();
|
||||
$engine = new Google2FA;
|
||||
$this->post('/two-factor-challenge', ['code' => $engine->getCurrentOtp($secret)])->assertRedirect('/dashboard');
|
||||
$this->assertAuthenticatedAs($user);
|
||||
$this->get('/dashboard')->assertOk();
|
||||
$this->post('/logout')->assertRedirect('/');
|
||||
$this->assertGuest();
|
||||
$this->post('/login', $credentials)->assertRedirect('/two-factor-challenge');
|
||||
// A new login uses a fresh code in Fortify's normal accepted window, not the consumed code.
|
||||
$this->post('/two-factor-challenge', ['code' => $engine->oathTotp($secret, $engine->getTimestamp() + 1)])->assertRedirect('/dashboard');
|
||||
$this->assertAuthenticatedAs($user);
|
||||
}
|
||||
|
||||
public function test_qa_account_policy_does_not_apply_when_flag_is_absent(): void
|
||||
{
|
||||
config(['portal.qa_synthetic_accounts_only' => false]);
|
||||
$user = User::factory()->create(['email' => 'ordinary-user@source.invalid']);
|
||||
$this->post('/login', ['email' => $user->email, 'password' => 'password'])->assertRedirect('/dashboard');
|
||||
$this->assertAuthenticatedAs($user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
|
||||
use Laravel\Fortify\Fortify;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TwoFactorDecryptionFailureTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function actor(bool $brokenRecovery = false): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
app(EnableTwoFactorAuthentication::class)($user);
|
||||
$user->forceFill(['two_factor_confirmed_at' => now()])->save();
|
||||
$foreign = new Encrypter(random_bytes(32), 'AES-256-CBC');
|
||||
$user->forceFill($brokenRecovery
|
||||
? ['two_factor_recovery_codes' => $foreign->encrypt('["synthetic-only"]')]
|
||||
: ['two_factor_secret' => $foreign->encrypt('SYNTHETICQAONLY')])->save();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function test_wrong_key_secret_returns_controlled_json_and_cancels_challenge_without_authenticating(): void
|
||||
{
|
||||
$user = $this->actor();
|
||||
$original = $user->getRawOriginal('two_factor_secret');
|
||||
$this->postJson('/login', ['email' => $user->email, 'password' => 'password'])->assertOk()->assertJson(['two_factor' => true]);
|
||||
$oldSession = session()->getId();
|
||||
$this->postJson('/two-factor-challenge', ['code' => '123456'])->assertUnprocessable()->assertJsonValidationErrors('code')
|
||||
->assertJsonPath('message', 'Não foi possível verificar a autenticação desta conta. Inicie sessão novamente ou contacte o suporte.')
|
||||
->assertSessionMissing('login.id')->assertSessionMissing('_old_input.code');
|
||||
$this->assertGuest();
|
||||
$this->assertNotSame($oldSession, session()->getId());
|
||||
$this->assertSame($original, $user->fresh()->two_factor_secret);
|
||||
$this->getJson('/dashboard')->assertUnauthorized();
|
||||
}
|
||||
|
||||
public function test_wrong_key_recovery_codes_are_controlled_and_never_replaced_or_authenticated(): void
|
||||
{
|
||||
$user = $this->actor(true);
|
||||
$original = $user->two_factor_recovery_codes;
|
||||
$this->postJson('/login', ['email' => $user->email, 'password' => 'password'])->assertOk();
|
||||
$this->postJson('/two-factor-challenge', ['recovery_code' => 'synthetic-only'])->assertUnprocessable()->assertJsonValidationErrors('recovery_code')
|
||||
->assertSessionMissing('login.id')->assertSessionMissing('_old_input.recovery_code');
|
||||
$this->assertGuest();
|
||||
$this->assertSame($original, $user->fresh()->two_factor_recovery_codes);
|
||||
}
|
||||
|
||||
public function test_browser_failure_redirects_to_login_with_safe_visible_message(): void
|
||||
{
|
||||
$user = $this->actor();
|
||||
$this->post('/login', ['email' => $user->email, 'password' => 'password'])->assertRedirect('/two-factor-challenge');
|
||||
$this->post('/two-factor-challenge', ['code' => '123456'])->assertRedirect('/login')->assertSessionHasErrors('email')
|
||||
->assertSessionMissing('login.id')->assertSessionMissing('_old_input.code');
|
||||
$this->assertGuest();
|
||||
$this->get('/two-factor-challenge')->assertRedirect('/login');
|
||||
}
|
||||
|
||||
public function test_normal_enrollment_and_valid_mfa_still_authenticate_after_invalid_code_is_rejected(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
app(EnableTwoFactorAuthentication::class)($user);
|
||||
$user->forceFill(['two_factor_confirmed_at' => now()])->save();
|
||||
$secret = Fortify::currentEncrypter()->decrypt($user->two_factor_secret);
|
||||
$this->assertCount(8, $user->recoveryCodes());
|
||||
$this->assertSame($secret, Crypt::decrypt($user->two_factor_secret));
|
||||
$this->postJson('/login', ['email' => $user->email, 'password' => 'password'])->assertOk()->assertJson(['two_factor' => true]);
|
||||
$this->postJson('/two-factor-challenge', ['code' => 'invalid'])->assertUnprocessable();
|
||||
$this->assertGuest();
|
||||
$code = (new Google2FA)->getCurrentOtp($secret);
|
||||
$this->postJson('/two-factor-challenge', ['code' => $code])->assertNoContent();
|
||||
$this->assertAuthenticatedAs($user);
|
||||
$this->postJson('/logout')->assertNoContent();
|
||||
$this->assertGuest();
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\InterestRequest;
|
||||
use App\Models\User;
|
||||
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 BusinessMarketplacePublicTest extends TestCase
|
||||
@@ -36,6 +38,8 @@ class BusinessMarketplacePublicTest extends TestCase
|
||||
|
||||
public function test_interest_is_limited_to_published_business_and_deduplicated_idempotently(): void
|
||||
{
|
||||
$this->withoutVite();
|
||||
config(['portal.notifications_enabled' => false]);
|
||||
$business = $this->publishedBusiness();
|
||||
$payload = [
|
||||
'existingBusinessId' => $business['id'],
|
||||
@@ -44,12 +48,17 @@ class BusinessMarketplacePublicTest extends TestCase
|
||||
'email' => 'portal-integration-test@gmail.com',
|
||||
'phone' => '+258 840000000',
|
||||
'investorType' => 'empresa',
|
||||
'country' => 'Moçambique',
|
||||
'country' => 'PT',
|
||||
'declared_origin_country_code' => 'ZA',
|
||||
'sectorInterest' => 'Energia',
|
||||
'message' => 'Synthetic interest for the published business listing.',
|
||||
'privacyConsent' => true,
|
||||
];
|
||||
$key = (string) Str::uuid();
|
||||
$this->get('/negocios/'.$business['public_slug'].'/manifestar-interesse')->assertInertia(fn (Assert $p) => $p
|
||||
->component('investment/business-interest')->has('countries', 249)->where('record.id', $business['id']));
|
||||
$this->postJson('/api/v1/interest-requests', [...$payload, 'country' => 'ZZ'], ['Idempotency-Key' => (string) Str::uuid()])->assertUnprocessable();
|
||||
$this->assertDatabaseCount('interest_requests', 0);
|
||||
|
||||
$first = $this->postJson('/api/v1/interest-requests', $payload, ['Idempotency-Key' => $key])->assertCreated();
|
||||
$second = $this->postJson('/api/v1/interest-requests', $payload, ['Idempotency-Key' => $key])->assertCreated();
|
||||
@@ -65,6 +74,18 @@ class BusinessMarketplacePublicTest extends TestCase
|
||||
$this->postJson('/api/v1/interest-requests', [...$payload, 'message' => 'Another valid message for this active interest.'])
|
||||
->assertStatus(409);
|
||||
$this->assertDatabaseCount('interest_requests', 1);
|
||||
$interest = InterestRequest::firstOrFail();
|
||||
$this->assertSame('PT', $interest->country);
|
||||
$this->assertSame('ZA', $interest->metadata['declared_origin_country_code']);
|
||||
$manager = User::factory()->create();
|
||||
config(['portal.managers' => [$manager->email]]);
|
||||
$this->actingAs($manager)->get('/management/interests/'.$interest->id)->assertInertia(fn (Assert $p) => $p
|
||||
->where('interest.country', 'PT')->where('declaredOrigin.code', 'ZA'));
|
||||
$this->get('/management/interests?country=PT&origin=ZA')->assertInertia(fn (Assert $p) => $p
|
||||
->where('requests.total', 1)->where('originStatistics.interests.0.code', 'ZA')->where('originStatistics.interests.0.count', 1)
|
||||
->has('originStatistics.businesses', 0));
|
||||
$this->get('/management/interests?origin=PT')->assertInertia(fn (Assert $p) => $p->where('requests.total', 0)->has('originStatistics.interests', 0));
|
||||
$this->actingAs(User::factory()->create())->get('/management/interests/'.$interest->id)->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_public_catalogue_filters_by_existing_opportunity_modality_and_opens_response_flow(): void
|
||||
|
||||
@@ -261,7 +261,7 @@ class EducationTest extends TestCase
|
||||
'reviewed_by' => $reviewer->id, 'status' => 'reviewed', 'review_reason' => 'Evidência sintética revista.',
|
||||
'period_start' => now()->toDateString(), 'period_end' => $data['period_start']]);
|
||||
$url = '/management/education/demands/'.$demand->id.'/analysis';
|
||||
$this->get($url)->assertInertia(fn (AssertableInertia $page) => $page->where('eligible', false)->has('candidates', 0));
|
||||
$this->get($url)->assertOk()->assertInertia(fn (AssertableInertia $page) => $page->where('eligible', false)->has('candidates', 0));
|
||||
$this->post('/management/education/demands/'.$demand->id.'/review', ['status' => 'reviewed', 'version_no' => 1,
|
||||
'review_reason' => 'Revisão independente sintética.'])->assertSessionHasNoErrors();
|
||||
$this->get($url)->assertInertia(fn (AssertableInertia $page) => $page->component('education/analysis')
|
||||
|
||||
@@ -127,8 +127,10 @@ class InvestmentPortfolioTest extends TestCase
|
||||
$this->post('/investidor/guardar/'.$dossier->id)->assertRedirect();
|
||||
$this->assertDatabaseCount('investor_journeys', 1);
|
||||
$journey = InvestorJourney::firstOrFail();
|
||||
$payload = ['message' => 'Pretendo investir neste projecto de teste.', 'country' => 'Moçambique', 'privacy_consent' => true, 'role' => 'investir', 'horizon' => '12 meses'];
|
||||
$payload = ['message' => 'Pretendo investir neste projecto de teste.', 'country' => 'Moçambique', 'privacy_consent' => true, 'role' => 'investir', 'horizon' => '12 meses', 'declared_origin_country_code' => 'PT'];
|
||||
$url = '/investidor/'.$journey->id.'/interesse';
|
||||
$this->postJson($url, [...$payload, 'declared_origin_country_code' => 'ZZ'])->assertUnprocessable();
|
||||
$this->assertDatabaseCount('interest_requests', 0);
|
||||
$this->post($url, [...$payload, 'privacy_consent' => false])->assertSessionHasErrors('privacy_consent');
|
||||
$this->actingAs($other)->post($url, $payload)->assertNotFound();
|
||||
$this->get('/investidor')->assertInertia(fn (Assert $page) => $page->has('journeys', 0));
|
||||
@@ -139,8 +141,10 @@ class InvestmentPortfolioTest extends TestCase
|
||||
$this->assertDatabaseHas('investor_journeys', ['id' => $journey->id, 'status' => 'active']);
|
||||
$metadata = json_decode(DB::table('interest_requests')->value('metadata'), true);
|
||||
$this->assertSame($journey->id, $metadata['journey_id']);
|
||||
$this->assertSame('PT', $metadata['declared_origin_country_code']);
|
||||
$this->assertSame($dossier->project_id, $metadata['project_id']);
|
||||
$this->get('/investidor')->assertInertia(fn (Assert $page) => $page->has('journeys', 1)->where('journeys.0.request_status', 'received'));
|
||||
$this->get('/investidor')->assertInertia(fn (Assert $page) => $page->has('journeys', 1)->where('journeys.0.request_status', 'received')
|
||||
->where('journeys.0.country', 'Moçambique')->where('journeys.0.declared_origin.code', 'PT'));
|
||||
$conversion = ['owner_user_id' => $owner->id, 'title' => 'Interesse da jornada', 'next_action' => 'Contactar investidor', 'due_at' => now()->addDay()->toIso8601String()];
|
||||
$interest = InterestRequest::firstOrFail();
|
||||
try {
|
||||
@@ -152,6 +156,11 @@ class InvestmentPortfolioTest extends TestCase
|
||||
$lead = app(ConvertInterestToLead::class)->convert($interest, [...$conversion, 'investor_id' => $journey->investor_id], $owner->id);
|
||||
$this->assertSame($journey->investor_id, $lead->investor_id);
|
||||
$this->assertDatabaseCount('investors', 1);
|
||||
config(['portal.managers' => [$owner->email]]);
|
||||
$this->get('/management/interests?origin=PT')->assertInertia(fn (Assert $page) => $page
|
||||
->where('requests.total', 1)->where('requests.data.0.declaredOrigin.code', 'PT')
|
||||
->where('originStatistics.interests.0.code', 'PT')->where('originStatistics.interests.0.count', 1));
|
||||
$this->get('/management/interests?origin=MZ')->assertInertia(fn (Assert $page) => $page->where('requests.total', 0)->has('originStatistics.interests', 0));
|
||||
}
|
||||
|
||||
public function test_private_manifestation_link_requires_authentication_and_preserves_the_public_dossier(): void
|
||||
|
||||
@@ -198,9 +198,21 @@ class InvestorGuidanceTest extends TestCase
|
||||
$this->assertArrayNotHasKey('market_evidence', $owner['facts']);
|
||||
$this->assertStringNotContainsString('PRIVATE PERSONAL', json_encode($owner));
|
||||
$this->assertSame(1, $owner['consulted'][0]['version']);
|
||||
$draftUrl = '/simulador-fiscal?draft='.$draft->id;
|
||||
$this->assertSame($draftUrl, $owner['actions']['simulator']);
|
||||
$this->assertSame($draftUrl, $owner['actions']['plan']);
|
||||
$finance = collect($owner['phases'])->firstWhere('id', 'finance');
|
||||
$this->assertSame($draftUrl, $finance['recommendations'][0]['href']);
|
||||
$otherDraft = $draft->replicate();
|
||||
$otherDraft->id = (string) Str::uuid();
|
||||
$otherDraft->economic_snapshot = SimulationFormDraft::encryptSnapshot(['project_summary' => 'Outro projecto, não reutilizar']);
|
||||
$otherDraft->save();
|
||||
$this->getJson('/api/v1/simulation-drafts/'.$draft->id)->assertOk()->assertJsonPath('data.id', $draft->id)->assertJsonPath('data.economic_snapshot.project_summary', 'Ideia guardada');
|
||||
$manager = $this->actingAs($this->manager)->getJson('/balcao-virtual/dossiers/'.$id.'/orientacao')->assertOk()->json();
|
||||
$this->assertNull($manager['financial_draft']);
|
||||
$this->assertSame([], $manager['consulted']);
|
||||
$this->assertSame('/simulador-fiscal', $manager['actions']['simulator']);
|
||||
$this->getJson('/api/v1/simulation-drafts/'.$draft->id)->assertNotFound();
|
||||
$this->actingAs($this->investor)->postJson('/balcao-virtual/dossiers', ['title' => 'Reutilização', 'simulation_id' => $draft->id, 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertJsonPath('id', $id);
|
||||
$this->assertDatabaseCount('desk_cases', 1);
|
||||
$this->actingAs(User::factory()->create())->getJson('/balcao-virtual/dossiers/'.$id.'/orientacao')->assertForbidden();
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InvestorPathTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $owner;
|
||||
|
||||
private User $manager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutVite();
|
||||
$this->owner = User::factory()->create();
|
||||
$this->manager = User::factory()->create();
|
||||
config(['investor-path.enabled' => true, 'portal.managers' => [$this->manager->email], 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
|
||||
$identity = DB::selectOne('select current_database() as name, inet_server_port() as port');
|
||||
$this->assertStringEndsWith('_test', $identity->name);
|
||||
$this->assertSame(55441, (int) $identity->port);
|
||||
Http::fake();
|
||||
}
|
||||
|
||||
private function context(string $title = 'Ideia sintética QA'): string
|
||||
{
|
||||
return $this->actingAs($this->owner)->postJson('/investimentos', ['title' => $title, 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
|
||||
}
|
||||
|
||||
private function pathView(string $id): array
|
||||
{
|
||||
return $this->actingAs($this->owner)->getJson('/investimentos/'.$id)->assertOk()->json();
|
||||
}
|
||||
|
||||
private function snapshot(string $title): array
|
||||
{
|
||||
return ['personal_snapshot' => [], 'economic_snapshot' => ['purpose' => 'simulate_only', 'project_title' => $title, 'project_summary' => 'Descrição guardada do investimento '.$title,
|
||||
'financial_tools_snapshot' => json_encode(['version' => 1, 'data' => ['budgetPeriod' => 'Mensal',
|
||||
'loan' => ['principal' => '100000', 'rate' => '0', 'months' => '24', 'rateType' => 'nominal', 'periodMonths' => '1', 'gracePeriods' => '0', 'upfrontFee' => '0', 'recurringFee' => '0'],
|
||||
'breakEven' => ['fixed' => '1000', 'price' => '100', 'variable' => '50'], 'funding' => ['total' => '100000', 'secured' => '30000'],
|
||||
'incomes' => [['label' => 'Receitas QA', 'amount' => '100']], 'expenses' => [['label' => 'Custos QA', 'amount' => '50']]]])]];
|
||||
}
|
||||
|
||||
public function test_two_independent_investments_recover_tool_evidence_support_and_internal_tasks_without_external_calls(): void
|
||||
{
|
||||
$service = $this->actingAs($this->manager)->postJson('/management/balcao/servicos', ['code' => 'project-preparation', 'confirm' => true])->assertCreated()->json('id');
|
||||
$ids = [$this->context('A sem terra nem financiamento externo'), $this->context('B com terra e financiamento externo')];
|
||||
$draftIds = [];
|
||||
foreach ($ids as $index => $id) {
|
||||
$view = $this->pathView($id);
|
||||
$this->assertCount(19, $view['steps']);
|
||||
$this->assertCount(5, $view['phases']);
|
||||
$this->postJson('/investimentos/'.$id.'/aplicabilidade', ['version' => $view['case']['version'], 'land_needed' => $index ? 'yes' : 'no', 'external_funding' => $index ? 'yes' : 'no', 'viability' => 'proceed', 'reason' => 'Cenário sintético declarado pelo promotor'])->assertOk();
|
||||
$data = [...$this->snapshot($index ? 'B' : 'A'), 'context' => $id];
|
||||
$key = (string) Str::uuid();
|
||||
$created = $this->postJson('/api/v1/simulation-drafts', $data, ['Idempotency-Key' => $key])->assertCreated();
|
||||
$draft = $created->json('data.id');
|
||||
$draftIds[] = $draft;
|
||||
$this->postJson('/api/v1/simulation-drafts', $data, ['Idempotency-Key' => $key])->assertOk()->assertJsonPath('data.replayed', true);
|
||||
$this->postJson('/api/v1/simulation-drafts', $data, ['Idempotency-Key' => (string) Str::uuid()])->assertConflict();
|
||||
$this->getJson('/api/v1/simulation-drafts/current?context='.$id)->assertJsonPath('data.id', $draft)->assertJsonPath('data.economic_snapshot.project_title', $index ? 'B' : 'A');
|
||||
$returned = $this->postJson('/investimentos/'.$id.'/resultados', ['version' => $this->pathView($id)['case']['version'], 'draft_id' => $draft, 'draft_version' => 1])->assertOk()->json('result');
|
||||
$this->postJson('/investimentos/'.$id.'/resultados', ['version' => 1, 'draft_id' => $draft, 'draft_version' => 1])->assertOk()->assertJsonPath('result.id', $returned['id']);
|
||||
$support = $this->postJson('/balcao-virtual/dossiers/'.$id.'/pedidos', ['service_version_id' => $service, 'client_key' => (string) Str::uuid()])->assertCreated()->json('id');
|
||||
$this->getJson('/balcao-virtual/pedidos/'.$support)->assertJsonPath('request.status', 'draft')->assertJsonPath('request.payload.description', $data['economic_snapshot']['project_summary']);
|
||||
$v = $this->pathView($id);
|
||||
$this->assertSame($index ? 'applicable' : 'not_needed_for_preparation', collect($v['steps'])->firstWhere('code', 'S10')['applicability']);
|
||||
$this->postJson('/investimentos/'.$id.'/etapas/S10', ['version' => $v['case']['version'], 'state' => 'waiting_external', 'action' => 'prepare', 'reason' => 'Aguarda localização e informação do terreno'])->assertStatus($index ? 200 : 422);
|
||||
$task = $this->postJson('/investimentos/'.$id.'/tarefas', ['version' => $this->pathView($id)['case']['version'], 'code' => 'S02', 'title' => 'Análise de mercado independente', 'due_at' => now()->addWeek()->toIso8601String(), 'consent' => true])->assertCreated()->json('id');
|
||||
$this->actingAs($this->manager)->postJson('/balcao-virtual/dossiers/'.$id.'/tarefas/'.$task, ['version' => 1, 'status' => 'in_progress', 'assignee_user_id' => $this->manager->id, 'reason' => 'Acompanhamento interno atribuído'])->assertOk();
|
||||
$this->postJson('/balcao-virtual/dossiers/'.$id.'/tarefas/'.$task, ['version' => 2, 'status' => 'done', 'reason' => 'Evidência de mercado preparada enquanto a terra aguarda'])->assertOk();
|
||||
$this->getJson('/investimentos/'.$id)->assertJsonPath('results', [])->assertJsonPath('tasks.0.status', 'done')->assertJsonPath('canWrite', false)->assertJsonPath('canReview', true);
|
||||
$this->getJson('/balcao-virtual/dossiers/'.$id)->assertJsonMissingPath('case.payload.investor_path');
|
||||
// A fresh HTTP session and a newly resolved account recover only this context's data.
|
||||
$this->flushSession();
|
||||
$this->actingAs(User::findOrFail($this->owner->id))->getJson('/investimentos/'.$id)->assertJsonPath('results.0.id', $returned['id'])->assertJsonPath('results.0.draft_id', $draft)->assertJsonPath('tasks.0.status', 'done');
|
||||
$this->get('/investimentos/'.$id)->assertInertia(fn (Assert $page) => $page->component('investor-path/show')->has('steps', 19)->has('phases', 5)->where('case.id', $id));
|
||||
}
|
||||
$this->assertNotSame($draftIds[0], $draftIds[1]);
|
||||
$this->actingAs($this->owner)->getJson('/api/v1/simulation-drafts/'.$draftIds[0].'?context='.$ids[1])->assertForbidden();
|
||||
$this->putJson('/api/v1/simulation-drafts/'.$draftIds[0], [...$this->snapshot('Outra ideia'), 'context' => $ids[1]], ['If-Match' => '"sfd-'.$draftIds[0].'-v1"'])->assertForbidden();
|
||||
$this->postJson('/investimentos/'.$ids[1].'/resultados', ['version' => $this->pathView($ids[1])['case']['version'], 'draft_id' => $draftIds[0], 'draft_version' => 1])->assertForbidden();
|
||||
$this->actingAs($this->manager)->getJson('/api/v1/simulation-drafts/'.$draftIds[0])->assertNotFound();
|
||||
$this->actingAs($this->owner)->getJson('/api/v1/simulation-drafts/current')->assertJsonPath('data', null);
|
||||
$this->assertDatabaseCount('simulation_form_drafts', 2);
|
||||
$this->assertDatabaseCount('journey_steps', 38);
|
||||
$this->assertDatabaseCount('desk_requests', 2);
|
||||
$this->assertDatabaseCount('tasks', 2);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_permissions_stale_versions_and_real_dependencies_do_not_block_independent_preparation(): void
|
||||
{
|
||||
$id = $this->context();
|
||||
$v = $this->pathView($id);
|
||||
$this->assertSame('undetermined', collect($v['steps'])->firstWhere('code', 'S10')['applicability']);
|
||||
$payload = ['version' => $v['case']['version'], 'state' => 'preparing', 'action' => 'prepare', 'reason' => 'Preparação independente'];
|
||||
$this->postJson('/investimentos/'.$id.'/etapas/S18', $payload)->assertOk();
|
||||
$this->postJson('/investimentos/'.$id.'/etapas/S02', $payload)->assertConflict();
|
||||
$payload['version'] = $this->pathView($id)['case']['version'];
|
||||
$this->postJson('/investimentos/'.$id.'/etapas/S18', [...$payload, 'action' => 'operate'])->assertUnprocessable();
|
||||
$this->postJson('/investimentos/'.$id.'/etapas/S18', [...$payload, 'state' => 'completed'])->assertUnprocessable();
|
||||
$this->postJson('/investimentos/'.$id.'/etapas/S02', [...$payload, 'state' => 'approved', 'action' => 'record_external'])->assertForbidden();
|
||||
$this->actingAs($this->manager)->postJson('/investimentos/'.$id.'/etapas/S02', [...$payload, 'state' => 'approved', 'action' => 'record_external'])->assertUnprocessable();
|
||||
$stranger = User::factory()->create();
|
||||
$this->actingAs($stranger)->getJson('/investimentos/'.$id)->assertForbidden();
|
||||
$this->postJson('/api/v1/simulation-drafts', [...$this->snapshot('Invasão'), 'context' => $id], ['Idempotency-Key' => (string) Str::uuid()])->assertForbidden();
|
||||
$this->postJson('/investimentos/'.$id.'/etapas/S02', $payload)->assertForbidden();
|
||||
$this->assertDatabaseCount('simulation_form_drafts', 0);
|
||||
$this->assertDatabaseCount('journey_steps', 19);
|
||||
$this->actingAs($this->owner)->get('/dashboard')->assertInertia(fn (Assert $page) => $page->where('summary', null)->where('investorPath.contexts.0.id', $id));
|
||||
$this->actingAs($this->manager)->get('/dashboard')->assertInertia(fn (Assert $page) => $page->has('summary')->missing('investorPath'));
|
||||
}
|
||||
|
||||
public function test_applicability_changes_preserve_old_branch_evidence_and_legacy_workflow(): void
|
||||
{
|
||||
$id = $this->context();
|
||||
$workflow = DB::table('desk_cases')->where('id', $id)->value('workflow');
|
||||
$facts = ['land_needed' => 'yes', 'external_funding' => 'unknown', 'viability' => 'reformulate', 'reason' => 'Preparação provisória'];
|
||||
$this->postJson('/investimentos/'.$id.'/aplicabilidade', ['version' => $this->pathView($id)['case']['version'], ...$facts])->assertOk();
|
||||
$this->postJson('/investimentos/'.$id.'/etapas/S10', ['version' => $this->pathView($id)['case']['version'], 'state' => 'preparing', 'action' => 'prepare', 'reason' => 'Evidência anterior do terreno'])->assertOk();
|
||||
$this->postJson('/investimentos/'.$id.'/aplicabilidade', ['version' => $this->pathView($id)['case']['version'], ...$facts, 'land_needed' => 'no'])->assertOk();
|
||||
$step = collect($this->pathView($id)['steps'])->firstWhere('code', 'S10');
|
||||
$this->assertSame('dispensed', $step['state']);
|
||||
$this->assertSame('preparing', $step['saved_state']);
|
||||
$this->assertSame('Evidência anterior do terreno', $step['detail']['reason']);
|
||||
$this->postJson('/investimentos/'.$id.'/aplicabilidade', ['version' => $this->pathView($id)['case']['version'], ...$facts])->assertOk();
|
||||
$this->assertSame('preparing', collect($this->pathView($id)['steps'])->firstWhere('code', 'S10')['state']);
|
||||
$this->assertSame($workflow, DB::table('desk_cases')->where('id', $id)->value('workflow'));
|
||||
config(['investor-path.enabled' => false]);
|
||||
$this->getJson('/investimentos')->assertNotFound();
|
||||
$this->getJson('/investimentos/'.$id)->assertNotFound();
|
||||
$this->getJson('/balcao-virtual/dossiers/'.$id)->assertOk();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use App\Services\Malware\MalwareScanner;
|
||||
use App\Services\OpportunityManagement;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class OpportunityRealDocumentOperationsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function setupDraft(): Opportunity
|
||||
{
|
||||
Storage::fake('local');
|
||||
Queue::fake(); // Inspect quarantine first; execute the real job explicitly below.
|
||||
config(['services.malware.scanner' => 'clamd', 'services.malware.host' => '127.0.0.1', 'services.malware.port' => 3310]);
|
||||
if (! app(MalwareScanner::class)->health()['available']) {
|
||||
$this->markTestSkipped('Real ClamD required; operational evidence remains pending.');
|
||||
}
|
||||
$user = User::factory()->create();
|
||||
config(['portal.managers' => [$user->email]]);
|
||||
$id = $this->actingAs($user)->postJson('/management/opportunities/drafts', ['creation_key' => (string) Str::uuid(), 'reason' => 'Operações documentais reais em QA isolada'])->assertCreated()->json('id');
|
||||
|
||||
return Opportunity::findOrFail($id);
|
||||
}
|
||||
|
||||
private function input(Opportunity $o, array $payload): array
|
||||
{
|
||||
return ['etag' => app(OpportunityManagement::class)->etag($o->fresh()), 'creation_key' => (string) Str::uuid(), 'payload' => $payload, 'reason' => 'Operação e recuperação documental em QA isolada'];
|
||||
}
|
||||
|
||||
private function scan(string $id): void
|
||||
{
|
||||
(new ScanDocumentForMalware($id))->handle(app(MalwareScanner::class));
|
||||
$d = DB::table('documents')->where('id', $id)->first();
|
||||
$this->assertSame('clean', $d->malware_scan_status);
|
||||
$this->assertSame('ClamAV', $d->malware_engine);
|
||||
$this->assertNotSame('unknown', $d->malware_engine_version);
|
||||
$this->assertSame(trim($d->sha256), $d->malware_scanned_sha256);
|
||||
$this->assertNotNull($d->malware_signatures_at);
|
||||
$this->assertDatabaseHas('document_malware_scans', ['document_id' => $id, 'result' => 'clean', 'engine' => 'ClamAV']);
|
||||
}
|
||||
|
||||
public function test_real_original_replacement_quarantine_retry_download_delete_restore_and_authorization(): void
|
||||
{
|
||||
$o = $this->setupDraft();
|
||||
$bytes = "%PDF-1.7\nSynthetic QA only\n%%EOF";
|
||||
$url = '/management/opportunities/'.$o->id.'/resources/documents';
|
||||
$r = $this->post($url, [...$this->input($o, ['title' => 'Synthetic QA document']), 'file' => UploadedFile::fake()->createWithContent('qa.pdf', $bytes)], ['Accept' => 'application/json'])->assertCreated()->json();
|
||||
$doc = DB::table('opportunity_documents')->where('id', $r['id'])->value('document_id');
|
||||
$download = $url.'/'.$r['id'].'/download';
|
||||
$this->get($download)->assertStatus(423);
|
||||
config(['services.malware.port' => 1]);
|
||||
(new ScanDocumentForMalware($doc))->handle(app(MalwareScanner::class));
|
||||
$this->assertDatabaseHas('documents', ['id' => $doc, 'status' => 'quarantined', 'malware_scan_status' => 'inconclusive']);
|
||||
$this->get($download)->assertStatus(423);
|
||||
config(['services.malware.port' => 3310]);
|
||||
$this->scan($doc);
|
||||
$this->get($download)->assertOk()->assertContent($bytes);
|
||||
$old = DB::table('documents')->where('id', $doc)->first();
|
||||
$this->assertNotSame($bytes, Storage::disk('local')->get($old->storage_key));
|
||||
$newBytes = "%PDF-1.7\nSecond synthetic QA edition\n%%EOF";
|
||||
$replace = [...$this->input($o, ['title' => 'Synthetic second edition', 'edition' => '2']), 'version' => 1, 'file' => UploadedFile::fake()->createWithContent('qa-v2.pdf', $newBytes)];
|
||||
$this->post($url.'/'.$r['id'], $replace, ['Accept' => 'application/json'])->assertOk();
|
||||
$this->post($url.'/'.$r['id'], $replace, ['Accept' => 'application/json'])->assertConflict();
|
||||
$next = DB::table('opportunity_documents')->where('id', $r['id'])->value('document_id');
|
||||
$this->assertNotSame($doc, $next);
|
||||
$this->assertSame($bytes, Crypt::decryptString(Storage::disk('local')->get($old->storage_key)));
|
||||
$this->get($download)->assertStatus(423);
|
||||
$this->scan($next);
|
||||
$this->get($download)->assertContent($newBytes);
|
||||
$life = ['etag' => app(OpportunityManagement::class)->etag($o->fresh()), 'version' => 2, 'action' => 'delete', 'confirm' => true, 'reason' => 'Exclusão lógica do recurso apenas em QA'];
|
||||
$this->postJson($url.'/'.$r['id'].'/lifecycle', $life)->assertOk();
|
||||
$this->get($download)->assertStatus(423);
|
||||
$this->postJson($url.'/'.$r['id'].'/lifecycle', [...$life, 'etag' => app(OpportunityManagement::class)->etag($o->fresh()), 'version' => 3, 'action' => 'restore'])->assertOk();
|
||||
$this->get($download)->assertContent($newBytes);
|
||||
$this->assertDatabaseHas('documents', ['id' => $doc, 'deleted_at' => null]);
|
||||
$this->actingAs(User::factory()->create())->get($download)->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_three_derivatives_have_independent_real_scans_signed_previews_and_integrity_checks(): void
|
||||
{
|
||||
$o = $this->setupDraft();
|
||||
$this->assertTrue(function_exists('imagewebp'), 'GD/WebP required; rejection does not demonstrate derivatives.');
|
||||
$image = imagecreatetruecolor(1600, 1600);
|
||||
ob_start();
|
||||
imagepng($image);
|
||||
$bytes = ob_get_clean();
|
||||
imagedestroy($image);
|
||||
$base = '/management/opportunities/'.$o->id.'/resources/images';
|
||||
$r = $this->post($base, [...$this->input($o, ['title' => 'Synthetic QA image', 'alt_text' => 'Synthetic square for QA only', 'caption' => 'Synthetic image no active data', 'rights_status' => 'owned']), 'file' => UploadedFile::fake()->createWithContent('qa.png', $bytes)], ['Accept' => 'application/json'])->assertCreated()->json();
|
||||
$doc = DB::table('opportunity_images')->where('id', $r['id'])->value('document_id');
|
||||
$this->postJson($base.'/'.$r['id'].'/prepare', ['etag' => app(OpportunityManagement::class)->etag($o->fresh()), 'version' => 1, 'reason' => 'Preparar derivadas sintéticas em QA'])->assertStatus(423);
|
||||
$this->scan($doc);
|
||||
$input = ['etag' => app(OpportunityManagement::class)->etag($o->fresh()), 'version' => 1, 'reason' => 'Preparar derivadas sintéticas em QA'];
|
||||
$this->postJson($base.'/'.$r['id'].'/prepare', $input)->assertOk();
|
||||
$this->postJson($base.'/'.$r['id'].'/prepare', $input)->assertConflict();
|
||||
$p = json_decode(DB::table('opportunity_images')->where('id', $r['id'])->value('payload'), true);
|
||||
$this->assertCount(3, $p['derivatives']);
|
||||
$this->assertSame([320, 640, 1280], array_column($p['derivatives'], 'width'));
|
||||
foreach ($p['derivatives'] as $derived) {
|
||||
$id = $derived['document_id'];
|
||||
$url = URL::temporarySignedRoute('management.opportunity-image-preview', now()->addMinutes(10), ['id' => $o->id, 'resourceId' => $r['id'], 'documentId' => $id]);
|
||||
$this->get($url)->assertStatus(423);
|
||||
$this->scan($id);
|
||||
$d = DB::table('documents')->where('id', $id)->first();
|
||||
$webp = Crypt::decryptString(Storage::disk('local')->get($d->storage_key));
|
||||
$this->assertSame('image/webp', getimagesizefromstring($webp)['mime']);
|
||||
$this->assertStringNotContainsString('EXIF', $webp);
|
||||
$this->get($url)->assertOk()->assertHeader('Content-Type', 'image/webp')->assertContent($webp);
|
||||
$this->get(explode('?', $url)[0])->assertForbidden();
|
||||
Storage::disk('local')->put($d->storage_key, Crypt::encryptString($webp.'tampered QA'));
|
||||
$this->get($url)->assertConflict();
|
||||
Storage::disk('local')->put($d->storage_key, Crypt::encryptString($webp));
|
||||
}
|
||||
$this->assertSame(4, DB::table('document_malware_scans')->where('result', 'clean')->count());
|
||||
$this->get('/management/opportunities/'.$o->id.'/resources')->assertOk();
|
||||
$this->actingAs(User::factory()->create())->get($url)->assertForbidden();
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,7 @@ class OpportunityResourcesTest extends TestCase
|
||||
$this->assertSame('image/webp', getimagesizefromstring($webp)['mime']);
|
||||
(new ScanDocumentForMalware($document->id))->handle(new CleanScannerFixture);
|
||||
}
|
||||
$preview = URL::temporarySignedRoute('opportunity-image-preview', now()->addMinutes(10), ['id' => $o->id, 'resourceId' => $image->id, 'documentId' => $payload['derivatives'][0]['document_id']]);
|
||||
$preview = URL::temporarySignedRoute('management.opportunity-image-preview', now()->addMinutes(10), ['id' => $o->id, 'resourceId' => $image->id, 'documentId' => $payload['derivatives'][0]['document_id']]);
|
||||
$this->get($preview)->assertOk()->assertHeader('Content-Type', 'image/webp');
|
||||
$this->actingAs(User::factory()->create())->get($preview)->assertForbidden();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\VirtualDesk;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PortalAuditConsumerLotTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutVite();
|
||||
Http::fake();
|
||||
config(['suitecrm.enabled' => false, 'investmoz_ai.enabled' => false, 'crm.notifications.enabled' => false]);
|
||||
$this->assertSame(55441, (int) DB::selectOne('SELECT inet_server_port() AS port')->port);
|
||||
}
|
||||
|
||||
private function manager(): User
|
||||
{
|
||||
$actor = User::factory()->create();
|
||||
$actor->forceFill(['two_factor_secret' => Crypt::encryptString('QA-ONLY'), 'two_factor_confirmed_at' => now()])->save();
|
||||
config(['portal.managers' => [$actor->email]]);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
private function location(string $type, ?string $parent = null): string
|
||||
{
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('geo_locations')->insert(['id' => $id, 'name' => 'Synthetic '.$type, 'code' => $id, 'location_type' => $type, 'parent_id' => $parent,
|
||||
'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$id, 'data_classification' => 'public']);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function test_private_official_context_is_authorized_and_does_not_become_a_public_investment_site(): void
|
||||
{
|
||||
$manager = $this->manager();
|
||||
$province = $this->location('province', $this->location('country'));
|
||||
$district = $this->location('district', $province);
|
||||
$post = $this->location('admin_post', $district);
|
||||
$locality = $this->location('locality', $post);
|
||||
$id = (string) Str::uuid();
|
||||
$properties = json_encode(['purpose' => 'administrative_boundary', 'authority' => 'Synthetic official fixture, QA only', 'source_version' => '2019-06-07', 'legal_currentness' => 'not_determined', 'source_sha256' => str_repeat('a', 64)]);
|
||||
DB::insert("INSERT INTO geo_geometries(id,geo_location_id,geometry_type,geom,properties,data_classification) VALUES (?,?,'polygon',ST_GeomFromText('POLYGON((32 -20,33 -20,33 -19,32 -19,32 -20))',4326),?::jsonb,'internal')", [$id, $post, $properties]);
|
||||
$url = '/api/v1/investment/territory-geometries?territory_id='.$locality;
|
||||
$this->getJson($url)->assertOk()->assertJsonPath('territory_registered', true)->assertJsonPath('selected_geometry_available', false)->assertJsonCount(0, 'areas');
|
||||
$private = '/management/territory-geometries?territory_id='.$locality;
|
||||
$this->actingAs(User::factory()->create())->getJson($private)->assertForbidden();
|
||||
$this->actingAs($manager)->getJson($private)->assertOk()->assertJsonPath('selected_geometry_available', false)->assertJsonPath('context_geometry_count', 1)
|
||||
->assertJsonPath('areas.0.territory_id', $post)->assertJsonPath('areas.0.source_date', '2019-06-07')->assertJsonPath('areas.0.geometry.type', 'Polygon')->assertJsonPath('investment_site', false);
|
||||
$this->getJson('/management/territory-geometries?territory_id='.$post)->assertJsonPath('selected_geometry_available', true);
|
||||
$this->getJson('/management/territory-geometries?territory_id=bad')->assertUnprocessable();
|
||||
DB::table('geo_geometries')->where('id', $id)->update(['data_classification' => 'public']); // explicit synthetic QA permission, never production
|
||||
$this->getJson($url)->assertJsonPath('context_geometry_count', 1)->assertJsonPath('selected_geometry_available', false);
|
||||
DB::table('geo_geometries')->where('id', $id)->update(['deleted_at' => now()]);
|
||||
$this->getJson($private)->assertJsonCount(0, 'areas');
|
||||
$this->assertDatabaseCount('opportunity_locations', 0);
|
||||
$this->assertDatabaseCount('investment_zone_geometries', 0);
|
||||
}
|
||||
|
||||
public function test_all_three_desk_forms_save_recover_countries_and_support_authorized_corrections(): void
|
||||
{
|
||||
$manager = $this->manager();
|
||||
$owner = User::factory()->create();
|
||||
foreach (['project-preparation', 'project-change', 'project-follow-up'] as $index => $code) {
|
||||
$service = $this->actingAs($manager)->postJson('/management/balcao/servicos', ['code' => $code, 'confirm' => true])->assertCreated()->json('id');
|
||||
$case = $this->actingAs($owner)->postJson('/balcao-virtual/dossiers', ['title' => 'Synthetic QA '.$code, 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
|
||||
$id = $this->postJson('/balcao-virtual/dossiers/'.$case.'/pedidos', ['service_version_id' => $service, 'client_key' => (string) Str::uuid()])->assertCreated()->json('id');
|
||||
$url = '/balcao-virtual/pedidos/'.$id;
|
||||
$payload = ['title' => 'Synthetic QA', 'description' => 'Synthetic description', 'country' => 'PT', 'declared_origin_country_code' => 'ZA'];
|
||||
if ($code === 'project-preparation') {
|
||||
$payload += ['investment_amount' => '10.00', 'currency' => 'MZN'];
|
||||
}
|
||||
if ($code === 'project-change') {
|
||||
$payload += ['original_reference' => 'QA-only', 'change_reason' => 'Synthetic change'];
|
||||
}
|
||||
if ($code === 'project-follow-up') {
|
||||
$payload += ['original_reference' => 'QA-only', 'progress' => 'Synthetic progress'];
|
||||
}
|
||||
$this->putJson($url, ['version' => 1, 'payload' => [...$payload, 'country' => 'ZZ']])->assertUnprocessable()->assertJsonValidationErrors('payload.country');
|
||||
$this->putJson($url, ['version' => 1, 'payload' => $payload])->assertOk()->assertJsonPath('version', 2);
|
||||
$this->get($url)->assertInertia(fn (Assert $p) => $p->has('countries', 249)->where('request.payload.country', 'PT')->where('request.payload.declared_origin_country_code', 'ZA'));
|
||||
$cipher = DB::table('desk_requests')->where('id', $id)->value('payload');
|
||||
$this->assertSame('PT', app(VirtualDesk::class)->decode($cipher)['country']);
|
||||
$this->putJson($url, ['version' => 2, 'payload' => $payload, 'submit' => true, 'consent' => true])->assertOk()->assertJsonPath('status', 'submitted');
|
||||
$this->postJson($url.'/estado', ['version' => 3, 'status' => 'reviewing', 'reason' => 'Synthetic self review'])->assertForbidden();
|
||||
$this->actingAs($manager)->postJson($url.'/estado', ['version' => 3, 'status' => 'returned', 'reason' => 'Synthetic authorized correction', 'editable_fields' => ['country', 'declared_origin_country_code']])->assertOk()->assertJsonPath('version', 4);
|
||||
$this->actingAs($owner)->putJson($url, ['version' => 4, 'payload' => [...$payload, 'title' => 'Unauthorized change']])->assertUnprocessable();
|
||||
$this->putJson($url, ['version' => 4, 'payload' => [...$payload, 'country' => 'MZ', 'declared_origin_country_code' => 'PT']])->assertOk()->assertJsonPath('version', 5);
|
||||
$this->getJson($url)->assertJsonPath('request.payload.country', 'MZ')->assertJsonPath('request.payload.declared_origin_country_code', 'PT');
|
||||
$this->putJson($url, ['version' => 4, 'payload' => $payload])->assertConflict();
|
||||
$this->actingAs(User::factory()->create())->getJson($url)->assertForbidden();
|
||||
$this->assertDatabaseHas('desk_requests', ['id' => $id, 'version' => 5, 'status' => 'returned']);
|
||||
$this->actingAs($manager)->get('/management/interests?origin=PT')->assertInertia(fn (Assert $p) => $p
|
||||
->where('originStatistics.desk.0.code', 'PT')->where('originStatistics.desk.0.count', $index + 1)
|
||||
->has('originStatistics.interests', 0)->has('originStatistics.businesses', 0));
|
||||
$this->get('/management/interests?origin=ZA')->assertInertia(fn (Assert $p) => $p->has('originStatistics.desk', 0));
|
||||
}
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\InterestRequest;
|
||||
use App\Models\SimulationFormDraft;
|
||||
use App\Models\User;
|
||||
use App\Services\CountryCatalogue;
|
||||
use App\Services\VirtualDesk;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PortalAuditContinuationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutVite();
|
||||
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
||||
$this->assertStringEndsWith('_test', $identity->database);
|
||||
$this->assertSame(55441, (int) $identity->port);
|
||||
config(['portal.notifications_enabled' => false, 'investmoz_ai.enabled' => false, 'suitecrm.enabled' => false]);
|
||||
Http::fake();
|
||||
}
|
||||
|
||||
private function publicPayload(): array
|
||||
{
|
||||
return ['fullName' => 'Pessoa sintética QA', 'email' => 'portal-integration-test@gmail.com', 'investorType' => 'individual',
|
||||
'country' => 'PT', 'declared_origin_country_code' => 'ZA', 'sectorInterest' => 'Energia',
|
||||
'message' => 'Pedido exclusivamente sintético na base QA.', 'privacyConsent' => true];
|
||||
}
|
||||
|
||||
public function test_public_country_and_origin_survive_api_storage_management_recovery_and_statistics(): void
|
||||
{
|
||||
foreach (['/', '/manifestar-interesse', '/apoio', '/formularios/investidor', '/formularios/empresa'] as $url) {
|
||||
$this->get($url)->assertInertia(fn (Assert $page) => $page->has('countries', 249));
|
||||
}
|
||||
$data = $this->publicPayload();
|
||||
$this->postJson('/api/v1/interest-requests', [...$data, 'country' => 'ZZ'], ['Idempotency-Key' => (string) Str::uuid()])->assertUnprocessable()->assertJsonValidationErrors('country');
|
||||
$this->assertDatabaseCount('interest_requests', 0);
|
||||
$key = ['Idempotency-Key' => (string) Str::uuid()];
|
||||
$receipt = $this->postJson('/api/v1/interest-requests', $data, $key)->assertCreated()->json();
|
||||
$this->postJson('/api/v1/interest-requests', $data, $key)->assertExactJson($receipt);
|
||||
$interest = InterestRequest::firstOrFail();
|
||||
$this->assertSame('PT', $interest->country);
|
||||
$this->assertSame('ZA', $interest->metadata['declared_origin_country_code']);
|
||||
$manager = User::factory()->create();
|
||||
config(['portal.managers' => [$manager->email]]);
|
||||
$this->actingAs($manager)->get('/management/interests/'.$interest->id)->assertInertia(fn (Assert $page) => $page
|
||||
->component('management/interest')->where('interest.country', 'PT')->where('declaredOrigin.code', 'ZA')->where('declaredOrigin.name', app(CountryCatalogue::class)->describe('ZA')['name']));
|
||||
$this->get('/management/crm/leads?origin=ZA')->assertInertia(fn (Assert $page) => $page
|
||||
->where('originStatistics.interests.0.code', 'ZA')->where('originStatistics.interests.0.count', 1));
|
||||
$this->get('/management/crm/leads?origin=PT')->assertInertia(fn (Assert $page) => $page->has('originStatistics.interests', 0));
|
||||
$this->actingAs(User::factory()->create())->get('/management/interests/'.$interest->id)->assertForbidden();
|
||||
$this->assertDatabaseCount('interest_requests', 1);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_support_kinds_keep_iso_country_separate_and_legacy_text_unchanged(): void
|
||||
{
|
||||
$data = $this->publicPayload();
|
||||
$manager = User::factory()->create();
|
||||
config(['portal.managers' => [$manager->email]]);
|
||||
foreach (['support', 'investor', 'company', 'partnership'] as $index => $kind) {
|
||||
$payload = [...$data, 'kind' => $kind, 'company' => 'Empresa sintética QA', 'submission_key' => (string) Str::uuid()];
|
||||
$this->post('/apoio', $payload)->assertSessionHasNoErrors()->assertRedirect();
|
||||
$this->assertSame('PT', InterestRequest::latest('id')->firstOrFail()->country);
|
||||
$this->assertSame('ZA', InterestRequest::latest('id')->firstOrFail()->metadata['declared_origin_country_code']);
|
||||
$interest = InterestRequest::latest('id')->firstOrFail();
|
||||
$this->actingAs($manager)->get('/management/interests/'.$interest->id)->assertInertia(fn (Assert $p) => $p
|
||||
->where('interest.country', 'PT')->where('declaredOrigin.code', 'ZA'));
|
||||
$this->get('/management/interests?country=PT&origin=ZA')->assertInertia(fn (Assert $p) => $p
|
||||
->where('requests.total', $index + 1)->where('originStatistics.interests.0.code', 'ZA')
|
||||
->where('originStatistics.interests.0.count', $index + 1));
|
||||
$this->get('/management/interests?origin=PT')->assertInertia(fn (Assert $p) => $p->where('requests.total', 0)->has('originStatistics.interests', 0));
|
||||
}
|
||||
$this->postJson('/apoio', [...$data, 'kind' => 'support', 'country' => 'ZZ', 'submission_key' => (string) Str::uuid()])->assertUnprocessable()->assertJsonValidationErrors('country');
|
||||
$this->post('/apoio', [...$data, 'kind' => 'support', 'country' => 'Moçambique', 'submission_key' => (string) Str::uuid()])->assertSessionHasNoErrors();
|
||||
$this->assertSame('Moçambique', InterestRequest::latest('id')->firstOrFail()->country);
|
||||
$this->assertDatabaseCount('interest_requests', 5);
|
||||
}
|
||||
|
||||
private function territory(string $type, ?string $parent = null, bool $public = true): string
|
||||
{
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('geo_locations')->insert(['id' => $id, 'code' => $id, 'name' => 'Território sintético QA '.$type, 'location_type' => $type,
|
||||
'parent_id' => $parent, 'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$id,
|
||||
'data_classification' => $public ? 'public' : 'internal']);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function test_iso_catalogue_does_not_depend_on_geo_publication_and_is_searchable_and_pageable(): void
|
||||
{
|
||||
$this->assertDatabaseCount('geo_locations', 0);
|
||||
$this->assertDatabaseCount('reference_catalog_versions', 0);
|
||||
$codes = [];
|
||||
foreach ([1, 2, 3] as $page) {
|
||||
$rows = $this->getJson('/api/v1/catalogs/countries?per_page=100&page='.$page)->assertOk()->assertJsonPath('total', 249)->json('data');
|
||||
$codes = [...$codes, ...array_column($rows, 'code')];
|
||||
}
|
||||
$this->assertCount(249, array_unique($codes));
|
||||
$this->assertContains('PT', $codes);
|
||||
$this->assertContains('ZA', $codes);
|
||||
$this->getJson('/api/v1/catalogs/countries?q=Mocambique')->assertOk()->assertJsonPath('data.0.value', 'MZ');
|
||||
$this->getJson('/api/v1/catalogs/countries?q=Portugal')->assertOk()->assertJsonPath('data.0.value', 'PT');
|
||||
$this->getJson('/api/v1/catalogs/countries?parent_id='.Str::uuid())->assertUnprocessable();
|
||||
$this->getJson('/api/v1/catalogs/countries?per_page=101')->assertUnprocessable();
|
||||
$this->assertDatabaseCount('geo_locations', 0);
|
||||
$this->assertDatabaseCount('reference_catalog_versions', 0);
|
||||
}
|
||||
|
||||
public function test_simulator_recovers_iso_countries_in_private_draft_without_reinterpreting_legacy_values(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$this->actingAs($owner);
|
||||
$data = ['personal_snapshot' => ['fiscal_residency_country' => 'PT'],
|
||||
'economic_snapshot' => ['purpose' => 'simulate_only', 'journey_type' => 'investor', 'tax_calculation' => 'no', 'funds_origin_countries' => ['PT', 'ZA']]];
|
||||
$headers = ['Idempotency-Key' => (string) Str::uuid()];
|
||||
$created = $this->postJson('/api/v1/simulation-drafts', $data, $headers)->assertCreated();
|
||||
$id = $created->json('data.id');
|
||||
$row = DB::table('simulation_form_drafts')->where('id', $id)->first();
|
||||
$this->assertSame('PT', SimulationFormDraft::decryptSnapshot($row->personal_snapshot)['fiscal_residency_country']);
|
||||
$this->assertSame(['PT', 'ZA'], SimulationFormDraft::decryptSnapshot($row->economic_snapshot)['funds_origin_countries']);
|
||||
$this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->assertJsonPath('data.personal_snapshot.fiscal_residency_country', 'PT')->assertJsonPath('data.economic_snapshot.funds_origin_countries', ['PT', 'ZA']);
|
||||
$this->postJson('/api/v1/simulation-drafts', [...$data, 'personal_snapshot' => ['fiscal_residency_country' => 'ZZ']], ['Idempotency-Key' => (string) Str::uuid()])
|
||||
->assertUnprocessable()->assertJsonValidationErrors('personal_snapshot.fiscal_residency_country');
|
||||
$legacy = [...$data, 'personal_snapshot' => ['fiscal_residency_country' => 'Portugal']];
|
||||
$this->putJson('/api/v1/simulation-drafts/'.$id, $legacy, ['If-Match' => $created->headers->get('ETag')])->assertOk()->assertJsonPath('data.version_no', 2);
|
||||
$this->actingAs(User::factory()->create())->getJson('/api/v1/simulation-drafts/'.$id)->assertNotFound();
|
||||
$this->actingAs($owner)->getJson('/api/v1/simulation-drafts/'.$id)->assertJsonPath('data.personal_snapshot.fiscal_residency_country', 'Portugal');
|
||||
$this->assertDatabaseCount('simulations', 0);
|
||||
}
|
||||
|
||||
public function test_desk_territory_cascade_is_saved_recovered_and_presented_with_origin_without_cross_account_access(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$manager = User::factory()->create();
|
||||
config(['portal.managers' => [$manager->email]]);
|
||||
$country = $this->territory('country');
|
||||
$province = $this->territory('province', $country);
|
||||
$district = $this->territory('district', $province);
|
||||
$post = $this->territory('admin_post', $district);
|
||||
$locality = $this->territory('locality', $post);
|
||||
$private = $this->territory('district', $province, false);
|
||||
foreach ([['province', null, $province], ['district', $province, $district], ['admin_post', $district, $post], ['locality', $post, $locality]] as [$type, $parent, $selected]) {
|
||||
$this->getJson('/api/v1/investment/territory-options?'.http_build_query(['type' => $type, 'parent_id' => $parent, 'q' => 'sintetico']))
|
||||
->assertOk()->assertJsonPath('data.0.id', $selected);
|
||||
}
|
||||
$service = $this->actingAs($manager)->postJson('/management/balcao/servicos', ['code' => 'project-preparation', 'confirm' => true])->assertCreated()->json('id');
|
||||
$case = $this->actingAs($owner)->postJson('/balcao-virtual/dossiers', ['title' => 'Dossier sintético QA', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
|
||||
$id = $this->postJson('/balcao-virtual/dossiers/'.$case.'/pedidos', ['service_version_id' => $service, 'client_key' => (string) Str::uuid()])->assertCreated()->json('id');
|
||||
$url = '/balcao-virtual/pedidos/'.$id;
|
||||
$payload = ['title' => 'Projeto sintético QA', 'territory_id' => $locality, 'location_source' => 'Fonte sintética exclusivamente QA', 'declared_origin_country_code' => 'PT'];
|
||||
$this->putJson($url, ['version' => 1, 'payload' => $payload])->assertOk()->assertJsonPath('version', 2);
|
||||
$stored = DB::table('desk_requests')->where('id', $id)->value('payload');
|
||||
$this->assertSame($locality, app(VirtualDesk::class)->decode($stored)['territory_id']);
|
||||
$this->assertStringNotContainsString($locality, $stored);
|
||||
$this->get($url)->assertInertia(fn (Assert $page) => $page->component('desk/request')->has('countries', 249)
|
||||
->where('request.payload.territory_id', $locality)->where('request.payload.declared_origin_country_code', 'PT')
|
||||
->has('territoryPaths.territory_id', 4)->where('territoryPaths.territory_id.0.id', $province)->where('territoryPaths.territory_id.3.id', $locality));
|
||||
$this->putJson($url, ['version' => 2, 'payload' => [...$payload, 'territory_id' => $private]])->assertUnprocessable()->assertJsonValidationErrors('payload.territory_id');
|
||||
$this->getJson($url)->assertJsonPath('request.version', 2)->assertJsonPath('request.payload.territory_id', $locality);
|
||||
$this->actingAs(User::factory()->create())->getJson($url)->assertForbidden();
|
||||
$this->actingAs($owner)->getJson($url)->assertJsonPath('territoryPaths.territory_id.3.id', $locality);
|
||||
$submitted = [...$payload, 'description' => 'Descrição exclusivamente sintética na QA.', 'investment_amount' => '10.00', 'currency' => 'MZN'];
|
||||
$this->putJson($url, ['version' => 2, 'payload' => $submitted, 'consent' => true, 'submit' => true])->assertOk()->assertJsonPath('status', 'submitted');
|
||||
$this->actingAs($manager)->get('/management/crm/leads?origin=PT')->assertInertia(fn (Assert $page) => $page
|
||||
->has('originStatistics.interests', 0)->where('originStatistics.desk.0.code', 'PT')->where('originStatistics.desk.0.count', 1));
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\ExistingBusiness;
|
||||
use App\Models\InterestRequest;
|
||||
use App\Models\LandAssistanceCase;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use App\Services\DeclaredOriginStatistics;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\OpportunityMaturity;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PortalAuditNextLotTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutVite();
|
||||
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
||||
$this->assertStringEndsWith('_test', $identity->database);
|
||||
$this->assertSame(55441, (int) $identity->port);
|
||||
config(['portal.notifications_enabled' => false, 'existing-business.enabled' => true, 'investmoz_ai.enabled' => false, 'suitecrm.enabled' => false]);
|
||||
Http::fake();
|
||||
}
|
||||
|
||||
public function test_eight_maturity_stages_recover_versioned_without_reinterpreting_legacy_or_publishing(): void
|
||||
{
|
||||
$manager = User::factory()->create();
|
||||
config(['portal.managers' => [$manager->email]]);
|
||||
$this->actingAs($manager);
|
||||
foreach (array_keys(OpportunityMaturity::STAGES) as $index => $stage) {
|
||||
$input = ['creation_key' => (string) Str::uuid(), 'reason' => 'Verificação das oito etapas exclusivamente na QA', 'maturity_level' => $index + 1];
|
||||
if ($index < 6) {
|
||||
$input['maturity_catalogue_version'] = OpportunityMaturity::VERSION;
|
||||
}
|
||||
$result = $this->postJson('/management/opportunities/drafts', $input)->assertCreated()->json();
|
||||
$record = Opportunity::findOrFail($result['id']);
|
||||
$stored = json_decode($record->management_draft, true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->assertSame($stage, $stored['maturity_stage']);
|
||||
$this->assertSame($index + 1, $stored['maturity_level']);
|
||||
$this->assertSame(OpportunityMaturity::VERSION, $stored['maturity_catalogue_version']);
|
||||
$this->assertNull($record->maturity_level);
|
||||
$this->assertSame('draft', $record->status);
|
||||
$this->postJson('/management/opportunities/drafts', $input)->assertCreated()->assertJsonPath('id', $record->id);
|
||||
$this->get('/management/opportunities/'.$record->id.'/edit')->assertInertia(fn (Assert $page) => $page
|
||||
->has('maturityCatalogue.stages', 8)->where('values.maturity_stage', $stage)->where('values.maturity_catalogue_version', OpportunityMaturity::VERSION));
|
||||
}
|
||||
$legacy = $this->postJson('/management/opportunities/drafts', ['creation_key' => (string) Str::uuid(), 'reason' => 'Preservar significado histórico exclusivamente QA', 'maturity_level' => 6])->assertCreated()->json();
|
||||
$record = Opportunity::findOrFail($legacy['id']);
|
||||
$label = $record->maturity_label;
|
||||
$this->assertSame(6, $record->maturity_level);
|
||||
$this->assertSame(OpportunityMaturity::LEGACY_LABELS[6], $label);
|
||||
$this->putJson('/management/opportunities/'.$record->id.'/draft', ['etag' => $legacy['etag'], 'reason' => 'Rejeitar conversão implícita do catálogo histórico', 'maturity_catalogue_version' => OpportunityMaturity::VERSION])->assertUnprocessable()->assertJsonValidationErrors('maturity_catalogue_version');
|
||||
$updated = $this->putJson('/management/opportunities/'.$record->id.'/draft', ['etag' => $legacy['etag'], 'reason' => 'Escolha explícita da nova etapa na versão QA', 'maturity_stage' => 'operational', 'maturity_catalogue_version' => OpportunityMaturity::VERSION])->assertOk()->json();
|
||||
$this->assertSame(6, $record->fresh()->maturity_level);
|
||||
$this->assertSame($label, $record->fresh()->maturity_label);
|
||||
$this->assertSame('operational', app(OpportunityManagement::class)->values($record->fresh())['maturity_stage']);
|
||||
$this->putJson('/management/opportunities/'.$record->id.'/draft', ['etag' => $updated['etag'], 'reason' => 'Rejeitar etapa e posição incoerentes em QA', 'maturity_level' => 7, 'maturity_stage' => 'operational'])->assertUnprocessable()->assertJsonValidationErrors('maturity_stage');
|
||||
$this->putJson('/management/opportunities/'.$record->id.'/draft', ['etag' => $legacy['etag'], 'reason' => 'Rejeitar gravação concorrente exclusivamente QA', 'maturity_level' => 8])->assertConflict();
|
||||
$this->assertSame(9, Opportunity::count());
|
||||
$this->assertSame(0, Opportunity::published()->count());
|
||||
$this->actingAs(User::factory()->create())->postJson('/management/opportunities/drafts', ['maturity_level' => 8])->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_business_country_origin_save_recover_filters_submission_statistics_and_actor_isolation(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$manager = User::factory()->create();
|
||||
config(['portal.managers' => [$manager->email]]);
|
||||
$payload = ['title' => 'Negócio sintético QA', 'country' => 'PT', 'declared_origin_country_code' => 'ZA', 'wizard_version' => '1'];
|
||||
$input = ['modality' => 'company_sale', 'classification' => 'confidential', 'reason' => 'País e origem independentes exclusivamente QA', 'payload' => $payload];
|
||||
$this->actingAs($owner)->postJson('/api/v1/existing-business/listings', [...$input, 'payload' => [...$payload, 'country' => 'ZZ']])->assertUnprocessable()->assertJsonValidationErrors('payload.country');
|
||||
$record = $this->postJson('/api/v1/existing-business/listings', $input)->assertCreated()->assertJsonPath('payload.country', 'PT')->assertJsonPath('payload.declared_origin_country_code', 'ZA')->json();
|
||||
$url = '/api/v1/existing-business/listings/'.$record['id'];
|
||||
$this->getJson($url)->assertOk()->assertJsonPath('record.payload.country', 'PT')->assertJsonPath('record.payload.declared_origin_country_code', 'ZA')->assertJsonCount(249, 'countries');
|
||||
$this->getJson('/api/v1/existing-business/listings?country=PT&origin=ZA')->assertOk()->assertJsonPath('records.total', 1);
|
||||
$this->getJson('/api/v1/existing-business/listings?origin=PT')->assertOk()->assertJsonPath('records.total', 0);
|
||||
$this->assertSame([], app(DeclaredOriginStatistics::class)->forManager($manager)['businesses']);
|
||||
$this->putJson($url, [...$input, 'version_no' => 1, 'payload' => [...$payload, 'country' => 'Portugal']])->assertOk()->assertJsonPath('version_no', 2);
|
||||
$this->getJson($url)->assertOk()->assertJsonPath('record.payload.country', 'Portugal');
|
||||
$this->putJson($url, [...$input, 'version_no' => 2, 'payload' => $payload])->assertOk()->assertJsonPath('version_no', 3);
|
||||
$payload = [...$payload, 'proponent_type' => 'person', 'proponent_name' => 'Promotor QA', 'tax_identifier' => 'SYNTHETIC', 'contact_email' => 'person@example.test', 'acting_capacity' => 'holder', 'declared_holder' => 'Promotor QA', 'location_notes' => 'Localização declarada QA', 'legitimacy_declaration' => 'yes', 'description' => 'Descrição sintética para permitir a submissão deste percurso privado na QA.', 'source_reference' => 'Referência sintética exclusivamente QA', 'valid_until' => today()->addMonth()->toDateString()];
|
||||
$this->putJson($url, [...$input, 'version_no' => 3, 'payload' => $payload])->assertOk()->assertJsonPath('version_no', 4);
|
||||
$this->putJson($url, [...$input, 'version_no' => 4, 'payload' => [...$payload, 'declared_origin_country_code' => 'ZZ']])->assertUnprocessable()->assertJsonValidationErrors('payload.declared_origin_country_code');
|
||||
$this->assertSame(4, ExistingBusiness::findOrFail($record['id'])->version_no);
|
||||
$this->postJson($url.'/transitions', ['version_no' => 4, 'action' => 'submitted', 'note' => 'Submissão privada exclusivamente na base QA', 'review_consent' => true, 'confirmation_code' => $record['code']])->assertOk()->assertJsonPath('status', 'submitted');
|
||||
$this->getJson($url)->assertOk()->assertJsonPath('record.payload.declared_origin_country_code', 'ZA')->assertJsonPath('record.payload.country', 'PT');
|
||||
$stats = app(DeclaredOriginStatistics::class)->forManager($manager, 'ZA');
|
||||
$this->assertSame(1, $stats['businesses'][0]['count']);
|
||||
$this->assertSame('ZA', $stats['businesses'][0]['code']);
|
||||
$this->assertSame([], $stats['interests']);
|
||||
$this->assertSame([], app(DeclaredOriginStatistics::class)->forManager($manager, 'PT')['businesses']);
|
||||
$this->actingAs($manager)->get('/management/interests?origin=ZA')->assertInertia(fn (Assert $page) => $page->where('originStatistics.businesses.0.count', 1));
|
||||
$this->actingAs(User::factory()->create())->getJson($url)->assertForbidden();
|
||||
$this->getJson('/api/v1/existing-business/listings?country=PT&origin=ZA')->assertOk()->assertJsonPath('records.total', 0);
|
||||
$this->assertDatabaseCount('existing_business_revisions', 5);
|
||||
$this->assertStringNotContainsString('person@example.test', (string) DB::table('existing_businesses')->where('id', $record['id'])->value('payload'));
|
||||
}
|
||||
|
||||
public function test_land_country_origin_and_four_territory_levels_recover_in_support_filters_statistics(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$manager = User::factory()->create();
|
||||
config(['portal.managers' => [$manager->email]]);
|
||||
$path = [];
|
||||
$parent = (string) Str::uuid();
|
||||
DB::table('geo_locations')->insert(['id' => $parent, 'code' => $parent, 'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$parent, 'data_classification' => 'public', 'name' => 'QA country', 'location_type' => 'country']);
|
||||
foreach (['province', 'district', 'admin_post', 'locality'] as $type) {
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('geo_locations')->insert(['id' => $id, 'code' => $id, 'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$id, 'data_classification' => 'public', 'name' => 'QA '.$type, 'location_type' => $type, 'parent_id' => $parent]);
|
||||
$path[$type === 'admin_post' ? 'admin_post_id' : $type.'_id'] = $id;
|
||||
$parent = $id;
|
||||
}
|
||||
$payload = ['country' => 'PT', 'declared_origin_country_code' => 'ZA', 'support_kind' => 'investment', 'request_description' => 'Pedido sintético de apoio fundiário exclusivamente QA', 'applicant_category' => 'individual', 'full_name' => 'Pessoa QA', 'email' => 'land@example.test', 'preferred_contact' => 'email', 'area_status' => 'identified', 'purpose' => 'Preparação sintética QA', 'parcels' => [$path], 'contact_consent' => true, 'truth_declaration' => true];
|
||||
$this->actingAs($owner)->get('/investidor/terra-duat/novo')->assertInertia(fn (Assert $page) => $page->has('countries', 249));
|
||||
$record = $this->postJson('/investidor/terra-duat', ['payload' => $payload])->assertCreated()->json();
|
||||
$url = '/investidor/terra-duat/'.$record['id'];
|
||||
$this->getJson($url)->assertOk()->assertJsonPath('case.payload.country', 'PT')->assertJsonPath('case.payload.declared_origin_country_code', 'ZA')->assertJsonPath('case.payload.parcels.0.locality_id', $parent)->assertJsonCount(4, 'territoryPaths.0')->assertJsonPath('territoryPaths.0.3.id', $parent)->assertJsonPath('declaredOrigin.code', 'ZA');
|
||||
$this->putJson($url, ['version' => 1, 'payload' => [...$payload, 'country' => 'Moçambique']])->assertOk()->assertJsonPath('version', 2);
|
||||
$this->getJson($url)->assertOk()->assertJsonPath('case.payload.country', 'Moçambique');
|
||||
$this->putJson($url, ['version' => 2, 'payload' => [...$payload, 'declared_origin_country_code' => 'ZZ']])->assertUnprocessable()->assertJsonValidationErrors('payload.declared_origin_country_code');
|
||||
$this->assertSame(2, LandAssistanceCase::findOrFail($record['id'])->version);
|
||||
$this->postJson($url.'/solicitar-apoio', ['version' => 2, 'payload' => $payload])->assertAccepted();
|
||||
$interest = InterestRequest::firstOrFail();
|
||||
$this->assertSame('PT', $interest->country);
|
||||
$this->assertSame('ZA', $interest->metadata['declared_origin_country_code']);
|
||||
$this->getJson($url)->assertOk()->assertJsonPath('case.payload.parcels.0.locality_id', $parent)->assertJsonPath('case.payload.declared_origin_country_code', 'ZA');
|
||||
$this->actingAs($manager)->get('/management/interests?country=PT&origin=ZA')->assertInertia(fn (Assert $page) => $page->where('requests.total', 1)->where('requests.data.0.country', 'PT')->where('requests.data.0.declaredOrigin.code', 'ZA')->where('originStatistics.interests.0.count', 1));
|
||||
$this->get('/management/interests?origin=PT')->assertInertia(fn (Assert $page) => $page->where('requests.total', 0)->has('originStatistics.interests', 0));
|
||||
$this->get('/management/interests?origin=unknown')->assertInertia(fn (Assert $page) => $page->where('requests.total', 0));
|
||||
$this->actingAs(User::factory()->create())->getJson($url)->assertNotFound();
|
||||
$this->get('/management/interests?origin=ZA')->assertForbidden();
|
||||
$this->assertDatabaseCount('interest_requests', 1);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PortalAuditRoutesTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_static_portal_get_routes_for_public_investor_and_manager_do_not_crash(): void
|
||||
{
|
||||
$this->withoutVite();
|
||||
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
||||
$this->assertStringEndsWith('_test', $identity->database);
|
||||
$this->assertSame(55441, (int) $identity->port);
|
||||
Http::fake();
|
||||
$investor = User::factory()->create();
|
||||
$manager = User::factory()->create();
|
||||
$reviewer = User::factory()->create();
|
||||
$reviewer->forceFill(['two_factor_secret' => Crypt::encryptString('QA-SYNTHETIC'), 'two_factor_confirmed_at' => now()])->save();
|
||||
config(['portal.managers' => [$manager->email, $reviewer->email], 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false, 'education.enabled' => true]);
|
||||
foreach (['existing-business' => ['reviewers', 'triagers', 'approvers_l1', 'approvers_l2', 'publishers'], 'investment-zones' => ['legal_reviewers', 'gis_reviewers', 'territory_reviewers', 'publishers'], 'territorial' => ['editors', 'reviewers', 'publishers'], 'simulation-tax-governance' => ['editors', 'reviewers', 'publishers']] as $module => $roles) {
|
||||
foreach ($roles as $role) {
|
||||
config([$module.'.'.$role => [$reviewer->email]]);
|
||||
}
|
||||
}
|
||||
$results = [];
|
||||
$failures = [];
|
||||
foreach (['public' => null, 'investor' => $investor, 'manager' => $manager, 'designated_mfa_qa' => $reviewer] as $profile => $actor) {
|
||||
if ($actor) {
|
||||
$this->actingAs($actor);
|
||||
} else {
|
||||
$this->app['auth']->forgetGuards();
|
||||
}
|
||||
foreach (Route::getRoutes() as $route) {
|
||||
$uri = $route->uri();
|
||||
if (! in_array('GET', $route->methods(), true) || str_contains($uri, '{') || ! str_contains($route->getActionName(), 'App\\Http\\Controllers\\') || str_contains($uri, 'export')) {
|
||||
continue;
|
||||
}
|
||||
$url = '/'.ltrim($uri, '/');
|
||||
if (str_ends_with($url, '/territory-options')) {
|
||||
$url .= '?type=province&q=Maputo';
|
||||
}
|
||||
$response = $this->get($url);
|
||||
$results[] = ['profile' => $profile, 'route' => $url, 'status' => $response->status(), 'coverage' => 'HTTP GET, schema migrated and empty QA; not visual acceptance'];
|
||||
if ($response->status() >= 500) {
|
||||
$failures[] = $profile.' '.$url.' HTTP '.$response->status();
|
||||
}
|
||||
}
|
||||
}
|
||||
$dir = storage_path('app/private/investmoz-v2/portal-audit');
|
||||
if (! is_dir($dir)) {
|
||||
mkdir($dir, 0700, true);
|
||||
}
|
||||
file_put_contents($dir.'/ensaios-rotas.json', json_encode(['captured_at' => gmdate('c'), 'qa' => $identity, 'results' => $results, 'failures' => $failures, 'active_writes' => 0], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
Http::assertNothingSent();
|
||||
$this->assertSame([], $failures);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\InterestRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\DeclaredOriginStatistics;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PortalOriginCompatibilityTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function payload(): array
|
||||
{
|
||||
return ['fullName' => 'Pessoa sintética QA', 'email' => 'portal-integration-test@gmail.com', 'investorType' => 'individual',
|
||||
'country' => 'Moçambique', 'sectorInterest' => 'Energia', 'message' => 'Pedido exclusivamente sintético na base QA.', 'privacyConsent' => true];
|
||||
}
|
||||
|
||||
public function test_public_api_preserves_explicit_origin_in_receipt_replay_and_statistics(): void
|
||||
{
|
||||
config(['portal.notifications_enabled' => false]);
|
||||
$headers = ['Idempotency-Key' => (string) Str::uuid()];
|
||||
$data = [...$this->payload(), 'declared_origin_country_code' => 'PT'];
|
||||
$this->postJson('/api/v1/interest-requests', [...$data, 'declared_origin_country_code' => 'ZZ'], $headers)->assertUnprocessable()->assertJsonValidationErrors('declared_origin_country_code');
|
||||
$this->assertDatabaseCount('interest_requests', 0);
|
||||
$first = $this->postJson('/api/v1/interest-requests', $data, $headers)->assertCreated()->json();
|
||||
$this->postJson('/api/v1/interest-requests', $data, $headers)->assertCreated()->assertExactJson($first);
|
||||
$this->postJson('/api/v1/interest-requests', [...$data, 'declared_origin_country_code' => 'ZA'], $headers)->assertConflict();
|
||||
$interest = InterestRequest::firstOrFail();
|
||||
$this->assertSame('PT', $interest->metadata['declared_origin_country_code']);
|
||||
$this->assertSame('Moçambique', $interest->country);
|
||||
$this->assertDatabaseCount('interest_requests', 1);
|
||||
$this->postJson('/api/v1/interest-requests', $this->payload(), ['Idempotency-Key' => (string) Str::uuid()])->assertCreated();
|
||||
$manager = User::factory()->create();
|
||||
config(['portal.managers' => [$manager->email]]);
|
||||
$stats = app(DeclaredOriginStatistics::class)->forManager($manager);
|
||||
$this->assertSame([null, 'PT'], array_column($stats['interests'], 'code'));
|
||||
$this->assertSame([1, 1], array_column($stats['interests'], 'count'));
|
||||
}
|
||||
|
||||
public function test_support_form_keeps_origin_optional_and_exposes_local_catalogue(): void
|
||||
{
|
||||
$this->withoutVite();
|
||||
$this->get('/apoio')->assertInertia(fn (Assert $page) => $page->has('countries', 249));
|
||||
$this->get('/manifestar-interesse')->assertInertia(fn (Assert $page) => $page->has('countries', 249));
|
||||
$data = [...$this->payload(), 'kind' => 'support', 'submission_key' => (string) Str::uuid(), 'declared_origin_country_code' => 'ZA'];
|
||||
$this->postJson('/apoio', [...$data, 'declared_origin_country_code' => 'ZZ'])->assertUnprocessable();
|
||||
$this->assertDatabaseCount('interest_requests', 0);
|
||||
$this->from('/apoio')->post('/apoio', $data)->assertRedirect('/apoio')->assertSessionHasNoErrors();
|
||||
$this->assertSame('ZA', InterestRequest::firstOrFail()->metadata['declared_origin_country_code']);
|
||||
$this->post('/apoio', $data)->assertSessionHasNoErrors();
|
||||
$this->assertDatabaseCount('interest_requests', 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use App\Models\User;
|
||||
use App\Services\Malware\MalwareScanner;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PortalRealDocumentConsumersTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Storage::fake('local');
|
||||
Queue::fake();
|
||||
config(['services.malware.scanner' => 'clamd', 'services.malware.host' => '127.0.0.1', 'services.malware.port' => 3310]);
|
||||
if (! app(MalwareScanner::class)->health()['available']) {
|
||||
$this->markTestSkipped('Real ClamD required for these consumer operations.');
|
||||
}
|
||||
}
|
||||
|
||||
private function scan(string $id): void
|
||||
{
|
||||
(new ScanDocumentForMalware($id))->handle(app(MalwareScanner::class));
|
||||
$d = DB::table('documents')->where('id', $id)->first();
|
||||
$this->assertSame('clean', $d->malware_scan_status);
|
||||
$this->assertSame('ClamAV', $d->malware_engine);
|
||||
$this->assertSame(trim($d->sha256), $d->malware_scanned_sha256);
|
||||
$this->assertNotNull($d->malware_signatures_at);
|
||||
}
|
||||
|
||||
public function test_library_private_registration_real_scan_explicit_classification_publication_and_download(): void
|
||||
{
|
||||
$manager = User::factory()->create();
|
||||
config(['portal.managers' => [$manager->email]]);
|
||||
$bytes = 'Synthetic QA guide; no official or legal value.';
|
||||
$upload = ['file' => UploadedFile::fake()->createWithContent('qa-guide.txt', $bytes)];
|
||||
$this->actingAs($manager)->post('/management/portal-services/upload', $upload)->assertSessionHasNoErrors();
|
||||
$d = DB::table('documents')->first();
|
||||
$this->assertSame('quarantined', $d->status);
|
||||
$payload = ['document_id' => $d->id, 'title' => 'Synthetic QA guide', 'description' => 'Synthetic quality assurance document.', 'category' => 'Guia', 'topic' => 'Investimento',
|
||||
'document_type' => 'guide', 'issuer' => 'Synthetic QA only', 'source_url' => 'https://example.invalid/qa', 'language' => 'pt', 'version_label' => 'QA-1',
|
||||
'territorial_scope' => 'national', 'source_confirmation' => 'identified', 'legal_status' => 'not_applicable', 'rights_status' => 'unconfirmed', 'data_classification' => 'internal',
|
||||
'source_reference' => 'Synthetic QA fixture, not an official source.', 'legislation' => false, 'essential' => false, 'independent_review' => false, 'associations' => []];
|
||||
$response = $this->post('/management/portal-services/resources', $payload);
|
||||
$this->assertSame(302, $response->status(), $response->getContent());
|
||||
$response->assertSessionHasNoErrors();
|
||||
$r = DB::table('portal_resources')->first();
|
||||
$this->post('/management/portal-services/resources/'.$r->id, ['published' => true])->assertSessionHasErrors('published');
|
||||
$this->get('/documentos/'.$r->id.'/download')->assertNotFound();
|
||||
$this->scan($d->id);
|
||||
$this->assertDatabaseHas('portal_resources', ['id' => $r->id, 'published' => false]);
|
||||
$update = [...$payload, 'metadata_version' => 1, 'rights_status' => 'authorized', 'data_classification' => 'public'];
|
||||
$this->put('/management/portal-services/resources/'.$r->id, $update)->assertSessionHasNoErrors();
|
||||
$this->get('/documentos/'.$r->id.'/download')->assertNotFound();
|
||||
$this->post('/management/portal-services/resources/'.$r->id, ['published' => true])->assertSessionHasNoErrors();
|
||||
$this->get('/documentos/'.$r->id.'/download')->assertOk()->assertDownload('qa-guide.txt');
|
||||
$this->put('/management/portal-services/resources/'.$r->id, $update)->assertConflict();
|
||||
$this->post('/management/portal-services/upload', $upload)->assertSessionHasErrors('file');
|
||||
$this->assertSame(1, DB::table('documents')->count());
|
||||
$this->assertSame(3, DB::table('portal_resource_revisions')->count());
|
||||
Storage::disk('local')->put($d->storage_key, 'Tampered synthetic QA bytes');
|
||||
$this->get('/documentos/'.$r->id.'/download')->assertNotFound();
|
||||
$this->actingAs(User::factory()->create())->put('/management/portal-services/resources/'.$r->id, [...$update, 'metadata_version' => 2])->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_desk_upload_real_scan_dedup_recovery_foreign_actor_and_tamper(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$this->actingAs($owner);
|
||||
$id = $this->postJson('/balcao-virtual/dossiers', ['title' => 'Synthetic document QA case', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
|
||||
$base = '/balcao-virtual/dossiers/'.$id;
|
||||
$bytes = 'Synthetic confidential QA attachment only.';
|
||||
$input = ['version' => 1, 'category' => 'proof', 'source_reference' => 'Synthetic QA owner attachment.', 'consent' => true, 'file' => UploadedFile::fake()->createWithContent('qa-desk.txt', $bytes)];
|
||||
$this->post($base.'/documentos', $input, ['Accept' => 'application/json'])->assertOk();
|
||||
$link = DB::table('desk_documents')->first();
|
||||
$d = DB::table('documents')->where('id', $link->document_id)->first();
|
||||
$url = $base.'/documentos/'.$link->id;
|
||||
$this->get($url)->assertNotFound();
|
||||
$this->scan($d->id);
|
||||
$this->get($url)->assertOk()->assertDownload('qa-desk.txt');
|
||||
$this->actingAs(User::factory()->create())->get($url)->assertForbidden();
|
||||
$this->actingAs($owner)->getJson($base)->assertOk();
|
||||
$version = DB::table('desk_cases')->where('id', $id)->value('version');
|
||||
$this->post($base.'/documentos', [...$input, 'version' => $version], ['Accept' => 'application/json'])->assertOk();
|
||||
$this->assertSame(1, DB::table('desk_documents')->count());
|
||||
$this->assertSame(1, DB::table('documents')->count());
|
||||
$this->assertDatabaseHas('documents', ['id' => $d->id, 'data_classification' => 'confidential']);
|
||||
Storage::disk('local')->put($d->storage_key, 'Tampered QA bytes');
|
||||
$this->get($url)->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_encrypted_canonical_original_is_reusable_in_desk_and_manager_library_download(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
config(['portal.managers' => [$owner->email]]);
|
||||
$bytes = "%PDF-1.4\n% Synthetic encrypted canonical QA only.\n%%EOF";
|
||||
$doc = (string) Str::uuid();
|
||||
$key = 'qa-canonical/'.$doc;
|
||||
Storage::disk('local')->put($key, Crypt::encryptString($bytes));
|
||||
DB::table('documents')->insert(['id' => $doc, 'source_system' => 'qa', 'file_name' => 'encrypted-qa.pdf', 'media_type' => 'application/pdf', 'storage_key' => $key,
|
||||
'sha256' => hash('sha256', $bytes), 'size_bytes' => strlen($bytes), 'storage_encryption' => 'laravel', 'status' => 'quarantined', 'data_classification' => 'confidential', 'uploaded_by' => $owner->id]);
|
||||
$this->scan($doc);
|
||||
$this->actingAs($owner)->get('/management/documents/'.$doc.'/download')->assertOk()->assertContent($bytes);
|
||||
$case = $this->postJson('/balcao-virtual/dossiers', ['title' => 'Synthetic encrypted reuse QA', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
|
||||
$this->postJson('/balcao-virtual/dossiers/'.$case.'/documentos', ['version' => 1, 'document_id' => $doc, 'category' => 'proof', 'source_reference' => 'Existing owned encrypted QA original', 'consent' => true])->assertOk();
|
||||
$link = DB::table('desk_documents')->where('document_id', $doc)->first();
|
||||
$url = '/balcao-virtual/dossiers/'.$case.'/documentos/'.$link->id;
|
||||
$this->get($url)->assertOk()->assertContent($bytes);
|
||||
$this->assertSame(1, DB::table('documents')->count());
|
||||
$metadata = ['document_id' => $doc, 'title' => 'Synthetic encrypted QA guide', 'description' => 'Synthetic QA only.', 'category' => 'Guia', 'topic' => 'Investimento',
|
||||
'document_type' => 'guide', 'source_url' => 'https://example.invalid/qa', 'language' => 'pt', 'version_label' => 'QA-1', 'territorial_scope' => 'national',
|
||||
'source_confirmation' => 'identified', 'legal_status' => 'not_applicable', 'rights_status' => 'authorized', 'data_classification' => 'public',
|
||||
'source_reference' => 'Synthetic QA, not an official source.', 'legislation' => false, 'essential' => false, 'independent_review' => false, 'associations' => []];
|
||||
$this->post('/management/portal-services/resources', $metadata)->assertSessionHasNoErrors();
|
||||
$resource = DB::table('portal_resources')->first();
|
||||
$public = '/documentos/'.$resource->id;
|
||||
$this->get($public.'/download')->assertNotFound();
|
||||
$this->get($public.'/view')->assertNotFound();
|
||||
$this->post('/management/portal-services/resources/'.$resource->id, ['published' => true])->assertSessionHasNoErrors();
|
||||
$this->get($public.'/download')->assertOk()->assertContent($bytes);
|
||||
$this->get($public.'/view')->assertOk()->assertContent($bytes)->assertHeader('Content-Type', 'application/pdf')
|
||||
->assertHeader('Content-Security-Policy', "sandbox; default-src 'none'; style-src 'unsafe-inline'");
|
||||
$this->assertSame(1, DB::table('documents')->count());
|
||||
Storage::disk('local')->put($key, Crypt::encryptString('Changed synthetic QA bytes'));
|
||||
$this->get($url)->assertNotFound();
|
||||
$this->get('/management/documents/'.$doc.'/download')->assertNotFound();
|
||||
$this->get($public.'/download')->assertNotFound();
|
||||
$this->get($public.'/view')->assertNotFound();
|
||||
}
|
||||
}
|
||||
@@ -367,14 +367,22 @@ class SimulationExecutionTest extends TestCase
|
||||
$this->assertDatabaseHas('simulation_jobs', ['simulation_id' => $simulation->id, 'status' => 'failed', 'attempts' => 3]);
|
||||
}
|
||||
|
||||
public function test_empty_schema_rolls_back_and_reapplies_with_questionnaire_seed(): void
|
||||
public function test_core_rollback_refuses_published_path_definition_and_preserves_rls(): void
|
||||
{
|
||||
$migration = require database_path('migrations/2026_10_01_000200_create_simulation_execution_core.php');
|
||||
$migration->down();
|
||||
$this->assertFalse(Schema::hasTable('simulations'));
|
||||
$migration->up();
|
||||
// The full installation is no longer a base-only empty schema: it owns a second immutable definition.
|
||||
$before = DB::select('SELECT relname,relrowsecurity,relforcerowsecurity FROM pg_class WHERE relnamespace = to_regnamespace(?) AND relname IN (?,?) ORDER BY relname', ['public', 'simulations', 'questionnaire_versions']);
|
||||
try {
|
||||
$migration->down();
|
||||
$this->fail('A published path definition must not be silently erased by fiscal core rollback.');
|
||||
} catch (\RuntimeException $exception) {
|
||||
$this->assertSame('Cannot rollback simulation execution: questionnaire_versions contains data.', $exception->getMessage());
|
||||
}
|
||||
$after = DB::select('SELECT relname,relrowsecurity,relforcerowsecurity FROM pg_class WHERE relnamespace = to_regnamespace(?) AND relname IN (?,?) ORDER BY relname', ['public', 'simulations', 'questionnaire_versions']);
|
||||
$this->assertEquals($before, $after);
|
||||
$this->assertTrue(Schema::hasTable('simulation_results'));
|
||||
$this->assertDatabaseHas('questionnaire_versions', ['id' => '61000100-0000-4000-8000-000000000001', 'status' => 'published']);
|
||||
$this->assertDatabaseHas('questionnaire_versions', ['code' => 'investor-path-19', 'status' => 'published']);
|
||||
}
|
||||
|
||||
public function test_questionnaire_endpoint_exposes_only_published_version_with_etag(): void
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Malware\MalwareScanner;
|
||||
use App\Services\TerritoryGeometryImport;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TerritoryGeometryImportTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private string $directory;
|
||||
|
||||
private function setupImport(): User
|
||||
{
|
||||
$this->directory = storage_path('app/private/testing/geometry-import-'.Str::uuid());
|
||||
mkdir($this->directory, 0700, true);
|
||||
$originals = storage_path('app/private/investmoz-v2/portal-audit');
|
||||
foreach (['moz-adm1-ine-20190607.geojson', 'moz-adm1-ine-metadata.html'] as $file) {
|
||||
copy($originals.'/'.$file, $this->directory.'/'.$file);
|
||||
}
|
||||
$file = $this->directory.'/moz-adm1-ine-20190607.geojson';
|
||||
config(['services.malware.scanner' => 'clamd', 'services.malware.host' => '127.0.0.1', 'services.malware.port' => 3310]);
|
||||
$scanner = app(MalwareScanner::class);
|
||||
if (! $scanner->health()['available']) {
|
||||
$this->markTestSkipped('Real local ClamD required; no fake clean evidence.');
|
||||
}
|
||||
$scan = $scanner->scan($file, filesize($file), storage_path('app/private'));
|
||||
$this->assertSame('clean', $scan['result']);
|
||||
$scan['signatures_at'] = $scan['signatures_at']?->format(DATE_ATOM);
|
||||
file_put_contents($this->directory.'/geometry-source-scans.json', json_encode(['captured_at' => gmdate('c'), 'files' => [['file' => basename($file), 'sha256' => hash_file('sha256', $file), 'scan' => $scan]]], JSON_THROW_ON_ERROR));
|
||||
$features = json_decode(file_get_contents($file), true)['features'];
|
||||
$country = (string) Str::uuid();
|
||||
DB::table('geo_locations')->insert(['id' => $country, 'code' => 'MZ', 'name' => 'Moçambique', 'location_type' => 'country', 'data_classification' => 'internal']);
|
||||
foreach (array_slice($features, 0, 2) as $f) {
|
||||
DB::table('geo_locations')->insert(['id' => (string) Str::uuid(), 'code' => (string) Str::uuid(), 'name' => $f['properties']['ADM1_PT'], 'location_type' => 'province', 'parent_id' => $country, 'data_classification' => 'internal']);
|
||||
}
|
||||
$foreignCountry = (string) Str::uuid();
|
||||
DB::table('geo_locations')->insert(['id' => $foreignCountry, 'code' => 'QA-FOREIGN', 'name' => 'Synthetic foreign QA only', 'location_type' => 'country', 'data_classification' => 'internal']);
|
||||
DB::table('geo_locations')->insert(['id' => (string) Str::uuid(), 'code' => (string) Str::uuid(), 'name' => $features[0]['properties']['ADM1_PT'], 'location_type' => 'province', 'parent_id' => $foreignCountry, 'data_classification' => 'internal']);
|
||||
$actor = User::factory()->create();
|
||||
$actor->forceFill(['two_factor_secret' => Crypt::encryptString('QA-TEST-ONLY'), 'two_factor_confirmed_at' => now()])->save();
|
||||
config(['portal.managers' => [$actor->email], 'geometry-import.enabled' => true, 'geometry-import.directory' => $this->directory]);
|
||||
$this->actingAs($actor);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (isset($this->directory) && is_dir($this->directory)) {
|
||||
foreach (glob($this->directory.'/*') as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
rmdir($this->directory);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function input(array $plan): array
|
||||
{
|
||||
return ['level' => 1, 'hash' => $plan['hash'], 'idempotency_key' => (string) Str::uuid(), 'reason' => 'Ensaio autenticado de cartografia apenas em QA', 'confirm' => true];
|
||||
}
|
||||
|
||||
public function test_preview_apply_replay_reuse_and_reverse_preserve_history(): void
|
||||
{
|
||||
$actor = $this->setupImport();
|
||||
$plan = $this->getJson('/management/territory-geometry-import?level=1')->assertOk()->assertJsonPath('expected', 11)->assertJsonPath('resolved', 2)->json();
|
||||
$input = $this->input($plan);
|
||||
$result = $this->postJson('/management/territory-geometry-import', $input)->assertOk()->assertJsonPath('inserted', 2)->assertJsonPath('published', false)->json();
|
||||
$this->postJson('/management/territory-geometry-import', $input)->assertOk()->assertExactJson($result);
|
||||
$this->postJson('/management/territory-geometry-import', [...$input, 'reason' => 'Chave com outro conteúdo: recusar sem escrever'])->assertConflict();
|
||||
$this->assertSame(2, DB::table('geo_geometries')->count());
|
||||
$reusePlan = $this->getJson('/management/territory-geometry-import?level=1')->json();
|
||||
$reuseResponse = $this->postJson('/management/territory-geometry-import', $this->input($reusePlan));
|
||||
$this->assertSame(200, $reuseResponse->status(), $reuseResponse->getContent());
|
||||
$reuse = $reuseResponse->assertJsonPath('inserted', 0)->assertJsonPath('reused', 2)->json();
|
||||
$reverse = ['idempotency_key' => (string) Str::uuid(), 'reason' => 'Reversão do lote QA mantendo todo o histórico', 'confirm' => true];
|
||||
$this->postJson('/management/territory-geometry-import/'.$reuse['batch_id'].'/reverse', $reverse)->assertOk()->assertJsonPath('reversed', 0)->assertJsonPath('reused_preserved', 2);
|
||||
$this->assertSame(2, DB::table('geo_geometries')->whereNull('deleted_at')->count());
|
||||
$url = '/management/territory-geometry-import/'.$result['batch_id'].'/reverse';
|
||||
$reverse['idempotency_key'] = (string) Str::uuid();
|
||||
$this->postJson($url, $reverse)->assertOk()->assertJsonPath('reversed', 2);
|
||||
$this->postJson($url, $reverse)->assertOk()->assertJsonPath('reversed', 2);
|
||||
$this->assertSame(0, DB::table('geo_geometries')->whereNull('deleted_at')->count());
|
||||
$this->assertSame(2, DB::table('geo_geometries')->count());
|
||||
$this->assertSame(4, DB::table('audit_events')->where('source_system', 'geometry-import')->where('actor_user_id', $actor->id)->count());
|
||||
// Reversed identities stay reserved; do not silently resurrect or duplicate.
|
||||
$this->postJson('/management/territory-geometry-import', $this->input($this->getJson('/management/territory-geometry-import?level=1')->json()))->assertConflict();
|
||||
}
|
||||
|
||||
public function test_stale_preview_changed_geometry_mfa_and_foreign_actor_are_rejected(): void
|
||||
{
|
||||
$actor = $this->setupImport();
|
||||
$plan = $this->getJson('/management/territory-geometry-import?level=1')->json();
|
||||
DB::table('geo_locations')->where('name', $plan['rows'][0]['name'])->update(['name' => 'Changed QA only']);
|
||||
$this->postJson('/management/territory-geometry-import', $this->input($plan))->assertConflict();
|
||||
$plan = $this->getJson('/management/territory-geometry-import?level=1')->json();
|
||||
$result = $this->postJson('/management/territory-geometry-import', $this->input($plan))->assertOk()->assertJsonPath('inserted', 1)->json();
|
||||
DB::table('geo_geometries')->where('id', $result['created'][0]['id'])->update(['data_classification' => 'public']);
|
||||
$this->postJson('/management/territory-geometry-import/'.$result['batch_id'].'/reverse', ['idempotency_key' => (string) Str::uuid(), 'reason' => 'Recusar reversão de geometria posteriormente publicada', 'confirm' => true])->assertConflict();
|
||||
$actor->forceFill(['two_factor_confirmed_at' => null])->save();
|
||||
$this->postJson('/management/territory-geometry-import', $this->input($plan))->assertForbidden();
|
||||
$this->actingAs(User::factory()->create())->getJson('/management/territory-geometry-import?level=1')->assertForbidden();
|
||||
$this->postJson('/management/territory-geometry-import', $this->input($plan))->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_expired_scan_and_non_qa_target_cannot_write(): void
|
||||
{
|
||||
$this->setupImport();
|
||||
$path = $this->directory.'/geometry-source-scans.json';
|
||||
$proof = json_decode(file_get_contents($path), true);
|
||||
$proof['captured_at'] = '2000-01-01T00:00:00Z';
|
||||
file_put_contents($path, json_encode($proof));
|
||||
$plan = $this->getJson('/management/territory-geometry-import?level=1')->assertOk()->assertJsonPath('scan_ready', false)->json();
|
||||
$this->postJson('/management/territory-geometry-import', $this->input($plan))->assertStatus(423);
|
||||
$this->app->detectEnvironment(fn () => 'production');
|
||||
try {
|
||||
app(TerritoryGeometryImport::class)->apply(auth()->user(), $this->input($plan));
|
||||
$this->fail('An active target cannot bypass the backup and evidence guard.');
|
||||
} catch (HttpException $e) {
|
||||
$this->assertSame(422, $e->getStatusCode());
|
||||
} finally {
|
||||
$this->app->detectEnvironment(fn () => 'testing');
|
||||
}
|
||||
$this->assertSame(0, DB::table('geo_geometries')->count());
|
||||
}
|
||||
|
||||
public function test_reversal_refuses_a_real_foreign_key_consumer_and_is_atomic(): void
|
||||
{
|
||||
$this->setupImport();
|
||||
$plan = $this->getJson('/management/territory-geometry-import?level=1')->json();
|
||||
$result = $this->postJson('/management/territory-geometry-import', $this->input($plan))->assertOk()->json();
|
||||
// Transactional QA-only fixture, rolled back by RefreshDatabase; never a product migration.
|
||||
DB::statement('CREATE TABLE geometry_import_qa_consumer (geometry_id uuid REFERENCES geo_geometries(id))');
|
||||
DB::table('geometry_import_qa_consumer')->insert(['geometry_id' => $result['created'][1]['id']]);
|
||||
$this->postJson('/management/territory-geometry-import/'.$result['batch_id'].'/reverse', ['idempotency_key' => (string) Str::uuid(), 'reason' => 'Reversão deve recusar qualquer consumidor ligado ao lote', 'confirm' => true])->assertConflict();
|
||||
$this->assertSame(2, DB::table('geo_geometries')->whereNull('deleted_at')->count());
|
||||
$this->assertSame(0, DB::table('audit_events')->where('action', 'geometry_import_reversed')->count());
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ use Laravel\Fortify\Features;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
/** @var array<string, resource> Locks held until this test process exits. */
|
||||
private static array $qaDatabaseLocks = [];
|
||||
|
||||
public function createApplication(): Application
|
||||
{
|
||||
$app = parent::createApplication();
|
||||
@@ -28,6 +31,20 @@ abstract class TestCase extends BaseTestCase
|
||||
|| ! in_array($url, [null, ''], true)) {
|
||||
throw new \LogicException('PHPUnit PostgreSQL target rejected: configure an explicit loopback *_test database with no DB_URL.');
|
||||
}
|
||||
|
||||
// Two test processes must not migrate/clean the same isolated database concurrently.
|
||||
$lockKey = hash('sha256', $host.':'.($connection['port'] ?? '').':'.$database);
|
||||
if (! isset(self::$qaDatabaseLocks[$lockKey])) {
|
||||
$handle = fopen($app->storagePath('framework/qa-database-'.$lockKey.'.lock'), 'c');
|
||||
if ($handle === false) {
|
||||
throw new \LogicException('QA database execution lock unavailable.');
|
||||
}
|
||||
if (! flock($handle, LOCK_EX | LOCK_NB)) {
|
||||
fclose($handle);
|
||||
throw new \LogicException('Another test process owns this QA database; run suites sequentially or use separate *_test databases.');
|
||||
}
|
||||
self::$qaDatabaseLocks[$lockKey] = $handle;
|
||||
}
|
||||
}
|
||||
|
||||
// migrate:fresh only clears the connection's public search path. This module owns a separate schema.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { recoverPathResult } from '../../resources/js/lib/investor-path.ts';
|
||||
import { serializeFinancialToolsSnapshot } from '../../resources/js/lib/financial-calculators.ts';
|
||||
|
||||
const inputs = {
|
||||
budgetPeriod: 'Mensal',
|
||||
loan: {
|
||||
principal: '100000',
|
||||
rate: '0',
|
||||
months: '24',
|
||||
rateType: 'nominal',
|
||||
periodMonths: '1',
|
||||
gracePeriods: '0',
|
||||
upfrontFee: '0',
|
||||
recurringFee: '0',
|
||||
},
|
||||
breakEven: { fixed: '1000', price: '100', variable: '50' },
|
||||
funding: { total: '100000', secured: '30000' },
|
||||
incomes: [{ label: 'Receita QA', amount: '100' }],
|
||||
expenses: [{ label: 'Custo QA', amount: '50' }],
|
||||
};
|
||||
void test('A/B returned dossier evidence reuses the tool formulas after a JSON round trip', () => {
|
||||
const result = JSON.parse(
|
||||
JSON.stringify({
|
||||
engine: 'financial-calculators:v1',
|
||||
financial_snapshot: serializeFinancialToolsSnapshot(inputs),
|
||||
}),
|
||||
);
|
||||
const recovered = recoverPathResult(result);
|
||||
assert.equal(recovered.breakEven.value.breakEvenUnits, '20');
|
||||
assert.equal(recovered.funding.value.gap, '70000.00');
|
||||
});
|
||||
void test('missing or invalid financial evidence stays indeterminate without hiding an independent calculation', () => {
|
||||
const recovered = recoverPathResult({
|
||||
engine: 'financial-calculators:v1',
|
||||
financial_snapshot: serializeFinancialToolsSnapshot({
|
||||
...inputs,
|
||||
breakEven: { fixed: '', price: '100', variable: '50' },
|
||||
}),
|
||||
});
|
||||
assert.equal(recovered.breakEven.value, null);
|
||||
assert.equal(recovered.funding.value.gap, '70000.00');
|
||||
assert.throws(
|
||||
() =>
|
||||
recoverPathResult({ engine: 'unknown', financial_snapshot: '{}' }),
|
||||
/Versão/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
recoverPathResult({
|
||||
engine: 'financial-calculators:v1',
|
||||
financial_snapshot: '{}',
|
||||
}),
|
||||
/Entradas/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user