867 lines
39 KiB
PHP
867 lines
39 KiB
PHP
<?php
|
|
|
|
app_require('classes/release_manager.php');
|
|
app_require('classes/release_manager_schema_bootstrap.php');
|
|
|
|
use classes\release_manager;
|
|
|
|
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('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');
|
|
$method->setAccessible(true);
|
|
|
|
$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');
|
|
$method->setAccessible(true);
|
|
|
|
$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');
|
|
$normalizeMode->setAccessible(true);
|
|
expect($normalizeMode->invoke($manager, ' isolated_stack '))->toBe('isolated_stack');
|
|
|
|
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload');
|
|
$payloadMethod->setAccessible(true);
|
|
$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 Coolify GitHub App application payloads so pulls use the app token', function (): void {
|
|
$manager = new release_manager();
|
|
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload');
|
|
$payloadMethod->setAccessible(true);
|
|
|
|
$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('static');
|
|
expect($payload['ports_exposes'])->toBe('80');
|
|
expect($payload['install_command'])->toBe('npm ci');
|
|
expect($payload['build_command'])->toBe('npm run build');
|
|
expect($payload['publish_directory'])->toBe('dist');
|
|
expect($payload['is_static'])->toBeTrue();
|
|
expect($payload['is_spa'])->toBeTrue();
|
|
expect($payload['domains'])->toBe('https://canary.example.test/canary/frontend');
|
|
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');
|
|
$payloadMethod->setAccessible(true);
|
|
|
|
$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('builds explicit Coolify application route labels for release API targets', function (): void {
|
|
$manager = new release_manager();
|
|
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationRoutePayload');
|
|
$payloadMethod->setAccessible(true);
|
|
|
|
$payload = $payloadMethod->invoke($manager, [
|
|
'channel_slug' => 'internal',
|
|
'app' => 'api',
|
|
'repository' => 'copenhagentruckwash/api',
|
|
'branch' => 'master',
|
|
], [
|
|
'coolify_ports_exposes' => '8080',
|
|
], '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.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');
|
|
});
|
|
|
|
it('updates existing frontend Coolify applications away from legacy Nixpacks detection', function (): void {
|
|
$manager = new release_manager();
|
|
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationUpdatePayload');
|
|
$payloadMethod->setAccessible(true);
|
|
|
|
$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('static');
|
|
expect($payload['ports_exposes'])->toBe('80');
|
|
expect($payload['install_command'])->toBe('npm ci');
|
|
expect($payload['build_command'])->toBe('npm run build');
|
|
expect($payload['publish_directory'])->toBe('dist');
|
|
expect($payload['is_static'])->toBeTrue();
|
|
expect($payload['is_spa'])->toBeTrue();
|
|
});
|
|
|
|
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');
|
|
$payloadMethod->setAccessible(true);
|
|
|
|
$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');
|
|
$resourceTypeMethod->setAccessible(true);
|
|
|
|
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('keeps release branch services out of the production Coolify environment except beta', function (): void {
|
|
$manager = new release_manager();
|
|
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload');
|
|
$payloadMethod->setAccessible(true);
|
|
|
|
$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('production');
|
|
expect($betaPayload['environment_uuid'])->toBe('env-production');
|
|
});
|
|
|
|
it('does not invent GHCR images for Coolify service payloads', function (): void {
|
|
$manager = new release_manager();
|
|
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload');
|
|
$payloadMethod->setAccessible(true);
|
|
|
|
$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');
|
|
$payloadMethod->setAccessible(true);
|
|
|
|
$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'));
|
|
|
|
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_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_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_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', '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('/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}/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/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('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('setChannelBundle');
|
|
expect($manager)->toContain('searchAssignmentSubjects');
|
|
expect($manager)->toContain('publicAssignmentSubjectSuggestion');
|
|
expect($manager)->toContain('available_channels');
|
|
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('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($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($index)->toContain('X-Release-Trace');
|
|
expect($manager)->toContain('X-Release-Channel');
|
|
expect($manager)->toContain('normalizeReleaseApiIngressPath');
|
|
expect($manager)->toContain('Path-routed release targets require a Coolify application resource with StripPrefix labels');
|
|
});
|
|
|
|
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('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');
|
|
$availability->setAccessible(true);
|
|
$runtimeUrls = new ReflectionMethod(release_manager::class, 'releaseRuntimeUrls');
|
|
$runtimeUrls->setAccessible(true);
|
|
$publicUrl = new ReflectionMethod(release_manager::class, 'releaseCoolifyPublicUrl');
|
|
$publicUrl->setAccessible(true);
|
|
$targetPublicBaseUrl = new ReflectionMethod(release_manager::class, 'releaseTargetPublicBaseUrl');
|
|
$targetPublicBaseUrl->setAccessible(true);
|
|
$applicationLabels = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationLabels');
|
|
$applicationLabels->setAccessible(true);
|
|
|
|
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'
|
|
);
|
|
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.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-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)->toContain('release_bundle');
|
|
expect($methodSource)->toContain('frontend_version');
|
|
expect($methodSource)->toContain('api_version');
|
|
});
|
|
|
|
it('exposes release version git commit metadata for runtime channel cards', function (): void {
|
|
$manager = new release_manager();
|
|
$publicVersion = new ReflectionMethod(release_manager::class, 'publicVersion');
|
|
$publicVersion->setAccessible(true);
|
|
|
|
$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([
|
|
'github_access' => [
|
|
'commit' => [
|
|
'sha' => 'c0ffee0000001111222233334444555566667777',
|
|
'authored_at' => '2026-05-19T10:15:00Z',
|
|
],
|
|
],
|
|
]),
|
|
'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']['sha'])->toBe('c0ffee0000001111222233334444555566667777');
|
|
expect($version['commit_authored_at'])->toBe('2026-05-19T10:15:00Z');
|
|
expect($version['deployed_at'])->toBe('2026-05-19 10:20:00');
|
|
});
|
|
|
|
it('chooses a requested runtime channel only when it is available to the principal', function (): void {
|
|
$manager = new release_manager();
|
|
$choose = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel');
|
|
$choose->setAccessible(true);
|
|
|
|
$stable = [
|
|
'id' => 1,
|
|
'slug' => 'stable',
|
|
'name' => 'Stable',
|
|
'default_channel' => 1,
|
|
];
|
|
$canary = [
|
|
'id' => 2,
|
|
'slug' => 'canary',
|
|
'name' => 'Canary',
|
|
'default_channel' => 0,
|
|
];
|
|
|
|
expect($choose->invoke($manager, $stable, [$stable, $canary], 'canary'))->toBe($canary);
|
|
expect($choose->invoke($manager, $stable, [$stable, $canary], 'unknown'))->toBe($stable);
|
|
expect($choose->invoke($manager, $canary, [$stable, $canary], ''))->toBe($canary);
|
|
});
|
|
|
|
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();
|
|
});
|