From c467354d34a95e72f7392dc3dd3bc646852bb6f4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:55:24 +0100 Subject: [PATCH] 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 --- .../Api/DeleteResourceDeployAbilityTest.php | 20 +++++++++---------- tests/Feature/Api/ServerDeployAbilityTest.php | 12 +++++------ tests/Feature/Mcp/McpApplicationToolsTest.php | 18 ++++++++--------- tests/Feature/Mcp/McpDataToolsTest.php | 16 +++++++-------- tests/Feature/Mcp/McpOperationsToolsTest.php | 10 +++++----- tests/Feature/Mcp/McpServerToolsTest.php | 2 +- .../Security/ApiTokenCreationSecurityTest.php | 8 ++++---- 7 files changed, 43 insertions(+), 43 deletions(-) diff --git a/tests/Feature/Api/DeleteResourceDeployAbilityTest.php b/tests/Feature/Api/DeleteResourceDeployAbilityTest.php index b6f6bb4c52..c20f000afa 100644 --- a/tests/Feature/Api/DeleteResourceDeployAbilityTest.php +++ b/tests/Feature/Api/DeleteResourceDeployAbilityTest.php @@ -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(); diff --git a/tests/Feature/Api/ServerDeployAbilityTest.php b/tests/Feature/Api/ServerDeployAbilityTest.php index 041796f5f6..d73605003f 100644 --- a/tests/Feature/Api/ServerDeployAbilityTest.php +++ b/tests/Feature/Api/ServerDeployAbilityTest.php @@ -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(); diff --git a/tests/Feature/Mcp/McpApplicationToolsTest.php b/tests/Feature/Mcp/McpApplicationToolsTest.php index 031cf39f9c..a68439172c 100644 --- a/tests/Feature/Mcp/McpApplicationToolsTest.php +++ b/tests/Feature/Mcp/McpApplicationToolsTest.php @@ -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(); }); diff --git a/tests/Feature/Mcp/McpDataToolsTest.php b/tests/Feature/Mcp/McpDataToolsTest.php index 3a584344c4..fd8736e85a 100644 --- a/tests/Feature/Mcp/McpDataToolsTest.php +++ b/tests/Feature/Mcp/McpDataToolsTest.php @@ -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(); diff --git a/tests/Feature/Mcp/McpOperationsToolsTest.php b/tests/Feature/Mcp/McpOperationsToolsTest.php index e3c0a9a3e3..604f8c8003 100644 --- a/tests/Feature/Mcp/McpOperationsToolsTest.php +++ b/tests/Feature/Mcp/McpOperationsToolsTest.php @@ -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]); diff --git a/tests/Feature/Mcp/McpServerToolsTest.php b/tests/Feature/Mcp/McpServerToolsTest.php index c0182bbaf6..ee791bc3b6 100644 --- a/tests/Feature/Mcp/McpServerToolsTest.php +++ b/tests/Feature/Mcp/McpServerToolsTest.php @@ -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'); diff --git a/tests/Feature/Security/ApiTokenCreationSecurityTest.php b/tests/Feature/Security/ApiTokenCreationSecurityTest.php index fa7e4434b2..67af723a7e 100644 --- a/tests/Feature/Security/ApiTokenCreationSecurityTest.php +++ b/tests/Feature/Security/ApiTokenCreationSecurityTest.php @@ -1,6 +1,6 @@ $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); });