diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 074269fa0..0bef2dc0f 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -17,6 +17,7 @@ use App\Models\LocalPersistentVolume; use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; +use App\Rules\DockerImageFormat; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Services\DockerImageParser; @@ -1790,8 +1791,8 @@ class ApplicationsController extends Controller ]))->setStatusCode(201); } elseif ($type === 'dockerimage') { $validationRules = [ - 'docker_registry_image_name' => 'string|required', - 'docker_registry_image_tag' => 'string', + 'docker_registry_image_name' => ['required', 'string', 'max:255', new DockerImageFormat], + 'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(), 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', ]; $validationRules = array_merge(sharedDataApplications(), $validationRules); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 2edc08235..c8fc20a69 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -220,6 +220,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->restart_only = $this->restart_only && $this->application->build_pack !== 'dockerimage' && $this->application->build_pack !== 'dockerfile'; $this->only_this_server = $this->application_deployment_queue->only_this_server; $this->dockerImagePreviewTag = $this->application_deployment_queue->docker_registry_image_tag; + $this->validateDockerRegistryImageConfiguration(); $this->git_type = data_get($this->application_deployment_queue, 'git_type'); @@ -1139,6 +1140,21 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $this->pull_request_id === 0; } + private function validateDockerRegistryImageConfiguration(): void + { + if (! ValidationPatterns::isValidDockerImageName($this->application->docker_registry_image_name)) { + throw new DeploymentException('Docker registry image name contains invalid characters.'); + } + + if (! ValidationPatterns::isValidDockerImageTag($this->application->docker_registry_image_tag)) { + throw new DeploymentException('Docker registry image tag contains invalid characters.'); + } + + if (! ValidationPatterns::isValidDockerImageTag($this->dockerImagePreviewTag)) { + throw new DeploymentException('Docker registry preview image tag contains invalid characters.'); + } + } + private function generate_image_names() { if ($this->application->dockerfile) { diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 258b54eed..2a64b3b21 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -157,8 +157,8 @@ class General extends Component 'portsMappings' => ValidationPatterns::portMappingRules(), 'customNetworkAliases' => 'nullable', 'dockerfile' => 'nullable', - 'dockerRegistryImageName' => 'nullable', - 'dockerRegistryImageTag' => 'nullable', + 'dockerRegistryImageName' => ValidationPatterns::dockerImageNameRules(), + 'dockerRegistryImageTag' => ValidationPatterns::dockerImageTagRules(), 'dockerfileLocation' => ValidationPatterns::filePathRules(), 'dockerComposeLocation' => ValidationPatterns::filePathRules(), 'dockerCompose' => 'nullable', @@ -848,7 +848,7 @@ class General extends Component } if ($this->buildPack === 'dockerimage') { $this->validate([ - 'dockerRegistryImageName' => 'required', + 'dockerRegistryImageName' => ValidationPatterns::dockerImageNameRules(required: true), ]); } diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php index b89ce2c6a..737806cb8 100644 --- a/app/Livewire/Project/New/DockerImage.php +++ b/app/Livewire/Project/New/DockerImage.php @@ -5,6 +5,7 @@ namespace App\Livewire\Project\New; use App\Models\Application; use App\Models\Project; use App\Services\DockerImageParser; +use App\Support\ValidationPatterns; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -81,8 +82,8 @@ class DockerImage extends Component public function submit() { $this->validate([ - 'imageName' => ['required', 'string'], - 'imageTag' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9._-]*$/i'], + 'imageName' => ValidationPatterns::dockerImageNameRules(required: true), + 'imageTag' => ValidationPatterns::dockerImageTagRules(), 'imageSha256' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'], ]); diff --git a/app/Rules/DockerImageFormat.php b/app/Rules/DockerImageFormat.php index a6a78a76c..038cc2761 100644 --- a/app/Rules/DockerImageFormat.php +++ b/app/Rules/DockerImageFormat.php @@ -2,18 +2,26 @@ namespace App\Rules; +use App\Support\ValidationPatterns; use Closure; use Illuminate\Contracts\Validation\ValidationRule; +use Illuminate\Translation\PotentiallyTranslatedString; class DockerImageFormat implements ValidationRule { /** * Run the validation rule. * - * @param \Closure(string, ?string=): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @param Closure(string, ?string=): PotentiallyTranslatedString $fail */ public function validate(string $attribute, mixed $value, Closure $fail): void { + if (! is_string($value)) { + $fail('The :attribute format is invalid. Use image:tag or image@sha256:hash format.'); + + return; + } + // Check if the value contains ":sha256:" or ":sha" which is incorrect format if (preg_match('/:sha256?:/i', $value)) { $fail('The :attribute must use @ before sha256 digest (e.g., image@sha256:hash, not image:sha256:hash).'); @@ -21,20 +29,21 @@ class DockerImageFormat implements ValidationRule return; } - // Valid formats: - // 1. image:tag (e.g., nginx:latest) - // 2. registry/image:tag (e.g., ghcr.io/user/app:v1.2.3) - // 3. image@sha256:hash (e.g., nginx@sha256:abc123...) - // 4. registry/image@sha256:hash - // 5. registry:port/image:tag (e.g., localhost:5000/app:latest) + $imageName = $value; + $tag = null; - $pattern = '/^ - (?:[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[0-9]+)?\/)? # Optional registry with optional port - [a-z0-9]+(?:[._\/-][a-z0-9]+)* # Image name (required) - (?::[a-z0-9][a-z0-9._-]*|@sha256:[a-f0-9]{64})? # Optional :tag or @sha256:hash - $/ix'; + if (preg_match('/\A(.+)@sha256:([a-f0-9]{64})\z/i', $value, $matches) === 1) { + $imageName = $matches[1]; + } else { + $lastColon = strrpos($value, ':'); + $lastSlash = strrpos($value, '/'); + if ($lastColon !== false && ($lastSlash === false || $lastColon > $lastSlash)) { + $imageName = substr($value, 0, $lastColon); + $tag = substr($value, $lastColon + 1); + } + } - if (! preg_match($pattern, $value)) { + if (! ValidationPatterns::isValidDockerImageName($imageName) || ! ValidationPatterns::isValidDockerImageTag($tag)) { $fail('The :attribute format is invalid. Use image:tag or image@sha256:hash format.'); } } diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 07926e1cf..54397d4f1 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -102,6 +102,23 @@ class ValidationPatterns */ public const DB_PASSWORD_PATTERN = '/^[A-Za-z0-9!@#%^*()_+\-=\[\]{}:,.?\/~]+$/'; + /** + * Pattern for Docker image repository names without a tag. + * + * Allows an optional registry host/port followed by lowercase repository + * path components. A trailing @sha256 marker is accepted for existing + * digest-based dockerimage records that store the digest hash separately. + */ + public const DOCKER_IMAGE_NAME_PATTERN = '/\A(?=.{1,255}\z)(?:(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]+)?\/)?[a-z0-9]+(?:(?:[._-]|__)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._-]|__)[a-z0-9]+)*)*)(?:@sha256)?\z/'; + + /** + * Pattern for Docker image tags. + * + * Docker tags may contain letters, digits, underscores, dots, and hyphens, + * must start with an alphanumeric/underscore, and are limited to 128 chars. + */ + public const DOCKER_IMAGE_TAG_PATTERN = '/\A[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}\z/'; + /** * Normalize environment variable keys before validation and storage. */ @@ -163,6 +180,81 @@ class ValidationPatterns return $key; } + /** + * Get validation rules for Docker image repository names without tags. + */ + public static function dockerImageNameRules(bool $required = false, int $maxLength = 255): array + { + $rules = []; + + if ($required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + $rules[] = 'string'; + $rules[] = "max:$maxLength"; + $rules[] = 'regex:'.self::DOCKER_IMAGE_NAME_PATTERN; + + return $rules; + } + + /** + * Get validation rules for Docker image tags. + */ + public static function dockerImageTagRules(bool $required = false, int $maxLength = 128): array + { + $rules = []; + + if ($required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + $rules[] = 'string'; + $rules[] = "max:$maxLength"; + $rules[] = 'regex:'.self::DOCKER_IMAGE_TAG_PATTERN; + + return $rules; + } + + /** + * Get validation messages for Docker image fields. + */ + public static function dockerImageMessages(string $nameField = 'docker_registry_image_name', string $tagField = 'docker_registry_image_tag'): array + { + return [ + "{$nameField}.regex" => 'The Docker registry image name must be a valid image repository without a tag and may not contain shell metacharacters.', + "{$tagField}.regex" => 'The Docker registry image tag must be a valid Docker tag and may not contain shell metacharacters.', + ]; + } + + /** + * Check if a string is a valid Docker image repository name without a tag. + */ + public static function isValidDockerImageName(?string $value): bool + { + if (blank($value)) { + return true; + } + + return preg_match(self::DOCKER_IMAGE_NAME_PATTERN, $value) === 1; + } + + /** + * Check if a string is a valid Docker image tag. + */ + public static function isValidDockerImageTag(?string $value): bool + { + if (blank($value)) { + return true; + } + + return preg_match(self::DOCKER_IMAGE_TAG_PATTERN, $value) === 1; + } + /** * Get validation rules for database identifier fields (username, database name). * diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 4b76200d2..656daf08c 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -3,6 +3,7 @@ use App\Enums\BuildPackTypes; use App\Enums\RedirectTypes; use App\Enums\StaticImageTypes; +use App\Support\ValidationPatterns; use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\Request; use Illuminate\Validation\Rule; @@ -93,16 +94,16 @@ function sharedDataApplications() 'domains' => 'string|nullable', 'redirect' => Rule::enum(RedirectTypes::class), 'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], - 'docker_registry_image_name' => 'string|nullable', - 'docker_registry_image_tag' => 'string|nullable', - 'install_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), - 'build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), - 'start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), + 'docker_registry_image_name' => ValidationPatterns::dockerImageNameRules(), + 'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(), + 'install_command' => ValidationPatterns::shellSafeCommandRules(), + 'build_command' => ValidationPatterns::shellSafeCommandRules(), + 'start_command' => ValidationPatterns::shellSafeCommandRules(), 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/', 'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable', 'custom_network_aliases' => 'string|nullable', - 'base_directory' => \App\Support\ValidationPatterns::directoryPathRules(), - 'publish_directory' => \App\Support\ValidationPatterns::directoryPathRules(), + 'base_directory' => ValidationPatterns::directoryPathRules(), + 'publish_directory' => ValidationPatterns::directoryPathRules(), 'health_check_enabled' => 'boolean', 'health_check_type' => 'string|in:http,cmd', 'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'], @@ -125,24 +126,24 @@ function sharedDataApplications() 'limits_cpuset' => 'string|nullable', 'limits_cpu_shares' => 'numeric', 'custom_labels' => 'string|nullable', - 'custom_docker_run_options' => \App\Support\ValidationPatterns::shellSafeCommandRules(2000), + 'custom_docker_run_options' => ValidationPatterns::shellSafeCommandRules(2000), // Security: deployment commands are intentionally arbitrary shell (e.g. "php artisan migrate"). // Access is gated by API token authentication. Commands run inside the app container, not the host. 'post_deployment_command' => 'string|nullable', - 'post_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(), + 'post_deployment_command_container' => ValidationPatterns::containerNameRules(), 'pre_deployment_command' => 'string|nullable', - 'pre_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(), + 'pre_deployment_command_container' => ValidationPatterns::containerNameRules(), 'manual_webhook_secret_github' => 'string|nullable', 'manual_webhook_secret_gitlab' => 'string|nullable', 'manual_webhook_secret_bitbucket' => 'string|nullable', 'manual_webhook_secret_gitea' => 'string|nullable', - 'dockerfile_location' => \App\Support\ValidationPatterns::filePathRules(), - 'dockerfile_target_build' => \App\Support\ValidationPatterns::dockerTargetRules(), - 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(), + 'dockerfile_location' => ValidationPatterns::filePathRules(), + 'dockerfile_target_build' => ValidationPatterns::dockerTargetRules(), + 'docker_compose_location' => ValidationPatterns::filePathRules(), 'docker_compose' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', - 'docker_compose_custom_start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), - 'docker_compose_custom_build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), + 'docker_compose_custom_start_command' => ValidationPatterns::shellSafeCommandRules(), + 'docker_compose_custom_build_command' => ValidationPatterns::shellSafeCommandRules(), 'is_container_label_escape_enabled' => 'boolean', 'is_preserve_repository_enabled' => 'boolean', ]; diff --git a/tests/Feature/ApplicationDockerRegistryImageValidationApiTest.php b/tests/Feature/ApplicationDockerRegistryImageValidationApiTest.php new file mode 100644 index 000000000..852b537ec --- /dev/null +++ b/tests/Feature/ApplicationDockerRegistryImageValidationApiTest.php @@ -0,0 +1,108 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'docker-registry-validation-api-test-'.Str::random(6), + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + $this->bearerToken = $token->getKey().'|'.$plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::factory()->create([ + 'server_id' => $this->server->id, + 'network' => 'coolify-'.Str::lower(Str::random(8)), + ]); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function dockerRegistryApiHeaders(string $bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +function makeDockerRegistryValidationApplication(array $overrides = []): Application +{ + return Application::factory()->create(array_merge([ + 'environment_id' => test()->environment->id, + 'destination_id' => test()->destination->id, + 'destination_type' => test()->destination->getMorphClass(), + 'build_pack' => 'nixpacks', + 'docker_registry_image_name' => 'ghcr.io/coollabsio/example', + 'docker_registry_image_tag' => 'latest', + ], $overrides)); +} + +describe('PATCH /api/v1/applications/{uuid} docker registry image validation', function () { + test('rejects shell metacharacters in docker registry image name without persisting them', function () { + $application = makeDockerRegistryValidationApplication(); + + $response = $this->withHeaders(dockerRegistryApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$application->uuid}", [ + 'docker_registry_image_name' => 'coolify/poc$(touch /tmp/pwned)', + 'docker_registry_image_tag' => 'latest', + ]); + + $response->assertUnprocessable() + ->assertInvalid(['docker_registry_image_name']); + + $application->refresh(); + expect($application->docker_registry_image_name)->toBe('ghcr.io/coollabsio/example') + ->and($application->docker_registry_image_tag)->toBe('latest'); + }); + + test('rejects shell metacharacters in docker registry image tag without persisting them', function () { + $application = makeDockerRegistryValidationApplication(); + + $response = $this->withHeaders(dockerRegistryApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$application->uuid}", [ + 'docker_registry_image_name' => 'ghcr.io/coollabsio/example', + 'docker_registry_image_tag' => 'latest$(touch /tmp/pwned)', + ]); + + $response->assertUnprocessable() + ->assertInvalid(['docker_registry_image_tag']); + + $application->refresh(); + expect($application->docker_registry_image_name)->toBe('ghcr.io/coollabsio/example') + ->and($application->docker_registry_image_tag)->toBe('latest'); + }); + + test('accepts valid docker registry image values', function () { + $application = makeDockerRegistryValidationApplication(); + + $response = $this->withHeaders(dockerRegistryApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$application->uuid}", [ + 'docker_registry_image_name' => 'registry.example.com:5000/team/app', + 'docker_registry_image_tag' => 'v1.2.3', + ]); + + $response->assertOk(); + + $application->refresh(); + expect($application->docker_registry_image_name)->toBe('registry.example.com:5000/team/app') + ->and($application->docker_registry_image_tag)->toBe('v1.2.3'); + }); +}); diff --git a/tests/Feature/Livewire/ApplicationGeneralDockerRegistryImageValidationTest.php b/tests/Feature/Livewire/ApplicationGeneralDockerRegistryImageValidationTest.php new file mode 100644 index 000000000..34c889837 --- /dev/null +++ b/tests/Feature/Livewire/ApplicationGeneralDockerRegistryImageValidationTest.php @@ -0,0 +1,21 @@ +invoke($component); + + $validator = validator([ + 'dockerRegistryImageName' => 'coolify/poc$(touch /tmp/pwned)', + 'dockerRegistryImageTag' => 'latest$(touch /tmp/pwned)', + ], [ + 'dockerRegistryImageName' => $rules['dockerRegistryImageName'], + 'dockerRegistryImageTag' => $rules['dockerRegistryImageTag'], + ]); + + expect($validator->fails())->toBeTrue() + ->and($validator->errors()->has('dockerRegistryImageName'))->toBeTrue() + ->and($validator->errors()->has('dockerRegistryImageTag'))->toBeTrue(); +}); diff --git a/tests/Unit/DockerImageReferenceValidationTest.php b/tests/Unit/DockerImageReferenceValidationTest.php new file mode 100644 index 000000000..d6bf2ebd5 --- /dev/null +++ b/tests/Unit/DockerImageReferenceValidationTest.php @@ -0,0 +1,108 @@ +toBeTrue(); +})->with([ + 'single component' => 'nginx', + 'namespace image' => 'library/nginx', + 'ghcr image' => 'ghcr.io/coollabsio/coolify', + 'registry with port' => 'registry.example.com:5000/team/app', + 'digest marker used by existing dockerimage records' => 'nginx@sha256', +]); + +it('rejects docker registry image names with shell metacharacters', function (string $imageName) { + expect(ValidationPatterns::isValidDockerImageName($imageName))->toBeFalse(); +})->with([ + 'command substitution' => 'coolify/poc$(touch /tmp/pwned)', + 'semicolon' => 'coolify/poc;id', + 'backticks' => 'coolify/poc`id`', + 'pipe' => 'coolify/poc|id', + 'logical and' => 'coolify/poc&&id', + 'newline' => "coolify/poc\nid", + 'space' => 'coolify/poc image', + 'tag in image-name-only field' => 'coolify/poc:latest', +]); + +it('accepts valid docker registry image tags', function (string $tag) { + expect(ValidationPatterns::isValidDockerImageTag($tag))->toBeTrue(); +})->with([ + 'latest' => 'latest', + 'version' => 'v1.2.3', + 'uppercase and underscore' => 'PR_123', + 'sha256 hash' => '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + 'legacy sha256 prefixed hash' => 'sha256-1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', +]); + +it('rejects docker registry image tags with shell metacharacters', function (string $tag) { + expect(ValidationPatterns::isValidDockerImageTag($tag))->toBeFalse(); +})->with([ + 'command substitution' => 'latest$(touch /tmp/pwned)', + 'semicolon' => 'latest;id', + 'backticks' => 'latest`id`', + 'pipe' => 'latest|id', + 'logical and' => 'latest&&id', + 'newline' => "latest\nid", +]); + +it('accepts supported full docker image reference formats', function (string $imageReference) { + $failures = []; + + (new DockerImageFormat)->validate('image', $imageReference, function (string $message) use (&$failures): void { + $failures[] = $message; + }); + + expect($failures)->toBeEmpty(); +})->with([ + 'image with tag' => 'nginx:latest', + 'registry image with tag' => 'ghcr.io/user/app:v1.2.3', + 'image with sha256 digest' => 'nginx@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + 'registry image with sha256 digest' => 'ghcr.io/user/app@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + 'registry port image with tag' => 'localhost:5000/app:latest', +]); + +it('rejects unsupported full docker image reference formats', function (string $imageReference) { + $failures = []; + + (new DockerImageFormat)->validate('image', $imageReference, function (string $message) use (&$failures): void { + $failures[] = $message; + }); + + expect($failures)->not->toBeEmpty(); +})->with([ + 'colon sha256 marker' => 'nginx:sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + 'command substitution' => 'nginx:latest$(touch /tmp/pwned)', + 'newline' => "nginx:latest\nid", +]); + +it('stops deployments when a stored docker registry image value is unsafe', function () { + $job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor(); + + $application = new Application([ + 'docker_registry_image_name' => 'coolify/poc$(touch /tmp/pwned)', + 'docker_registry_image_tag' => 'latest', + ]); + $deploymentQueue = new ApplicationDeploymentQueue([ + 'docker_registry_image_tag' => null, + ]); + + $jobReflection = new ReflectionClass($job); + foreach ([ + 'application' => $application, + 'application_deployment_queue' => $deploymentQueue, + 'dockerImagePreviewTag' => null, + ] as $property => $value) { + $reflectionProperty = $jobReflection->getProperty($property); + $reflectionProperty->setValue($job, $value); + } + + $method = $jobReflection->getMethod('validateDockerRegistryImageConfiguration'); + + expect(fn () => $method->invoke($job))->toThrow(DeploymentException::class); +});