create(['email_verified_at' => now()]); $other = User::factory()->create(['email_verified_at' => now()]); $this->actingAs($owner)->postJson('/api/v1/simulations', [ 'base' => '1200', 'rate' => '7.5', 'reduction' => '0', ], ['Idempotency-Key' => (string) Str::uuid()])->assertAccepted(); $this->actingAs($other)->postJson('/api/v1/simulations', [ 'base' => '9900', 'rate' => '9', 'reduction' => '1', ], ['Idempotency-Key' => (string) Str::uuid()])->assertAccepted(); $organizationId = (string) Str::uuid(); DB::table('organizations')->insert([ 'id' => $organizationId, 'name' => 'History organization', 'organization_type' => 'private', 'data_classification' => 'internal', 'created_at' => now(), 'updated_at' => now(), ]); DB::table('memberships')->insert([ 'id' => (string) Str::uuid(), 'user_id' => $owner->id, 'organization_id' => $organizationId, 'role' => 'admin', 'created_at' => now(), 'updated_at' => now(), ]); $this->actingAs($owner)->postJson('/api/v1/simulations', [ 'base' => '700', 'rate' => '4', 'reduction' => '0', 'organization_id' => $organizationId, ], ['Idempotency-Key' => (string) Str::uuid()])->assertAccepted(); auth()->logout(); $this->postJson('/api/v1/simulations', [ 'base' => '500', 'rate' => '10', 'reduction' => '0', ], ['Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => Str::random(48)])->assertAccepted(); $response = $this->actingAs($owner)->getJson('/api/v1/simulations/history') ->assertOk()->assertJsonPath('meta.total', 2)->assertJsonPath('data.0.status', 'queued') ->assertJsonStructure(['data' => [['id', 'scope', 'organization_name', 'status', 'version_no', 'created_at', 'updated_at', 'has_ready_report']], 'meta' => ['current_page', 'last_page', 'per_page', 'total']]); $entries = $response->json('data'); $this->assertEqualsCanonicalizing(['personal', 'organization'], array_column($entries, 'scope')); $this->assertSame('History organization', collect($entries)->firstWhere('scope', 'organization')['organization_name']); $entry = $entries[0]; $this->assertArrayHasKey('id', $entry); $this->assertArrayNotHasKey('input_snapshot', $entry); $this->assertArrayNotHasKey('result_snapshot', $entry); $this->assertArrayNotHasKey('anonymous_token_hash', $entry); $this->actingAs($other)->getJson('/api/v1/simulations/history') ->assertOk()->assertJsonPath('meta.total', 1); } public function test_account_history_requires_authentication_and_verified_email(): void { $this->getJson('/api/v1/simulations/history')->assertUnauthorized(); $user = User::factory()->create(['email_verified_at' => null]); $this->actingAs($user)->getJson('/api/v1/simulations/history')->assertForbidden(); } public function test_anonymous_execution_keeps_only_token_hash_and_reconciles_immutable_sources(): void { $token = Str::random(48); $key = (string) Str::uuid(); $response = $this->postJson('/api/v1/simulations', ['base' => '1000', 'rate' => '10', 'reduction' => '50'], [ 'Idempotency-Key' => $key, 'X-Simulation-Token' => $token, ])->assertAccepted()->assertJsonPath('data.status', 'queued')->assertJsonPath('access_token', $token); $queuedEtag = $response->headers->get('ETag'); $simulationId = $response->json('data.id'); $this->assertDatabaseHas('simulations', ['id' => $simulationId, 'anonymous_token_hash' => hash_hmac('sha256', $token, (string) config('app.key'))]); $this->assertDatabaseMissing('simulations', ['id' => $simulationId, 'anonymous_token_hash' => $token]); app(RunSimulation::class, ['simulationId' => $simulationId, 'actorId' => null, 'tokenHash' => hash_hmac('sha256', $token, (string) config('app.key'))]) ->handle(app(SimulationExecution::class)); $completedResponse = $this->getJson('/api/v1/simulations/'.$simulationId, ['X-Simulation-Token' => $token]) ->assertOk()->assertJsonPath('data.status', 'completed'); $this->assertNotSame($queuedEtag, $completedResponse->headers->get('ETag')); $simulationView = $completedResponse->json('data'); $this->assertEqualsWithDelta(50.0, (float) $simulationView['result_snapshot']['indicative_total'], 0.0000001); $resultSnapshot = $simulationView['result_snapshot']; $scenario = DB::table('simulation_scenarios')->where('simulation_id', $simulationId)->first(); $this->assertSame('custom_planning', $resultSnapshot['scenario_kind']); $this->assertSame('custom_planning', json_decode($scenario->parameters, true, flags: JSON_THROW_ON_ERROR)['scenario_kind']); $this->assertSame(1, $resultSnapshot['contract_version']); $this->assertSame('calculated', $resultSnapshot['result']['status']); $this->assertSame('Resultado aritmético determinístico; não constitui cálculo nem conclusão fiscal.', $resultSnapshot['result']['explanation']); $this->assertSame($resultSnapshot['indicative_total'], $resultSnapshot['result']['value']); $this->assertIsString($resultSnapshot['result']['value']); $this->assertSame([], $resultSnapshot['result']['missing_inputs']); $this->assertSame([], $resultSnapshot['result']['source_ids']); $this->assertSame((string) $scenario->id, $resultSnapshot['context']['scenario_id']); $this->assertSame((int) $scenario->version_no, $resultSnapshot['context']['scenario_version']); $this->assertSame(substr((string) $scenario->created_at, 0, 10), $resultSnapshot['context']['reference_date']); $this->assertNull($resultSnapshot['context']['currency']); $this->assertNull($resultSnapshot['context']['ruleset_version']); $this->assertSame('portal-arithmetic-v1+simulation-v1', $resultSnapshot['context']['engine_version']); $this->assertSame('portal-arithmetic-v1', $resultSnapshot['formula_version']); $this->assertDatabaseHas('simulation_result_sources', ['source_type' => 'user_input']); $this->assertSame(1, DB::table('simulation_reports')->where('simulation_id', $simulationId)->where('status', 'ready')->count()); $savedReport = DB::table('simulation_reports')->where('simulation_id', $simulationId)->where('status', 'ready')->first(); $this->assertSame($resultSnapshot, json_decode($savedReport->report_snapshot, true, flags: JSON_THROW_ON_ERROR)); $reportResponse = $this->getJson('/api/v1/simulations/'.$simulationId.'/reports/'.$savedReport->id, [ 'X-Simulation-Token' => $token, ])->assertOk()->assertJsonPath('data.id', $savedReport->id) ->assertJsonPath('data.status', 'ready') ->assertJsonStructure(['data' => ['id', 'simulation_id', 'version_no', 'status', 'report_snapshot', 'report_sha256']]); $this->assertSame($resultSnapshot, $reportResponse->json('data.report_snapshot')); $this->assertSame(trim((string) $savedReport->report_sha256), $reportResponse->json('data.report_sha256')); $jobsResponse = $this->getJson('/api/v1/simulations/'.$simulationId.'/jobs', ['X-Simulation-Token' => $token]) ->assertOk()->assertJsonPath('data.0.status', 'succeeded') ->assertJsonStructure(['data' => [['id', 'simulation_id', 'job_type', 'status', 'attempts', 'available_at', 'started_at', 'finished_at', 'error_code']]]); $this->assertNotNull($jobsResponse->json('data.0.started_at')); $this->assertNotNull($jobsResponse->json('data.0.finished_at')); $this->assertNull($jobsResponse->json('data.0.error_code')); $this->getJson('/api/v1/simulations/'.$simulationId.'/reports/'.$savedReport->id, [ 'X-Simulation-Token' => Str::random(48), ])->assertNotFound(); $simulation = DB::table('simulations')->where('id', $simulationId)->first(); $result = DB::table('simulation_results')->where('simulation_id', $simulationId)->first(); $source = DB::table('simulation_result_sources')->where('result_id', $result->id)->first(); $resultCanonical = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$result->result_snapshot])->canonical; $sourceCanonical = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$source->source_snapshot])->canonical; $this->assertSame($result->result_sha256, hash('sha256', $resultCanonical)); $this->assertSame($source->source_sha256, hash('sha256', $sourceCanonical)); $this->assertNotNull($simulation->questionnaire_version_id); } public function test_simulation_audit_events_are_append_only_for_an_authorized_owner(): void { $owner = User::factory()->create(['email_verified_at' => now()]); $response = $this->actingAs($owner)->postJson('/api/v1/simulations', [ 'base' => '1000', 'rate' => '10', 'reduction' => '0', ], ['Idempotency-Key' => (string) Str::uuid()])->assertAccepted(); $simulationId = $response->json('data.id'); app(SimulationExecution::class)->setContext($owner->id, null); $event = DB::table('simulation_audit_events')->where('simulation_id', $simulationId)->first(); $this->assertNotNull($event); try { DB::transaction(fn () => DB::table('simulation_audit_events') ->where('id', $event->id) ->update(['action' => 'audit.tampered'])); } catch (QueryException) { // Superuser-based test connections reach the immutable trigger; // restricted roles may be denied earlier by row-level security. } $this->assertDatabaseMissing('simulation_audit_events', [ 'id' => $event->id, 'action' => 'audit.tampered', ]); $this->assertSame(1, (int) DB::selectOne( 'select count(*) as n from pg_trigger where tgname = ? and not tgisinternal', ['simulation_audit_immutable'], )->n); } public function test_execution_requires_an_explicit_user_supplied_reduction(): void { $this->postJson('/api/v1/simulations', ['base' => '500', 'rate' => '5'], [ 'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => Str::random(48), ])->assertUnprocessable()->assertJsonValidationErrors('reduction'); } public function test_idempotency_replays_same_payload_and_rejects_key_reuse_with_different_payload(): void { $token = Str::random(48); $key = (string) Str::uuid(); $headers = ['Idempotency-Key' => $key, 'X-Simulation-Token' => $token]; $first = $this->postJson('/api/v1/simulations', ['base' => '500', 'rate' => '5', 'reduction' => '0'], $headers)->assertAccepted(); $id = $first->json('data.id'); $this->postJson('/api/v1/simulations', ['base' => '500', 'rate' => '5', 'reduction' => '0'], $headers) ->assertOk()->assertJsonPath('data.id', $id)->assertJsonPath('replayed', true); $this->postJson('/api/v1/simulations', ['base' => '500', 'rate' => '6', 'reduction' => '0'], $headers)->assertConflict(); $this->assertSame(1, DB::table('simulations')->where('id', $id)->count()); } public function test_if_match_is_required_and_stale_versions_return_412_then_success_advances_version(): void { $token = Str::random(48); $response = $this->postJson('/api/v1/simulations', ['base' => '1000', 'rate' => '10', 'reduction' => '0'], [ 'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token, ])->assertAccepted(); $id = $response->json('data.id'); $scenarioId = $response->json('data.scenarios.0.id'); app(RunSimulation::class, ['simulationId' => $id, 'actorId' => null, 'tokenHash' => hash_hmac('sha256', $token, (string) config('app.key'))]) ->handle(app(SimulationExecution::class)); $response = $this->getJson('/api/v1/simulations/'.$id, ['X-Simulation-Token' => $token])->assertOk(); $payload = ['label' => 'Cenário revisto', 'base' => '2000', 'rate' => '8', 'reduction' => '25']; $url = "/api/v1/simulations/{$id}/scenarios/{$scenarioId}"; $headers = ['X-Simulation-Token' => $token]; $this->patchJson($url, $payload, $headers)->assertStatus(428); $this->patchJson($url, $payload, $headers + ['If-Match' => '"sim-'.$id.'-v999"'])->assertStatus(412); $etag = $response->headers->get('ETag'); $updated = $this->patchJson($url, $payload, $headers + ['If-Match' => $etag])->assertAccepted(); $updated->assertHeader('ETag', '"sim-'.$id.'-v3"') ->assertJsonPath('data.status', 'queued') ->assertJsonPath('data.version_no', 3) ->assertJsonPath('data.scenarios.0.parameters.base', '2000') ->assertJsonPath('data.scenarios.0.parameters.rate', '8') ->assertJsonPath('data.scenarios.0.parameters.reduction', '25'); $tokenHash = hash_hmac('sha256', $token, (string) config('app.key')); app(RunSimulation::class, ['simulationId' => $id, 'actorId' => null, 'tokenHash' => $tokenHash])->handle(app(SimulationExecution::class)); $this->assertSame(2, DB::table('simulation_results')->where('simulation_id', $id)->count()); $this->assertSame(2, DB::table('simulation_reports')->where('simulation_id', $id)->count()); $this->patchJson($url, $payload, $headers + ['If-Match' => $etag])->assertStatus(412); } public function test_rls_hides_rows_without_context_and_from_an_unrelated_actor(): void { $owner = User::factory()->create(['email_verified_at' => now()]); $other = User::factory()->create(['email_verified_at' => now()]); $this->actingAs($owner)->postJson('/api/v1/simulations', ['base' => '10', 'rate' => '1', 'reduction' => '0'], ['Idempotency-Key' => (string) Str::uuid()])->assertAccepted(); $id = DB::table('simulations')->where('owner_user_id', $owner->id)->value('id'); $execution = app(SimulationExecution::class); DB::unprepared('DO $$ BEGIN CREATE ROLE simulation_rls_test NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$'); DB::statement('GRANT simulation_rls_test TO CURRENT_USER'); DB::statement('GRANT USAGE ON SCHEMA public TO simulation_rls_test'); DB::statement('GRANT SELECT ON public.simulations, public.memberships TO simulation_rls_test'); try { DB::statement('SET ROLE simulation_rls_test'); $execution->setContext($other->id, null); $this->assertSame(0, (int) DB::scalar('select count(*) from public.simulations where id = ?', [$id])); $execution->setContext($owner->id, null); $this->assertSame(1, (int) DB::scalar('select count(*) from public.simulations where id = ?', [$id])); } finally { DB::statement('RESET ROLE'); DB::statement('REVOKE simulation_rls_test FROM CURRENT_USER'); DB::statement('REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM simulation_rls_test'); DB::statement('REVOKE USAGE ON SCHEMA public FROM simulation_rls_test'); DB::statement('DROP ROLE IF EXISTS simulation_rls_test'); } $this->actingAs($other)->getJson('/api/v1/simulations/'.$id)->assertNotFound(); } public function test_terminal_job_failure_is_reconciled_without_exposing_exception_text(): void { $owner = User::factory()->create(['email_verified_at' => now()]); $this->actingAs($owner)->postJson('/api/v1/simulations', ['base' => '10', 'rate' => '1', 'reduction' => '0'], ['Idempotency-Key' => (string) Str::uuid()])->assertAccepted(); $simulation = DB::table('simulations')->where('owner_user_id', $owner->id)->first(); $job = new RunSimulation($simulation->id, $owner->id, null); $job->failed(new \RuntimeException('sensitive internal detail')); $this->assertDatabaseHas('simulation_jobs', ['simulation_id' => $simulation->id, 'status' => 'failed', 'error_code' => 'execution_failed']); $this->assertDatabaseHas('simulations', ['id' => $simulation->id, 'status' => 'failed', 'error_code' => 'execution_failed']); $this->assertDatabaseMissing('simulation_audit_events', ['simulation_id' => $simulation->id, 'action' => 'sensitive internal detail']); } public function test_organization_membership_scopes_simulation_reads(): void { $owner = User::factory()->create(['email_verified_at' => now()]); $member = User::factory()->create(['email_verified_at' => now()]); $outsider = User::factory()->create(['email_verified_at' => now()]); $organizationId = (string) Str::uuid(); DB::table('organizations')->insert(['id' => $organizationId, 'name' => 'Test organization', 'organization_type' => 'private', 'data_classification' => 'internal', 'created_at' => now(), 'updated_at' => now()]); DB::table('memberships')->insert([ ['id' => (string) Str::uuid(), 'user_id' => $owner->id, 'organization_id' => $organizationId, 'role' => 'admin', 'created_at' => now(), 'updated_at' => now()], ['id' => (string) Str::uuid(), 'user_id' => $member->id, 'organization_id' => $organizationId, 'role' => 'viewer', 'created_at' => now(), 'updated_at' => now()], ]); $this->actingAs($owner)->postJson('/api/v1/simulations', ['base' => '100', 'rate' => '2', 'reduction' => '0', 'organization_id' => $organizationId], ['Idempotency-Key' => (string) Str::uuid()])->assertAccepted(); $id = DB::table('simulations')->where('owner_user_id', $owner->id)->value('id'); $this->actingAs($member)->getJson('/api/v1/simulations/'.$id)->assertOk(); $this->actingAs($outsider)->getJson('/api/v1/simulations/'.$id)->assertNotFound(); DB::unprepared('DO $$ BEGIN CREATE ROLE simulation_rls_test NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$'); DB::statement('GRANT simulation_rls_test TO CURRENT_USER'); DB::statement('GRANT USAGE ON SCHEMA public TO simulation_rls_test'); DB::statement('GRANT SELECT, UPDATE ON public.simulations TO simulation_rls_test'); DB::statement('GRANT SELECT ON public.memberships TO simulation_rls_test'); try { DB::statement('SET ROLE simulation_rls_test'); $execution = app(SimulationExecution::class); $execution->setContext($member->id, null); $this->assertSame(1, (int) DB::scalar('select count(*) from public.simulations where id = ?', [$id])); $affected = null; try { $affected = DB::transaction(fn () => DB::affectingStatement( 'update public.simulations set version_no = version_no + 1 where id = ?', [$id], )); } catch (QueryException) { // PostgreSQL may reject the UPDATE with WITH CHECK instead of returning zero rows. } $this->assertTrue($affected === null || $affected === 0); } finally { DB::statement('RESET ROLE'); DB::statement('REVOKE simulation_rls_test FROM CURRENT_USER'); DB::statement('REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM simulation_rls_test'); DB::statement('REVOKE USAGE ON SCHEMA public FROM simulation_rls_test'); DB::statement('DROP ROLE IF EXISTS simulation_rls_test'); } DB::table('memberships')->where('user_id', $owner->id)->where('organization_id', $organizationId)->delete(); $this->actingAs($owner)->getJson('/api/v1/simulations/'.$id)->assertNotFound(); } public function test_postgres_rls_uses_anonymous_token_hmac_not_plain_token(): void { $token = Str::random(48); $response = $this->postJson('/api/v1/simulations', ['base' => '40', 'rate' => '10', 'reduction' => '0'], [ 'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token, ])->assertAccepted(); $id = $response->json('data.id'); $tokenHash = hash_hmac('sha256', $token, (string) config('app.key')); DB::unprepared('DO $$ BEGIN CREATE ROLE simulation_rls_test NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$'); DB::statement('GRANT simulation_rls_test TO CURRENT_USER'); DB::statement('GRANT USAGE ON SCHEMA public TO simulation_rls_test'); DB::statement('GRANT SELECT ON public.simulations, public.memberships TO simulation_rls_test'); try { DB::statement('SET ROLE simulation_rls_test'); $execution = app(SimulationExecution::class); $execution->setContext(null, hash_hmac('sha256', 'wrong-token', (string) config('app.key'))); $this->assertSame(0, (int) DB::scalar('select count(*) from public.simulations where id = ?', [$id])); $execution->setContext(null, $tokenHash); $this->assertSame(1, (int) DB::scalar('select count(*) from public.simulations where id = ?', [$id])); } finally { DB::statement('RESET ROLE'); DB::statement('REVOKE simulation_rls_test FROM CURRENT_USER'); DB::statement('REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM simulation_rls_test'); DB::statement('REVOKE USAGE ON SCHEMA public FROM simulation_rls_test'); DB::statement('DROP ROLE IF EXISTS simulation_rls_test'); } } public function test_retry_budget_is_bounded_and_failed_attempt_is_reconciled(): void { $owner = User::factory()->create(['email_verified_at' => now()]); $this->actingAs($owner)->postJson('/api/v1/simulations', ['base' => '10', 'rate' => '1', 'reduction' => '0'], ['Idempotency-Key' => (string) Str::uuid()])->assertAccepted(); $simulation = DB::table('simulations')->where('owner_user_id', $owner->id)->first(); $execution = app(SimulationExecution::class); DB::transaction(function () use ($execution, $owner, $simulation): void { $execution->setContext($owner->id, null); DB::table('simulation_scenarios')->where('simulation_id', $simulation->id)->delete(); }); $job = new RunSimulation($simulation->id, $owner->id, null); try { $job->handle($execution); $this->fail('A simulation without a scenario must fail and remain retryable.'); } catch (\RuntimeException $exception) { $this->assertSame('Simulation has no scenario.', $exception->getMessage()); } $this->assertSame(3, $job->tries); $this->assertSame([10, 60], $job->backoff()); $this->assertDatabaseHas('simulation_jobs', ['simulation_id' => $simulation->id, 'status' => 'running', 'attempts' => 1]); $job->failed(new \RuntimeException('do not persist this detail')); $this->assertDatabaseHas('simulation_jobs', ['simulation_id' => $simulation->id, 'status' => 'failed', 'attempts' => 3]); } 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'); // 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 { $this->getJson('/api/v1/questionnaires/user-entered-arithmetic')->assertOk() ->assertJsonStructure(['data' => ['id', 'code', 'version_no', 'locale', 'schema_snapshot', 'status', 'valid_from', 'valid_to', 'created_at', 'updated_at']]) ->assertHeader('ETag', '"questionnaire-61000100-0000-4000-8000-000000000001-v1"') ->assertJsonPath('data.locale', 'pt-MZ')->assertJsonPath('data.schema_snapshot.rate_origin', 'user_input'); $this->getJson('/api/v1/questionnaires/unknown')->assertNotFound(); } }