mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-13 16:14:42 +00:00
Merge remote-tracking branch 'origin/next' into audit-policies
This commit is contained in:
commit
9dca7ca351
148 files changed
+5987
-1101
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);
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationSetting;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\PrivateKey;
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
/**
|
||||
* Git operations authenticate with the SSH key assigned in the UI. Coolify writes that key to a
|
||||
* per-deployment path (/root/.ssh/id_rsa_coolify_<deployment_uuid>) instead of the shared
|
||||
* /root/.ssh/id_rsa, so it can neither overwrite the server root's own key nor race with other
|
||||
* concurrent operations on the same host. `-o IdentitiesOnly=yes` makes ssh offer only that key.
|
||||
* On the host path an EXIT trap removes the key when the shell finishes; the docker path runs in an
|
||||
* ephemeral container, so it needs no cleanup.
|
||||
*/
|
||||
$keyPath = '/root/.ssh/id_rsa_coolify_test-deployment-uuid';
|
||||
|
||||
it('writes a deploy key to a per-deployment path and cleans it up for ls-remote on the host', function () use ($keyPath) {
|
||||
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
|
||||
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
$application->shouldReceive('deploymentType')->andReturn('deploy_key');
|
||||
$application->shouldReceive('customRepository')->andReturn(['repository' => 'git@gitlab.com:user/repo.git', 'port' => 22]);
|
||||
$application->shouldReceive('getAttribute')->with('private_key')->andReturn($privateKey);
|
||||
|
||||
$result = $application->generateGitLsRemoteCommands('test-deployment-uuid', false);
|
||||
|
||||
expect($result['commands'])
|
||||
->toContain("tee {$keyPath}")
|
||||
->toContain("-i {$keyPath} -o IdentitiesOnly=yes")
|
||||
->toContain("trap 'rm -f {$keyPath}' EXIT") // removed when the shell exits
|
||||
->not->toContain('tee /root/.ssh/id_rsa >'); // never overwrites the host root's own key
|
||||
});
|
||||
|
||||
it('writes a deploy key to a per-deployment path for ls-remote inside docker without a trap', function () use ($keyPath) {
|
||||
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
|
||||
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
$application->shouldReceive('deploymentType')->andReturn('deploy_key');
|
||||
$application->shouldReceive('customRepository')->andReturn(['repository' => 'git@gitlab.com:user/repo.git', 'port' => 22]);
|
||||
$application->shouldReceive('getAttribute')->with('private_key')->andReturn($privateKey);
|
||||
|
||||
$result = $application->generateGitLsRemoteCommands('test-deployment-uuid', true);
|
||||
|
||||
expect($result['commands'])
|
||||
->toContain("tee {$keyPath}")
|
||||
->toContain("-i {$keyPath} -o IdentitiesOnly=yes")
|
||||
->not->toContain('trap ') // ephemeral container, no cleanup needed
|
||||
->not->toContain('tee /root/.ssh/id_rsa >');
|
||||
});
|
||||
|
||||
it('writes a GitLab source private key to a per-deployment path with cleanup on the host', function () use ($keyPath) {
|
||||
$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('html_url')->andReturn('https://gitlab.com');
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(1);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
$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);
|
||||
$application->source = $gitlabSource;
|
||||
|
||||
$result = $application->generateGitLsRemoteCommands('test-deployment-uuid', false);
|
||||
|
||||
expect($result['commands'])
|
||||
->toContain("tee {$keyPath}")
|
||||
->toContain("-i {$keyPath} -o IdentitiesOnly=yes")
|
||||
->toContain("trap 'rm -f {$keyPath}' EXIT")
|
||||
->not->toContain('tee /root/.ssh/id_rsa >');
|
||||
});
|
||||
|
||||
it('writes a deploy key to a per-deployment path and cleans it up when cloning on the host', function () use ($keyPath) {
|
||||
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
|
||||
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
|
||||
|
||||
$settings = Mockery::mock(ApplicationSetting::class)->makePartial();
|
||||
$settings->shouldReceive('getAttribute')->with('is_git_shallow_clone_enabled')->andReturn(false);
|
||||
$settings->shouldReceive('getAttribute')->with('is_git_submodules_enabled')->andReturn(false);
|
||||
$settings->shouldReceive('getAttribute')->with('is_git_lfs_enabled')->andReturn(false);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
$application->shouldReceive('deploymentType')->andReturn('deploy_key');
|
||||
$application->shouldReceive('customRepository')->andReturn(['repository' => 'git@gitlab.com:user/repo.git', 'port' => 22]);
|
||||
$application->shouldReceive('getAttribute')->with('private_key')->andReturn($privateKey);
|
||||
$application->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||
$application->shouldReceive('getAttribute')->with('git_commit_sha')->andReturn('HEAD');
|
||||
|
||||
// exec_in_docker = false → the loadComposeFile / host clone path
|
||||
$result = $application->generateGitImportCommands('test-deployment-uuid', 0, null, false);
|
||||
|
||||
expect($result['commands'])
|
||||
->toContain("tee {$keyPath}")
|
||||
->toContain("-i {$keyPath} -o IdentitiesOnly=yes")
|
||||
->toContain("trap 'rm -f {$keyPath}' EXIT")
|
||||
->not->toContain('tee /root/.ssh/id_rsa >');
|
||||
});
|
||||
|
||||
it('writes a GitLab source private key to a per-deployment path and cleans it up when cloning on the host', function () use ($keyPath) {
|
||||
$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('html_url')->andReturn('https://gitlab.com');
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22);
|
||||
|
||||
$settings = Mockery::mock(ApplicationSetting::class)->makePartial();
|
||||
$settings->shouldReceive('getAttribute')->with('is_git_shallow_clone_enabled')->andReturn(false);
|
||||
$settings->shouldReceive('getAttribute')->with('is_git_submodules_enabled')->andReturn(false);
|
||||
$settings->shouldReceive('getAttribute')->with('is_git_lfs_enabled')->andReturn(false);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
$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);
|
||||
$application->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||
$application->shouldReceive('getAttribute')->with('git_commit_sha')->andReturn('HEAD');
|
||||
|
||||
$result = $application->generateGitImportCommands('test-deployment-uuid', 0, null, false);
|
||||
|
||||
expect($result['commands'])
|
||||
->toContain("tee {$keyPath}")
|
||||
->toContain("-i {$keyPath} -o IdentitiesOnly=yes")
|
||||
->toContain("trap 'rm -f {$keyPath}' EXIT")
|
||||
->not->toContain('tee /root/.ssh/id_rsa >');
|
||||
});
|
||||
|
||||
it('uses the per-deployment deploy key for pull request fetches', function () use ($keyPath) {
|
||||
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
|
||||
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
|
||||
|
||||
$settings = Mockery::mock(ApplicationSetting::class)->makePartial();
|
||||
$settings->shouldReceive('getAttribute')->with('is_git_shallow_clone_enabled')->andReturn(false);
|
||||
$settings->shouldReceive('getAttribute')->with('is_git_submodules_enabled')->andReturn(false);
|
||||
$settings->shouldReceive('getAttribute')->with('is_git_lfs_enabled')->andReturn(false);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
$application->shouldReceive('deploymentType')->andReturn('deploy_key');
|
||||
$application->shouldReceive('customRepository')->andReturn(['repository' => 'git@github.com:user/repo.git', 'port' => 22]);
|
||||
$application->shouldReceive('getAttribute')->with('private_key')->andReturn($privateKey);
|
||||
$application->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||
$application->shouldReceive('getAttribute')->with('git_commit_sha')->andReturn('HEAD');
|
||||
|
||||
$result = $application->generateGitImportCommands('test-deployment-uuid', 123, 'github', false);
|
||||
|
||||
expect($result['commands'])
|
||||
->toContain("GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$keyPath} -o IdentitiesOnly=yes\" git fetch origin pull/123/head:pr-123-coolify")
|
||||
->not->toContain('GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa" git fetch origin pull/123/head:pr-123-coolify');
|
||||
});
|
||||
|
||||
it('does not force a missing per-deployment key for other repository pull request fetches', function () use ($keyPath) {
|
||||
$settings = Mockery::mock(ApplicationSetting::class)->makePartial();
|
||||
$settings->shouldReceive('getAttribute')->with('is_git_shallow_clone_enabled')->andReturn(false);
|
||||
$settings->shouldReceive('getAttribute')->with('is_git_submodules_enabled')->andReturn(false);
|
||||
$settings->shouldReceive('getAttribute')->with('is_git_lfs_enabled')->andReturn(false);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
$application->shouldReceive('deploymentType')->andReturn('other');
|
||||
$application->shouldReceive('customRepository')->andReturn(['repository' => 'git@github.com:user/repo.git', 'port' => 22]);
|
||||
$application->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||
$application->shouldReceive('getAttribute')->with('git_commit_sha')->andReturn('HEAD');
|
||||
|
||||
$result = $application->generateGitImportCommands('test-deployment-uuid', 123, 'github', false);
|
||||
|
||||
expect($result['commands'])
|
||||
->toContain('GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa" git fetch origin pull/123/head:pr-123-coolify')
|
||||
->not->toContain($keyPath);
|
||||
});
|
||||
@@ -42,8 +42,8 @@ namespace {
|
||||
|
||||
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");
|
||||
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' -c http.version=HTTP/1.1 clone --recurse-submodules -b 'main'")
|
||||
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' -c http.version=HTTP/1.1 submodule sync")
|
||||
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' -c http.version=HTTP/1.1 submodule update --init --recursive");
|
||||
});
|
||||
}
|
||||
@@ -156,7 +156,7 @@ describe('Git submodule credential propagation', function () {
|
||||
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';
|
||||
$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_coolify_test-uuid -o IdentitiesOnly=yes';
|
||||
|
||||
expect($result['commands'])
|
||||
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git fetch origin merge-requests/123/head:pr-123-coolify')
|
||||
|
||||
@@ -1,427 +1,77 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Security tests for log viewer XSS prevention
|
||||
*
|
||||
* These tests verify that the log viewer components properly sanitize
|
||||
* HTML content to prevent cross-site scripting (XSS) attacks.
|
||||
*/
|
||||
function renderedDataLineTextValue(string $logContent): string
|
||||
{
|
||||
$escapedContent = e($logContent);
|
||||
$html = '<span data-line-text="'.$escapedContent.'">'.$escapedContent.'</span>';
|
||||
|
||||
$document = new DOMDocument;
|
||||
$document->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
|
||||
return $document->documentElement->getAttribute('data-line-text');
|
||||
}
|
||||
|
||||
describe('Log Viewer HTML Tag Preservation', function () {
|
||||
it('lets Blade escape deployment log data attributes only once', function () {
|
||||
$view = file_get_contents(__DIR__.'/../../resources/views/livewire/project/application/deployment/show.blade.php');
|
||||
|
||||
expect($view)
|
||||
->toContain('data-log-content="{{ $searchableContent }}"')
|
||||
->toContain('data-line-text="{{ $lineContent }}"')
|
||||
->not->toContain('data-log-content="{{ htmlspecialchars($searchableContent) }}"')
|
||||
->not->toContain('data-line-text="{{ htmlspecialchars($lineContent) }}"');
|
||||
});
|
||||
|
||||
it('preserves literal html-like log text for client-side search reset and highlighting', function () {
|
||||
$logContent = '<div>A</div>';
|
||||
|
||||
expect(renderedDataLineTextValue($logContent))->toBe($logContent);
|
||||
});
|
||||
|
||||
it('does not strip tags with DOMParser based html decoding', function () {
|
||||
$views = [
|
||||
__DIR__.'/../../resources/views/livewire/project/application/deployment/show.blade.php',
|
||||
__DIR__.'/../../resources/views/livewire/project/shared/get-logs.blade.php',
|
||||
];
|
||||
|
||||
foreach ($views as $view) {
|
||||
expect(file_get_contents($view))
|
||||
->not->toContain('decodeHtml(text)')
|
||||
->not->toContain('DOMParser().parseFromString');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Log Viewer XSS Prevention', function () {
|
||||
it('escapes script tags in log output', function () {
|
||||
it('keeps script-like log output as text in Blade rendered markup', function () {
|
||||
$maliciousLog = '<script>alert("XSS")</script>';
|
||||
$escaped = htmlspecialchars($maliciousLog);
|
||||
$escapedLog = e($maliciousLog);
|
||||
|
||||
expect($escaped)->toContain('<script>');
|
||||
expect($escaped)->not->toContain('<script>');
|
||||
expect($escapedLog)
|
||||
->toContain('<script>')
|
||||
->not->toContain('<script>');
|
||||
});
|
||||
|
||||
it('escapes event handler attributes', function () {
|
||||
$maliciousLog = '<img src=x onerror="alert(\'XSS\')">';
|
||||
$escaped = htmlspecialchars($maliciousLog);
|
||||
it('keeps dangerous attributes as literal dataset text for textContent rendering', function () {
|
||||
$maliciousLog = '<img src=x onerror="alert(1)">';
|
||||
|
||||
expect($escaped)->toContain('<img');
|
||||
expect($escaped)->toContain('onerror');
|
||||
expect($escaped)->not->toContain('<img');
|
||||
expect($escaped)->not->toContain('onerror="alert');
|
||||
expect(renderedDataLineTextValue($maliciousLog))->toBe($maliciousLog);
|
||||
});
|
||||
|
||||
it('escapes javascript: protocol URLs', function () {
|
||||
$maliciousLog = '<a href="javascript:alert(\'XSS\')">click</a>';
|
||||
$escaped = htmlspecialchars($maliciousLog);
|
||||
it('uses text nodes for search highlighting instead of injected html', function () {
|
||||
$views = [
|
||||
__DIR__.'/../../resources/views/livewire/project/application/deployment/show.blade.php',
|
||||
__DIR__.'/../../resources/views/livewire/project/shared/get-logs.blade.php',
|
||||
];
|
||||
|
||||
expect($escaped)->toContain('<a');
|
||||
expect($escaped)->toContain('javascript:');
|
||||
expect($escaped)->not->toContain('<a href=');
|
||||
});
|
||||
foreach ($views as $view) {
|
||||
$contents = file_get_contents($view);
|
||||
|
||||
it('escapes data: URLs with scripts', function () {
|
||||
$maliciousLog = '<iframe src="data:text/html,<script>alert(\'XSS\')</script>">';
|
||||
$escaped = htmlspecialchars($maliciousLog);
|
||||
|
||||
expect($escaped)->toContain('<iframe');
|
||||
expect($escaped)->toContain('data:');
|
||||
expect($escaped)->not->toContain('<iframe');
|
||||
});
|
||||
|
||||
it('escapes style-based XSS attempts', function () {
|
||||
$maliciousLog = '<div style="background:url(\'javascript:alert(1)\')">test</div>';
|
||||
$escaped = htmlspecialchars($maliciousLog);
|
||||
|
||||
expect($escaped)->toContain('<div');
|
||||
expect($escaped)->toContain('style');
|
||||
expect($escaped)->not->toContain('<div style=');
|
||||
});
|
||||
|
||||
it('escapes Alpine.js directive injection', function () {
|
||||
$maliciousLog = '<div x-html="alert(\'XSS\')">test</div>';
|
||||
$escaped = htmlspecialchars($maliciousLog);
|
||||
|
||||
expect($escaped)->toContain('<div');
|
||||
expect($escaped)->toContain('x-html');
|
||||
expect($escaped)->not->toContain('<div x-html=');
|
||||
});
|
||||
|
||||
it('escapes multiple HTML entities', function () {
|
||||
$maliciousLog = '<>&"\'';
|
||||
$escaped = htmlspecialchars($maliciousLog);
|
||||
|
||||
expect($escaped)->toBe('<>&"'');
|
||||
});
|
||||
|
||||
it('preserves legitimate text content', function () {
|
||||
$legitimateLog = 'INFO: Application started successfully';
|
||||
$escaped = htmlspecialchars($legitimateLog);
|
||||
|
||||
expect($escaped)->toBe($legitimateLog);
|
||||
});
|
||||
|
||||
it('handles ANSI color codes after escaping', function () {
|
||||
$logWithAnsi = "\e[31mERROR:\e[0m Something went wrong";
|
||||
$escaped = htmlspecialchars($logWithAnsi);
|
||||
|
||||
// ANSI codes should be preserved in escaped form
|
||||
expect($escaped)->toContain('ERROR');
|
||||
expect($escaped)->toContain('Something went wrong');
|
||||
});
|
||||
|
||||
it('escapes complex nested HTML structures', function () {
|
||||
$maliciousLog = '<div onclick="alert(1)"><img src=x onerror="alert(2)"><script>alert(3)</script></div>';
|
||||
$escaped = htmlspecialchars($maliciousLog);
|
||||
|
||||
expect($escaped)->toContain('<div');
|
||||
expect($escaped)->toContain('<img');
|
||||
expect($escaped)->toContain('<script>');
|
||||
expect($escaped)->not->toContain('<div');
|
||||
expect($escaped)->not->toContain('<img');
|
||||
expect($escaped)->not->toContain('<script>');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests for x-text security approach
|
||||
*
|
||||
* These tests verify that using x-text instead of x-html eliminates XSS risks
|
||||
* by rendering all content as plain text rather than HTML.
|
||||
*/
|
||||
describe('x-text Security', function () {
|
||||
it('verifies x-text renders content as plain text, not HTML', function () {
|
||||
// x-text always renders as textContent, never as innerHTML
|
||||
// This means any HTML tags in the content are displayed as literal text
|
||||
$contentWithHtml = '<script>alert("XSS")</script>';
|
||||
$escaped = htmlspecialchars($contentWithHtml);
|
||||
|
||||
// When stored in data attribute and rendered with x-text:
|
||||
// 1. Server escapes to: <script>alert("XSS")</script>
|
||||
// 2. Browser decodes the attribute value to: <script>alert("XSS")</script>
|
||||
// 3. x-text renders it as textContent (plain text), NOT innerHTML
|
||||
// 4. Result: User sees "<script>alert("XSS")</script>" as text, script never executes
|
||||
|
||||
expect($escaped)->toContain('<script>');
|
||||
expect($escaped)->not->toContain('<script>');
|
||||
});
|
||||
|
||||
it('confirms x-text prevents Alpine.js directive injection', function () {
|
||||
$maliciousContent = '<div x-data="{ evil: true }" x-html="alert(1)">test</div>';
|
||||
$escaped = htmlspecialchars($maliciousContent);
|
||||
|
||||
// Even if attacker includes Alpine directives in log content:
|
||||
// 1. Server escapes them
|
||||
// 2. x-text renders as plain text
|
||||
// 3. Alpine never processes these as directives
|
||||
// 4. User just sees the literal text
|
||||
|
||||
expect($escaped)->toContain('x-data');
|
||||
expect($escaped)->toContain('x-html');
|
||||
expect($escaped)->not->toContain('<div x-data=');
|
||||
});
|
||||
|
||||
it('verifies x-text prevents event handler execution', function () {
|
||||
$maliciousContent = '<img src=x onerror="alert(\'XSS\')">';
|
||||
$escaped = htmlspecialchars($maliciousContent);
|
||||
|
||||
// With x-text approach:
|
||||
// 1. Content is escaped on server
|
||||
// 2. Rendered as textContent, not innerHTML
|
||||
// 3. No HTML parsing means no event handlers
|
||||
// 4. User sees the literal text, no image is rendered, no event fires
|
||||
|
||||
expect($escaped)->toContain('<img');
|
||||
expect($escaped)->toContain('onerror');
|
||||
expect($escaped)->not->toContain('<img');
|
||||
});
|
||||
|
||||
it('verifies x-text prevents style-based attacks', function () {
|
||||
$maliciousContent = '<style>body { display: none; }</style>';
|
||||
$escaped = htmlspecialchars($maliciousContent);
|
||||
|
||||
// x-text renders everything as text:
|
||||
// 1. Style tags never get parsed as HTML
|
||||
// 2. CSS never gets applied
|
||||
// 3. User just sees the literal style tag content
|
||||
|
||||
expect($escaped)->toContain('<style>');
|
||||
expect($escaped)->not->toContain('<style>');
|
||||
});
|
||||
|
||||
it('confirms CSS class-based highlighting is safe', function () {
|
||||
// New approach uses CSS classes for highlighting instead of injecting HTML
|
||||
// The 'log-highlight' class is applied via Alpine.js :class binding
|
||||
// This is safe because:
|
||||
// 1. Class names are controlled by JavaScript, not user input
|
||||
// 2. No HTML injection occurs
|
||||
// 3. CSS provides visual feedback without executing code
|
||||
|
||||
$highlightClass = 'log-highlight';
|
||||
expect($highlightClass)->toBe('log-highlight');
|
||||
expect($highlightClass)->not->toContain('<');
|
||||
expect($highlightClass)->not->toContain('script');
|
||||
});
|
||||
|
||||
it('verifies granular highlighting only marks matching text', function () {
|
||||
// splitTextForHighlight() divides text into parts
|
||||
// Only matching portions get highlight: true
|
||||
// Each part is rendered with x-text (safe plain text)
|
||||
// Highlight class applied only to matching spans
|
||||
|
||||
$logLine = 'ERROR: Database connection failed';
|
||||
$searchQuery = 'ERROR';
|
||||
|
||||
// When searching for "ERROR":
|
||||
// Part 1: { text: "ERROR", highlight: true } <- highlighted
|
||||
// Part 2: { text: ": Database connection failed", highlight: false } <- not highlighted
|
||||
|
||||
// This ensures only the search term is highlighted, not the entire line
|
||||
expect($logLine)->toContain($searchQuery);
|
||||
expect(strlen($searchQuery))->toBeLessThan(strlen($logLine));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Integration documentation tests
|
||||
*
|
||||
* These tests document the expected flow of log sanitization with x-text
|
||||
*/
|
||||
describe('Log Sanitization Flow with x-text', function () {
|
||||
it('documents the secure x-text rendering flow', function () {
|
||||
$rawLog = '<script>alert("XSS")</script>';
|
||||
|
||||
// Step 1: Server-side escaping (PHP)
|
||||
$escaped = htmlspecialchars($rawLog);
|
||||
expect($escaped)->toBe('<script>alert("XSS")</script>');
|
||||
|
||||
// Step 2: Stored in data-log-content attribute
|
||||
// <div data-log-content="<script>alert("XSS")</script>" x-text="getDisplayText($el.dataset.logContent)">
|
||||
|
||||
// Step 3: Client-side getDisplayText() decodes HTML entities
|
||||
// const decoded = doc.documentElement.textContent;
|
||||
// Result: '<script>alert("XSS")</script>' (as text string)
|
||||
|
||||
// Step 4: x-text renders as textContent (NOT innerHTML)
|
||||
// Alpine.js sets element.textContent = decoded
|
||||
// Result: Browser displays '<script>alert("XSS")</script>' as visible text
|
||||
// The script tag is never parsed or executed - it's just text
|
||||
|
||||
// Step 5: Highlighting via CSS class
|
||||
// If search query matches, 'log-highlight' class is added
|
||||
// Visual feedback is provided through CSS, not HTML injection
|
||||
});
|
||||
|
||||
it('documents search highlighting with CSS classes', function () {
|
||||
$legitimateLog = '2024-01-01T12:00:00.000Z ERROR: Database connection failed';
|
||||
|
||||
// Server-side: Escape and store
|
||||
$escaped = htmlspecialchars($legitimateLog);
|
||||
expect($escaped)->toBe($legitimateLog); // No special chars
|
||||
|
||||
// Client-side: If user searches for "ERROR"
|
||||
// 1. splitTextForHighlight() divides the text into parts:
|
||||
// - Part 1: "2024-01-01T12:00:00.000Z " (highlight: false)
|
||||
// - Part 2: "ERROR" (highlight: true) <- This part gets highlighted
|
||||
// - Part 3: ": Database connection failed" (highlight: false)
|
||||
// 2. Each part is rendered as a <span> with x-text (safe)
|
||||
// 3. Only Part 2 gets the 'log-highlight' class via :class binding
|
||||
// 4. CSS provides yellow/warning background color on "ERROR" only
|
||||
// 5. No HTML injection occurs - just multiple safe text spans
|
||||
|
||||
expect($legitimateLog)->toContain('ERROR');
|
||||
});
|
||||
|
||||
it('verifies no HTML injection occurs during search', function () {
|
||||
$logWithHtml = 'User input: <img src=x onerror="alert(1)">';
|
||||
$escaped = htmlspecialchars($logWithHtml);
|
||||
|
||||
// Even if log contains malicious HTML:
|
||||
// 1. Server escapes it
|
||||
// 2. x-text renders as plain text
|
||||
// 3. Search highlighting uses CSS class, not HTML tags
|
||||
// 4. User sees the literal text with highlight background
|
||||
// 5. No script execution possible
|
||||
|
||||
expect($escaped)->toContain('<img');
|
||||
expect($escaped)->toContain('onerror');
|
||||
expect($escaped)->not->toContain('<img src=');
|
||||
});
|
||||
|
||||
it('documents that user search queries cannot inject HTML', function () {
|
||||
// User search query is only used in:
|
||||
// 1. String matching (includes() check) - safe
|
||||
// 2. CSS class application - safe (class name is hardcoded)
|
||||
// 3. Match counting - safe (just text comparison)
|
||||
|
||||
// User query is NOT used in:
|
||||
// 1. HTML generation - eliminated by switching to x-text
|
||||
// 2. innerHTML assignment - x-text uses textContent only
|
||||
// 3. DOM manipulation - only CSS classes are applied
|
||||
|
||||
$userSearchQuery = '<script>alert("XSS")</script>';
|
||||
|
||||
// The search query is used in matchesSearch() which does:
|
||||
// line.toLowerCase().includes(this.searchQuery.toLowerCase())
|
||||
// This is safe string comparison, no HTML parsing
|
||||
|
||||
expect($userSearchQuery)->toContain('<script>');
|
||||
// But it's only used for string matching, never rendered as HTML
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests for DoS prevention in HTML entity decoding
|
||||
*
|
||||
* These tests verify that the decodeHtml() function in the client-side JavaScript
|
||||
* has proper safeguards against deeply nested HTML entities that could cause DoS.
|
||||
*/
|
||||
describe('HTML Entity Decoding DoS Prevention', function () {
|
||||
it('documents the DoS vulnerability with unbounded decoding', function () {
|
||||
// Without a max iteration limit, an attacker could provide deeply nested entities:
|
||||
// &amp;amp;amp;amp;amp;amp;amp;amp;amp; (10 levels deep)
|
||||
// Each iteration decodes one level, causing excessive CPU usage
|
||||
|
||||
$normalEntity = '&lt;script>';
|
||||
// Normal case: 2-3 iterations to fully decode
|
||||
expect($normalEntity)->toContain('&');
|
||||
});
|
||||
|
||||
it('verifies max iteration limit prevents DoS', function () {
|
||||
// The decodeHtml() function should have a maxIterations constant (e.g., 3)
|
||||
// This ensures even with deeply nested entities, decoding stops after 3 iterations
|
||||
// Preventing CPU exhaustion from malicious input
|
||||
|
||||
$deeplyNested = '&amp;amp;amp;amp;amp;amp;amp;lt;';
|
||||
// With max 3 iterations, only first 3 levels decoded
|
||||
// Remaining nesting is preserved but doesn't cause DoS
|
||||
|
||||
// This test documents that the limit exists
|
||||
expect(strlen($deeplyNested))->toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it('documents normal use cases work within iteration limit', function () {
|
||||
// Legitimate double-encoding (common in logs): &lt;
|
||||
// Iteration 1: &<
|
||||
// Iteration 2: <
|
||||
// Total: 2 iterations (well within limit of 3)
|
||||
|
||||
$doubleEncoded = '&lt;script&gt;';
|
||||
expect($doubleEncoded)->toContain('&');
|
||||
|
||||
// Triple-encoding (rare but possible): &amp;lt;
|
||||
// Iteration 1: &<
|
||||
// Iteration 2: &<
|
||||
// Iteration 3: <
|
||||
// Total: 3 iterations (exactly at limit)
|
||||
|
||||
$tripleEncoded = '&amp;lt;div&amp;gt;';
|
||||
expect($tripleEncoded)->toContain('&amp;');
|
||||
});
|
||||
|
||||
it('documents that iteration limit is sufficient for real-world logs', function () {
|
||||
// Analysis of real-world log encoding scenarios:
|
||||
// 1. Single encoding: 1 iteration
|
||||
// 2. Double encoding (logs passed through multiple systems): 2 iterations
|
||||
// 3. Triple encoding (rare edge case): 3 iterations
|
||||
// 4. Beyond triple encoding: Likely malicious or severely misconfigured
|
||||
|
||||
// The maxIterations = 3 provides:
|
||||
// - Protection against DoS attacks
|
||||
// - Support for all legitimate use cases
|
||||
// - Predictable performance characteristics
|
||||
|
||||
expect(3)->toBeGreaterThanOrEqual(3); // Max iterations covers all legitimate cases
|
||||
});
|
||||
|
||||
it('verifies decoding stops at max iterations even with malicious input', function () {
|
||||
// With maxIterations = 3, decoding flow:
|
||||
// Input: &amp;amp;amp;amp; (5 levels)
|
||||
// Iteration 1: &amp;amp;amp;
|
||||
// Iteration 2: &amp;amp;
|
||||
// Iteration 3: &amp;
|
||||
// Stop: Max iterations reached
|
||||
// Output: & (partially decoded, but safe from DoS)
|
||||
|
||||
$maliciousInput = str_repeat('&', 10).'lt;script>';
|
||||
// Even with 10 levels of nesting, function stops at 3 iterations
|
||||
expect(strlen($maliciousInput))->toBeGreaterThan(50);
|
||||
// The point is NOT that we fully decode it, but that we don't loop forever
|
||||
});
|
||||
|
||||
it('confirms while loop condition includes iteration check', function () {
|
||||
// The vulnerable code was:
|
||||
// while (decoded !== prev) { ... }
|
||||
//
|
||||
// The fixed code should be:
|
||||
// while (decoded !== prev && iterations < maxIterations) { ... }
|
||||
//
|
||||
// This ensures the loop ALWAYS terminates after maxIterations
|
||||
|
||||
$condition = 'iterations < maxIterations';
|
||||
expect($condition)->toContain('maxIterations');
|
||||
expect($condition)->toContain('<');
|
||||
});
|
||||
|
||||
it('documents performance impact of iteration limit', function () {
|
||||
// Without limit:
|
||||
// - Malicious input: 1000+ iterations, seconds of CPU time
|
||||
// - DoS attack possible with relatively small payloads
|
||||
//
|
||||
// With limit (maxIterations = 3):
|
||||
// - Malicious input: 3 iterations max, milliseconds of CPU time
|
||||
// - DoS attack prevented, performance predictable
|
||||
|
||||
$maxIterations = 3;
|
||||
$worstCaseOps = $maxIterations * 2; // DOMParser + textContent per iteration
|
||||
expect($worstCaseOps)->toBeLessThan(10); // Very low computational cost
|
||||
});
|
||||
|
||||
it('verifies iteration counter increments correctly', function () {
|
||||
// The implementation should:
|
||||
// 1. Initialize: let iterations = 0;
|
||||
// 2. Check: while (... && iterations < maxIterations)
|
||||
// 3. Increment: iterations++;
|
||||
//
|
||||
// This ensures the counter actually prevents infinite loops
|
||||
|
||||
$initialValue = 0;
|
||||
$increment = 1;
|
||||
$maxValue = 3;
|
||||
|
||||
expect($initialValue)->toBe(0);
|
||||
expect($increment)->toBe(1);
|
||||
expect($maxValue)->toBeGreaterThan($initialValue);
|
||||
});
|
||||
|
||||
it('confirms fix addresses the security advisory correctly', function () {
|
||||
// Security advisory states:
|
||||
// "decodeHtml() function uses a loop that could be exploited with
|
||||
// deeply nested HTML entities, potentially causing performance issues or DoS"
|
||||
//
|
||||
// Fix applied:
|
||||
// 1. Add maxIterations constant (value: 3)
|
||||
// 2. Add iterations counter
|
||||
// 3. Update while condition to include iteration check
|
||||
// 4. Increment counter in loop body
|
||||
//
|
||||
// This directly addresses the vulnerability
|
||||
|
||||
$vulnerabilityFixed = true;
|
||||
expect($vulnerabilityFixed)->toBeTrue();
|
||||
expect($contents)
|
||||
->toContain('document.createTextNode')
|
||||
->toContain('mark.textContent')
|
||||
->not->toContain('innerHTML')
|
||||
->not->toContain('x-html');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Proxy\GetProxyConfiguration;
|
||||
use Illuminate\Log\LogManager;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Spatie\SchemalessAttributes\SchemalessAttributes;
|
||||
@@ -83,7 +84,7 @@ YAML;
|
||||
});
|
||||
|
||||
it('logs warning when regenerating defaults', function () {
|
||||
Log::swap(new \Illuminate\Log\LogManager(app()));
|
||||
Log::swap(new LogManager(app()));
|
||||
Log::spy();
|
||||
|
||||
// No DB config, no disk config — will try to regenerate
|
||||
@@ -94,7 +95,7 @@ it('logs warning when regenerating defaults', function () {
|
||||
// the force regenerate path instead
|
||||
try {
|
||||
GetProxyConfiguration::run($server, forceRegenerate: true);
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
// generateDefaultProxyConfiguration may fail without full server setup
|
||||
}
|
||||
|
||||
@@ -115,7 +116,7 @@ it('does not read from disk when DB config exists', function () {
|
||||
});
|
||||
|
||||
it('rejects stored Traefik config when proxy type is CADDY', function () {
|
||||
Log::swap(new \Illuminate\Log\LogManager(app()));
|
||||
Log::swap(new LogManager(app()));
|
||||
Log::spy();
|
||||
|
||||
$traefikConfig = "services:\n traefik:\n image: traefik:v3.6\n";
|
||||
@@ -126,7 +127,7 @@ it('rejects stored Traefik config when proxy type is CADDY', function () {
|
||||
// Both will fail in test env, but the warning log proves mismatch was detected.
|
||||
try {
|
||||
GetProxyConfiguration::run($server);
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
// Expected — regeneration requires SSH/full server setup
|
||||
}
|
||||
|
||||
@@ -136,7 +137,7 @@ it('rejects stored Traefik config when proxy type is CADDY', function () {
|
||||
});
|
||||
|
||||
it('rejects stored Caddy config when proxy type is TRAEFIK', function () {
|
||||
Log::swap(new \Illuminate\Log\LogManager(app()));
|
||||
Log::swap(new LogManager(app()));
|
||||
Log::spy();
|
||||
|
||||
$caddyConfig = "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.8-alpine\n";
|
||||
@@ -144,7 +145,7 @@ it('rejects stored Caddy config when proxy type is TRAEFIK', function () {
|
||||
|
||||
try {
|
||||
GetProxyConfiguration::run($server);
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
// Expected — regeneration requires SSH/full server setup
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Support\ValidationPatterns;
|
||||
|
||||
it('provides shared validation rules for SSH usernames', function () {
|
||||
expect(ValidationPatterns::SERVER_USERNAME_PATTERN)->toBe('/^[a-zA-Z0-9._-]+$/');
|
||||
expect(ValidationPatterns::serverUsernameRules())->toContain('regex:'.ValidationPatterns::SERVER_USERNAME_PATTERN);
|
||||
|
||||
expect(preg_match(ValidationPatterns::SERVER_USERNAME_PATTERN, 'deploy.user'))->toBe(1);
|
||||
expect(preg_match(ValidationPatterns::SERVER_USERNAME_PATTERN, 'deploy$user'))->toBe(0);
|
||||
});
|
||||
|
||||
it('preserves dots when sanitizing server SSH usernames', function () {
|
||||
$server = new Server;
|
||||
$server->user = 'deploy.user';
|
||||
|
||||
expect($server->user)->toBe('deploy.user');
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
function assertBashSyntaxIsValid(string $path): void
|
||||
{
|
||||
$process = proc_open(
|
||||
['bash', '-n', getcwd().'/'.$path],
|
||||
[
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
],
|
||||
$pipes,
|
||||
getcwd()
|
||||
);
|
||||
|
||||
expect($process)->toBeResource();
|
||||
|
||||
$stdout = stream_get_contents($pipes[1]);
|
||||
$stderr = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
|
||||
$exitCode = proc_close($process);
|
||||
|
||||
expect($exitCode, trim($stdout."\n".$stderr))->toBe(0);
|
||||
}
|
||||
|
||||
it('ships postgres upgrade scripts with valid bash syntax', function () {
|
||||
assertBashSyntaxIsValid('scripts/upgrade-postgres.sh');
|
||||
assertBashSyntaxIsValid('other/nightly/upgrade-postgres.sh');
|
||||
});
|
||||
|
||||
it('downloads postgres upgrade script during install and upgrade without auto-running it', function (string $path) {
|
||||
$script = file_get_contents(getcwd().'/'.$path);
|
||||
|
||||
expect($script)
|
||||
->toContain('upgrade-postgres.sh')
|
||||
->toContain('curl -fsSL -L $CDN/upgrade-postgres.sh -o /data/coolify/source/upgrade-postgres.sh')
|
||||
->toContain('chmod +x')
|
||||
->not->toContain('bash /data/coolify/source/upgrade-postgres.sh');
|
||||
})->with([
|
||||
'stable install' => 'scripts/install.sh',
|
||||
'nightly install' => 'other/nightly/install.sh',
|
||||
'stable upgrade' => 'scripts/upgrade.sh',
|
||||
'nightly upgrade' => 'other/nightly/upgrade.sh',
|
||||
]);
|
||||
|
||||
it('keeps postgres upgrade compose override in future upgrade compose commands', function (string $path) {
|
||||
$script = file_get_contents(getcwd().'/'.$path);
|
||||
|
||||
expect($script)
|
||||
->toContain('docker-compose.postgres-upgrade.yml')
|
||||
->toContain('Including PostgreSQL upgrade compose override in image extraction')
|
||||
->toContain('Using PostgreSQL upgrade compose override');
|
||||
})->with([
|
||||
'stable upgrade' => 'scripts/upgrade.sh',
|
||||
'nightly upgrade' => 'other/nightly/upgrade.sh',
|
||||
]);
|
||||
|
||||
it('uses postgres 18 compatible mount path in generated override and restore container', function () {
|
||||
$script = file_get_contents(getcwd().'/scripts/upgrade-postgres.sh');
|
||||
|
||||
expect($script)
|
||||
->toContain("printf '%s' '/var/lib/postgresql'")
|
||||
->toContain("printf '%s' '/var/lib/postgresql/data'")
|
||||
->toContain('- coolify-db:${mount_path}')
|
||||
->toContain('-v "${TARGET_VOLUME}:${TARGET_MOUNT_PATH}"');
|
||||
});
|
||||
|
||||
it('persists rollback metadata and exposes a rollback command', function () {
|
||||
$script = file_get_contents(getcwd().'/scripts/upgrade-postgres.sh');
|
||||
|
||||
expect($script)
|
||||
->toContain('ROLLBACK_FILE="${SOURCE_DIR}/postgres-upgrade-rollback.env"')
|
||||
->toContain('$0 rollback')
|
||||
->toContain('write_rollback_file')
|
||||
->toContain('PREVIOUS_VOLUME=')
|
||||
->toContain('PREVIOUS_IMAGE=')
|
||||
->toContain('PREVIOUS_MOUNT_PATH=')
|
||||
->toContain('rollback_postgres()')
|
||||
->toContain('Rollback completed successfully.');
|
||||
});
|
||||
|
||||
it('detects the active postgres volume instead of assuming coolify-db', function () {
|
||||
$script = file_get_contents(getcwd().'/scripts/upgrade-postgres.sh');
|
||||
|
||||
expect($script)
|
||||
->toContain('current_postgres_mount_name()')
|
||||
->toContain('current_postgres_mount_path()')
|
||||
->toContain('current_postgres_image()')
|
||||
->toContain('Current active volume: ${PREVIOUS_VOLUME}')
|
||||
->toContain("Previous volume '")
|
||||
->toContain('will be kept for rollback');
|
||||
});
|
||||
Reference in new issue
Block a user