test: use the delete ability in delete permission tests

next requires the delete ability for delete endpoints and checks member
token access in EnsureTokenBelongsToCurrentTeamMember. Update the tests
from main to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-26 12:55:24 +01:00
co-authored by Claude Opus 5.5
parent 2be4f259b8
commit c467354d34
7 changed files with 43 additions and 43 deletions
@@ -79,43 +79,43 @@ function deleteAbilityHeaders(User $user, int $teamId, array $abilities): array
return ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken];
}
test('write-only token cannot delete a resource', function (string $type) {
test('token without the delete permission cannot delete a resource', function (string $type) {
$resource = $this->resources[$type];
$this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write']))
$this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']))
->deleteJson("/api/v1/{$type}/{$resource->uuid}")
->assertForbidden()
->assertJsonPath('message', 'Missing required permissions: deploy');
->assertJsonPath('message', 'Missing required permissions: delete');
expect($resource::query()->whereKey($resource->getKey())->exists())->toBeTrue();
Queue::assertNotPushed(DeleteResourceJob::class);
})->with(['applications', 'services', 'databases']);
test('write and deploy token deletes a resource', function (string $type) {
test('token with the delete permission deletes a resource', function (string $type) {
$resource = $this->resources[$type];
$this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']))
$this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['delete']))
->deleteJson("/api/v1/{$type}/{$resource->uuid}")
->assertOk();
expect($resource::query()->whereKey($resource->getKey())->exists())->toBeFalse();
})->with(['applications', 'services', 'databases']);
test('write-only token cannot delete a preview deployment', function () {
test('token without the delete permission cannot delete a preview deployment', function () {
$application = $this->resources['applications'];
$this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write']))
$this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']))
->deleteJson("/api/v1/applications/{$application->uuid}/previews/42")
->assertForbidden()
->assertJsonPath('message', 'Missing required permissions: deploy');
->assertJsonPath('message', 'Missing required permissions: delete');
expect($this->preview->fresh()->trashed())->toBeFalse();
});
test('write and deploy token deletes a preview deployment', function () {
test('token with the delete permission deletes a preview deployment', function () {
$application = $this->resources['applications'];
$this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']))
$this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['delete']))
->deleteJson("/api/v1/applications/{$application->uuid}/previews/42")
->assertOk();
@@ -157,11 +157,11 @@ describe('validate with install', function () {
});
describe('delete', function () {
test('write-only token cannot delete with force or delete_from_provider', function (string $query) {
test('token without the deploy permission cannot delete with force or delete_from_provider', function (string $query) {
CloudProviderToken::factory()->create(['team_id' => $this->team->id, 'provider' => 'hetzner']);
$this->server->update(['hetzner_server_id' => 123]);
$this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write']))
$this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write', 'delete']))
->deleteJson("/api/v1/servers/{$this->server->uuid}?{$query}")
->assertForbidden()
->assertJsonPath('message', 'Missing required permissions: deploy. Deleting the resources or the cloud server needs a token with the deploy permission.');
@@ -169,16 +169,16 @@ describe('delete', function () {
expect(Server::query()->whereKey($this->server->id)->exists())->toBeTrue();
})->with(['force=true', 'delete_from_provider=true']);
test('write-only token can delete an empty server', function () {
$this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write']))
test('delete token can delete an empty server', function () {
$this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['delete']))
->deleteJson("/api/v1/servers/{$this->server->uuid}")
->assertOk();
expect(Server::query()->whereKey($this->server->id)->exists())->toBeFalse();
});
test('write and deploy token can delete with force', function () {
$this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']))
test('delete and deploy token can delete with force', function () {
$this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['delete', 'deploy']))
->deleteJson("/api/v1/servers/{$this->server->uuid}?force=true")
->assertOk();
@@ -307,13 +307,13 @@ test('manage_application updates, clones and moves an application of the token t
->and($this->application->fresh()->environment_id)->toBe($target->id);
});
test('manage_application deletes an application only with confirm=true and a deploy token', function () {
$writeOnly = mcpAppCall(mcpAppToken($this->user), 'manage_application', ['action' => 'delete', 'uuid' => $this->application->uuid, 'confirm' => true]);
test('manage_application deletes an application only with confirm=true and a delete token', function () {
$writeOnly = mcpAppCall(mcpAppToken($this->user, ['write']), 'manage_application', ['action' => 'delete', 'uuid' => $this->application->uuid, 'confirm' => true]);
expect(mcpAppIsError($writeOnly))->toBeTrue()
->and(mcpAppText($writeOnly))->toContain('Missing required permissions: deploy')
->and(mcpAppText($writeOnly))->toContain('Missing required permissions: delete')
->and(Application::find($this->application->id))->not->toBeNull();
$token = mcpAppToken($this->user, ['write', 'deploy']);
$token = mcpAppToken($this->user, ['write', 'delete']);
$denied = mcpAppCall($token, 'manage_application', ['action' => 'delete', 'uuid' => $this->application->uuid]);
expect(mcpAppIsError($denied))->toBeTrue()
@@ -411,7 +411,7 @@ test('manage_application rejects read-only and member tokens', function () {
// manage_preview and get_application_preview
test('manage_preview creates, updates and deletes a preview of the token team', function () {
$writeToken = mcpAppToken($this->user, ['write', 'delete']);
$writeToken = mcpAppToken($this->user, ['write']);
$deployToken = mcpAppToken($this->user, ['deploy']);
$writeOnly = mcpAppCall($writeToken, 'manage_preview', ['action' => 'create', 'uuid' => $this->application->uuid, 'data' => ['pull_request_id' => 7, 'git_type' => 'github', 'instant_deploy' => false]]);
@@ -428,14 +428,14 @@ test('manage_preview creates, updates and deletes a preview of the token team',
$writeOnlyDelete = mcpAppCall($writeToken, 'manage_preview', ['action' => 'delete', 'uuid' => $this->application->uuid, 'pull_request_id' => '7', 'confirm' => true]);
expect(mcpAppIsError($writeOnlyDelete))->toBeTrue()
->and(mcpAppText($writeOnlyDelete))->toContain('Missing required permissions: deploy');
->and(mcpAppText($writeOnlyDelete))->toContain('Missing required permissions: delete');
$writeDeployToken = mcpAppToken($this->user, ['write', 'deploy']);
$denied = mcpAppCall($writeDeployToken, 'manage_preview', ['action' => 'delete', 'uuid' => $this->application->uuid, 'pull_request_id' => '7']);
$deleteToken = mcpAppToken($this->user, ['delete']);
$denied = mcpAppCall($deleteToken, 'manage_preview', ['action' => 'delete', 'uuid' => $this->application->uuid, 'pull_request_id' => '7']);
expect(mcpAppIsError($denied))->toBeTrue()
->and(mcpAppText($denied))->toContain('confirm=true');
$deleted = mcpAppCall($writeDeployToken, 'manage_preview', ['action' => 'delete', 'uuid' => $this->application->uuid, 'pull_request_id' => '7', 'confirm' => true]);
$deleted = mcpAppCall($deleteToken, 'manage_preview', ['action' => 'delete', 'uuid' => $this->application->uuid, 'pull_request_id' => '7', 'confirm' => true]);
expect(mcpAppIsError($deleted))->toBeFalse(mcpAppText($deleted))
->and(ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', 7)->exists())->toBeFalse();
});
+8 -8
View File
@@ -308,7 +308,7 @@ test('manage_database clones to an own destination and moves to an own environme
test('manage_database destructive actions need confirm=true', function (string $action, array $data) {
$own = $this->mine['database'];
$denied = mcpDataCall(mcpDataToken($this->user, ['write', 'deploy']), 'manage_database', ['action' => $action, 'uuid' => $own->uuid, 'data' => $data]);
$denied = mcpDataCall(mcpDataToken($this->user, ['write', 'deploy', 'delete']), 'manage_database', ['action' => $action, 'uuid' => $own->uuid, 'data' => $data]);
expect(mcpDataIsError($denied))->toBeTrue()
->and(mcpDataText($denied))->toContain('confirm=true')
@@ -324,12 +324,12 @@ test('manage_database destructive actions need confirm=true', function (string $
test('manage_database delete with confirm=true deletes an own database', function () {
$own = $this->mine['database'];
$writeOnly = mcpDataCall(mcpDataToken($this->user), 'manage_database', ['action' => 'delete', 'uuid' => $own->uuid, 'confirm' => true]);
$writeOnly = mcpDataCall(mcpDataToken($this->user, ['write']), 'manage_database', ['action' => 'delete', 'uuid' => $own->uuid, 'confirm' => true]);
expect(mcpDataIsError($writeOnly))->toBeTrue()
->and(mcpDataText($writeOnly))->toContain('Missing required permissions: deploy')
->and(mcpDataText($writeOnly))->toContain('Missing required permissions: delete')
->and(StandalonePostgresql::find($own->id))->not->toBeNull();
$response = mcpDataCall(mcpDataToken($this->user, ['write', 'deploy']), 'manage_database', ['action' => 'delete', 'uuid' => $own->uuid, 'confirm' => true]);
$response = mcpDataCall(mcpDataToken($this->user, ['delete']), 'manage_database', ['action' => 'delete', 'uuid' => $own->uuid, 'confirm' => true]);
expect(mcpDataIsError($response))->toBeFalse()
->and(StandalonePostgresql::find($own->id))->toBeNull();
@@ -565,7 +565,7 @@ test('manage_service rejects a destination, environment, or S3 storage of anothe
test('manage_service destructive actions need confirm=true', function (string $action) {
$own = $this->mine;
$denied = mcpDataCall(mcpDataToken($this->user, ['write', 'deploy']), 'manage_service', ['action' => $action, 'uuid' => $own['service']->uuid, 'database_uuid' => $own['service_db']->uuid, 'data' => ['destination_uuid' => 'x']]);
$denied = mcpDataCall(mcpDataToken($this->user, ['write', 'deploy', 'delete']), 'manage_service', ['action' => $action, 'uuid' => $own['service']->uuid, 'database_uuid' => $own['service_db']->uuid, 'data' => ['destination_uuid' => 'x']]);
expect(mcpDataIsError($denied))->toBeTrue()
->and(mcpDataText($denied))->toContain('confirm=true')
@@ -573,12 +573,12 @@ test('manage_service destructive actions need confirm=true', function (string $a
})->with(['delete', 'clone', 'move', 'import_database']);
test('manage_service delete with confirm=true deletes an own service', function () {
$writeOnly = mcpDataCall(mcpDataToken($this->user), 'manage_service', ['action' => 'delete', 'uuid' => $this->mine['service']->uuid, 'confirm' => true]);
$writeOnly = mcpDataCall(mcpDataToken($this->user, ['write']), 'manage_service', ['action' => 'delete', 'uuid' => $this->mine['service']->uuid, 'confirm' => true]);
expect(mcpDataIsError($writeOnly))->toBeTrue()
->and(mcpDataText($writeOnly))->toContain('Missing required permissions: deploy')
->and(mcpDataText($writeOnly))->toContain('Missing required permissions: delete')
->and(Service::find($this->mine['service']->id))->not->toBeNull();
$response = mcpDataCall(mcpDataToken($this->user, ['write', 'deploy']), 'manage_service', ['action' => 'delete', 'uuid' => $this->mine['service']->uuid, 'confirm' => true]);
$response = mcpDataCall(mcpDataToken($this->user, ['delete']), 'manage_service', ['action' => 'delete', 'uuid' => $this->mine['service']->uuid, 'confirm' => true]);
expect(mcpDataIsError($response))->toBeFalse()
->and(Service::find($this->mine['service']->id))->toBeNull();
+5 -5
View File
@@ -268,7 +268,7 @@ test('deploy by tag rejects read-only and member tokens', function () {
// ---------------------------------------------------------------- manage_scheduled_task
test('manage_scheduled_task creates, updates, executes, and deletes tasks of the token team', function (string $resource, string $key) {
$token = mcpOpsToken($this->user, ['read', 'write', 'deploy']);
$token = mcpOpsToken($this->user, ['read', 'write', 'deploy', 'delete']);
$owner = $this->own[$key];
$created = mcpOpsCall($token, 'manage_scheduled_task', ['action' => 'create', 'resource' => $resource, 'uuid' => $owner->uuid, 'data' => ['name' => 'cleanup', 'command' => 'echo SECRETCMD', 'frequency' => 'daily']]);
@@ -318,7 +318,7 @@ test('manage_scheduled_task cannot reach tasks or resources of another team', fu
test('manage_scheduled_task needs confirm for delete and execute and rejects read-only tokens', function () {
$task = ScheduledTask::factory()->create(['team_id' => $this->team->id, 'application_id' => $this->own['application']->id]);
$token = mcpOpsToken($this->user, ['read', 'write', 'deploy']);
$token = mcpOpsToken($this->user, ['read', 'write', 'deploy', 'delete']);
foreach (['delete', 'execute'] as $action) {
$response = mcpOpsCall($token, 'manage_scheduled_task', ['action' => $action, 'resource' => 'application', 'uuid' => $this->own['application']->uuid, 'task_uuid' => $task->uuid]);
@@ -339,7 +339,7 @@ test('manage_scheduled_task needs confirm for delete and execute and rejects rea
// ---------------------------------------------------------------- manage_backup
test('manage_backup creates, updates, runs, and deletes a backup schedule of the token team', function () {
$token = mcpOpsToken($this->user, ['read', 'write', 'deploy']);
$token = mcpOpsToken($this->user, ['read', 'write', 'deploy', 'delete']);
$database = $this->own['database'];
$created = mcpOpsCall($token, 'manage_backup', ['action' => 'create', 'uuid' => $database->uuid, 'data' => ['frequency' => 'daily', 'save_s3' => true, 's3_storage_uuid' => $this->own['s3']->uuid]]);
@@ -452,7 +452,7 @@ test('manage_storage creates, updates, and deletes storages of the token team',
});
test('manage_storage sets, runs, and removes a volume backup without returning S3 secrets', function () {
$token = mcpOpsToken($this->user, ['read', 'write', 'deploy']);
$token = mcpOpsToken($this->user, ['read', 'write', 'deploy', 'delete']);
$args = ['resource' => 'application', 'uuid' => $this->own['application']->uuid, 'storage_uuid' => $this->own['volume']->uuid];
$set = mcpOpsCall($token, 'manage_storage', [...$args, 'action' => 'set_backup', 'data' => ['frequency' => 'daily', 'save_s3' => true, 's3_storage_uuid' => $this->own['s3']->uuid]]);
@@ -517,7 +517,7 @@ test('manage_storage cannot reach storages of another team', function (string $a
test('manage_storage needs confirm for delete, remove_backup, and run_backup and rejects read-only tokens', function () {
$this->own['volume']->scheduledBackups()->create(['team_id' => $this->team->id, 'frequency' => 'daily']);
$args = ['resource' => 'application', 'uuid' => $this->own['application']->uuid, 'storage_uuid' => $this->own['volume']->uuid];
$token = mcpOpsToken($this->user, ['read', 'write', 'deploy']);
$token = mcpOpsToken($this->user, ['read', 'write', 'deploy', 'delete']);
foreach (['delete', 'remove_backup', 'run_backup'] as $action) {
$response = mcpOpsCall($token, 'manage_storage', [...$args, 'action' => $action]);
+1 -1
View File
@@ -215,7 +215,7 @@ test('server group write actions reject member tokens', function (string $tool,
]);
test('server group destructive actions need confirm=true', function (string $tool, array $arguments) {
$response = mcpSrvCall(mcpSrvToken($this->user, ['read', 'write', 'deploy']), $tool, $arguments);
$response = mcpSrvCall(mcpSrvToken($this->user, ['read', 'write', 'deploy', 'delete']), $tool, $arguments);
expect(mcpSrvIsError($response))->toBeTrue()
->and(mcpSrvText($response))->toContain('requires confirm=true');
@@ -1,6 +1,6 @@
<?php
use App\Http\Middleware\ApiAbility;
use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember;
use App\Livewire\Security\ApiTokens;
use App\Models\InstanceSettings;
use App\Models\Team;
@@ -157,7 +157,7 @@ describe('Livewire ApiTokens — member cannot create elevated tokens', function
});
});
describe('ApiAbility middleware — member with elevated token blocked', function () {
describe('API middleware — member with elevated token blocked', function () {
test('existing member wildcard token is blocked', function () {
session(['currentTeam' => $this->team]);
$token = $this->member->createToken('legacy-wildcard-token', ['*']);
@@ -203,7 +203,7 @@ describe('ApiAbility middleware — member with elevated token blocked', functio
->assertStatus(403);
});
test('ApiAbility evaluates member token abilities through the token', function () {
test('the token team middleware evaluates member token abilities through the token', function () {
session(['currentTeam' => $this->team]);
$token = $this->member->createToken('token', ['*']);
$this->member->withAccessToken($token->accessToken);
@@ -211,7 +211,7 @@ describe('ApiAbility middleware — member with elevated token blocked', functio
$request = Request::create('/api/v1/projects');
$request->setUserResolver(fn () => $this->member);
$response = (new ApiAbility)->handle($request, fn () => response('ok'), 'read');
$response = (new EnsureTokenBelongsToCurrentTeamMember)->handle($request, fn () => response('ok'));
expect($response->getStatusCode())->toBe(403);
});