mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-27 17:55:59 -04:00
Merge remote-tracking branch 'origin/main' into pr/11528
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationSetting;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
function containerNamingJob(Application $application, int $pullRequestId = 0): array
|
||||
{
|
||||
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$reflection->getProperty('application')->setValue($job, $application);
|
||||
$reflection->getProperty('pull_request_id')->setValue($job, $pullRequestId);
|
||||
|
||||
return [$job, $reflection];
|
||||
}
|
||||
|
||||
function applicationWithContainerNaming(string $customName = 'shadowuw'): Application
|
||||
{
|
||||
$application = new Application;
|
||||
$application->forceFill(['uuid' => 'application-uuid']);
|
||||
$application->setRelation('settings', new ApplicationSetting([
|
||||
'custom_internal_name' => $customName,
|
||||
'is_consistent_container_name_enabled' => true,
|
||||
]));
|
||||
|
||||
return $application;
|
||||
}
|
||||
|
||||
it('uses the custom container name when consistent naming is enabled', function () {
|
||||
$application = applicationWithContainerNaming();
|
||||
|
||||
[$job, $reflection] = containerNamingJob($application);
|
||||
|
||||
expect($reflection->getMethod('resolveContainerName')->invoke($job))->toBe('shadowuw');
|
||||
});
|
||||
|
||||
it('adds the pull request suffix to a custom container name', function () {
|
||||
$application = applicationWithContainerNaming();
|
||||
|
||||
[$job, $reflection] = containerNamingJob($application, 42);
|
||||
|
||||
expect($reflection->getMethod('resolveContainerName')->invoke($job))->toBe('shadowuw-pr-42');
|
||||
});
|
||||
|
||||
it('includes old generated containers when cleaning up a consistent deployment', function () {
|
||||
$application = applicationWithContainerNaming();
|
||||
[$job, $reflection] = containerNamingJob($application);
|
||||
$reflection->getProperty('container_name')->setValue($job, 'shadowuw');
|
||||
|
||||
$containers = new Collection([
|
||||
['Names' => 'application-uuid-192238854305'],
|
||||
['Names' => 'shadowuw'],
|
||||
]);
|
||||
|
||||
expect($reflection->getMethod('containerNamesToRemove')->invoke($job, $containers)->all())
|
||||
->toBe(['application-uuid-192238854305', 'shadowuw']);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
class TestableCoolifyUrlDeploymentJob extends ApplicationDeploymentJob
|
||||
{
|
||||
public function __construct() {}
|
||||
|
||||
public function execute_remote_command(...$commands): void {}
|
||||
}
|
||||
|
||||
function coolifyVariablesForFqdn(string $fqdn, string $composeParsingVersion = '3'): string
|
||||
{
|
||||
$team = Team::create([
|
||||
'name' => 'Coolify Url Team',
|
||||
'personal_team' => false,
|
||||
'show_boarding' => false,
|
||||
]);
|
||||
$project = Project::create([
|
||||
'name' => 'Coolify Url Project',
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
$environment = Environment::where('project_id', $project->id)->firstOrFail();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$destination = $server->standaloneDockers()->firstOrFail();
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'dockercompose',
|
||||
'fqdn' => $fqdn,
|
||||
]);
|
||||
|
||||
// The created hook resets this, so it has to be set afterwards.
|
||||
$application->compose_parsing_version = $composeParsingVersion;
|
||||
$application->save();
|
||||
|
||||
$job = new TestableCoolifyUrlDeploymentJob;
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
|
||||
foreach ([
|
||||
'application' => $application->fresh(),
|
||||
'pull_request_id' => 0,
|
||||
'commit' => 'HEAD',
|
||||
] as $property => $value) {
|
||||
$reflection->getProperty($property)->setValue($job, $value);
|
||||
}
|
||||
|
||||
$reflection->getMethod('set_coolify_variables')->invoke($job);
|
||||
|
||||
return $reflection->getProperty('coolify_variables')->getValue($job);
|
||||
}
|
||||
|
||||
it('keeps every domain intact when an application has multiple domains', function () {
|
||||
$variables = coolifyVariablesForFqdn('https://a.example.com,https://b.example.com');
|
||||
|
||||
expect($variables)
|
||||
->toContain("COOLIFY_URL='https://a.example.com,https://b.example.com'")
|
||||
->toContain("COOLIFY_FQDN='a.example.com,b.example.com'");
|
||||
});
|
||||
|
||||
it('strips the port from every domain when an application has multiple domains', function () {
|
||||
$variables = coolifyVariablesForFqdn('https://a.example.com:8080,https://b.example.com:9000');
|
||||
|
||||
expect($variables)
|
||||
->toContain("COOLIFY_URL='https://a.example.com,https://b.example.com'")
|
||||
->toContain("COOLIFY_FQDN='a.example.com,b.example.com'");
|
||||
});
|
||||
|
||||
it('keeps every domain intact on the legacy compose parsing version', function () {
|
||||
$variables = coolifyVariablesForFqdn('https://a.example.com,https://b.example.com', '2');
|
||||
|
||||
expect($variables)
|
||||
->toContain("COOLIFY_URL='a.example.com,b.example.com'")
|
||||
->toContain("COOLIFY_FQDN='https://a.example.com,https://b.example.com'");
|
||||
});
|
||||
|
||||
it('still resolves a single domain', function () {
|
||||
$variables = coolifyVariablesForFqdn('https://a.example.com');
|
||||
|
||||
expect($variables)
|
||||
->toContain("COOLIFY_URL='https://a.example.com'")
|
||||
->toContain("COOLIFY_FQDN='a.example.com'");
|
||||
});
|
||||
@@ -64,7 +64,7 @@ function generateComposeServiceWithCustomDockerOptions(string $customDockerOptio
|
||||
'server' => $server,
|
||||
'mainServer' => $server,
|
||||
'pull_request_id' => 0,
|
||||
'container_name' => $application->uuid,
|
||||
'container_name' => 'custom-internal-name',
|
||||
'production_image_name' => 'example/app:latest',
|
||||
'deployment_uuid' => 'deployment-uuid',
|
||||
'workdir' => '/artifacts/custom-docker-options-app',
|
||||
@@ -82,7 +82,8 @@ function generateComposeServiceWithCustomDockerOptions(string $customDockerOptio
|
||||
|
||||
it('applies an entrypoint when consistent naming and a custom internal name are configured', function () {
|
||||
expect(generateComposeServiceWithCustomDockerOptions('--entrypoint "/bin/echo hello world"'))
|
||||
->toHaveKey('entrypoint', '/bin/echo hello world');
|
||||
->toHaveKey('entrypoint', '/bin/echo hello world')
|
||||
->toHaveKey('container_name', 'custom-internal-name');
|
||||
});
|
||||
|
||||
it('preserves custom network aliases when a static IP is configured', function () {
|
||||
|
||||
@@ -26,3 +26,14 @@ it('generates commit links for direct repository remotes', function (string $rep
|
||||
'https://bitbucket.org/coollabsio/coolify/commits/1234567890abcdef',
|
||||
],
|
||||
]);
|
||||
|
||||
it('does not generate commit links from incomplete repository URLs', function (string $repository) {
|
||||
$application = new Application;
|
||||
$application->setRelation('source', null);
|
||||
$application->git_repository = $repository;
|
||||
|
||||
expect($application->gitCommitLink('1234567890abcdef'))->toBeNull();
|
||||
})->with([
|
||||
'missing host' => 'https://',
|
||||
'missing scheme' => 'github.com/coollabsio/coolify',
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CleanupHelperContainersJob;
|
||||
use App\Models\Server;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
it('matches helper image references without matching similarly named images', function () {
|
||||
$command = (new ReflectionMethod(CleanupHelperContainersJob::class, 'helperContainersCommand'))->invoke(null);
|
||||
$directory = sys_get_temp_dir().'/coolify-helper-filter-'.bin2hex(random_bytes(4));
|
||||
$docker = $directory.'/docker';
|
||||
$images = [
|
||||
'coollabsio/coolify-helper:1.0.15',
|
||||
'docker.io/coollabsio/coolify-helper:1.0.16',
|
||||
'ghcr.io/coollabsio/coolify-helper@sha256:abc',
|
||||
'registry.example/team/coollabsio/coolify-helper:latest',
|
||||
'coollabsio/coolify:latest',
|
||||
'coollabsio/coolify:4.3.12',
|
||||
'coollabsio/coolify-realtime:1.0.10',
|
||||
'coolify-helper:latest',
|
||||
'someone/coolify-helper:1.0.16',
|
||||
'evil/coollabsio/coolify-helper-copy:latest',
|
||||
'coollabsio/not-coolify-helper:latest',
|
||||
];
|
||||
|
||||
mkdir($directory);
|
||||
file_put_contents($docker, "#!/bin/sh\n".implode("\n", array_map(
|
||||
fn (string $image): string => 'echo '.escapeshellarg(json_encode(['Image' => $image], JSON_THROW_ON_ERROR)),
|
||||
$images
|
||||
))."\n");
|
||||
chmod($docker, 0755);
|
||||
|
||||
try {
|
||||
$process = new Process(['/bin/sh', '-c', $command], env: [
|
||||
'PATH' => $directory.':'.getenv('PATH'),
|
||||
]);
|
||||
$process->mustRun();
|
||||
|
||||
expect(array_column(json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR), 'Image'))
|
||||
->toBe(array_slice($images, 0, 4));
|
||||
} finally {
|
||||
unlink($docker);
|
||||
rmdir($directory);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves the helper image filter for non-root servers', function () {
|
||||
$command = (new ReflectionMethod(CleanupHelperContainersJob::class, 'helperContainersCommand'))->invoke(null);
|
||||
$server = Mockery::mock(Server::class)->makePartial();
|
||||
$server->user = 'ubuntu';
|
||||
$command = parseCommandsByLineForSudo(collect([$command]), $server)[0];
|
||||
$directory = sys_get_temp_dir().'/coolify-helper-sudo-filter-'.bin2hex(random_bytes(4));
|
||||
$docker = $directory.'/docker';
|
||||
$sudo = $directory.'/sudo';
|
||||
|
||||
mkdir($directory);
|
||||
file_put_contents($docker, "#!/bin/sh\necho '{\"Image\":\"coollabsio/coolify-helper:1.0.15\"}'\n");
|
||||
file_put_contents($sudo, "#!/bin/sh\nexec \"\$@\"\n");
|
||||
chmod($docker, 0755);
|
||||
chmod($sudo, 0755);
|
||||
|
||||
try {
|
||||
$process = new Process(['/bin/sh', '-c', $command], env: [
|
||||
'PATH' => $directory.':'.getenv('PATH'),
|
||||
]);
|
||||
$process->mustRun();
|
||||
|
||||
expect(array_column(json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR), 'Image'))
|
||||
->toBe(['coollabsio/coolify-helper:1.0.15']);
|
||||
} finally {
|
||||
unlink($docker);
|
||||
unlink($sudo);
|
||||
rmdir($directory);
|
||||
}
|
||||
});
|
||||
@@ -70,12 +70,12 @@ describe('aggregateFromStrings', function () {
|
||||
expect($result)->toBe('running:unknown');
|
||||
});
|
||||
|
||||
test('returns degraded:unhealthy for crash loop (exited with restart count)', function () {
|
||||
test('returns exited for an exited container with a restart count', function () {
|
||||
$statuses = collect(['exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 5);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
expect($result)->toBe('exited');
|
||||
});
|
||||
|
||||
test('returns exited for exited containers without restart count', function () {
|
||||
@@ -214,12 +214,12 @@ describe('aggregateFromStrings', function () {
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('prioritizes crash loop over running containers', function () {
|
||||
test('returns exited when all containers are exited with restart counts', function () {
|
||||
$statuses = collect(['exited', 'exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 3);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
expect($result)->toBe('exited');
|
||||
});
|
||||
|
||||
test('prioritizes mixed state over healthy running', function () {
|
||||
@@ -238,12 +238,12 @@ describe('aggregateFromStrings', function () {
|
||||
expect($result)->toBe('starting:unknown');
|
||||
});
|
||||
|
||||
test('prioritizes running over paused/exited when no starting', function () {
|
||||
test('returns degraded for mixed running and exited containers', function () {
|
||||
$statuses = collect(['running:healthy', 'paused', 'exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('running:healthy');
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('prioritizes dead over paused/starting/exited', function () {
|
||||
@@ -357,7 +357,7 @@ describe('aggregateFromContainers', function () {
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('returns degraded:unhealthy for crash loop (exited with restart count)', function () {
|
||||
test('returns exited for an exited container object with a restart count', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
@@ -368,7 +368,7 @@ describe('aggregateFromContainers', function () {
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers, maxRestartCount: 5);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
expect($result)->toBe('exited');
|
||||
});
|
||||
|
||||
test('returns exited for exited containers without restart count', function () {
|
||||
@@ -501,7 +501,7 @@ describe('state priority enforcement', function () {
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('crash loop has third highest priority', function () {
|
||||
test('mixed running and exited containers are degraded before paused or starting states', function () {
|
||||
$statuses = collect([
|
||||
'exited',
|
||||
'running:healthy',
|
||||
@@ -602,31 +602,29 @@ describe('maxRestartCount validation', function () {
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 0);
|
||||
|
||||
// Zero is valid default - no crash loop detection
|
||||
expect($result)->toBe('exited');
|
||||
});
|
||||
|
||||
test('positive maxRestartCount works correctly', function () {
|
||||
test('positive maxRestartCount does not override an exited state', function () {
|
||||
$statuses = collect(['exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 5);
|
||||
|
||||
// Positive value enables crash loop detection
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
expect($result)->toBe('exited');
|
||||
});
|
||||
|
||||
test('crash loop detection still functions after validation', function () {
|
||||
test('exited state is preserved for any positive restart count', function () {
|
||||
$statuses = collect(['exited']);
|
||||
|
||||
// Test with various positive restart counts
|
||||
expect($this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 1))
|
||||
->toBe('degraded:unhealthy');
|
||||
->toBe('exited');
|
||||
|
||||
expect($this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 100))
|
||||
->toBe('degraded:unhealthy');
|
||||
->toBe('exited');
|
||||
|
||||
expect($this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 999))
|
||||
->toBe('degraded:unhealthy');
|
||||
->toBe('exited');
|
||||
});
|
||||
|
||||
test('default maxRestartCount parameter works', function () {
|
||||
|
||||
@@ -29,6 +29,14 @@ it('ensures label parsing converts array values to strings', function () {
|
||||
->toContain('$removedLabel = (string) collect($removedLabel)->first();');
|
||||
});
|
||||
|
||||
it('falls back to the template port for service application proxy labels', function () {
|
||||
$sharedFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php');
|
||||
|
||||
expect($sharedFile)->toContain(
|
||||
'? ($savedService->getRequiredPort() ?? $predefinedPort)'
|
||||
);
|
||||
});
|
||||
|
||||
it('verifies label parsing array check occurs before preg_match', function () {
|
||||
// Read the parseDockerComposeFile function from shared.php
|
||||
$sharedFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php');
|
||||
|
||||
@@ -27,6 +27,14 @@ it('StandaloneDocker accepts valid network names', function (string $network) {
|
||||
'alphanumeric' => 'network123',
|
||||
]);
|
||||
|
||||
it('creates standalone destinations with a bridge network', function () {
|
||||
$model = new StandaloneDocker;
|
||||
$model->network = 'test-network';
|
||||
|
||||
expect($model->networkCreateCommand())
|
||||
->toBe("docker network inspect 'test-network' >/dev/null 2>&1 || docker network create --attachable 'test-network' >/dev/null");
|
||||
});
|
||||
|
||||
it('SwarmDocker rejects network names with shell metacharacters', function (string $network) {
|
||||
$model = new SwarmDocker;
|
||||
$model->network = $network;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Support\DomainPortOverrides;
|
||||
|
||||
it('copies the source port override to an automatically paired domain', function (string $source, string $counterpart) {
|
||||
$result = DomainPortOverrides::normalize(
|
||||
"$source,$counterpart",
|
||||
[$source => 8080],
|
||||
);
|
||||
|
||||
expect($result['overrides'])->toBe([
|
||||
$source => 8080,
|
||||
$counterpart => 8080,
|
||||
]);
|
||||
})->with([
|
||||
'www redirect' => ['https://example.com', 'https://www.example.com'],
|
||||
'non-www redirect' => ['https://www.example.com', 'https://example.com'],
|
||||
]);
|
||||
|
||||
it('keeps an explicit override on the paired domain', function (string $source, string $counterpart) {
|
||||
$result = DomainPortOverrides::normalize(
|
||||
"$source,$counterpart",
|
||||
[
|
||||
$source => 8080,
|
||||
$counterpart => 9090,
|
||||
],
|
||||
);
|
||||
|
||||
expect($result['overrides'])->toBe([
|
||||
$source => 8080,
|
||||
$counterpart => 9090,
|
||||
]);
|
||||
})->with([
|
||||
'www redirect' => ['https://example.com', 'https://www.example.com'],
|
||||
'non-www redirect' => ['https://www.example.com', 'https://example.com'],
|
||||
]);
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
it('includes an Executor one-click service template', function () {
|
||||
$templatePath = __DIR__.'/../../templates/compose/executor.yaml';
|
||||
|
||||
expect($templatePath)->toBeFile();
|
||||
|
||||
$compose = file_get_contents($templatePath);
|
||||
|
||||
expect($compose)
|
||||
->toContain('ghcr.io/rhyssullivan/executor-selfhost:${EXECUTOR_VERSION:-latest}')
|
||||
->toContain('SERVICE_URL_EXECUTOR_4788')
|
||||
->toContain('EXECUTOR_WEB_BASE_URL=${SERVICE_URL_EXECUTOR_4788}')
|
||||
->toContain('executor-data:/data')
|
||||
->toContain('http://127.0.0.1:${process.env.PORT||4788}/api/health');
|
||||
|
||||
foreach (['service-templates.json', 'service-templates-latest.json'] as $templateFile) {
|
||||
$templateCatalogPath = __DIR__."/../../templates/{$templateFile}";
|
||||
$templateCatalog = file_get_contents($templateCatalogPath);
|
||||
|
||||
if ($templateCatalog === false) {
|
||||
throw new RuntimeException("Unable to read service template catalog at {$templateCatalogPath}.");
|
||||
}
|
||||
|
||||
$templates = json_decode(
|
||||
$templateCatalog,
|
||||
associative: true,
|
||||
flags: JSON_THROW_ON_ERROR,
|
||||
);
|
||||
|
||||
expect($templates)->toHaveKey('executor');
|
||||
expect($templates['executor']['port'] ?? null)->toBe('4788');
|
||||
expect($templates['executor']['logo'] ?? null)->toBe('svgs/executor.png');
|
||||
expect($templates['executor']['category'] ?? null)->toBe('development');
|
||||
|
||||
$generatedCompose = base64_decode($templates['executor']['compose'], strict: true);
|
||||
|
||||
expect($generatedCompose)
|
||||
->toContain('ghcr.io/rhyssullivan/executor-selfhost:${EXECUTOR_VERSION:-latest}')
|
||||
->toContain('http://127.0.0.1:${process.env.PORT||4788}/api/health')
|
||||
->toContain($templateFile === 'service-templates.json'
|
||||
? 'EXECUTOR_WEB_BASE_URL=${SERVICE_FQDN_EXECUTOR_4788}'
|
||||
: 'EXECUTOR_WEB_BASE_URL=${SERVICE_URL_EXECUTOR_4788}');
|
||||
}
|
||||
});
|
||||
|
||||
it('ships the Executor service icon from the public path used by the service picker', function () {
|
||||
expect(__DIR__.'/../../public/svgs/executor.png')->toBeFile();
|
||||
});
|
||||
@@ -232,3 +232,55 @@ describe('Caddy noindex header', function () {
|
||||
)->all())->toBeEmpty();
|
||||
});
|
||||
});
|
||||
|
||||
test('fqdnLabelsForCaddy routes each portless domain to its override port', function () {
|
||||
$labels = fqdnLabelsForCaddy(
|
||||
network: 'testnetwork',
|
||||
uuid: 'appuuid',
|
||||
domains: collect(['https://one.example.com', 'https://two.example.com']),
|
||||
onlyPort: 80,
|
||||
is_force_https_enabled: true,
|
||||
domainPortOverrides: [
|
||||
'https://one.example.com' => 3000,
|
||||
'https://two.example.com' => 8080,
|
||||
],
|
||||
)->values()->all();
|
||||
|
||||
expect($labels)
|
||||
->toContain('caddy_0=https://one.example.com')
|
||||
->toContain('caddy_0.handle_path.0_reverse_proxy={{upstreams 3000}}')
|
||||
->toContain('caddy_1=https://two.example.com')
|
||||
->toContain('caddy_1.handle_path.1_reverse_proxy={{upstreams 8080}}')
|
||||
->not->toContain('caddy_0=https://one.example.com:3000')
|
||||
->not->toContain('caddy_1=https://two.example.com:8080');
|
||||
});
|
||||
|
||||
test('fqdnLabelsForCaddy uses onlyPort when a portless domain has no override', function () {
|
||||
$labels = fqdnLabelsForCaddy(
|
||||
network: 'testnetwork',
|
||||
uuid: 'appuuid',
|
||||
domains: collect(['https://plain.example.com']),
|
||||
onlyPort: 4000,
|
||||
is_force_https_enabled: true,
|
||||
domainPortOverrides: [],
|
||||
)->values()->all();
|
||||
|
||||
expect($labels)
|
||||
->toContain('caddy_0=https://plain.example.com')
|
||||
->toContain('caddy_0.handle_path.0_reverse_proxy={{upstreams 4000}}');
|
||||
});
|
||||
|
||||
test('fqdnLabelsForCaddy keeps routing a legacy port-bearing FQDN without an override map', function () {
|
||||
$labels = fqdnLabelsForCaddy(
|
||||
network: 'testnetwork',
|
||||
uuid: 'appuuid',
|
||||
domains: collect(['https://legacy.example.com:9090']),
|
||||
onlyPort: 80,
|
||||
is_force_https_enabled: true,
|
||||
domainPortOverrides: [],
|
||||
)->values()->all();
|
||||
|
||||
expect($labels)
|
||||
->toContain('caddy_0=https://legacy.example.com')
|
||||
->toContain('caddy_0.handle_path.0_reverse_proxy={{upstreams 9090}}');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
it('uses Livewire navigation after deleting or converting page resources', function (string $path, array $redirects) {
|
||||
$contents = file_get_contents(dirname(__DIR__, 2).'/'.$path);
|
||||
|
||||
foreach ($redirects as $redirect) {
|
||||
expect($contents)->toContain($redirect);
|
||||
}
|
||||
})->with([
|
||||
'S3 storage' => [
|
||||
'app/Livewire/Storage/Show.php',
|
||||
["redirectRoute(\$this, 'storage.index')"],
|
||||
],
|
||||
'database backup schedule' => [
|
||||
'app/Livewire/Project/Database/BackupEdit.php',
|
||||
[
|
||||
"redirectRoute(\$this, 'project.service.database.backups'",
|
||||
"redirectRoute(\$this, 'project.database.backup.index'",
|
||||
],
|
||||
],
|
||||
'scheduled task' => [
|
||||
'app/Livewire/Project/Shared/ScheduledTask/Show.php',
|
||||
[
|
||||
"redirectRoute(\$this, 'project.application.scheduled-tasks.show'",
|
||||
"redirectRoute(\$this, 'project.service.scheduled-tasks.show'",
|
||||
],
|
||||
],
|
||||
'GitHub source' => [
|
||||
'app/Livewire/Source/Github/Change.php',
|
||||
["redirectRoute(\$this, 'source.all')"],
|
||||
],
|
||||
'GitLab source' => [
|
||||
'app/Livewire/Source/Gitlab/Change.php',
|
||||
["redirectRoute(\$this, 'source.all')"],
|
||||
],
|
||||
'service resources' => [
|
||||
'app/Livewire/Project/Service/Index.php',
|
||||
[
|
||||
"return redirectRoute(\$this, 'project.service.configuration', \$this->parameters);",
|
||||
"return redirectRoute(\$this, 'project.service.configuration', \$redirectParams);",
|
||||
],
|
||||
],
|
||||
]);
|
||||
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
use PhpParser\Node;
|
||||
use PhpParser\Node\Expr\ConstFetch;
|
||||
use PhpParser\Node\Expr\MethodCall;
|
||||
use PhpParser\Node\Identifier;
|
||||
use PhpParser\Node\Stmt\ClassMethod;
|
||||
use PhpParser\Node\Stmt\If_;
|
||||
use PhpParser\Node\Stmt\Return_;
|
||||
use PhpParser\Node\Stmt\TryCatch;
|
||||
use PhpParser\NodeFinder;
|
||||
use PhpParser\ParserFactory;
|
||||
|
||||
/** @return list<array{path: string, method: ClassMethod}> */
|
||||
function livewireSyncDataMethods(string $directory): array
|
||||
{
|
||||
$methods = [];
|
||||
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory));
|
||||
$parser = (new ParserFactory)->createForNewestSupportedVersion();
|
||||
$finder = new NodeFinder;
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if (! $file->isFile() || $file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
$path = $file->getPathname();
|
||||
$contents = file_get_contents($path);
|
||||
if ($contents === false) {
|
||||
continue;
|
||||
}
|
||||
foreach ($finder->findInstanceOf($parser->parse($contents) ?? [], ClassMethod::class) as $method) {
|
||||
if (preg_match('/^sync(Data|DatabaseData|ApplicationData)/', $method->name->toString())) {
|
||||
$methods[] = ['path' => $path, 'method' => $method];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
usort($methods, fn (array $a, array $b): int => [$a['path'], $a['method']->name->toString()] <=> [$b['path'], $b['method']->name->toString()]);
|
||||
|
||||
return $methods;
|
||||
}
|
||||
|
||||
function livewireMethodCallName(MethodCall $call): ?string
|
||||
{
|
||||
return $call->name instanceof Identifier ? $call->name->toString() : null;
|
||||
}
|
||||
|
||||
function livewireIsWriteSyncCall(MethodCall $call, string $syncMethod): bool
|
||||
{
|
||||
if (livewireMethodCallName($call) !== $syncMethod) {
|
||||
return false;
|
||||
}
|
||||
foreach ($call->args as $position => $argument) {
|
||||
if (($position === 0 || $argument->name?->toString() === 'toModel') && $argument->value instanceof ConstFetch && $argument->value->name->toLowerString() === 'true') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @param list<Node\Stmt> $statements @param list<int> $unauthorizedLines */
|
||||
function livewireScanStatements(array $statements, string $syncMethod, bool $authorized, array &$unauthorizedLines): bool
|
||||
{
|
||||
foreach ($statements as $statement) {
|
||||
$authorized = livewireScanNode($statement, $syncMethod, $authorized, $unauthorizedLines);
|
||||
}
|
||||
|
||||
return $authorized;
|
||||
}
|
||||
|
||||
/** @param list<int> $unauthorizedLines */
|
||||
function livewireScanNode(mixed $node, string $syncMethod, bool $authorized, array &$unauthorizedLines): bool
|
||||
{
|
||||
if (! $node instanceof Node && ! is_array($node)) {
|
||||
return $authorized;
|
||||
}
|
||||
if (is_array($node)) {
|
||||
return livewireScanStatements($node, $syncMethod, $authorized, $unauthorizedLines);
|
||||
}
|
||||
if ($node instanceof TryCatch) {
|
||||
$tryAuthorized = livewireScanStatements($node->stmts, $syncMethod, $authorized, $unauthorizedLines);
|
||||
$allCatchesTerminate = $node->catches !== [];
|
||||
foreach ($node->catches as $catch) {
|
||||
livewireScanStatements($catch->stmts, $syncMethod, $authorized, $unauthorizedLines);
|
||||
$allCatchesTerminate = $allCatchesTerminate && collect($catch->stmts)->contains(fn (Node\Stmt $statement): bool => $statement instanceof Return_);
|
||||
}
|
||||
if ($node->finally !== null) {
|
||||
livewireScanStatements($node->finally->stmts, $syncMethod, $authorized, $unauthorizedLines);
|
||||
}
|
||||
|
||||
return $allCatchesTerminate ? $tryAuthorized : $authorized;
|
||||
}
|
||||
if ($node instanceof If_) {
|
||||
livewireScanNode($node->cond, $syncMethod, $authorized, $unauthorizedLines);
|
||||
livewireScanStatements($node->stmts, $syncMethod, $authorized, $unauthorizedLines);
|
||||
foreach ($node->elseifs as $elseif) {
|
||||
livewireScanNode($elseif->cond, $syncMethod, $authorized, $unauthorizedLines);
|
||||
livewireScanStatements($elseif->stmts, $syncMethod, $authorized, $unauthorizedLines);
|
||||
}
|
||||
if ($node->else !== null) {
|
||||
livewireScanStatements($node->else->stmts, $syncMethod, $authorized, $unauthorizedLines);
|
||||
}
|
||||
|
||||
return $authorized;
|
||||
}
|
||||
if ($node instanceof MethodCall) {
|
||||
$name = livewireMethodCallName($node);
|
||||
if ($name === 'authorize') {
|
||||
return true;
|
||||
}
|
||||
if (livewireIsWriteSyncCall($node, $syncMethod) && ! $authorized) {
|
||||
$unauthorizedLines[] = $node->getStartLine();
|
||||
}
|
||||
}
|
||||
foreach ($node->getSubNodeNames() as $name) {
|
||||
$authorized = livewireScanNode($node->{$name}, $syncMethod, $authorized, $unauthorizedLines);
|
||||
}
|
||||
|
||||
return $authorized;
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
function livewireUnauthorizedSyncWriteLines(ClassMethod $method, string $syncMethod): array
|
||||
{
|
||||
$lines = [];
|
||||
livewireScanStatements($method->stmts ?? [], $syncMethod, false, $lines);
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
function livewireMethodFromSource(string $source, string $method): ClassMethod
|
||||
{
|
||||
$nodes = (new ParserFactory)->createForNewestSupportedVersion()->parse($source) ?? [];
|
||||
|
||||
return (new NodeFinder)->findFirst($nodes, fn (Node $node): bool => $node instanceof ClassMethod && $node->name->toString() === $method);
|
||||
}
|
||||
|
||||
it('keeps every Livewire syncData helper private', function () {
|
||||
$violations = [];
|
||||
foreach (livewireSyncDataMethods(dirname(__DIR__, 2).'/app/Livewire') as $syncMethod) {
|
||||
if (! $syncMethod['method']->isPrivate()) {
|
||||
$relative = str_replace(dirname(__DIR__, 2).'/', '', $syncMethod['path']);
|
||||
$violations[] = "{$relative}::{$syncMethod['method']->name}() is not private";
|
||||
}
|
||||
}
|
||||
expect($violations)->toBeEmpty("Livewire syncData helpers must be private:\n".implode("\n", $violations));
|
||||
});
|
||||
|
||||
it('discovers files that only define syncApplicationData helpers', function () {
|
||||
$directory = sys_get_temp_dir().'/livewire-sync-'.uniqid();
|
||||
mkdir($directory);
|
||||
file_put_contents($directory.'/Example.php', '<?php class Example { private function syncApplicationData(bool $toModel = false) {} }');
|
||||
|
||||
$methods = livewireSyncDataMethods($directory);
|
||||
|
||||
unlink($directory.'/Example.php');
|
||||
rmdir($directory);
|
||||
|
||||
expect($methods)->toHaveCount(1)
|
||||
->and($methods[0]['method']->name->toString())->toBe('syncApplicationData');
|
||||
});
|
||||
|
||||
it('detects named write arguments and does not carry authorization into catch paths', function () {
|
||||
$method = livewireMethodFromSource(<<<'PHP'
|
||||
<?php
|
||||
class Example {
|
||||
public function instantSave() {
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->syncData(toModel: true);
|
||||
} catch (Throwable $exception) {
|
||||
$this->syncData(toModel: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
PHP, 'instantSave');
|
||||
|
||||
expect(livewireUnauthorizedSyncWriteLines($method, 'syncData'))->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('authorizes before every syncData write call', function () {
|
||||
$violations = [];
|
||||
$root = dirname(__DIR__, 2);
|
||||
foreach (livewireSyncDataMethods($root.'/app/Livewire') as $syncMethod) {
|
||||
$contents = file_get_contents($syncMethod['path']);
|
||||
$nodes = (new ParserFactory)->createForNewestSupportedVersion()->parse($contents) ?? [];
|
||||
foreach ((new NodeFinder)->findInstanceOf($nodes, ClassMethod::class) as $caller) {
|
||||
foreach (livewireUnauthorizedSyncWriteLines($caller, $syncMethod['method']->name->toString()) as $line) {
|
||||
$relative = str_replace($root.'/', '', $syncMethod['path']);
|
||||
$violations[] = "{$relative}:{$line}::{$caller->name}() calls {$syncMethod['method']->name}(true) without prior authorization";
|
||||
}
|
||||
}
|
||||
}
|
||||
expect($violations)->toBeEmpty("Missing authorization before syncData write calls:\n".implode("\n", array_unique($violations)));
|
||||
});
|
||||
@@ -23,8 +23,8 @@ it('publishes v4 branch builds under the commit sha with a traceable internal ve
|
||||
->toContain('ARG COOLIFY_VERSION')
|
||||
->toContain('ENV COOLIFY_VERSION=${COOLIFY_VERSION}')
|
||||
->and($constants)
|
||||
->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.11'")
|
||||
->and($versions['coolify']['v4']['version'])->toBe('4.3.11')
|
||||
->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.18'")
|
||||
->and($versions['coolify']['v4']['version'])->toBe('4.3.18')
|
||||
->and($versions['coolify']['nightly']['version'])->toBe('4.4-rc.1')
|
||||
->and($nightlyVersions)->toBe($versions);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\PushServerUpdateJob;
|
||||
|
||||
test('database status update declares a void return type', function () {
|
||||
$method = new ReflectionMethod(PushServerUpdateJob::class, 'updateDatabaseStatus');
|
||||
|
||||
expect($method->getReturnType()?->getName())->toBe('void');
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
it('does not keep the unreliable generic stopped container notification path', function () {
|
||||
$statusAction = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
$telegramChannel = file_get_contents(__DIR__.'/../../app/Notifications/Channels/TelegramChannel.php');
|
||||
|
||||
expect($statusAction)->not->toContain('ContainerStopped')
|
||||
->and($telegramChannel)->not->toContain('ContainerStopped')
|
||||
->and(file_exists(__DIR__.'/../../app/Notifications/Container/ContainerStopped.php'))->toBeFalse()
|
||||
->and(file_exists(__DIR__.'/../../resources/views/emails/container-stopped.blade.php'))->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
use App\Services\RestartCountTracker;
|
||||
|
||||
it('starts a new generation when an active container restart count drops', function () {
|
||||
$result = (new RestartCountTracker)->evaluate(
|
||||
previousRestartCount: 11,
|
||||
observedRestartCount: 0,
|
||||
maxRestartCount: 2,
|
||||
newGenerationConfirmed: true,
|
||||
);
|
||||
|
||||
expect($result)->toMatchArray([
|
||||
'restart_count' => 0,
|
||||
'restart_count_changed' => true,
|
||||
'restart_limit_reached' => false,
|
||||
'new_generation' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
it('evaluates the restart limit immediately in a new generation', function () {
|
||||
$result = (new RestartCountTracker)->evaluate(
|
||||
previousRestartCount: 11,
|
||||
observedRestartCount: 3,
|
||||
maxRestartCount: 2,
|
||||
newGenerationConfirmed: true,
|
||||
);
|
||||
|
||||
expect($result)->toMatchArray([
|
||||
'restart_count' => 3,
|
||||
'restart_count_changed' => true,
|
||||
'restart_limit_reached' => true,
|
||||
'new_generation' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not reset the generation without explicit confirmation', function () {
|
||||
$result = (new RestartCountTracker)->evaluate(
|
||||
previousRestartCount: 11,
|
||||
observedRestartCount: 0,
|
||||
maxRestartCount: 2,
|
||||
);
|
||||
|
||||
expect($result)->toMatchArray([
|
||||
'restart_count' => 11,
|
||||
'restart_count_changed' => false,
|
||||
'restart_limit_reached' => false,
|
||||
'new_generation' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves the previous count when an active payload omits the container with the previous maximum', function () {
|
||||
$result = (new RestartCountTracker)->evaluate(
|
||||
previousRestartCount: 11,
|
||||
observedRestartCount: 3,
|
||||
maxRestartCount: 20,
|
||||
);
|
||||
|
||||
expect($result)->toMatchArray([
|
||||
'restart_count' => 11,
|
||||
'restart_count_changed' => false,
|
||||
'restart_limit_reached' => false,
|
||||
'new_generation' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('detects a normal threshold crossing', function () {
|
||||
$result = (new RestartCountTracker)->evaluate(
|
||||
previousRestartCount: 1,
|
||||
observedRestartCount: 2,
|
||||
maxRestartCount: 2,
|
||||
);
|
||||
|
||||
expect($result)->toMatchArray([
|
||||
'restart_count' => 2,
|
||||
'restart_count_changed' => true,
|
||||
'restart_limit_reached' => true,
|
||||
'new_generation' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('detects a limit that is enabled below the current observed restart count', function () {
|
||||
$result = (new RestartCountTracker)->evaluate(
|
||||
previousRestartCount: 17,
|
||||
observedRestartCount: 17,
|
||||
maxRestartCount: 10,
|
||||
);
|
||||
|
||||
expect($result)->toMatchArray([
|
||||
'restart_count' => 17,
|
||||
'restart_count_changed' => false,
|
||||
'restart_limit_reached' => true,
|
||||
'new_generation' => false,
|
||||
]);
|
||||
});
|
||||
@@ -198,6 +198,47 @@ YAML;
|
||||
expect($result)->toBe(3000);
|
||||
});
|
||||
|
||||
it('falls back to the one-click template port when SERVICE_URL has no port suffix', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
wordpress:
|
||||
environment:
|
||||
- SERVICE_URL_WORDPRESS
|
||||
- WORDPRESS_DB_HOST=mysql
|
||||
YAML;
|
||||
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->docker_compose_raw = $yaml;
|
||||
$service->shouldReceive('getRequiredPort')->andReturn(80);
|
||||
|
||||
$app = Mockery::mock(ServiceApplication::class)->makePartial();
|
||||
$app->name = 'wordpress';
|
||||
$app->shouldReceive('getAttribute')->with('service')->andReturn($service);
|
||||
$app->service = $service;
|
||||
|
||||
expect($app->getRequiredPort())->toBe(80);
|
||||
});
|
||||
|
||||
it('does not apply the template port to a container without SERVICE_URL or SERVICE_FQDN', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
mysql:
|
||||
environment:
|
||||
- MYSQL_DATABASE=wordpress
|
||||
YAML;
|
||||
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->docker_compose_raw = $yaml;
|
||||
$service->shouldReceive('getRequiredPort')->andReturn(80);
|
||||
|
||||
$app = Mockery::mock(ServiceApplication::class)->makePartial();
|
||||
$app->name = 'mysql';
|
||||
$app->shouldReceive('getAttribute')->with('service')->andReturn($service);
|
||||
$app->service = $service;
|
||||
|
||||
expect($app->getRequiredPort())->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for map-style environment without port', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
|
||||
@@ -15,3 +15,9 @@ test('sentinel startup regenerates an empty endpoint from instance settings', fu
|
||||
->and($component)
|
||||
->toContain('$this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url;');
|
||||
});
|
||||
|
||||
test('sentinel startup allows legacy metrics migrations to finish before health checks fail', function () {
|
||||
$action = file_get_contents(dirname(__DIR__, 2).'/app/Actions/Server/StartSentinel.php');
|
||||
|
||||
expect($action)->toContain('--health-start-period 120s');
|
||||
});
|
||||
|
||||
@@ -6,12 +6,22 @@ it('persists exited status when stopping standalone databases', function () {
|
||||
expect($action)->toContain("'status' => 'exited'");
|
||||
});
|
||||
|
||||
it('does not change Docker restart policies when retaining stopped containers', function (string $actionPath) {
|
||||
$action = file_get_contents(__DIR__.'/../../'.$actionPath);
|
||||
|
||||
expect($action)->not->toContain('docker update --restart=no');
|
||||
})->with([
|
||||
'applications' => 'app/Actions/Application/StopApplication.php',
|
||||
'application previews' => 'app/Actions/Application/StopApplicationPreview.php',
|
||||
'service applications' => 'app/Actions/Service/StopServiceApplication.php',
|
||||
'standalone databases' => 'app/Actions/Database/StopDatabase.php',
|
||||
]);
|
||||
|
||||
it('persists exited status for every full application stop path', function () {
|
||||
$action = file_get_contents(__DIR__.'/../../app/Actions/Application/StopApplication.php');
|
||||
|
||||
expect($action)
|
||||
->toContain("\$status = ['status' => 'exited'];")
|
||||
->toContain('$application->update($status);')
|
||||
->toMatch('/\$status\s*=\s*\[\s*\'status\'\s*=>\s*\'exited\',.*?\];.*?\$application->update\(\$status\);/s')
|
||||
->not->toMatch('/docker stack rm .*?return;/s');
|
||||
});
|
||||
|
||||
@@ -19,8 +29,10 @@ it('persists exited status for all children when stopping a service', function (
|
||||
$action = file_get_contents(__DIR__.'/../../app/Actions/Service/StopService.php');
|
||||
|
||||
expect($action)
|
||||
->toContain("\$applications->each->update(['status' => 'exited']);")
|
||||
->toContain("\$dbs->each->update(['status' => 'exited']);");
|
||||
->toContain("\$application->update(['status' => 'exited']);")
|
||||
->toContain('$application->resetRestartLimit();')
|
||||
->toContain("\$database->update(['status' => 'exited']);")
|
||||
->not->toContain('$database->resetRestartLimit();');
|
||||
});
|
||||
|
||||
it('persists exited status when stopping an individual service resource', function () {
|
||||
@@ -28,6 +40,8 @@ it('persists exited status when stopping an individual service resource', functi
|
||||
|
||||
expect($action)
|
||||
->toContain("\$serviceApplication->update(['status' => 'exited']);")
|
||||
->toContain('$commands = ["docker rm -f {$containerName}"];')
|
||||
->toContain('throwError: ! $removeContainer')
|
||||
->toContain('ServiceStatusChanged::dispatch($service->environment->project->team->id);');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Database\StopDatabase;
|
||||
use App\Models\BaseModel;
|
||||
|
||||
it('declares strict method types', function () {
|
||||
$handle = new ReflectionMethod(StopDatabase::class, 'handle');
|
||||
$stopContainer = new ReflectionMethod(StopDatabase::class, 'stopContainer');
|
||||
|
||||
expect($handle->getReturnType()?->getName())->toBe('string')
|
||||
->and($stopContainer->getParameters()[0]->getType()?->getName())->toBe(BaseModel::class);
|
||||
});
|
||||
@@ -107,3 +107,49 @@ test('application labels keep redirect capture groups single escaped before comp
|
||||
expect($labels)
|
||||
->toContain('traefik.http.middlewares.0-application-uuid-to-www.redirectregex.replacement=${1}://www.${2}');
|
||||
});
|
||||
|
||||
test('fqdnLabelsForTraefik routes each portless domain to its override port', function () {
|
||||
$labels = fqdnLabelsForTraefik(
|
||||
uuid: 'appuuid',
|
||||
domains: collect(['https://one.example.com', 'https://two.example.com']),
|
||||
onlyPort: 80,
|
||||
domainPortOverrides: [
|
||||
'https://one.example.com' => 3000,
|
||||
'https://two.example.com' => 8080,
|
||||
],
|
||||
);
|
||||
|
||||
expect($labels)
|
||||
->toContain('traefik.http.routers.https-0-appuuid.rule=Host(`one.example.com`) && PathPrefix(`/`)')
|
||||
->toContain('traefik.http.services.https-0-appuuid.loadbalancer.server.port=3000')
|
||||
->toContain('traefik.http.routers.https-1-appuuid.rule=Host(`two.example.com`) && PathPrefix(`/`)')
|
||||
->toContain('traefik.http.services.https-1-appuuid.loadbalancer.server.port=8080')
|
||||
->not->toContain('Host(`one.example.com:3000`)')
|
||||
->not->toContain('Host(`two.example.com:8080`)');
|
||||
});
|
||||
|
||||
test('fqdnLabelsForTraefik uses onlyPort when a portless domain has no override', function () {
|
||||
$labels = fqdnLabelsForTraefik(
|
||||
uuid: 'appuuid',
|
||||
domains: collect(['https://plain.example.com']),
|
||||
onlyPort: 4000,
|
||||
domainPortOverrides: [],
|
||||
);
|
||||
|
||||
expect($labels)
|
||||
->toContain('traefik.http.routers.https-0-appuuid.rule=Host(`plain.example.com`) && PathPrefix(`/`)')
|
||||
->toContain('traefik.http.services.https-0-appuuid.loadbalancer.server.port=4000');
|
||||
});
|
||||
|
||||
test('fqdnLabelsForTraefik keeps routing a legacy port-bearing FQDN without an override map', function () {
|
||||
$labels = fqdnLabelsForTraefik(
|
||||
uuid: 'appuuid',
|
||||
domains: collect(['https://legacy.example.com:9090']),
|
||||
onlyPort: 80,
|
||||
domainPortOverrides: [],
|
||||
);
|
||||
|
||||
expect($labels)
|
||||
->toContain('traefik.http.routers.https-0-appuuid.rule=Host(`legacy.example.com`) && PathPrefix(`/`)')
|
||||
->toContain('traefik.http.services.https-0-appuuid.loadbalancer.server.port=9090');
|
||||
});
|
||||
|
||||
@@ -161,6 +161,33 @@ KEY',
|
||||
'empty' => '',
|
||||
]);
|
||||
|
||||
it('accepts shell-safe keys for sourced build-time env files', function (string $key) {
|
||||
expect(ValidationPatterns::validatedShellEnvironmentVariableKey($key))->toBe($key);
|
||||
})->with([
|
||||
'letters' => 'APP_ENV',
|
||||
'leading underscore' => '_TOKEN',
|
||||
'digits after first character' => 'NODE_VERSION_20',
|
||||
]);
|
||||
|
||||
it('rejects keys that bash would interpret when sourcing a build-time env file', function (string $key) {
|
||||
expect(fn () => ValidationPatterns::validatedShellEnvironmentVariableKey($key))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
})->with([
|
||||
'command substitution' => 'X$(id)',
|
||||
'dot notation' => 'X.VALUE',
|
||||
'command substitution with arguments' => 'X$(docker run --rm -v /:/mnt alpine true)',
|
||||
]);
|
||||
|
||||
it('makes unsafe environment variable keys safe to show in logs', function () {
|
||||
expect(ValidationPatterns::displayShellEnvironmentVariableKey('APP_ENV'))->toBe('APP_ENV');
|
||||
expect(ValidationPatterns::displayShellEnvironmentVariableKey("X\nid"))->toBe('X\\nid');
|
||||
expect(ValidationPatterns::displayShellEnvironmentVariableKey("X\e[2Jid\x7F"))->toBe('X\\x1B[2Jid\\x7F');
|
||||
expect(ValidationPatterns::displayShellEnvironmentVariableKey(''))->toBe('(empty)');
|
||||
expect(ValidationPatterns::displayShellEnvironmentVariableKey(str_repeat('A', 100)))
|
||||
->toEndWith('...')
|
||||
->toBe(str_repeat('A', 80).'...');
|
||||
});
|
||||
|
||||
it('generates environment variable key rules with correct defaults', function () {
|
||||
$rules = ValidationPatterns::environmentVariableKeyRules();
|
||||
|
||||
@@ -191,3 +218,23 @@ it('normalizes application domain scheme and host without lowercasing path query
|
||||
it('validates application domains with underscores in the hostname', function () {
|
||||
expect(ValidationPatterns::validateApplicationDomains('https://myapp_service.example.com'))->toBeEmpty();
|
||||
});
|
||||
|
||||
it('rejects single-label application hostnames but allows IP addresses', function () {
|
||||
expect(ValidationPatterns::validateApplicationDomains('https://aaa'))->not->toBeEmpty()
|
||||
->and(ValidationPatterns::validateApplicationDomains('https://localhost'))->not->toBeEmpty()
|
||||
->and(ValidationPatterns::validateApplicationDomains('http://192.0.2.10:8000'))->toBeEmpty();
|
||||
});
|
||||
|
||||
it('rejects application domain ports outside the valid TCP range', function (string $domain) {
|
||||
expect(ValidationPatterns::validateApplicationDomains($domain))->not->toBeEmpty();
|
||||
})->with([
|
||||
'zero' => 'https://example.com:0',
|
||||
'above maximum' => 'https://example.com:65536',
|
||||
]);
|
||||
|
||||
it('accepts application domain ports at the TCP range boundaries', function (string $domain) {
|
||||
expect(ValidationPatterns::validateApplicationDomains($domain))->toBeEmpty();
|
||||
})->with([
|
||||
'minimum' => 'https://example.com:1',
|
||||
'maximum' => 'https://example.com:65535',
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user