Files
coolify/tests/Feature/Api/ServerSubsystemsApiTest.php
2026-10-27 22:25:43 +01:00

559 lines
23 KiB
PHP

<?php
use App\Actions\Server\StartLogDrain;
use App\Actions\Server\StartSentinel;
use App\Actions\Server\StopLogDrain;
use App\Jobs\DockerCleanupJob;
use App\Models\DockerCleanupExecution;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Lorisleiva\Actions\Decorators\JobDecorator;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'app.maintenance.store' => 'array',
'cache.default' => 'array',
'cache.stores.redis.driver' => 'array',
]);
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'ip' => '10.0.0.10',
]);
$otherTeam = Team::factory()->create();
$this->otherServer = Server::factory()->create([
'team_id' => $otherTeam->id,
'ip' => '10.0.0.20',
]);
$this->token = $this->user->createToken('server-subsystems', ['*'])->plainTextToken;
});
function serverSubsystemsHeaders(): array
{
return [
'Authorization' => 'Bearer '.test()->token,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
];
}
describe('Docker cleanup API', function () {
test('GET returns docker cleanup settings for own team server', function () {
$this->server->settings->update([
'docker_cleanup_frequency' => '0 0 * * *',
'docker_cleanup_threshold' => 25,
'force_docker_cleanup' => true,
'delete_unused_volumes' => true,
'delete_unused_networks' => false,
'disable_application_image_retention' => true,
]);
$this->withHeaders(serverSubsystemsHeaders())
->getJson("/api/v1/servers/{$this->server->uuid}/docker-cleanup")
->assertOk()
->assertJsonPath('docker_cleanup_threshold', 25)
->assertJsonPath('force_docker_cleanup', true)
->assertJsonPath('delete_unused_volumes', true)
->assertJsonPath('disable_application_image_retention', true);
});
test('PATCH updates docker cleanup settings for own team server', function () {
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/docker-cleanup", [
'docker_cleanup_frequency' => '0 */6 * * *',
'docker_cleanup_threshold' => 42,
'force_docker_cleanup' => true,
'delete_unused_volumes' => true,
'delete_unused_networks' => true,
'disable_application_image_retention' => true,
])
->assertOk()
->assertJsonPath('docker_cleanup_threshold', 42)
->assertJsonPath('force_docker_cleanup', true);
$settings = $this->server->settings->fresh();
expect($settings->docker_cleanup_threshold)->toBe(42)
->and((bool) $settings->force_docker_cleanup)->toBeTrue()
->and((bool) $settings->delete_unused_volumes)->toBeTrue()
->and((bool) $settings->delete_unused_networks)->toBeTrue()
->and((bool) $settings->disable_application_image_retention)->toBeTrue();
});
test('POST run dispatches DockerCleanupJob for own team server', function () {
Queue::fake();
$this->server->settings->update([
'delete_unused_volumes' => true,
'delete_unused_networks' => false,
]);
$this->withHeaders(serverSubsystemsHeaders())
->postJson("/api/v1/servers/{$this->server->uuid}/docker-cleanup/run")
->assertOk()
->assertJsonPath('message', fn ($message) => str_contains($message, 'Manual cleanup job started'));
Queue::assertPushed(DockerCleanupJob::class, function (DockerCleanupJob $job) {
return $job->server->is($this->server)
&& $job->manualCleanup === true
&& $job->deleteUnusedVolumes === true
&& $job->deleteUnusedNetworks === false;
});
});
test('GET executions lists recent cleanup runs for own team server', function () {
$execution = DockerCleanupExecution::create([
'server_id' => $this->server->id,
'status' => 'success',
'message' => 'Cleanup completed',
]);
$this->withHeaders(serverSubsystemsHeaders())
->getJson("/api/v1/servers/{$this->server->uuid}/docker-cleanup/executions")
->assertOk()
->assertJsonCount(1)
->assertJsonPath('0.uuid', $execution->uuid)
->assertJsonPath('0.status', 'success');
});
test('other-team docker cleanup endpoints return 404', function () {
$this->withHeaders(serverSubsystemsHeaders())
->getJson("/api/v1/servers/{$this->otherServer->uuid}/docker-cleanup")
->assertNotFound();
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->otherServer->uuid}/docker-cleanup", [
'docker_cleanup_threshold' => 50,
])
->assertNotFound();
$this->withHeaders(serverSubsystemsHeaders())
->postJson("/api/v1/servers/{$this->otherServer->uuid}/docker-cleanup/run")
->assertNotFound();
$this->withHeaders(serverSubsystemsHeaders())
->getJson("/api/v1/servers/{$this->otherServer->uuid}/docker-cleanup/executions")
->assertNotFound();
});
});
describe('Log drains API', function () {
test('GET returns log drain settings and hides secrets without read:sensitive', function () {
$this->server->settings->update([
'is_logdrain_axiom_enabled' => false,
'logdrain_axiom_dataset_name' => 'coolify-logs',
'logdrain_axiom_api_key' => 'secret-axiom-key',
'logdrain_newrelic_license_key' => 'secret-nr-key',
]);
$readToken = $this->user->createToken('server-subsystems-read', ['read'])->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$readToken,
'Accept' => 'application/json',
])
->getJson("/api/v1/servers/{$this->server->uuid}/log-drains")
->assertOk()
->assertJsonPath('logdrain_axiom_dataset_name', 'coolify-logs')
->assertJsonPath('is_logdrain_axiom_enabled', false);
expect($response->json())->not->toHaveKey('logdrain_axiom_api_key')
->and($response->json())->not->toHaveKey('logdrain_newrelic_license_key');
});
test('PATCH updates log drain settings for own team server', function () {
Queue::fake();
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [
'logdrain_axiom_dataset_name' => 'api-dataset',
'logdrain_axiom_api_key' => 'axiom-key-123',
])
->assertOk()
->assertJsonPath('logdrain_axiom_dataset_name', 'api-dataset');
$settings = $this->server->settings->fresh();
expect($settings->logdrain_axiom_dataset_name)->toBe('api-dataset')
->and($settings->logdrain_axiom_api_key)->toBe('axiom-key-123');
});
test('PATCH sets the Axiom edge domain and rejects a value that is not a hostname', function () {
Queue::fake();
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [
'logdrain_axiom_domain' => 'eu-central-1.aws.edge.axiom.co',
])
->assertOk()
->assertJsonPath('logdrain_axiom_domain', 'eu-central-1.aws.edge.axiom.co');
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [
'logdrain_axiom_domain' => "evil.example.com\nAXIOM_API_KEY=x",
])
->assertUnprocessable()
->assertJsonValidationErrors('logdrain_axiom_domain');
expect($this->server->settings->fresh()->logdrain_axiom_domain)->toBe('eu-central-1.aws.edge.axiom.co');
});
test('PATCH saves log drain settings without connecting to a server that is not validated', function () {
Queue::fake();
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [
'is_logdrain_axiom_enabled' => false,
])
->assertOk();
StopLogDrain::assertNotPushed();
StartLogDrain::assertNotPushed();
});
test('PATCH requires the deploy permission to change the custom log drain config', function (string $field) {
Queue::fake();
$writeOnlyToken = $this->user->createToken('server-subsystems-write', ['write'])->plainTextToken;
$this->withHeaders([
'Authorization' => 'Bearer '.$writeOnlyToken,
'Accept' => 'application/json',
])
->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [
$field => "[OUTPUT]\n Name stdout",
])
->assertForbidden()
->assertJsonPath('message', 'Missing required permissions: deploy');
$settings = $this->server->settings->fresh();
expect($settings->{$field})->toBeNull();
})->with(['logdrain_custom_config', 'logdrain_custom_config_parser']);
test('PATCH lets a write and deploy token enable the custom log drain with its saved config', function () {
Queue::fake();
$this->server->settings->update(['logdrain_custom_config' => "[OUTPUT]\n Name stdout"]);
$deployToken = $this->user->createToken('server-subsystems-deploy', ['write', 'deploy'])->plainTextToken;
$this->withHeaders([
'Authorization' => 'Bearer '.$deployToken,
'Accept' => 'application/json',
])
->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [
'is_logdrain_custom_enabled' => true,
'logdrain_custom_config' => "[OUTPUT]\n Name stdout",
])
->assertOk();
expect($this->server->settings->fresh()->is_logdrain_custom_enabled)->toBeTruthy();
});
test('PATCH changes the custom log drain config with the deploy permission', function () {
Queue::fake();
$deployToken = $this->user->createToken('server-subsystems-deploy', ['write', 'deploy'])->plainTextToken;
$this->withHeaders([
'Authorization' => 'Bearer '.$deployToken,
'Accept' => 'application/json',
])
->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [
'logdrain_custom_config' => "[OUTPUT]\n Name stdout",
])
->assertOk();
expect($this->server->settings->fresh()->logdrain_custom_config)->toBe("[OUTPUT]\n Name stdout");
});
test('PATCH enables the Amazon CloudWatch Logs drain', function () {
Queue::fake();
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [
'is_logdrain_cloudwatch_enabled' => true,
'logdrain_cloudwatch_group' => '/coolify/prod',
'logdrain_cloudwatch_region' => 'us-east-1',
'logdrain_cloudwatch_stream_prefix' => 'docker/',
])
->assertOk()
->assertJsonPath('is_logdrain_cloudwatch_enabled', true)
->assertJsonPath('logdrain_cloudwatch_group', '/coolify/prod')
->assertJsonPath('logdrain_cloudwatch_stream_prefix', 'docker/');
$settings = $this->server->settings->fresh();
expect($settings->is_logdrain_cloudwatch_enabled)->toBeTrue()
->and($settings->logdrain_cloudwatch_region)->toBe('us-east-1');
});
test('PATCH rejects enabling CloudWatch without a log group', function () {
Queue::fake();
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [
'is_logdrain_cloudwatch_enabled' => true,
'logdrain_cloudwatch_region' => 'us-east-1',
])
->assertUnprocessable()
->assertJsonValidationErrors('logdrain_cloudwatch_group');
expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse();
});
test('PATCH rejects enabling CloudWatch without a region', function () {
Queue::fake();
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [
'is_logdrain_cloudwatch_enabled' => true,
'logdrain_cloudwatch_group' => 'coolify',
])
->assertUnprocessable()
->assertJsonValidationErrors('logdrain_cloudwatch_region');
expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse();
});
test('PATCH rejects CloudWatch values that could break the compose file', function (string $field, mixed $value) {
Queue::fake();
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [
$field => $value,
])
->assertUnprocessable()
->assertJsonValidationErrors($field);
})->with([
'prefix with interpolation' => ['logdrain_cloudwatch_stream_prefix', 'docker/${HOME}'],
'prefix with colon' => ['logdrain_cloudwatch_stream_prefix', 'docker:'],
'prefix with template braces' => ['logdrain_cloudwatch_stream_prefix', '{{.ID}}'],
'invalid region' => ['logdrain_cloudwatch_region', 'not a region'],
'group with spaces' => ['logdrain_cloudwatch_group', 'my group'],
]);
test('PATCH rejects enabling CloudWatch together with another log drain', function () {
Queue::fake();
$this->server->settings->update([
'is_logdrain_axiom_enabled' => true,
'logdrain_axiom_dataset_name' => 'ds',
'logdrain_axiom_api_key' => 'key',
]);
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [
'is_logdrain_cloudwatch_enabled' => true,
'logdrain_cloudwatch_group' => 'coolify',
'logdrain_cloudwatch_region' => 'us-east-1',
])
->assertUnprocessable()
->assertJsonValidationErrors('is_logdrain_cloudwatch_enabled');
});
test('other-team log drains endpoints return 404', function () {
$this->withHeaders(serverSubsystemsHeaders())
->getJson("/api/v1/servers/{$this->otherServer->uuid}/log-drains")
->assertNotFound();
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->otherServer->uuid}/log-drains", [
'logdrain_axiom_dataset_name' => 'nope',
])
->assertNotFound();
});
});
describe('Sentinel API', function () {
test('GET returns sentinel settings without token without read:sensitive', function () {
// Saved quietly: a metrics change restarts Sentinel, which needs SSH.
$this->server->settings->forceFill([
'is_sentinel_enabled' => true,
'is_metrics_enabled' => true,
'is_sentinel_debug_enabled' => false,
])->saveQuietly();
$readToken = $this->user->createToken('server-subsystems-read', ['read'])->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$readToken,
'Accept' => 'application/json',
])
->getJson("/api/v1/servers/{$this->server->uuid}/sentinel")
->assertOk()
->assertJsonPath('is_sentinel_enabled', true)
->assertJsonPath('is_metrics_enabled', true)
->assertJsonPath('traffic_topn', 50)
->assertJsonPath('is_geoip_enabled', true)
->assertJsonPath('traffic_ip_mode', 'full');
expect($response->json())->not->toHaveKey('sentinel_token')
->and($response->json())->not->toHaveKey('sentinel_custom_url');
});
test('PATCH updates sentinel settings for own team server and restarts Sentinel once', function () {
Queue::fake();
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [
'is_metrics_enabled' => true,
'is_sentinel_debug_enabled' => true,
])
->assertOk()
->assertJsonPath('is_metrics_enabled', true)
->assertJsonPath('is_sentinel_debug_enabled', true);
$settings = $this->server->settings->fresh();
expect((bool) $settings->is_metrics_enabled)->toBeTrue()
->and((bool) $settings->is_sentinel_debug_enabled)->toBeTrue();
// Sentinel reads both settings from its environment, so it must be recreated.
expect(Queue::pushed(JobDecorator::class, fn (JobDecorator $job): bool => $job->getAction() instanceof StartSentinel))
->toHaveCount(1);
});
test('PATCH updates the client IP mode and restarts Sentinel', function (string $mode) {
Queue::fake();
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [
'traffic_ip_mode' => $mode,
])
->assertOk()
->assertJsonPath('traffic_ip_mode', $mode);
expect($this->server->settings->fresh()->traffic_ip_mode->value)->toBe($mode);
expect(Queue::pushed(JobDecorator::class, fn (JobDecorator $job): bool => $job->getAction() instanceof StartSentinel))
->toHaveCount(1);
})->with(['anonymized', 'off']);
test('PATCH rejects an unknown client IP mode', function () {
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [
'traffic_ip_mode' => 'partial',
])
->assertUnprocessable()
->assertJsonValidationErrors('traffic_ip_mode');
expect($this->server->settings->fresh()->traffic_ip_mode->value)->toBe('full');
});
test('PATCH rejects disabling mandatory Sentinel', function () {
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [
'is_sentinel_enabled' => false,
])
->assertUnprocessable()
->assertJsonValidationErrors('is_sentinel_enabled');
expect($this->server->fresh()->isSentinelEnabled())->toBeTrue();
});
test('other-team sentinel endpoints return 404', function () {
$this->withHeaders(serverSubsystemsHeaders())
->getJson("/api/v1/servers/{$this->otherServer->uuid}/sentinel")
->assertNotFound();
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->otherServer->uuid}/sentinel", [
'is_metrics_enabled' => true,
])
->assertNotFound();
});
});
describe('Cloudflare Tunnel API', function () {
test('GET returns cloudflare tunnel settings for own team server', function () {
$this->server->settings->update(['is_cloudflare_tunnel' => true]);
$this->withHeaders(serverSubsystemsHeaders())
->getJson("/api/v1/servers/{$this->server->uuid}/cloudflare-tunnel")
->assertOk()
->assertJsonPath('is_cloudflare_tunnel', true)
->assertJsonPath('ip', '10.0.0.10');
});
test('PATCH enables cloudflare tunnel setting for own team server', function () {
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/cloudflare-tunnel", [
'is_cloudflare_tunnel' => true,
])
->assertOk()
->assertJsonPath('is_cloudflare_tunnel', true);
expect((bool) $this->server->settings->fresh()->is_cloudflare_tunnel)->toBeTrue();
});
test('POST enable and disable match manual UI actions', function () {
// Changing ip auto-sets ip_previous to the previous IP via Server model boot.
$originalIp = (string) $this->server->ip;
$this->server->update(['ip' => '100.64.0.5']);
expect((string) $this->server->fresh()->ip_previous)->toBe($originalIp);
$this->server->settings->update(['is_cloudflare_tunnel' => false]);
$this->withHeaders(serverSubsystemsHeaders())
->postJson("/api/v1/servers/{$this->server->uuid}/cloudflare-tunnel/enable")
->assertOk()
->assertJsonPath('is_cloudflare_tunnel', true);
expect((bool) $this->server->settings->fresh()->is_cloudflare_tunnel)->toBeTrue();
$this->withHeaders(serverSubsystemsHeaders())
->postJson("/api/v1/servers/{$this->server->uuid}/cloudflare-tunnel/disable")
->assertOk()
->assertJsonPath('is_cloudflare_tunnel', false)
->assertJsonPath('ip', $originalIp);
expect((bool) $this->server->settings->fresh()->is_cloudflare_tunnel)->toBeFalse()
->and((string) $this->server->fresh()->ip)->toBe($originalIp);
});
test('other-team cloudflare tunnel endpoints return 404', function () {
$this->withHeaders(serverSubsystemsHeaders())
->getJson("/api/v1/servers/{$this->otherServer->uuid}/cloudflare-tunnel")
->assertNotFound();
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->otherServer->uuid}/cloudflare-tunnel", [
'is_cloudflare_tunnel' => true,
])
->assertNotFound();
$this->withHeaders(serverSubsystemsHeaders())
->postJson("/api/v1/servers/{$this->otherServer->uuid}/cloudflare-tunnel/enable")
->assertNotFound();
$this->withHeaders(serverSubsystemsHeaders())
->postJson("/api/v1/servers/{$this->otherServer->uuid}/cloudflare-tunnel/disable")
->assertNotFound();
});
});
describe('Server update is_terminal_enabled', function () {
test('PATCH /servers/{uuid} updates is_terminal_enabled on settings', function () {
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}", [
'is_terminal_enabled' => true,
])
->assertCreated()
->assertJsonPath('uuid', $this->server->uuid);
expect((bool) $this->server->settings->fresh()->is_terminal_enabled)->toBeTrue();
});
test('PATCH /servers/{uuid} other-team returns 404', function () {
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->otherServer->uuid}", [
'is_terminal_enabled' => true,
])
->assertNotFound();
});
});