217 lines
11 KiB
PHP
217 lines
11 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\InvestmentLegalSource;
|
|
use App\Models\User;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Tests\TestCase;
|
|
|
|
class SimulationTaxRuleGovernanceTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private User $editor;
|
|
|
|
private User $reviewer;
|
|
|
|
private User $approver;
|
|
|
|
private User $publisher;
|
|
|
|
private InvestmentLegalSource $source;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->editor = User::factory()->withTwoFactor()->create();
|
|
$this->reviewer = User::factory()->withTwoFactor()->create();
|
|
$this->approver = User::factory()->withTwoFactor()->create();
|
|
$this->publisher = User::factory()->withTwoFactor()->create();
|
|
config([
|
|
'portal.managers' => [$this->editor->email, $this->reviewer->email, $this->approver->email, $this->publisher->email],
|
|
'simulation-tax-governance.editors' => [$this->editor->email],
|
|
'simulation-tax-governance.reviewers' => [$this->editor->email, $this->reviewer->email],
|
|
'simulation-tax-governance.approvers' => [$this->approver->email],
|
|
'simulation-tax-governance.publishers' => [$this->publisher->email],
|
|
]);
|
|
$this->source = InvestmentLegalSource::query()->create([
|
|
'title' => 'Synthetic source for governance test',
|
|
'url' => 'https://law.example.invalid/test',
|
|
'diploma' => 'Synthetic test instrument',
|
|
'article' => 'Article 1 (test only)',
|
|
'competent_entity' => 'Synthetic test reviewer',
|
|
'valid_from' => '2020-01-01',
|
|
'valid_until' => null,
|
|
'validation_status' => 'validated',
|
|
'owner_id' => $this->editor->id,
|
|
'validator_id' => $this->reviewer->id,
|
|
'validated_at' => now(),
|
|
'version_no' => 2,
|
|
]);
|
|
}
|
|
|
|
public function test_rule_requires_independent_review_approval_publication_and_records_hash_chained_versions(): void
|
|
{
|
|
$this->actingAs($this->editor);
|
|
$created = $this->postJson('/api/v2/investment/admin/tax-rules', $this->rulePayload())
|
|
->assertCreated()->assertJsonPath('status', 'draft')->assertJsonPath('version_no', 1);
|
|
$id = $created->json('id');
|
|
|
|
$this->postJson("/api/v2/investment/admin/tax-rules/{$id}/submit", $this->transitionPayload(1))
|
|
->assertOk()->assertJsonPath('status', 'submitted')->assertJsonPath('version_no', 2);
|
|
|
|
$this->postJson("/api/v2/investment/admin/tax-rules/{$id}/review", $this->transitionPayload(2))
|
|
->assertForbidden();
|
|
|
|
$this->actingAs($this->reviewer)
|
|
->postJson("/api/v2/investment/admin/tax-rules/{$id}/review", $this->transitionPayload(2))
|
|
->assertOk()->assertJsonPath('status', 'reviewed')->assertJsonPath('reviewer_id', $this->reviewer->id);
|
|
|
|
$this->actingAs($this->approver)
|
|
->postJson("/api/v2/investment/admin/tax-rules/{$id}/approve", $this->transitionPayload(3))
|
|
->assertOk()->assertJsonPath('status', 'approved')->assertJsonPath('approver_id', $this->approver->id);
|
|
|
|
$this->actingAs($this->publisher)
|
|
->postJson("/api/v2/investment/admin/tax-rules/{$id}/publish", $this->transitionPayload(4))
|
|
->assertOk()->assertJsonPath('status', 'published')->assertJsonPath('publisher_id', $this->publisher->id);
|
|
|
|
$events = $this->visibleEvents($id);
|
|
$this->assertSame(['created', 'submit', 'review', 'approve', 'publish'], $events->pluck('action')->all());
|
|
foreach ($events as $event) {
|
|
$canonical = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$event->snapshot])->canonical;
|
|
$this->assertSame(hash('sha256', $canonical), $event->sha256);
|
|
}
|
|
$this->assertDatabaseHas('simulation_tax_rule_versions', [
|
|
'id' => $id, 'status' => 'published', 'rule_version' => 1, 'source_version_no' => 2,
|
|
]);
|
|
|
|
DB::select("select set_config('app.actor_user_id', '', true), set_config('app.simulation_tax_governance', '', true)");
|
|
$affected = null;
|
|
try {
|
|
$affected = DB::transaction(fn () => DB::table('simulation_tax_rule_events')
|
|
->where('rule_version_id', $id)->update(['action' => 'tampered']), 1);
|
|
} catch (QueryException) {
|
|
// The immutable trigger rejects this on superuser-based test connections.
|
|
}
|
|
$this->assertTrue($affected === null || $affected === 0, 'RLS or the immutable trigger must reject direct event mutation.');
|
|
$this->assertSame($events->pluck('action')->all(), $this->visibleEvents($id)->pluck('action')->all());
|
|
$publishedStatus = DB::transaction(function () use ($id): ?string {
|
|
DB::select("select set_config('app.actor_user_id', ?, true), set_config('app.simulation_tax_governance', 'on', true)", [(string) $this->publisher->id]);
|
|
|
|
return DB::table('simulation_tax_rule_versions')->where('id', $id)->value('status');
|
|
});
|
|
$this->assertSame('published', $publishedStatus);
|
|
$this->actingAs($this->publisher)->getJson('/api/v2/investment/admin/tax-rules?rule_code=SYNTHETIC.TEST.RULE')
|
|
->assertOk()->assertJsonPath('total', 1);
|
|
}
|
|
|
|
public function test_source_revocation_stops_publication_and_conflicting_effective_rules_are_rejected(): void
|
|
{
|
|
$first = $this->publishRule($this->rulePayload());
|
|
$this->assertSame('published', $first['status']);
|
|
|
|
$conflict = $this->rulePayload();
|
|
$conflict['rule_version'] = 2;
|
|
$this->actingAs($this->editor);
|
|
$draft = $this->postJson('/api/v2/investment/admin/tax-rules', $conflict)->assertCreated();
|
|
$id = $draft->json('id');
|
|
$this->postJson("/api/v2/investment/admin/tax-rules/{$id}/submit", $this->transitionPayload(1))->assertOk();
|
|
$this->actingAs($this->reviewer)->postJson("/api/v2/investment/admin/tax-rules/{$id}/review", $this->transitionPayload(2))->assertOk();
|
|
$this->actingAs($this->approver)->postJson("/api/v2/investment/admin/tax-rules/{$id}/approve", $this->transitionPayload(3))->assertOk();
|
|
$this->actingAs($this->publisher)->postJson("/api/v2/investment/admin/tax-rules/{$id}/publish", $this->transitionPayload(4))
|
|
->assertConflict();
|
|
|
|
$this->source->update(['validation_status' => 'revoked', 'version_no' => 3]);
|
|
$this->postJson("/api/v2/investment/admin/tax-rules/{$id}/publish", $this->transitionPayload(4))
|
|
->assertUnprocessable();
|
|
$ruleId = $first['id'];
|
|
$this->postJson("/api/v2/investment/admin/tax-rules/{$ruleId}/suspend", $this->transitionPayload(5))
|
|
->assertOk()->assertJsonPath('status', 'suspended');
|
|
}
|
|
|
|
public function test_draft_edit_uses_optimistic_locking_and_other_roles_cannot_create_rules(): void
|
|
{
|
|
$this->actingAs($this->reviewer)->postJson('/api/v2/investment/admin/tax-rules', $this->rulePayload())->assertForbidden();
|
|
|
|
$this->actingAs($this->editor);
|
|
$created = $this->postJson('/api/v2/investment/admin/tax-rules', $this->rulePayload())->assertCreated();
|
|
$id = $created->json('id');
|
|
$payload = $this->rulePayload();
|
|
$payload['rule_definition'] = ['kind' => 'unexecuted_declarative_example'];
|
|
$this->putJson("/api/v2/investment/admin/tax-rules/{$id}", ['version_no' => 99, ...$payload])->assertConflict();
|
|
$this->putJson("/api/v2/investment/admin/tax-rules/{$id}", ['version_no' => 1, ...$payload])
|
|
->assertOk()->assertJsonPath('version_no', 2);
|
|
$this->postJson("/api/v2/investment/admin/tax-rules/{$id}/submit", $this->transitionPayload(1))->assertConflict();
|
|
}
|
|
|
|
public function test_rollback_refuses_to_drop_rule_versions_or_their_audit_history(): void
|
|
{
|
|
$this->actingAs($this->editor)->postJson('/api/v2/investment/admin/tax-rules', $this->rulePayload())->assertCreated();
|
|
$migration = require database_path('migrations/2026_10_03_170000_create_simulation_tax_rule_governance.php');
|
|
|
|
try {
|
|
$migration->down();
|
|
$this->fail('Rollback must preserve recorded rule governance.');
|
|
} catch (\RuntimeException $exception) {
|
|
$this->assertStringContainsString('history exists', $exception->getMessage());
|
|
}
|
|
|
|
$this->assertTrue(Schema::hasTable('simulation_tax_rule_versions'));
|
|
$this->assertTrue(Schema::hasTable('simulation_tax_rule_events'));
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
private function publishRule(array $payload): array
|
|
{
|
|
$this->actingAs($this->editor);
|
|
$draft = $this->postJson('/api/v2/investment/admin/tax-rules', $payload)->assertCreated();
|
|
$id = $draft->json('id');
|
|
$this->postJson("/api/v2/investment/admin/tax-rules/{$id}/submit", $this->transitionPayload(1))->assertOk();
|
|
$this->actingAs($this->reviewer)->postJson("/api/v2/investment/admin/tax-rules/{$id}/review", $this->transitionPayload(2))->assertOk();
|
|
$this->actingAs($this->approver)->postJson("/api/v2/investment/admin/tax-rules/{$id}/approve", $this->transitionPayload(3))->assertOk();
|
|
|
|
return $this->actingAs($this->publisher)->postJson("/api/v2/investment/admin/tax-rules/{$id}/publish", $this->transitionPayload(4))
|
|
->assertOk()->json();
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
private function rulePayload(): array
|
|
{
|
|
return [
|
|
'rule_code' => 'SYNTHETIC.TEST.RULE',
|
|
'rule_version' => 1,
|
|
'component' => 'IRPC',
|
|
'scope_key' => 'MZ:test:synthetic',
|
|
'valid_from' => '2026-01-01',
|
|
'valid_until' => null,
|
|
'legal_source_id' => $this->source->id,
|
|
'source_sha256' => str_repeat('a', 64),
|
|
'rule_definition' => ['kind' => 'unexecuted_synthetic_fixture'],
|
|
'parameters' => ['fixture' => 'not-a-tax-rate'],
|
|
'rounding_policy' => ['fixture' => 'not-approved'],
|
|
];
|
|
}
|
|
|
|
/** @return array{version_no:int,evidence:string} */
|
|
private function transitionPayload(int $version): array
|
|
{
|
|
return ['version_no' => $version, 'evidence' => 'Synthetic governance evidence for automated test only.'];
|
|
}
|
|
|
|
/** @return Collection<int,object> */
|
|
private function visibleEvents(string $ruleId): Collection
|
|
{
|
|
return DB::transaction(function () use ($ruleId): Collection {
|
|
DB::select("select set_config('app.actor_user_id', ?, true), set_config('app.simulation_tax_governance', 'on', true)", [(string) $this->publisher->id]);
|
|
|
|
return DB::table('simulation_tax_rule_events')->where('rule_version_id', $ruleId)->orderBy('version_no')->get();
|
|
});
|
|
}
|
|
}
|