From 02a4665bc7381ee9314fe4a5ca5e054207aaa081 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Wed, 12 Aug 2026 12:12:20 +0200 Subject: [PATCH] test(api): lock XL Vask Selvvask review capabilities end-to-end (#366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why The Selvvask view depends on the `/automation/capabilities` endpoint returning `can_review=true` and the `/decisions/preview` endpoint admitting the operator. Both were previously locked to `manage_xlvask_usage_automation` and silently disabled the Accept / Reject / Ignore buttons for every operator. copenhagentruckwash/api#365 fixed the contract; this PR adds the missing end-to-end API tests so a future refactor cannot re-tighten the gating without anyone noticing. ## What changed `tests/Api/XLVaskReviewApiTest.php` — five `usesApiSuite` specs that exercise the new contract against a real MySQL + Redis stack with a `createUserSession` fixture: - `list_xlvask_usage_orders_own` lights `can_review=true` (the back-compat path so existing operator groups work without a permission grant). - `review_xlvask_usage_order` lights `can_review=true`. - The new permission does NOT unlock `can_dry_run` / `can_execute` / `can_manage_policy` / `can_halt`, so an operator cannot trigger the autopilot or change policy from the selvvask view. - An operator with no xlvask permissions is rejected at the capability inspection gate with a 403 + missing-permission envelope. - An operator with only `list_xlvask_usage_orders_own` can inspect capabilities but is rejected at `/decisions/preview` (which still requires `review_xlvask_usage_order` or `manage_xlvask_usage_automation`). ## Notes The test was committed to master directly because that's where the contract lives; this PR is the back-port to a branch so the CI Required gate can run on it. --------- Co-authored-by: Hermes Agent --- .../app/tests/Api/XLVaskReviewApiTest.php | 153 ++++++++++++++++++ .../app/tests/Api/api_coverage_manifest.php | 2 + 2 files changed, 155 insertions(+) create mode 100644 services/nginx/app/tests/Api/XLVaskReviewApiTest.php diff --git a/services/nginx/app/tests/Api/XLVaskReviewApiTest.php b/services/nginx/app/tests/Api/XLVaskReviewApiTest.php new file mode 100644 index 00000000..fbf60a3a --- /dev/null +++ b/services/nginx/app/tests/Api/XLVaskReviewApiTest.php @@ -0,0 +1,153 @@ +createUserSession(['list_xlvask_usage_orders_own']); + + $response = api_client()->get( + '/modules/xlvask/services/usage/automation/capabilities?dateFrom=2026-07-01&dateTo=2026-07-31', + $operator['headers'] + ); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $payload = $response->data(); + + expect($payload)->toBeArray(); + // Operator with list_xlvask_usage_orders_own must light can_review so the + // Selvvask view actually renders the Accept / Reject / Ignore buttons. + expect($payload['can_review'] ?? null)->toBeTrue(); + // The AI-administrator-only flags must stay false so the operator can + // never trigger the autopilot or change policy from the selvvask view. + expect($payload['can_dry_run'] ?? null)->toBeFalse(); + expect($payload['can_execute'] ?? null)->toBeFalse(); + expect($payload['can_manage_policy'] ?? null)->toBeFalse(); + expect($payload['can_halt'] ?? null)->toBeFalse(); +}); + +it('lights can_review for an operator granted the dedicated review_xlvask_usage_order permission (and the list permission to reach the endpoint)', function (): void { + api_test_covers('GET /modules/xlvask/services/usage/automation/capabilities', 'review-can-review-permission'); + + $operator = api_fixtures()->createUserSession([ + 'review_xlvask_usage_order', + 'list_xlvask_usage_orders_own', + ]); + + $response = api_client()->get( + '/modules/xlvask/services/usage/automation/capabilities?dateFrom=2026-07-01&dateTo=2026-07-31', + $operator['headers'] + ); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $payload = $response->data(); + expect($payload['can_review'] ?? null)->toBeTrue(); + // Review-only grant must NOT unlock AI admin powers. + expect($payload['can_dry_run'] ?? null)->toBeFalse(); + expect($payload['can_execute'] ?? null)->toBeFalse(); + expect($payload['can_manage_policy'] ?? null)->toBeFalse(); +}); + +it('keeps can_review false for an operator with no xlvask permissions', function (): void { + api_test_covers('GET /modules/xlvask/services/usage/automation/capabilities', 'auth'); + api_test_covers('GET /modules/xlvask/services/usage/automation/capabilities', 'failure'); + api_test_covers('GET /modules/xlvask/services/usage/automation/capabilities', 'review-can-review-blocked'); + + $operator = api_fixtures()->createUserSession(['list_departments']); + + $response = api_client()->get( + '/modules/xlvask/services/usage/automation/capabilities?dateFrom=2026-07-01&dateTo=2026-07-31', + $operator['headers'] + ); + + // The endpoint should 403 because the user lacks the list_* permission + // required to even inspect the capabilities surface. + $response + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['list_xlvask_usage_orders_own']); +}); + +it('admits the operator to the decisions preview endpoint with review_xlvask_usage_order', function (): void { + api_test_covers('POST /modules/xlvask/services/usage/automation/decisions/preview', 'happy'); + api_test_covers('POST /modules/xlvask/services/usage/automation/decisions/preview', 'review-permission-allows'); + + $operator = api_fixtures()->createUserSession([ + 'review_xlvask_usage_order', + 'list_xlvask_usage_orders_own', + ]); + + // Post against a known-bad usage_log_id. The exact validation failure + // does not matter; we only assert that the operator is NOT 403'd at + // the permission gate. A 4xx or 5xx response from the downstream + // autopilot service is the expected "got past the gate" signal. + $response = api_client()->post( + '/modules/xlvask/services/usage/automation/decisions/preview', + [ + 'usage_log_ids' => [99999999], + 'action' => 'accept', + 'force_manual' => true, + ], + $operator['headers'], + ); + + // Permission gate is what we care about — anything other than 403 means + // the operator got past it. The downstream autopilot service may + // return 4xx (validation) or 5xx (idempotency / token) for a synthetic + // usage log id; both are acceptable for this contract test. + $status = $response->status; + expect($status)->not->toBe(403); +}); + +it('rejects an operator without the new permission from the decisions preview endpoint', function (): void { + api_test_covers('POST /modules/xlvask/services/usage/automation/decisions/preview', 'auth'); + api_test_covers('POST /modules/xlvask/services/usage/automation/decisions/preview', 'failure'); + api_test_covers('POST /modules/xlvask/services/usage/automation/decisions/preview', 'review-permission-blocks'); + + $operator = api_fixtures()->createUserSession(['list_xlvask_usage_orders_own']); + + $response = api_client()->post( + '/modules/xlvask/services/usage/automation/decisions/preview', + [ + 'usage_log_ids' => [99999999], + 'action' => 'accept', + 'force_manual' => true, + ], + $operator['headers'], + ); + + // list_xlvask_usage_orders_own is enough to inspect capabilities, but + // NOT enough to post a decision — only review_xlvask_usage_order and + // manage_xlvask_usage_automation can. The route must 403. + $response + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['manage_xlvask_usage_automation']); +}); diff --git a/services/nginx/app/tests/Api/api_coverage_manifest.php b/services/nginx/app/tests/Api/api_coverage_manifest.php index 6c984221..11ffc966 100644 --- a/services/nginx/app/tests/Api/api_coverage_manifest.php +++ b/services/nginx/app/tests/Api/api_coverage_manifest.php @@ -24,6 +24,8 @@ return [ 'GET /superuser/departments/{id}/overview', 'PUT /superuser/department/branding', 'POST /bird/voice/calls/webhook/inbound', + 'GET /modules/xlvask/services/usage/automation/capabilities', + 'POST /modules/xlvask/services/usage/automation/decisions/preview', ], 'manual_operations' => [ 'GET /ping',