varias alteracoes
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-06 14:50:12 +02:00
parent 4f35313d06
commit e65cb26901
333 changed files with 54656 additions and 2058 deletions
+141 -8
View File
@@ -23,6 +23,56 @@ class SimulationExecutionTest extends TestCase
Queue::fake();
}
public function test_account_history_contains_only_the_verified_users_personal_simulations(): 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' => '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);
@@ -43,8 +93,44 @@ class SimulationExecutionTest extends TestCase
$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();
@@ -54,12 +140,35 @@ class SimulationExecutionTest extends TestCase
$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('simulation_id', $simulationId)->update(['action' => 'audit.tampered']));
$this->fail('Simulation audit events must be append-only.');
DB::transaction(fn () => DB::table('simulation_audit_events')
->where('id', $event->id)
->update(['action' => 'audit.tampered']));
} catch (QueryException) {
$this->assertDatabaseMissing('simulation_audit_events', ['simulation_id' => $simulationId, 'action' => 'audit.tampered']);
// 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
@@ -100,7 +209,12 @@ class SimulationExecutionTest extends TestCase
$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');
$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));
@@ -118,6 +232,7 @@ class SimulationExecutionTest extends TestCase
$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 {
@@ -128,7 +243,9 @@ class SimulationExecutionTest extends TestCase
$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('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();
@@ -166,6 +283,7 @@ class SimulationExecutionTest extends TestCase
$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');
@@ -174,10 +292,21 @@ class SimulationExecutionTest extends TestCase
$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]));
$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('DROP OWNED BY simulation_rls_test');
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();
@@ -194,6 +323,7 @@ class SimulationExecutionTest extends TestCase
$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 {
@@ -205,7 +335,9 @@ class SimulationExecutionTest extends TestCase
$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('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');
}
}
@@ -248,6 +380,7 @@ class SimulationExecutionTest extends TestCase
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();