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); $this->assertDatabaseHas('simulation_result_sources', ['source_type' => 'user_input']); $this->assertSame(1, DB::table('simulation_reports')->where('simulation_id', $simulationId)->where('status', 'ready')->count()); $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); try { DB::transaction(fn () => DB::table('simulation_audit_events')->where('simulation_id', $simulationId)->update(['action' => 'audit.tampered'])); $this->fail('Simulation audit events must be append-only.'); } catch (QueryException) { $this->assertDatabaseMissing('simulation_audit_events', ['simulation_id' => $simulationId, 'action' => 'audit.tampered']); } } 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'); $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 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('DROP OWNED BY 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 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])); $this->assertSame(0, DB::affectingStatement('update public.simulations set version_no = version_no + 1 where id = ?', [$id])); } finally { DB::statement('RESET ROLE'); DB::statement('DROP OWNED BY 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 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('DROP OWNED BY 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_empty_schema_rolls_back_and_reapplies_with_questionnaire_seed(): void { $migration = require database_path('migrations/2026_10_01_000200_create_simulation_execution_core.php'); $migration->down(); $this->assertFalse(Schema::hasTable('simulations')); $migration->up(); $this->assertTrue(Schema::hasTable('simulation_results')); $this->assertDatabaseHas('questionnaire_versions', ['id' => '61000100-0000-4000-8000-000000000001', 'status' => 'published']); } public function test_questionnaire_endpoint_exposes_only_published_version_with_etag(): void { $this->getJson('/api/v1/questionnaires/user-entered-arithmetic')->assertOk() ->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(); } }