envRows = $envRows; } public function listApplicationEnvs(string $uuid): array { return $this->envRows; } public function deleteApplicationEnv(string $uuid, string $envUuid): array { $this->deleted[] = [$uuid, $envUuid]; return ['message' => 'deleted']; } } it('redacts sensitive release timeline payload fields recursively', function (): void { $payload = [ 'Authorization' => 'Bearer secret-token', 'nested' => [ 'api_key' => 'key-value', 'safe' => 'visible', 'items' => [ ['password' => 'hidden', 'status' => 500], ], ], ]; expect(release_manager::redactPayload($payload))->toBe([ 'Authorization' => '[redacted]', 'nested' => [ 'api_key' => '[redacted]', 'safe' => 'visible', 'items' => [ ['password' => '[redacted]', 'status' => 500], ], ], ]); }); it('verifies GitHub sha256 webhook signatures', function (): void { $secret = 'release-webhook-secret'; $payload = '{"ref":"refs/heads/main"}'; $signature = 'sha256=' . hash_hmac('sha256', $payload, $secret); expect(release_manager::verifyGithubSignature($secret, $payload, $signature))->toBeTrue(); expect(release_manager::verifyGithubSignature($secret, $payload, 'sha256=bad'))->toBeFalse(); expect(release_manager::verifyGithubSignature('', $payload, $signature))->toBeFalse(); }); it('verifies CI release gate bearer tokens from dedicated release credentials', function (): void { $previous = getenv('RELEASE_MANAGER_GATE_TOKEN'); try { putenv('RELEASE_MANAGER_GATE_TOKEN=release-gate-secret'); $_SERVER['RELEASE_MANAGER_GATE_TOKEN'] = 'release-gate-secret'; $manager = new release_manager(); expect($manager->verifyReleaseGateToken('release-gate-secret'))->toBeTrue(); expect($manager->verifyReleaseGateToken('wrong-secret'))->toBeFalse(); expect($manager->verifyReleaseGateToken(''))->toBeFalse(); } finally { if ($previous === false) { putenv('RELEASE_MANAGER_GATE_TOKEN'); unset($_SERVER['RELEASE_MANAGER_GATE_TOKEN']); } else { putenv('RELEASE_MANAGER_GATE_TOKEN=' . $previous); $_SERVER['RELEASE_MANAGER_GATE_TOKEN'] = $previous; } } }); it('normalizes gate-authenticated frontend release version updates', function (): void { $release = release_manager::normalizeFrontendVersionGateInput([ 'version' => 'A' . str_repeat('b', 39), 'repository' => 'https://github.com/copenhagentruckwash/pleno-vue.git', 'branch' => 'master', 'build_id' => '30818161014-2', ]); expect($release)->toBe([ 'version' => 'a' . str_repeat('b', 39), 'repository' => 'copenhagentruckwash/pleno-vue', 'branch' => 'master', 'build_id' => '30818161014-2', ]); }); it('rejects untrusted frontend release version updates', function (array $payload, string $message): void { expect(fn () => release_manager::normalizeFrontendVersionGateInput($payload))->toThrow(RuntimeException::class, $message); })->with([ 'short commit' => [[ 'version' => 'deadbeef', 'repository' => 'copenhagentruckwash/pleno-vue', 'branch' => 'master', 'build_id' => '123-1', ], 'full commit SHA'], 'wrong repository' => [[ 'version' => str_repeat('a', 40), 'repository' => 'copenhagentruckwash/api', 'branch' => 'master', 'build_id' => '123-1', ], 'repository is not authorized'], 'wrong branch' => [[ 'version' => str_repeat('a', 40), 'repository' => 'copenhagentruckwash/pleno-vue', 'branch' => 'beta', 'build_id' => '123-1', ], 'branch must be master'], 'invalid build id' => [[ 'version' => str_repeat('a', 40), 'repository' => 'copenhagentruckwash/pleno-vue', 'branch' => 'master', 'build_id' => '../release', ], 'build id is invalid'], ]); it('normalizes app-specific release gate auto-sync metadata', function (): void { $manager = new release_manager(); $normalizeGate = new ReflectionMethod(release_manager::class, 'normalizeReleaseGateInput'); $appMatches = new ReflectionMethod(release_manager::class, 'releaseGateAppMatches'); $gate = $normalizeGate->invoke($manager, [ 'channel_slug' => 'stable', 'app' => 'api', 'repository' => 'https://github.com/copenhagentruckwash/api.git', 'branch' => 'master', 'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8', 'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123', 'auto_sync' => true, ], ['slug' => 'stable']); expect($gate)->toMatchArray([ 'channel_slug' => 'stable', 'route_slug' => 'master', 'app' => 'api', 'apps' => ['api'], 'repository' => 'copenhagentruckwash/api', 'branch' => 'master', 'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8', 'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123', 'auto_sync' => true, ]); $fullStackGate = $normalizeGate->invoke($manager, [ 'channel_slug' => 'stable', ], ['slug' => 'stable']); expect($fullStackGate['apps'])->toBe(['frontend', 'api']); expect($appMatches->invoke($manager, ['app' => 'api'], 'api'))->toBeTrue(); expect($appMatches->invoke($manager, ['apps' => ['frontend', 'api']], 'api'))->toBeTrue(); expect($appMatches->invoke($manager, [], 'frontend'))->toBeTrue(); expect($appMatches->invoke($manager, [], 'api'))->toBeFalse(); }); it('requires non-empty release gate checks before auto-sync can proceed', function (): void { $manager = new release_manager(); $method = new ReflectionMethod(release_manager::class, 'releaseGateAutoSyncValidationSteps'); $failed = $method->invoke($manager, [ 'channel_slug' => 'stable', 'app' => 'api', 'repository' => 'copenhagentruckwash/api', 'branch' => 'master', 'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8', 'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123', 'required_checks' => [], ], ['slug' => 'stable']); $failedByStep = array_column($failed, null, 'step_key'); expect($failedByStep)->toHaveKey('auto_sync_required_checks') ->and($failedByStep['auto_sync_required_checks'])->toMatchArray([ 'step_key' => 'auto_sync_required_checks', 'status' => 'failed', ]); $passed = $method->invoke($manager, [ 'channel_slug' => 'stable', 'app' => 'api', 'repository' => 'copenhagentruckwash/api', 'branch' => 'master', 'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8', 'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123', 'required_checks' => ['api_gateway'], ], ['slug' => 'stable']); $passedByStep = array_column($passed, null, 'step_key'); expect($passedByStep)->toHaveKey('auto_sync_inputs') ->and($passedByStep['auto_sync_inputs'])->toMatchArray([ 'step_key' => 'auto_sync_inputs', 'status' => 'passed', ]); }); it('extracts API ping commit metadata for release gate verification', function (): void { $manager = new release_manager(); $method = new ReflectionMethod(release_manager::class, 'releaseGateApiPayloadCommitSha'); expect($method->invoke($manager, [ 'success' => true, 'data' => [ 'message' => 'pong', 'api_commit_sha' => '327a77edf48069c14cb592f298924b0ea1aaf208', ], ]))->toBe('327a77edf48069c14cb592f298924b0ea1aaf208') ->and($method->invoke($manager, [ 'success' => true, 'data' => [ 'backend_version' => '75c19bcce44fe3f0657d84b45dbc1c89f29332b4', ], ]))->toBe('75c19bcce44fe3f0657d84b45dbc1c89f29332b4'); }); it('normalizes GitHub repository identifiers for private repository access checks', function (): void { expect(release_manager::normalizeGithubRepositoryName('truckwash/backend-php'))->toBe('truckwash/backend-php'); expect(release_manager::normalizeGithubRepositoryName('https://github.com/truckwash/front-end-vue.git'))->toBe('truckwash/front-end-vue'); expect(release_manager::normalizeGithubRepositoryName('git@github.com:truckwash/backend-php.git'))->toBe('truckwash/backend-php'); expect(release_manager::normalizeGithubRepositoryName('not a repository'))->toBe(''); }); it('keeps GitHub commit timestamps in public release manager commit payloads', function (): void { $manager = new release_manager(); $method = new ReflectionMethod(release_manager::class, 'publicGithubCommit'); $commit = $method->invoke($manager, [ 'sha' => 'feedface00000000000000000000000000000000', 'html_url' => 'https://github.com/truckwash/backend-php/commit/feedface', 'commit' => [ 'message' => "Deploy release bundle\n\nBody is intentionally omitted from option labels.", 'author' => [ 'name' => 'Release Bot', 'date' => '2026-05-19T08:10:00Z', ], ], ]); expect($commit)->toMatchArray([ 'sha' => 'feedface00000000000000000000000000000000', 'short_sha' => 'feedface0000', 'message' => 'Deploy release bundle', 'author_name' => 'Release Bot', 'authored_at' => '2026-05-19T08:10:00Z', ]); }); it('summarizes failed deployments and blocks promotion until a deployment succeeds', function (): void { $summary = release_manager::deploymentFailureSummary( new RuntimeException('Coolify API request failed: HTTP 404'), [ 'app' => 'api', 'repository' => 'copenhagentruckwash/api', 'branch' => 'master', 'commit_sha' => 'ab31cd6dbb288606a24fcdbcb7bb7d58d843e4f3', 'coolify_service_uuid' => 'api-service', ] ); expect($summary['category'])->toBe('coolify_target'); expect($summary['stage'])->toBe('provider_target'); expect($summary['promotion_blocked'])->toBeTrue(); expect($summary['evidence']['commit_sha'])->toBe('ab31cd6dbb288606a24fcdbcb7bb7d58d843e4f3'); expect(release_manager::deploymentCanBePromoted('deployed'))->toBeTrue(); expect(release_manager::deploymentCanBePromoted('failed'))->toBeFalse(); $reason = release_manager::deploymentPromotionBlockedReason([ 'status' => 'failed', 'result_json' => json_encode(['failure_summary' => $summary]), ]); expect($reason)->toContain('Deployment failed'); expect($reason)->toContain('HTTP 404'); }); it('uses the selected Coolify project and resolves server UUID from the instance default', function (): void { $manager = new release_manager(); $method = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload'); $payload = $method->invoke($manager, [ 'channel_slug' => 'internal', 'app' => 'api', 'repository' => 'copenhagentruckwash/api', 'branch' => 'master', ], [ 'coolify_project_uuid' => 'project-selected', 'coolify_deploy_now' => true, 'image' => 'ghcr.io/copenhagentruckwash/api:master', ], [ 'default_project_uuid' => 'project-default', 'default_environment_name' => 'production', 'default_server_uuid' => 'server-default', ]); expect($payload['project_uuid'])->toBe('project-selected'); expect($payload['server_uuid'])->toBe('server-default'); expect($payload['environment_name'])->toBe('internal'); expect($payload)->not->toHaveKey('environment_uuid'); expect($payload)->not->toHaveKey('coolify_server_uuid'); }); it('supports isolated stack mode and names new Coolify services explicitly', function (): void { $manager = new release_manager(); $normalizeMode = new ReflectionMethod(release_manager::class, 'normalizeServiceSetMode'); expect($normalizeMode->invoke($manager, ' isolated_stack '))->toBe('isolated_stack'); $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload'); $payload = $payloadMethod->invoke($manager, [ 'channel_slug' => 'internal', 'app' => 'frontend', 'repository' => 'copenhagentruckwash/front-end-vue', 'branch' => 'main', ], [ 'coolify_service_name' => 'release-internal-safe-stack-frontend', 'coolify_project_uuid' => 'project-internal', 'coolify_deploy_now' => true, 'image' => 'ghcr.io/copenhagentruckwash/front-end-vue:main', ], [ 'default_environment_name' => 'production', 'default_server_uuid' => 'server-default', ]); expect($payload['name'])->toBe('release-internal-safe-stack-frontend'); expect($payload['project_uuid'])->toBe('project-internal'); expect($payload['environment_name'])->toBe('internal'); expect($payload)->not->toHaveKey('type'); expect(base64_decode($payload['docker_compose_raw'], true))->toContain('ghcr.io/copenhagentruckwash/front-end-vue:main'); }); it('creates frontend Coolify GitHub App application payloads with the release Dockerfile', function (): void { $manager = new release_manager(); $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload'); $payload = $payloadMethod->invoke($manager, [ 'channel_slug' => 'canary', 'app' => 'frontend', 'repository' => 'copenhagentruckwash/pleno-vue', 'branch' => 'release/canary', 'auto_deploy' => 1, ], [ 'coolify_service_name' => 'release-canary-pleno-vue', 'coolify_project_uuid' => 'project-canary', 'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github', 'coolify_build_pack' => 'nixpacks', 'coolify_deploy_now' => true, 'coolify_public_url' => 'https://canary.example.test', 'coolify_enable_ssl' => true, ], [ 'default_environment_name' => 'production', 'default_server_uuid' => 'server-default', ]); expect($payload['name'])->toBe('release-canary-pleno-vue'); expect($payload['project_uuid'])->toBe('project-canary'); expect($payload['environment_name'])->toBe('release-canary'); expect($payload['server_uuid'])->toBe('server-default'); expect($payload['github_app_uuid'])->toBe('github-app-copenhagentruckwash-github'); expect($payload['git_repository'])->toBe('copenhagentruckwash/pleno-vue'); expect($payload['git_branch'])->toBe('release/canary'); expect($payload['build_pack'])->toBe('dockerfile'); expect($payload['ports_exposes'])->toBe('80'); expect($payload['dockerfile_location'])->toBe('/Dockerfile.coolify-frontend'); expect($payload['domains'])->toBe('https://canary.example.test/canary/frontend'); expect($payload)->not->toHaveKey('publish_directory'); expect($payload)->not->toHaveKey('is_static'); expect($payload)->not->toHaveKey('docker_compose_raw'); }); it('uses the self-contained Coolify API Dockerfile for API applications', function (): void { $manager = new release_manager(); $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload'); $payload = $payloadMethod->invoke($manager, [ 'channel_slug' => 'internal', 'app' => 'api', 'repository' => 'copenhagentruckwash/api', 'branch' => 'master', 'auto_deploy' => 1, ], [ 'coolify_service_name' => 'release-internal-api-node3-truckwash-io', 'coolify_project_uuid' => 'project-internal', 'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github', 'coolify_deploy_now' => true, 'coolify_public_url' => 'https://api-v2.truckwash.io', 'coolify_enable_ssl' => true, 'gateway_route_autoprovision' => true, ], [ 'default_environment_name' => 'production', 'default_server_uuid' => 'server-node3', ]); expect($payload['build_pack'])->toBe('dockerfile'); expect($payload['ports_exposes'])->toBe('80'); expect($payload['dockerfile_location'])->toBe('/Dockerfile.coolify-api'); expect($payload['domains'])->toBe('https://api-v2.truckwash.io:80'); expect($payload)->not->toHaveKey('publish_directory'); expect($payload)->not->toHaveKey('is_static'); }); it('creates private Coolify application payloads for cron workers', function (): void { $manager = new release_manager(); $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload'); $payload = $payloadMethod->invoke($manager, [ 'channel_slug' => 'internal', 'app' => 'cron', 'repository' => 'copenhagentruckwash/api', 'branch' => 'master', 'auto_deploy' => 0, ], [ 'coolify_service_name' => 'release-internal-cron-worker', 'coolify_project_uuid' => 'project-internal', 'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github', 'coolify_build_pack' => 'dockerfile', 'coolify_deploy_now' => true, 'coolify_start_command' => 'php index.php run cron-worker', ], [ 'default_environment_name' => 'production', 'default_server_uuid' => 'server-node3', ]); expect($payload['name'])->toBe('release-internal-cron-worker'); expect($payload['build_pack'])->toBe('dockerfile'); expect($payload['ports_exposes'])->toBe('80'); expect($payload['dockerfile_location'])->toBe('/Dockerfile.coolify-api'); expect($payload['start_command'])->toBe('php index.php run cron-worker'); expect($payload['is_auto_deploy_enabled'])->toBeFalse(); expect($payload)->not->toHaveKey('domains'); expect($payload)->not->toHaveKey('is_force_https_enabled'); }); it('derives cron worker deployment context from the API target without public routing', function (): void { $manager = new release_manager(); $contextMethod = new ReflectionMethod(release_manager::class, 'cronWorkerDeployContext'); $context = $contextMethod->invoke($manager, [ 'id' => 17, 'channel_id' => 3, 'channel_slug' => 'internal', 'deploy_context_json' => json_encode([ 'coolify_project_uuid' => 'project-internal', 'coolify_environment_name' => 'production', 'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github', 'coolify_public_url' => 'https://api-v2.truckwash.io', 'manual_endpoint_host' => 'manual.example.test', ]), ], null, '5555555555555555555555555555555555555555', 41); expect($context['coolify_auto_create'])->toBeTrue(); expect($context['coolify_resource_type'])->toBe('application'); expect($context['coolify_build_pack'])->toBe('dockerfile'); expect($context['coolify_dockerfile_location'])->toBe('/Dockerfile.coolify-api'); expect($context['coolify_start_command'])->toBe('php index.php run cron-worker'); expect($context['coolify_is_auto_deploy_enabled'])->toBeFalse(); expect($context['coolify_enable_ssl'])->toBeFalse(); expect($context['coolify_service_name'])->toBe('release-internal-cron-worker'); expect($context['coolify_git_commit_sha'])->toBe('5555555555555555555555555555555555555555'); expect($context['coolify_env']['CRON_WORKER_RELEASE_CHANNEL_ID'])->toBe('3'); expect($context['coolify_env']['CRON_WORKER_RELEASE_TARGET_ID'])->toBe('41'); expect($context['coolify_env']['CRON_WORKER_SOURCE'])->toBe('coolify_worker'); expect($context)->not->toHaveKey('coolify_public_url'); expect($context)->not->toHaveKey('manual_endpoint_host'); }); it('requires Coolify cron worker autoprovisioning for API deployments by default', function (): void { $manager = new release_manager(); $enabledMethod = new ReflectionMethod(release_manager::class, 'cronWorkerAutoprovisionEnabled'); $requiredMethod = new ReflectionMethod(release_manager::class, 'cronWorkerAutoprovisionRequired'); expect($enabledMethod->invoke($manager, ['deploy_context_json' => null]))->toBeTrue(); expect($requiredMethod->invoke($manager, ['deploy_context_json' => null]))->toBeTrue(); $optionalTarget = [ 'deploy_context_json' => json_encode([ 'cron_worker_autoprovision_required' => false, ]), ]; expect($enabledMethod->invoke($manager, $optionalTarget))->toBeTrue(); expect($requiredMethod->invoke($manager, $optionalTarget))->toBeFalse(); $disabledTarget = [ 'deploy_context_json' => json_encode([ 'cron_worker_autoprovision' => false, ]), ]; expect($enabledMethod->invoke($manager, $disabledTarget))->toBeFalse(); expect($requiredMethod->invoke($manager, $disabledTarget))->toBeFalse(); $managerSource = file_get_contents(app_path('classes/release_manager.php')); expect($managerSource)->toContain('Cron worker deployment is required for API deployments'); }); it('classifies cron worker deployment and heartbeat lifecycle states', function (): void { $manager = new release_manager(); $healthMethod = new ReflectionMethod(release_manager::class, 'cronWorkerHealth'); expect($healthMethod->invoke($manager, null, null, [], ['running' => 0, 'stale' => 0, 'failed' => 0], null)['state']) ->toBe('needs_deploy'); expect($healthMethod->invoke($manager, ['id' => 4], null, [], ['running' => 0, 'stale' => 0, 'failed' => 0], null)['state']) ->toBe('needs_deploy'); expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [], ['running' => 0, 'stale' => 0, 'failed' => 0], [ 'status' => 'deploying', 'created_at' => date('Y-m-d H:i:s'), ])['state'])->toBe('deploying'); expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [], ['running' => 0, 'stale' => 0, 'failed' => 0], [ 'status' => 'deployed', 'completed_at' => date('Y-m-d H:i:s'), ])['state'])->toBe('waiting_for_heartbeat'); expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [ ['status' => 'running', 'stale' => false], ], ['running' => 1, 'stale' => 0, 'failed' => 0], [ 'status' => 'deployed', ])['state'])->toBe('healthy'); expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [], ['running' => 0, 'stale' => 0, 'failed' => 0], [ 'status' => 'deployed', 'completed_at' => '2020-01-01 00:00:00', ])['state'])->toBe('failed'); }); it('extracts Coolify cron deployment operation identifiers from provider payloads', function (): void { $manager = new release_manager(); $operationMethod = new ReflectionMethod(release_manager::class, 'coolifyDeploymentOperationId'); expect($operationMethod->invoke($manager, ['deployment' => ['deployment_uuid' => 'deployment-123']])) ->toBe('deployment-123'); expect($operationMethod->invoke($manager, ['data' => ['uuid' => 'operation-456']])) ->toBe('operation-456'); expect($operationMethod->invoke($manager, ['message' => 'queued'])) ->toBeNull(); }); it('detects missing Coolify cron worker resources from provider errors', function (): void { $method = new ReflectionMethod(release_manager::class, 'coolifyResourceMissing'); expect($method->invoke(null, new RuntimeException('Coolify API request failed: HTTP 404')))->toBeTrue(); expect($method->invoke(null, new RuntimeException('Application not found')))->toBeTrue(); expect($method->invoke(null, new RuntimeException('Coolify API request failed: HTTP 401')))->toBeFalse(); }); it('classifies missing Coolify cron worker resources as repairable', function (): void { $manager = new release_manager(); $readiness = new ReflectionMethod(release_manager::class, 'cronWorkerDeploymentReadiness'); $result = $readiness->invoke($manager, [ 'id' => 17, 'app' => 'api', 'coolify_instance_id' => 3, 'repository' => 'copenhagentruckwash/api', ], [ 'id' => 71, 'app' => 'cron', 'coolify_instance_id' => 3, 'coolify_service_uuid' => 'missing-cron-worker', 'repository' => 'copenhagentruckwash/api', 'branch' => 'master', ], [ 'configured' => true, 'missing' => true, ]); expect($result['action'])->toBe('repair'); expect($result['can_deploy'])->toBeTrue(); expect(array_column($result['issues'], 'code'))->toContain('missing_coolify_worker_resource'); }); it('repairs from an existing cron target when the API target is absent', function (): void { $manager = new release_manager(); $readiness = new ReflectionMethod(release_manager::class, 'cronWorkerDeploymentReadiness'); $result = $readiness->invoke($manager, null, [ 'id' => 71, 'app' => 'cron', 'coolify_instance_id' => 3, 'coolify_service_uuid' => 'missing-cron-worker', 'repository' => 'copenhagentruckwash/api', 'branch' => 'master', ], [ 'configured' => true, 'missing' => true, ]); expect($result['action'])->toBe('repair'); expect($result['can_deploy'])->toBeTrue(); expect(array_column($result['issues'], 'code'))->toContain('repairable_cron_target'); }); it('blocks cron worker deployment without an API target or deployable cron context', function (): void { $manager = new release_manager(); $readiness = new ReflectionMethod(release_manager::class, 'cronWorkerDeploymentReadiness'); $result = $readiness->invoke($manager, null, [ 'id' => 71, 'app' => 'cron', 'coolify_instance_id' => null, 'coolify_service_uuid' => 'missing-cron-worker', 'repository' => '', 'branch' => 'master', ], [ 'configured' => true, 'missing' => true, ]); expect($result['action'])->toBe('blocked'); expect($result['can_deploy'])->toBeFalse(); expect(array_column($result['issues'], 'code'))->toContain('missing_api_target'); }); it('builds explicit Coolify application route labels for release API targets', function (): void { $manager = new release_manager(); $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationRoutePayload'); $payload = $payloadMethod->invoke($manager, [ 'channel_slug' => 'internal', 'app' => 'api', 'repository' => 'copenhagentruckwash/api', 'branch' => 'master', ], [ 'coolify_ports_exposes' => '8080', 'runtime_env' => [ 'CORS' => 'https://partner.example.test/app', ], ], 'https://api-v2.truckwash.io', 'api-app-uuid', base64_encode('custom.keep=true')); $labels = explode("\n", base64_decode($payload['custom_labels'], true)); expect($payload['domains'])->toBe('https://api-v2.truckwash.io:8080') ->and($payload['is_force_https_enabled'])->toBeTrue() ->and($payload['force_domain_override'])->toBeTrue() ->and($labels)->toContain('custom.keep=true') ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/`)') ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.priority=1001') ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.middlewares=https-0-api-app-uuid-cors,gzip') ->and($labels)->toContain('traefik.http.middlewares.https-0-api-app-uuid-cors.headers.accesscontrolallowcredentials=true') ->and($labels)->toContain( 'traefik.http.middlewares.https-0-api-app-uuid-cors.headers.accesscontrolalloworiginlist=' . implode(',', cors_policy::allowedOrigins('https://partner.example.test/app')) ) ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.certresolver=letsencrypt') ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.domains[0].main=api-v2.truckwash.io') ->and($labels)->toContain('traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=8080'); $frontendPayload = $payloadMethod->invoke($manager, [ 'channel_slug' => 'internal', 'app' => 'frontend', 'repository' => 'copenhagentruckwash/pleno-vue', 'branch' => 'master', ], [ 'coolify_ports_exposes' => '80', ], 'https://api-v2.truckwash.io/internal/frontend', 'frontend-app-uuid', ''); $frontendLabels = explode("\n", base64_decode($frontendPayload['custom_labels'], true)); expect($frontendLabels) ->toContain('traefik.http.routers.https-0-frontend-app-uuid.middlewares=https-0-frontend-app-uuid-stripprefix,gzip'); expect(implode("\n", $frontendLabels))->not->toContain('-cors'); }); it('updates existing frontend Coolify applications away from legacy Nixpacks detection', function (): void { $manager = new release_manager(); $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationUpdatePayload'); $payload = $payloadMethod->invoke($manager, [ 'channel_slug' => 'internal', 'app' => 'frontend', 'repository' => 'copenhagentruckwash/pleno-vue', 'branch' => 'master', 'commit_sha' => '1132c8c2560e44478d1bb777c88c762a5e1d0b20', ], [ 'coolify_build_pack' => 'nixpacks', ]); expect($payload['git_repository'])->toBe('copenhagentruckwash/pleno-vue'); expect($payload['git_branch'])->toBe('master'); expect($payload['git_commit_sha'])->toBe('1132c8c2560e44478d1bb777c88c762a5e1d0b20'); expect($payload['build_pack'])->toBe('dockerfile'); expect($payload['ports_exposes'])->toBe('80'); expect($payload['dockerfile_location'])->toBe('/Dockerfile.coolify-frontend'); expect($payload['install_command'])->toBe(''); expect($payload['build_command'])->toBe(''); expect($payload['publish_directory'])->toBe(''); expect($payload['is_static'])->toBeFalse(); expect($payload['is_spa'])->toBeFalse(); }); it('can use the Coolify instance default GitHub App when source targets do not store it yet', function (): void { $manager = new release_manager(); $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload'); $payload = $payloadMethod->invoke($manager, [ 'channel_slug' => 'internal', 'app' => 'api', 'repository' => 'copenhagentruckwash/api', 'branch' => 'master', ], [ 'coolify_project_uuid' => 'project-internal', 'coolify_deploy_now' => true, ], [ 'default_environment_name' => 'production', 'default_server_uuid' => 'server-default', 'default_github_app_uuid' => 'github-app-copenhagentruckwash-github', ]); expect($payload['github_app_uuid'])->toBe('github-app-copenhagentruckwash-github'); expect($payload['git_repository'])->toBe('copenhagentruckwash/api'); expect($payload['build_pack'])->toBe('dockerfile'); expect($payload['ports_exposes'])->toBe('80'); expect($payload)->not->toHaveKey('docker_compose_raw'); }); it('does not treat an existing Coolify service as an application just because a GitHub App UUID is stored', function (): void { $manager = new release_manager(); $resourceTypeMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyResourceType'); expect($resourceTypeMethod->invoke($manager, [ 'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github', ], 'existing-service-uuid'))->toBe('service'); expect($resourceTypeMethod->invoke($manager, [ 'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github', ], ''))->toBe('application'); expect($resourceTypeMethod->invoke($manager, [ 'coolify_resource_type' => 'application', 'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github', ], 'existing-application-uuid'))->toBe('application'); }); it('auto-prepares path-routed release targets for Coolify application creation', function (): void { $manager = new release_manager(); $needsApplication = new ReflectionMethod(release_manager::class, 'releaseTargetNeedsApplicationAutoCreate'); $target = [ 'id' => 42, 'channel_slug' => 'stable', 'app' => 'api', 'coolify_instance_id' => 7, 'coolify_service_uuid' => '', 'deploy_context_json' => null, ]; expect($needsApplication->invoke($manager, $target, []))->toBeTrue(); expect($needsApplication->invoke($manager, array_replace($target, [ 'coolify_service_uuid' => 'existing-service', ]), []))->toBeFalse(); expect($needsApplication->invoke($manager, array_replace($target, [ 'coolify_instance_id' => null, ]), []))->toBeFalse(); expect($needsApplication->invoke($manager, $target, [ 'coolify_auto_create' => true, ]))->toBeFalse(); }); it('offers application target preparation for missing Coolify service creation failures', function (): void { $needsApplicationAction = new ReflectionMethod(release_manager::class, 'releaseStatusIssueNeedsApplicationTarget'); expect($needsApplicationAction->invoke(null, [ 'message' => 'API deployment failed before activation.', 'next_action' => 'Select an existing Coolify service or enable Coolify service creation before deployment.', ]))->toBeTrue(); expect($needsApplicationAction->invoke(null, [ 'message' => 'Path-routed release targets require a Coolify application resource with StripPrefix labels.', 'next_action' => 'Migrate this target before deploying.', ]))->toBeTrue(); expect($needsApplicationAction->invoke(null, [ 'message' => 'Release gate failed.', 'next_action' => 'Run live smoke tests before promotion.', ]))->toBeFalse(); }); it('builds API Coolify runtime environment from allowed process variables', function (): void { $keys = ['CONFIG_DB_HOST', 'CONFIG_DB_PASSWORD', 'EDGE_BROKER_URL', 'CORS', 'PATH']; $previous = []; foreach ($keys as $key) { $previous[$key] = getenv($key); } try { putenv('CONFIG_DB_HOST=db.example.test'); $_ENV['CONFIG_DB_HOST'] = 'db.example.test'; $_SERVER['CONFIG_DB_HOST'] = 'db.example.test'; putenv('CONFIG_DB_PASSWORD=runtime-secret'); $_ENV['CONFIG_DB_PASSWORD'] = 'runtime-secret'; $_SERVER['CONFIG_DB_PASSWORD'] = 'runtime-secret'; putenv('EDGE_BROKER_URL=https://edge.example.test'); $_ENV['EDGE_BROKER_URL'] = 'https://edge.example.test'; $_SERVER['EDGE_BROKER_URL'] = 'https://edge.example.test'; putenv('CORS=https://truckwash.io,https://api-v2.truckwash.io/master/api'); $_ENV['CORS'] = 'https://truckwash.io,https://api-v2.truckwash.io/master/api'; $_SERVER['CORS'] = 'https://truckwash.io,https://api-v2.truckwash.io/master/api'; putenv('PATH=/should/not/copy'); $_ENV['PATH'] = '/should/not/copy'; $_SERVER['PATH'] = '/should/not/copy'; $manager = new release_manager(); $runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv'); $env = $runtimeEnv->invoke($manager, [ 'app' => 'api', ], [ 'coolify_env' => [ 'CONFIG_DB_HOST' => 'context-db.example.test', 'CUSTOM_ALLOWED' => 'from-context', ], ]); expect($env['USE_ENV'])->toBe('true'); expect($env['CONFIG_DB_HOST'])->toBe('context-db.example.test'); expect($env['CONFIG_DB_PASSWORD'])->toBe('runtime-secret'); expect($env['EDGE_BROKER_URL'])->toBe('https://edge.example.test'); expect($env['CUSTOM_ALLOWED'])->toBe('from-context'); expect(explode(',', $env['CORS']))->toContain('https://api-v2.truckwash.io'); expect(explode(',', $env['CORS']))->toContain('http://localhost:5173'); expect(explode(',', $env['CORS']))->not->toContain('https://api-v2.truckwash.io/master/api'); expect($env)->not->toHaveKey('PATH'); } finally { foreach ($previous as $key => $value) { if ($value === false) { putenv($key); unset($_ENV[$key], $_SERVER[$key]); } else { putenv($key . '=' . $value); $_ENV[$key] = $value; $_SERVER[$key] = $value; } } } }); it('resolves backend commit sha from API runtime environment in priority order', function (): void { $keys = ['API_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'RELEASE_COMMIT_SHA']; $previous = []; foreach ($keys as $key) { $previous[$key] = [ 'process' => getenv($key), 'env_set' => array_key_exists($key, $_ENV), 'env' => $_ENV[$key] ?? null, 'server_set' => array_key_exists($key, $_SERVER), 'server' => $_SERVER[$key] ?? null, ]; putenv($key); unset($_ENV[$key], $_SERVER[$key]); } $set = static function (string $key, string $value): void { putenv($key . '=' . $value); $_ENV[$key] = $value; $_SERVER[$key] = $value; }; try { $set('API_COMMIT_SHA', 'not-a-sha'); $set('COMMIT_SHA', 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'); $set('GITHUB_SHA', 'cccccccccccccccccccccccccccccccccccccccc'); expect(release_manager::backendCommitSha())->toBe('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'); $set('API_COMMIT_SHA', 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); expect(release_manager::backendCommitSha())->toBe('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); } finally { foreach ($previous as $key => $state) { if ($state['process'] === false) { putenv($key); } else { putenv($key . '=' . $state['process']); } if ($state['env_set']) { $_ENV[$key] = $state['env']; } else { unset($_ENV[$key]); } if ($state['server_set']) { $_SERVER[$key] = $state['server']; } else { unset($_SERVER[$key]); } } } }); it('forces selected API commit into generated Coolify runtime env keys', function (): void { $manager = new release_manager(); $runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv'); $selectedCommit = '1111111111111111111111111111111111111111'; $explicitCommit = '2222222222222222222222222222222222222222'; $env = $runtimeEnv->invoke($manager, [ 'app' => 'api', 'commit_sha' => $selectedCommit, ], [ 'coolify_env' => [ 'API_COMMIT_SHA' => $explicitCommit, 'COMMIT_SHA' => $explicitCommit, 'GITHUB_SHA' => $explicitCommit, 'RELEASE_COMMIT_SHA' => $explicitCommit, ], ]); expect($env['API_COMMIT_SHA'])->toBe($selectedCommit); expect($env['COMMIT_SHA'])->toBe($selectedCommit); expect($env['GITHUB_SHA'])->toBe($selectedCommit); expect($env['RELEASE_COMMIT_SHA'])->toBe($selectedCommit); }); it('builds cron worker runtime environment from API runtime keys and cron context', function (): void { $keys = ['CONFIG_DB_HOST', 'CRON_WORKER_NAME']; $previous = []; foreach ($keys as $key) { $previous[$key] = getenv($key); } try { putenv('CONFIG_DB_HOST=db.example.test'); $_ENV['CONFIG_DB_HOST'] = 'db.example.test'; $_SERVER['CONFIG_DB_HOST'] = 'db.example.test'; putenv('CRON_WORKER_NAME=ignored-runtime-name'); $_ENV['CRON_WORKER_NAME'] = 'ignored-runtime-name'; $_SERVER['CRON_WORKER_NAME'] = 'ignored-runtime-name'; $manager = new release_manager(); $runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv'); $selectedCommit = '4444444444444444444444444444444444444444'; $env = $runtimeEnv->invoke($manager, [ 'app' => 'cron', 'commit_sha' => $selectedCommit, 'coolify_service_uuid' => 'cron-worker-resource-uuid', ], [ 'coolify_env' => [ 'CRON_WORKER_NAME' => 'release-internal-cron-worker', 'CRON_WORKER_SOURCE' => 'coolify_worker', ], ]); expect($env['USE_ENV'])->toBe('true'); expect($env['CONFIG_DB_HOST'])->toBe('db.example.test'); expect($env['CRON_WORKER_NAME'])->toBe('release-internal-cron-worker'); expect($env['CRON_WORKER_SOURCE'])->toBe('coolify_worker'); expect($env['CRON_WORKER_COOLIFY_RESOURCE_UUID'])->toBe('cron-worker-resource-uuid'); expect($env['CRON_WORKER_COMMIT_SHA'])->toBe($selectedCommit); expect($env['API_COMMIT_SHA'])->toBe($selectedCommit); expect($env['COMMIT_SHA'])->toBe($selectedCommit); } finally { foreach ($previous as $key => $value) { if ($value === false) { putenv($key); unset($_ENV[$key], $_SERVER[$key]); } else { putenv($key . '=' . $value); $_ENV[$key] = $value; $_SERVER[$key] = $value; } } } }); it('uses the selected deployment commit before stale Coolify context commits', function (): void { $manager = new release_manager(); $gitCommitSha = new ReflectionMethod(release_manager::class, 'releaseCoolifyGitCommitSha'); $selectedCommit = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; $staleCommit = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; expect($gitCommitSha->invoke($manager, [ 'commit_sha' => $selectedCommit, ], [ 'coolify_git_commit_sha' => $staleCommit, 'git_commit_sha' => $staleCommit, ]))->toBe($selectedCommit); }); it('injects selected frontend commit into Coolify runtime env for manifest builds', function (): void { $manager = new release_manager(); $runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv'); $selectedCommit = '3333333333333333333333333333333333333333'; $env = $runtimeEnv->invoke($manager, [ 'app' => 'frontend', 'commit_sha' => $selectedCommit, ], []); expect($env['SOURCE_COMMIT'])->toBe($selectedCommit); expect($env['RELEASE_COMMIT_SHA'])->toBe($selectedCommit); expect($env['COMMIT_SHA'])->toBe($selectedCommit); expect($env['GITHUB_SHA'])->toBe($selectedCommit); expect($env['VITE_COMMIT_HASH'])->toBe($selectedCommit); }); it('keeps beta API runtime environment on production database target', function (): void { $keys = ['CONFIG_DB_TARGET', 'CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', 'DEBUG']; $previous = []; foreach ($keys as $key) { $previous[$key] = getenv($key); } try { putenv('CONFIG_DB_TARGET=production'); $_ENV['CONFIG_DB_TARGET'] = 'production'; $_SERVER['CONFIG_DB_TARGET'] = 'production'; putenv('CONFIG_DB_HOST=prod-db.example.test'); $_ENV['CONFIG_DB_HOST'] = 'prod-db.example.test'; $_SERVER['CONFIG_DB_HOST'] = 'prod-db.example.test'; putenv('CONFIG_DB_DEBUG_HOST=debug-db.example.test'); $_ENV['CONFIG_DB_DEBUG_HOST'] = 'debug-db.example.test'; $_SERVER['CONFIG_DB_DEBUG_HOST'] = 'debug-db.example.test'; putenv('DEBUG=false'); $_ENV['DEBUG'] = 'false'; $_SERVER['DEBUG'] = 'false'; $manager = new release_manager(); $runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv'); $env = $runtimeEnv->invoke($manager, [ 'app' => 'api', 'channel_slug' => 'beta', ], []); expect($env['CONFIG_DB_TARGET'])->toBe('production'); expect($env['CONFIG_DB_HOST'])->toBe('prod-db.example.test'); expect($env['DEBUG'])->toBe('false'); expect($env['CONFIG_DB_TARGET'])->not->toBe('debug'); } finally { foreach ($previous as $key => $value) { if ($value === false) { putenv($key); unset($_ENV[$key], $_SERVER[$key]); } else { putenv($key . '=' . $value); $_ENV[$key] = $value; $_SERVER[$key] = $value; } } } }); it('treats attach-existing service sets without data target ids as production-shared ready', function (): void { $manager = new release_manager(); $status = new ReflectionMethod(release_manager::class, 'serviceSetStatus'); $dataTargets = ['database' => null, 'redis' => null, 'minio' => null]; expect($status->invoke($manager, 'attach_existing', 10, 11, $dataTargets))->toBe('ready'); expect($status->invoke($manager, 'clone_existing', 10, 11, $dataTargets))->toBe('needs_clone_targets'); expect($status->invoke($manager, 'fresh_empty', 10, 11, $dataTargets))->toBe('isolated_empty'); expect($status->invoke($manager, 'isolated_stack', 10, 11, $dataTargets))->toBe('needs_isolated_targets'); }); it('detects explicit data target ids so beta service sets can stay data-only', function (): void { $manager = new release_manager(); $hasExplicitDataTargets = new ReflectionMethod(release_manager::class, 'serviceSetInputHasExplicitDataTargets'); expect($hasExplicitDataTargets->invoke($manager, [ 'mode' => 'attach_existing', 'data_source_service_set_id' => 10, ]))->toBeFalse(); expect($hasExplicitDataTargets->invoke($manager, [ 'data_targets' => [ 'database' => 42, ], ]))->toBeTrue(); expect($hasExplicitDataTargets->invoke($manager, [ 'redis_coolify_target_id' => 43, ]))->toBeTrue(); }); it('allows beta production-service bundles only when data services stay production-shared', function (): void { $manager = new release_manager(); $assert = new ReflectionMethod(release_manager::class, 'assertBetaProductionDataPolicy'); $betaChannel = ['id' => 2, 'slug' => 'beta']; expect($assert->invoke($manager, $betaChannel, ['mode' => 'attach_existing']))->toBeNull(); foreach (['clone_existing', 'fresh_empty', 'isolated_stack'] as $mode) { expect(fn() => $assert->invoke($manager, $betaChannel, ['mode' => $mode])) ->toThrow(RuntimeException::class, 'production-shared'); } }); it('keeps release branch services out of the production Coolify environment', function (): void { $manager = new release_manager(); $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload'); $canaryPayload = $payloadMethod->invoke($manager, [ 'channel_slug' => 'canary', 'app' => 'api', 'repository' => 'copenhagentruckwash/api', 'branch' => 'release/canary', ], [ 'coolify_project_uuid' => 'project-release', 'coolify_deploy_now' => true, 'environment_name' => 'production', 'image' => 'ghcr.io/copenhagentruckwash/api:canary', ], [ 'default_environment_uuid' => 'env-production', 'default_environment_name' => 'production', 'default_server_uuid' => 'server-default', ]); expect($canaryPayload['environment_name'])->toBe('release-canary'); expect($canaryPayload)->not->toHaveKey('environment_uuid'); $betaPayload = $payloadMethod->invoke($manager, [ 'channel_slug' => 'beta', 'app' => 'api', 'repository' => 'copenhagentruckwash/api', 'branch' => 'release/beta', ], [ 'coolify_project_uuid' => 'project-release', 'coolify_deploy_now' => true, 'image' => 'ghcr.io/copenhagentruckwash/api:beta', ], [ 'default_environment_uuid' => 'env-production', 'default_environment_name' => 'production', 'default_server_uuid' => 'server-default', ]); expect($betaPayload['environment_name'])->toBe('release-beta'); expect($betaPayload)->not->toHaveKey('environment_uuid'); }); it('does not invent GHCR images for Coolify service payloads', function (): void { $manager = new release_manager(); $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload'); $payloadMethod->invoke($manager, [ 'channel_slug' => 'internal', 'app' => 'frontend', 'repository' => 'copenhagentruckwash/pleno-vue', 'branch' => 'master', ], [ 'coolify_service_name' => 'release-internal-safe-stack-frontend', 'coolify_project_uuid' => 'project-internal', 'coolify_deploy_now' => true, ], [ 'default_environment_name' => 'production', 'default_server_uuid' => 'server-default', ]); })->throws(RuntimeException::class, 'explicit image'); it('creates Coolify service payloads from raw compose without a service type', function (): void { $manager = new release_manager(); $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload'); $compose = "services:\n app:\n image: ghcr.io/copenhagentruckwash/api:test"; $payload = $payloadMethod->invoke($manager, [ 'channel_slug' => 'internal', 'app' => 'api', 'repository' => 'copenhagentruckwash/api', 'branch' => 'master', ], [ 'coolify_project_uuid' => 'project-internal', 'coolify_deploy_now' => true, 'docker_compose_raw' => $compose, ], [ 'default_environment_name' => 'production', 'default_server_uuid' => 'server-default', ]); expect($payload)->not->toHaveKey('type'); expect($payload['docker_compose_raw'])->toBe(base64_encode($compose)); expect(base64_decode($payload['docker_compose_raw'], true))->toBe($compose); }); it('defines release manager schema, routes, permissions, and system-status integration hooks', function (): void { $schema = file_get_contents(app_path('classes/release_manager_schema_bootstrap.php')); $manager = file_get_contents(app_path('classes/release_manager.php')); $route = file_get_contents(app_path('routes/releaseManagerRoute.php')); $auth = file_get_contents(app_path('routes/authRoute.php')); $response = file_get_contents(app_path('classes/response.php')); $status = file_get_contents(app_path('classes/superuser_system_status_service.php')); $index = file_get_contents(app_path('index.php')); $corsPolicy = file_get_contents(app_path('classes/cors_policy.php')); expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_channels'); expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_versions'); expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_channel_versions'); expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_assignments'); expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_deployment_targets'); expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_auto_sync_events'); expect($schema)->toContain('UNIQUE KEY uq_release_auto_sync_event'); expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_service_sets'); expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_deployments'); expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_bundles'); expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_operation_runs'); expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_operation_steps'); expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_replay_targets'); expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_timeline_sessions'); expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_timeline_events'); expect($schema)->toContain('device_type VARCHAR(16) NULL'); expect($schema)->toContain('frontend_version_label VARCHAR(128) NULL'); expect($schema)->toContain('api_version_label VARCHAR(128) NULL'); expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_module_health_snapshots'); expect($schema)->toContain("ensureColumn('release_channel_versions', 'service_set_id'"); expect($schema)->toContain("ensureColumn('release_channel_versions', 'bundle_id'"); expect($schema)->toContain("ensureColumn('release_deployments', 'deployment_kind'"); expect($schema)->toContain("ensureColumn('release_deployments', 'active_channel_app_key'"); expect($schema)->toContain("ensureUniqueIndex('release_deployments', 'uniq_release_deployments_active_channel_app'"); expect($schema)->toContain("ensureColumn('release_timeline_sessions', 'device_type'"); expect($schema)->toContain("ensureIndex('release_timeline_sessions', 'idx_release_timeline_device'"); expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'enabled'"); expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'release_gate_token'"); expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'release_gate_required_for_promotion'"); expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'github_token'"); expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'github_api_url'"); expect($schema)->toContain("['stable', 'Stable'"); expect($schema)->toContain("['canary', 'Canary'"); expect($schema)->toContain("['beta', 'Beta'"); expect($schema)->toContain("['internal', 'Internal'"); expect($route)->toContain('/release/bootstrap'); expect($route)->toContain('/release/runtime'); expect($route)->toContain('/release/timeline/events'); expect($route)->toContain('/release/github/webhook'); expect($route)->toContain('/release/gate/test-runs'); expect($route)->toContain('/release/gate/frontend-version'); expect($route)->toContain('releaseGateToken'); expect($route)->toContain('normalizeFrontendVersionGateInput'); expect($route)->toContain("redis->set('worker_target_version'"); expect($route)->toContain("redis->get('worker_target_version'"); expect($route)->toContain('/superuser/releases/config'); expect($route)->toContain('/superuser/releases/github/repositories'); expect($route)->toContain('/superuser/releases/github/branches'); expect($route)->toContain('/superuser/releases/github/commits'); expect($route)->toContain('/superuser/releases/github/test'); expect($route)->toContain('/superuser/releases/channels'); expect($route)->toContain('/superuser/releases/channels/{id}/sync'); expect($route)->toContain('/superuser/releases/test-runs'); expect($route)->toContain('/superuser/releases/operations'); expect($route)->toContain('/superuser/releases/operations/{id}'); expect($route)->toContain('/superuser/releases/channels/{id}/bundle'); expect($route)->toContain('/superuser/releases/assignment-subjects'); expect($route)->toContain('/superuser/releases/assignments'); expect($route)->toContain('/superuser/releases/service-sets'); expect($route)->toContain("\$this->delete('/superuser/releases/service-sets/{id}'"); expect($route)->toContain('/superuser/releases/service-sets/{id}/isolated-data-services'); expect($route)->toContain('/superuser/releases/bundles'); expect($route)->toContain('/superuser/releases/bundles/{id}/deploy'); expect($route)->toContain('/superuser/releases/bundles/{id}/promote'); expect($route)->toContain('/superuser/releases/deployments'); expect($route)->toContain('/superuser/releases/issues/actions'); expect($route)->toContain('/superuser/releases/replay-targets'); expect($route)->toContain('/superuser/releases/timeline/sessions'); expect($route)->toContain('/superuser/releases/timeline/sessions/{traceId}'); expect($route)->toContain('/superuser/releases/timeline'); expect($route)->toContain("requirePermission('superuser_release_manager_view')"); expect($route)->toContain("requirePermission('superuser_release_manager_manage')"); expect($route)->toContain("requirePermission('superuser_release_manager_deploy')"); expect($route)->toContain("requirePermission('superuser_release_manager_rollback')"); expect($route)->toContain("requirePermission('superuser_release_manager_replay')"); expect($manager)->toContain('verifyGithubSignature'); expect($manager)->toContain('verifyReleaseGateToken'); expect($manager)->toContain('normalizeReleaseGateInput'); expect($manager)->toContain('processReleaseGateAutoSync'); expect($manager)->toContain('release_auto_sync_events'); expect($manager)->toContain('require_readiness'); expect($manager)->toContain('release-manifest.json'); expect($manager)->toContain('static_artifact'); expect($manager)->toContain('api_gateway'); expect($manager)->toContain('assertReleaseGatePassedForPromotion'); expect($manager)->toContain('release_gate_required_for_promotion'); expect($manager)->toContain("'public_smoke_required' => true"); expect($manager)->toContain('normalizeGithubRepositoryName'); expect($manager)->toContain("private const DEFAULT_BRANCH = 'master'"); expect($manager)->toContain('releaseConfig'); expect($manager)->toContain('updateReleaseConfig'); expect($manager)->toContain('github_token_variable'); expect($manager)->toContain('github_token_env_variable'); expect($manager)->toContain('listGithubRepositories'); expect($manager)->toContain('listGithubBranches'); expect($manager)->toContain('listGithubCommits'); expect($manager)->toContain('testGithubRepositoryAccess'); expect($manager)->toContain('githubRepositoryAccess'); expect($manager)->toContain('github_token'); expect($manager)->toContain('commit_mode'); expect($manager)->toContain('restartCoolifyService'); expect($manager)->toContain('deployCoolifyReleaseTarget'); expect($manager)->toContain('deployResource'); expect($manager)->toContain('releaseCoolifyGitCommitSha'); expect($manager)->toContain('releaseCoolifyForceRebuild'); expect($manager)->toContain('listServiceSets'); expect($manager)->toContain('createServiceSet'); expect($manager)->toContain('deleteServiceSet'); expect($manager)->toContain('createBundle'); expect($manager)->toContain('deployBundle'); expect($manager)->toContain('promoteBundle'); expect($manager)->toContain('if ($apiVersionId !== null)'); expect($manager)->toContain('setChannelBundle'); expect($manager)->toContain('searchAssignmentSubjects'); expect($manager)->toContain('publicAssignmentSubjectSuggestion'); expect($manager)->toContain('available_channels'); expect($manager)->toContain("'source' => 'deployment'"); expect($manager)->toContain('chooseRuntimeChannel'); expect($manager)->toContain('requestedRuntimeChannelSlug'); expect($manager)->toContain("status = 'superseded'"); expect($manager)->toContain('serviceSetIsActive'); expect($manager)->toContain('bundleIsActive'); expect($manager)->toContain('service_set_removed'); expect($manager)->toContain('clone_replica_from_source'); expect($manager)->toContain('register_isolated_empty_service'); expect($manager)->toContain('isolated_stack'); expect($manager)->toContain('create_isolated_empty_stack_service'); expect($manager)->toContain('assertIsolatedStackTarget'); expect($manager)->toContain('completeIsolatedStackDataServices'); expect($manager)->toContain('createIsolatedStackDataTarget'); expect($manager)->toContain('skip_replication_provisioning'); expect($manager)->toContain('must not point at an existing Coolify service'); expect($manager)->toContain("'data_promotion' => false"); expect($manager)->toContain("'replica_failover' => false"); expect($manager)->toContain('deploymentCanBePromoted'); expect($manager)->toContain('deploymentFailureSummary'); expect($manager)->toContain('runIssueAction'); expect($manager)->toContain('release_issue_action_attempted'); expect($manager)->toContain('prepareReleaseIssueApplicationTarget'); expect($manager)->toContain('coolifyProjectSuggestions'); expect($manager)->toContain('releaseCoolifyServerUuid'); expect($manager)->toContain('coolify_project_uuid'); expect($manager)->toContain('releaseCoolifyServicePayload'); expect($manager)->toContain('releaseCoolifyApplicationPayload'); expect($manager)->toContain('releaseCoolifyApplicationUpdatePayload'); expect($manager)->toContain('createPrivateGithubAppApplication'); expect($manager)->toContain('coolifyGithubAppSuggestions'); expect($manager)->toContain('coolify_github_apps'); expect($manager)->toContain('release_deployment_targets'); expect($manager)->toContain('resolveChannel'); expect($manager)->toContain('capturePolicyFor'); expect($manager)->toContain('listTimelineSessions'); expect($manager)->toContain('timelineSessionDetail'); expect($manager)->toContain('timelineSessionContext'); expect($manager)->toContain('timelineErrorReports'); expect($manager)->toContain('timelineReleaseContext'); expect($manager)->toContain('publicTimelineDeploymentReference'); expect($manager)->toContain('publicTimelineBundleReference'); expect($manager)->toContain('cleanupExpiredReplayData'); expect($manager)->toContain('channelAvailability'); expect($manager)->toContain("'availability' =>"); expect($manager)->toContain('redactPayload'); expect($manager)->toContain('moduleKeys'); expect($manager)->toContain('releaseSuggestions'); expect($manager)->toContain('load_balancer_domains'); expect($manager)->toContain('appendDomainSuggestion'); expect($manager)->toContain('Coolify SSL requires a DNS domain routed to the load balancer.'); expect($manager)->toContain('releaseRuntimeUrls'); expect($manager)->toContain('coolify_services'); expect($manager)->toContain('coolify_enable_ssl'); expect($manager)->toContain('createService'); expect($manager)->toContain('updateService'); expect(file_get_contents(app_path('classes/coolify_api_client.php')))->toContain('updateApplicationEnvsBulk'); expect(file_get_contents(app_path('classes/coolify_api_client.php')))->toContain('stopApplication'); expect($manager)->toContain('channel_presets'); expect($manager)->toContain('target_presets'); expect($auth)->toContain("'release' => (new release_manager())->runtimeForPayload"); expect($response)->toContain('recordBackendFailure'); expect($status)->toContain("'key' => 'releasemanager'"); expect($status)->toContain('probeReleaseManagerModule'); expect($index)->toContain('release_manager::initializeRequestContext'); expect($corsPolicy)->toContain('X-Release-Trace'); expect($manager)->toContain('X-Release-Channel'); expect($manager)->toContain('normalizeReleaseApiIngressPath'); expect($manager)->toContain('routeSlugForChannel'); expect($manager)->toContain('channelSlugForRoute'); expect($manager)->toContain('syncChannel'); expect($manager)->toContain('runReleaseTest'); expect($manager)->toContain("'apps' => \$this->releaseTestAppsFromInput(\$input)"); expect($manager)->toContain('releaseTestAppsFromInput'); expect($manager)->toContain('release_operation_runs'); expect($manager)->toContain('active_channel_app_key'); expect($manager)->toContain('production_shared'); expect($manager)->toContain('Path-routed release targets require a Coolify application resource with StripPrefix labels'); expect($manager)->toContain('$applicationPayload[\'instant_deploy\'] = false'); expect($manager)->toContain('$servicePayload[\'instant_deploy\'] = false'); }); it('captures release request context from headers and runtime query parameters', function (): void { $server = $_SERVER; $get = $_GET; $context = $GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null; try { $_SERVER['HTTP_X_RELEASE_TRACE'] = 'trace-abc123'; $_SERVER['HTTP_X_RELEASE_CHANNEL'] = 'Internal'; $_SERVER['HTTP_X_FRONTEND_VERSION'] = 'frontend-130cc2fc106a'; $_SERVER['REQUEST_URI'] = '/release/runtime?release_channel=canary'; $_GET = ['release_channel' => 'canary']; $initialized = release_manager::initializeRequestContext(); expect($initialized)->toMatchArray([ 'trace_id' => 'trace-abc123', 'requested_channel' => 'internal', 'frontend_version' => 'frontend-130cc2fc106a', 'original_request_uri' => '/release/runtime?release_channel=canary', 'ingress_prefix_stripped' => false, ]); expect($GLOBALS['RELEASE_REQUEST_CONTEXT']['requested_channel'])->toBe('internal'); } finally { $_SERVER = $server; $_GET = $get; if ($context === null) { unset($GLOBALS['RELEASE_REQUEST_CONTEXT']); } else { $GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context; } } }); it('normalizes channel-prefixed API ingress paths before route dispatch', function (): void { $server = $_SERVER; $get = $_GET; $context = $GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null; try { $_SERVER['REQUEST_URI'] = '/internal/api/auth/session?foo=bar'; $_GET = ['foo' => 'bar']; $GLOBALS['RELEASE_REQUEST_CONTEXT'] = [ 'trace_id' => 'trace-normalize', 'requested_channel' => '', 'frontend_version' => '', 'backend_version' => 'test', 'request_started_at' => date('c'), 'original_request_uri' => '/internal/api/auth/session?foo=bar', 'normalized_request_uri' => '', 'ingress_prefix_stripped' => false, ]; $normalized = release_manager::normalizeReleaseApiIngressPath(['stable', 'internal']); expect($normalized)->toMatchArray([ 'channel_slug' => 'internal', 'original_request_uri' => '/internal/api/auth/session?foo=bar', 'normalized_request_uri' => '/auth/session?foo=bar', 'normalized_path' => '/auth/session', ]); expect($_SERVER['REQUEST_URI'])->toBe('/auth/session?foo=bar'); expect($_SERVER['PATH_INFO'])->toBe('/auth/session'); expect($_GET['release_channel'])->toBe('internal'); expect($GLOBALS['RELEASE_REQUEST_CONTEXT'])->toMatchArray([ 'requested_channel' => 'internal', 'normalized_request_uri' => '/auth/session?foo=bar', 'ingress_prefix_stripped' => true, ]); } finally { $_SERVER = $server; $_GET = $get; if ($context === null) { unset($GLOBALS['RELEASE_REQUEST_CONTEXT']); } else { $GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context; } } }); it('maps the public master API prefix to the stable release channel', function (): void { $server = $_SERVER; $get = $_GET; $context = $GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null; try { $_SERVER['REQUEST_URI'] = '/master/api/release/runtime'; $_GET = []; $GLOBALS['RELEASE_REQUEST_CONTEXT'] = [ 'trace_id' => 'trace-master', 'requested_channel' => '', 'frontend_version' => '', 'backend_version' => 'test', 'request_started_at' => date('c'), 'original_request_uri' => '/master/api/release/runtime', 'normalized_request_uri' => '', 'ingress_prefix_stripped' => false, ]; $normalized = release_manager::normalizeReleaseApiIngressPath(['stable', 'beta', 'canary', 'internal']); expect($normalized)->toMatchArray([ 'route_slug' => 'master', 'channel_slug' => 'stable', 'normalized_request_uri' => '/release/runtime', 'normalized_path' => '/release/runtime', ]); expect($_GET['release_channel'])->toBe('stable'); expect($GLOBALS['RELEASE_REQUEST_CONTEXT'])->toMatchArray([ 'requested_channel' => 'stable', 'release_route_slug' => 'master', 'ingress_prefix_stripped' => true, ]); } finally { $_SERVER = $server; $_GET = $get; if ($context === null) { unset($GLOBALS['RELEASE_REQUEST_CONTEXT']); } else { $GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context; } } }); it('uses master as the public route slug for the stable release channel', function (): void { expect(release_manager::routeSlugForChannel('stable'))->toBe('master'); expect(release_manager::routeSlugForChannel('beta'))->toBe('beta'); expect(release_manager::channelSlugForRoute('master'))->toBe('stable'); expect(release_manager::channelSlugForRoute('internal'))->toBe('internal'); }); it('ignores explicit runtime selection for channels outside the principal channel set', function (): void { $manager = new release_manager(); $chooseRuntimeChannel = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel'); $stable = [ 'id' => 1, 'slug' => 'stable', 'enabled' => 1, 'default_channel' => 1, ]; $internal = [ 'id' => 4, 'slug' => 'internal', 'name' => 'Internal', 'enabled' => 1, 'default_channel' => 0, ]; expect($chooseRuntimeChannel->invoke($manager, $stable, [$stable], 'internal'))->toBe($stable); expect($chooseRuntimeChannel->invoke($manager, $stable, [$stable, $internal], 'internal'))->toBe($internal); }); it('requires non-default release channel runtime URLs and preserves load balancer paths', function (): void { $manager = new release_manager(); $availability = new ReflectionMethod(release_manager::class, 'channelAvailability'); $runtimeUrls = new ReflectionMethod(release_manager::class, 'releaseRuntimeUrls'); $publicUrl = new ReflectionMethod(release_manager::class, 'releaseCoolifyPublicUrl'); $targetPublicBaseUrl = new ReflectionMethod(release_manager::class, 'releaseTargetPublicBaseUrl'); $applicationLabels = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationLabels'); expect($availability->invoke($manager, [ 'id' => 1, 'slug' => 'stable', 'default_channel' => 1, 'frontend_base_url' => null, 'api_base_url' => null, ]))->toMatchArray([ 'configured' => true, 'missing' => [], 'status' => 'ready', ]); expect($runtimeUrls->invoke($manager, [ 'id' => 2, 'slug' => 'canary', 'default_channel' => 0, 'frontend_base_url' => null, 'api_base_url' => null, ], [ 'frontend' => ['deployed_url' => 'https://api-v2.truckwash.io/canary/frontend/health'], 'api' => ['deployed_url' => 'https://api-v2.truckwash.io/canary/api/ping'], ]))->toBe([ 'frontend_base_url' => 'https://api-v2.truckwash.io/canary/frontend', 'api_base_url' => 'https://api-v2.truckwash.io/canary/api', ]); expect($runtimeUrls->invoke($manager, [ 'id' => 3, 'slug' => 'internal', 'default_channel' => 0, 'frontend_base_url' => null, 'api_base_url' => null, ], [ 'frontend' => ['deployed_url' => null], 'api' => ['deployed_url' => null], 'service_set' => [ 'targets' => [ 'frontend' => [ 'app' => 'frontend', 'channel_slug' => 'internal', 'deploy_context' => [ 'coolify_public_url' => 'https://api-v2.truckwash.io', ], ], 'api' => [ 'app' => 'api', 'channel_slug' => 'internal', 'deploy_context' => [ 'coolify_domain' => 'api-v2.truckwash.io', ], ], ], ], ]))->toBe([ 'frontend_base_url' => 'https://api-v2.truckwash.io/internal/frontend', 'api_base_url' => 'https://api-v2.truckwash.io/internal/api', ]); expect($publicUrl->invoke($manager, ['app' => 'frontend', 'channel_slug' => 'canary'], [ 'coolify_public_url' => 'https://api-v2.truckwash.io/canary/frontend', 'coolify_enable_ssl' => true, ]))->toBe('https://api-v2.truckwash.io/canary/frontend'); expect($publicUrl->invoke($manager, ['app' => 'frontend', 'channel_slug' => 'internal'], [ 'coolify_public_url' => 'https://api-v2.truckwash.io', 'coolify_enable_ssl' => true, ]))->toBe('https://api-v2.truckwash.io/internal/frontend'); expect($publicUrl->invoke($manager, ['app' => 'api', 'channel_slug' => 'internal'], [ 'coolify_domain' => 'api-v2.truckwash.io', 'coolify_enable_ssl' => true, ]))->toBe('https://api-v2.truckwash.io/internal/api'); expect($targetPublicBaseUrl->invoke($manager, [ 'app' => 'frontend', 'channel_slug' => 'internal', 'deploy_context_json' => json_encode([ 'coolify_public_url' => 'https://api-v2.truckwash.io', ]), ]))->toBe('https://api-v2.truckwash.io/internal/frontend'); $labels = $applicationLabels->invoke( null, 'https://api-v2.truckwash.io/internal/api', 'release-api-internal', 80, 'letsencrypt', 'api' ); expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/api`)'); expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.priority=1013'); expect($labels)->toContain('traefik.http.middlewares.https-0-release-api-internal-stripprefix.stripprefix.prefixes=/internal/api'); expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.middlewares=https-0-release-api-internal-cors,https-0-release-api-internal-stripprefix,gzip'); $source = file(app_path('classes/release_manager.php')); $methodSource = implode('', array_slice( $source, $availability->getStartLine() - 1, $availability->getEndLine() - $availability->getStartLine() + 1 )); expect($methodSource)->toContain('frontend_base_url'); expect($methodSource)->toContain('api_base_url'); expect($methodSource)->not->toContain("missing[] = 'release_bundle'"); expect($methodSource)->toContain('frontend_version'); expect($methodSource)->toContain('api_version'); }); it('resolves release deployment endpoints from manual overrides, URLs, health checks, and gateway defaults', function (): void { $manager = new release_manager(); $endpoint = new ReflectionMethod(release_manager::class, 'releaseDeploymentEndpoint'); $publicUrl = new ReflectionMethod(release_manager::class, 'releaseCoolifyPublicUrl'); $targetPublicBaseUrl = new ReflectionMethod(release_manager::class, 'releaseTargetPublicBaseUrl'); $manual = $endpoint->invoke($manager, [ 'app' => 'api', 'channel_slug' => 'beta', 'deploy_context' => [ 'endpoint_mode' => 'manual', 'manual_endpoint_host' => 'manual-api.example.test', 'manual_endpoint_port' => '8443', ], ]); expect($manual)->toMatchArray([ 'mode' => 'manual', 'status' => 'resolved', 'host' => 'manual-api.example.test', 'port' => 8443, 'url' => 'https://manual-api.example.test:8443', 'source' => 'manual', ]); expect($publicUrl->invoke($manager, ['app' => 'api', 'channel_slug' => 'beta'], [ 'endpoint_mode' => 'manual', 'manual_endpoint_host' => 'manual-api.example.test', 'manual_endpoint_port' => '8443', ]))->toBe('https://manual-api.example.test:8443'); $fromPublicUrl = $endpoint->invoke($manager, [ 'app' => 'frontend', 'channel_slug' => 'internal', 'deploy_context' => [ 'endpoint_mode' => 'auto', 'coolify_public_url' => 'https://api-v2.truckwash.io', ], ]); expect($fromPublicUrl)->toMatchArray([ 'mode' => 'auto', 'status' => 'resolved', 'host' => 'api-v2.truckwash.io', 'port' => 443, 'url' => 'https://api-v2.truckwash.io/internal/frontend', 'source' => 'coolify_public_url', ]); $fromHealth = $endpoint->invoke($manager, [ 'app' => 'api', 'channel_slug' => 'canary', 'health_url' => 'https://health.example.test/canary/api/ping', 'deploy_context' => [ 'endpoint_mode' => 'auto', 'coolify_domain' => 'domain.example.test', ], ]); expect($fromHealth)->toMatchArray([ 'status' => 'resolved', 'host' => 'health.example.test', 'url' => 'https://health.example.test/canary/api', 'source' => 'health_url', ]); $gateway = $endpoint->invoke($manager, [ 'app' => 'frontend', 'channel_slug' => 'beta', 'deploy_context_json' => json_encode([ 'endpoint_mode' => 'auto', 'public_gateway_host' => 'gateway.example.test', ]), ]); expect($gateway)->toMatchArray([ 'mode' => 'auto', 'status' => 'pending', 'host' => 'gateway.example.test', 'port' => 443, 'url' => 'https://gateway.example.test/beta/frontend', 'source' => 'auto_gateway', ]); expect($targetPublicBaseUrl->invoke($manager, [ 'app' => 'frontend', 'channel_slug' => 'beta', 'deploy_context_json' => json_encode([ 'endpoint_mode' => 'auto', 'public_gateway_host' => 'gateway.example.test', ]), ]))->toBe('https://gateway.example.test/beta/frontend'); $cron = $endpoint->invoke($manager, [ 'app' => 'cron', 'channel_slug' => 'beta', 'deploy_context_json' => json_encode([ 'endpoint_mode' => 'auto', 'public_gateway_host' => 'gateway.example.test', 'coolify_public_url' => 'https://api-v2.truckwash.io', ]), ]); expect($cron)->toMatchArray([ 'status' => 'pending', 'host' => null, 'url' => null, 'source' => 'private_worker', ]); expect($publicUrl->invoke($manager, ['app' => 'cron', 'channel_slug' => 'beta'], [ 'public_gateway_host' => 'gateway.example.test', ]))->toBeNull(); expect($targetPublicBaseUrl->invoke($manager, [ 'app' => 'cron', 'channel_slug' => 'beta', 'deploy_context_json' => json_encode([ 'public_gateway_host' => 'gateway.example.test', ]), ]))->toBeNull(); }); it('collects previous Coolify application UUIDs for stale route cleanup', function (): void { $manager = new release_manager(); $previousApplications = new ReflectionMethod(release_manager::class, 'releaseCoolifyPreviousApplicationUuids'); expect($previousApplications->invoke($manager, [ 'coolify_previous_artifact_app_uuid' => 'old-artifact-app', 'coolify_previous_application_uuid' => 'old-application', 'coolify_previous_application_uuids' => [ 'old-application', 'active-application', 'other-old-application', '', ], ], 'active-application'))->toBe([ 'old-application', 'old-artifact-app', 'other-old-application', ]); }); it('deletes only generated API commit env rows before Coolify application env updates', function (): void { $manager = new release_manager(); $deleteCommitEnvs = new ReflectionMethod(release_manager::class, 'deleteCoolifyGeneratedCommitEnvs'); $client = new ReleaseManagerCoolifyEnvFake([ ['uuid' => 'api-commit', 'key' => 'API_COMMIT_SHA'], ['uuid' => 'commit', 'key' => 'COMMIT_SHA'], ['uuid' => 'github', 'key' => 'GITHUB_SHA'], ['uuid' => 'release', 'key' => 'RELEASE_COMMIT_SHA'], ['uuid' => 'frontend-source', 'key' => 'SOURCE_COMMIT'], ['uuid' => 'secret', 'key' => 'CONFIG_DB_PASSWORD'], ]); $deleteCommitEnvs->invoke($manager, $client, 'application-uuid', ['app' => 'api'], []); expect($client->deleted)->toBe([ ['application-uuid', 'api-commit'], ['application-uuid', 'commit'], ['application-uuid', 'github'], ['application-uuid', 'release'], ]); }); it('waits for an in-flight automatic sync instead of passing the retry immediately', function (): void { $manager = file_get_contents(app_path('classes/release_manager.php')); expect($manager)->toContain('return $this->waitForReleaseAutoSyncEventResult($eventId, $channelId, $app, $commitSha, $gateInput);') ->and($manager)->toContain('Automatic container update is already being processed for event %d but did not finish'); }); it('redacts GitHub access metadata from public release versions', function (): void { $manager = new release_manager(); $publicVersion = new ReflectionMethod(release_manager::class, 'publicVersion'); $version = $publicVersion->invoke($manager, [ 'id' => 12, 'app' => 'frontend', 'repository' => 'truckwash/front-end-vue', 'branch' => 'release/canary', 'commit_sha' => 'c0ffee0000001111222233334444555566667777', 'tag' => null, 'version_label' => 'frontend-canary', 'build_url' => null, 'artifact_url' => null, 'deployed_url' => null, 'status' => 'active', 'metadata_json' => json_encode([ 'commit_mode' => 'specific', 'github_access' => [ 'commit' => [ 'sha' => 'c0ffee0000001111222233334444555566667777', 'message' => 'Private implementation detail', 'author_name' => 'Release Bot', 'authored_at' => '2026-05-19T10:15:00Z', ], 'commit_authored_at' => '2026-05-19T10:15:00Z', ], 'bundle_member' => true, ]), 'created_at' => '2026-05-19 10:10:00', 'deployed_at' => '2026-05-19 10:20:00', ]); expect($version['commit_sha'])->toBe('c0ffee0000001111222233334444555566667777'); expect($version['commit'])->toBeNull(); expect($version['commit_authored_at'])->toBeNull(); expect($version['metadata'])->toBe([ 'commit_mode' => 'specific', 'bundle_member' => true, ]); expect($version['deployed_at'])->toBe('2026-05-19 10:20:00'); }); it('only chooses requested runtime channels from channels available to the principal', function (): void { $manager = new release_manager(); $choose = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel'); $stable = [ 'id' => 1, 'slug' => 'stable', 'name' => 'Stable', 'default_channel' => 1, ]; $canary = [ 'id' => 2, 'slug' => 'canary', 'name' => 'Canary', 'default_channel' => 0, ]; $internal = [ 'id' => 3, 'slug' => 'internal', 'name' => 'Internal', 'default_channel' => 0, 'enabled' => 1, ]; expect($choose->invoke($manager, $stable, [$stable, $canary], 'canary'))->toBe($canary); expect($choose->invoke($manager, $stable, [$stable, $canary], 'stable'))->toBe($stable); expect($choose->invoke($manager, $stable, [$stable, $canary], 'internal'))->toBe($stable); expect($choose->invoke($manager, $stable, [$stable, $canary], 'unknown'))->toBe($stable); expect($choose->invoke($manager, $canary, [$stable, $canary], ''))->toBe($canary); expect($internal['enabled'])->toBe(1); }); it('normalizes release assignment subject suggestions without leaking private fields', function (): void { $suggestion = release_manager::publicAssignmentSubjectSuggestion([ 'subject_type' => 'USER', 'subject_id' => 42, 'title' => ' Dispatcher ', 'description' => 'Customer #424242 / dispatcher@example.test', 'icon' => 'fas fa-user', 'source' => 'users', 'password' => 'secret', 'two_factor_secret' => 'private', ]); expect($suggestion)->toBe([ 'subject_type' => 'user', 'subject_id' => '42', 'label' => 'Dispatcher - Customer #424242 / dispatcher@example.test', 'title' => 'Dispatcher', 'description' => 'Customer #424242 / dispatcher@example.test', 'icon' => 'fas fa-user', 'source' => 'users', ]); expect(array_keys($suggestion))->not->toContain('password'); expect(array_keys($suggestion))->not->toContain('two_factor_secret'); expect(release_manager::publicAssignmentSubjectSuggestion([ 'subject_type' => 'invalid', 'subject_id' => 42, 'title' => 'Invalid', ]))->toBeNull(); }); it('restricts release gate fetches to Truckwash release hosts and relative paths', function (): void { $manager = new release_manager(); $joinUrl = new ReflectionMethod(release_manager::class, 'releaseGateJoinUrl'); $hostAllowed = new ReflectionMethod(release_manager::class, 'releaseGateFetchHostAllowed'); $publicIpAllowed = new ReflectionMethod(release_manager::class, 'releaseGatePublicIpAllowed'); $stringArray = new ReflectionMethod(release_manager::class, 'releaseGateStringArray'); expect($joinUrl->invoke($manager, 'https://api-v2.truckwash.io', '/master/api/ping')) ->toBe('https://api-v2.truckwash.io/master/api/ping') ->and($hostAllowed->invoke($manager, 'api-v2.truckwash.io'))->toBeTrue() ->and($hostAllowed->invoke($manager, 'assets.canary.truckwash.io'))->toBeTrue() ->and($hostAllowed->invoke($manager, 'truckwash.io.evil.test'))->toBeFalse() ->and($hostAllowed->invoke($manager, '127.0.0.1'))->toBeFalse() ->and($publicIpAllowed->invoke($manager, '8.8.8.8'))->toBeTrue() ->and($publicIpAllowed->invoke($manager, '127.0.0.1'))->toBeFalse() ->and($publicIpAllowed->invoke($manager, '10.0.0.5'))->toBeFalse() ->and($publicIpAllowed->invoke($manager, '169.254.169.254'))->toBeFalse() ->and($publicIpAllowed->invoke($manager, '::1'))->toBeFalse() ->and($stringArray->invoke($manager, ['/a', '/b', '/c'], 2))->toBe(['/a', '/b']); expect(fn() => $joinUrl->invoke($manager, 'https://api-v2.truckwash.io', 'http://127.0.0.1/ping')) ->toThrow(RuntimeException::class, 'relative'); expect(fn() => $joinUrl->invoke($manager, 'https://api-v2.truckwash.io', '//127.0.0.1/ping')) ->toThrow(RuntimeException::class, 'relative'); }); it('normalizes Coolify application list payloads for release cleanup previews', function (): void { $manager = new release_manager(); $payloadRows = new ReflectionMethod(release_manager::class, 'payloadRows'); expect($payloadRows->invoke($manager, [ 'applications' => [ ['uuid' => 'app-1', 'name' => 'release-canary-api'], ['uuid' => 'app-2', 'name' => 'release-old-frontend'], ], ]))->toBe([ ['uuid' => 'app-1', 'name' => 'release-canary-api'], ['uuid' => 'app-2', 'name' => 'release-old-frontend'], ]); }); it('keeps Coolify cleanup selection hashes tied to action and resource identity', function (): void { $manager = new release_manager(); $hash = new ReflectionMethod(release_manager::class, 'coolifyCleanupSelectionHash'); $phrase = new ReflectionMethod(release_manager::class, 'coolifyCleanupConfirmationPhrase'); $matches = new ReflectionMethod(release_manager::class, 'coolifyCleanupReferenceMatchesPolicy'); $policy = [ 'instance_id' => 3, 'channel_id' => 2, 'channel_slug' => 'canary', 'app' => 'api', 'resource_type' => 'application', 'action' => 'delete', ]; $candidate = [ 'instance_id' => 3, 'type' => 'application', 'uuid' => 'old-api-canary-app', 'action' => 'delete', ]; $reference = [ 'apps' => ['api' => 'api'], 'channels' => [2 => 2], 'resource_types' => ['application' => 'application'], ]; $deleteHash = $hash->invoke($manager, $policy, [$candidate]); $stopHash = $hash->invoke($manager, array_replace($policy, ['action' => 'stop']), [ array_replace($candidate, ['action' => 'stop']), ]); expect($matches->invoke($manager, $reference, $policy))->toBeTrue() ->and($matches->invoke($manager, $reference, array_replace($policy, ['app' => 'frontend'])))->toBeFalse() ->and($matches->invoke($manager, $reference, array_replace($policy, ['channel_id' => 99])))->toBeFalse() ->and($deleteHash)->not->toBe($stopHash) ->and($phrase->invoke($manager, $deleteHash))->toBe('cleanup-coolify-' . substr($deleteHash, 0, 12)); });