test(api): lock XL Vask Selvvask review capabilities end-to-end (#366)

## 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 <agent@truckwash.io>
This commit is contained in:
Jeppe B
2026-08-12 12:12:20 +02:00
committed by GitHub
co-authored by Hermes Agent
parent 3e683c8559
commit 02a4665bc7
2 changed files with 155 additions and 0 deletions
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
usesApiSuite();
/**
* End-to-end API tests for the XL Vask Selvvask review surface.
*
* The Superuser → Fakturaer → Periode → Selvvask view only shows the
* Accept / Reject / Ignore / Link / Compare buttons when the API returns
* `can_review=true` for the authenticated user, and only succeeds at
* posting the decision when the same user can hit /decisions/preview
* and /decisions/apply. Both of those gating decisions were previously
* locked to `manage_xlvask_usage_automation`, an admin-only permission,
* which silently disabled the buttons for every ordinary operator. This
* test suite locks in the new operator-friendly contract end to end.
*/
it('lights can_review for an operator who only has list_xlvask_usage_orders_own', function (): void {
api_test_covers('GET /modules/xlvask/services/usage/automation/capabilities', 'happy');
api_test_covers('GET /modules/xlvask/services/usage/automation/capabilities', 'review-can-review-operator');
$operator = api_fixtures()->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']);
});
@@ -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',