Files
api/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php
T

487 lines
24 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('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');
expect($payload)->not->toHaveKey('docker_compose_raw');
});
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/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("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('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)->not->toContain('X-Release-Channel');
});