diff --git a/.ai/lessons.md b/.ai/lessons.md index 84b871b1f7..3d2c6a1e68 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -7,10 +7,12 @@ - Use the same regression test before and after the production change so the result shows the behavior difference. - Several dev instances can run from one checkout (see `./scripts/dev urls`). After you add a migration, run it on each running instance of that checkout, or those instances fail on the new code. - The checkout is shared with other sessions. When the full suite fails, rerun each failing file alone and compare with a clean `git archive HEAD` copy before you connect a failure to your change. +- Run seeders and other artisan commands that write SSH keys in a dev container as `www-data` (`docker exec -u www-data`). Root-owned files in `storage/app/ssh` make every SSH call from the web process fail. - `sshd` keeps the login shell of an open multiplexed SSH connection. Call `SshMultiplexingHelper::removeMuxFile()` before a live test of SSH or login-shell behavior. - SSH retries replay the full command batch. Only exit code 255 is an SSH transport failure; other exit codes come from the remote command, so do not retry them. Keep batches safe to run twice (for example `docker rm -f || true` before `docker run --name `). - Redirect browser test output to a file (`> /tmp/x.log 2>&1`); piping it (`| tail`) hangs because the Playwright server keeps the pipe open. - Browser `click('Text')` can match a hidden element first (for example inactive modal steps) and wait forever; use `button:visible:has-text("Text")`. +- Never run `php artisan test` inside a dev container (`./scripts/dev exec` or `docker exec coolify-dev-*`). The container sets `DB_CONNECTION=pgsql` as a real environment variable, which wins over `phpunit.xml`, so `RefreshDatabase` runs `migrate:fresh` on the dev database and wipes it. Run tests on the host only. - Call `visit()` directly in each `tests/v4/Browser` test body; Pest does not mark a test that only uses helper-wrapped `visit()` as a browser test, so it fails with `sendText() on null`. ## Verify the complete user flow diff --git a/AGENTS.md b/AGENTS.md index 8fd7b21092..fa5c428160 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,6 +150,12 @@ Because the "server" and the test share one PHP process, they share the phpunit - Multi-tenancy via Teams — team auto-initializes notification settings on creation - Authorize every server-side read and mutation where access can vary by user, role, team, or resource. Use policies, gates, or `$this->authorize(...)`; never rely on hidden Blade/Livewire controls such as `@can` for security. - Scope queries to the current team before returning records. Treat route and model identifiers as untrusted, and prevent users from reading or changing resources owned by another team. +- Use the session team (`currentTeam()`, `Model::ownedByCurrentTeam()`) only to find or open records, for "my team" lists, to create new top-level records, and on team pages. A user can switch teams in another tab while a page stays open, and every user owns a personal team. +- Once a component, action, or controller works on a loaded resource, use that resource's team for everything after it: + - Roles and redaction: use the policy (`$this->authorize('update', $resource)`) or `auth()->user()->isAdminOfTeam($resource->team()->id)`. Never use `isMember()`, `isAdmin()`, or `role()` for resource permissions; they read the role in the session team. + - Related records the user can pick (S3 storages, keys, servers, sources, tags, tokens): query with `->where('team_id', $resource->team()->id)` and validate submitted IDs against that team. + - New rows that belong to the resource: set `team_id` from the resource's team, not `currentTeam()->id`. +- Test this with a user who is a member of the resource's team and the owner of another team, with the session switched to the other team after mount. - Apply authorization consistently across Livewire actions, API and web controllers, actions, downloads, exports, search, event listeners, and any other path that exposes or changes protected data. - Default to denying access when a policy or ownership relationship is missing or ambiguous. Members must not gain access to administrative, credential, security, billing, or instance-wide data merely because they belong to the team. - Add authorization regression tests for protected changes. Cover permitted access, member restrictions where applicable, and cross-team access; verify unauthorized reads and writes return `403` or otherwise reveal no protected data. diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index 7dde9ac21b..f3831125a1 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -84,13 +84,23 @@ class StartClickhouse if (count($this->database->ports_mappings_array) > 0) { $docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array; } + + $docker_compose['services'][$container_name]['volumes'] ??= []; + if (count($persistent_storages) > 0) { - $docker_compose['services'][$container_name]['volumes'] = $persistent_storages; + $docker_compose['services'][$container_name]['volumes'] = array_merge( + $docker_compose['services'][$container_name]['volumes'], + $persistent_storages + ); } + if (count($persistent_file_volumes) > 0) { - $docker_compose['services'][$container_name]['volumes'] = $persistent_file_volumes->map(function ($item) { - return "$item->fs_path:$item->mount_path"; - })->toArray(); + $docker_compose['services'][$container_name]['volumes'] = array_merge( + $docker_compose['services'][$container_name]['volumes'], + $persistent_file_volumes->map(function ($item) { + return "$item->fs_path:$item->mount_path"; + })->toArray() + ); } if (count($volume_names) > 0) { $docker_compose['volumes'] = $volume_names; diff --git a/app/Actions/Database/StartDatabase.php b/app/Actions/Database/StartDatabase.php index 75d2bca5ce..7a0a0a556d 100644 --- a/app/Actions/Database/StartDatabase.php +++ b/app/Actions/Database/StartDatabase.php @@ -267,6 +267,10 @@ class StartDatabase */ public static function prerequisiteError(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|StandaloneSqlite $database): ?string { + if ($database instanceof StandaloneClickhouse && ($volumeName = $database->anonymousDataVolume()) !== null) { + return DatabaseStartException::clickhouseDataInAnonymousVolume($volumeName)->getMessage(); + } + if (! $database->enable_ssl) { return null; } diff --git a/app/Actions/Database/StopDatabase.php b/app/Actions/Database/StopDatabase.php index ecdffdeda3..1d1e5c0855 100644 --- a/app/Actions/Database/StopDatabase.php +++ b/app/Actions/Database/StopDatabase.php @@ -20,7 +20,7 @@ class StopDatabase { use AsAction; - public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|StandaloneSqlite $database, bool $dockerCleanup = true, bool $resetRestartCount = true, bool $removeContainer = true): string + public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|StandaloneSqlite $database, bool $dockerCleanup = true, bool $resetRestartCount = true, bool $removeContainer = true, bool $keepAnonymousDataVolume = true): string { try { $server = $database->destination->server; @@ -28,6 +28,11 @@ class StopDatabase return 'Server is not functional'; } + // A removed container loses the link to the unnamed volume that holds the current data. + if ($removeContainer && $keepAnonymousDataVolume && $database instanceof StandaloneClickhouse && $database->anonymousDataVolume() !== null) { + $removeContainer = false; + } + $this->stopContainer($database, $database->uuid, 30, $removeContainer); // Reset restart tracking when database is manually stopped diff --git a/app/Exceptions/DatabaseStartException.php b/app/Exceptions/DatabaseStartException.php index 8dc54fb3ea..8f41dad221 100644 --- a/app/Exceptions/DatabaseStartException.php +++ b/app/Exceptions/DatabaseStartException.php @@ -15,6 +15,11 @@ class DatabaseStartException extends Exception return new static('No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); } + public static function clickhouseDataInAnonymousVolume(string $volumeName): static + { + return new static("This ClickHouse database keeps its current data in the unnamed Docker volume {$volumeName}, not in its data volume. A start or restart would use the data volume, and the current data would seem lost. On the database's General page, click \"Keep current data\" first."); + } + public static function startCommandsDidNotRun(): static { return new static('Database start ended without running the start commands.'); diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index b32a16fc61..84486f6a9f 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -4802,7 +4802,7 @@ class DatabasesController extends Controller ], 422); } - if ($storage->isSharedWithAnotherResource()) { + if ($storage instanceof LocalPersistentVolume && $storage->isSharedWithAnotherResource()) { return response()->json([ 'message' => 'This volume is mounted by an application. Unlink it on the SQLite database page first.', ], 422); diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index 419366d95f..b58a72d6ff 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -459,8 +459,8 @@ class DeployController extends Controller // $message->push("Tag {$tag} not found."); continue; } - $applications = $found_tag->applications()->get(); - $services = $found_tag->services()->get(); + $applications = $found_tag->applications()->whereRelation('environment.project', 'team_id', $team_id)->get(); + $services = $found_tag->services()->whereRelation('environment.project', 'team_id', $team_id)->get(); if ($applications->count() === 0 && $services->count() === 0) { $message->push("No resources found for tag {$tag}."); diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index 3d0e62e766..a5df1ebfa4 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -79,7 +79,7 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue case 'standalone-dragonfly': case 'standalone-clickhouse': case 'standalone-sqlite': - StopDatabase::run($this->resource, dockerCleanup: $this->dockerCleanup); + StopDatabase::run($this->resource, dockerCleanup: $this->dockerCleanup, keepAnonymousDataVolume: false); break; case 'service': StopService::run($this->resource, $this->deleteConnectedNetworks, $this->dockerCleanup); diff --git a/app/Livewire/Destination/New/Docker.php b/app/Livewire/Destination/New/Docker.php index 702bc29f4f..49da69911b 100644 --- a/app/Livewire/Destination/New/Docker.php +++ b/app/Livewire/Destination/New/Docker.php @@ -72,7 +72,7 @@ class Docker extends Component { try { $isSwarm = $this->selectedServer->isSwarm(); - $this->authorize('create', $isSwarm ? SwarmDocker::class : StandaloneDocker::class); + $this->authorize('update', $this->selectedServer); $this->validate(); if ($isSwarm) { $found = $this->selectedServer->swarmDockers()->where('network', $this->network)->first(); diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php index 2f8592ced2..22f2b8043d 100644 --- a/app/Livewire/Project/Application/DeploymentNavbar.php +++ b/app/Livewire/Project/Application/DeploymentNavbar.php @@ -69,19 +69,22 @@ class DeploymentNavbar extends Component public function copyLogsToClipboard(): string { + $this->authorize('view', $this->application); + $logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR); if (! $logs) { return ''; } - $isMember = auth()->user()->isMember(); + $teamId = $this->application->team()?->id; + $hideDebugLines = is_null($teamId) || ! auth()->user()->isAdminOfTeam($teamId); $markdown = "# Deployment Logs\n\n"; $markdown .= "```\n"; foreach ($logs as $log) { - if ($isMember && ! empty($log['hidden'])) { + if ($hideDebugLines && ! empty($log['hidden'])) { continue; } if (isset($log['output'])) { @@ -101,8 +104,9 @@ class DeploymentNavbar extends Component } catch (\Throwable $e) { return handleError($e, $this); } + $teamId = $this->application->team()->id; $server_id = $this->application_deployment_queue->server_id ?? $this->application->destination->server_id; - $server = Server::ownedByCurrentTeam()->find($server_id); + $server = Server::where('team_id', $teamId)->find($server_id); // First, mark the deployment as cancelled to prevent further processing $this->application_deployment_queue->update([ @@ -110,7 +114,7 @@ class DeploymentNavbar extends Component ]); try { $this->application_deployment_queue->addLogEntry('Deployment cancelled by user.', 'stderr'); - CleanupCancelledDeployment::run($this->application_deployment_queue, currentTeam()->id); + CleanupCancelledDeployment::run($this->application_deployment_queue, $teamId); } catch (\Throwable $e) { // Still mark as cancelled even if cleanup fails return handleError($e, $this); diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 5d272b36fa..9849811dab 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -923,7 +923,7 @@ class General extends Component $this->application->custom_labels = base64_encode($this->customLabels); $this->application->save(); $this->application->refresh(); - $dnsCleanup->queueReleaseOfRemovedHostnames($this->application, $previousDnsHostnames, currentTeam()->id); + $dnsCleanup->queueReleaseOfRemovedHostnames($this->application, $previousDnsHostnames, $this->application->team()->id); $this->syncData(); if ($oldPortsExposes !== $this->portsExposes) { $this->dispatch('applicationNetworkingUpdated')->to(InternalAccess::class); diff --git a/app/Livewire/Project/Application/PreviewDomains.php b/app/Livewire/Project/Application/PreviewDomains.php index 5e9a942a63..b873d4a9ed 100644 --- a/app/Livewire/Project/Application/PreviewDomains.php +++ b/app/Livewire/Project/Application/PreviewDomains.php @@ -503,7 +503,7 @@ class PreviewDomains extends Component 'changed_fields' => $changedFields, ]); } - $dnsCleanup->queueReleaseOfRemovedHostnames($this->preview, $previousDnsHostnames, currentTeam()->id); + $dnsCleanup->queueReleaseOfRemovedHostnames($this->preview, $previousDnsHostnames, $this->preview->application->team()->id); $this->persistDnsStatuses(); $this->refreshDomains(); $this->dispatch('update_links'); diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index 60a7955738..5b6ad61eae 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -89,14 +89,14 @@ class Source extends Component private function getPrivateKeys() { - $this->privateKeys = PrivateKey::whereTeamId(currentTeam()->id)->get()->reject(function ($key) { + $this->privateKeys = PrivateKey::where('team_id', $this->application->team()->id)->get()->reject(function ($key) { return $key->id == $this->privateKeyId; }); } private function getSources() { - $this->sources = currentTeam()->sources()->filter(function ($source) { + $this->sources = $this->application->team()->sources()->filter(function ($source) { if ($source->id === $this->application->source_id && $source->getMorphClass() === $this->application->source_type) { return false; @@ -116,7 +116,7 @@ class Source extends Component { try { $this->authorize('update', $this->application); - $key = PrivateKey::ownedByCurrentTeam()->findOrFail($privateKeyId); + $key = PrivateKey::where('team_id', $this->application->team()->id)->findOrFail($privateKeyId); $this->privateKeyId = $key->id; $this->syncData(true); $this->getPrivateKeys(); @@ -151,7 +151,10 @@ class Source extends Component $this->authorize('update', $this->application); $allowedSourceTypes = [GithubApp::class, GitlabApp::class]; abort_unless(in_array($sourceType, $allowedSourceTypes, true), 404); - $source = $sourceType::ownedByCurrentTeam()->findOrFail($sourceId); + $teamId = $this->application->team()->id; + $source = $sourceType::query() + ->where(fn ($query) => $query->where('team_id', $teamId)->orWhere('is_system_wide', true)) + ->findOrFail($sourceId); $this->application->update([ 'source_id' => $source->id, 'source_type' => $sourceType, diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index cc91f865c9..8f2ff9af40 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -10,8 +10,11 @@ use App\Jobs\VolumeCloneJob; use App\Models\Environment; use App\Models\Project; use App\Models\Server; +use App\Models\StandaloneDocker; +use App\Models\SwarmDocker; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\DB; use Livewire\Component; class CloneMe extends Component @@ -59,8 +62,8 @@ class CloneMe extends Component $this->project = Project::ownedByCurrentTeam()->where('uuid', $project_uuid)->firstOrFail(); $this->environment = $this->project->environments->where('uuid', $this->environment_uuid)->first(); $this->project_id = $this->project->id; - $this->servers = currentTeam() - ->servers() + $this->servers = Server::query() + ->where('team_id', $this->project->team_id) ->get() ->reject(fn ($server) => $server->isBuildServer()); $this->newName = str($this->project->name.'-clone-'.new_public_id())->slug(); @@ -93,12 +96,16 @@ class CloneMe extends Component public function clone(string $type) { try { - $this->authorize('create', Project::class); + $this->authorize('update', $this->project); $this->validate([ 'selectedDestination' => 'required', 'newName' => ValidationPatterns::nameRules(), ]); - $selectedDestination = find_resource_destination_for_current_team($this->selectedDestination); + $teamId = $this->project->team_id; + if (! $this->environment || (int) $this->environment->project_id !== (int) $this->project->id) { + throw new \Exception('Environment not found.'); + } + $selectedDestination = $this->findProjectTeamDestination($this->selectedDestination); if (! $selectedDestination) { throw new \Exception('Destination not found.'); } @@ -110,94 +117,247 @@ class CloneMe extends Component 'new_name' => $this->newName, 'destination_uuid' => $selectedDestination->uuid, ]); - if ($type === 'project') { - $foundProject = Project::where('name', $this->newName)->first(); - if ($foundProject) { - throw new \Exception('Project with the same name already exists.'); - } - $project = Project::create([ - 'name' => $this->newName, - 'team_id' => currentTeam()->id, - 'description' => $this->project->description.' (clone)', - ]); - if ($this->environment->name !== 'production') { - $project->environments()->create([ - 'name' => $this->environment->name, - 'uuid' => new_public_id(), - ]); - } - $environment = $project->environments->where('name', $this->environment->name)->first(); - } else { - $foundEnv = $this->project->environments()->where('name', $this->newName)->first(); - if ($foundEnv) { - throw new \Exception('Environment with the same name already exists.'); - } - $project = $this->project; - $environment = $this->project->environments()->create([ - 'name' => $this->newName, + [$project, $environment] = DB::transaction(fn () => $this->cloneInto($type, $teamId, $selectedDestination)); + } catch (\Exception $e) { + handleError($e, $this); + + return; + } + + return redirect()->route('project.resource.index', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + ]); + } + + /** + * @return array{0: Project, 1: Environment} + */ + private function cloneInto(string $type, int $teamId, StandaloneDocker|SwarmDocker $selectedDestination): array + { + if ($type === 'project') { + $foundProject = Project::query()->where('team_id', $teamId)->where('name', $this->newName)->first(); + if ($foundProject) { + throw new \Exception('Project with the same name already exists.'); + } + $project = Project::create([ + 'name' => $this->newName, + 'team_id' => $teamId, + 'description' => $this->project->description.' (clone)', + ]); + if ($this->environment->name !== 'production') { + $project->environments()->create([ + 'name' => $this->environment->name, 'uuid' => new_public_id(), ]); } - $applications = $this->environment->applications; - $databases = $this->environment->databases(); - $services = $this->environment->services; - foreach ($applications as $application) { - clone_application($application, $selectedDestination, [ - 'environment_id' => $environment->id, - ], $this->cloneVolumeData); + $environment = $project->environments->where('name', $this->environment->name)->first(); + } else { + $foundEnv = $this->project->environments()->where('name', $this->newName)->first(); + if ($foundEnv) { + throw new \Exception('Environment with the same name already exists.'); + } + $project = $this->project; + $environment = $this->project->environments()->create([ + 'name' => $this->newName, + 'uuid' => new_public_id(), + ]); + } + $applications = $this->environment->applications; + $databases = $this->environment->databases(); + $services = $this->environment->services; + foreach ($applications as $application) { + clone_application($application, $selectedDestination, [ + 'environment_id' => $environment->id, + ], $this->cloneVolumeData); + } + + foreach ($databases as $database) { + $uuid = new_public_id(); + $newDatabase = $database->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'uuid' => $uuid, + 'status' => 'exited', + 'started_at' => null, + 'environment_id' => $environment->id, + 'destination_id' => $selectedDestination->id, + 'destination_type' => $selectedDestination->getMorphClass(), + ]); + $newDatabase->save(); + + $tags = $database->tags; + foreach ($tags as $tag) { + $newDatabase->tags()->attach($tag->id); } - foreach ($databases as $database) { + $newDatabase->persistentStorages()->delete(); + $persistentVolumes = $database->persistentStorages()->get(); + foreach ($persistentVolumes as $volume) { + $originalName = $volume->name; + $newName = ''; + + if (str_starts_with($originalName, 'postgres-data-')) { + $newName = 'postgres-data-'.$newDatabase->uuid; + } elseif (str_starts_with($originalName, 'mysql-data-')) { + $newName = 'mysql-data-'.$newDatabase->uuid; + } elseif (str_starts_with($originalName, 'redis-data-')) { + $newName = 'redis-data-'.$newDatabase->uuid; + } elseif (str_starts_with($originalName, 'clickhouse-data-')) { + $newName = 'clickhouse-data-'.$newDatabase->uuid; + } elseif (str_starts_with($originalName, 'mariadb-data-')) { + $newName = 'mariadb-data-'.$newDatabase->uuid; + } elseif (str_starts_with($originalName, 'mongodb-data-')) { + $newName = 'mongodb-data-'.$newDatabase->uuid; + } elseif (str_starts_with($originalName, 'keydb-data-')) { + $newName = 'keydb-data-'.$newDatabase->uuid; + } elseif (str_starts_with($originalName, 'dragonfly-data-')) { + $newName = 'dragonfly-data-'.$newDatabase->uuid; + } elseif (str_starts_with($originalName, 'sqlite-data-')) { + $newName = 'sqlite-data-'.$newDatabase->uuid; + } else { + if (str_starts_with($volume->name, $database->uuid)) { + $newName = str($volume->name)->replace($database->uuid, $newDatabase->uuid); + } else { + $newName = $newDatabase->uuid.'-'.$volume->name; + } + } + + $newPersistentVolume = $volume->replicate([ + 'id', + 'created_at', + 'updated_at', + 'uuid', + ])->fill([ + 'name' => $newName, + 'resource_id' => $newDatabase->id, + ]); + $newPersistentVolume->save(); + + if ($this->cloneVolumeData) { + try { + StopDatabase::dispatch($database); + $sourceVolume = $volume->name; + $targetVolume = $newPersistentVolume->name; + $sourceServer = $database->destination->server; + $targetServer = $newDatabase->destination->server; + + VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); + + StartDatabase::dispatch($database); + } catch (\Exception $e) { + \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); + } + } + } + + $fileStorages = $database->fileStorages()->get(); + foreach ($fileStorages as $storage) { + $newStorage = $storage->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'resource_id' => $newDatabase->id, + ]); + $newStorage->save(); + } + + $scheduledBackups = $database->scheduledBackups()->get(); + foreach ($scheduledBackups as $backup) { $uuid = new_public_id(); - $newDatabase = $database->replicate([ + $newBackup = $backup->replicate([ 'id', 'created_at', 'updated_at', ])->fill([ 'uuid' => $uuid, - 'status' => 'exited', - 'started_at' => null, - 'environment_id' => $environment->id, - 'destination_id' => $selectedDestination->id, - 'destination_type' => $selectedDestination->getMorphClass(), + 'database_id' => $newDatabase->id, + 'database_type' => $newDatabase->getMorphClass(), + 'team_id' => $teamId, ]); - $newDatabase->save(); + $newBackup->save(); + } - $tags = $database->tags; - foreach ($tags as $tag) { - $newDatabase->tags()->attach($tag->id); - } + $environmentVaribles = $database->environment_variables()->get(); + foreach ($environmentVaribles as $environmentVarible) { + $payload = []; + $payload['resourceable_id'] = $newDatabase->id; + $payload['resourceable_type'] = $newDatabase->getMorphClass(); + $newEnvironmentVariable = $environmentVarible->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill($payload); + $newEnvironmentVariable->save(); + } - $newDatabase->persistentStorages()->delete(); - $persistentVolumes = $database->persistentStorages()->get(); + $database->cloneSecretManagerLinkTo($newDatabase); + } + + foreach ($services as $service) { + $uuid = new_public_id(); + $newService = $service->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'uuid' => $uuid, + 'environment_id' => $environment->id, + 'destination_id' => $selectedDestination->id, + 'destination_type' => $selectedDestination->getMorphClass(), + 'server_id' => $selectedDestination->server_id, + ]); + $newService->save(); + + $tags = $service->tags; + foreach ($tags as $tag) { + $newService->tags()->attach($tag->id); + } + + $scheduledTasks = $service->scheduled_tasks()->get(); + foreach ($scheduledTasks as $task) { + $newTask = $task->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'uuid' => new_public_id(), + 'service_id' => $newService->id, + 'team_id' => $teamId, + ]); + $newTask->save(); + } + + $environmentVariables = $service->environment_variables()->get(); + foreach ($environmentVariables as $environmentVariable) { + $newEnvironmentVariable = $environmentVariable->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'resourceable_id' => $newService->id, + 'resourceable_type' => $newService->getMorphClass(), + ]); + $newEnvironmentVariable->save(); + } + + $service->cloneSecretManagerLinkTo($newService); + + foreach ($newService->applications() as $application) { + $application->fill([ + 'status' => 'exited', + ])->save(); + + $persistentVolumes = $application->persistentStorages()->get(); foreach ($persistentVolumes as $volume) { - $originalName = $volume->name; $newName = ''; - - if (str_starts_with($originalName, 'postgres-data-')) { - $newName = 'postgres-data-'.$newDatabase->uuid; - } elseif (str_starts_with($originalName, 'mysql-data-')) { - $newName = 'mysql-data-'.$newDatabase->uuid; - } elseif (str_starts_with($originalName, 'redis-data-')) { - $newName = 'redis-data-'.$newDatabase->uuid; - } elseif (str_starts_with($originalName, 'clickhouse-data-')) { - $newName = 'clickhouse-data-'.$newDatabase->uuid; - } elseif (str_starts_with($originalName, 'mariadb-data-')) { - $newName = 'mariadb-data-'.$newDatabase->uuid; - } elseif (str_starts_with($originalName, 'mongodb-data-')) { - $newName = 'mongodb-data-'.$newDatabase->uuid; - } elseif (str_starts_with($originalName, 'keydb-data-')) { - $newName = 'keydb-data-'.$newDatabase->uuid; - } elseif (str_starts_with($originalName, 'dragonfly-data-')) { - $newName = 'dragonfly-data-'.$newDatabase->uuid; - } elseif (str_starts_with($originalName, 'sqlite-data-')) { - $newName = 'sqlite-data-'.$newDatabase->uuid; + if (str_starts_with($volume->name, $application->uuid)) { + $newName = str($volume->name)->replace($application->uuid, $application->uuid); } else { - if (str_starts_with($volume->name, $database->uuid)) { - $newName = str($volume->name)->replace($database->uuid, $newDatabase->uuid); - } else { - $newName = $newDatabase->uuid.'-'.$volume->name; - } + $newName = $application->uuid.'-'.$volume->name; } $newPersistentVolume = $volume->replicate([ @@ -207,21 +367,76 @@ class CloneMe extends Component 'uuid', ])->fill([ 'name' => $newName, - 'resource_id' => $newDatabase->id, + 'resource_id' => $application->id, ]); $newPersistentVolume->save(); if ($this->cloneVolumeData) { try { - StopDatabase::dispatch($database); + StopService::dispatch($application); $sourceVolume = $volume->name; $targetVolume = $newPersistentVolume->name; - $sourceServer = $database->destination->server; - $targetServer = $newDatabase->destination->server; + $sourceServer = $application->service->destination->server; + $targetServer = $newService->destination->server; VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); - StartDatabase::dispatch($database); + StartService::dispatch($application); + } catch (\Exception $e) { + \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); + } + } + } + + $fileStorages = $application->fileStorages()->get(); + foreach ($fileStorages as $storage) { + $newStorage = $storage->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'resource_id' => $application->id, + ]); + $newStorage->save(); + } + } + + foreach ($newService->databases() as $database) { + $database->fill([ + 'status' => 'exited', + ])->save(); + + $persistentVolumes = $database->persistentStorages()->get(); + foreach ($persistentVolumes as $volume) { + $newName = ''; + if (str_starts_with($volume->name, $database->uuid)) { + $newName = str($volume->name)->replace($database->uuid, $database->uuid); + } else { + $newName = $database->uuid.'-'.$volume->name; + } + + $newPersistentVolume = $volume->replicate([ + 'id', + 'created_at', + 'updated_at', + 'uuid', + ])->fill([ + 'name' => $newName, + 'resource_id' => $database->id, + ]); + $newPersistentVolume->save(); + + if ($this->cloneVolumeData) { + try { + StopService::dispatch($database->service); + $sourceVolume = $volume->name; + $targetVolume = $newPersistentVolume->name; + $sourceServer = $database->service->destination->server; + $targetServer = $newService->destination->server; + + VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); + + StartService::dispatch($database->service); } catch (\Exception $e) { \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); } @@ -235,7 +450,7 @@ class CloneMe extends Component 'created_at', 'updated_at', ])->fill([ - 'resource_id' => $newDatabase->id, + 'resource_id' => $database->id, ]); $newStorage->save(); } @@ -249,218 +464,30 @@ class CloneMe extends Component 'updated_at', ])->fill([ 'uuid' => $uuid, - 'database_id' => $newDatabase->id, - 'database_type' => $newDatabase->getMorphClass(), - 'team_id' => currentTeam()->id, + 'database_id' => $database->id, + 'database_type' => $database->getMorphClass(), + 'team_id' => $teamId, ]); $newBackup->save(); } - - $environmentVaribles = $database->environment_variables()->get(); - foreach ($environmentVaribles as $environmentVarible) { - $payload = []; - $payload['resourceable_id'] = $newDatabase->id; - $payload['resourceable_type'] = $newDatabase->getMorphClass(); - $newEnvironmentVariable = $environmentVarible->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill($payload); - $newEnvironmentVariable->save(); - } - - $database->cloneSecretManagerLinkTo($newDatabase); } - foreach ($services as $service) { - $uuid = new_public_id(); - $newService = $service->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'uuid' => $uuid, - 'environment_id' => $environment->id, - 'destination_id' => $selectedDestination->id, - 'destination_type' => $selectedDestination->getMorphClass(), - 'server_id' => $selectedDestination->server_id, - ]); - $newService->save(); - - $tags = $service->tags; - foreach ($tags as $tag) { - $newService->tags()->attach($tag->id); - } - - $scheduledTasks = $service->scheduled_tasks()->get(); - foreach ($scheduledTasks as $task) { - $newTask = $task->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'uuid' => new_public_id(), - 'service_id' => $newService->id, - 'team_id' => currentTeam()->id, - ]); - $newTask->save(); - } - - $environmentVariables = $service->environment_variables()->get(); - foreach ($environmentVariables as $environmentVariable) { - $newEnvironmentVariable = $environmentVariable->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'resourceable_id' => $newService->id, - 'resourceable_type' => $newService->getMorphClass(), - ]); - $newEnvironmentVariable->save(); - } - - $service->cloneSecretManagerLinkTo($newService); - - foreach ($newService->applications() as $application) { - $application->fill([ - 'status' => 'exited', - ])->save(); - - $persistentVolumes = $application->persistentStorages()->get(); - foreach ($persistentVolumes as $volume) { - $newName = ''; - if (str_starts_with($volume->name, $application->uuid)) { - $newName = str($volume->name)->replace($application->uuid, $application->uuid); - } else { - $newName = $application->uuid.'-'.$volume->name; - } - - $newPersistentVolume = $volume->replicate([ - 'id', - 'created_at', - 'updated_at', - 'uuid', - ])->fill([ - 'name' => $newName, - 'resource_id' => $application->id, - ]); - $newPersistentVolume->save(); - - if ($this->cloneVolumeData) { - try { - StopService::dispatch($application); - $sourceVolume = $volume->name; - $targetVolume = $newPersistentVolume->name; - $sourceServer = $application->service->destination->server; - $targetServer = $newService->destination->server; - - VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); - - StartService::dispatch($application); - } catch (\Exception $e) { - \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); - } - } - } - - $fileStorages = $application->fileStorages()->get(); - foreach ($fileStorages as $storage) { - $newStorage = $storage->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'resource_id' => $application->id, - ]); - $newStorage->save(); - } - } - - foreach ($newService->databases() as $database) { - $database->fill([ - 'status' => 'exited', - ])->save(); - - $persistentVolumes = $database->persistentStorages()->get(); - foreach ($persistentVolumes as $volume) { - $newName = ''; - if (str_starts_with($volume->name, $database->uuid)) { - $newName = str($volume->name)->replace($database->uuid, $database->uuid); - } else { - $newName = $database->uuid.'-'.$volume->name; - } - - $newPersistentVolume = $volume->replicate([ - 'id', - 'created_at', - 'updated_at', - 'uuid', - ])->fill([ - 'name' => $newName, - 'resource_id' => $database->id, - ]); - $newPersistentVolume->save(); - - if ($this->cloneVolumeData) { - try { - StopService::dispatch($database->service); - $sourceVolume = $volume->name; - $targetVolume = $newPersistentVolume->name; - $sourceServer = $database->service->destination->server; - $targetServer = $newService->destination->server; - - VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); - - StartService::dispatch($database->service); - } catch (\Exception $e) { - \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); - } - } - } - - $fileStorages = $database->fileStorages()->get(); - foreach ($fileStorages as $storage) { - $newStorage = $storage->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'resource_id' => $database->id, - ]); - $newStorage->save(); - } - - $scheduledBackups = $database->scheduledBackups()->get(); - foreach ($scheduledBackups as $backup) { - $uuid = new_public_id(); - $newBackup = $backup->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'uuid' => $uuid, - 'database_id' => $database->id, - 'database_type' => $database->getMorphClass(), - 'team_id' => currentTeam()->id, - ]); - $newBackup->save(); - } - } - - $newService->parse(); - } - - } catch (\Exception $e) { - handleError($e, $this); - - return; - } finally { - if (! isset($e)) { - return redirect()->route('project.resource.index', [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $environment->uuid, - ]); - } + $newService->parse(); } + + return [$project, $environment]; + } + + private function findProjectTeamDestination(?string $uuid): StandaloneDocker|SwarmDocker|null + { + if (blank($uuid)) { + return null; + } + + $teamId = $this->project->team_id; + $destination = StandaloneDocker::query()->whereRelation('server', 'team_id', $teamId)->where('uuid', $uuid)->first() + ?? SwarmDocker::query()->whereRelation('server', 'team_id', $teamId)->where('uuid', $uuid)->first(); + + return $destination?->server?->canHostResources() ? $destination : null; } } diff --git a/app/Livewire/Project/Database/Backup/Execution.php b/app/Livewire/Project/Database/Backup/Execution.php index a96a190e82..726ee3b698 100644 --- a/app/Livewire/Project/Database/Backup/Execution.php +++ b/app/Livewire/Project/Database/Backup/Execution.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Database\Backup; +use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use Livewire\Component; @@ -43,7 +44,10 @@ class Execution extends Component $this->database = $database; $this->backup = $backup; $this->executions = $executions; - $this->s3s = currentTeam()->s3s; + $this->s3s = S3Storage::query() + ->where('team_id', $database->team()->id) + ->where('is_usable', true) + ->get(); $this->parameters = get_route_parameters(); $this->section = match (request()->route()?->getName()) { 'project.database.backup.s3' => 's3', diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 0507529696..59dd1a29c0 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -6,6 +6,7 @@ use App\Jobs\DatabaseBackupJob; use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\ServiceDatabase; +use App\Models\StandalonePostgresql; use App\Traits\ListensToTeamChannel; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -400,22 +401,16 @@ class BackupEdit extends Component private function availableS3StorageIds(): Collection { - $storages = collect($this->availableS3Storages); - $storageIds = $storages->pluck('id')->filter()->all(); + $database = $this->backup->database; + // The instance database (id 0) has no project and belongs to the root team. + $teamId = $database instanceof StandalonePostgresql && $database->id === 0 ? 0 : $database?->team()?->id; - if (empty($storageIds)) { - return collect(); - } - - $teamIds = $storages->pluck('team_id')->reject(fn ($teamId) => $teamId === null)->unique()->values()->all(); - - if (empty($teamIds)) { + if ($teamId === null) { return collect(); } return S3Storage::query() - ->whereKey($storageIds) - ->whereIn('team_id', $teamIds) + ->where('team_id', $teamId) ->where('is_usable', true) ->pluck('id'); } diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index d61a19f2c7..ea8d8f197b 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -10,6 +10,7 @@ use App\Support\ValidationPatterns; use App\Traits\ListensToTeamChannel; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Locked; use Livewire\Component; class General extends Component @@ -45,6 +46,10 @@ class General extends Component public bool $isPasswordHiddenForMember = false; + /** Unnamed Docker volume that holds the current data; shown as a warning. */ + #[Locked] + public ?string $anonymousDataVolume = null; + public function getListeners(): array { return $this->teamChannelListeners([ @@ -63,6 +68,7 @@ class General extends Component return; } + $this->anonymousDataVolume = $this->database->anonymousDataVolume(); } catch (\Throwable $e) { return handleError($e, $this); } @@ -197,6 +203,27 @@ class General extends Component } } + public function keepCurrentDataVolume(): void + { + try { + $this->authorize('update', $this->database); + + $volumeName = $this->database->anonymousDataVolume(); + if ($volumeName === null) { + $this->anonymousDataVolume = null; + $this->dispatch('info', 'This database already uses its data volume.'); + + return; + } + + $this->database->keepAnonymousDataVolume($volumeName); + $this->anonymousDataVolume = null; + $this->dispatch('success', 'The current data volume is now the data volume of this database. You can restart it safely.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function databaseProxyStopped(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Service/BackupExecutions.php b/app/Livewire/Project/Service/BackupExecutions.php index 2f21e60383..81be0ff8ad 100644 --- a/app/Livewire/Project/Service/BackupExecutions.php +++ b/app/Livewire/Project/Service/BackupExecutions.php @@ -122,12 +122,14 @@ class BackupExecutions extends Component ->whereIn('id', $rows->where('type', 'storage')->pluck('id')) ->get()->keyBy('id'); - return $rows->map(function (object $row) use ($databaseExecutions, $volumeExecutions): array { + $serviceTeamId = $this->service->team()?->id; + + return $rows->map(function (object $row) use ($databaseExecutions, $volumeExecutions, $serviceTeamId): array { $isDatabase = $row->type === 'database'; $execution = $isDatabase ? $databaseExecutions->get($row->id) : $volumeExecutions->get($row->id); $schedule = $isDatabase ? $execution->scheduledDatabaseBackup : $execution->scheduledVolumeBackup; $storage = $isDatabase ? ($schedule->save_s3 ? $schedule->s3 : null) : $execution->s3; - if ($storage?->team_id !== currentTeam()->id) { + if ($storage?->team_id !== $serviceTeamId) { $storage = null; } $storageLabel = $storage ? $storage->name.' (bucket: '.$storage->bucket.')' : 'Unavailable'; diff --git a/app/Livewire/Project/Service/DatabaseBackups.php b/app/Livewire/Project/Service/DatabaseBackups.php index 8535584dd8..71885094aa 100644 --- a/app/Livewire/Project/Service/DatabaseBackups.php +++ b/app/Livewire/Project/Service/DatabaseBackups.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Service; +use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\Service; use App\Models\ServiceDatabase; @@ -77,7 +78,10 @@ class DatabaseBackups extends Component $this->backup = $this->serviceDatabase->scheduledBackups() ->where('uuid', request()->route('backup_uuid')) ->firstOrFail(); - $this->s3s = currentTeam()->s3s; + $this->s3s = S3Storage::query() + ->where('team_id', $this->service->team()->id) + ->where('is_usable', true) + ->get(); $this->backupParameters = [...$this->parameters, 'backup_uuid' => $this->backup->uuid]; $this->section = match (request()->route()?->getName()) { 'project.service.database.backup.s3' => 's3', diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index e486f8c07a..fd3b8c54da 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -5,6 +5,7 @@ namespace App\Livewire\Project\Service; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; use App\Actions\Service\DeleteService; +use App\Models\S3Storage; use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; @@ -128,7 +129,7 @@ class Index extends Component $this->serviceApplication = null; $this->resourceType = 'database'; $this->initializeDatabaseProperties(); - $this->s3s = currentTeam()->s3s; + $this->s3s = $this->serviceTeamS3Storages(); return; } @@ -146,7 +147,7 @@ class Index extends Component $this->serviceApplication = $serviceApplication; $this->resourceType = 'application'; $this->initializeApplicationProperties(); - $this->s3s = currentTeam()->s3s; + $this->s3s = $this->serviceTeamS3Storages(); return; } @@ -189,12 +190,20 @@ class Index extends Component $this->serviceDatabase->getFilesFromServer(); $this->initializeDatabaseProperties(); } - $this->s3s = currentTeam()->s3s; + $this->s3s = $this->serviceTeamS3Storages(); } catch (\Throwable $e) { return handleError($e, $this); } } + private function serviceTeamS3Storages(): Collection + { + return S3Storage::query() + ->where('team_id', $this->service->team()->id) + ->where('is_usable', true) + ->get(); + } + private function initializeDatabaseProperties(): void { $this->server = $this->serviceDatabase->service->destination->server; @@ -661,7 +670,7 @@ class Index extends Component $this->serviceApplication->save(); $this->auditServiceSubResourceUpdate($this->serviceApplication, $changedFields); $this->serviceApplication->refresh(); - app(ManagedDnsRecordCleanup::class)->queueReleaseOfRemovedHostnames($this->serviceApplication, $previousDnsHostnames, currentTeam()->id); + app(ManagedDnsRecordCleanup::class)->queueReleaseOfRemovedHostnames($this->serviceApplication, $previousDnsHostnames, $this->service->team()->id); $this->syncApplicationData(false); updateCompose($this->serviceApplication); if (str($this->serviceApplication->fqdn)->contains(',')) { diff --git a/app/Livewire/Project/Service/VolumeBackup/Index.php b/app/Livewire/Project/Service/VolumeBackup/Index.php index 77ee2b9d1c..60a78e5c15 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Index.php +++ b/app/Livewire/Project/Service/VolumeBackup/Index.php @@ -4,6 +4,7 @@ namespace App\Livewire\Project\Service\VolumeBackup; use App\Jobs\DatabaseBackupJob; use App\Jobs\VolumeBackupJob; +use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledVolumeBackup; use App\Models\Service; @@ -65,7 +66,10 @@ class Index extends Component { $this->authorize('update', $this->service); $this->loadSelectedSchedule($backupUuid); - $this->s3s = currentTeam()->s3s; + $this->s3s = S3Storage::query() + ->where('team_id', $this->service->team()?->id) + ->where('is_usable', true) + ->get(); $this->scheduleModalOpen = true; } @@ -132,6 +136,7 @@ class Index extends Component 'backups' => $backups, 'databaseBackups' => $databaseBackups, 'databaseTargets' => $databaseTargets, + 'serviceTeamId' => $this->service->team()?->id, ]); } diff --git a/app/Livewire/Project/Shared/ConfigurationChecker.php b/app/Livewire/Project/Shared/ConfigurationChecker.php index 14ce65f95f..1b78f65ecd 100644 --- a/app/Livewire/Project/Shared/ConfigurationChecker.php +++ b/app/Livewire/Project/Shared/ConfigurationChecker.php @@ -107,7 +107,8 @@ class ConfigurationChecker extends Component $array = $diff->toArray(); // Fail closed: only owners/admins may see unlocked env values. - $redactEnvironment = ! (bool) auth()->user()?->isAdmin(); + $teamId = $this->resource->team()?->id; + $redactEnvironment = is_null($teamId) || ! (bool) auth()->user()?->isAdminOfTeam($teamId); $array['changes'] = $this->redactHiddenChanges($array['changes'] ?? [], $redactEnvironment); $this->configurationDiff = $array; diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 1e5a6fb2d4..570973e64d 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -9,6 +9,7 @@ use App\Models\Application; use App\Models\Server; use App\Models\StandaloneDocker; use App\Traits\ListensToTeamChannel; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; @@ -45,9 +46,16 @@ class Destination extends Component $all_networks = $all_networks->push($this->resource->destination); $all_networks = $all_networks->merge($this->resource->additional_networks); - $this->networks = Server::isUsable()->get()->map(function ($server) { - return $server->standaloneDockers; - })->flatten(); + $this->networks = $this->resourceServers() + ->whereRelation('settings', 'is_reachable', true) + ->whereRelation('settings', 'is_usable', true) + ->whereRelation('settings', 'is_swarm_worker', false) + ->whereRelation('settings', 'force_disabled', false) + ->get() + ->filter(fn (Server $server) => $server->canHostResources()) + ->map(function ($server) { + return $server->standaloneDockers; + })->flatten(); $this->networks = $this->networks->reject(function ($network) use ($all_networks) { return $all_networks->pluck('id')->contains($network->id); }); @@ -61,11 +69,19 @@ class Destination extends Component } } + private function resourceServers(): Builder + { + return Server::query() + ->where('team_id', $this->resource->team()->id) + ->with('settings', 'standaloneDockers') + ->orderBy('name'); + } + public function stop($serverId) { try { $this->authorize('deploy', $this->resource); - $server = Server::ownedByCurrentTeam()->findOrFail($serverId); + $server = $this->resourceServers()->findOrFail($serverId); StopApplicationOneServer::run($this->resource, $server); auditLog('ui.application.destination_stopped', [ 'team_id' => $this->resource->team()?->id, @@ -90,7 +106,7 @@ class Destination extends Component return; } $deployment_uuid = new_public_id(); - $server = Server::ownedByCurrentTeam()->findOrFail($server_id); + $server = $this->resourceServers()->findOrFail($server_id); $destination = $server->standaloneDockers->where('id', $network_id)->firstOrFail(); $result = queue_application_deployment( deployment_uuid: $deployment_uuid, @@ -125,8 +141,8 @@ class Destination extends Component public function promote(int $network_id, int $server_id) { try { - $server = Server::ownedByCurrentTeam()->findOrFail($server_id); - $network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id); + $server = $this->resourceServers()->findOrFail($server_id); + $network = StandaloneDocker::query()->where('server_id', $server->id)->findOrFail($network_id); $this->authorize('update', $this->resource); $this->resource->getConnection()->transaction(function () use ($network, $server) { $mainDestination = $this->resource->destination; @@ -156,8 +172,8 @@ class Destination extends Component public function addServer(int $network_id, int $server_id) { try { - $server = Server::ownedByCurrentTeam()->findOrFail($server_id); - $network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id); + $server = $this->resourceServers()->findOrFail($server_id); + $network = StandaloneDocker::query()->where('server_id', $server->id)->findOrFail($network_id); $this->authorize('update', $this->resource); $reason = $this->resource instanceof Application ? $this->resource->additionalServersUnavailableReason($server) @@ -190,7 +206,7 @@ class Destination extends Component return; } - $server = Server::ownedByCurrentTeam()->findOrFail($server_id); + $server = $this->resourceServers()->findOrFail($server_id); StopApplicationOneServer::run($this->resource, $server); $this->resource->additional_networks() ->wherePivot('server_id', $server_id) diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index 15b4410a5f..8adba953db 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -92,7 +92,7 @@ class Add extends Component #[Computed] public function availableSharedVariables(): array { - $team = currentTeam(); + $team = $this->resource ? $this->resource->team() : currentTeam(); $result = [ 'team' => [], 'project' => [], diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 32a8e7e28a..55da7f7228 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -219,12 +219,17 @@ class All extends Component return $this->environmentVariableRowCount > 0; } + private function canViewEnvironmentValues(): bool + { + return auth()->user()?->can('manageEnvironment', $this->resource) ?? false; + } + private function nullLockedValues($envs) { - $isMember = auth()->user()?->isMember(); + $hideValues = ! $this->canViewEnvironmentValues(); - $envs->each(function ($env) use ($isMember) { - if ($env->is_shown_once || $isMember) { + $envs->each(function ($env) use ($hideValues) { + if ($env->is_shown_once || $hideValues) { $env->value = null; $env->real_value = null; } @@ -828,12 +833,12 @@ class All extends Component private function formatEnvironmentVariables($variables) { - $isMember = auth()->user()?->isMember(); + $hideValues = ! $this->canViewEnvironmentValues(); return $variables ->reject(fn ($item): bool => $this->isProtectedEnvironmentVariable($item->key)) - ->map(function ($item) use ($isMember) { - if ($isMember) { + ->map(function ($item) use ($hideValues) { + if ($hideValues) { return "$item->key=(Hidden, only admins can view)"; } if ($item->is_shown_once) { @@ -849,6 +854,7 @@ class All extends Component public function switch() { + $this->authorize('view', $this->resource); $this->view = $this->view === 'normal' ? 'dev' : 'normal'; if ($this->view === 'dev') { $this->ensureEnvironmentVariablesLoaded(); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 191c7b8223..636a0e6ea8 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -173,7 +173,7 @@ class Show extends Component public function copyValue(): ?string { - if ($this->env->is_shown_once || (auth()->user()?->cannot('update', $this->env) ?? true)) { + if ($this->env->is_shown_once || $this->valuesHiddenForUser()) { return null; } @@ -231,7 +231,7 @@ class Show extends Component $this->is_required = (bool) ($this->env->is_required ?? false); // Use the stored column, not the value-based accessor (that decrypts). $this->is_shared = (bool) ($this->env->getAttributes()['is_shared'] ?? false); - $this->isValueHidden = auth()->user()?->isMember() ?? true; + $this->isValueHidden = $this->valuesHiddenForUser(); if ($this->valuesLoaded) { $this->hydrateValueFields(); @@ -260,12 +260,17 @@ class Show extends Component $this->is_really_required = $this->is_required && blank($this->value); } - if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) { + if ($this->env->is_shown_once || $this->valuesHiddenForUser()) { $this->value = null; $this->real_value = null; } - $this->isValueHidden = auth()->user()?->isMember() ?? true; + $this->isValueHidden = $this->valuesHiddenForUser(); + } + + private function valuesHiddenForUser(): bool + { + return auth()->user()?->cannot('update', $this->env) ?? true; } public function checkEnvs() @@ -345,7 +350,7 @@ class Show extends Component // Shared across all Show row components in the same request (edit modals). static $requestCache = []; - $team = currentTeam(); + $team = $this->isSharedVariable ? $this->env->team : $this->env->resourceable?->team(); $cacheKey = implode('|', [ $team?->id ?? 'none', data_get($this->parameters, 'project_uuid', ''), diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index 6f9bc0265e..2b6f5b4587 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -11,7 +11,9 @@ use App\Jobs\VolumeCloneJob; use App\Models\Application; use App\Models\Environment; use App\Models\Project; +use App\Models\Server; use App\Models\StandaloneClickhouse; +use App\Models\StandaloneDocker; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; use App\Models\StandaloneMariadb; @@ -20,9 +22,11 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use App\Models\StandaloneSqlite; +use App\Models\SwarmDocker; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Validation\ValidationException; use Livewire\Component; +use RuntimeException; class ResourceOperations extends Component { @@ -49,8 +53,9 @@ class ResourceOperations extends Component $parameters = get_route_parameters(); $this->projectUuid = data_get($parameters, 'project_uuid'); $this->environmentUuid = data_get($parameters, 'environment_uuid'); - $this->projects = Project::ownedByCurrentTeamCached(); - $servers = currentTeam()->servers()->get(); + $teamId = $this->resourceTeamId(); + $this->projects = Project::query()->where('team_id', $teamId)->orderByRaw('LOWER(name)')->get(); + $servers = Server::query()->where('team_id', $teamId)->get(); $this->servers = $servers->reject(fn ($server) => $server->isBuildServer()); $this->buildServers = $servers->filter(fn ($server) => $server->isBuildServer()); } @@ -72,13 +77,13 @@ class ResourceOperations extends Component $new_environment = $this->resource->environment; if ($environment_id !== null) { - $new_environment = Environment::ownedByCurrentTeam()->find($environment_id); + $new_environment = $this->findResourceTeamEnvironment($environment_id); if (! $new_environment) { return $this->addError('environment_id', 'Environment not found.'); } } - $new_destination = find_resource_destination_for_current_team($destination_uuid); + $new_destination = $this->findResourceTeamDestination($destination_uuid); if (! $new_destination) { return $this->addError('destination_id', 'Destination not found.'); } @@ -224,7 +229,7 @@ class ResourceOperations extends Component 'uuid' => $uuid, 'database_id' => $new_resource->id, 'database_type' => $new_resource->getMorphClass(), - 'team_id' => currentTeam()->id, + 'team_id' => $this->resourceTeamId(), ]); $newBackup->save(); } @@ -283,7 +288,7 @@ class ResourceOperations extends Component ])->fill([ 'uuid' => new_public_id(), 'service_id' => $new_resource->id, - 'team_id' => currentTeam()->id, + 'team_id' => $this->resourceTeamId(), ]); $newTask->save(); } @@ -408,9 +413,12 @@ class ResourceOperations extends Component { try { $this->authorize('update', $this->resource); - $new_environment = Environment::ownedByCurrentTeam()->findOrFail($environment_id); + $new_environment = $this->findResourceTeamEnvironment($environment_id); + if (! $new_environment) { + return $this->addError('environment_id', 'Environment not found.'); + } $this->resource->fill([ - 'environment_id' => $environment_id, + 'environment_id' => $new_environment->id, ])->save(); if ($this->resource->type() === 'application') { $route = route('project.application.configuration', [ @@ -447,7 +455,7 @@ class ResourceOperations extends Component try { $this->authorize('update', $this->resource); - $new_destination = find_resource_destination_for_current_team($destination_uuid); + $new_destination = $this->findResourceTeamDestination($destination_uuid); if (! $new_destination) { return $this->addError('destination_id', 'Destination not found.'); } @@ -500,6 +508,32 @@ class ResourceOperations extends Component } } + private function resourceTeamId(): int + { + return $this->resource->team()?->id ?? throw new RuntimeException('The resource has no team.'); + } + + /** Cloning and moving stay inside the resource team; the session team can differ after a team switch. */ + private function findResourceTeamEnvironment(int|string|null $environmentId): ?Environment + { + return Environment::query() + ->whereRelation('project', 'team_id', $this->resourceTeamId()) + ->find($environmentId); + } + + private function findResourceTeamDestination(?string $uuid): StandaloneDocker|SwarmDocker|null + { + if (blank($uuid)) { + return null; + } + + $teamId = $this->resourceTeamId(); + $destination = StandaloneDocker::query()->whereRelation('server', 'team_id', $teamId)->where('uuid', $uuid)->first() + ?? SwarmDocker::query()->whereRelation('server', 'team_id', $teamId)->where('uuid', $uuid)->first(); + + return $destination?->server?->canHostResources() ? $destination : null; + } + public function render() { return view('livewire.project.shared.resource-operations'); diff --git a/app/Livewire/Project/Shared/ScheduledTask/Add.php b/app/Livewire/Project/Shared/ScheduledTask/Add.php index 525a3ccb41..df49f6cf0e 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Add.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Add.php @@ -111,7 +111,7 @@ class Add extends Component $task->frequency = $this->frequency; $task->container = $this->container; $task->timeout = $this->timeout; - $task->team_id = currentTeam()->id; + $task->team_id = $this->resource->team()->id; switch ($this->type) { case 'application': diff --git a/app/Livewire/Project/Shared/Tags.php b/app/Livewire/Project/Shared/Tags.php index 6b3493ace9..9305d20a63 100644 --- a/app/Livewire/Project/Shared/Tags.php +++ b/app/Livewire/Project/Shared/Tags.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\Shared; use App\Models\Tag; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; @@ -28,7 +29,7 @@ class Tags extends Component public function loadTags() { - $this->tags = Tag::ownedByCurrentTeam()->get(); + $this->tags = $this->resourceTags()->get(); $this->filteredTags = $this->tags->filter(function ($tag) { return ! $this->resource->tags->contains($tag); }); @@ -52,13 +53,11 @@ class Tags extends Component continue; } - $found = Tag::ownedByCurrentTeam()->where(['name' => $tag])->exists(); - if (! $found) { - $found = Tag::create([ + $found = $this->resourceTags()->where('name', $tag)->first() + ?? Tag::create([ 'name' => $tag, - 'team_id' => currentTeam()->id, + 'team_id' => $this->resource->team()->id, ]); - } $this->resource->tags()->attach($found->id); } $this->refresh(); @@ -71,7 +70,7 @@ class Tags extends Component { try { $this->authorize('update', $this->resource); - $tag = Tag::ownedByCurrentTeam()->findOrFail($id); + $tag = $this->resourceTags()->findOrFail($id); if ($this->resource->tags()->whereKey($tag->id)->exists()) { $this->dispatch('error', 'Duplicate tags.', 'Tag '.e($tag->name).' already added.'); @@ -90,7 +89,7 @@ class Tags extends Component try { $this->authorize('update', $this->resource); $this->resource->tags()->detach($id); - $found_more_tags = Tag::ownedByCurrentTeam()->find($id); + $found_more_tags = $this->resourceTags()->find($id); $found_more_tags?->deleteIfOrphaned(); $this->refresh(); $this->dispatch('success', 'Tag deleted.'); @@ -99,6 +98,11 @@ class Tags extends Component } } + private function resourceTags(): Builder + { + return Tag::query()->where('team_id', $this->resource->team()?->id)->orderBy('name'); + } + public function refresh() { $this->resource->refresh(); // Remove this when legacy_model_binding is false diff --git a/app/Livewire/Project/Show.php b/app/Livewire/Project/Show.php index 0481a3f4d5..ee73e72f93 100644 --- a/app/Livewire/Project/Show.php +++ b/app/Livewire/Project/Show.php @@ -63,7 +63,7 @@ class Show extends Component public function submit() { try { - $this->authorize('create', Environment::class); + $this->authorize('update', $this->project); $this->validate(); $environment = Environment::create([ 'name' => $this->name, diff --git a/app/Livewire/Server/CloudProviderToken/Show.php b/app/Livewire/Server/CloudProviderToken/Show.php index a42a688042..e8216d0549 100644 --- a/app/Livewire/Server/CloudProviderToken/Show.php +++ b/app/Livewire/Server/CloudProviderToken/Show.php @@ -44,7 +44,7 @@ class Show extends Component $this->provider = $this->server->vultr_instance_id ? 'vultr' : 'hetzner'; $this->providerName = $this->provider === 'vultr' ? 'Vultr' : 'Hetzner'; - $this->cloudProviderTokens = CloudProviderToken::ownedByCurrentTeam() + $this->cloudProviderTokens = CloudProviderToken::where('team_id', $this->server->team_id) ->where('provider', $this->provider) ->get(); } @@ -56,7 +56,7 @@ class Show extends Component public function setCloudProviderToken($tokenId) { - $ownedToken = CloudProviderToken::ownedByCurrentTeam()->find($tokenId); + $ownedToken = CloudProviderToken::where('team_id', $this->server->team_id)->find($tokenId); if (is_null($ownedToken)) { $this->dispatch('error', 'You are not allowed to use this token.'); @@ -77,7 +77,7 @@ class Show extends Component $this->server->save(); auditLog('ui.server.cloud_token_assigned', [ - 'team_id' => currentTeam()->id, + 'team_id' => $this->server->team_id, 'server_uuid' => $this->server->uuid, 'server_name' => $this->server->name, 'cloud_token_uuid' => $ownedToken->uuid, @@ -171,7 +171,7 @@ class Show extends Component } auditLog('ui.server.cloud_token_validated', [ - 'team_id' => currentTeam()->id, + 'team_id' => $this->server->team_id, 'server_uuid' => $this->server->uuid, 'server_name' => $this->server->name, 'cloud_token_uuid' => $token->uuid, diff --git a/app/Livewire/Server/Destinations.php b/app/Livewire/Server/Destinations.php index c3740a7613..7f6cfe954e 100644 --- a/app/Livewire/Server/Destinations.php +++ b/app/Livewire/Server/Destinations.php @@ -29,8 +29,8 @@ class Destinations extends Component public function add($name) { + $this->authorize('update', $this->server); if ($this->server->isSwarm()) { - $this->authorize('create', SwarmDocker::class); $found = $this->server->swarmDockers()->where('network', $name)->first(); if ($found) { $this->dispatch('error', 'Network already added to this server.'); @@ -45,7 +45,6 @@ class Destinations extends Component $this->auditDestinationCreated($destination, 'swarm'); } } else { - $this->authorize('create', StandaloneDocker::class); $found = $this->server->standaloneDockers()->where('network', $name)->first(); if ($found) { $this->dispatch('error', 'Network already added to this server.'); diff --git a/app/Livewire/Server/PrivateKey/Show.php b/app/Livewire/Server/PrivateKey/Show.php index 54f3436dce..7bc02aa03d 100644 --- a/app/Livewire/Server/PrivateKey/Show.php +++ b/app/Livewire/Server/PrivateKey/Show.php @@ -22,15 +22,20 @@ class Show extends Component { try { $this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); - $this->privateKeys = PrivateKey::ownedByCurrentTeam()->get()->where('is_git_related', false); + $this->loadPrivateKeys(); } catch (\Throwable $e) { return handleError($e, $this); } } + private function loadPrivateKeys(): void + { + $this->privateKeys = PrivateKey::where('team_id', $this->server->team_id)->get()->where('is_git_related', false); + } + public function setPrivateKey($privateKeyId) { - $ownedPrivateKey = PrivateKey::ownedByCurrentTeam()->find($privateKeyId); + $ownedPrivateKey = PrivateKey::where('team_id', $this->server->team_id)->find($privateKeyId); if (is_null($ownedPrivateKey)) { $this->dispatch('error', 'You are not allowed to use this private key.'); @@ -59,6 +64,7 @@ class Show extends Component { try { $this->authorize('create', PrivateKey::class); + $this->authorize('update', $this->server); if (! in_array($type, ['ed25519', 'rsa'], true)) { $this->dispatch('error', 'Invalid private key type.'); @@ -71,10 +77,10 @@ class Show extends Component 'name' => $keyData['name'], 'description' => $keyData['description'], 'private_key' => $keyData['private_key'], - 'team_id' => currentTeam()->id, + 'team_id' => $this->server->team_id, ]); - $this->privateKeys = PrivateKey::ownedByCurrentTeam()->get()->where('is_git_related', false); + $this->loadPrivateKeys(); $this->dispatch('copyPublicKeyToClipboard', publicKey: $privateKey->public_key); $this->dispatch('success', 'Private key created successfully.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 275622691a..77b31ce211 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -784,21 +784,21 @@ class Show extends Component public function loadHetznerTokens(): void { - $this->availableHetznerTokens = CloudProviderToken::ownedByCurrentTeam() + $this->availableHetznerTokens = CloudProviderToken::where('team_id', $this->server->team_id) ->where('provider', 'hetzner') ->get(); } public function loadVultrTokens(): void { - $this->availableVultrTokens = CloudProviderToken::ownedByCurrentTeam() + $this->availableVultrTokens = CloudProviderToken::where('team_id', $this->server->team_id) ->where('provider', 'vultr') ->get(); } public function loadDigitalOceanTokens(): void { - $this->availableDigitalOceanTokens = CloudProviderToken::ownedByCurrentTeam() + $this->availableDigitalOceanTokens = CloudProviderToken::where('team_id', $this->server->team_id) ->where('provider', 'digitalocean') ->get(); } diff --git a/app/Livewire/SharedVariables/Environment/Show.php b/app/Livewire/SharedVariables/Environment/Show.php index 4bc4679591..5e07fc4f72 100644 --- a/app/Livewire/SharedVariables/Environment/Show.php +++ b/app/Livewire/SharedVariables/Environment/Show.php @@ -42,7 +42,7 @@ class Show extends Component 'is_literal' => $data['is_literal'], 'comment' => $data['comment'] ?? null, 'type' => 'environment', - 'team_id' => currentTeam()->id, + 'team_id' => $this->project->team_id, ]); $this->environment->refresh(); $this->getDevView(); @@ -76,10 +76,10 @@ class Show extends Component private function formatEnvironmentVariables($variables) { - $isMember = auth()->user()?->isMember(); + $canViewValues = auth()->user()?->isAdminOfTeam($this->project->team_id) ?? false; - return $variables->map(function ($item) use ($isMember) { - if ($isMember) { + return $variables->map(function ($item) use ($canViewValues) { + if (! $canViewValues) { return "$item->key=(Hidden, only admins can view)"; } if ($item->is_shown_once) { @@ -167,7 +167,7 @@ class Show extends Component 'is_multiline' => false, 'is_literal' => false, 'type' => 'environment', - 'team_id' => currentTeam()->id, + 'team_id' => $this->project->team_id, ]); $count++; } diff --git a/app/Livewire/SharedVariables/Project/Show.php b/app/Livewire/SharedVariables/Project/Show.php index 6b7f26c26e..af06ce7d7f 100644 --- a/app/Livewire/SharedVariables/Project/Show.php +++ b/app/Livewire/SharedVariables/Project/Show.php @@ -35,7 +35,7 @@ class Show extends Component 'is_literal' => $data['is_literal'], 'comment' => $data['comment'] ?? null, 'type' => 'project', - 'team_id' => currentTeam()->id, + 'team_id' => $this->project->team_id, ]); $this->project->refresh(); $this->getDevView(); @@ -74,10 +74,10 @@ class Show extends Component private function formatEnvironmentVariables($variables) { - $isMember = auth()->user()?->isMember(); + $canViewValues = auth()->user()?->isAdminOfTeam($this->project->team_id) ?? false; - return $variables->map(function ($item) use ($isMember) { - if ($isMember) { + return $variables->map(function ($item) use ($canViewValues) { + if (! $canViewValues) { return "$item->key=(Hidden, only admins can view)"; } if ($item->is_shown_once) { @@ -159,7 +159,7 @@ class Show extends Component 'is_multiline' => false, 'is_literal' => false, 'type' => 'project', - 'team_id' => currentTeam()->id, + 'team_id' => $this->project->team_id, ]); $count++; } diff --git a/app/Livewire/SharedVariables/Server/Show.php b/app/Livewire/SharedVariables/Server/Show.php index 4701465739..ee89b2dcd3 100644 --- a/app/Livewire/SharedVariables/Server/Show.php +++ b/app/Livewire/SharedVariables/Server/Show.php @@ -39,7 +39,7 @@ class Show extends Component 'is_literal' => $data['is_literal'], 'comment' => $data['comment'] ?? null, 'type' => 'server', - 'team_id' => currentTeam()->id, + 'team_id' => $this->server->team_id, ]); $this->server->refresh(); $this->getDevView(); @@ -75,10 +75,10 @@ class Show extends Component private function formatEnvironmentVariables($variables) { - $isMember = auth()->user()?->isMember(); + $canViewValues = auth()->user()?->isAdminOfTeam($this->server->team_id) ?? false; - return $variables->map(function ($item) use ($isMember) { - if ($isMember) { + return $variables->map(function ($item) use ($canViewValues) { + if (! $canViewValues) { return "$item->key=(Hidden, only admins can view)"; } if ($item->is_shown_once) { @@ -173,7 +173,7 @@ class Show extends Component 'is_multiline' => false, 'is_literal' => false, 'type' => 'server', - 'team_id' => currentTeam()->id, + 'team_id' => $this->server->team_id, ]); $count++; } diff --git a/app/Livewire/SharedVariables/Team/Index.php b/app/Livewire/SharedVariables/Team/Index.php index 32da83672e..b7052590b2 100644 --- a/app/Livewire/SharedVariables/Team/Index.php +++ b/app/Livewire/SharedVariables/Team/Index.php @@ -35,7 +35,7 @@ class Index extends Component 'is_literal' => $data['is_literal'], 'comment' => $data['comment'] ?? null, 'type' => 'team', - 'team_id' => currentTeam()->id, + 'team_id' => $this->team->id, ]); $this->team->refresh(); $this->getDevView(); @@ -68,10 +68,10 @@ class Index extends Component private function formatEnvironmentVariables($variables) { - $isMember = auth()->user()?->isMember(); + $canViewValues = auth()->user()?->isAdminOfTeam($this->team->id) ?? false; - return $variables->map(function ($item) use ($isMember) { - if ($isMember) { + return $variables->map(function ($item) use ($canViewValues) { + if (! $canViewValues) { return "$item->key=(Hidden, only admins can view)"; } if ($item->is_shown_once) { @@ -158,7 +158,7 @@ class Index extends Component 'is_multiline' => false, 'is_literal' => false, 'type' => 'team', - 'team_id' => currentTeam()->id, + 'team_id' => $this->team->id, ]); $count++; } diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 02a9f8385d..a5f60fc0e8 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -397,7 +397,7 @@ class Change extends Component return; } - if (! PrivateKey::ownedByCurrentTeam()->find($this->privateKeyId)) { + if (! PrivateKey::where('team_id', $this->github_app->team_id)->find($this->privateKeyId)) { $this->dispatch('error', 'No private key found for this GitHub App.'); return; diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 6265345ee9..8c15e6853b 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -11,6 +11,7 @@ use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; +use Throwable; class StandaloneClickhouse extends BaseModel { @@ -18,6 +19,8 @@ class StandaloneClickhouse extends BaseModel protected array $auditExclude = ['last_online_at']; + public const DATA_DIRECTORY = '/var/lib/clickhouse'; + protected $fillable = [ 'uuid', 'name', @@ -185,6 +188,57 @@ class StandaloneClickhouse extends BaseModel } } + /** + * Name of the unnamed Docker volume that holds this database's data, or null. + * + * Before v4.4, adding a file mount removed the data volume from the compose file, so the + * container keeps its data in an unnamed volume. Recreating the container leaves that volume + * behind and starts on the configured data volume, so the current data seems lost. + */ + public function anonymousDataVolume(): ?string + { + $server = data_get($this, 'destination.server'); + if (! $server?->isFunctional()) { + return null; + } + + $format = '{{range .Mounts}}{{if eq .Destination "'.self::DATA_DIRECTORY.'"}}{{.Type}} {{.Name}}{{end}}{{end}}'; + try { + $output = instant_remote_process_with_timeout([ + 'docker inspect --format '.escapeshellarg($format).' '.escapeshellarg($this->uuid).' 2>/dev/null || true', + ], $server, false); + } catch (Throwable) { + return null; + } + + [$type, $name] = array_pad(explode(' ', trim((string) $output), 2), 2, ''); + if ($type !== 'volume' || $name === '' || $this->persistentStorages()->where('name', $name)->exists()) { + return null; + } + + return $name; + } + + /** + * Use the unnamed volume that holds the current data as the data volume, so a restart keeps that data. + */ + public function keepAnonymousDataVolume(string $volumeName): void + { + $storage = $this->persistentStorages()->where('mount_path', self::DATA_DIRECTORY)->first(); + if ($storage) { + $storage->update(['name' => $volumeName]); + + return; + } + + LocalPersistentVolume::create([ + 'name' => $volumeName, + 'mount_path' => self::DATA_DIRECTORY, + 'resource_id' => $this->id, + 'resource_type' => $this->getMorphClass(), + ]); + } + public function deleteVolumes() { $persistentStorages = $this->persistentStorages()->get() ?? collect(); diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index 890a3dac48..b518abff7d 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -7,6 +7,7 @@ use App\Jobs\VolumeCloneJob; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationPreview; +use App\Models\Environment; use App\Models\EnvironmentVariable; use App\Models\Server; use App\Models\StandaloneDocker; @@ -275,8 +276,11 @@ function clone_application(Application $source, $destination, array $overrides = $uuid = $overrides['uuid'] ?? new_public_id(); $server = $destination->server; - if ($server->team_id !== currentTeam()->id) { - throw new RuntimeException('Destination does not belong to the current team.'); + $teamId = $server->team_id; + $sourceTeamId = $source->team()?->id; + $environmentTeamId = Environment::query()->find($overrides['environment_id'] ?? $source->environment_id)?->project?->team_id; + if ($sourceTeamId === null || $environmentTeamId === null || (int) $sourceTeamId !== (int) $teamId || (int) $environmentTeamId !== (int) $teamId) { + throw new RuntimeException('The application, the target environment, and the destination must belong to the same team.'); } // Prepare name and URL @@ -342,7 +346,7 @@ function clone_application(Application $source, $destination, array $overrides = ])->fill([ 'uuid' => new_public_id(), 'application_id' => $newApplication->id, - 'team_id' => currentTeam()->id, + 'team_id' => $teamId, ]); $newTask->save(); } diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index e32d3cf63e..da3b6215ed 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -231,8 +231,11 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d $serverTimezone = getServerTimezone(data_get($application, 'destination.server')); // Members should never see debug logs, even if an admin enabled debug mode - if ($is_debug_enabled && auth()->check() && auth()->user()->isMember()) { - $is_debug_enabled = false; + if ($is_debug_enabled && auth()->check()) { + $teamId = $application?->team()?->id; + if (is_null($teamId) || ! auth()->user()->isAdminOfTeam($teamId)) { + $is_debug_enabled = false; + } } $logs = data_get($application_deployment_queue, 'logs'); diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 1df2bedfb2..3f8ef0fce9 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -280,7 +280,7 @@ @endif @if ($buildPack !== 'dockercompose') @php - $hasBuildServers = \App\Models\Server::buildServers(currentTeam()->id)->exists(); + $hasBuildServers = \App\Models\Server::buildServers($application->team()?->id)->exists(); $buildServerOptions = [ ['value' => false, 'label' => 'Deployment server'], $hasBuildServers diff --git a/resources/views/livewire/project/database/clickhouse/general.blade.php b/resources/views/livewire/project/database/clickhouse/general.blade.php index 1a83934c8d..e7fe47fe0f 100644 --- a/resources/views/livewire/project/database/clickhouse/general.blade.php +++ b/resources/views/livewire/project/database/clickhouse/general.blade.php @@ -2,6 +2,29 @@
+ @if ($anonymousDataVolume) + +

