Files
coolify/tests/Feature/Api/ApplicationSettingsApiTest.php
Andras Bacsai a3712f8902 fix(deploy): stop 502 errors during rolling updates
Traefik and Caddy routes of applications now retry a failed connection
on another container, and the rolling update waits a configurable drain
time (default 5 seconds) after the new container is healthy before it
stops the old containers. The wait is skipped when no old container runs
or when the proxy labels changed, because the proxy drops a route that
two containers define differently.

Fixes #8627
2026-10-24 11:16:46 +02:00

795 lines
33 KiB
PHP

<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.maintenance.driver' => 'file']);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$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 = $this->user->createToken('application-settings-api-test', ['*'])->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
});
function applicationSettingsApiHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
function recommendedApplicationSettingsPayload(): array
{
return [
'is_git_submodules_enabled' => false,
'is_git_lfs_enabled' => false,
'is_git_shallow_clone_enabled' => false,
'disable_build_cache' => true,
'inject_build_args_to_dockerfile' => false,
'include_source_commit_in_build' => true,
'is_env_sorting_enabled' => true,
'is_pr_deployments_public_enabled' => true,
'stop_grace_period' => 45,
'rolling_update_drain_seconds' => 12,
'docker_images_to_keep' => 7,
'is_gzip_enabled' => false,
'is_stripprefix_enabled' => false,
'is_raw_compose_deployment_enabled' => true,
'is_log_drain_enabled' => true,
'is_gpu_enabled' => true,
'gpu_driver' => 'nvidia',
'gpu_count' => '1',
'gpu_device_ids' => '0',
'gpu_options' => null,
'is_consistent_container_name_enabled' => true,
'custom_internal_name' => 'my-app-internal',
];
}
test('GET /api/v1/applications/{uuid} includes settings without internal metadata', function () {
$this->application->settings->update(recommendedApplicationSettingsPayload());
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$this->application->uuid}")
->assertOk()
->assertJsonPath('settings.disable_build_cache', true)
->assertJsonPath('settings.stop_grace_period', 45)
->assertJsonMissingPath('settings.id')
->assertJsonMissingPath('settings.application_id')
->assertJsonMissingPath('settings.created_at')
->assertJsonMissingPath('settings.updated_at');
});
test('PATCH /api/v1/applications/{uuid} updates application settings', function () {
$this->application->update(['build_pack' => 'dockercompose']);
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", recommendedApplicationSettingsPayload())
->assertOk();
$settings = $this->application->fresh()->settings;
foreach (recommendedApplicationSettingsPayload() as $field => $value) {
expect($settings->{$field})->toBe($value);
}
});
test('application creation accepts application settings', function () {
Queue::fake();
$response = $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/public', array_merge([
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'git_repository' => 'https://gitlab.com/coolify/application-settings-test',
'git_branch' => 'main',
'build_pack' => 'dockercompose',
'autogenerate_domain' => false,
], recommendedApplicationSettingsPayload()))
->assertCreated();
$settings = Application::where('uuid', $response->json('uuid'))->firstOrFail()->settings;
foreach (recommendedApplicationSettingsPayload() as $field => $value) {
expect($settings->{$field})->toBe($value);
}
});
test('proxy settings regenerate managed labels', function () {
$this->application->settings->update([
'is_container_label_readonly_enabled' => true,
'is_gzip_enabled' => true,
'is_stripprefix_enabled' => true,
]);
$this->application->update(['custom_labels' => base64_encode('sentinel-label=true')]);
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'is_gzip_enabled' => false,
'is_stripprefix_enabled' => false,
])
->assertOk();
expect(base64_decode($this->application->fresh()->custom_labels))->not->toContain('sentinel-label=true');
});
test('changing a domain port regenerates managed labels with the requested port', function () {
$this->application->settings->update(['is_container_label_readonly_enabled' => true]);
$this->application->update([
'fqdn' => 'https://app.example.com',
'ports_exposes' => '80',
'domain_port_overrides' => [
'https://app.example.com' => 3000,
],
]);
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'domains' => 'https://app.example.com:8080',
])
->assertOk();
$application = $this->application->fresh();
$labels = $application->parseContainerLabels();
expect($application->fqdn)->toBe('https://app.example.com')
->and($application->domain_port_overrides)->toBe([
'https://app.example.com' => 8080,
])
->and($labels)->toContain('loadbalancer.server.port=8080')
->and($labels)->not->toContain('loadbalancer.server.port=3000');
});
test('compose domain ports are stored as overrides when updating through the API', function () {
$this->application->update([
'build_pack' => 'dockercompose',
'docker_compose_raw' => "services:\n api:\n image: nginx\n frontend:\n image: nginx\n",
'docker_compose_domains' => json_encode([
'api' => ['domain' => 'https://api.example.com'],
'frontend' => ['domain' => 'https://app.example.com'],
]),
]);
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'docker_compose_domains' => [
['name' => 'api', 'domain' => 'https://api.example.com'],
['name' => 'frontend', 'domain' => 'https://app.example.com:80'],
],
])
->assertOk();
$application = $this->application->fresh();
$domains = json_decode($application->docker_compose_domains, true);
expect(data_get($domains, 'frontend.domain'))->toBe('https://app.example.com')
->and($application->domain_port_overrides)->toBe([
'https://app.example.com' => 80,
]);
});
test('compose domain ports are stored as overrides when creating through the API', function () {
Queue::fake();
$response = $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/public', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'git_repository' => 'https://gitlab.com/coolify/compose-domain-port-test',
'git_branch' => 'main',
'build_pack' => 'dockercompose',
'autogenerate_domain' => false,
'docker_compose_domains' => [
['name' => 'frontend', 'domain' => 'https://app.example.com:80'],
],
])
->assertCreated();
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
$domains = json_decode($application->docker_compose_domains, true);
expect(data_get($domains, 'frontend.domain'))->toBe('https://app.example.com')
->and($application->domain_port_overrides)->toBe([
'https://app.example.com' => 80,
]);
});
test('http basic auth updates regenerate managed labels', function () {
$this->application->settings->update(['is_container_label_readonly_enabled' => true]);
$this->application->update([
'fqdn' => 'https://app.example.com',
'is_http_basic_auth_enabled' => false,
'http_basic_auth_username' => null,
'http_basic_auth_password' => null,
'custom_labels' => base64_encode('sentinel-label=true'),
]);
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'is_http_basic_auth_enabled' => true,
'http_basic_auth_username' => 'api-user',
'http_basic_auth_password' => 'api-password',
])
->assertOk();
$application = $this->application->fresh();
$labels = $application->parseContainerLabels();
expect((bool) $application->is_http_basic_auth_enabled)->toBeTrue()
->and($application->http_basic_auth_username)->toBe('api-user')
->and($application->http_basic_auth_password)->toBe('api-password')
->and($labels)->toContain('basicauth')
->and($labels)->toContain('api-user')
->and($labels)->not->toContain('sentinel-label=true');
});
test('http basic auth updates preserve user-managed labels', function () {
$this->application->settings->update(['is_container_label_readonly_enabled' => false]);
$this->application->update([
'fqdn' => 'https://app.example.com',
'custom_labels' => base64_encode('sentinel-label=true'),
]);
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'is_http_basic_auth_enabled' => true,
'http_basic_auth_username' => 'api-user',
'http_basic_auth_password' => 'api-password',
])
->assertOk();
expect(base64_decode($this->application->fresh()->custom_labels))->toBe('sentinel-label=true');
});
test('nested base64 custom labels are stored in canonical form', function () {
$labels = "traefik.enable=true\ntraefik.http.routers.web.rule=Host(`example.com`)";
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'custom_labels' => base64_encode(base64_encode($labels)),
])
->assertOk();
expect($this->application->fresh()->custom_labels)->toBe(base64_encode($labels));
});
test('deployment parsing repairs historically nested custom labels', function () {
$labels = "traefik.enable=true\ntraefik.http.routers.web.rule=Host(`example.com`)";
$this->application->update(['custom_labels' => base64_encode(base64_encode($labels))]);
expect($this->application->parseContainerLabels())->toBe($labels)
->and($this->application->fresh()->custom_labels)->toBe(base64_encode($labels));
});
test('deployment parsing keeps commas in raw custom label values', function () {
$labels = "traefik.enable=true\ntraefik.http.routers.web.middlewares=gzip,redirect-to-https";
$this->application->update(['custom_labels' => $labels]);
expect($this->application->parseContainerLabels())->toBe($labels)
->and($this->application->fresh()->custom_labels)->toBe(base64_encode($labels));
});
test('http basic auth hash settings are saved and applied to managed labels', function () {
$this->application->update([
'fqdn' => 'https://app.example.com',
'is_http_basic_auth_enabled' => true,
'http_basic_auth_username' => 'api-user',
'http_basic_auth_password' => 'api-password',
]);
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", ['http_basic_auth_bcrypt_cost' => 4])
->assertOk();
expect($this->application->fresh()->parseContainerLabels())->toContain('.basicauth.users=api-user:$2y$04$');
});
test('rejects http basic auth settings that are not supported', function (string $field, mixed $value) {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [$field => $value])
->assertUnprocessable()
->assertJsonValidationErrors($field);
})->with([
'Argon2id on a proxy without it' => ['http_basic_auth_hash_algorithm', 'argon2id'],
'unknown algorithm' => ['http_basic_auth_hash_algorithm', 'md5'],
'bcrypt cost above 14' => ['http_basic_auth_bcrypt_cost', 15],
'Argon2id memory above 256 MiB' => ['http_basic_auth_argon2id_memory_cost', 262145],
'Argon2id iterations above 12' => ['http_basic_auth_argon2id_time_cost', 13],
'password longer than 72 characters' => ['http_basic_auth_password', str_repeat('a', 73)],
]);
test('rejects invalid boolean application settings', function () {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'disable_build_cache' => 'not-a-boolean',
])
->assertUnprocessable()
->assertJsonValidationErrors('disable_build_cache');
});
test('validates stop grace period bounds', function (int $stopGracePeriod) {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'stop_grace_period' => $stopGracePeriod,
])
->assertUnprocessable()
->assertJsonValidationErrors('stop_grace_period');
})->with([
'below minimum' => 0,
'above maximum' => 3601,
]);
test('validates rolling update drain time bounds', function (int $seconds) {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'rolling_update_drain_seconds' => $seconds,
])
->assertUnprocessable()
->assertJsonValidationErrors('rolling_update_drain_seconds');
})->with([
'below minimum' => -1,
'above maximum' => 301,
]);
test('validates Docker image retention bounds', function (int $dockerImagesToKeep) {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'docker_images_to_keep' => $dockerImagesToKeep,
])
->assertUnprocessable()
->assertJsonValidationErrors('docker_images_to_keep');
})->with([
'below minimum' => -1,
'above maximum' => 101,
]);
test('stop grace period can be reset to null', function () {
$this->application->settings->update(['stop_grace_period' => 45]);
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'stop_grace_period' => null,
])
->assertOk();
expect($this->application->fresh()->settings->stop_grace_period)->toBeNull();
});
test('raw compose deployment can only be enabled for Docker Compose applications', function () {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'is_raw_compose_deployment_enabled' => true,
])
->assertUnprocessable()
->assertJsonValidationErrors('is_raw_compose_deployment_enabled');
});
function advancedApplicationSettingsPayload(): array
{
return [
'is_log_drain_enabled' => true,
'is_gpu_enabled' => true,
'gpu_driver' => 'nvidia',
'gpu_count' => '1',
'gpu_device_ids' => '0',
'gpu_options' => 'capabilities=compute,utility',
'is_consistent_container_name_enabled' => true,
'custom_internal_name' => 'my-app-container',
];
}
test('PATCH /api/v1/applications/{uuid} updates advanced application settings', function () {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", advancedApplicationSettingsPayload())
->assertOk();
$settings = $this->application->fresh()->settings;
foreach (advancedApplicationSettingsPayload() as $field => $value) {
expect($settings->{$field})->toBe($value);
}
});
test('PATCH /api/v1/applications/{uuid} accepts Docker-compatible custom internal names', function (string $name) {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'custom_internal_name' => $name,
'is_consistent_container_name_enabled' => true,
])
->assertOk();
expect($this->application->fresh()->settings->custom_internal_name)->toBe($name);
})->with([
'hyphens' => 'my-app-container',
'uppercase, underscores, and dots' => 'My_App.v2',
]);
test('PATCH /api/v1/applications/{uuid} rejects unsafe custom internal names', function (string $name) {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'custom_internal_name' => $name,
])
->assertUnprocessable()
->assertJsonValidationErrors('custom_internal_name');
expect($this->application->fresh()->settings->custom_internal_name)->toBeNull();
})->with([
'semicolon' => 'app;id',
'command substitution' => 'app$(id)',
'backticks' => 'app`id`',
'single quote' => "app'id",
'double quote' => 'app"id',
'space' => 'app name',
'newline' => "app\nid",
'option-like prefix' => '--help',
'pipe' => 'app|id',
'ampersand' => 'app&id',
]);
test('application creation rejects an unsafe custom internal name', function () {
Queue::fake();
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/public', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'git_repository' => 'https://gitlab.com/coolify/custom-name-test',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'custom_internal_name' => 'app$(id)',
])
->assertUnprocessable()
->assertJsonValidationErrors('custom_internal_name');
});
test('PATCH /api/v1/applications/{uuid} updates preview_url_template and max_restart_count', function () {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'preview_url_template' => '{{pr_id}}.preview.example.com',
'max_restart_count' => 5,
])
->assertOk();
$application = $this->application->fresh();
expect($application->preview_url_template)->toBe('{{pr_id}}.preview.example.com')
->and($application->max_restart_count)->toBe(5);
});
test('PATCH /api/v1/applications/{uuid} clears ports_exposes with null or an empty string', function (mixed $portsExposes) {
$this->application->update(['ports_exposes' => '3000,8080']);
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'ports_exposes' => $portsExposes,
])
->assertOk();
expect($this->application->fresh()->ports_exposes)->toBeNull();
})->with([
'null' => null,
'empty string' => '',
]);
test('PATCH /api/v1/applications/{uuid} rejects invalid exposed ports', function (string $portsExposes) {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'ports_exposes' => $portsExposes,
])
->assertUnprocessable()
->assertJsonValidationErrors('ports_exposes');
})->with([
'not numeric' => '80,abc',
'zero' => '0',
'above TCP range' => '65536',
]);
test('GET /api/v1/applications/{uuid} includes advanced settings', function () {
$this->application->settings->update(advancedApplicationSettingsPayload());
$this->application->update([
'preview_url_template' => '{{pr_id}}.preview.example.com',
'max_restart_count' => 3,
]);
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$this->application->uuid}")
->assertOk()
->assertJsonPath('settings.is_log_drain_enabled', true)
->assertJsonPath('settings.is_gpu_enabled', true)
->assertJsonPath('settings.gpu_driver', 'nvidia')
->assertJsonPath('settings.custom_internal_name', 'my-app-container')
->assertJsonPath('settings.is_consistent_container_name_enabled', true)
->assertJsonPath('preview_url_template', '{{pr_id}}.preview.example.com')
->assertJsonPath('max_restart_count', 3);
});
test('application creation accepts advanced application settings', function () {
Queue::fake();
$response = $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/public', array_merge([
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'git_repository' => 'https://gitlab.com/coolify/advanced-settings-test',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'autogenerate_domain' => false,
'preview_url_template' => '{{pr_id}}.create.example.com',
'max_restart_count' => 7,
], advancedApplicationSettingsPayload()))
->assertCreated();
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
$settings = $application->settings;
foreach (advancedApplicationSettingsPayload() as $field => $value) {
expect($settings->{$field})->toBe($value);
}
expect($application->preview_url_template)->toBe('{{pr_id}}.create.example.com')
->and($application->max_restart_count)->toBe(7);
});
test('rejects invalid max_restart_count', function () {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'max_restart_count' => -1,
])
->assertUnprocessable()
->assertJsonValidationErrors('max_restart_count');
});
test('rejects invalid gpu boolean settings', function () {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'is_gpu_enabled' => 'not-a-boolean',
])
->assertUnprocessable()
->assertJsonValidationErrors('is_gpu_enabled');
});
test('rejects swarm fields on application update', function (string $field, mixed $value) {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
$field => $value,
])
->assertUnprocessable()
->assertJsonValidationErrors($field);
})->with([
'swarm_replicas' => ['swarm_replicas', 3],
'swarm_placement_constraints' => ['swarm_placement_constraints', 'node.role==worker'],
'is_swarm_only_worker_nodes' => ['is_swarm_only_worker_nodes', true],
]);
test('PATCH /api/v1/applications/{uuid} saves a slugged container name prefix', function () {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", ['custom_container_name_prefix' => 'My API'])
->assertOk();
expect($this->application->fresh()->settings->custom_container_name_prefix)->toBe('my-api');
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$this->application->uuid}")
->assertOk()
->assertJsonPath('settings.custom_container_name_prefix', 'my-api');
});
test('PATCH /api/v1/applications/{uuid} rejects a container name prefix that is in use', function () {
$otherApplication = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$otherApplication->settings->update(['custom_container_name_prefix' => 'shared-prefix']);
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", ['custom_container_name_prefix' => 'shared-prefix'])
->assertUnprocessable()
->assertJsonValidationErrors('custom_container_name_prefix');
expect($this->application->fresh()->settings->custom_container_name_prefix)->toBeNull();
});
test('application creation rejects a non-string container name prefix with 422', function () {
Queue::fake();
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/public', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'git_repository' => 'https://gitlab.com/coolify/prefix-test',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'custom_container_name_prefix' => ['nested' => 'value'],
])
->assertUnprocessable()
->assertJsonValidationErrors('custom_container_name_prefix');
expect(Application::query()->where('git_repository', 'like', '%prefix-test%')->exists())->toBeFalse();
});
test('PATCH /api/v1/applications/{uuid} rejects a non-string container name prefix with 422', function () {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", ['custom_container_name_prefix' => ['a', 'b']])
->assertUnprocessable()
->assertJsonValidationErrors('custom_container_name_prefix');
expect($this->application->fresh()->settings->custom_container_name_prefix)->toBeNull();
});
test('PATCH /api/v1/applications/{uuid} sets the Traefik certificate resolver', function (?string $resolver) {
$this->application->settings->update(['traefik_certificate_resolver' => 'previous']);
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", ['traefik_certificate_resolver' => $resolver])
->assertOk();
expect($this->application->fresh()->settings->traefik_certificate_resolver)->toBe($resolver);
})->with([
'custom resolver' => ['cloudflare'],
'no resolver' => ['none'],
'server setting' => [null],
]);
test('PATCH /api/v1/applications/{uuid} rejects an unsafe Traefik certificate resolver', function (mixed $resolver) {
$this->application->settings->update(['traefik_certificate_resolver' => 'previous']);
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", ['traefik_certificate_resolver' => $resolver])
->assertUnprocessable()
->assertJsonValidationErrors('traefik_certificate_resolver');
expect($this->application->fresh()->settings->traefik_certificate_resolver)->toBe('previous');
})->with([
'label injection' => ['a.b=c'],
'whitespace' => ['lets encrypt'],
'non-string' => [['cloudflare']],
]);
test('application creation accepts a Traefik certificate resolver', function () {
Queue::fake();
$response = $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/public', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'git_repository' => 'https://gitlab.com/coolify/resolver-test',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'traefik_certificate_resolver' => 'none',
])
->assertCreated();
expect(Application::where('uuid', $response->json('uuid'))->firstOrFail()->settings->traefik_certificate_resolver)->toBe('none');
});
test('application creation rejects an unsafe Traefik certificate resolver', function () {
Queue::fake();
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/public', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'git_repository' => 'https://gitlab.com/coolify/resolver-test',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'traefik_certificate_resolver' => 'a.b=c',
])
->assertUnprocessable()
->assertJsonValidationErrors('traefik_certificate_resolver');
expect(Application::query()->where('git_repository', 'like', '%resolver-test%')->exists())->toBeFalse();
});
test('PATCH /api/v1/applications/{uuid} turns off label management and keeps custom labels with new domains', function () {
$this->application->settings->update(['is_container_label_readonly_enabled' => true]);
$this->application->update([
'fqdn' => 'https://app.example.com',
'custom_labels' => base64_encode('sentinel-label=true'),
]);
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'is_container_label_readonly_enabled' => false,
'domains' => 'https://new.example.com',
])
->assertOk();
$application = $this->application->fresh();
expect($application->settings->is_container_label_readonly_enabled)->toBeFalse()
->and($application->fqdn)->toBe('https://new.example.com')
->and(base64_decode($application->custom_labels))->toBe('sentinel-label=true');
});
test('PATCH /api/v1/applications/{uuid} regenerates labels when label management is turned back on', function () {
$this->application->settings->update(['is_container_label_readonly_enabled' => false]);
$this->application->update([
'fqdn' => 'https://app.example.com',
'custom_labels' => base64_encode('sentinel-label=true'),
]);
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'is_container_label_readonly_enabled' => true,
])
->assertOk();
$application = $this->application->fresh();
$labels = $application->parseContainerLabels();
expect($application->settings->is_container_label_readonly_enabled)->toBeTrue()
->and($labels)->not->toContain('sentinel-label=true')
->and($labels)->toContain('app.example.com');
});
test('application creation keeps custom labels when label management is off', function () {
Queue::fake();
$response = $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/public', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'git_repository' => 'https://gitlab.com/coolify/readonly-labels-test',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'domains' => 'https://app.example.com',
'custom_labels' => base64_encode('sentinel-label=true'),
'is_container_label_readonly_enabled' => false,
])
->assertCreated();
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
expect($application->settings->is_container_label_readonly_enabled)->toBeFalse()
->and(base64_decode($application->custom_labels))->toBe('sentinel-label=true');
});
test('rejects an invalid is_container_label_readonly_enabled value', function () {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'is_container_label_readonly_enabled' => 'not-a-boolean',
])
->assertUnprocessable()
->assertJsonValidationErrors('is_container_label_readonly_enabled');
});