256 lines
16 KiB
PHP
256 lines
16 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Jobs\RunSimulation;
|
|
use App\Models\User;
|
|
use App\Services\SimulationExecution;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Str;
|
|
use Tests\TestCase;
|
|
|
|
class SimulationExecutionTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
Queue::fake();
|
|
}
|
|
|
|
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);
|
|
$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();
|
|
}
|
|
}
|