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

1480 lines
65 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('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 app-specific release gate auto-sync metadata', function (): void {
$manager = new release_manager();
$normalizeGate = new ReflectionMethod(release_manager::class, 'normalizeReleaseGateInput');
$normalizeGate->setAccessible(true);
$appMatches = new ReflectionMethod(release_manager::class, 'releaseGateAppMatches');
$appMatches->setAccessible(true);
$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',
'repository' => 'copenhagentruckwash/api',
'branch' => 'master',
'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8',
'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123',
'auto_sync' => true,
]);
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');
$method->setAccessible(true);
$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']);
expect($failed)->toContainEqual(expect()->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']);
expect($passed)->toContainEqual(expect()->toMatchArray([
'step_key' => 'auto_sync_inputs',
'status' => 'passed',
]));
});
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 frontend Coolify GitHub App application payloads with the release Dockerfile', 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('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');
$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.priority=1001')
->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('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');
$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('auto-prepares path-routed release targets for Coolify application creation', function (): void {
$manager = new release_manager();
$needsApplication = new ReflectionMethod(release_manager::class, 'releaseTargetNeedsApplicationAutoCreate');
$needsApplication->setAccessible(true);
$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');
$needsApplicationAction->setAccessible(true);
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');
$runtimeEnv->setAccessible(true);
$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('injects selected API commit into Coolify runtime env unless explicitly set', function (): void {
$manager = new release_manager();
$runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');
$runtimeEnv->setAccessible(true);
$selectedCommit = '1111111111111111111111111111111111111111';
$explicitCommit = '2222222222222222222222222222222222222222';
$env = $runtimeEnv->invoke($manager, [
'app' => 'api',
'commit_sha' => $selectedCommit,
], [
'coolify_env' => [
'COMMIT_SHA' => $explicitCommit,
],
]);
expect($env['API_COMMIT_SHA'])->toBe($selectedCommit);
expect($env['COMMIT_SHA'])->toBe($explicitCommit);
});
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');
$runtimeEnv->setAccessible(true);
$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');
$status->setAccessible(true);
$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');
$hasExplicitDataTargets->setAccessible(true);
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');
$assert->setAccessible(true);
$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');
$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('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->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'));
$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('releaseGateToken');
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('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($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('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');
$chooseRuntimeChannel->setAccessible(true);
$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');
$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.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-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');
$endpoint->setAccessible(true);
$publicUrl = new ReflectionMethod(release_manager::class, 'releaseCoolifyPublicUrl');
$publicUrl->setAccessible(true);
$targetPublicBaseUrl = new ReflectionMethod(release_manager::class, 'releaseTargetPublicBaseUrl');
$targetPublicBaseUrl->setAccessible(true);
$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');
});
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('only chooses requested runtime channels from channels 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,
];
$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');
$joinUrl->setAccessible(true);
$hostAllowed = new ReflectionMethod(release_manager::class, 'releaseGateFetchHostAllowed');
$hostAllowed->setAccessible(true);
$publicIpAllowed = new ReflectionMethod(release_manager::class, 'releaseGatePublicIpAllowed');
$publicIpAllowed->setAccessible(true);
$stringArray = new ReflectionMethod(release_manager::class, 'releaseGateStringArray');
$stringArray->setAccessible(true);
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');
});