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 */ 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 */ 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 */ 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(); }); } }