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); });