diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 8e31a7051..f3783696d 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -1766,7 +1766,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('postgres_conf', $postgresConf); } - $database = create_standalone_postgresql($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_postgresql($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1821,7 +1821,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('mariadb_conf', $mariadbConf); } - $database = create_standalone_mariadb($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_mariadb($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1880,7 +1880,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('mysql_conf', $mysqlConf); } - $database = create_standalone_mysql($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_mysql($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1936,7 +1936,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('redis_conf', $redisConf); } - $database = create_standalone_redis($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_redis($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1973,7 +1973,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } removeUnnecessaryFieldsFromRequest($request); - $database = create_standalone_dragonfly($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_dragonfly($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -2022,7 +2022,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('keydb_conf', $keydbConf); } - $database = create_standalone_keydb($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_keydb($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -2058,7 +2058,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) ], 422); } removeUnnecessaryFieldsFromRequest($request); - $database = create_standalone_clickhouse($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_clickhouse($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -2116,7 +2116,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('mongo_conf', $mongoConf); } - $database = create_standalone_mongodb($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_mongodb($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } diff --git a/app/Livewire/Admin/Index.php b/app/Livewire/Admin/Index.php index d1345e7bf..4d22047cc 100644 --- a/app/Livewire/Admin/Index.php +++ b/app/Livewire/Admin/Index.php @@ -37,7 +37,7 @@ public function back() Auth::login($user); refreshSession($team_to_switch_to); - return redirect(request()->header('Referer')); + return redirect()->route('admin.index'); } } @@ -70,7 +70,7 @@ public function switchUser(int $user_id) Auth::login($user); refreshSession($team_to_switch_to); - return redirect(request()->header('Referer')); + return redirect()->route('dashboard'); } private function authorizeAdminAccess(): void diff --git a/app/Livewire/Destination/Show.php b/app/Livewire/Destination/Show.php index f2cdad074..9d55d7462 100644 --- a/app/Livewire/Destination/Show.php +++ b/app/Livewire/Destination/Show.php @@ -2,9 +2,7 @@ namespace App\Livewire\Destination; -use App\Models\Server; use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; @@ -29,16 +27,8 @@ class Show extends Component public function mount(string $destination_uuid) { try { - $destination = StandaloneDocker::whereUuid($destination_uuid)->first() ?? - SwarmDocker::whereUuid($destination_uuid)->firstOrFail(); - - $ownedByTeam = Server::ownedByCurrentTeam()->each(function ($server) use ($destination) { - if ($server->standaloneDockers->contains($destination) || $server->swarmDockers->contains($destination)) { - $this->destination = $destination; - $this->syncData(); - } - }); - if ($ownedByTeam === false) { + $destination = find_destination_for_current_team($destination_uuid); + if (! $destination) { return redirect()->route('destination.index'); } $this->destination = $destination; @@ -80,7 +70,7 @@ public function delete() try { $this->authorize('delete', $this->destination); - if ($this->destination->getMorphClass() === \App\Models\StandaloneDocker::class) { + if ($this->destination->getMorphClass() === StandaloneDocker::class) { if ($this->destination->attachedTo()) { return $this->dispatch('error', 'You must delete all resources before deleting this destination.'); } diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php index 2b92902c6..2cf0659bf 100644 --- a/app/Livewire/Project/New/DockerCompose.php +++ b/app/Livewire/Project/New/DockerCompose.php @@ -5,8 +5,6 @@ use App\Models\EnvironmentVariable; use App\Models\Project; use App\Models\Service; -use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use Livewire\Component; use Symfony\Component\Yaml\Yaml; @@ -31,7 +29,6 @@ public function mount() public function submit() { - $server_id = $this->query['server_id']; try { $this->validate([ 'dockerComposeRaw' => 'required', @@ -44,20 +41,17 @@ public function submit() $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); - $destination_uuid = $this->query['destination']; - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); + $destination_uuid = $this->query['destination'] ?? null; + $destination = find_destination_for_current_team($destination_uuid); if (! $destination) { - $destination = SwarmDocker::where('uuid', $destination_uuid)->first(); - } - if (! $destination) { - throw new \Exception('Destination not found. What?!'); + throw new \Exception('Destination not found.'); } $destination_class = $destination->getMorphClass(); $service = Service::create([ 'docker_compose_raw' => $this->dockerComposeRaw, 'environment_id' => $environment->id, - 'server_id' => (int) $server_id, + 'server_id' => $destination->server_id, 'destination_id' => $destination->id, 'destination_type' => $destination_class, ]); diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php index 268333d07..b89ce2c6a 100644 --- a/app/Livewire/Project/New/DockerImage.php +++ b/app/Livewire/Project/New/DockerImage.php @@ -4,8 +4,6 @@ use App\Models\Application; use App\Models\Project; -use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use App\Services\DockerImageParser; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -111,13 +109,10 @@ public function submit() $parser = new DockerImageParser; $parser->parse($dockerImage); - $destination_uuid = $this->query['destination']; - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); + $destination_uuid = $this->query['destination'] ?? null; + $destination = find_destination_for_current_team($destination_uuid); if (! $destination) { - $destination = SwarmDocker::where('uuid', $destination_uuid)->first(); - } - if (! $destination) { - throw new \Exception('Destination not found. What?!'); + throw new \Exception('Destination not found.'); } $destination_class = $destination->getMorphClass(); diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 0222008b0..86e407136 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -5,8 +5,6 @@ use App\Models\Application; use App\Models\GithubApp; use App\Models\Project; -use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use App\Rules\ValidGitBranch; use App\Support\ValidationPatterns; use Illuminate\Support\Facades\Http; @@ -178,13 +176,10 @@ public function submit() throw new \RuntimeException('Invalid repository data: '.$validator->errors()->first()); } - $destination_uuid = $this->query['destination']; - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); + $destination_uuid = $this->query['destination'] ?? null; + $destination = find_destination_for_current_team($destination_uuid); if (! $destination) { - $destination = SwarmDocker::where('uuid', $destination_uuid)->first(); - } - if (! $destination) { - throw new \Exception('Destination not found. What?!'); + throw new \Exception('Destination not found.'); } $destination_class = $destination->getMorphClass(); diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index f8642d6fc..5a6f288b3 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -7,8 +7,6 @@ use App\Models\GitlabApp; use App\Models\PrivateKey; use App\Models\Project; -use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Support\ValidationPatterns; @@ -130,13 +128,10 @@ public function submit() { $this->validate(); try { - $destination_uuid = $this->query['destination']; - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); + $destination_uuid = $this->query['destination'] ?? null; + $destination = find_destination_for_current_team($destination_uuid); if (! $destination) { - $destination = SwarmDocker::where('uuid', $destination_uuid)->first(); - } - if (! $destination) { - throw new \Exception('Destination not found. What?!'); + throw new \Exception('Destination not found.'); } $destination_class = $destination->getMorphClass(); diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index dbfa15a55..b350538ac 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -7,8 +7,6 @@ use App\Models\GitlabApp; use App\Models\Project; use App\Models\Service; -use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Support\ValidationPatterns; @@ -34,8 +32,6 @@ class PublicGitRepository extends Component public bool $isStatic = false; - public bool $checkCoolifyConfig = true; - public ?string $publish_directory = null; // In case of docker compose @@ -284,16 +280,13 @@ public function submit() throw new \RuntimeException('Invalid branch: '.$branchValidator->errors()->first('git_branch')); } - $destination_uuid = $this->query['destination']; + $destination_uuid = $this->query['destination'] ?? null; $project_uuid = $this->parameters['project_uuid']; $environment_uuid = $this->parameters['environment_uuid']; - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); + $destination = find_destination_for_current_team($destination_uuid); if (! $destination) { - $destination = SwarmDocker::where('uuid', $destination_uuid)->first(); - } - if (! $destination) { - throw new \Exception('Destination not found. What?!'); + throw new \Exception('Destination not found.'); } $destination_class = $destination->getMorphClass(); @@ -371,12 +364,6 @@ public function submit() $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->fqdn = $fqdn; $application->save(); - if ($this->checkCoolifyConfig) { - // $config = loadConfigFromGit($this->repository_url, $this->git_branch, $this->base_directory, $this->query['server_id'], auth()->user()->currentTeam()->id); - // if ($config) { - // $application->setConfig($config); - // } - } return redirect()->route('project.application.configuration', [ 'application_uuid' => $application->uuid, diff --git a/app/Livewire/Project/New/SimpleDockerfile.php b/app/Livewire/Project/New/SimpleDockerfile.php index 1073157e6..f07948dba 100644 --- a/app/Livewire/Project/New/SimpleDockerfile.php +++ b/app/Livewire/Project/New/SimpleDockerfile.php @@ -5,8 +5,6 @@ use App\Models\Application; use App\Models\GithubApp; use App\Models\Project; -use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -35,13 +33,10 @@ public function submit() $this->validate([ 'dockerfile' => 'required', ]); - $destination_uuid = $this->query['destination']; - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); + $destination_uuid = $this->query['destination'] ?? null; + $destination = find_destination_for_current_team($destination_uuid); if (! $destination) { - $destination = SwarmDocker::where('uuid', $destination_uuid)->first(); - } - if (! $destination) { - throw new \Exception('Destination not found. What?!'); + throw new \Exception('Destination not found.'); } $destination_class = $destination->getMorphClass(); diff --git a/app/Livewire/Project/Resource/Create.php b/app/Livewire/Project/Resource/Create.php index 966c66a14..4619ddf37 100644 --- a/app/Livewire/Project/Resource/Create.php +++ b/app/Livewire/Project/Resource/Create.php @@ -4,7 +4,6 @@ use App\Models\EnvironmentVariable; use App\Models\Service; -use App\Models\StandaloneDocker; use Livewire\Component; class Create extends Component @@ -18,7 +17,6 @@ public function mount() $type = str(request()->query('type')); $destination_uuid = request()->query('destination'); - $server_id = request()->query('server_id'); $database_image = request()->query('database_image'); $project = currentTeam()->load(['projects'])->projects->where('uuid', request()->route('project_uuid'))->first(); @@ -30,7 +28,11 @@ public function mount() if (! $environment) { return redirect()->route('dashboard'); } - if (isset($type) && isset($destination_uuid) && isset($server_id)) { + if (isset($type) && isset($destination_uuid)) { + $destination = find_destination_for_current_team($destination_uuid); + if (! $destination) { + return redirect()->route('dashboard'); + } $services = get_service_templates(); if (in_array($type, DATABASE_TYPES)) { @@ -44,23 +46,23 @@ public function mount() } $database = create_standalone_postgresql( environmentId: $environment->id, - destinationUuid: $destination_uuid, + destination: $destination, databaseImage: $database_image ); } elseif ($type->value() === 'redis') { - $database = create_standalone_redis($environment->id, $destination_uuid); + $database = create_standalone_redis($environment->id, $destination); } elseif ($type->value() === 'mongodb') { - $database = create_standalone_mongodb($environment->id, $destination_uuid); + $database = create_standalone_mongodb($environment->id, $destination); } elseif ($type->value() === 'mysql') { - $database = create_standalone_mysql($environment->id, $destination_uuid); + $database = create_standalone_mysql($environment->id, $destination); } elseif ($type->value() === 'mariadb') { - $database = create_standalone_mariadb($environment->id, $destination_uuid); + $database = create_standalone_mariadb($environment->id, $destination); } elseif ($type->value() === 'keydb') { - $database = create_standalone_keydb($environment->id, $destination_uuid); + $database = create_standalone_keydb($environment->id, $destination); } elseif ($type->value() === 'dragonfly') { - $database = create_standalone_dragonfly($environment->id, $destination_uuid); + $database = create_standalone_dragonfly($environment->id, $destination); } elseif ($type->value() === 'clickhouse') { - $database = create_standalone_clickhouse($environment->id, $destination_uuid); + $database = create_standalone_clickhouse($environment->id, $destination); } return redirect()->route('project.database.configuration', [ @@ -69,7 +71,7 @@ public function mount() 'database_uuid' => $database->uuid, ]); } - if ($type->startsWith('one-click-service-') && ! is_null((int) $server_id)) { + if ($type->startsWith('one-click-service-')) { $oneClickServiceName = $type->after('one-click-service-')->value(); $oneClickService = data_get($services, "$oneClickServiceName.compose"); $oneClickDotEnvs = data_get($services, "$oneClickServiceName.envs", null); @@ -79,12 +81,11 @@ public function mount() }); } if ($oneClickService) { - $destination = StandaloneDocker::whereUuid($destination_uuid)->first(); $service_payload = [ 'docker_compose_raw' => base64_decode($oneClickService), 'environment_id' => $environment->id, 'service_type' => $oneClickServiceName, - 'server_id' => (int) $server_id, + 'server_id' => $destination->server_id, 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), ]; diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index f4813dd4c..2a8747c33 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -58,10 +58,9 @@ public function cloneTo($destination_id) { $this->authorize('update', $this->resource); - $teamScope = fn ($q) => $q->where('team_id', currentTeam()->id); - $new_destination = StandaloneDocker::whereHas('server', $teamScope)->find($destination_id); + $new_destination = StandaloneDocker::ownedByCurrentTeam()->find($destination_id); if (! $new_destination) { - $new_destination = SwarmDocker::whereHas('server', $teamScope)->find($destination_id); + $new_destination = SwarmDocker::ownedByCurrentTeam()->find($destination_id); } if (! $new_destination) { return $this->addError('destination_id', 'Destination not found.'); diff --git a/app/Livewire/Storage/Resources.php b/app/Livewire/Storage/Resources.php index 643ecb3eb..0dad2d548 100644 --- a/app/Livewire/Storage/Resources.php +++ b/app/Livewire/Storage/Resources.php @@ -25,7 +25,9 @@ public function mount(): void public function disableS3(int $backupId): void { - $backup = ScheduledDatabaseBackup::findOrFail($backupId); + $backup = ScheduledDatabaseBackup::where('id', $backupId) + ->where('s3_storage_id', $this->storage->id) + ->firstOrFail(); $backup->update([ 'save_s3' => false, @@ -39,7 +41,9 @@ public function disableS3(int $backupId): void public function moveBackup(int $backupId): void { - $backup = ScheduledDatabaseBackup::findOrFail($backupId); + $backup = ScheduledDatabaseBackup::where('id', $backupId) + ->where('s3_storage_id', $this->storage->id) + ->firstOrFail(); $newStorageId = $this->selectedStorages[$backupId] ?? null; if (! $newStorageId || (int) $newStorageId === $this->storage->id) { diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index dcb349405..d6b4d1a1c 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -90,6 +90,16 @@ public function server() return $this->belongsTo(Server::class); } + public static function ownedByCurrentTeam() + { + return static::whereHas('server', fn ($q) => $q->whereTeamId(currentTeam()->id)); + } + + public static function ownedByCurrentTeamAPI(int $teamId) + { + return static::whereHas('server', fn ($q) => $q->whereTeamId($teamId)); + } + /** * Get the server attribute using identity map caching. * This intercepts lazy-loading to use cached Server lookups. diff --git a/app/Models/SwarmDocker.php b/app/Models/SwarmDocker.php index 134e36189..0e9620457 100644 --- a/app/Models/SwarmDocker.php +++ b/app/Models/SwarmDocker.php @@ -71,6 +71,16 @@ public function server() return $this->belongsTo(Server::class); } + public static function ownedByCurrentTeam() + { + return static::whereHas('server', fn ($q) => $q->whereTeamId(currentTeam()->id)); + } + + public static function ownedByCurrentTeamAPI(int $teamId) + { + return static::whereHas('server', fn ($q) => $q->whereTeamId($teamId)); + } + /** * Get the server attribute using identity map caching. * This intercepts lazy-loading to use cached Server lookups. diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index 5df36db33..4d5e085f3 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -3,6 +3,7 @@ use App\Models\EnvironmentVariable; use App\Models\S3Storage; use App\Models\Server; +use App\Models\ServiceDatabase; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDocker; use App\Models\StandaloneDragonfly; @@ -12,18 +13,19 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use App\Models\SwarmDocker; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; use Visus\Cuid2\Cuid2; -function create_standalone_postgresql($environmentId, $destinationUuid, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql +function create_standalone_postgresql($environmentId, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql { - $destination = StandaloneDocker::where('uuid', $destinationUuid)->firstOrFail(); $database = new StandalonePostgresql; $database->uuid = (new Cuid2); $database->name = 'postgresql-database-'.$database->uuid; $database->image = $databaseImage; - $database->postgres_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->postgres_password = Str::password(length: 64, symbols: false); $database->environment_id = $environmentId; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -35,14 +37,13 @@ function create_standalone_postgresql($environmentId, $destinationUuid, ?array $ return $database; } -function create_standalone_redis($environment_id, $destination_uuid, ?array $otherData = null): StandaloneRedis +function create_standalone_redis($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneRedis { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneRedis; $database->uuid = (new Cuid2); $database->name = 'redis-database-'.$database->uuid; - $redis_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $redis_password = Str::password(length: 64, symbols: false); if ($otherData && isset($otherData['redis_password'])) { $redis_password = $otherData['redis_password']; unset($otherData['redis_password']); @@ -75,13 +76,12 @@ function create_standalone_redis($environment_id, $destination_uuid, ?array $oth return $database; } -function create_standalone_mongodb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMongodb +function create_standalone_mongodb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMongodb { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneMongodb; $database->uuid = (new Cuid2); $database->name = 'mongodb-database-'.$database->uuid; - $database->mongo_initdb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->mongo_initdb_root_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -93,14 +93,13 @@ function create_standalone_mongodb($environment_id, $destination_uuid, ?array $o return $database; } -function create_standalone_mysql($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMysql +function create_standalone_mysql($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMysql { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneMysql; $database->uuid = (new Cuid2); $database->name = 'mysql-database-'.$database->uuid; - $database->mysql_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false); - $database->mysql_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->mysql_root_password = Str::password(length: 64, symbols: false); + $database->mysql_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -112,14 +111,13 @@ function create_standalone_mysql($environment_id, $destination_uuid, ?array $oth return $database; } -function create_standalone_mariadb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMariadb +function create_standalone_mariadb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMariadb { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneMariadb; $database->uuid = (new Cuid2); $database->name = 'mariadb-database-'.$database->uuid; - $database->mariadb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false); - $database->mariadb_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->mariadb_root_password = Str::password(length: 64, symbols: false); + $database->mariadb_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -131,13 +129,12 @@ function create_standalone_mariadb($environment_id, $destination_uuid, ?array $o return $database; } -function create_standalone_keydb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneKeydb +function create_standalone_keydb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneKeydb { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneKeydb; $database->uuid = (new Cuid2); $database->name = 'keydb-database-'.$database->uuid; - $database->keydb_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->keydb_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -149,13 +146,12 @@ function create_standalone_keydb($environment_id, $destination_uuid, ?array $oth return $database; } -function create_standalone_dragonfly($environment_id, $destination_uuid, ?array $otherData = null): StandaloneDragonfly +function create_standalone_dragonfly($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneDragonfly { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneDragonfly; $database->uuid = (new Cuid2); $database->name = 'dragonfly-database-'.$database->uuid; - $database->dragonfly_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->dragonfly_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -167,13 +163,12 @@ function create_standalone_dragonfly($environment_id, $destination_uuid, ?array return $database; } -function create_standalone_clickhouse($environment_id, $destination_uuid, ?array $otherData = null): StandaloneClickhouse +function create_standalone_clickhouse($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneClickhouse { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneClickhouse; $database->uuid = (new Cuid2); $database->name = 'clickhouse-database-'.$database->uuid; - $database->clickhouse_admin_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->clickhouse_admin_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -279,7 +274,7 @@ function removeOldBackups($backup): void ->whereNull('s3_uploaded') ->delete(); - } catch (\Exception $e) { + } catch (Exception $e) { throw $e; } } @@ -345,7 +340,7 @@ function deleteOldBackupsLocally($backup): Collection $processedBackups = collect(); $server = null; - if ($backup->database_type === \App\Models\ServiceDatabase::class) { + if ($backup->database_type === ServiceDatabase::class) { $server = $backup->database->service->server; } else { $server = $backup->database->destination->server; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 011c149c9..77ca64fbd 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -18,6 +18,7 @@ use App\Models\ServiceDatabase; use App\Models\SharedEnvironmentVariable; use App\Models\StandaloneClickhouse; +use App\Models\StandaloneDocker; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; use App\Models\StandaloneMariadb; @@ -25,6 +26,7 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use App\Models\SwarmDocker; use App\Models\Team; use App\Models\User; use Carbon\CarbonImmutable; @@ -259,6 +261,16 @@ function currentTeam() return Auth::user()?->currentTeam() ?? null; } +function find_destination_for_current_team(?string $uuid): StandaloneDocker|SwarmDocker|null +{ + if (blank($uuid) || ! currentTeam()) { + return null; + } + + return StandaloneDocker::ownedByCurrentTeam()->where('uuid', $uuid)->first() + ?? SwarmDocker::ownedByCurrentTeam()->where('uuid', $uuid)->first(); +} + function showBoarding(): bool { if (isDev()) { @@ -3489,34 +3501,6 @@ function getHelperVersion(): string return config('constants.coolify.helper_version'); } -function loadConfigFromGit(string $repository, string $branch, string $base_directory, int $server_id, int $team_id) -{ - $server = Server::find($server_id)->where('team_id', $team_id)->first(); - if (! $server) { - return; - } - $uuid = new Cuid2; - $cloneCommand = "git clone --no-checkout -b $branch $repository ."; - $workdir = rtrim($base_directory, '/'); - $fileList = collect([".$workdir/coolify.json"]); - $commands = collect([ - "rm -rf /tmp/{$uuid}", - "mkdir -p /tmp/{$uuid}", - "cd /tmp/{$uuid}", - $cloneCommand, - 'git sparse-checkout init --cone', - "git sparse-checkout set {$fileList->implode(' ')}", - 'git read-tree -mu HEAD', - "cat .$workdir/coolify.json", - 'rm -rf /tmp/{$uuid}', - ]); - try { - return instant_remote_process($commands, $server); - } catch (Exception) { - // continue - } -} - function loggy($message = null, array $context = []) { if (! isDev()) { diff --git a/tests/Feature/AdminAccessAuthorizationTest.php b/tests/Feature/AdminAccessAuthorizationTest.php index 4840bc4dd..97895ecda 100644 --- a/tests/Feature/AdminAccessAuthorizationTest.php +++ b/tests/Feature/AdminAccessAuthorizationTest.php @@ -1,6 +1,7 @@ set('constants.coolify.self_hosted', false); - $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); $rootUser = User::factory()->create(['id' => 0]); - $rootTeam->members()->attach($rootUser->id, ['role' => 'admin']); + $rootTeam = Team::find(0); $targetUser = User::factory()->create(); $targetTeam = Team::factory()->create(); @@ -84,7 +85,47 @@ Livewire::test(AdminIndex::class) ->assertOk() ->call('switchUser', $targetUser->id) - ->assertRedirect(); + ->assertRedirect(route('dashboard')); +}); + +test('back() redirects impersonator to admin index and clears session', function () { + config()->set('constants.coolify.self_hosted', false); + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); + $rootUser = User::factory()->create(['id' => 0]); + $rootTeam = Team::find(0); + + $this->actingAs($rootUser); + session([ + 'currentTeam' => ['id' => $rootTeam->id], + 'impersonating' => true, + ]); + + Livewire::test(AdminIndex::class) + ->call('back') + ->assertRedirect(route('admin.index')); + + expect(session('impersonating'))->toBeNull(); +}); + +test('switchUser ignores Referer header and uses dashboard route', function () { + config()->set('constants.coolify.self_hosted', false); + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); + $rootUser = User::factory()->create(['id' => 0]); + $rootTeam = Team::find(0); + + $targetUser = User::factory()->create(); + $targetTeam = Team::factory()->create(); + $targetTeam->members()->attach($targetUser->id, ['role' => 'admin']); + + $this->actingAs($rootUser); + session(['currentTeam' => ['id' => $rootTeam->id]]); + + Livewire::withHeaders(['Referer' => 'https://example.com/elsewhere']) + ->test(AdminIndex::class) + ->call('switchUser', $targetUser->id) + ->assertRedirect(route('dashboard')); }); test('switchUser rejects non-root user', function () { diff --git a/tests/Feature/TeamScopedBackupStorageTest.php b/tests/Feature/TeamScopedBackupStorageTest.php new file mode 100644 index 000000000..57a065ae8 --- /dev/null +++ b/tests/Feature/TeamScopedBackupStorageTest.php @@ -0,0 +1,106 @@ + InstanceSettings::query()->create(['id' => 0])); + + $this->userA = User::factory()->create(); + $this->teamA = Team::factory()->create(); + $this->userA->teams()->attach($this->teamA, ['role' => 'owner']); + + $this->userB = User::factory()->create(); + $this->teamB = Team::factory()->create(); + $this->userB->teams()->attach($this->teamB, ['role' => 'owner']); + + $this->storageA = S3Storage::unguarded(fn () => S3Storage::create([ + 'uuid' => fake()->uuid(), + 'name' => 'storage-a-'.fake()->unique()->word(), + 'region' => 'us-east-1', + 'key' => 'key-a', + 'secret' => 'secret-a', + 'bucket' => 'bucket-a', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $this->teamA->id, + ])); + + $this->storageB = S3Storage::unguarded(fn () => S3Storage::create([ + 'uuid' => fake()->uuid(), + 'name' => 'storage-b-'.fake()->unique()->word(), + 'region' => 'us-east-1', + 'key' => 'key-b', + 'secret' => 'secret-b', + 'bucket' => 'bucket-b', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $this->teamB->id, + ])); + + $this->backupA = ScheduledDatabaseBackup::create([ + 'uuid' => fake()->uuid(), + 'team_id' => $this->teamA->id, + 'enabled' => true, + 'save_s3' => true, + 'frequency' => '0 0 * * *', + 'database_type' => 'App\\Models\\StandalonePostgresql', + 'database_id' => 1, + 's3_storage_id' => $this->storageA->id, + ]); + + $this->backupB = ScheduledDatabaseBackup::create([ + 'uuid' => fake()->uuid(), + 'team_id' => $this->teamB->id, + 'enabled' => true, + 'save_s3' => true, + 'frequency' => '0 0 * * *', + 'database_type' => 'App\\Models\\StandalonePostgresql', + 'database_id' => 2, + 's3_storage_id' => $this->storageB->id, + ]); + + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); +}); + +describe('Storage/Resources team-scoped backup access', function () { + test('disableS3 on other team backup throws and leaves row unchanged', function () { + expect(fn () => Livewire::test(StorageResources::class, ['storage' => $this->storageA]) + ->call('disableS3', $this->backupB->id)) + ->toThrow(ModelNotFoundException::class); + + $this->backupB->refresh(); + expect((bool) $this->backupB->save_s3)->toBeTrue(); + expect($this->backupB->s3_storage_id)->toBe($this->storageB->id); + }); + + test('moveBackup on other team backup throws and leaves row unchanged', function () { + expect(fn () => Livewire::test(StorageResources::class, ['storage' => $this->storageA]) + ->set('selectedStorages', [$this->backupB->id => $this->storageA->id]) + ->call('moveBackup', $this->backupB->id)) + ->toThrow(ModelNotFoundException::class); + + $this->backupB->refresh(); + expect($this->backupB->s3_storage_id)->toBe($this->storageB->id); + }); + + test('disableS3 on own backup succeeds', function () { + Livewire::test(StorageResources::class, ['storage' => $this->storageA]) + ->call('disableS3', $this->backupA->id); + + $this->backupA->refresh(); + expect((bool) $this->backupA->save_s3)->toBeFalse(); + expect($this->backupA->s3_storage_id)->toBeNull(); + }); +}); diff --git a/tests/Feature/TeamScopedDestinationTest.php b/tests/Feature/TeamScopedDestinationTest.php new file mode 100644 index 000000000..bdac0251d --- /dev/null +++ b/tests/Feature/TeamScopedDestinationTest.php @@ -0,0 +1,297 @@ + InstanceSettings::query()->create(['id' => 0])); + + $this->userA = User::factory()->create(); + $this->teamA = Team::factory()->create(); + $this->userA->teams()->attach($this->teamA, ['role' => 'owner']); + + $this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]); + $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]); + $this->destinationA = StandaloneDocker::factory()->create([ + 'server_id' => $this->serverA->id, + 'name' => 'dest-a-'.fake()->unique()->word(), + 'network' => 'coolify-a-'.fake()->unique()->word(), + ]); + + $this->userB = User::factory()->create(); + $this->teamB = Team::factory()->create(); + $this->userB->teams()->attach($this->teamB, ['role' => 'owner']); + + $this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]); + $this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]); + $this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]); + $this->destinationB = StandaloneDocker::factory()->create([ + 'server_id' => $this->serverB->id, + 'name' => 'dest-b-'.fake()->unique()->word(), + 'network' => 'coolify-b-'.fake()->unique()->word(), + ]); + $this->swarmDestinationB = SwarmDocker::create([ + 'uuid' => fake()->uuid(), + 'name' => 'swarm-b-'.fake()->unique()->word(), + 'network' => 'swarm-b-'.fake()->unique()->word(), + 'server_id' => $this->serverB->id, + ]); + + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); +}); + +describe('find_destination_for_current_team helper', function () { + test('returns null for other team destination UUID', function () { + expect(find_destination_for_current_team($this->destinationB->uuid))->toBeNull(); + }); + + test('returns null for other team swarm destination UUID', function () { + expect(find_destination_for_current_team($this->swarmDestinationB->uuid))->toBeNull(); + }); + + test('returns own team destination', function () { + $found = find_destination_for_current_team($this->destinationA->uuid); + expect($found)->not->toBeNull(); + expect($found->id)->toBe($this->destinationA->id); + }); + + test('returns null for blank uuid', function () { + expect(find_destination_for_current_team(null))->toBeNull(); + expect(find_destination_for_current_team(''))->toBeNull(); + }); +}); + +describe('SimpleDockerfile destination team scope', function () { + test('submit with other team destination throws and creates no application', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + request()->headers->set('referer', route('project.resource.create', $routeParams).'?destination='.$this->destinationB->uuid); + + $before = Application::count(); + + expect(fn () => Livewire::withUrlParams(['destination' => $this->destinationB->uuid]) + ->test(SimpleDockerfile::class, $routeParams) + ->set('dockerfile', "FROM nginx\nCMD [\"nginx\"]\n") + ->call('submit')) + ->toThrow(Exception::class, 'Destination not found.'); + + expect(Application::count())->toBe($before); + }); +}); + +describe('DockerImage destination team scope', function () { + test('submit with other team destination throws and creates no application', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + + $before = Application::count(); + + expect(fn () => Livewire::withUrlParams(['destination' => $this->destinationB->uuid]) + ->test(DockerImage::class, $routeParams) + ->set('imageName', 'nginx') + ->set('imageTag', 'latest') + ->call('submit')) + ->toThrow(Exception::class, 'Destination not found.'); + + expect(Application::count())->toBe($before); + }); + + test('submit with other team swarm destination throws', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + + expect(fn () => Livewire::withUrlParams(['destination' => $this->swarmDestinationB->uuid]) + ->test(DockerImage::class, $routeParams) + ->set('imageName', 'nginx') + ->set('imageTag', 'latest') + ->call('submit')) + ->toThrow(Exception::class, 'Destination not found.'); + }); +}); + +describe('DockerCompose destination + server_id team scope', function () { + test('submit with other team destination throws and creates no service', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + + $before = Service::count(); + + Livewire::withUrlParams([ + 'destination' => $this->destinationB->uuid, + 'server_id' => $this->serverB->id, + ]) + ->test(DockerCompose::class, $routeParams) + ->set('dockerComposeRaw', "services:\n app:\n image: nginx\n") + ->call('submit'); + + expect(Service::count())->toBe($before); + }); + +}); + +describe('PublicGitRepository destination team scope', function () { + test('submit with other team destination creates no application', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + + $before = Application::count(); + + try { + Livewire::withUrlParams(['destination' => $this->destinationB->uuid]) + ->test(PublicGitRepository::class, $routeParams) + ->set('repository_url', 'https://github.com/coollabsio/coolify') + ->set('git_repository', 'coollabsio/coolify') + ->set('git_branch', 'main') + ->set('port', 3000) + ->set('build_pack', 'nixpacks') + ->set('git_source', 'other') + ->call('submit'); + } catch (Throwable $e) { + // submit wraps errors via handleError; count assertion below is source of truth + } + + expect(Application::count())->toBe($before); + }); +}); + +describe('GithubPrivateRepository destination team scope', function () { + test('submit with other team destination throws and creates no application', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + + $before = Application::count(); + + try { + Livewire::withUrlParams(['destination' => $this->destinationB->uuid]) + ->test(GithubPrivateRepository::class, $routeParams) + ->call('submit'); + } catch (Throwable $e) { + // expected + } + + expect(Application::count())->toBe($before); + }); +}); + +describe('GithubPrivateRepositoryDeployKey destination team scope', function () { + test('submit with other team destination throws and creates no application', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + + $before = Application::count(); + + try { + Livewire::withUrlParams(['destination' => $this->destinationB->uuid]) + ->test(GithubPrivateRepositoryDeployKey::class, $routeParams) + ->call('submit'); + } catch (Throwable $e) { + // expected + } + + expect(Application::count())->toBe($before); + }); +}); + +describe('Resource/Create database destination team scope', function () { + test('mount with other team destination does not create database', function () { + $before = StandalonePostgresql::count(); + + $url = route('project.resource.create', [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]).'?type=postgresql&destination='.$this->destinationB->uuid.'&server_id='.$this->serverB->id.'&database_image=postgres:16-alpine'; + + $this->get($url); + + expect(StandalonePostgresql::count())->toBe($before); + }); + +}); + +describe('StandaloneDocker/SwarmDocker ownedByCurrentTeam scope', function () { + test('StandaloneDocker::ownedByCurrentTeam excludes other team destinations', function () { + expect(StandaloneDocker::ownedByCurrentTeam()->where('uuid', $this->destinationB->uuid)->first())->toBeNull(); + }); + + test('SwarmDocker::ownedByCurrentTeam excludes other team destinations', function () { + expect(SwarmDocker::ownedByCurrentTeam()->where('uuid', $this->swarmDestinationB->uuid)->first())->toBeNull(); + }); + + test('StandaloneDocker::ownedByCurrentTeam returns own destination', function () { + $found = StandaloneDocker::ownedByCurrentTeam()->where('uuid', $this->destinationA->uuid)->first(); + expect($found)->not->toBeNull(); + expect($found->id)->toBe($this->destinationA->id); + }); + + test('StandaloneDocker::ownedByCurrentTeamAPI scopes by explicit team id', function () { + expect(StandaloneDocker::ownedByCurrentTeamAPI($this->teamA->id)->where('uuid', $this->destinationB->uuid)->first())->toBeNull(); + expect(StandaloneDocker::ownedByCurrentTeamAPI($this->teamB->id)->where('uuid', $this->destinationB->uuid)->first()?->id)->toBe($this->destinationB->id); + }); + + test('SwarmDocker::ownedByCurrentTeamAPI scopes by explicit team id', function () { + expect(SwarmDocker::ownedByCurrentTeamAPI($this->teamA->id)->where('uuid', $this->swarmDestinationB->uuid)->first())->toBeNull(); + expect(SwarmDocker::ownedByCurrentTeamAPI($this->teamB->id)->where('uuid', $this->swarmDestinationB->uuid)->first()?->id)->toBe($this->swarmDestinationB->id); + }); +}); + +describe('Destination/Show team scope', function () { + test('mount with other team destination UUID redirects to index', function () { + $component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destinationB->uuid]); + + expect($component->get('destination'))->toBeNull(); + $component->assertRedirect(route('destination.index')); + }); + + test('mount with own destination UUID loads it', function () { + $component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destinationA->uuid]); + + expect($component->get('destination'))->not->toBeNull(); + expect($component->get('destination')->id)->toBe($this->destinationA->id); + }); + + test('mount with other team swarm destination UUID redirects to index', function () { + $component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->swarmDestinationB->uuid]); + + expect($component->get('destination'))->toBeNull(); + $component->assertRedirect(route('destination.index')); + }); +}); diff --git a/tests/Feature/TeamScopedResourceProofsTest.php b/tests/Feature/TeamScopedResourceProofsTest.php new file mode 100644 index 000000000..b56fbd60e --- /dev/null +++ b/tests/Feature/TeamScopedResourceProofsTest.php @@ -0,0 +1,96 @@ +userA = User::factory()->create(); + $this->teamA = Team::factory()->create(); + $this->userA->teams()->attach($this->teamA, ['role' => 'owner']); + $this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]); + $this->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id, 'network' => 'net-a-'.fake()->uuid()]); + $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]); + + // Team B (other team) + $this->userB = User::factory()->create(); + $this->teamB = Team::factory()->create(); + $this->userB->teams()->attach($this->teamB, ['role' => 'owner']); + $this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]); + $this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id, 'network' => 'net-b-'.fake()->uuid()]); + $this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]); + $this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]); + + // Authenticate as Team A + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); +}); + +test('unscoped Project lookup returns another teams project', function () { + $project = Project::where('uuid', $this->projectB->uuid)->first(); + + expect($project)->not->toBeNull() + ->and($project->team_id)->toBe($this->teamB->id) + ->and($project->team_id)->not->toBe($this->teamA->id); +}); + +test('unscoped StandaloneDocker lookup returns another teams destination', function () { + $dest = StandaloneDocker::where('uuid', $this->destinationB->uuid)->first(); + + expect($dest)->not->toBeNull() + ->and($dest->server->team_id)->toBe($this->teamB->id); +}); + +test('ownedByCurrentTeam scope blocks other-team Project access', function () { + expect(Project::ownedByCurrentTeam()->where('uuid', $this->projectB->uuid)->first())->toBeNull(); +}); + +test('ownedByCurrentTeam scope allows own Project access', function () { + expect(Project::ownedByCurrentTeam()->where('uuid', $this->projectA->uuid)->first())->not->toBeNull(); +}); + +test('Team A can create Application in Team B environment via unscoped lookups', function () { + $destination = StandaloneDocker::where('uuid', $this->destinationB->uuid)->first(); + $project = Project::where('uuid', $this->projectB->uuid)->first(); + $environment = $project->load(['environments'])->environments->where('uuid', $this->environmentB->uuid)->first(); + + $application = Application::create([ + 'name' => 'team-scope-test-canary', + 'repository_project_id' => 0, + 'git_repository' => 'coollabsio/coolify', + 'git_branch' => 'main', + 'build_pack' => 'dockerfile', + 'dockerfile' => "FROM alpine\nCMD echo hello", + 'ports_exposes' => 80, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'health_check_enabled' => false, + 'source_id' => 0, + 'source_type' => GithubApp::class, + ]); + + expect($application->environment_id)->toBe($this->environmentB->id) + ->and($application->destination_id)->toBe($this->destinationB->id) + ->and($application->environment->project->team->id)->toBe($this->teamB->id) + ->and($application->environment->project->team->id)->not->toBe($this->teamA->id); +}); + +test('resource creation page loads with another teams project UUID', function () { + $response = $this->get(route('project.resource.create', [ + 'project_uuid' => $this->projectB->uuid, + 'environment_uuid' => $this->environmentB->uuid, + ])); + + expect($response->status())->not->toBe(403); +});