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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user