Files
coolify/tests/Feature/Api/ServerProxyApiTest.php
T
Andras Bacsai 7fe40f4cf6 fix(api): require deploy ability for server side effects
Require deploy permission for API changes that trigger server side effects, including resource deletion, storage access, provider operations, and database imports. Also escape Docker cleanup filters and sanitize log drain server names.
2026-10-19 09:07:42 +02:00

351 lines
14 KiB
PHP

<?php
use App\Actions\Proxy\SaveProxyConfiguration;
use App\Actions\Proxy\StartProxy;
use App\Enums\ProxyTypes;
use App\Jobs\RestartProxyJob;
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 Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'cache.default' => 'array',
'session.driver' => 'array',
'queue.default' => 'sync',
'app.maintenance.driver' => 'file',
]);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(
['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->bearerToken = serverProxyApiToken($this->user, $this->team, ['*']);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$this->server->proxy->set('status', 'exited');
$this->server->proxy->redirect_enabled = true;
$this->server->save();
});
function serverProxyApiHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
function serverProxyApiToken(User $user, Team $team, array $abilities): string
{
$plainTextToken = Str::random(40);
$token = $user->tokens()->create([
'name' => 'server-proxy-api-test-'.Str::random(6),
'token' => hash('sha256', $plainTextToken),
'abilities' => $abilities,
'team_id' => $team->id,
]);
return $token->getKey().'|'.$plainTextToken;
}
test('GET /api/v1/servers/{uuid}/proxy returns proxy settings without configuration when none is stored', function () {
$sensitiveToken = serverProxyApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$this->withHeaders(serverProxyApiHeaders($sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/proxy")
->assertOk()
->assertJsonPath('proxy_type', ProxyTypes::TRAEFIK->value)
->assertJsonPath('redirect_enabled', true)
->assertJsonPath('redirect_url', null)
->assertJsonPath('generate_exact_labels', false)
->assertJsonPath('configuration', null);
});
test('GET /api/v1/servers/{uuid}/proxy omits stored configuration without read:sensitive', function () {
$compose = "services:\n traefik:\n image: traefik:v3.5\n";
$this->server->proxy->set('last_saved_proxy_configuration', $compose);
$this->server->save();
// '*' tokens grant all abilities including read:sensitive; use a read-only token.
$readToken = serverProxyApiToken($this->user, $this->team, ['read']);
$this->withHeaders(serverProxyApiHeaders($readToken))
->getJson("/api/v1/servers/{$this->server->uuid}/proxy")
->assertOk()
->assertJsonMissingPath('configuration');
});
test('GET /api/v1/servers/{uuid}/proxy returns stored configuration with read:sensitive for admins', function () {
$compose = "services:\n traefik:\n image: traefik:v3.5\n";
$this->server->proxy->set('last_saved_proxy_configuration', $compose);
$this->server->save();
$sensitiveToken = serverProxyApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$this->withHeaders(serverProxyApiHeaders($sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/proxy")
->assertOk()
->assertJsonPath('configuration', $compose);
});
test('GET /api/v1/servers/{uuid}/proxy hides configuration from non-admin users with read:sensitive', function () {
$compose = "services:\n traefik:\n image: traefik:v3.5\n";
$this->server->proxy->set('last_saved_proxy_configuration', $compose);
$this->server->save();
// ApiSensitiveData requires admin/owner of the token team even with read:sensitive.
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
session(['currentTeam' => $this->team]);
$memberToken = serverProxyApiToken($member, $this->team, ['read', 'read:sensitive']);
// Members may be forbidden from viewing servers via policy; when allowed, config must still be hidden.
$response = $this->withHeaders(serverProxyApiHeaders($memberToken))
->getJson("/api/v1/servers/{$this->server->uuid}/proxy");
if ($response->status() === 200) {
$response->assertJsonMissingPath('configuration');
} else {
$response->assertForbidden();
}
});
test('GET /api/v1/servers/{uuid}/proxy does not expose another team server', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
->getJson("/api/v1/servers/{$otherServer->uuid}/proxy")
->assertNotFound();
});
test('PATCH /api/v1/servers/{uuid}/proxy updates redirect and label settings without SSH', function () {
// Factory servers are not reachable, so setupDefaultRedirect is skipped.
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [
'redirect_enabled' => false,
'redirect_url' => null,
'generate_exact_labels' => true,
])
->assertOk()
->assertJsonPath('redirect_enabled', false)
->assertJsonPath('redirect_url', null)
->assertJsonPath('generate_exact_labels', true);
$server = $this->server->fresh();
expect((bool) data_get($server->proxy, 'redirect_enabled'))->toBeFalse()
->and(data_get($server->proxy, 'redirect_url'))->toBeNull()
->and((bool) $server->settings->generate_exact_labels)->toBeTrue();
});
test('PATCH /api/v1/servers/{uuid}/proxy updates the Traefik certificate resolver', function (?string $input, ?string $expected) {
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
->getJson("/api/v1/servers/{$this->server->uuid}/proxy")
->assertOk()
->assertJsonPath('traefik_certificate_resolver', 'letsencrypt');
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [
'traefik_certificate_resolver' => $input,
])
->assertOk()
->assertJsonPath('traefik_certificate_resolver', $expected);
expect($this->server->settings->fresh()->traefik_certificate_resolver)->toBe($expected);
})->with([
'custom resolver' => ['cloudflare', 'cloudflare'],
'no resolver' => [null, null],
'empty resolver' => ['', null],
]);
test('PATCH /api/v1/servers/{uuid}/proxy rejects unsafe certificate resolver names', function () {
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [
'traefik_certificate_resolver' => 'letsencrypt.acme=x',
])
->assertUnprocessable()
->assertJsonValidationErrors('traefik_certificate_resolver');
expect($this->server->settings->fresh()->traefik_certificate_resolver)->toBe('letsencrypt');
});
test('PATCH /api/v1/servers/{uuid}/proxy rejects unknown fields', function () {
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [
'redirect_enabled' => true,
'unknown_field' => 'nope',
])
->assertUnprocessable()
->assertJsonPath('errors.unknown_field.0', 'This field is not allowed.');
});
test('PATCH /api/v1/servers/{uuid}/proxy rejects invalid proxy type', function () {
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [
'proxy_type' => 'haproxy',
])
->assertUnprocessable()
->assertJsonPath('errors.proxy_type.0', 'Invalid proxy type.');
});
test('PATCH /api/v1/servers/{uuid}/proxy can change proxy type asynchronously', function () {
StartProxy::shouldRun()->andReturn('OK');
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [
'proxy_type' => 'caddy',
])
->assertOk()
->assertJsonPath('proxy_type', ProxyTypes::CADDY->value);
expect($this->server->fresh()->proxyType())->toBe(ProxyTypes::CADDY->value);
});
test('PUT /api/v1/servers/{uuid}/proxy/configuration saves base64 configuration via action', function () {
$compose = "services:\n traefik:\n image: traefik:v3.5\n";
SaveProxyConfiguration::shouldRun()
->once()
->withArgs(function (Server $server, string $configuration) use ($compose) {
return $server->is($this->server) && $configuration === $compose;
});
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [
'configuration' => base64_encode($compose),
])
->assertOk()
->assertJsonPath('message', 'Proxy configuration saved.');
});
test('PUT /api/v1/servers/{uuid}/proxy/configuration rejects missing configuration', function () {
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [
'foo' => 'bar',
])
->assertUnprocessable();
});
test('PUT /api/v1/servers/{uuid}/proxy/configuration rejects command injection without mutation', function () {
$originalProxy = $this->server->proxy->toArray();
$configuration = "services:\n traefik:\n ports:\n - '`id>/tmp/pwned`:443'\n";
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [
'configuration' => base64_encode($configuration),
])
->assertUnprocessable()
->assertJsonPath('errors.configuration.0', 'Proxy ports must use Docker Compose port syntax with ports from 1 through 65535.');
expect($this->server->fresh()->proxy->toArray())->toBe($originalProxy);
});
test('PUT /api/v1/servers/{uuid}/proxy/configuration forbids normal members', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$memberToken = serverProxyApiToken($member, $this->team, ['*']);
SaveProxyConfiguration::shouldNotRun();
$this->withHeaders(serverProxyApiHeaders($memberToken))
->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [
'configuration' => base64_encode("services:\n traefik:\n ports: ['80:80']\n"),
])
->assertForbidden();
});
test('PUT /api/v1/servers/{uuid}/proxy/configuration requires the deploy permission', function () {
$writeOnlyToken = serverProxyApiToken($this->user, $this->team, ['write']);
$configuration = "services:\n traefik:\n image: traefik:v3.5\n";
SaveProxyConfiguration::shouldNotRun();
$this->withHeaders(serverProxyApiHeaders($writeOnlyToken))
->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [
'configuration' => base64_encode($configuration),
])
->assertForbidden()
->assertJsonPath('message', 'Missing required permissions: deploy. Coolify applies the proxy configuration on the server, so it needs a token with the deploy permission.');
});
test('PUT /api/v1/servers/{uuid}/proxy/configuration saves with write and deploy permissions', function () {
$deployToken = serverProxyApiToken($this->user, $this->team, ['write', 'deploy']);
SaveProxyConfiguration::shouldRun()->once();
$this->withHeaders(serverProxyApiHeaders($deployToken))
->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [
'configuration' => base64_encode("services:\n traefik:\n image: traefik:v3.5\n"),
])
->assertOk();
});
test('PUT /api/v1/servers/{uuid}/proxy/configuration requires the write permission', function () {
$deployOnlyToken = serverProxyApiToken($this->user, $this->team, ['deploy']);
SaveProxyConfiguration::shouldNotRun();
$this->withHeaders(serverProxyApiHeaders($deployOnlyToken))
->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [
'configuration' => base64_encode("services:\n traefik:\n image: traefik:v3.5\n"),
])
->assertForbidden();
});
test('POST /api/v1/servers/{uuid}/proxy/restart requires the deploy permission', function () {
Queue::fake();
$writeOnlyToken = serverProxyApiToken($this->user, $this->team, ['write']);
$this->withHeaders(serverProxyApiHeaders($writeOnlyToken))
->postJson("/api/v1/servers/{$this->server->uuid}/proxy/restart")
->assertForbidden();
Queue::assertNotPushed(RestartProxyJob::class);
});
test('POST /api/v1/servers/{uuid}/proxy/restart works with the deploy permission', function () {
Queue::fake();
$deployToken = serverProxyApiToken($this->user, $this->team, ['deploy']);
$this->withHeaders(serverProxyApiHeaders($deployToken))
->postJson("/api/v1/servers/{$this->server->uuid}/proxy/restart")
->assertOk();
Queue::assertPushed(RestartProxyJob::class);
});
test('POST /api/v1/servers/{uuid}/proxy/restart queues RestartProxyJob', function () {
Queue::fake();
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
->postJson("/api/v1/servers/{$this->server->uuid}/proxy/restart")
->assertOk()
->assertJsonPath('message', 'Proxy restart queued.');
Queue::assertPushed(
RestartProxyJob::class,
fn (RestartProxyJob $job): bool => $job->server->is($this->server)
);
});
test('proxy endpoints require authentication', function () {
$this->getJson("/api/v1/servers/{$this->server->uuid}/proxy")->assertUnauthorized();
$this->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", ['redirect_enabled' => false])->assertUnauthorized();
$this->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", ['configuration' => 'x'])->assertUnauthorized();
$this->postJson("/api/v1/servers/{$this->server->uuid}/proxy/restart")->assertUnauthorized();
});