Files
Investidor-V2/tests/Feature/SimulationFiscalMaintenanceActorTest.php
2026-10-21 12:39:07 +02:00

218 lines
13 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\InvestmentLegalSource;
use App\Models\SimulationTaxRuleVersion;
use App\Models\User;
use App\Services\PortalProfileAdministration;
use App\Services\SimulationTaxParameterResolver;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Tests\TestCase;
/** Only the maintenance control is exercised; no simulation or arithmetic runs here. */
class SimulationFiscalMaintenanceActorTest extends TestCase
{
use DatabaseTransactions;
private User $maintainer;
private array $otherConstraints;
protected function setUp(): void
{
parent::setUp();
$this->otherConstraints = $this->unrelatedConstraints();
if (! Schema::hasColumn('investment_legal_sources', 'fiscal_acceptance')) {
(require database_path('migrations/2026_10_21_130000_correct_fiscal_maintenance_actor_constraints.php'))->up();
}
$this->maintainer = User::factory()->withTwoFactor()->create();
config(['portal.managers' => [$this->maintainer->email],
'simulation-tax-governance.editors' => [], 'simulation-tax-governance.reviewers' => [],
'simulation-tax-governance.approvers' => [], 'simulation-tax-governance.publishers' => [],
'simulation-fiscal-consumer.enabled' => false]);
}
private function unrelatedConstraints(): array
{
return array_map(fn ($row) => (array) $row, DB::select("SELECT conrelid::regclass::text AS relation, conname, pg_get_constraintdef(oid) AS definition FROM pg_constraint
WHERE connamespace='public'::regnamespace AND conrelid NOT IN ('public.investment_legal_sources'::regclass,'public.simulation_tax_rule_versions'::regclass)
ORDER BY relation,conname"));
}
private function sourceInput(): array
{
return ['title' => 'Synthetic QA official source curation; not a destination record',
'url' => 'https://www.inm.gov.mz/', 'diploma' => 'Lei 22/2022', 'article' => 'Artigo 17(1)',
'competent_entity' => 'Assembleia da República', 'valid_from' => '2023-01-01', 'valid_until' => null,
'sha256' => str_repeat('a', 64), 'citation' => 'QA fixture: PDF 3, art.17(1); no legal opinion.',
'official_origin' => 'QA fixture of an identified official publication; not a new legal validation.'];
}
private function source(): string
{
return $this->actingAs($this->maintainer)->postJson('/api/v2/investment/admin/tax-rule-sources', $this->sourceInput())
->assertCreated()->assertJsonPath('fiscal_acceptance.legal_opinion', false)
->assertJsonPath('fiscal_acceptance.independent_review', false)
->assertJsonPath('owner_id', $this->maintainer->id)->assertJsonPath('validator_id', null)
->assertJsonPath('validation_status', 'pending')->assertJsonPath('fiscal_acceptance.responsible_user_id', $this->maintainer->id)->json('id');
}
private function rule(string $source): string
{
$payload = json_decode(file_get_contents(database_path('qa/iva-general-net-v1.json')), true, flags: JSON_THROW_ON_ERROR);
$payload['legal_source_id'] = $source;
$payload['source_sha256'] = str_repeat('a', 64);
return $this->postJson('/api/v2/investment/admin/tax-rules', $payload)->assertCreated()->json('id');
}
public function test_one_existing_administrator_completes_fiscal_curation_with_responsibility_and_history(): void
{
$simulationCount = DB::table('simulations')->count();
$source = $this->source();
$id = $this->rule($source);
$evidence = 'QA technical parameter maintenance by the authorised same actor; not independent review or legal validation.';
$this->postJson('/api/v2/investment/admin/tax-rules/'.$id.'/publish', ['version_no' => 1, 'evidence' => $evidence])->assertUnprocessable();
foreach (['submit', 'review', 'approve', 'publish'] as $offset => $action) {
$this->postJson('/api/v2/investment/admin/tax-rules/'.$id.'/'.$action,
['version_no' => $offset + 1, 'evidence' => $evidence])->assertOk()->assertJsonPath('version_no', $offset + 2);
}
$rule = SimulationTaxRuleVersion::findOrFail($id);
foreach (['author_id', 'reviewer_id', 'approver_id', 'publisher_id'] as $column) {
$this->assertSame($this->maintainer->id, (int) $rule->$column);
}
$this->assertSame('published', $rule->status);
$this->assertFalse(InvestmentLegalSource::whereKey($source)->where('validation_status', 'validated')->exists());
// Source eligibility is checked through the existing resolver, without executing a formula.
DB::statement('CREATE ROLE qa_fiscal_maintenance_read_20261021 NOLOGIN NOSUPERUSER NOBYPASSRLS');
DB::statement('GRANT USAGE ON SCHEMA public TO qa_fiscal_maintenance_read_20261021');
DB::statement('GRANT SELECT ON simulation_tax_rule_versions, investment_legal_sources TO qa_fiscal_maintenance_read_20261021');
DB::statement("SELECT set_config('app.simulation_tax_governance', 'off', true)");
DB::statement('SET LOCAL ROLE qa_fiscal_maintenance_read_20261021');
try {
$this->assertSame(1, SimulationTaxRuleVersion::whereKey($id)->count());
$parameters = app(SimulationTaxParameterResolver::class)->resolvePublishedForPeriod('IVA', '2026-10-21', '2026-10-21',
['currency' => 'MZN', 'operation' => 'internal_general_net']);
$this->assertSame('resolved', $parameters[0]['status']);
} finally {
DB::statement('RESET ROLE');
}
$this->assertSame(1, (int) $rule->source_version_no);
$this->assertSame(str_repeat('a', 64), $rule->source_sha256);
$events = DB::table('simulation_tax_rule_events')->where('rule_version_id', $id)->orderBy('version_no')->get();
$this->assertSame(['created', 'submit', 'review', 'approve', 'publish'], $events->pluck('action')->all());
foreach ($events as $event) {
$this->assertSame($this->maintainer->id, (int) $event->actor_user_id);
$canonical = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$event->snapshot])->canonical;
$this->assertSame(hash('sha256', $canonical), $event->sha256);
}
$this->assertDatabaseHas('audit_events', ['entity_id' => $source, 'actor_user_id' => $this->maintainer->id, 'action' => 'official_source_acceptance']);
$this->getJson('/api/v2/investment/admin/tax-rules/'.$id)->assertOk()->assertJsonPath('id', $id);
$this->getJson('/api/v1/fiscal-consumer')->assertOk()->assertJsonPath('data.enabled', false);
$this->assertSame($simulationCount, DB::table('simulations')->count());
$this->assertSame($this->otherConstraints, $this->unrelatedConstraints());
$this->postJson('/api/v2/investment/admin/tax-rules/'.$id.'/suspend', ['version_no' => 5, 'evidence' => $evidence])->assertOk();
$this->assertSame(6, DB::table('simulation_tax_rule_events')->where('rule_version_id', $id)->count());
}
public function test_investor_and_unconfirmed_mfa_cannot_maintain_fiscal_parameters(): void
{
$path = '/api/v2/investment/admin/tax-rule-sources';
$this->postJson($path, $this->sourceInput())->assertUnauthorized();
$this->actingAs(User::factory()->withTwoFactor()->create())->postJson($path, $this->sourceInput())->assertForbidden();
$withoutMfa = User::factory()->create();
config(['portal.managers' => [$withoutMfa->email]]);
$this->actingAs($withoutMfa)->postJson($path, $this->sourceInput())->assertForbidden();
config(['portal.managers' => [$this->maintainer->email], 'portal.profile_access_enabled' => true]);
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->withTwoFactor()->create(),
['user_id' => $this->maintainer->id, 'role' => 'auditor', 'organization_id' => null,
'opportunity_types' => ['investment_project'], 'source' => 'synthetic-qa-fiscal-control']);
$this->actingAs($this->maintainer)->postJson($path, $this->sourceInput())->assertForbidden();
}
public function test_evidence_source_version_locking_and_immutable_events_remain_required(): void
{
$this->actingAs($this->maintainer)->postJson('/api/v2/investment/admin/tax-rule-sources',
[...$this->sourceInput(), 'sha256' => ''])->assertUnprocessable();
$source = $this->source();
$wrong = json_decode(file_get_contents(database_path('qa/iva-general-net-v1.json')), true, flags: JSON_THROW_ON_ERROR);
$wrong['legal_source_id'] = $source;
$wrong['source_sha256'] = str_repeat('b', 64);
$this->postJson('/api/v2/investment/admin/tax-rules', $wrong)->assertUnprocessable();
$id = $this->rule($source);
$path = '/api/v2/investment/admin/tax-rules/'.$id.'/submit';
$this->postJson($path, ['version_no' => 1, 'evidence' => 'short'])->assertUnprocessable();
$this->postJson($path, ['version_no' => 2, 'evidence' => 'QA evidence and correct actor; stale version.'])->assertConflict();
InvestmentLegalSource::whereKey($source)->update(['version_no' => 2]);
$this->postJson($path, ['version_no' => 1, 'evidence' => 'QA evidence and correct actor; source changed.'])->assertUnprocessable();
$this->assertSame('draft', SimulationTaxRuleVersion::findOrFail($id)->status);
try {
DB::transaction(fn () => DB::table('simulation_tax_rule_events')->where('rule_version_id', $id)->update(['action' => 'tampered']));
$this->fail('Audit must stay immutable.');
} catch (QueryException) {
$this->assertDatabaseHas('simulation_tax_rule_events', ['rule_version_id' => $id, 'action' => 'created']);
}
try {
(require database_path('migrations/2026_10_21_130000_correct_fiscal_maintenance_actor_constraints.php'))->down();
$this->fail('Rollback must preserve legitimate fiscal curation.');
} catch (\LogicException $exception) {
$this->assertStringContainsString('conservar', $exception->getMessage());
}
}
public function test_other_source_validation_keeps_its_independence_and_active_migration_is_refused(): void
{
$data = $this->sourceInput();
unset($data['sha256'], $data['citation'], $data['official_origin']);
$source = InvestmentLegalSource::create([...$data, 'owner_id' => $this->maintainer->id, 'validation_status' => 'pending']);
config(['investment-zones.legal_reviewers' => [$this->maintainer->email]]);
$this->actingAs($this->maintainer)->postJson('/api/v2/investment/admin/legal-sources/'.$source->id.'/validate',
['version_no' => 1])->assertForbidden();
try {
DB::transaction(fn () => $source->update(['validator_id' => $this->maintainer->id, 'validated_at' => now(), 'validation_status' => 'validated']));
$this->fail('Other source curation must retain its check.');
} catch (QueryException) {
$this->assertSame('pending', $source->fresh()->validation_status);
}
config(['database.connections.pgsql.url' => 'not-an-isolated-qa-target']);
$this->expectException(\LogicException::class);
(require database_path('migrations/2026_10_21_130000_correct_fiscal_maintenance_actor_constraints.php'))->up();
}
public function test_scoped_administration_is_reused_without_four_email_designations(): void
{
config(['portal.managers' => []]);
config(['portal.profile_access_enabled' => true]);
$second = User::factory()->withTwoFactor()->create();
// Pre-existing-authority fixture, exclusively inside the reverted QA transaction.
DB::table('portal_superuser_pair')->updateOrInsert(['id' => 1], [
'first_user_id' => $this->maintainer->id, 'second_user_id' => $second->id,
'approved_by' => $second->id, 'version' => 1, 'created_at' => now(), 'updated_at' => now(),
]);
$this->actingAs($this->maintainer)->withSession(['portal.mfa_actor' => $this->maintainer->id, 'portal.mfa_at' => time()]);
$source = $this->source();
$id = $this->rule($source);
foreach (['submit', 'review', 'approve', 'publish'] as $offset => $action) {
$this->postJson('/api/v2/investment/admin/tax-rules/'.$id.'/'.$action,
['version_no' => $offset + 1, 'evidence' => 'QA existing scoped administrator completes fiscal maintenance; not independent legal validation.'])->assertOk();
}
$this->withSession(['portal.mfa_at' => time() - 901])->postJson('/api/v2/investment/admin/tax-rule-sources', $this->sourceInput())->assertForbidden();
}
public function test_unused_correction_can_be_rolled_back_and_reapplied_only_in_qa(): void
{
$migration = require database_path('migrations/2026_10_21_130000_correct_fiscal_maintenance_actor_constraints.php');
$migration->down();
$this->assertFalse(Schema::hasColumn('investment_legal_sources', 'fiscal_acceptance'));
$migration->up();
$this->assertTrue(Schema::hasColumn('investment_legal_sources', 'fiscal_acceptance'));
$this->assertSame($this->otherConstraints, $this->unrelatedConstraints());
}
}