Because of a bug in older Coolify versions, a file mount removed the data volume. This database + keeps its current data in the unnamed Docker volume {{ $anonymousDataVolume }}. + Starting or restarting it is blocked, because it would use the data volume and the current data + would seem lost.

+

An admin can click "Keep current data" to use this volume as the data volume. The previous data + volume is not used anymore, but stays on the server. It can contain data from before the file + mount was added.

+ @can('update', $database) +
+ +
+ @endcan +
+ @endif + diff --git a/resources/views/livewire/project/service/volume-backup/index.blade.php b/resources/views/livewire/project/service/volume-backup/index.blade.php index 34535d8fe7..26b835cdc8 100644 --- a/resources/views/livewire/project/service/volume-backup/index.blade.php +++ b/resources/views/livewire/project/service/volume-backup/index.blade.php @@ -268,7 +268,7 @@ default => 'neutral', }; $databaseBackupId = 'database:'.$databaseBackup->id; - $databaseS3 = $databaseBackup->s3?->team_id === currentTeam()->id ? $databaseBackup->s3 : null; + $databaseS3 = $databaseBackup->s3?->team_id === $serviceTeamId ? $databaseBackup->s3 : null; $databaseS3Tooltip = ! $databaseBackup->save_s3 ? 'S3 storage: Not configured' : ($databaseS3 ? 'S3 storage: '.$databaseS3->name.' (bucket: '.$databaseS3->bucket.')' : 'S3 storage: Unavailable'); @endphp
latestExecution; - $volumeS3 = $backup->s3?->team_id === currentTeam()->id ? $backup->s3 : null; + $volumeS3 = $backup->s3?->team_id === $serviceTeamId ? $backup->s3 : null; $volumeS3Tooltip = ! $backup->save_s3 ? 'S3 storage: Not configured' : ($volumeS3 ? 'S3 storage: '.$volumeS3->name.' (bucket: '.$volumeS3->bucket.')' : 'S3 storage: Unavailable'); $status = $latestExecution?->status; $statusLabel = match ($status) { diff --git a/resources/views/livewire/project/shared/storages/all.blade.php b/resources/views/livewire/project/shared/storages/all.blade.php index d213f827c1..f13568bb07 100644 --- a/resources/views/livewire/project/shared/storages/all.blade.php +++ b/resources/views/livewire/project/shared/storages/all.blade.php @@ -59,9 +59,6 @@ class="min-w-0 truncate text-[13px] font-medium text-neutral-950 dark:text-fg" title="{{ $form['name'] }}">{{ $form['name'] }}
- @if (blank($storage->host_path)) - Volume mount - @endif @if ($form['replacedExternalVolume']) Replaces the external volume '{{ $form['replacedExternalVolume'] }}'. Copy the data into the external volume, then delete this entry to use it. @@ -202,8 +199,6 @@ SQLite database {{ $storage->standaloneSqlite->name }} - @elseif (blank($storage->host_path)) - Volume mount @endif diff --git a/tests/Feature/Application/GithubSourceChangeTest.php b/tests/Feature/Application/GithubSourceChangeTest.php index 44baaf8fb6..ccbd47005b 100644 --- a/tests/Feature/Application/GithubSourceChangeTest.php +++ b/tests/Feature/Application/GithubSourceChangeTest.php @@ -9,6 +9,7 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Storage; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -884,3 +885,50 @@ describe('GitHub Source Change Component', function () { ->assertSee('Setup incomplete'); }); }); + +describe('GitHub App name sync after the user switches the session team', function () { + beforeEach(function () { + Storage::fake('ssh-keys'); + $this->otherTeam = Team::factory()->create(); + $this->otherTeam->members()->attach($this->user->id, ['role' => 'owner']); + $this->appKey = PrivateKey::create(['name' => 'App Key', 'private_key' => validPrivateKey(), 'team_id' => $this->team->id]); + $this->otherTeamKey = PrivateKey::create(['name' => 'Other Team Key', 'private_key' => validPrivateKey(), 'team_id' => $this->otherTeam->id]); + $this->githubApp = GithubApp::create([ + 'name' => 'Test GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 12345, + 'installation_id' => 67890, + 'private_key_id' => $this->appKey->id, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + ]); + + Http::preventStrayRequests(); + Http::fake([ + 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['date' => now()->toRfc7231String()]), + 'https://api.github.com/app' => Http::response(['slug' => 'synced-app']), + ]); + + $this->component = Livewire::withQueryParams(['github_app_uuid' => $this->githubApp->uuid])->test(Change::class); + session(['currentTeam' => $this->otherTeam]); + }); + + test('rejects a private key of the session team', function () { + $this->component->set('privateKeyId', $this->otherTeamKey->id) + ->call('updateGithubAppName') + ->assertDispatched('error', 'No private key found for this GitHub App.'); + + expect($this->githubApp->fresh()->private_key_id)->toBe($this->appKey->id); + Http::assertNothingSent(); + }); + + test('syncs the name with a private key of the GitHub App team', function () { + $this->component->call('updateGithubAppName') + ->assertDispatched('success'); + + expect($this->githubApp->fresh()->name)->toBe('synced-app'); + }); +}); diff --git a/tests/Feature/ApplicationSourceCrossTeamTest.php b/tests/Feature/ApplicationSourceCrossTeamTest.php index 6db2383be5..5d6fd1c3dc 100644 --- a/tests/Feature/ApplicationSourceCrossTeamTest.php +++ b/tests/Feature/ApplicationSourceCrossTeamTest.php @@ -168,3 +168,77 @@ test('privateKeyId is locked so submit() cannot persist a client-supplied foreig Livewire::test(Source::class, ['application' => $this->applicationA]) ->set('privateKeyId', $this->victimPrivateKey->id); })->throws(CannotUpdateLockedPropertyException::class); + +describe('after the user switches the session team', function () { + beforeEach(function () { + Http::fake([ + 'https://api.github.com/repos/*' => Http::response(['id' => 123]), + ]); + $this->teamB->members()->attach($this->userA->id, ['role' => 'owner']); + $this->applicationA->update(['git_repository' => 'coollabsio/coolify']); + + $this->ownKey = makePrivateKey('own-ssh-key', 'OWN_KEY_MATERIAL', 'own-fingerprint', $this->teamA->id); + $this->ownGithubApp = GithubApp::create([ + 'name' => 'own-github-app', + 'team_id' => $this->teamA->id, + 'app_id' => 1, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'is_public' => false, + ]); + $this->victimGithubApp->update(['app_id' => 2]); + $this->systemWideGithubApp = GithubApp::create([ + 'name' => 'system-wide-github-app', + 'team_id' => Team::factory()->create()->id, + 'app_id' => 3, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'is_public' => false, + 'is_system_wide' => true, + ]); + + $this->component = Livewire::test(Source::class, ['application' => $this->applicationA->fresh()]); + session(['currentTeam' => $this->teamB]); + }); + + test('lists only keys and sources of the application team and system-wide sources', function () { + $component = Livewire::test(Source::class, ['application' => $this->applicationA->fresh()]); + + expect($component->get('privateKeys')->pluck('id')->all())->toBe([$this->ownKey->id]) + ->and($component->get('sources')->pluck('id')->sort()->values()->all()) + ->toBe([$this->ownGithubApp->id, $this->systemWideGithubApp->id]); + }); + + test('setPrivateKey rejects a key of the session team', function () { + $this->component->call('setPrivateKey', $this->victimPrivateKey->id); + + expect($this->applicationA->refresh()->private_key_id)->toBeNull(); + }); + + test('setPrivateKey accepts a key of the application team', function () { + $this->component->call('setPrivateKey', $this->ownKey->id); + + expect($this->applicationA->refresh()->private_key_id)->toBe($this->ownKey->id); + }); + + test('changeSource rejects a source of the session team', function () { + try { + $this->component->call('changeSource', $this->victimGithubApp->id, GithubApp::class); + } catch (Throwable $e) { + } + + expect($this->applicationA->refresh()->source_id)->toBeNull(); + }); + + test('changeSource accepts a source of the application team', function () { + $this->component->call('changeSource', $this->ownGithubApp->id, GithubApp::class); + + expect($this->applicationA->refresh()->source_id)->toBe($this->ownGithubApp->id); + }); + + test('changeSource accepts a system-wide source', function () { + $this->component->call('changeSource', $this->systemWideGithubApp->id, GithubApp::class); + + expect($this->applicationA->refresh()->source_id)->toBe($this->systemWideGithubApp->id); + }); +}); diff --git a/tests/Feature/Authorization/CloneMeCrossTenantTest.php b/tests/Feature/Authorization/CloneMeCrossTenantTest.php new file mode 100644 index 0000000000..01174d5d8c --- /dev/null +++ b/tests/Feature/Authorization/CloneMeCrossTenantTest.php @@ -0,0 +1,178 @@ +withoutVite(); + InstanceSettings::forceCreate(['id' => 0]); + Server::flushIdentityMap(); + + $this->user = User::factory()->create(); + $this->teamA = Team::factory()->create(); + $this->teamB = Team::factory()->create(); + $this->user->teams()->attach($this->teamA, ['role' => 'admin']); + $this->user->teams()->attach($this->teamB, ['role' => 'owner']); + + $this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]); + $this->destinationA = StandaloneDocker::where('server_id', $this->serverA->id)->firstOrFail(); + $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]); + $this->applicationA = Application::factory()->create([ + 'environment_id' => $this->environmentA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => $this->destinationA->getMorphClass(), + ]); + + $this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]); + $this->destinationB = StandaloneDocker::where('server_id', $this->serverB->id)->firstOrFail(); + + $this->actingAs($this->user); + session(['currentTeam' => $this->teamA]); +}); + +afterEach(function () { + Server::flushIdentityMap(); +}); + +function cloneMeAfterTeamSwitch(object $test): Testable +{ + $component = Livewire::test(CloneMe::class, [ + 'project_uuid' => $test->projectA->uuid, + 'environment_uuid' => $test->environmentA->uuid, + ]); + session(['currentTeam' => $test->teamB]); + + return $component; +} + +test('lists only servers of the project team', function () { + session(['currentTeam' => $this->teamA]); + + $component = Livewire::test(CloneMe::class, [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]); + + expect($component->get('servers')->pluck('id')->all())->toBe([$this->serverA->id]); +}); + +test('project clone with a session team destination creates nothing after a team switch', function () { + $projectCount = Project::count(); + $environmentCount = Environment::count(); + + cloneMeAfterTeamSwitch($this) + ->set('selectedDestination', $this->destinationB->uuid) + ->set('newName', 'zz-cross-team-clone') + ->call('clone', 'project') + ->assertDispatched('error'); + + expect(Project::count())->toBe($projectCount) + ->and(Environment::count())->toBe($environmentCount) + ->and(Application::count())->toBe(1); +}); + +test('environment clone with a session team destination creates nothing after a team switch', function () { + $environmentCount = Environment::count(); + + cloneMeAfterTeamSwitch($this) + ->set('selectedDestination', $this->destinationB->uuid) + ->set('newName', 'zz-cross-team-env') + ->call('clone', 'environment') + ->assertDispatched('error'); + + expect(Environment::count())->toBe($environmentCount) + ->and(Application::count())->toBe(1); +}); + +test('a member of the project team cannot clone after switching to a team they own', function () { + $this->user->teams()->updateExistingPivot($this->teamA->id, ['role' => 'member']); + $this->user->unsetRelation('teams'); + $projectCount = Project::count(); + + cloneMeAfterTeamSwitch($this) + ->set('selectedDestination', $this->destinationA->uuid) + ->set('newName', 'zz-member-clone') + ->call('clone', 'project') + ->assertDispatched('error'); + + expect(Project::count())->toBe($projectCount) + ->and(Application::count())->toBe(1); +}); + +test('project clone after a team switch stays in the source project team', function () { + $database = StandalonePostgresql::create([ + 'name' => 'pg-clone', + 'uuid' => new_public_id(), + 'postgres_password' => 'secret', + 'postgres_user' => 'postgres', + 'postgres_db' => 'postgres', + 'environment_id' => $this->environmentA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => $this->destinationA->getMorphClass(), + 'status' => 'exited', + ]); + ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', 'database_type' => $database->getMorphClass(), 'database_id' => $database->id, + 'team_id' => $this->teamA->id, + ]); + + cloneMeAfterTeamSwitch($this) + ->set('selectedDestination', $this->destinationA->uuid) + ->set('newName', 'zz-same-team-clone') + ->call('clone', 'project') + ->assertNotDispatched('error') + ->assertRedirect(); + + $project = Project::where('name', 'zz-same-team-clone')->sole(); + $environment = $project->environments()->where('name', $this->environmentA->name)->sole(); + $clonedDatabase = StandalonePostgresql::where('environment_id', $environment->id)->sole(); + + expect($project->team_id)->toBe($this->teamA->id) + ->and($environment->applications()->count())->toBe(1) + ->and($clonedDatabase->scheduledBackups()->sole()->team_id)->toBe($this->teamA->id); +}); + +test('a failing clone step leaves no partial project behind', function () { + StandalonePostgresql::create([ + 'name' => 'pg-fails', + 'uuid' => new_public_id(), + 'postgres_password' => 'secret', + 'postgres_user' => 'postgres', + 'postgres_db' => 'postgres', + 'environment_id' => $this->environmentA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => $this->destinationA->getMorphClass(), + 'status' => 'exited', + ]); + StandalonePostgresql::created(fn () => throw new RuntimeException('Database clone failed.')); + $projectCount = Project::count(); + $environmentCount = Environment::count(); + + Livewire::test(CloneMe::class, [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]) + ->set('selectedDestination', $this->destinationA->uuid) + ->set('newName', 'zz-failing-clone') + ->call('clone', 'project') + ->assertDispatched('error'); + + expect(Project::count())->toBe($projectCount) + ->and(Environment::count())->toBe($environmentCount) + ->and(Application::count())->toBe(1); +}); diff --git a/tests/Feature/Authorization/EnvironmentVariableValueHidingTest.php b/tests/Feature/Authorization/EnvironmentVariableValueHidingTest.php index ec78d0f30d..54943eb9e5 100644 --- a/tests/Feature/Authorization/EnvironmentVariableValueHidingTest.php +++ b/tests/Feature/Authorization/EnvironmentVariableValueHidingTest.php @@ -288,3 +288,59 @@ test('API shows env values for admin with read:sensitive token', function () { expect($unlocked)->not->toBeNull(); expect($unlocked)->toHaveKey('value'); }); + +// --- Session team differs from the resource team --- + +test('member dev view hides values after switching to a team they own', function () { + $ownTeam = Team::factory()->create(); + $this->member->teams()->attach($ownTeam, ['role' => 'owner']); + $this->actingAs($this->member); + session(['currentTeam' => $ownTeam]); + + $component = Livewire::test(EnvironmentVariableAll::class, [ + 'resource' => $this->application, + ])->call('switch'); + + expect($component->get('variables'))->not->toContain('secret-unlocked-value') + ->and($component->get('variables'))->toContain('UNLOCKED_VAR=(Hidden'); + $component->assertDontSee('secret-unlocked-value'); +}); + +test('admin dev view shows values while the session is on a team where they are a member', function () { + $otherTeam = Team::factory()->create(); + $this->admin->teams()->attach($otherTeam, ['role' => 'member']); + $this->actingAs($this->admin); + session(['currentTeam' => $otherTeam]); + + $component = Livewire::test(EnvironmentVariableAll::class, [ + 'resource' => $this->application, + ])->call('switch'); + + expect($component->get('variables'))->toContain('UNLOCKED_VAR=secret-unlocked-value'); +}); + +test('user outside the resource team cannot open the dev view', function () { + $outsider = User::factory()->create(); + $outsiderTeam = Team::factory()->create(); + $outsider->teams()->attach($outsiderTeam, ['role' => 'owner']); + $this->actingAs($outsider); + session(['currentTeam' => $outsiderTeam]); + + Livewire::test(EnvironmentVariableAll::class, [ + 'resource' => $this->application, + ])->call('switch')->assertForbidden(); +}); + +test('member keeps isValueHidden after switching to a team they own', function () { + $ownTeam = Team::factory()->create(); + $this->member->teams()->attach($ownTeam, ['role' => 'owner']); + $this->actingAs($this->member); + session(['currentTeam' => $ownTeam]); + + $component = Livewire::test(EnvironmentVariableShow::class, [ + 'env' => $this->unlockedEnv, + 'type' => 'application', + ]); + + expect($component->get('isValueHidden'))->toBeTrue(); +}); diff --git a/tests/Feature/Authorization/ProjectEnvironmentAuthorizationTest.php b/tests/Feature/Authorization/ProjectEnvironmentAuthorizationTest.php index d1e01f0e67..d34df9a132 100644 --- a/tests/Feature/Authorization/ProjectEnvironmentAuthorizationTest.php +++ b/tests/Feature/Authorization/ProjectEnvironmentAuthorizationTest.php @@ -53,3 +53,21 @@ test('member cannot create environment in project', function () { expect(Environment::where('name', 'staging')->exists())->toBeFalse(); }); + +test('member cannot create environment in project after switching to an owned team', function () { + $ownTeam = Team::factory()->create(); + $this->member->teams()->attach($ownTeam, ['role' => 'owner']); + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(Show::class, ['project_uuid' => $this->project->uuid]); + + $this->member->load('teams'); + session(['currentTeam' => $ownTeam]); + + $component->set('name', 'staging') + ->call('submit') + ->assertDispatched('error'); + + expect(Environment::where('name', 'staging')->exists())->toBeFalse(); +}); diff --git a/tests/Feature/Authorization/ResourceOperationsCrossTenantTest.php b/tests/Feature/Authorization/ResourceOperationsCrossTenantTest.php index 439d04ef9d..8bb086a972 100644 --- a/tests/Feature/Authorization/ResourceOperationsCrossTenantTest.php +++ b/tests/Feature/Authorization/ResourceOperationsCrossTenantTest.php @@ -5,11 +5,16 @@ use App\Models\Application; use App\Models\Environment; use App\Models\InstanceSettings; use App\Models\Project; +use App\Models\ScheduledDatabaseBackup; +use App\Models\ScheduledTask; use App\Models\Server; +use App\Models\Service; use App\Models\StandaloneDocker; +use App\Models\StandalonePostgresql; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Livewire\Features\SupportTesting\Testable; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -114,3 +119,143 @@ test('StandaloneDockerPolicy denies update for cross-team user', function () { test('StandaloneDockerPolicy allows update for same-team user', function () { expect($this->userA->can('update', $this->destinationA))->toBeTrue(); }); + +describe('after the session team changes', function () { + beforeEach(function () { + Server::flushIdentityMap(); + $this->userA->teams()->attach($this->teamB, ['role' => 'owner']); + $this->userA->unsetRelation('teams'); + $this->secondEnvironmentA = Environment::factory()->create(['project_id' => $this->projectA->id]); + }); + + function resourceOperationsAfterTeamSwitch(object $test, $resource): Testable + { + $component = Livewire::test(ResourceOperations::class, ['resource' => $resource]); + session(['currentTeam' => $test->teamB]); + + return $component; + } + + test('lists the projects and servers of the resource team', function () { + session(['currentTeam' => $this->teamB]); + + $component = Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA]); + + expect($component->get('projects')->pluck('id')->all())->toBe([$this->projectA->id]) + ->and($component->get('servers')->pluck('id')->all())->toBe([$this->serverA->id]); + }); + + test('cloneTo rejects a destination of the session team', function () { + resourceOperationsAfterTeamSwitch($this, $this->applicationA) + ->call('cloneTo', $this->destinationB->uuid) + ->assertHasErrors('destination_id'); + + expect(Application::count())->toBe(1); + }); + + test('cloneTo rejects an environment and destination of another team', function () { + resourceOperationsAfterTeamSwitch($this, $this->applicationA) + ->call('cloneTo', $this->destinationB->uuid, $this->environmentB->id) + ->assertHasErrors('environment_id'); + + expect(Application::count())->toBe(1); + }); + + test('moveTo rejects an environment of the session team', function () { + resourceOperationsAfterTeamSwitch($this, $this->applicationA) + ->call('moveTo', $this->environmentB->id); + + expect($this->applicationA->fresh()->environment_id)->toBe($this->environmentA->id); + }); + + test('migrateTo rejects a destination of the session team', function () { + resourceOperationsAfterTeamSwitch($this, $this->applicationA) + ->call('migrateTo', $this->destinationB->uuid) + ->assertHasErrors(['destination_id' => 'Destination not found.']); + }); + + test('cloneTo and moveTo still work inside the resource team', function () { + resourceOperationsAfterTeamSwitch($this, $this->applicationA) + ->call('cloneTo', $this->destinationA->uuid, $this->secondEnvironmentA->id) + ->assertHasNoErrors() + ->assertRedirect(); + + $clone = Application::whereKeyNot($this->applicationA->id)->sole(); + expect($clone->environment_id)->toBe($this->secondEnvironmentA->id) + ->and($clone->destination_id)->toBe($this->destinationA->id); + + resourceOperationsAfterTeamSwitch($this, $this->applicationA) + ->call('moveTo', $this->secondEnvironmentA->id) + ->assertRedirect(); + + expect($this->applicationA->fresh()->environment_id)->toBe($this->secondEnvironmentA->id); + }); + + test('cloned application tasks belong to the resource team', function () { + ScheduledTask::factory()->create(['application_id' => $this->applicationA->id, 'team_id' => $this->teamA->id]); + + resourceOperationsAfterTeamSwitch($this, $this->applicationA) + ->call('cloneTo', $this->destinationA->uuid) + ->assertRedirect(); + + $clone = Application::whereKeyNot($this->applicationA->id)->sole(); + expect($clone->scheduled_tasks()->sole()->team_id)->toBe($this->teamA->id); + }); + + test('cloned database backups belong to the resource team', function () { + $database = StandalonePostgresql::create([ + 'name' => 'pg', 'uuid' => new_public_id(), 'postgres_password' => 'secret', 'postgres_user' => 'postgres', + 'postgres_db' => 'postgres', 'environment_id' => $this->environmentA->id, 'status' => 'exited', + 'destination_id' => $this->destinationA->id, 'destination_type' => $this->destinationA->getMorphClass(), + ]); + ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', 'database_type' => $database->getMorphClass(), 'database_id' => $database->id, + 'team_id' => $this->teamA->id, + ]); + + resourceOperationsAfterTeamSwitch($this, $database) + ->call('cloneTo', $this->destinationA->uuid) + ->assertRedirect(); + + $clone = StandalonePostgresql::whereKeyNot($database->id)->sole(); + expect($clone->scheduledBackups()->sole()->team_id)->toBe($this->teamA->id); + }); + + test('cloned service tasks belong to the resource team', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environmentA->id, 'server_id' => $this->serverA->id, + 'destination_id' => $this->destinationA->id, 'destination_type' => $this->destinationA->getMorphClass(), + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", + ]); + ScheduledTask::factory()->create(['service_id' => $service->id, 'team_id' => $this->teamA->id]); + + resourceOperationsAfterTeamSwitch($this, $service) + ->call('cloneTo', $this->destinationA->uuid) + ->assertRedirect(); + + $clone = Service::whereKeyNot($service->id)->sole(); + expect($clone->scheduled_tasks()->sole()->team_id)->toBe($this->teamA->id); + }); + + test('a member of the resource team cannot clone or move after switching to an own team', function () { + $member = User::factory()->create(); + $member->teams()->attach($this->teamA, ['role' => 'member']); + $member->teams()->attach($this->teamB, ['role' => 'owner']); + $this->actingAs($member); + + resourceOperationsAfterTeamSwitch($this, $this->applicationA) + ->call('cloneTo', $this->destinationA->uuid) + ->call('moveTo', $this->secondEnvironmentA->id); + + expect(Application::count())->toBe(1) + ->and($this->applicationA->fresh()->environment_id)->toBe($this->environmentA->id); + }); + + test('clone_application rejects a destination outside the environment team', function () { + session(['currentTeam' => $this->teamB]); + + expect(fn () => clone_application($this->applicationA, $this->destinationB, ['environment_id' => $this->environmentA->id])) + ->toThrow(RuntimeException::class, 'same team'); + expect(Application::count())->toBe(1); + }); +}); diff --git a/tests/Feature/BackupEditValidationTest.php b/tests/Feature/BackupEditValidationTest.php index ab6d442483..66643ffc46 100644 --- a/tests/Feature/BackupEditValidationTest.php +++ b/tests/Feature/BackupEditValidationTest.php @@ -434,11 +434,29 @@ it('defaults to the first available storage when multiple storages are available expect($backup->s3_storage_id)->toBe($firstS3->id); }); -it('accepts the S3 storage scope passed to the component', function () { +it('accepts a root team S3 storage for the instance database backup', function () { + $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); + $this->user->teams()->syncWithoutDetaching([$rootTeam->id => ['role' => 'owner']]); + session(['currentTeam' => $this->team]); $s3 = createS3StorageForBackupEditValidationTest(0); - $backup = createBackupForEditValidationTest($this->team, [ + $server = Server::factory()->create(['id' => 0, 'team_id' => 0, 'ip' => '127.0.0.1']); + $database = new StandalonePostgresql; + $database->forceFill([ + 'id' => 0, + 'name' => 'coolify-db', + 'postgres_user' => 'coolify', + 'postgres_password' => 'password', + 'postgres_db' => 'coolify', + 'destination_type' => StandaloneDocker::class, + 'destination_id' => StandaloneDocker::where('server_id', $server->id)->firstOrFail()->id, + 'environment_id' => null, + ])->save(); + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', 'save_s3' => false, - 's3_storage_id' => null, + 'database_type' => StandalonePostgresql::class, + 'database_id' => 0, + 'team_id' => 0, ]); Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => collect([$s3])]) diff --git a/tests/Feature/BackupS3StorageListResourceTeamTest.php b/tests/Feature/BackupS3StorageListResourceTeamTest.php new file mode 100644 index 0000000000..90f87493b4 --- /dev/null +++ b/tests/Feature/BackupS3StorageListResourceTeamTest.php @@ -0,0 +1,130 @@ + 'file']); + Server::flushIdentityMap(); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); + + $this->teamA = Team::factory()->create(); + $this->teamB = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->teamA, ['role' => 'member']); + $this->user->teams()->attach($this->teamB, ['role' => 'owner']); + + $server = Server::factory()->create(['team_id' => $this->teamA->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->service = Service::factory()->create([ + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + $this->serviceDatabase = ServiceDatabase::create([ + 'service_id' => $this->service->id, + 'name' => 'zz-s3-list-db', + 'image' => 'postgres:16-alpine', + 'custom_type' => 'postgresql', + ]); + $this->serviceApplication = ServiceApplication::create([ + 'service_id' => $this->service->id, + 'name' => 'zz-s3-list-app', + 'image' => 'nginx:alpine', + ]); + $this->postgres = StandalonePostgresql::create([ + 'name' => 'zz-s3-list-pg', + 'image' => 'postgres:16-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $this->environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $this->ownS3 = createS3ForResourceTeamListTest($this->teamA, 'team-a-s3'); + createS3ForResourceTeamListTest($this->teamA, 'team-a-unusable-s3', false); + createS3ForResourceTeamListTest($this->teamB, 'team-b-s3'); + + $this->actingAs($this->user); + session(['currentTeam' => $this->teamA]); +}); + +function createS3ForResourceTeamListTest(Team $team, string $name, bool $isUsable = true): S3Storage +{ + return S3Storage::create([ + 'name' => $name, + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => $name.'-bucket', + 'endpoint' => 'https://s3.example.com', + 'is_usable' => $isUsable, + 'team_id' => $team->id, + ]); +} + +function switchSessionTeamForResourceTeamListTest(Team $team): void +{ + session(['currentTeam' => $team]); + Cache::flush(); +} + +test('embedded service database offers only S3 storages of the service team', function (string $sessionTeam) { + switchSessionTeamForResourceTeamListTest($this->{$sessionTeam}); + + Livewire::test(ServiceIndex::class, ['serviceApplication' => $this->serviceDatabase->fresh(), 'embedded' => true]) + ->assertSet('s3s', fn ($s3s) => $s3s->pluck('id')->all() === [$this->ownS3->id]); +})->with(['same session team' => 'teamA', 'after a team switch' => 'teamB']); + +test('embedded service application offers only S3 storages of the service team after a team switch', function () { + switchSessionTeamForResourceTeamListTest($this->teamB); + + Livewire::test(ServiceIndex::class, ['serviceApplication' => $this->serviceApplication->fresh(), 'embedded' => true]) + ->assertSet('s3s', fn ($s3s) => $s3s->pluck('id')->all() === [$this->ownS3->id]); +}); + +test('database backup page offers only usable S3 storages of the database team', function () { + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => '0 0 * * *', + 'database_id' => $this->postgres->id, + 'database_type' => $this->postgres->getMorphClass(), + ]); + $url = route('project.database.backup.s3', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'database_uuid' => $this->postgres->uuid, + 'backup_uuid' => $backup->uuid, + ]); + + $this->get($url) + ->assertOk() + ->assertSee('team-a-s3') + ->assertDontSee('team-a-unusable-s3') + ->assertDontSee('team-b-s3'); + + switchSessionTeamForResourceTeamListTest($this->teamB); + $this->get($url)->assertRedirect(route('dashboard')); +}); diff --git a/tests/Feature/CloudProviderLinkDropdownTest.php b/tests/Feature/CloudProviderLinkDropdownTest.php index 59cf37f97b..1f14482cf6 100644 --- a/tests/Feature/CloudProviderLinkDropdownTest.php +++ b/tests/Feature/CloudProviderLinkDropdownTest.php @@ -190,3 +190,20 @@ it('shows Vultr search errors in the modal', function () { ->assertSee('Failed to search Vultr instances:') ->assertSee('invalid token'); }); + +it('reloads only cloud provider tokens of the server team after the session team changes', function () { + $otherTeam = Team::factory()->create(); + $otherTeam->members()->attach($this->user->id, ['role' => 'owner']); + $sameTeamToken = CloudProviderToken::factory()->create(['team_id' => $this->team->id, 'provider' => 'hetzner']); + CloudProviderToken::factory()->create(['team_id' => $otherTeam->id, 'provider' => 'hetzner']); + CloudProviderToken::factory()->create(['team_id' => $otherTeam->id, 'provider' => 'vultr']); + CloudProviderToken::factory()->create(['team_id' => $otherTeam->id, 'provider' => 'digitalocean']); + + $component = Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]); + session(['currentTeam' => $otherTeam]); + $component->call('handleServerValidated'); + + expect($component->get('availableHetznerTokens')->pluck('id')->all())->toBe([$sameTeamToken->id]) + ->and($component->get('availableVultrTokens'))->toBeEmpty() + ->and($component->get('availableDigitalOceanTokens'))->toBeEmpty(); +}); diff --git a/tests/Feature/CloudProviderTokenShowTest.php b/tests/Feature/CloudProviderTokenShowTest.php index 8aa200ded0..fad6be2287 100644 --- a/tests/Feature/CloudProviderTokenShowTest.php +++ b/tests/Feature/CloudProviderTokenShowTest.php @@ -1,12 +1,14 @@ assertSee('Production Hetzner') ->assertSee('Used for production servers in the EU region.'); }); + +describe('after the user switches the session team', function () { + beforeEach(function () { + $this->withoutDefer(); + Http::fake(['*' => Http::response(['servers' => []])]); + + $this->otherTeam = Team::factory()->create(); + $this->otherTeam->members()->attach($this->user->id, ['role' => 'owner']); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->sameTeamToken = CloudProviderToken::factory()->create(['team_id' => $this->team->id, 'provider' => 'hetzner']); + $this->otherTeamToken = CloudProviderToken::factory()->create(['team_id' => $this->otherTeam->id, 'provider' => 'hetzner']); + + $this->component = Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]); + session(['currentTeam' => $this->otherTeam]); + }); + + test('lists only tokens of the server team', function () { + $tokenIds = $this->component->call('loadTokens')->get('cloudProviderTokens')->pluck('id')->all(); + + expect($tokenIds)->toBe([$this->sameTeamToken->id]); + }); + + test('rejects a token of the session team', function () { + $this->component->call('setCloudProviderToken', $this->otherTeamToken->id) + ->assertDispatched('error', 'You are not allowed to use this token.'); + + expect($this->server->fresh()->cloud_provider_token_id)->toBeNull(); + }); + + test('accepts a token of the server team and audits it for the server team', function () { + $this->component->call('setCloudProviderToken', $this->sameTeamToken->id) + ->assertDispatched('success'); + + expect($this->server->fresh()->cloud_provider_token_id)->toBe($this->sameTeamToken->id) + ->and(AuditEvent::query()->where('event', 'ui.server.cloud_token_assigned')->sole()->team_id)->toBe($this->team->id); + }); +}); diff --git a/tests/Feature/CrossTeamDestinationAttachTest.php b/tests/Feature/CrossTeamDestinationAttachTest.php index cccfa2b540..891d87e494 100644 --- a/tests/Feature/CrossTeamDestinationAttachTest.php +++ b/tests/Feature/CrossTeamDestinationAttachTest.php @@ -3,6 +3,7 @@ use App\Actions\Docker\GetContainersStatus; use App\Livewire\Project\Shared\Destination; use App\Models\Application; +use App\Models\ApplicationDeploymentQueue; use App\Models\Environment; use App\Models\InstanceSettings; use App\Models\Project; @@ -256,3 +257,64 @@ describe('Destination::removeServer', function () { ->exists())->toBeFalse(); }); }); + +describe('Destination after the user switches the session team', function () { + beforeEach(function () { + Server::flushIdentityMap(); + $this->userA->teams()->attach($this->teamB, ['role' => 'owner']); + foreach ([$this->serverA2, $this->serverB] as $server) { + $server->settings->update(['is_reachable' => true, 'is_usable' => true]); + } + + $this->component = Livewire::test(Destination::class, ['resource' => $this->applicationA]); + session(['currentTeam' => $this->teamB]); + }); + + test('lists only networks of the application team', function () { + $networkIds = $this->component->call('loadData')->get('networks')->pluck('id'); + + expect($networkIds)->toContain($this->destinationA2->id) + ->not->toContain($this->destinationB->id); + }); + + test('cannot attach a server of the session team to the application', function () { + try { + $this->component->call('addServer', $this->destinationB->id, $this->serverB->id); + } catch (Throwable $e) { + } + + expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0); + }); + + test('can attach a server of the application team', function () { + $this->component->call('addServer', $this->destinationA2->id, $this->serverA2->id); + + expect($this->applicationA->fresh()->additional_networks->pluck('id')->all())->toBe([$this->destinationA2->id]); + }); + + test('cannot promote a network of the session team', function () { + try { + $this->component->call('promote', $this->destinationB->id, $this->serverB->id); + } catch (Throwable $e) { + } + + expect($this->applicationA->fresh()->destination_id)->toBe($this->destinationA->id); + }); + + test('can promote a network of the application team', function () { + $this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]); + + $this->component->call('promote', $this->destinationA2->id, $this->serverA2->id); + + expect($this->applicationA->fresh()->destination_id)->toBe($this->destinationA2->id); + }); + + test('cannot redeploy to a server of the session team', function () { + try { + $this->component->call('redeploy', $this->destinationB->id, $this->serverB->id); + } catch (Throwable $e) { + } + + expect(ApplicationDeploymentQueue::query()->where('server_id', $this->serverB->id)->exists())->toBeFalse(); + }); +}); diff --git a/tests/Feature/DeploymentCancellationCleanupTest.php b/tests/Feature/DeploymentCancellationCleanupTest.php index 065b0b3057..6912ae604a 100644 --- a/tests/Feature/DeploymentCancellationCleanupTest.php +++ b/tests/Feature/DeploymentCancellationCleanupTest.php @@ -154,3 +154,28 @@ test('cancelling does not touch servers of another team', function () { expect(cancelCleanupCommandsOn($this->remoteCommands, '203.0.113.30', 'docker'))->toBeEmpty() ->and($this->deployment->fresh()->status)->toBe(ApplicationDeploymentStatus::CANCELLED_BY_USER->value); }); + +test('cancelling after switching to another team still cleans up and starts the next queued deployment', function () { + $otherTeam = Team::factory()->create(); + $otherTeam->members()->attach($this->user->id, ['role' => 'owner']); + $nextApplication = Application::factory()->create([ + 'environment_id' => $this->application->environment_id, + 'destination_id' => $this->application->destination_id, + 'destination_type' => $this->application->destination_type, + ]); + $queued = ApplicationDeploymentQueue::create([ + 'application_id' => $nextApplication->id, + 'deployment_uuid' => 'cancel-cleanup-next', + 'server_id' => $this->server->id, + 'destination_id' => $this->application->destination_id, + 'status' => ApplicationDeploymentStatus::QUEUED->value, + ]); + $this->actingAs($this->user); + + $component = Livewire::test(DeploymentNavbar::class, ['application_deployment_queue' => $this->deployment]); + session(['currentTeam' => $otherTeam]); + $component->call('cancel'); + + assertCancelCleanupRanCorrectly($this->remoteCommands, $this->deployment); + expect($queued->fresh()->status)->toBe(ApplicationDeploymentStatus::IN_PROGRESS->value); +}); diff --git a/tests/Feature/DeploymentDebugLogVisibilityTest.php b/tests/Feature/DeploymentDebugLogVisibilityTest.php new file mode 100644 index 0000000000..41384552ab --- /dev/null +++ b/tests/Feature/DeploymentDebugLogVisibilityTest.php @@ -0,0 +1,88 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->ownTeam = Team::factory()->create(); + $this->member = User::factory()->create(); + $this->team->members()->attach($this->member->id, ['role' => 'member']); + $this->ownTeam->members()->attach($this->member->id, ['role' => 'owner']); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $this->application->settings->update(['is_debug_enabled' => true]); + + $this->deployment = ApplicationDeploymentQueue::create([ + 'application_id' => $this->application->id, + 'deployment_uuid' => 'debug-visibility-uuid', + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'status' => ApplicationDeploymentStatus::FINISHED->value, + ]); + $this->deployment->addLogEntry('visible-line'); + $this->deployment->addLogEntry('hidden-debug-line', hidden: true); +}); + +afterEach(function () { + Server::flushIdentityMap(); +}); + +test('deployment log lines hide debug output from a member whose session is on a team they own', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->ownTeam]); + + $lines = decode_remote_command_output($this->deployment->fresh())->pluck('line')->implode("\n"); + + expect($lines)->toContain('visible-line') + ->not->toContain('hidden-debug-line'); +}); + +test('deployment log lines show debug output to a resource team admin whose session is on another team', function () { + $this->team->members()->updateExistingPivot($this->member->id, ['role' => 'admin']); + $this->ownTeam->members()->updateExistingPivot($this->member->id, ['role' => 'member']); + $this->actingAs($this->member); + session(['currentTeam' => $this->ownTeam]); + + $lines = decode_remote_command_output($this->deployment->fresh())->pluck('line')->implode("\n"); + + expect($lines)->toContain('hidden-debug-line'); +}); + +test('copied deployment logs hide debug output from a member after switching to a team they own', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(DeploymentNavbar::class, ['application_deployment_queue' => $this->deployment]); + + session(['currentTeam' => $this->ownTeam]); + + $markdown = $component->instance()->copyLogsToClipboard(); + + expect($markdown)->toContain('visible-line') + ->not->toContain('hidden-debug-line'); +}); diff --git a/tests/Feature/EnvironmentVariable/AutocompleteResourceTeamTest.php b/tests/Feature/EnvironmentVariable/AutocompleteResourceTeamTest.php new file mode 100644 index 0000000000..57de2cba32 --- /dev/null +++ b/tests/Feature/EnvironmentVariable/AutocompleteResourceTeamTest.php @@ -0,0 +1,76 @@ + 0]); + + $this->resourceTeam = Team::factory()->create(); + $this->sessionTeam = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->resourceTeam, ['role' => 'member']); + $this->user->teams()->attach($this->sessionTeam, ['role' => 'owner']); + + $this->project = Project::factory()->create(['team_id' => $this->resourceTeam->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->application = Application::factory()->create(['environment_id' => $this->environment->id]); + + SharedEnvironmentVariable::create(['key' => 'RESOURCE_TEAM_VAR', 'value' => 'a', 'type' => 'team', 'team_id' => $this->resourceTeam->id]); + SharedEnvironmentVariable::create(['key' => 'SESSION_TEAM_VAR', 'value' => 'b', 'type' => 'team', 'team_id' => $this->sessionTeam->id]); + SharedEnvironmentVariable::create(['key' => 'RESOURCE_PROJECT_VAR', 'value' => 'c', 'type' => 'project', 'team_id' => $this->resourceTeam->id, 'project_id' => $this->project->id]); + + $this->parameters = [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'application_uuid' => $this->application->uuid, + ]; + + $this->actingAs($this->user); + session(['currentTeam' => $this->sessionTeam]); +}); + +test('add suggests shared variables of the resource team when the session team differs', function () { + $component = Livewire::test(Add::class, ['resource' => $this->application]) + ->set('parameters', $this->parameters); + + $suggestions = $component->instance()->availableSharedVariables(); + + expect($suggestions['team'])->toBe(['RESOURCE_TEAM_VAR']) + ->and($suggestions['project'])->toBe(['RESOURCE_PROJECT_VAR']); +}); + +test('show suggests shared variables of the resource team when the session team differs', function () { + $env = EnvironmentVariable::create([ + 'key' => 'APP_VAR', + 'value' => 'x', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $component = Livewire::test(Show::class, ['env' => $env, 'type' => 'application']) + ->set('parameters', $this->parameters); + + $suggestions = $component->instance()->availableSharedVariables(); + + expect($suggestions['team'])->toBe(['RESOURCE_TEAM_VAR']) + ->and($suggestions['project'])->toBe(['RESOURCE_PROJECT_VAR']); +}); + +test('add without a resource keeps suggesting the session team variables', function () { + $component = Livewire::test(Add::class, ['shared' => true]); + + expect($component->instance()->availableSharedVariables()['team'])->toBe(['SESSION_TEAM_VAR']); +}); diff --git a/tests/Feature/Livewire/ConfigurationCheckerTest.php b/tests/Feature/Livewire/ConfigurationCheckerTest.php index 4adf86fff0..d721aaf25e 100644 --- a/tests/Feature/Livewire/ConfigurationCheckerTest.php +++ b/tests/Feature/Livewire/ConfigurationCheckerTest.php @@ -344,3 +344,61 @@ it('keeps compose changes visible to an owner in the change list', function () { expect(json_encode($component->get('configurationDiff')))->toContain('new-compose-value'); }); + +it('redacts environment values for a team member whose session is on a team they own', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $ownTeam = Team::factory()->create(); + $ownTeam->members()->attach($member->id, ['role' => 'owner']); + $this->actingAs($member); + session(['currentTeam' => $ownTeam]); + + $application = configurationCheckerApplication($this->environment); + markConfigurationCheckerApplicationDeployed($application); + EnvironmentVariable::create([ + 'key' => 'API_TOKEN', + 'value' => 'new-secret', + 'is_buildtime' => false, + 'is_runtime' => true, + 'is_preview' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + + $component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]) + ->assertDontSee('new-secret'); + + $envChange = collect(data_get($component->get('configurationDiff'), 'changes', [])) + ->first(fn (array $change): bool => str_contains((string) data_get($change, 'key'), 'API_TOKEN') + || str_contains((string) data_get($change, 'label'), 'API_TOKEN')); + + expect($envChange)->not->toBeNull() + ->and(data_get($envChange, 'new_display_value'))->toBe('••••••••') + ->and(data_get($envChange, 'new_full_value'))->toBeNull(); +}); + +it('shows environment values to a resource team owner whose session is on a team where they are a member', function () { + $otherTeam = Team::factory()->create(); + $otherTeam->members()->attach($this->user->id, ['role' => 'member']); + session(['currentTeam' => $otherTeam]); + + $application = configurationCheckerApplication($this->environment); + markConfigurationCheckerApplicationDeployed($application); + EnvironmentVariable::create([ + 'key' => 'API_TOKEN', + 'value' => 'new-secret', + 'is_buildtime' => false, + 'is_runtime' => true, + 'is_preview' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + + $component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]); + + $envChange = collect(data_get($component->get('configurationDiff'), 'changes', [])) + ->first(fn (array $change): bool => str_contains((string) data_get($change, 'key'), 'API_TOKEN') + || str_contains((string) data_get($change, 'label'), 'API_TOKEN')); + + expect(data_get($envChange, 'new_display_value'))->not->toBe('••••••••'); +}); diff --git a/tests/Feature/ManagedDnsRecordEditReleaseTest.php b/tests/Feature/ManagedDnsRecordEditReleaseTest.php index 7beff03e04..72a8b8000d 100644 --- a/tests/Feature/ManagedDnsRecordEditReleaseTest.php +++ b/tests/Feature/ManagedDnsRecordEditReleaseTest.php @@ -6,7 +6,10 @@ use App\Jobs\ConfigureDnsRecordJob; use App\Jobs\ReleaseManagedDnsRecordsJob; use App\Jobs\ReleaseRemovedDnsHostnamesJob; use App\Livewire\Project\Application\Domains; +use App\Livewire\Project\Application\General; +use App\Livewire\Project\Application\PreviewDomains; use App\Livewire\Project\Service\Domains as ServiceDomains; +use App\Livewire\Project\Service\Index as ServiceIndex; use App\Models\Application; use App\Models\ApplicationPreview; use App\Models\DnsProviderZone; @@ -614,3 +617,64 @@ describe('hostname lock', function () { $heldLock->release(); }); }); + +describe('domain edits after the session team changes', function () { + beforeEach(function () { + Queue::fake([CheckDomainDnsJob::class, ReleaseRemovedDnsHostnamesJob::class]); + fakeCloudflareDns(); + $this->sessionTeam = Team::factory()->create(); + $this->sessionTeam->members()->attach($this->user->id, ['role' => 'owner']); + $this->user->unsetRelation('teams'); + }); + + function assertReleaseQueuedForTeam(int $teamId, string $resourceType, int $resourceId): void + { + Queue::assertPushed(ReleaseRemovedDnsHostnamesJob::class, fn (ReleaseRemovedDnsHostnamesJob $job): bool => $job->teamId === $teamId + && $job->resourceType === $resourceType + && $job->resourceId === $resourceId + && $job->hostnames === ['app.example.com']); + } + + test('the application general form queues the release for the resource team', function () { + $application = createDnsTestApplication($this, 'https://app.example.com'); + app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application); + + $component = Livewire::test(General::class, ['application' => $application]); + session(['currentTeam' => $this->sessionTeam]); + $component->set('fqdn', 'https://new.example.com')->call('submit')->assertHasNoErrors(); + + expect($application->fresh()->fqdn)->toBe('https://new.example.com'); + assertReleaseQueuedForTeam($this->team->id, $application->getMorphClass(), $application->id); + }); + + test('the preview domain form queues the release for the resource team', function () { + $application = createDnsTestApplication($this, 'https://main.example.com'); + $preview = ApplicationPreview::create([ + 'application_id' => $application->id, 'pull_request_id' => 7, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/7', 'fqdn' => 'https://app.example.com', + ]); + app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $preview); + + $component = Livewire::test(PreviewDomains::class, ['preview' => $preview]); + session(['currentTeam' => $this->sessionTeam]); + $component->call('startEdit', 0) + ->set('editingDomainParts.host', 'new.example.com') + ->call('updateDomain') + ->assertHasNoErrors(); + + expect($preview->fresh()->fqdn)->toContain('new.example.com'); + assertReleaseQueuedForTeam($this->team->id, $preview->getMorphClass(), $preview->id); + }); + + test('the service application form queues the release for the resource team', function () { + $serviceApplication = editReleaseService($this, 'https://app.example.com'); + app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $serviceApplication); + + $component = Livewire::test(ServiceIndex::class, ['serviceApplication' => $serviceApplication->fresh()]); + session(['currentTeam' => $this->sessionTeam]); + $component->set('fqdn', 'https://new.example.com')->call('submitApplication')->assertHasNoErrors(); + + expect($serviceApplication->fresh()->fqdn)->toBe('https://new.example.com'); + assertReleaseQueuedForTeam($this->team->id, $serviceApplication->getMorphClass(), $serviceApplication->id); + }); +}); diff --git a/tests/Feature/PersistentStorageVolumesLayoutTest.php b/tests/Feature/PersistentStorageVolumesLayoutTest.php index 500a602265..cdbdbab546 100644 --- a/tests/Feature/PersistentStorageVolumesLayoutTest.php +++ b/tests/Feature/PersistentStorageVolumesLayoutTest.php @@ -191,7 +191,7 @@ it('does not show a source path or removal action for a named volume', function Livewire::test(All::class, ['resource' => $application]) ->assertDontSee('Directory mount') - ->assertSee('Volume mount') + ->assertDontSee('Volume mount') ->assertDontSee('Remove Source Path'); }); diff --git a/tests/Feature/ResourceTeamAfterTeamSwitchTest.php b/tests/Feature/ResourceTeamAfterTeamSwitchTest.php new file mode 100644 index 0000000000..6a434a035a --- /dev/null +++ b/tests/Feature/ResourceTeamAfterTeamSwitchTest.php @@ -0,0 +1,198 @@ + 'file']); + Server::flushIdentityMap(); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['is_api_enabled' => true])); + + $this->teamA = Team::factory()->create(); + $this->teamB = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->teamA, ['role' => 'owner']); + $this->user->teams()->attach($this->teamB, ['role' => 'owner']); + + $server = Server::factory()->create(['team_id' => $this->teamA->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->teamA->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $this->service = Service::factory()->create([ + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'environment_id' => $environment->id, + ]); + $this->serviceDatabase = ServiceDatabase::create([ + 'service_id' => $this->service->id, + 'name' => 'team-switch-db', + 'image' => 'postgres:16-alpine', + 'custom_type' => 'postgresql', + ]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->teamA]); +}); + +function createS3ForTeamSwitchTest(Team $team, string $name): S3Storage +{ + return S3Storage::create([ + 'name' => $name, + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => $name.'-bucket', + 'endpoint' => 'https://s3.example.com', + 'is_usable' => true, + 'team_id' => $team->id, + ]); +} + +test('tags of the session team are not listed or attached after a team switch', function () { + $foreignTag = Tag::create(['name' => 'production', 'team_id' => $this->teamB->id]); + $component = Livewire::test(Tags::class, ['resource' => $this->application]); + session(['currentTeam' => $this->teamB]); + + $component->call('loadTags') + ->assertSet('tags', fn ($tags) => $tags->isEmpty()) + ->set('newTags', 'production') + ->call('submit'); + Livewire::test(Tags::class, ['resource' => $this->application]) + ->call('addTag', (string) $foreignTag->id) + ->assertNotFound(); + + $attached = $this->application->tags()->get(); + expect($attached)->toHaveCount(1) + ->and($attached->first()->id)->not->toBe($foreignTag->id) + ->and($attached->first()->team_id)->toBe($this->teamA->id); +}); + +test('deploy by tag skips resources of another team', function () { + Queue::fake(); + $tag = Tag::create(['name' => 'production', 'team_id' => $this->teamB->id]); + $this->application->tags()->attach($tag->id); + + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'team-b-deploy', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['deploy'], + 'team_id' => $this->teamB->id, + ]); + $this->app['auth']->forgetGuards(); + + $this->withHeaders(['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken]) + ->postJson('/api/v1/deploy', ['tag' => 'production']) + ->assertOk() + ->assertJsonMissingPath('details'); + + expect(ApplicationDeploymentQueue::count())->toBe(0); + Queue::assertNotPushed(ApplicationDeploymentJob::class); +}); + +test('scheduled task belongs to the resource team after a team switch', function () { + $component = Livewire::test(AddScheduledTask::class, [ + 'id' => (string) $this->application->id, + 'type' => 'application', + 'containerNames' => collect(), + ]); + session(['currentTeam' => $this->teamB]); + + $component->set('name', 'team-switch-task') + ->set('command', 'echo ok') + ->set('frequency', '0 0 * * *') + ->call('submit') + ->assertHasNoErrors(); + + expect(ScheduledTask::query()->sole()->team_id)->toBe($this->teamA->id); +}); + +test('service backup schedule offers only S3 storages of the service team', function () { + $ownS3 = createS3ForTeamSwitchTest($this->teamA, 'team-a-s3'); + createS3ForTeamSwitchTest($this->teamB, 'team-b-s3'); + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_id' => $ownS3->id, + 'database_id' => $this->serviceDatabase->id, + 'database_type' => $this->serviceDatabase->getMorphClass(), + ]); + $component = Livewire::test(ServiceVolumeBackupIndex::class, ['service' => $this->service]); + session(['currentTeam' => $this->teamB]); + + $component->call('openSchedule', $backup->uuid) + ->assertSet('s3s', fn ($s3s) => $s3s->pluck('id')->all() === [$ownS3->id]) + ->assertSee('S3 storage: team-a-s3 (bucket: team-a-s3-bucket)'); +}); + +test('backup edit keeps the S3 storage of the backup team when given a foreign list', function () { + $ownS3 = createS3ForTeamSwitchTest($this->teamA, 'team-a-s3'); + $foreignS3 = createS3ForTeamSwitchTest($this->teamB, 'team-b-s3'); + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => $ownS3->id, + 'database_id' => $this->serviceDatabase->id, + 'database_type' => $this->serviceDatabase->getMorphClass(), + ]); + + $component = Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->teamB->s3s]) + ->call('instantSave'); + expect($backup->fresh()->s3_storage_id)->toBe($ownS3->id) + ->and($backup->fresh()->save_s3)->toBeTruthy(); + + $component->set('s3StorageId', $foreignS3->id)->call('instantSave'); + expect($backup->fresh()->s3_storage_id)->toBe($ownS3->id); +}); + +test('service backup executions label the S3 storage of the service team after a team switch', function () { + $ownS3 = createS3ForTeamSwitchTest($this->teamA, 'team-a-s3'); + $schedule = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_id' => $ownS3->id, + 'database_id' => $this->serviceDatabase->id, + 'database_type' => $this->serviceDatabase->getMorphClass(), + ]); + ScheduledDatabaseBackupExecution::create(['scheduled_database_backup_id' => $schedule->id, 'status' => 'success']); + $component = Livewire::test(BackupExecutions::class, ['service' => $this->service]); + session(['currentTeam' => $this->teamB]); + + $component->call('$refresh') + ->assertViewHas('executions', fn ($executions) => str_contains($executions->first()['s3_tooltip'], 'team-a-s3')); +}); diff --git a/tests/Feature/ServerDestinationsPageTest.php b/tests/Feature/ServerDestinationsPageTest.php index bc89312d12..2d7fb19b7a 100644 --- a/tests/Feature/ServerDestinationsPageTest.php +++ b/tests/Feature/ServerDestinationsPageTest.php @@ -108,3 +108,44 @@ test('adding a discovered swarm destination stores the selected network name', f expect(SwarmDocker::where('server_id', $server->id)->where('network', 'customer-network')->exists())->toBeTrue(); }); + +/** + * Makes the user a member of a new team that owns a usable server, with the session on that team. + */ +function destinationsMemberTeamServer(User $user, bool $swarm = false): Server +{ + $memberTeam = Team::factory()->create(); + $user->teams()->attach($memberTeam, ['role' => 'member']); + $user->load('teams'); + session(['currentTeam' => $memberTeam]); + + $server = Server::factory()->create(['team_id' => $memberTeam->id]); + $server->settings()->update(['is_reachable' => true, 'is_usable' => true, 'is_swarm_manager' => $swarm]); + + return $server; +} + +test('member cannot add a destination on the owning team server after switching to an owned team', function (bool $swarm) { + $server = destinationsMemberTeamServer($this->user, $swarm); + + $component = Livewire::test(Destinations::class, ['server_uuid' => $server->uuid]); + session(['currentTeam' => $this->team]); + + $component->call('add', 'customer-network')->assertForbidden(); + + expect(StandaloneDocker::where('network', 'customer-network')->exists())->toBeFalse() + ->and(SwarmDocker::where('network', 'customer-network')->exists())->toBeFalse(); +})->with(['standalone' => false, 'swarm' => true]); + +test('member cannot create a docker network on the owning team server after switching to an owned team', function () { + $server = destinationsMemberTeamServer($this->user); + + $component = Livewire::test(Docker::class, ['server_id' => (string) $server->id]); + session(['currentTeam' => $this->team]); + + $component->set('network', 'customer-network') + ->call('submit') + ->assertDispatched('error'); + + expect(StandaloneDocker::where('network', 'customer-network')->exists())->toBeFalse(); +}); diff --git a/tests/Feature/ServerPrivateKeyDropdownTest.php b/tests/Feature/ServerPrivateKeyDropdownTest.php index d42e554137..8951491a97 100644 --- a/tests/Feature/ServerPrivateKeyDropdownTest.php +++ b/tests/Feature/ServerPrivateKeyDropdownTest.php @@ -8,6 +8,7 @@ use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Js; use Illuminate\Support\Once; @@ -86,3 +87,52 @@ test('server private key cards include a copy public key button', function () { ->assertSee('Alternative SSH Key') ->assertSeeHtml('copyPublicKeyToClipboard('.Js::from($alternativeKey->public_key)->toHtml().')'); }); + +describe('after the user switches the session team', function () { + beforeEach(function () { + Process::fake(); + Storage::fake('ssh-mux'); + + $this->otherTeam = Team::factory()->create(); + $this->otherTeam->members()->attach($this->user->id, ['role' => 'owner']); + $this->otherTeamKey = PrivateKey::factory()->create([ + 'team_id' => $this->otherTeam->id, + 'private_key' => PrivateKey::generateNewKeyPair('ed25519')['private_key'], + ]); + $this->sameTeamKey = PrivateKey::factory()->create([ + 'team_id' => $this->team->id, + 'private_key' => PrivateKey::generateNewKeyPair('ed25519')['private_key'], + ]); + + $this->component = Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]); + session(['currentTeam' => $this->otherTeam]); + Once::flush(); + }); + + test('lists only keys of the server team', function () { + $keyIds = $this->component->call('generatePrivateKey', 'ed25519')->get('privateKeys')->pluck('id'); + + expect($keyIds)->toContain($this->sameTeamKey->id) + ->not->toContain($this->otherTeamKey->id); + }); + + test('generates the key in the server team', function () { + $this->component->call('generatePrivateKey', 'ed25519'); + + expect(PrivateKey::query()->latest('id')->firstOrFail()->team_id)->toBe($this->team->id); + }); + + test('rejects a key of the session team', function () { + $this->component->call('setPrivateKey', $this->otherTeamKey->id) + ->assertDispatched('error', 'You are not allowed to use this private key.'); + + expect($this->server->fresh()->private_key_id)->toBe($this->currentPrivateKey->id); + }); + + test('accepts a key of the server team', function () { + $this->component->call('setPrivateKey', $this->sameTeamKey->id) + ->assertDispatched('success'); + + expect($this->server->fresh()->private_key_id)->toBe($this->sameTeamKey->id); + }); +}); diff --git a/tests/Feature/SharedVariableDevViewTest.php b/tests/Feature/SharedVariableDevViewTest.php index 9fca94c146..9640193f13 100644 --- a/tests/Feature/SharedVariableDevViewTest.php +++ b/tests/Feature/SharedVariableDevViewTest.php @@ -10,6 +10,7 @@ use App\Models\SharedEnvironmentVariable; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Cache; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -228,3 +229,61 @@ test('server shared variable values are only visible to admins', function () { expect(json_encode($component->snapshot).$component->html())->not->toContain('stored-server-variable-value'); }); + +/** + * Mounts as a member of $this->team, then switches the session to a team the user owns. + */ +function sharedVarsSwitchToOwnedTeam(User $user): Team +{ + $ownTeam = Team::factory()->create(); + $user->teams()->attach($ownTeam, ['role' => 'owner']); + $user->load('teams'); + session(['currentTeam' => $ownTeam]); + Cache::flush(); + + return $ownTeam; +} + +dataset('shared variable scopes', [ + 'project' => fn () => [App\Livewire\SharedVariables\Project\Show::class, ['project_uuid' => test()->project->uuid], ['type' => 'project', 'project_id' => test()->project->id]], + 'environment' => fn () => [Show::class, ['project_uuid' => test()->project->uuid, 'environment_uuid' => test()->environment->uuid], ['type' => 'environment', 'environment_id' => test()->environment->id, 'project_id' => test()->project->id]], + 'server' => function () { + $server = Server::factory()->create(['team_id' => test()->team->id]); + + return [App\Livewire\SharedVariables\Server\Show::class, ['server_uuid' => $server->uuid], ['type' => 'server', 'server_id' => $server->id]]; + }, + 'team' => fn () => [Index::class, [], ['type' => 'team']], +]); + +test('shared variable values stay hidden from a member of the owning team after switching to an owned team', function (array $scope) { + [$component, $params, $attributes] = $scope; + SharedEnvironmentVariable::create([...$attributes, 'key' => 'ZZ_SECRET', 'value' => 'owning-team-secret', 'team_id' => $this->team->id]); + $this->user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']); + $this->user->refresh(); + + $livewire = Livewire::test($component, $params) + ->assertSet('variables', 'ZZ_SECRET=(Hidden, only admins can view)'); + + $ownTeam = sharedVarsSwitchToOwnedTeam($this->user); + expect(currentTeam()->id)->toBe($ownTeam->id); + + $livewire->call('switch') + ->call('refreshEnvs') + ->assertSet('variables', 'ZZ_SECRET=(Hidden, only admins can view)'); + + expect(json_encode($livewire->snapshot).$livewire->html())->not->toContain('owning-team-secret'); +})->with('shared variable scopes'); + +test('shared variables created after switching teams belong to the owning team', function (array $scope) { + [$component, $params] = $scope; + + $livewire = Livewire::test($component, $params); + sharedVarsSwitchToOwnedTeam($this->user); + + $livewire->call('saveKey', ['key' => 'ZZ_SAVED', 'value' => 'one', 'is_multiline' => false, 'is_literal' => false]) + ->set('variables', "ZZ_SAVED=one\nZZ_BULK=two") + ->call('submit'); + + expect(SharedEnvironmentVariable::whereIn('key', ['ZZ_SAVED', 'ZZ_BULK'])->pluck('team_id', 'key')->sortKeys()->all()) + ->toBe(['ZZ_BULK' => $this->team->id, 'ZZ_SAVED' => $this->team->id]); +})->with('shared variable scopes'); diff --git a/tests/Feature/StartClickhouseVolumesTest.php b/tests/Feature/StartClickhouseVolumesTest.php new file mode 100644 index 0000000000..faa13a0944 --- /dev/null +++ b/tests/Feature/StartClickhouseVolumesTest.php @@ -0,0 +1,176 @@ + 0]); + config(['app.maintenance.store' => 'array', 'constants.ssh.mux_enabled' => false]); + Process::fake(); + Queue::fake(); + + $team = Team::factory()->create(); + $this->team = $team; + $this->owner = User::factory()->create(); + $this->member = User::factory()->create(); + $team->members()->attach($this->owner->id, ['role' => 'owner']); + $team->members()->attach($this->member->id, ['role' => 'member']); + $this->server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + ]); + $this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); +}); + +/** + * @return array + */ +function startedClickhouseCompose(): array +{ + $compose = null; + Process::assertRan(function ($process) use (&$compose) { + if (preg_match("/echo '([A-Za-z0-9+\/=]+)' \| base64 -d \| (?:sudo )?tee \S+\/docker-compose\.yml/", $process->command, $matches)) { + $compose = Yaml::parse(base64_decode($matches[1])); + } + + return true; + }); + + return $compose; +} + +it('keeps the data volume when a file mount is added', function () { + $database = create_standalone_clickhouse($this->environment->id, $this->destination); + LocalFileVolume::create([ + 'fs_path' => database_configuration_dir().'/'.$database->uuid.'/etc/clickhouse-server/users.d/extra.xml', + 'mount_path' => '/etc/clickhouse-server/users.d/extra.xml', + 'content' => '', + 'is_directory' => false, + 'resource_id' => $database->id, + 'resource_type' => $database->getMorphClass(), + ]); + + StartClickhouse::run($database->fresh(), activity()->log('Starting database')); + + expect(startedClickhouseCompose()['services'][$database->uuid]['volumes'])->toBe([ + "clickhouse-data-{$database->uuid}:/var/lib/clickhouse", + database_configuration_dir()."/{$database->uuid}/etc/clickhouse-server/users.d/extra.xml:/etc/clickhouse-server/users.d/extra.xml", + ]); +}); + +/** + * Fake the remote `docker inspect` so the container's data directory is mounted from $volumeName. + */ +function fakeClickhouseDataMount(string $volumeName): void +{ + Process::fake(fn ($process) => Process::result( + output: str_contains($process->command, 'docker inspect') ? "volume {$volumeName}" : 'OK', + )); +} + +function ranRemoteCommandContaining(string $needle): bool +{ + $ran = false; + Process::assertRan(function ($process) use ($needle, &$ran) { + $ran = $ran || str_contains($process->command, $needle); + + return true; + }); + + return $ran; +} + +it('blocks start and restart while the current data is in an unnamed volume', function () { + $database = create_standalone_clickhouse($this->environment->id, $this->destination); + fakeClickhouseDataMount('3f1c0ffee'); + + expect(StartDatabase::run($database->fresh()))->toContain('unnamed Docker volume 3f1c0ffee') + ->and(RestartDatabase::run($database->fresh()))->toContain('unnamed Docker volume 3f1c0ffee') + ->and(ranRemoteCommandContaining("docker rm -f {$database->uuid}"))->toBeFalse(); + Queue::assertNotPushed(DatabaseStartJob::class); +}); + +it('starts a database that uses its data volume', function () { + $database = create_standalone_clickhouse($this->environment->id, $this->destination); + fakeClickhouseDataMount("clickhouse-data-{$database->uuid}"); + + expect(StartDatabase::run($database->fresh()))->toBeInstanceOf(Activity::class); + Queue::assertPushed(DatabaseStartJob::class); +}); + +it('keeps the container on stop so the unnamed volume stays linked, except on delete', function (bool $keepAnonymousDataVolume, bool $removesContainer) { + $database = create_standalone_clickhouse($this->environment->id, $this->destination); + fakeClickhouseDataMount('3f1c0ffee'); + + StopDatabase::run($database->fresh(), dockerCleanup: false, keepAnonymousDataVolume: $keepAnonymousDataVolume); + + expect(ranRemoteCommandContaining("docker rm -f {$database->uuid}"))->toBe($removesContainer); +})->with([ + 'stop' => [true, false], + 'delete' => [false, true], +]); + +it('lets an admin keep the current data volume and warns about it', function () { + $database = create_standalone_clickhouse($this->environment->id, $this->destination); + fakeClickhouseDataMount('3f1c0ffee'); + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + Livewire::test(ClickhouseGeneral::class, ['database' => $database->fresh()]) + ->assertSet('anonymousDataVolume', '3f1c0ffee') + ->assertSee('Keep current data') + ->call('keepCurrentDataVolume') + ->assertSet('anonymousDataVolume', null); + + expect($database->persistentStorages()->where('mount_path', StandaloneClickhouse::DATA_DIRECTORY)->value('name'))->toBe('3f1c0ffee') + ->and(StartDatabase::prerequisiteError($database->fresh()))->toBeNull(); +}); + +it('does not let a member keep the current data volume', function () { + $database = create_standalone_clickhouse($this->environment->id, $this->destination); + fakeClickhouseDataMount('3f1c0ffee'); + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ClickhouseGeneral::class, ['database' => $database->fresh()]) + ->assertSee('Current data is not in the data volume') + ->call('keepCurrentDataVolume'); + + expect($database->persistentStorages()->where('mount_path', StandaloneClickhouse::DATA_DIRECTORY)->value('name')) + ->toBe("clickhouse-data-{$database->uuid}"); +}); + +it('reads the data mount with sudo on a non-root server', function () { + $this->server->update(['user' => 'cooluser']); + $database = create_standalone_clickhouse($this->environment->id, $this->destination); + fakeClickhouseDataMount('3f1c0ffee'); + + expect($database->fresh()->anonymousDataVolume())->toBe('3f1c0ffee') + ->and(ranRemoteCommandContaining("sudo docker inspect --format '{{range .Mounts}}{{if eq .Destination \"/var/lib/clickhouse\"}}{{.Type}} {{.Name}}{{end}}{{end}}' '{$database->uuid}' 2>/dev/null || sudo true"))->toBeTrue(); +}); diff --git a/tests/Feature/StorageApiTest.php b/tests/Feature/StorageApiTest.php index 2b0774d8ff..652fa88ffa 100644 --- a/tests/Feature/StorageApiTest.php +++ b/tests/Feature/StorageApiTest.php @@ -649,6 +649,26 @@ describe('DELETE /api/v1/databases/{uuid}/storages/{storage_uuid}', function () $response->assertStatus(200); expect(LocalPersistentVolume::find($vol->id))->toBeNull(); }); + + test('deletes a file storage', function () { + $db = createTestDatabase($this); + + $vol = LocalFileVolume::create([ + 'fs_path' => database_configuration_dir().'/'.$db->uuid.'/postgres/postgresql.conf', + 'mount_path' => '/postgres/postgresql.conf', + 'content' => 'listen_addresses = "*"', + 'is_directory' => false, + 'resource_id' => $db->id, + 'resource_type' => $db->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->deleteJson("/api/v1/databases/{$db->uuid}/storages/{$vol->uuid}"); + + $response->assertStatus(200); + expect(LocalFileVolume::find($vol->id))->toBeNull(); + }); }); test('rejects host paths when creating persistent storage through the API', function (string $resourceType) { diff --git a/tests/Feature/TagQuickAddTest.php b/tests/Feature/TagQuickAddTest.php index 1289268aef..8ab856035c 100644 --- a/tests/Feature/TagQuickAddTest.php +++ b/tests/Feature/TagQuickAddTest.php @@ -79,3 +79,15 @@ test('member cannot create a tag through resource settings', function () { ->assertDispatched('error'); expect(Tag::where('name', 'member-tag')->exists())->toBeFalse(); }); + +test('typing the name of an existing team tag attaches that tag', function () { + $existing = Tag::create(['name' => 'existing-tag', 'team_id' => $this->team->id]); + + Livewire::test(Tags::class, ['resource' => $this->application]) + ->set('newTags', 'existing-tag') + ->call('submit') + ->assertNotDispatched('error'); + + expect($this->application->tags()->pluck('tags.id')->all())->toBe([$existing->id]) + ->and(Tag::where('name', 'existing-tag')->count())->toBe(1); +});