mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
482 lines
23 KiB
PHP
482 lines
23 KiB
PHP
<?php
|
|
|
|
use App\Jobs\ApplicationDeploymentJob;
|
|
use App\Livewire\Forms\ApiTokenForm;
|
|
use App\Livewire\Security\ApiTokens;
|
|
use App\Models\Application;
|
|
use App\Models\ApplicationDeploymentQueue;
|
|
use App\Models\Environment;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\PersonalAccessToken;
|
|
use App\Models\Project;
|
|
use App\Models\Scopes\VisibleThroughAccessGrants;
|
|
use App\Models\Server;
|
|
use App\Models\SharedEnvironmentVariable;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Once;
|
|
use Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
Server::flushIdentityMap();
|
|
Queue::fake();
|
|
config()->set('app.maintenance.store', 'array');
|
|
config()->set('cache.default', 'array');
|
|
|
|
InstanceSettings::query()->delete();
|
|
$settings = new InstanceSettings(['is_api_enabled' => true, 'is_mcp_server_enabled' => true]);
|
|
$settings->id = 0;
|
|
$settings->save();
|
|
Once::flush();
|
|
|
|
$this->team = Team::factory()->create(['is_mcp_server_enabled' => true]);
|
|
$this->admin = User::factory()->create();
|
|
$this->team->members()->attach($this->admin->id, ['role' => 'admin']);
|
|
|
|
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
|
$this->destination = $this->server->standaloneDockers()->firstOrFail();
|
|
|
|
$this->shop = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'shop']);
|
|
$this->shopProduction = Environment::factory()->create(['project_id' => $this->shop->id, 'name' => 'prod']);
|
|
$this->blog = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'blog']);
|
|
$this->blogStaging = Environment::factory()->create(['project_id' => $this->blog->id, 'name' => 'staging']);
|
|
|
|
$this->shopApi = scopedTokenTestApplication($this->shopProduction, $this->destination, 'shop-api');
|
|
$this->shopWorker = scopedTokenTestApplication($this->shopProduction, $this->destination, 'shop-worker');
|
|
$this->blogWeb = scopedTokenTestApplication($this->blogStaging, $this->destination, 'blog-web');
|
|
});
|
|
|
|
afterEach(function () {
|
|
Server::flushIdentityMap();
|
|
});
|
|
|
|
function scopedTokenTestApplication(Environment $environment, $destination, string $name): Application
|
|
{
|
|
return Application::factory()->create([
|
|
'name' => $name,
|
|
'environment_id' => $environment->id,
|
|
'destination_id' => $destination->id,
|
|
'destination_type' => $destination->getMorphClass(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param list<array{scope: string, uuid: string}>|null $scopes
|
|
*/
|
|
function scopedTokenTestToken(User $user, Team $team, array $abilities, ?array $scopes): string
|
|
{
|
|
$token = $user->createToken('scoped-test', $abilities, null, $team->id);
|
|
$token->accessToken->forceFill(['scopes' => $scopes])->save();
|
|
|
|
return $token->plainTextToken;
|
|
}
|
|
|
|
/**
|
|
* Fill the edit form the way the browser does when the user clicks Edit.
|
|
*/
|
|
function scopedTokenTestEdit($component, PersonalAccessToken $token)
|
|
{
|
|
$values = collect(ApiTokenForm::valuesOf($token))->except(['id', 'keepLabel']);
|
|
|
|
return $component->set($values->mapWithKeys(fn ($value, $field) => ["editForm.{$field}" => $value])->all());
|
|
}
|
|
|
|
function scopedTokenTestMember(Team $team, ?array $access): User
|
|
{
|
|
$member = User::factory()->create();
|
|
$team->members()->attach($member->id, ['role' => 'member', 'access' => $access === null ? null : json_encode($access)]);
|
|
|
|
return $member;
|
|
}
|
|
|
|
function scopedTokenTestApi(string $token, string $method, string $uri, array $data = [])
|
|
{
|
|
app('auth')->forgetGuards();
|
|
|
|
return test()->withHeaders(['Authorization' => 'Bearer '.$token])->json($method, $uri, $data);
|
|
}
|
|
|
|
function scopedTokenTestMcp(string $token, string $method, array $params = [])
|
|
{
|
|
app('auth')->forgetGuards();
|
|
|
|
return test()->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
'Accept' => 'application/json, text/event-stream',
|
|
])->postJson('/mcp', ['jsonrpc' => '2.0', 'id' => 1, 'method' => $method, 'params' => (object) $params]);
|
|
}
|
|
|
|
describe('admin token scoped to one project', function () {
|
|
beforeEach(function () {
|
|
$this->token = scopedTokenTestToken($this->admin, $this->team, ['read', 'write'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]);
|
|
});
|
|
|
|
test('lists only the applications and resources of the project', function () {
|
|
expect(scopedTokenTestApi($this->token, 'GET', '/api/v1/applications')->assertOk()->json('*.name'))
|
|
->toEqualCanonicalizing(['shop-api', 'shop-worker'])
|
|
->and(scopedTokenTestApi($this->token, 'GET', '/api/v1/resources')->assertOk()->json('*.name'))
|
|
->toEqualCanonicalizing(['shop-api', 'shop-worker'])
|
|
->and(scopedTokenTestApi($this->token, 'GET', '/api/v1/projects')->assertOk()->json('*.name'))
|
|
->toBe(['shop']);
|
|
});
|
|
|
|
test('does not find an application in another project', function () {
|
|
scopedTokenTestApi($this->token, 'GET', "/api/v1/applications/{$this->blogWeb->uuid}")->assertNotFound();
|
|
scopedTokenTestApi($this->token, 'GET', "/api/v1/projects/{$this->blog->uuid}")->assertNotFound();
|
|
});
|
|
|
|
test('cannot use team-level endpoints or create projects', function () {
|
|
scopedTokenTestApi($this->token, 'GET', '/api/v1/servers')
|
|
->assertForbidden()
|
|
->assertExactJson(['message' => 'This API token is limited to selected projects, environments, or resources, so it cannot use this endpoint.']);
|
|
scopedTokenTestApi($this->token, 'GET', '/api/v1/security/keys')->assertForbidden();
|
|
scopedTokenTestApi($this->token, 'GET', '/api/v1/team')->assertForbidden();
|
|
scopedTokenTestApi($this->token, 'POST', '/api/v1/projects', ['name' => 'new'])->assertForbidden();
|
|
|
|
expect(Project::query()->where('name', 'new')->exists())->toBeFalse();
|
|
});
|
|
|
|
test('lists only open deployments of the project', function () {
|
|
foreach ([$this->shopApi, $this->blogWeb] as $application) {
|
|
ApplicationDeploymentQueue::create([
|
|
'application_id' => (string) $application->id,
|
|
'deployment_uuid' => 'deploy-'.$application->name,
|
|
'server_id' => $this->server->id,
|
|
'status' => 'queued',
|
|
]);
|
|
}
|
|
|
|
expect(scopedTokenTestApi($this->token, 'GET', '/api/v1/deployments')->assertOk()->json('*.deployment_uuid'))
|
|
->toBe(['deploy-shop-api']);
|
|
scopedTokenTestApi($this->token, 'GET', '/api/v1/deployments/deploy-blog-web')->assertNotFound();
|
|
});
|
|
|
|
test('can create an application in the project', function () {
|
|
scopedTokenTestApi($this->token, 'POST', '/api/v1/applications/dockerimage', [
|
|
'project_uuid' => $this->shop->uuid,
|
|
'environment_uuid' => $this->shopProduction->uuid,
|
|
'server_uuid' => $this->server->uuid,
|
|
'docker_registry_image_name' => 'nginx',
|
|
'ports_exposes' => '80',
|
|
])->assertCreated();
|
|
|
|
expect($this->shopProduction->applications()->count())->toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('token scoped to one resource', function () {
|
|
test('deploys the resource with the deploy ability', function () {
|
|
$token = scopedTokenTestToken($this->admin, $this->team, ['deploy'], [['scope' => 'resource', 'uuid' => $this->shopApi->uuid]]);
|
|
|
|
scopedTokenTestApi($token, 'POST', "/api/v1/deploy?uuid={$this->shopApi->uuid}")
|
|
->assertOk()
|
|
->assertJsonPath('deployments.0.resource_uuid', $this->shopApi->uuid);
|
|
scopedTokenTestApi($token, 'POST', "/api/v1/deploy?uuid={$this->shopWorker->uuid}")->assertNotFound();
|
|
|
|
expect(ApplicationDeploymentQueue::query()->pluck('application_id')->all())->toBe([(string) $this->shopApi->id]);
|
|
Queue::assertPushed(ApplicationDeploymentJob::class);
|
|
});
|
|
|
|
test('cannot create an application in the environment of the resource', function () {
|
|
$token = scopedTokenTestToken($this->admin, $this->team, ['read', 'write'], [['scope' => 'resource', 'uuid' => $this->shopApi->uuid]]);
|
|
|
|
scopedTokenTestApi($token, 'POST', '/api/v1/applications/dockerimage', [
|
|
'project_uuid' => $this->shop->uuid,
|
|
'environment_uuid' => $this->shopProduction->uuid,
|
|
'server_uuid' => $this->server->uuid,
|
|
'docker_registry_image_name' => 'nginx',
|
|
'ports_exposes' => '80',
|
|
])->assertForbidden();
|
|
|
|
expect(Application::withoutGlobalScope(VisibleThroughAccessGrants::class)->where('environment_id', $this->shopProduction->id)->count())->toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('delete ability', function () {
|
|
test('a write token without delete cannot delete an application', function () {
|
|
$token = scopedTokenTestToken($this->admin, $this->team, ['read', 'write'], null);
|
|
|
|
scopedTokenTestApi($token, 'DELETE', "/api/v1/applications/{$this->shopApi->uuid}")
|
|
->assertForbidden()
|
|
->assertExactJson(['message' => 'Missing required permissions: delete']);
|
|
|
|
expect(Application::query()->whereKey($this->shopApi->id)->exists())->toBeTrue();
|
|
});
|
|
|
|
test('a token with delete can delete an application', function () {
|
|
$token = scopedTokenTestToken($this->admin, $this->team, ['read', 'write', 'delete'], null);
|
|
|
|
scopedTokenTestApi($token, 'DELETE', "/api/v1/applications/{$this->shopApi->uuid}")->assertOk();
|
|
|
|
expect(Application::query()->whereKey($this->shopApi->id)->exists())->toBeFalse();
|
|
});
|
|
});
|
|
|
|
describe('member tokens', function () {
|
|
test('a legacy member can use a read token but not a write token', function () {
|
|
$member = scopedTokenTestMember($this->team, null);
|
|
|
|
expect(scopedTokenTestApi(scopedTokenTestToken($member, $this->team, ['read'], null), 'GET', '/api/v1/applications')->assertOk()->json('*.name'))
|
|
->toEqualCanonicalizing(['shop-api', 'shop-worker', 'blog-web']);
|
|
scopedTokenTestApi(scopedTokenTestToken($member, $this->team, ['read', 'write'], null), 'GET', '/api/v1/applications')
|
|
->assertForbidden()
|
|
->assertExactJson(['message' => 'This API token has more access than you have in this team. Revoke it and create a new token.']);
|
|
});
|
|
|
|
test('a member with a project grant can use a write token limited to that project', function () {
|
|
$member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['write', 'deploy']]]);
|
|
$token = scopedTokenTestToken($member, $this->team, ['read', 'write'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]);
|
|
|
|
scopedTokenTestApi($token, 'PATCH', "/api/v1/applications/{$this->shopApi->uuid}", ['name' => 'renamed'])->assertOk();
|
|
scopedTokenTestApi($token, 'PATCH', "/api/v1/applications/{$this->blogWeb->uuid}", ['name' => 'renamed'])->assertNotFound();
|
|
|
|
expect($this->shopApi->fresh()->name)->toBe('renamed')
|
|
->and($this->blogWeb->fresh()->name)->toBe('blog-web');
|
|
});
|
|
|
|
test('a member with a project grant cannot use a team-wide write token', function () {
|
|
$member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['write', 'deploy']]]);
|
|
|
|
scopedTokenTestApi(scopedTokenTestToken($member, $this->team, ['read', 'write'], null), 'GET', '/api/v1/applications')->assertForbidden();
|
|
scopedTokenTestApi(scopedTokenTestToken($member, $this->team, ['delete'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]), 'GET', '/api/v1/applications')->assertForbidden();
|
|
});
|
|
|
|
test('a member with a write grant can create project and environment shared variables', function () {
|
|
$member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['write']]]);
|
|
$token = scopedTokenTestToken($member, $this->team, ['read', 'write'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]);
|
|
|
|
scopedTokenTestApi($token, 'POST', "/api/v1/projects/{$this->shop->uuid}/envs", ['key' => 'PROJECT_KEY', 'value' => 'v'])->assertCreated();
|
|
scopedTokenTestApi($token, 'POST', "/api/v1/projects/{$this->shop->uuid}/environments/{$this->shopProduction->uuid}/envs", ['key' => 'ENVIRONMENT_KEY', 'value' => 'v'])->assertCreated();
|
|
scopedTokenTestApi($token, 'POST', "/api/v1/projects/{$this->blog->uuid}/envs", ['key' => 'OTHER_KEY', 'value' => 'v'])->assertNotFound();
|
|
|
|
expect(SharedEnvironmentVariable::query()->whereIn('key', ['PROJECT_KEY', 'ENVIRONMENT_KEY', 'OTHER_KEY'])->pluck('key')->all())->toEqualCanonicalizing(['PROJECT_KEY', 'ENVIRONMENT_KEY']);
|
|
});
|
|
|
|
test('a member with a read grant cannot create project or environment shared variables', function () {
|
|
$member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']]]);
|
|
$token = scopedTokenTestToken($member, $this->team, ['read'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]);
|
|
$writeToken = scopedTokenTestToken($member, $this->team, ['read', 'write'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]);
|
|
|
|
foreach ([$token, $writeToken] as $candidate) {
|
|
scopedTokenTestApi($candidate, 'POST', "/api/v1/projects/{$this->shop->uuid}/envs", ['key' => 'PROJECT_KEY', 'value' => 'v'])->assertForbidden();
|
|
scopedTokenTestApi($candidate, 'POST', "/api/v1/projects/{$this->shop->uuid}/environments/{$this->shopProduction->uuid}/envs", ['key' => 'ENVIRONMENT_KEY', 'value' => 'v'])->assertForbidden();
|
|
}
|
|
|
|
expect(SharedEnvironmentVariable::query()->whereIn('key', ['PROJECT_KEY', 'ENVIRONMENT_KEY'])->exists())->toBeFalse();
|
|
});
|
|
});
|
|
|
|
describe('mcp', function () {
|
|
test('a scoped token lists only scoped applications and does not get team-level tools', function () {
|
|
$token = scopedTokenTestToken($this->admin, $this->team, ['read'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]);
|
|
|
|
$listed = json_decode((string) scopedTokenTestMcp($token, 'tools/call', ['name' => 'list_applications', 'arguments' => (object) []])->json('result.content.0.text'), true);
|
|
$tools = scopedTokenTestMcp($token, 'tools/list')->json('result.tools.*.name');
|
|
|
|
expect(collect($listed['data'])->pluck('name')->all())->toEqualCanonicalizing(['shop-api', 'shop-worker'])
|
|
->and($tools)->toContain('list_applications')
|
|
->and($tools)->not->toContain('list_servers')
|
|
->and($tools)->not->toContain('manage_private_key');
|
|
expect(scopedTokenTestMcp($token, 'tools/call', ['name' => 'list_servers', 'arguments' => (object) []])->json('error'))->not->toBeNull();
|
|
});
|
|
|
|
test('a team-wide token still gets team-level tools', function () {
|
|
$tools = scopedTokenTestMcp(scopedTokenTestToken($this->admin, $this->team, ['read'], null), 'tools/list')->json('result.tools.*.name');
|
|
|
|
expect($tools)->toContain('list_servers');
|
|
});
|
|
});
|
|
|
|
describe('token form', function () {
|
|
beforeEach(function () {
|
|
session(['currentTeam' => $this->team]);
|
|
});
|
|
|
|
test('creates a token limited to the selected project', function () {
|
|
$this->actingAs($this->admin);
|
|
|
|
Livewire::test(ApiTokens::class)
|
|
->set('form.description', 'shop deploys')
|
|
->set('form.permissions', ['deploy'])
|
|
->set('form.scopeMode', 'selected')
|
|
->set('form.selectedScopes', ["project:{$this->shop->uuid}"])
|
|
->call('addNewToken')
|
|
->assertHasNoErrors()
|
|
->assertNotDispatched('error');
|
|
|
|
$token = PersonalAccessToken::query()->sole();
|
|
expect($token->abilities)->toBe(['deploy'])
|
|
->and($token->scopes)->toBe([['scope' => 'project', 'uuid' => $this->shop->uuid]])
|
|
->and((int) $token->team_id)->toBe($this->team->id);
|
|
});
|
|
|
|
test('rejects a scope from another team', function () {
|
|
$otherTeam = Team::factory()->create();
|
|
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
|
|
$this->actingAs($this->admin);
|
|
|
|
Livewire::test(ApiTokens::class)
|
|
->set('form.description', 'foreign')
|
|
->set('form.permissions', ['read'])
|
|
->set('form.scopeMode', 'selected')
|
|
->set('form.selectedScopes', ["project:{$otherProject->uuid}"])
|
|
->call('addNewToken')
|
|
->assertDispatched('error');
|
|
|
|
expect(PersonalAccessToken::query()->count())->toBe(0);
|
|
});
|
|
|
|
test('a member can create a token only within their grants', function () {
|
|
$member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['write']]]);
|
|
$this->actingAs($member);
|
|
|
|
$form = Livewire::test(ApiTokens::class)->assertSet('form.scopeMode', 'selected');
|
|
expect(collect($form->instance()->scopeOptions())->pluck('value')->filter(fn ($value) => str_starts_with($value, 'project:'))->values()->all())
|
|
->toBe(["project:{$this->shop->uuid}"]);
|
|
|
|
$form->set('form.description', 'too wide')
|
|
->set('form.permissions', ['write'])
|
|
->set('form.scopeMode', 'team')
|
|
->call('addNewToken')
|
|
->assertDispatched('error');
|
|
$form->set('form.description', 'blog')
|
|
->set('form.scopeMode', 'selected')
|
|
->set('form.selectedScopes', ["project:{$this->blog->uuid}"])
|
|
->call('addNewToken')
|
|
->assertDispatched('error');
|
|
$form->set('form.description', 'shop')
|
|
->set('form.selectedScopes', ["project:{$this->shop->uuid}"])
|
|
->call('addNewToken');
|
|
|
|
expect($member->tokens()->pluck('name')->all())->toBe(['shop']);
|
|
});
|
|
|
|
test('edits a team-wide token to one project, and the same token value then sees only that project', function () {
|
|
$plainToken = scopedTokenTestToken($this->admin, $this->team, ['read'], null);
|
|
$token = PersonalAccessToken::query()->sole();
|
|
$this->actingAs($this->admin);
|
|
|
|
expect(ApiTokenForm::valuesOf($token))->toMatchArray([
|
|
'description' => 'scoped-test',
|
|
'permissions' => ['read'],
|
|
'scopeMode' => 'team',
|
|
'expiresInDays' => 'keep',
|
|
]);
|
|
|
|
scopedTokenTestEdit(Livewire::test(ApiTokens::class), $token)
|
|
->set('editForm.scopeMode', 'selected')
|
|
->set('editForm.selectedScopes', ["project:{$this->shop->uuid}"])
|
|
->call('updateToken', $token->id)
|
|
->assertHasNoErrors()
|
|
->assertNotDispatched('error')
|
|
->assertDispatched('api-token-updated');
|
|
|
|
expect($token->fresh()->scopes)->toBe([['scope' => 'project', 'uuid' => $this->shop->uuid]])
|
|
->and(scopedTokenTestApi($plainToken, 'GET', '/api/v1/projects')->assertOk()->json('*.name'))->toBe(['shop']);
|
|
});
|
|
|
|
test('edits permissions and keeps the expiry, then sets a new expiry', function () {
|
|
$plainToken = scopedTokenTestToken($this->admin, $this->team, ['read', 'write'], null);
|
|
$token = PersonalAccessToken::query()->sole();
|
|
$expiresAt = now()->addDays(10)->startOfSecond();
|
|
$token->forceFill(['expires_at' => $expiresAt, 'api_token_expiration_warning_sent_at' => now()])->save();
|
|
$this->actingAs($this->admin);
|
|
|
|
$form = scopedTokenTestEdit(Livewire::test(ApiTokens::class), $token)
|
|
->set('editForm.permissions', ['read'])
|
|
->call('updateToken', $token->id)
|
|
->assertHasNoErrors();
|
|
|
|
$token->refresh();
|
|
expect($token->abilities)->toBe(['read'])
|
|
->and($token->expires_at->equalTo($expiresAt))->toBeTrue()
|
|
->and($token->api_token_expiration_warning_sent_at)->not->toBeNull();
|
|
scopedTokenTestApi($plainToken, 'POST', '/api/v1/projects', ['name' => 'new'])->assertForbidden();
|
|
|
|
scopedTokenTestEdit($form, $token)
|
|
->set('editForm.expiresInDays', 90)
|
|
->call('updateToken', $token->id)
|
|
->assertHasNoErrors();
|
|
|
|
$token->refresh();
|
|
expect(round(now()->diffInDays($token->expires_at)))->toEqual(90)
|
|
->and($token->api_token_expiration_warning_sent_at)->toBeNull();
|
|
});
|
|
|
|
test('a member cannot give a token more access than their grants by editing it', function () {
|
|
$member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']]]);
|
|
scopedTokenTestToken($member, $this->team, ['read'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]);
|
|
$token = PersonalAccessToken::query()->sole();
|
|
$this->actingAs($member);
|
|
|
|
scopedTokenTestEdit(Livewire::test(ApiTokens::class), $token)
|
|
->set('editForm.selectedScopes', ["project:{$this->blog->uuid}"])
|
|
->call('updateToken', $token->id)
|
|
->assertDispatched('error');
|
|
|
|
expect($token->fresh()->scopes)->toBe([['scope' => 'project', 'uuid' => $this->shop->uuid]]);
|
|
});
|
|
|
|
test('cannot edit a token of another user or of another team', function () {
|
|
$otherAdmin = User::factory()->create();
|
|
$this->team->members()->attach($otherAdmin->id, ['role' => 'admin']);
|
|
scopedTokenTestToken($otherAdmin, $this->team, ['read'], null);
|
|
$otherTeam = Team::factory()->create();
|
|
$otherTeam->members()->attach($this->admin->id, ['role' => 'admin']);
|
|
scopedTokenTestToken($this->admin, $otherTeam, ['read'], null);
|
|
$this->actingAs($this->admin);
|
|
|
|
foreach (PersonalAccessToken::query()->get() as $token) {
|
|
scopedTokenTestEdit(Livewire::test(ApiTokens::class), $token)
|
|
->set('editForm.description', 'taken over')
|
|
->call('updateToken', $token->id)
|
|
->assertStatus(404);
|
|
|
|
expect($token->fresh()->name)->toBe('scoped-test');
|
|
}
|
|
});
|
|
|
|
test('a paused token is rejected by the API and MCP until it is resumed', function () {
|
|
$plainToken = scopedTokenTestToken($this->admin, $this->team, ['read'], null);
|
|
$token = PersonalAccessToken::query()->sole();
|
|
$this->actingAs($this->admin);
|
|
|
|
Livewire::test(ApiTokens::class)
|
|
->call('setPaused', $token->id, true)
|
|
->assertDispatched('success', 'API token paused.');
|
|
|
|
expect($token->fresh()->isPaused())->toBeTrue();
|
|
|
|
// A stale page that still shows "Pause" must not resume the token.
|
|
Livewire::test(ApiTokens::class)->call('setPaused', $token->id, true);
|
|
expect($token->fresh()->isPaused())->toBeTrue();
|
|
scopedTokenTestApi($plainToken, 'GET', '/api/v1/projects')->assertUnauthorized();
|
|
scopedTokenTestMcp($plainToken, 'tools/list')->assertUnauthorized();
|
|
|
|
$this->actingAs($this->admin);
|
|
Livewire::test(ApiTokens::class)
|
|
->call('setPaused', $token->id, false)
|
|
->assertDispatched('success', 'API token resumed.');
|
|
|
|
expect($token->fresh()->isPaused())->toBeFalse();
|
|
scopedTokenTestApi($plainToken, 'GET', '/api/v1/projects')->assertOk();
|
|
scopedTokenTestMcp($plainToken, 'tools/list')->assertOk();
|
|
});
|
|
|
|
test('cannot pause a token of another user', function () {
|
|
$otherAdmin = User::factory()->create();
|
|
$this->team->members()->attach($otherAdmin->id, ['role' => 'admin']);
|
|
scopedTokenTestToken($otherAdmin, $this->team, ['read'], null);
|
|
$token = PersonalAccessToken::query()->sole();
|
|
$this->actingAs($this->admin);
|
|
|
|
Livewire::test(ApiTokens::class)->call('setPaused', $token->id, true);
|
|
|
|
expect($token->fresh()->isPaused())->toBeFalse();
|
|
});
|
|
});
|