mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 16:46:37 +00:00
Changing the Vault base URL or namespace, or the Infisical base URL or client ID, sent the stored secret to the new settings. The secret must now be entered again, and base URL changes are audit logged.
440 lines
17 KiB
PHP
440 lines
17 KiB
PHP
<?php
|
|
|
|
use App\Livewire\Security\IntegrationTokenEditor;
|
|
use App\Livewire\Security\IntegrationTokenForm;
|
|
use App\Livewire\Security\IntegrationTokens;
|
|
use App\Models\AuditEvent;
|
|
use App\Models\DnsProviderZone;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\IntegrationToken;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Once;
|
|
use Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
$this->withoutDefer();
|
|
if (! InstanceSettings::query()->whereKey(0)->exists()) {
|
|
$settings = new InstanceSettings;
|
|
$settings->id = 0;
|
|
$settings->save();
|
|
}
|
|
Once::flush();
|
|
|
|
$this->team = Team::factory()->create();
|
|
$this->user = User::factory()->create();
|
|
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
|
|
|
session(['currentTeam' => $this->team]);
|
|
$this->actingAs($this->user);
|
|
});
|
|
|
|
test('a cloudflare dns token is validated with read only requests before it is saved', function () {
|
|
Http::fake([
|
|
'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([
|
|
'success' => true,
|
|
'result' => ['status' => 'active'],
|
|
]),
|
|
'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response([
|
|
'success' => true,
|
|
'result' => [['id' => 'zone-id']],
|
|
]),
|
|
'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1' => Http::response([
|
|
'success' => true,
|
|
'result' => [],
|
|
]),
|
|
'https://api.cloudflare.com/client/v4/zones?page=1&per_page=50' => Http::response([
|
|
'success' => true,
|
|
'result' => [['id' => 'zone-id', 'name' => 'example.com', 'account' => ['id' => 'account-id', 'name' => 'Production']]],
|
|
'result_info' => ['total_pages' => 1],
|
|
]),
|
|
]);
|
|
|
|
Livewire::test(IntegrationTokenForm::class, ['modal_mode' => true])
|
|
->set('provider', 'cloudflare')
|
|
->set('name', 'Production DNS')
|
|
->set('token', 'cloudflare-token')
|
|
->set('capabilities', ['dns'])
|
|
->call('addToken')
|
|
->assertHasNoErrors()
|
|
->assertDispatched('close-modal');
|
|
|
|
$this->assertDatabaseHas('integration_tokens', [
|
|
'team_id' => $this->team->id,
|
|
'provider' => 'cloudflare',
|
|
'name' => 'Production DNS',
|
|
]);
|
|
$this->assertDatabaseHas('audit_events', [
|
|
'team_id' => $this->team->id,
|
|
'event' => 'ui.integration_token.created',
|
|
'resource_name' => 'Production DNS',
|
|
]);
|
|
|
|
Http::assertSentCount(4);
|
|
Http::assertSent(fn ($request) => $request->method() === 'GET'
|
|
&& $request->url() === 'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1');
|
|
});
|
|
|
|
test('automatic dns is enabled by default and can be disabled when saving a cloudflare token', function () {
|
|
Http::fake([
|
|
'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response(['success' => true, 'result' => ['status' => 'active']]),
|
|
'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response(['success' => true, 'result' => [['id' => 'zone-id']]]),
|
|
'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1' => Http::response(['success' => true, 'result' => []]),
|
|
'https://api.cloudflare.com/client/v4/zones?page=1&per_page=50' => Http::response([
|
|
'success' => true,
|
|
'result' => [['id' => 'zone-id', 'name' => 'example.com', 'account' => ['id' => 'account-id', 'name' => 'Production']]],
|
|
'result_info' => ['total_pages' => 1],
|
|
]),
|
|
]);
|
|
|
|
Livewire::test(IntegrationTokenForm::class)
|
|
->assertSet('automaticDns', true)
|
|
->set('name', 'Manual DNS')
|
|
->set('token', 'cloudflare-token')
|
|
->set('automaticDns', false)
|
|
->call('addToken')
|
|
->assertHasNoErrors();
|
|
|
|
expect(IntegrationToken::query()->sole()->automaticDnsEnabled())->toBeFalse();
|
|
});
|
|
|
|
test('deleting an integration token is audited without storing its value', function () {
|
|
$token = IntegrationToken::query()->create([
|
|
'team_id' => $this->team->id,
|
|
'provider' => 'doppler',
|
|
'name' => 'Production secrets',
|
|
'token' => 'dp.st.super-secret',
|
|
'capabilities' => ['secrets'],
|
|
]);
|
|
|
|
Livewire::test(IntegrationTokens::class)->call('deleteToken', $token->id);
|
|
|
|
$auditEvent = AuditEvent::query()->where('event', 'ui.integration_token.deleted')->sole();
|
|
|
|
expect($auditEvent->resource_uuid)->toBe($token->uuid)
|
|
->and(json_encode($auditEvent->metadata))->not->toContain('dp.st.super-secret');
|
|
});
|
|
|
|
test('a cloudflare token is not saved when scope validation fails', function () {
|
|
Http::fake([
|
|
'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([
|
|
'success' => true,
|
|
'result' => ['status' => 'active'],
|
|
]),
|
|
'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response([
|
|
'success' => false,
|
|
'errors' => [['message' => 'Authentication error']],
|
|
], 403),
|
|
]);
|
|
|
|
Livewire::test(IntegrationTokenForm::class)
|
|
->set('name', 'Invalid DNS token')
|
|
->set('token', 'cloudflare-token')
|
|
->set('capabilities', ['dns'])
|
|
->call('addToken')
|
|
->assertDispatched('error');
|
|
|
|
$this->assertDatabaseCount('integration_tokens', 0);
|
|
});
|
|
|
|
test('at least one capability is required when adding a cloudflare token', function () {
|
|
Livewire::test(IntegrationTokenForm::class)
|
|
->set('name', 'Account token')
|
|
->set('token', 'cloudflare-token')
|
|
->set('capabilities', [])
|
|
->call('addToken')
|
|
->assertHasErrors(['capabilities' => 'required']);
|
|
|
|
$this->assertDatabaseCount('integration_tokens', 0);
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
test('integration tokens page lists saved provider and capabilities', function () {
|
|
IntegrationToken::query()->create([
|
|
'team_id' => $this->team->id,
|
|
'provider' => 'cloudflare',
|
|
'name' => 'Production DNS',
|
|
'token' => 'secret',
|
|
'capabilities' => ['dns'],
|
|
]);
|
|
|
|
Livewire::test(IntegrationTokens::class)
|
|
->assertSee('Production DNS')
|
|
->assertSee('Cloudflare')
|
|
->assertSee('DNS');
|
|
});
|
|
|
|
test('cloudflare dns scope guidance and token creation link are shown', function () {
|
|
Livewire::test(IntegrationTokenForm::class)
|
|
->set('capabilities', ['dns'])
|
|
->assertSee('Zone - DNS - Edit')
|
|
->assertSee('Zone - Zone - Read')
|
|
->assertSeeHtml('https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22edit%22%7D%5D&accountId=%2A&zoneId=all&name=Coolify%20DNS%20Management');
|
|
});
|
|
|
|
test('saved integration token rows render modal editors with a gear button', function () {
|
|
IntegrationToken::query()->create([
|
|
'team_id' => $this->team->id,
|
|
'provider' => 'cloudflare',
|
|
'name' => 'Production DNS',
|
|
'token' => 'original-token',
|
|
'capabilities' => ['dns'],
|
|
]);
|
|
|
|
Livewire::test(IntegrationTokens::class)
|
|
->assertSee('Edit Integration Token')
|
|
->assertSee('Production DNS')
|
|
->assertSeeHtml(':aria-label="`Edit ${tokenName}`"');
|
|
});
|
|
|
|
test('an integration token can be rotated after validating its capabilities', function () {
|
|
Http::fake([
|
|
'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([
|
|
'success' => true,
|
|
'result' => ['status' => 'active'],
|
|
]),
|
|
'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response([
|
|
'success' => true,
|
|
'result' => [['id' => 'zone-id']],
|
|
]),
|
|
'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1' => Http::response([
|
|
'success' => true,
|
|
'result' => [],
|
|
]),
|
|
'https://api.cloudflare.com/client/v4/zones?page=1&per_page=50' => Http::response([
|
|
'success' => true,
|
|
'result' => [['id' => 'zone-id', 'name' => 'example.com', 'account' => ['id' => 'account-id', 'name' => 'Production']]],
|
|
'result_info' => ['total_pages' => 1],
|
|
]),
|
|
]);
|
|
|
|
$savedToken = IntegrationToken::query()->create([
|
|
'team_id' => $this->team->id,
|
|
'provider' => 'cloudflare',
|
|
'name' => 'Production DNS',
|
|
'token' => 'original-token',
|
|
'capabilities' => ['dns'],
|
|
]);
|
|
|
|
Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid])
|
|
->set('name', 'Rotated DNS')
|
|
->set('newToken', 'rotated-token')
|
|
->call('save')
|
|
->assertHasNoErrors()
|
|
->assertDispatched('success');
|
|
|
|
$savedToken->refresh();
|
|
|
|
expect($savedToken->name)->toBe('Rotated DNS')
|
|
->and($savedToken->token)->toBe('rotated-token');
|
|
});
|
|
|
|
test('leaving the token field blank keeps the existing integration token', function () {
|
|
Http::fake();
|
|
|
|
$savedToken = IntegrationToken::query()->create([
|
|
'team_id' => $this->team->id,
|
|
'provider' => 'cloudflare',
|
|
'name' => 'Production DNS',
|
|
'token' => 'original-token',
|
|
'capabilities' => ['dns'],
|
|
]);
|
|
|
|
Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid])
|
|
->set('name', 'Renamed DNS')
|
|
->set('newToken', '')
|
|
->call('save')
|
|
->assertHasNoErrors();
|
|
|
|
$savedToken->refresh();
|
|
|
|
expect($savedToken->name)->toBe('Renamed DNS')
|
|
->and($savedToken->token)->toBe('original-token');
|
|
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
test('cloudflare token editor lists the zones managed by that token', function () {
|
|
$savedToken = IntegrationToken::factory()->for($this->team)->create([
|
|
'provider' => 'cloudflare',
|
|
'name' => 'Production DNS',
|
|
'capabilities' => ['dns'],
|
|
]);
|
|
DnsProviderZone::factory()->for($savedToken)->create([
|
|
'name' => 'example.com',
|
|
'account_name' => 'Production Account',
|
|
]);
|
|
DnsProviderZone::factory()->create(['name' => 'other-team.example']);
|
|
|
|
Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid])
|
|
->assertSee('Domains this token can manage')
|
|
->assertSee('example.com')
|
|
->assertSee('Production Account')
|
|
->assertDontSee('other-team.example');
|
|
});
|
|
|
|
test('an invalid replacement does not rotate the integration token', function () {
|
|
Http::fake([
|
|
'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([
|
|
'success' => false,
|
|
], 403),
|
|
]);
|
|
|
|
$savedToken = IntegrationToken::query()->create([
|
|
'team_id' => $this->team->id,
|
|
'provider' => 'cloudflare',
|
|
'name' => 'Production DNS',
|
|
'token' => 'original-token',
|
|
'capabilities' => ['dns'],
|
|
]);
|
|
|
|
Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid])
|
|
->set('newToken', 'invalid-token')
|
|
->call('save')
|
|
->assertDispatched('error');
|
|
|
|
expect($savedToken->fresh()->token)->toBe('original-token');
|
|
});
|
|
|
|
test('new integration token form controls declare authorization matching server-side actions', function () {
|
|
$editor = file_get_contents(resource_path('views/livewire/security/integration-token-editor.blade.php'));
|
|
$form = file_get_contents(resource_path('views/livewire/security/integration-token-form.blade.php'));
|
|
|
|
expect($editor)
|
|
->toMatch('/<x-forms\.checkbox(?=[^>]*id="edit-automatic-dns")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$integrationToken")[^>]*>/')
|
|
->toMatch('/<x-forms\.button(?=[^>]*wire:click="refreshZones")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$integrationToken")[^>]*>/');
|
|
|
|
expect($form)
|
|
->toMatch('/<x-forms\.checkbox(?=[^>]*id="automatic-dns")(?=[^>]*canGate="create")(?=[^>]*:canResource="\\\\App\\\\Models\\\\IntegrationToken::class")[^>]*>/');
|
|
});
|
|
|
|
function vaultIntegrationToken(Team $team, array $metadata = ['base_url' => 'https://example.com:8200']): IntegrationToken
|
|
{
|
|
return IntegrationToken::query()->create([
|
|
'team_id' => $team->id,
|
|
'provider' => 'vault',
|
|
'name' => 'Production Vault',
|
|
'token' => 'hvs.stored-secret',
|
|
'capabilities' => ['secrets'],
|
|
'metadata' => $metadata,
|
|
]);
|
|
}
|
|
|
|
test('changing the vault base url without re-entering the token does not send the stored token to the new host', function () {
|
|
Http::fake();
|
|
$savedToken = vaultIntegrationToken($this->team);
|
|
|
|
Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid])
|
|
->set('metadata.base_url', 'https://example.net:8200')
|
|
->set('newToken', '')
|
|
->call('save')
|
|
->assertHasErrors(['newToken'])
|
|
->assertNotDispatched('success');
|
|
|
|
Http::assertNothingSent();
|
|
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'example.net'));
|
|
expect($savedToken->fresh()->metadata)->toBe(['base_url' => 'https://example.com:8200'])
|
|
->and($savedToken->fresh()->token)->toBe('hvs.stored-secret');
|
|
});
|
|
|
|
test('changing the vault namespace without re-entering the token is rejected', function () {
|
|
Http::fake();
|
|
$savedToken = vaultIntegrationToken($this->team, ['base_url' => 'https://example.com:8200', 'namespace' => 'team-a']);
|
|
|
|
Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid])
|
|
->set('metadata.namespace', 'team-b')
|
|
->call('save')
|
|
->assertHasErrors(['newToken']);
|
|
|
|
Http::assertNothingSent();
|
|
expect($savedToken->fresh()->metadata)->toBe(['base_url' => 'https://example.com:8200', 'namespace' => 'team-a']);
|
|
});
|
|
|
|
test('changing the infisical base url or client id without re-entering the client secret is rejected', function (string $field, string $value) {
|
|
Http::fake();
|
|
$savedToken = IntegrationToken::query()->create([
|
|
'team_id' => $this->team->id,
|
|
'provider' => 'infisical',
|
|
'name' => 'Production Infisical',
|
|
'token' => 'stored-client-secret',
|
|
'capabilities' => ['secrets'],
|
|
'metadata' => ['base_url' => 'https://example.com', 'client_id' => 'client-a'],
|
|
]);
|
|
|
|
Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid])
|
|
->set("metadata.{$field}", $value)
|
|
->call('save')
|
|
->assertHasErrors(['newToken']);
|
|
|
|
Http::assertNothingSent();
|
|
expect($savedToken->fresh()->metadata)->toBe(['base_url' => 'https://example.com', 'client_id' => 'client-a']);
|
|
})->with([
|
|
'base url' => ['base_url', 'https://example.net'],
|
|
'client id' => ['client_id', 'client-b'],
|
|
]);
|
|
|
|
test('an admin changing the vault base url without the token gets a validation error', function () {
|
|
Http::fake();
|
|
$admin = User::factory()->create();
|
|
$this->team->members()->attach($admin->id, ['role' => 'admin']);
|
|
$this->actingAs($admin);
|
|
$savedToken = vaultIntegrationToken($this->team);
|
|
|
|
Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid])
|
|
->set('metadata.base_url', 'https://example.net:8200')
|
|
->call('save')
|
|
->assertHasErrors(['newToken']);
|
|
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
test('changing the vault base url with a re-entered token validates against the new host and is audited', function () {
|
|
Http::fake([
|
|
'https://example.net:8200/v1/auth/token/lookup-self' => Http::response(['data' => []]),
|
|
]);
|
|
$savedToken = vaultIntegrationToken($this->team);
|
|
|
|
Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid])
|
|
->set('metadata.base_url', 'https://example.net:8200')
|
|
->set('newToken', 'hvs.new-secret')
|
|
->call('save')
|
|
->assertHasNoErrors()
|
|
->assertDispatched('success');
|
|
|
|
Http::assertSent(fn ($request) => $request->url() === 'https://example.net:8200/v1/auth/token/lookup-self'
|
|
&& $request->hasHeader('X-Vault-Token', 'hvs.new-secret'));
|
|
Http::assertNotSent(fn ($request) => $request->hasHeader('X-Vault-Token', 'hvs.stored-secret'));
|
|
|
|
$savedToken->refresh();
|
|
expect($savedToken->metadata)->toBe(['base_url' => 'https://example.net:8200'])
|
|
->and($savedToken->token)->toBe('hvs.new-secret');
|
|
|
|
$event = AuditEvent::query()->where('event', 'ui.integration_token.base_url_changed')->first();
|
|
expect($event)->not->toBeNull()
|
|
->and($event->team_id)->toBe($this->team->id);
|
|
expect(json_encode($event->toArray()))->toContain('example.net')
|
|
->not->toContain('hvs.new-secret');
|
|
});
|
|
|
|
test('saving a vault token with unchanged connection settings and a blank token keeps working', function () {
|
|
Http::fake();
|
|
$savedToken = vaultIntegrationToken($this->team, ['base_url' => 'https://example.com:8200', 'namespace' => 'team-a']);
|
|
|
|
Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid])
|
|
->set('name', 'Renamed Vault')
|
|
->set('newToken', '')
|
|
->call('save')
|
|
->assertHasNoErrors()
|
|
->assertDispatched('success');
|
|
|
|
Http::assertNothingSent();
|
|
$savedToken->refresh();
|
|
expect($savedToken->name)->toBe('Renamed Vault')
|
|
->and($savedToken->token)->toBe('hvs.stored-secret')
|
|
->and($savedToken->metadata)->toBe(['base_url' => 'https://example.com:8200', 'namespace' => 'team-a']);
|
|
});
|