From 778a6985531854f87134b3907d0d8a633f648c9c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:01:48 +0200 Subject: [PATCH] fix(api): align service domain validation with UI rules --- .../Api/ServiceApplicationsController.php | 3 +- .../Controllers/Api/ServicesController.php | 6 +- .../ApplicationDomainValidationApiTest.php | 86 +++++++++++++++++++ tests/Feature/ServiceApplicationsApiTest.php | 33 +++++++ 4 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/Api/ApplicationDomainValidationApiTest.php diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php index e8446467de..5bf51bc027 100644 --- a/app/Http/Controllers/Api/ServiceApplicationsController.php +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -9,6 +9,7 @@ use App\Actions\Service\UpdateServiceApplicationFromApi; use App\Http\Controllers\Controller; use App\Models\Service; use App\Models\ServiceApplication; +use App\Support\ValidationPatterns; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Collection; @@ -333,7 +334,7 @@ class ServiceApplicationsController extends Controller ]; $validationRules = [ - 'url' => 'nullable|string', + 'url' => ValidationPatterns::applicationDomainRules(), 'noindex_domains' => 'sometimes|array|nullable', 'noindex_domains.*' => 'string', 'human_name' => 'nullable|string|max:255', diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 9bfcfd8539..1a199c07ec 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -386,7 +386,7 @@ class ServicesController extends Controller 'urls' => 'array|nullable', 'urls.*' => 'array:name,url', 'urls.*.name' => 'string|required', - 'urls.*.url' => 'string|nullable', + 'urls.*.url' => ValidationPatterns::applicationDomainRules(), 'force_domain_override' => 'boolean', 'is_container_label_escape_enabled' => 'boolean', 'tags' => 'array|nullable', @@ -602,7 +602,7 @@ class ServicesController extends Controller 'urls' => 'array|nullable', 'urls.*' => 'array:name,url', 'urls.*.name' => 'string|required', - 'urls.*.url' => 'string|nullable', + 'urls.*.url' => ValidationPatterns::applicationDomainRules(), 'force_domain_override' => 'boolean', 'is_container_label_escape_enabled' => 'boolean', 'tags' => 'array|nullable', @@ -1187,7 +1187,7 @@ class ServicesController extends Controller 'urls' => 'array|nullable', 'urls.*' => 'array:name,url', 'urls.*.name' => 'string|required', - 'urls.*.url' => 'string|nullable', + 'urls.*.url' => ValidationPatterns::applicationDomainRules(), 'force_domain_override' => 'boolean', 'is_container_label_escape_enabled' => 'boolean', ]; diff --git a/tests/Feature/Api/ApplicationDomainValidationApiTest.php b/tests/Feature/Api/ApplicationDomainValidationApiTest.php new file mode 100644 index 0000000000..88533ce760 --- /dev/null +++ b/tests/Feature/Api/ApplicationDomainValidationApiTest.php @@ -0,0 +1,86 @@ + '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->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + + StandaloneDocker::withoutEvents(function () { + $this->destination = $this->server->standaloneDockers()->firstOrCreate( + ['network' => 'coolify'], + ['uuid' => (string) new Cuid2, 'name' => 'test-docker'] + ); + }); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + + // Project boot event auto-creates a 'production' environment + $this->environment = $this->project->environments()->first(); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); +}); + +it('rejects wildcard domains on application updates regardless of proxy configuration', function (string $proxyType) { + $this->server->proxy = ['type' => $proxyType]; + $this->server->save(); + $originalDomain = $this->application->fqdn; + + $this->withToken($this->bearerToken) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'domains' => 'https://*.example.com', + 'force_domain_override' => true, + ]) + ->assertUnprocessable() + ->assertJsonPath('message', 'Validation failed.') + ->assertJsonPath('errors', fn (array $errors): bool => collect($errors)->flatten()->contains('Invalid URL: https://*.example.com')); + + expect($this->application->fresh()->fqdn)->toBe($originalDomain); +})->with([ProxyTypes::NONE->value, ProxyTypes::TRAEFIK->value]); + +it('rejects wildcard domains on application creation', function (string $proxyType) { + $this->server->proxy = ['type' => $proxyType]; + $this->server->save(); + $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + $count = Application::count(); + + $this->withToken($this->bearerToken) + ->postJson('/api/v1/applications/dockerimage', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'docker_registry_image_name' => 'nginx', + 'ports_exposes' => '80', + 'domains' => 'https://*.example.com', + ]) + ->assertUnprocessable() + ->assertJsonPath('message', 'Validation failed.') + ->assertJsonPath('errors', fn (array $errors): bool => collect($errors)->flatten()->contains('Invalid URL: https://*.example.com')); + + expect(Application::count())->toBe($count); +})->with([ProxyTypes::NONE->value, ProxyTypes::TRAEFIK->value]); diff --git a/tests/Feature/ServiceApplicationsApiTest.php b/tests/Feature/ServiceApplicationsApiTest.php index d6c687fbc5..3d7309ee32 100644 --- a/tests/Feature/ServiceApplicationsApiTest.php +++ b/tests/Feature/ServiceApplicationsApiTest.php @@ -410,3 +410,36 @@ describe('GET /api/v1/services/{uuid}/applications/{app_uuid}/logs', function () $response->assertJsonFragment(['message' => 'Server is not functional.']); }); }); + +it('applies UI domain validation to service API updates', function (string $target, string $url) { + $ctx = createServiceWithApplicationForApiTest($this); + $ctx->serviceApplication->update(['fqdn' => 'https://original.example.com']); + $isApplication = $target === 'application'; + $path = $isApplication + ? "/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}" + : "/api/v1/services/{$ctx->service->uuid}"; + $payload = $isApplication ? ['url' => $url] : ['urls' => [['name' => 'web', 'url' => $url]]]; + + $this->withToken($this->bearerToken)->patchJson($path, $payload) + ->assertUnprocessable() + ->assertJsonValidationErrors($isApplication ? 'url' : 'urls.0.url'); + + expect($ctx->serviceApplication->fresh()->fqdn)->toBe('https://original.example.com'); +})->with(['application', 'service'])->with([ + 'wildcard' => 'https://*.example.com', + 'too long' => 'https://example.com/'.str_repeat('a', 2048), +]); + +it('rejects oversized service domains before creating a service', function () { + $count = Service::count(); + + $this->withToken($this->bearerToken)->postJson('/api/v1/services', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'docker_compose_raw' => base64_encode("services:\n web:\n image: nginx:alpine\n"), + 'urls' => [['name' => 'web', 'url' => 'https://example.com/'.str_repeat('a', 2048)]], + ])->assertUnprocessable()->assertJsonValidationErrors('urls.0.url'); + + expect(Service::count())->toBe($count); +});