diff --git a/app/Actions/CoolifyTask/RunRemoteProcess.php b/app/Actions/CoolifyTask/RunRemoteProcess.php index 12639d9317..5b936686ce 100644 --- a/app/Actions/CoolifyTask/RunRemoteProcess.php +++ b/app/Actions/CoolifyTask/RunRemoteProcess.php @@ -9,6 +9,7 @@ use App\Jobs\ApplicationDeploymentJob; use App\Models\Server; use App\Support\DatabaseImport\DatabaseImportCleanup; use App\Support\RemoteProcessCommand; +use App\Traits\BroadcastsToTeam; use Illuminate\Contracts\Process\ProcessResult; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -123,12 +124,7 @@ class RunRemoteProcess $this->activity->save(); if ($this->call_event_on_finish) { try { - $eventClass = "App\\Events\\$this->call_event_on_finish"; - if (! is_null($this->call_event_data)) { - event(new $eventClass($this->call_event_data)); - } else { - event(new $eventClass($this->activity->causer_id)); - } + self::dispatchFinishEvent($this->activity, $this->call_event_on_finish, $this->call_event_data); } catch (\Throwable $e) { Log::error('Error calling event: '.$e->getMessage()); } @@ -140,6 +136,25 @@ class RunRemoteProcess return $processResult; } + /** + * Dispatches the event that a remote process asked for when it finishes. + * + * Without explicit event data, a team event goes to the team of the server that ran the + * process. The causer is a user, so its id is only used for events on a user channel. + */ + public static function dispatchFinishEvent(Activity $activity, string $eventName, mixed $eventData = null): void + { + $eventClass = "App\\Events\\{$eventName}"; + + if (is_null($eventData)) { + $eventData = in_array(BroadcastsToTeam::class, class_uses_recursive($eventClass), true) + ? $activity->getExtraProperty('team_id') + : $activity->causer_id; + } + + event(new $eventClass($eventData)); + } + protected function getCommand(): string { $server_uuid = $this->activity->getExtraProperty('server_uuid'); diff --git a/app/Actions/Database/StartDatabaseImport.php b/app/Actions/Database/StartDatabaseImport.php index 642ff35216..eef0ebe929 100644 --- a/app/Actions/Database/StartDatabaseImport.php +++ b/app/Actions/Database/StartDatabaseImport.php @@ -5,6 +5,7 @@ namespace App\Actions\Database; use App\Models\S3Storage; use App\Models\Server; use App\Models\ServiceDatabase; +use App\Models\StandaloneSqlite; use App\Models\SwarmDocker; use App\Support\DatabaseBackupFileValidator; use App\Support\DatabaseImport\DatabaseImportCleanup; @@ -45,6 +46,7 @@ class StartDatabaseImport if (! str($resource->status)->startsWith('running')) { throw new DatabaseImportException('The database must be running before an import can start.'); } + $sqliteDatabase = $this->sqliteRestoreTarget($resource, $source); [$server, $container, $network] = $this->target($resource); $destination = $resource instanceof ServiceDatabase ? $resource->service?->destination : $resource->destination; @@ -71,14 +73,14 @@ class StartDatabaseImport throw new DatabaseImportException(ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE, 409); } - return $this->startImport($resource, $source, $teamId, $server, $container, $network); + return $this->startImport($resource, $source, $teamId, $server, $container, $network, $sqliteDatabase); } finally { DatabaseOperationReservation::release($resource->uuid, $reservation); $lock->release(); } } - private function startImport(Model $resource, DatabaseImportSource $source, int $teamId, Server $server, string $container, string $network): Activity + private function startImport(Model $resource, DatabaseImportSource $source, int $teamId, Server $server, string $container, string $network, ?string $sqliteDatabase): Activity { $active = ResourceStartActivity::active($resource->uuid, ResourceStartActivity::DATABASE_IMPORT_OPERATION, $teamId); if (ResourceStartActivity::failStale($active)->isNotEmpty()) { @@ -150,7 +152,7 @@ class StartDatabaseImport if ($safety = $this->commands->buildPostgresSafetyCommand($resource, $container, $containerPath)) { $commandList[] = $safety; } - $restore = base64_encode($this->commands->buildRestoreCommand($resource, $containerPath, $source->dumpAll, $source->replaceExisting)); + $restore = base64_encode($this->commands->buildRestoreCommand($resource, $containerPath, $source->dumpAll, $source->replaceExisting, $source->keepOwners, $sqliteDatabase)); $commandList[] = 'echo '.escapeshellarg($restore).' | base64 -d > '.escapeshellarg($scriptPath); $commandList[] = 'chmod +x '.escapeshellarg($scriptPath); $commandList[] = 'docker cp '.escapeshellarg($scriptPath).' '.escapeshellarg("{$container}:{$scriptPath}"); @@ -178,6 +180,24 @@ class StartDatabaseImport return [$resource->destination?->server, $resource->uuid, $resource->destination?->network ?? 'coolify']; } + /** + * The SQLite file a backup is restored into. It must be one of the database's own files; without + * a choice it is the file named in the backup path, else the first file. + */ + private function sqliteRestoreTarget(Model $resource, DatabaseImportSource $source): ?string + { + if (! $resource instanceof StandaloneSqlite) { + return null; + } + + $file = $source->sqliteDatabase ?? $resource->defaultRestoreFile($source->type === 'upload' ? null : $source->path); + if ($file === null || ! in_array($file, $resource->databaseFiles(), true)) { + throw new DatabaseImportException('The SQLite database file is not one of the files of this database.'); + } + + return $file; + } + private function assertServerPath(?string $path): void { if (! $path || ! str_starts_with($path, '/') || preg_match('/\.\.|[$()`|;&><\r\n\0\'"\\\\]/', $path) || ! DatabaseBackupFileValidator::hasAllowedExtension(basename($path))) { diff --git a/app/Http/Controllers/Api/Concerns/HandlesDatabaseImportsApi.php b/app/Http/Controllers/Api/Concerns/HandlesDatabaseImportsApi.php index a2ec2b93be..0028fbc3e4 100644 --- a/app/Http/Controllers/Api/Concerns/HandlesDatabaseImportsApi.php +++ b/app/Http/Controllers/Api/Concerns/HandlesDatabaseImportsApi.php @@ -61,7 +61,7 @@ trait HandlesDatabaseImportsApi { $this->authorize('update', $resource); $payload = $request->json()->all() ?: $request->request->all(); - $allowed = ['source', 'upload_id', 's3_storage_uuid', 'path', 'dump_all', 'replace_existing']; + $allowed = ['source', 'upload_id', 's3_storage_uuid', 'path', 'dump_all', 'replace_existing', 'keep_owners', 'sqlite_database']; $validator = Validator::make($payload, [ 'source' => ['required', Rule::in(['upload', 's3', 'server'])], 'upload_id' => ['required_if:source,upload', 'prohibited_unless:source,upload', 'uuid'], @@ -69,6 +69,8 @@ trait HandlesDatabaseImportsApi 'path' => ['required_if:source,s3,server', 'prohibited_if:source,upload', 'string', 'max:4096'], 'dump_all' => ['sometimes', 'boolean'], 'replace_existing' => ['sometimes', 'boolean'], + 'keep_owners' => ['sometimes', 'boolean'], + 'sqlite_database' => ['sometimes', 'string', 'max:255'], ]); $extraFields = array_diff(array_keys($payload), $allowed); if ($validator->fails() || ! empty($extraFields)) { @@ -81,7 +83,7 @@ trait HandlesDatabaseImportsApi } try { - $source = new DatabaseImportSource((string) $payload['source'], $payload['upload_id'] ?? null, $payload['path'] ?? null, $payload['s3_storage_uuid'] ?? null, (bool) ($payload['dump_all'] ?? false), (bool) ($payload['replace_existing'] ?? false)); + $source = new DatabaseImportSource((string) $payload['source'], $payload['upload_id'] ?? null, $payload['path'] ?? null, $payload['s3_storage_uuid'] ?? null, (bool) ($payload['dump_all'] ?? false), (bool) ($payload['replace_existing'] ?? false), (bool) ($payload['keep_owners'] ?? false), isset($payload['sqlite_database']) ? (string) $payload['sqlite_database'] : null); $activity = app(StartDatabaseImport::class)->handle($resource, $source, $teamId); } catch (DatabaseImportException $exception) { return response()->json(['message' => $exception->getMessage()], $exception->status); @@ -92,6 +94,7 @@ trait HandlesDatabaseImportsApi 'database_name' => $resource->name, 'source' => $source->type, 'replace_existing' => $source->replaceExisting, + 'keep_owners' => $source->keepOwners, 'activity_id' => $activity->id, ]); $url = route($statusRoute, [...$routeParameters, 'activity_id' => $activity->id], false); diff --git a/app/Http/Controllers/Api/OpenApi.php b/app/Http/Controllers/Api/OpenApi.php index 43ce742168..9da513c677 100644 --- a/app/Http/Controllers/Api/OpenApi.php +++ b/app/Http/Controllers/Api/OpenApi.php @@ -16,9 +16,9 @@ use OpenApi\Attributes as OA; new OA\Schema( schema: 'DatabaseImportRequest', oneOf: [ - new OA\Schema(required: ['source', 'upload_id'], additionalProperties: false, properties: [new OA\Property(property: 'source', type: 'string', enum: ['upload']), new OA\Property(property: 'upload_id', type: 'string', format: 'uuid'), new OA\Property(property: 'dump_all', type: 'boolean', default: false), new OA\Property(property: 'replace_existing', description: 'Drop matching PostgreSQL objects before restoring a single-database archive.', type: 'boolean', default: false)]), - new OA\Schema(required: ['source', 's3_storage_uuid', 'path'], additionalProperties: false, properties: [new OA\Property(property: 'source', type: 'string', enum: ['s3']), new OA\Property(property: 's3_storage_uuid', type: 'string'), new OA\Property(property: 'path', type: 'string'), new OA\Property(property: 'dump_all', type: 'boolean', default: false), new OA\Property(property: 'replace_existing', description: 'Drop matching PostgreSQL objects before restoring a single-database archive.', type: 'boolean', default: false)]), - new OA\Schema(required: ['source', 'path'], additionalProperties: false, properties: [new OA\Property(property: 'source', type: 'string', enum: ['server']), new OA\Property(property: 'path', type: 'string', example: '/var/backups/database.sql.gz'), new OA\Property(property: 'dump_all', type: 'boolean', default: false), new OA\Property(property: 'replace_existing', description: 'Drop matching PostgreSQL objects before restoring a single-database archive.', type: 'boolean', default: false)]), + new OA\Schema(required: ['source', 'upload_id'], additionalProperties: false, properties: [new OA\Property(property: 'source', type: 'string', enum: ['upload']), new OA\Property(property: 'upload_id', type: 'string', format: 'uuid'), new OA\Property(property: 'dump_all', type: 'boolean', default: false), new OA\Property(property: 'replace_existing', description: 'Drop matching PostgreSQL objects before restoring a single-database archive.', type: 'boolean', default: false), new OA\Property(property: 'keep_owners', description: 'Restore object owners and privileges from a single-database PostgreSQL archive. By default they are skipped, because the roles of another server usually do not exist.', type: 'boolean', default: false), new OA\Property(property: 'sqlite_database', description: 'SQLite: the database file to restore into. Must be one of the database files. Defaults to the file named in the backup file name, else the first file.', type: 'string', example: 'app.db')]), + new OA\Schema(required: ['source', 's3_storage_uuid', 'path'], additionalProperties: false, properties: [new OA\Property(property: 'source', type: 'string', enum: ['s3']), new OA\Property(property: 's3_storage_uuid', type: 'string'), new OA\Property(property: 'path', type: 'string'), new OA\Property(property: 'dump_all', type: 'boolean', default: false), new OA\Property(property: 'replace_existing', description: 'Drop matching PostgreSQL objects before restoring a single-database archive.', type: 'boolean', default: false), new OA\Property(property: 'keep_owners', description: 'Restore object owners and privileges from a single-database PostgreSQL archive. By default they are skipped, because the roles of another server usually do not exist.', type: 'boolean', default: false), new OA\Property(property: 'sqlite_database', description: 'SQLite: the database file to restore into. Must be one of the database files. Defaults to the file named in the backup file name, else the first file.', type: 'string', example: 'app.db')]), + new OA\Schema(required: ['source', 'path'], additionalProperties: false, properties: [new OA\Property(property: 'source', type: 'string', enum: ['server']), new OA\Property(property: 'path', type: 'string', example: '/var/backups/database.sql.gz'), new OA\Property(property: 'dump_all', type: 'boolean', default: false), new OA\Property(property: 'replace_existing', description: 'Drop matching PostgreSQL objects before restoring a single-database archive.', type: 'boolean', default: false), new OA\Property(property: 'keep_owners', description: 'Restore object owners and privileges from a single-database PostgreSQL archive. By default they are skipped, because the roles of another server usually do not exist.', type: 'boolean', default: false), new OA\Property(property: 'sqlite_database', description: 'SQLite: the database file to restore into. Must be one of the database files. Defaults to the file named in the backup file name, else the first file.', type: 'string', example: 'app.db')]), ], type: 'object', ), diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index b98dd42e7c..c55eb6c8f8 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -40,7 +40,29 @@ class ServersController extends Controller ]); } - return serializeApiResponse($settings); + return serializeApiResponse($settings) + ->put('is_build_server', $settings->effectiveServerRole() === ServerRole::BUILD); + } + + /** + * Rejects requests where the deprecated is_build_server flag disagrees with server_role. + */ + private function legacyBuildServerRoleConflict(Request $request): ?JsonResponse + { + if (! $request->filled('is_build_server') || ! $request->filled('server_role')) { + return null; + } + + $wantsBuildOnly = $request->boolean('is_build_server'); + $roleIsBuildOnly = $request->string('server_role')->toString() === ServerRole::BUILD->value; + if ($wantsBuildOnly === $roleIsBuildOnly) { + return null; + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['is_build_server' => ['is_build_server is deprecated and conflicts with server_role. Send only server_role.']], + ], 422); } private function removeSensitiveData($server) @@ -452,6 +474,7 @@ class ServersController extends Controller 'user' => ['type' => 'string', 'example' => 'root', 'description' => 'The user of the server.'], 'private_key_uuid' => ['type' => 'string', 'example' => 'og888os', 'description' => 'The UUID of the private key.'], 'server_role' => ['type' => 'string', 'enum' => ['deployment', 'build', 'both'], 'example' => 'both', 'description' => 'Server role.'], + 'is_build_server' => ['type' => 'boolean', 'deprecated' => true, 'description' => 'Deprecated: use server_role instead. true sets server_role to build, false sets it to both. Must not conflict with server_role.'], 'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Instant validate.'], 'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'example' => 'traefik', 'description' => 'The proxy type.'], ], @@ -493,7 +516,7 @@ class ServersController extends Controller )] public function create_server(Request $request) { - $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'server_role', 'instant_validate', 'proxy_type']; + $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'server_role', 'is_build_server', 'instant_validate', 'proxy_type']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -513,6 +536,7 @@ class ServersController extends Controller 'private_key_uuid' => 'string|required', 'user' => ValidationPatterns::serverUsernameRules(required: false), 'server_role' => 'string|nullable|in:deployment,build,both', + 'is_build_server' => 'boolean|nullable', 'instant_validate' => 'boolean|nullable', 'proxy_type' => 'string|nullable', ], [ @@ -542,9 +566,15 @@ class ServersController extends Controller if (is_null($request->port)) { $request->offsetSet('port', 22); } - $serverRole = $request->filled('server_role') - ? ServerRole::from($request->string('server_role')->toString()) - : ServerRole::BOTH; + $legacyRoleConflict = $this->legacyBuildServerRoleConflict($request); + if ($legacyRoleConflict) { + return $legacyRoleConflict; + } + $serverRole = match (true) { + $request->filled('server_role') => ServerRole::from($request->string('server_role')->toString()), + $request->filled('is_build_server') && $request->boolean('is_build_server') => ServerRole::BUILD, + default => ServerRole::BOTH, + }; if ($serverRole === ServerRole::DEPLOYMENT && ! ModelsServer::buildServers($teamId)->exists()) { return response()->json([ @@ -639,6 +669,7 @@ class ServersController extends Controller 'user' => ['type' => 'string', 'description' => 'The user of the server.'], 'private_key_uuid' => ['type' => 'string', 'description' => 'The UUID of the private key.'], 'server_role' => ['type' => 'string', 'enum' => ['deployment', 'build', 'both'], 'description' => 'Server role.'], + 'is_build_server' => ['type' => 'boolean', 'deprecated' => true, 'description' => 'Deprecated: use server_role instead. true sets server_role to build, false changes a build server to both and leaves other roles unchanged. Must not conflict with server_role.'], 'instant_validate' => ['type' => 'boolean', 'description' => 'Instant validate.'], 'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'description' => 'The proxy type.'], 'concurrent_builds' => ['type' => 'integer', 'description' => 'Number of concurrent builds.'], @@ -684,7 +715,7 @@ class ServersController extends Controller )] public function update_server(Request $request) { - $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'server_role', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'server_disk_usage_notification_interval_hours', 'connection_timeout', 'is_terminal_enabled']; + $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'server_role', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'server_disk_usage_notification_interval_hours', 'connection_timeout', 'is_terminal_enabled']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -703,6 +734,7 @@ class ServersController extends Controller 'private_key_uuid' => 'string|nullable', 'user' => ValidationPatterns::serverUsernameRules(required: false), 'server_role' => 'string|nullable|in:deployment,build,both', + 'is_build_server' => 'boolean|nullable', 'instant_validate' => 'boolean|nullable', 'proxy_type' => 'string|nullable', 'concurrent_builds' => 'integer|min:1', @@ -760,9 +792,21 @@ class ServersController extends Controller ], 422); } + $legacyRoleConflict = $this->legacyBuildServerRoleConflict($request); + if ($legacyRoleConflict) { + return $legacyRoleConflict; + } + $serverRole = null; if ($request->filled('server_role')) { $serverRole = ServerRole::from($request->string('server_role')->toString()); + } elseif ($request->filled('is_build_server')) { + // Legacy flag: true makes the server build only, false turns a build only server back into a regular one. + if ($request->boolean('is_build_server')) { + $serverRole = ServerRole::BUILD; + } elseif ($server->isBuildServer()) { + $serverRole = ServerRole::BOTH; + } } if ($serverRole === ServerRole::BUILD && ! $server->isBuildServer() && ! $server->isEmpty()) { diff --git a/app/Http/Controllers/OauthController.php b/app/Http/Controllers/OauthController.php index c837cc49c8..6d0c57f4df 100644 --- a/app/Http/Controllers/OauthController.php +++ b/app/Http/Controllers/OauthController.php @@ -4,11 +4,15 @@ namespace App\Http\Controllers; use App\Models\OauthSetting; use App\Services\Auth\OauthLoginService; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; use Symfony\Component\HttpKernel\Exception\HttpException; class OauthController extends Controller { + public const string PASSWORD_CONFIRMATION_SESSION_KEY = 'oauth.password_confirmation'; + public function redirect(string $provider) { $oauthSetting = $this->enabledProvider($provider); @@ -17,8 +21,40 @@ class OauthController extends Controller return $socialiteProvider->redirect(); } - public function callback(string $provider, OauthLoginService $oauthLoginService) + /** + * Start confirming a destructive action by re-authenticating the logged-in + * user through an OAuth provider the user has a linked identity with. + */ + public function confirm(Request $request, string $provider) { + $user = $request->user(); + $oauthSetting = $this->enabledProvider($provider); + if (! $user->oauthIdentities()->where('provider', $oauthSetting->provider)->exists()) { + abort(403, 'No identity from this provider is linked to your account.'); + } + + $request->session()->put(self::PASSWORD_CONFIRMATION_SESSION_KEY, [ + 'user_id' => $user->getKey(), + 'provider' => $oauthSetting->provider, + 'return_to' => $this->sameOriginUrl(url()->previous()), + ]); + + $socialiteProvider = get_socialite_provider($oauthSetting->provider); + $parameters = socialite_reauthentication_parameters($oauthSetting); + if ($parameters !== []) { + $socialiteProvider->with($parameters); + } + + return $socialiteProvider->redirect(); + } + + public function callback(Request $request, string $provider, OauthLoginService $oauthLoginService) + { + $pendingConfirmation = $request->session()->pull(self::PASSWORD_CONFIRMATION_SESSION_KEY); + if (is_array($pendingConfirmation) && $request->user()) { + return $this->confirmCallback($request, $provider, $pendingConfirmation, $oauthLoginService); + } + try { $oauthSetting = $this->enabledProvider($provider); $oauthUser = get_socialite_provider($oauthSetting->provider)->user(); @@ -38,6 +74,67 @@ class OauthController extends Controller } } + /** + * Complete an OAuth confirmation. The returned identity must already be + * linked to the logged-in user; the session is never switched to another + * user and no identity is linked or updated. + * + * @param array{user_id?: mixed, provider?: mixed, return_to?: mixed} $pendingConfirmation + */ + private function confirmCallback(Request $request, string $provider, array $pendingConfirmation, OauthLoginService $oauthLoginService): RedirectResponse + { + $user = $request->user(); + + try { + $oauthSetting = $this->enabledProvider($provider); + if ( + data_get($pendingConfirmation, 'user_id') !== $user->getKey() + || data_get($pendingConfirmation, 'provider') !== $oauthSetting->provider + ) { + throw new HttpException(403, 'OAuth confirmation does not match the current session'); + } + + $oauthUser = get_socialite_provider($oauthSetting->provider)->user(); + if (! $oauthLoginService->identityBelongsToUser($user, $oauthSetting, $oauthUser)) { + throw new HttpException(403, 'OAuth identity is not linked to the current user'); + } + } catch (\Exception $e) { + auditLog('auth.oauth.password_confirmation_failed', [ + 'provider' => $provider, + 'actor_id' => $user->getKey(), + 'actor_email' => $user->email, + 'exception_class' => $e::class, + ], 'warning'); + Log::warning('OAuth password confirmation failed.', [ + 'provider' => $provider, + 'user_id' => $user->getKey(), + 'exception_class' => $e::class, + 'exception_message' => $e->getMessage(), + ]); + + abort(403, 'Confirmation failed. Sign in with the account linked to your Coolify user.'); + } + + $request->session()->passwordConfirmed(); + auditLog('auth.oauth.password_confirmed', [ + 'provider' => $oauthSetting->provider, + 'actor_id' => $user->getKey(), + 'actor_email' => $user->email, + ]); + + return redirect()->to($this->sameOriginUrl(data_get($pendingConfirmation, 'return_to'))); + } + + private function sameOriginUrl(mixed $url): string + { + $home = url('/'); + if (! is_string($url) || ($url !== $home && ! str_starts_with($url, $home.'/') && ! str_starts_with($url, $home.'?'))) { + return $home; + } + + return $url; + } + private function logCallbackFailure(string $provider, \Throwable $exception): void { auditLog('auth.oauth.callback_failed', [ diff --git a/app/Http/Middleware/DecideWhatToDoWithUser.php b/app/Http/Middleware/DecideWhatToDoWithUser.php index 6babdb69a0..603ba1ed8f 100644 --- a/app/Http/Middleware/DecideWhatToDoWithUser.php +++ b/app/Http/Middleware/DecideWhatToDoWithUser.php @@ -12,6 +12,13 @@ class DecideWhatToDoWithUser { public function handle(Request $request, Closure $next): Response { + // OAuth confirmation of destructive actions only re-authenticates the + // logged-in user and returns to the page it started from, so it must not + // be redirected to onboarding, subscription, or team selection. + if ($request->routeIs('auth.confirm', 'auth.callback')) { + return $next($request); + } + if (auth()?->user()?->teams?->count() === 0) { $currentTeam = auth()->user()?->recreate_personal_team(); refreshSession($currentTeam); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index f21e2bdb39..e9203cbab4 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -431,6 +431,17 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue { $this->build_server = $this->server; + // An additional server of a multi-server application does not build: it pulls the image the main server pushed. + if ($this->is_this_additional_server && str($this->application->docker_registry_image_name)->isNotEmpty()) { + if (! $this->server->canBuildApplications()) { + // Disabled build cache forces a rebuild, which would skip the registry pull on a server that cannot build. + $this->force_rebuild = false; + } + $this->application_deployment_queue->addLogEntry('Additional server: pulls the image from the registry, no build server needed.'); + + return; + } + // A deployments-only server never builds. Docker image and Compose applications are exempt: // the first builds nothing and the second does not support build servers. $mustBuildElsewhere = ! $this->server->canBuildApplications() @@ -1443,6 +1454,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->application_deployment_queue->addLogEntry('Build configuration changed. Rebuilding image.'); } } else { + if ($this->is_this_additional_server && ! $this->server->canBuildApplications() && $this->application->build_pack !== 'dockercompose') { + throw new DeploymentException("Image ({$this->production_image_name}) not found in the registry; the main server must push it first. The server ({$this->server->name}) is set to deployments only and cannot build it."); + } $this->application_deployment_queue->addLogEntry("Image not found ({$this->production_image_name}). Building new image."); } if ($this->restart_only) { diff --git a/app/Jobs/CheckMissingDatabaseBackupsJob.php b/app/Jobs/CheckMissingDatabaseBackupsJob.php index 957868f44d..61e7ced397 100644 --- a/app/Jobs/CheckMissingDatabaseBackupsJob.php +++ b/app/Jobs/CheckMissingDatabaseBackupsJob.php @@ -51,10 +51,11 @@ class CheckMissingDatabaseBackupsJob implements ShouldBeEncrypted, ShouldBeUniqu } // Send once for each period without backup activity. - if (! NotificationThrottle::claim($backup, BackupMissing::class, $lastActivityAt)) { - return; - } - - $backup->team->notify(new BackupMissing($backup, $lastExecutionAt)); + NotificationThrottle::sendOnce( + $backup, + BackupMissing::class, + $lastActivityAt, + fn () => $backup->team->notify(new BackupMissing($backup, $lastExecutionAt)), + ); } } diff --git a/app/Jobs/ConfigureDnsRecordJob.php b/app/Jobs/ConfigureDnsRecordJob.php index f6d89ca531..7430e45807 100644 --- a/app/Jobs/ConfigureDnsRecordJob.php +++ b/app/Jobs/ConfigureDnsRecordJob.php @@ -5,6 +5,7 @@ namespace App\Jobs; use App\Events\DnsRecordConfigurationFinished; use App\Models\DnsProviderZone; use App\Services\Dns\CloudflareDnsProvider; +use App\Support\DnsRecordHints; use Illuminate\Contracts\Cache\LockTimeoutException; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Database\Eloquent\Model; @@ -41,6 +42,12 @@ class ConfigureDnsRecordJob implements ShouldQueue ->whereHas('integrationToken', fn ($query) => $query->where('team_id', $this->teamId)) ->firstOrFail(); + if (! DnsRecordHints::isPublicAddress($this->content)) { + $this->finished($zone, false, DnsRecordHints::NO_PUBLIC_ADDRESS_MESSAGE); + + return; + } + try { $provider->createRecord($zone, $this->hostname, $this->content, $this->resource()); } catch (LockTimeoutException) { diff --git a/app/Jobs/CoolifyTask.php b/app/Jobs/CoolifyTask.php index 59bec472cb..c19b61e19f 100755 --- a/app/Jobs/CoolifyTask.php +++ b/app/Jobs/CoolifyTask.php @@ -113,12 +113,7 @@ class CoolifyTask implements ShouldBeEncrypted, ShouldQueue // Dispatch cleanup event on failure (same as on success) if ($this->call_event_on_finish) { try { - $eventClass = "App\\Events\\$this->call_event_on_finish"; - if (! is_null($this->call_event_data)) { - event(new $eventClass($this->call_event_data)); - } else { - event(new $eventClass($this->activity->causer_id)); - } + RunRemoteProcess::dispatchFinishEvent($this->activity, $this->call_event_on_finish, $this->call_event_data); Log::info('Cleanup event dispatched after job failure', [ 'event' => $this->call_event_on_finish, ]); diff --git a/app/Livewire/Concerns/InteractsWithDnsProviders.php b/app/Livewire/Concerns/InteractsWithDnsProviders.php index a82a152818..bf5cd4c609 100644 --- a/app/Livewire/Concerns/InteractsWithDnsProviders.php +++ b/app/Livewire/Concerns/InteractsWithDnsProviders.php @@ -9,6 +9,7 @@ use App\Models\DnsProviderZone; use App\Models\ManagedDnsRecord; use App\Services\Dns\CloudflareDnsProvider; use App\Services\Dns\ManagedDnsRecordCleanup; +use App\Support\DnsRecordHints; use Illuminate\Contracts\Cache\LockTimeoutException; use Illuminate\Database\Eloquent\Model; use Livewire\Attributes\Locked; @@ -48,6 +49,11 @@ trait InteractsWithDnsProviders $this->authorizeDnsProviderChange(); $hostname = strtolower($hostname); $cloudflare = app(CloudflareDnsProvider::class); + if ($this->publicServerIpsForDnsProvider() === []) { + $this->dispatch('error', DnsRecordHints::NO_PUBLIC_ADDRESS_MESSAGE); + + return; + } $zone = $this->findTeamZone($zoneId, $hostname); $content = $this->dnsRecordContent($content); if ($zone === null || $content === null) { @@ -102,6 +108,12 @@ trait InteractsWithDnsProviders if (blank($this->serverIp) || filter_var($this->serverIp, FILTER_VALIDATE_IP) === false) { return false; } + $content = $this->dnsRecordContent(); + if ($content === null) { + $this->dispatch('warning', DnsRecordHints::NO_PUBLIC_ADDRESS_MESSAGE); + + return false; + } $this->markDnsPending($hostnames); $proposalsByHostname = collect($this->dnsProviderProposals)->groupBy('hostname'); @@ -134,7 +146,7 @@ trait InteractsWithDnsProviders $resource?->getMorphClass(), $resource?->getKey(), $proposal['hostname'], - $this->serverIp, + $content, ); $this->dispatch('info', "Adding DNS record for {$proposal['hostname']}."); } @@ -155,6 +167,11 @@ trait InteractsWithDnsProviders $hostname = strtolower($hostname); $key = $hostname.'|'.$zoneId; $conflict = $this->dnsProviderConflicts[$key] ?? null; + if ($this->publicServerIpsForDnsProvider() === []) { + $this->dispatch('error', DnsRecordHints::NO_PUBLIC_ADDRESS_MESSAGE); + + return; + } $zone = $this->findTeamZone($zoneId, $hostname); $content = $this->dnsRecordContent($conflict['proposed'] ?? null); if ($conflict === null || $zone === null || $content === null) { @@ -188,11 +205,12 @@ trait InteractsWithDnsProviders $provider = app(CloudflareDnsProvider::class); $hostnames ??= $this->allDomainHostnames(); $teamId = $this->dnsTeamId(); + $target = $this->dnsRecordContent(); $managed = ManagedDnsRecord::query()->where('team_id', $teamId)->whereIn('name', $hostnames)->pluck('id', 'name'); $this->dnsProviderProposals = collect($hostnames)->flatMap(fn (string $hostname) => $provider->findZones($teamId, $hostname) ->map(fn (DnsProviderZone $zone) => [ 'hostname' => $hostname, 'zone_id' => $zone->id, 'zone' => $zone->name, - 'credential' => $zone->integrationToken->name, 'target' => (string) $this->serverIp, + 'credential' => $zone->integrationToken->name, 'target' => $target, 'managed' => $managed->has($hostname), ])->all())->values()->all(); } @@ -353,12 +371,26 @@ trait InteractsWithDnsProviders } /** - * Content of a DNS record for this resource: one of its server addresses (IPv4 or IPv6), + * Server addresses that a DNS provider may publish. Private, reserved, CGNAT, and link-local addresses are excluded: + * a public DNS record must not expose them, and the record would not be reachable anyway. + * + * @return array + */ + protected function publicServerIpsForDnsProvider(): array + { + return array_values(array_filter( + $this->serverIpsForDnsHints(), + fn (?string $address): bool => is_string($address) && DnsRecordHints::isPublicAddress($address), + )); + } + + /** + * Content of a DNS record for this resource: one of its public server addresses (IPv4 or IPv6), * the first one when none is requested. Any other address is rejected. */ protected function dnsRecordContent(?string $requested = null): ?string { - $addresses = array_values(array_filter($this->serverIpsForDnsHints())); + $addresses = $this->publicServerIpsForDnsProvider(); return $requested === null ? ($addresses[0] ?? null) : (in_array($requested, $addresses, true) ? $requested : null); } diff --git a/app/Livewire/Project/Database/ImportForm.php b/app/Livewire/Project/Database/ImportForm.php index f13f2e1313..96d4366e7b 100644 --- a/app/Livewire/Project/Database/ImportForm.php +++ b/app/Livewire/Project/Database/ImportForm.php @@ -11,6 +11,7 @@ use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; use App\Models\StandaloneRedis; +use App\Models\StandaloneSqlite; use App\Rules\SafeWebhookUrl; use App\Support\DatabaseImport\DatabaseImportCommandBuilder; use App\Support\DatabaseImport\DatabaseImportException; @@ -159,6 +160,16 @@ class ImportForm extends Component public bool $replaceExisting = false; + /** + * PostgreSQL archives: restore owners and privileges instead of skipping them. + */ + public bool $keepOwners = false; + + /** + * SQLite: the database file to restore into. Always one of the database's own files. + */ + public ?string $sqliteDatabase = null; + public string $restoreCommandText = ''; public string $customLocation = ''; @@ -184,6 +195,15 @@ class ImportForm extends Component return $this->resourceType::find($this->resourceId); } + /** + * @return list + */ + #[Computed] + public function sqliteDatabaseFiles(): array + { + return $this->resource instanceof StandaloneSqlite ? $this->resource->databaseFiles() : []; + } + #[Computed] public function server() { @@ -208,6 +228,12 @@ class ImportForm extends Component $this->parameters = get_route_parameters(); $this->getContainers(); $this->loadAvailableS3Storages(); + $this->initializeRestoreOptions(); + } + + protected function initializeRestoreOptions(): void + { + $this->sqliteDatabase = $this->sqliteDatabaseFiles[0] ?? null; $this->refreshRestoreCommandText(); } @@ -221,6 +247,31 @@ class ImportForm extends Component $this->refreshRestoreCommandText(); } + public function updatedKeepOwners(): void + { + $this->refreshRestoreCommandText(); + } + + public function updatedSqliteDatabase(): void + { + if (! in_array($this->sqliteDatabase, $this->sqliteDatabaseFiles, true)) { + $this->sqliteDatabase = $this->sqliteDatabaseFiles[0] ?? null; + } + $this->refreshRestoreCommandText(); + } + + /** + * Preselects the SQLite file whose name matches the chosen backup, else the first file. + */ + public function selectSqliteDatabaseFor(?string $backupName = null): void + { + if (! $this->resource instanceof StandaloneSqlite) { + return; + } + $this->sqliteDatabase = $this->resource->defaultRestoreFile($backupName); + $this->refreshRestoreCommandText(); + } + /** * Shows the exact script the import runs, so the confirmation matches the restore. */ @@ -228,9 +279,13 @@ class ImportForm extends Component { $commands = app(DatabaseImportCommandBuilder::class); - $this->restoreCommandText = $this->resource && $commands->supports($this->resource) - ? $commands->buildRestoreCommand($this->resource, '', $this->dumpAll, $this->replaceExisting) - : ''; + try { + $this->restoreCommandText = $this->resource && $commands->supports($this->resource) + ? $commands->buildRestoreCommand($this->resource, '', $this->dumpAll, $this->replaceExisting, $this->keepOwners, $this->sqliteDatabase) + : ''; + } catch (\InvalidArgumentException) { + $this->restoreCommandText = ''; + } } public function getContainers() @@ -361,6 +416,7 @@ class ImportForm extends Component return; } $this->filename = $this->customLocation; + $this->selectSqliteDatabaseFor($this->customLocation); $this->dispatch('success', 'The file exists.'); } catch (\Throwable $e) { return handleError($e, $this); @@ -397,8 +453,8 @@ class ImportForm extends Component try { $this->importRunning = true; $source = Storage::exists("upload/{$this->resourceUuid}/restore") - ? new DatabaseImportSource('upload', dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting) - : new DatabaseImportSource('server', path: $this->customLocation, dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting); + ? new DatabaseImportSource('upload', dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting, keepOwners: $this->keepOwners, sqliteDatabase: $this->sqliteDatabase) + : new DatabaseImportSource('server', path: $this->customLocation, dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting, keepOwners: $this->keepOwners, sqliteDatabase: $this->sqliteDatabase); $activity = StartDatabaseImport::run($this->resource, $source, (int) currentTeam()->id); $this->activityId = $activity->id; $this->dispatch('activityMonitor', $activity->id); @@ -514,6 +570,7 @@ class ImportForm extends Component // Get file size $this->s3FileSize = $disk->size($cleanPath); + $this->selectSqliteDatabaseFor($cleanPath); $this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize)); } catch (\Throwable $e) { @@ -557,7 +614,7 @@ class ImportForm extends Component try { $this->importRunning = true; - $source = new DatabaseImportSource('s3', path: $this->s3Path, s3StorageUuid: (string) $this->s3StorageId, dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting); + $source = new DatabaseImportSource('s3', path: $this->s3Path, s3StorageUuid: (string) $this->s3StorageId, dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting, keepOwners: $this->keepOwners, sqliteDatabase: $this->sqliteDatabase); $activity = StartDatabaseImport::run($this->resource, $source, (int) currentTeam()->id); $this->activityId = $activity->id; $this->dispatch('activityMonitor', $activity->id); @@ -586,6 +643,6 @@ class ImportForm extends Component public function buildRestoreCommand(string $tmpPath): string { - return app(DatabaseImportCommandBuilder::class)->buildRestoreCommand($this->resource, $tmpPath, $this->dumpAll, $this->replaceExisting); + return app(DatabaseImportCommandBuilder::class)->buildRestoreCommand($this->resource, $tmpPath, $this->dumpAll, $this->replaceExisting, $this->keepOwners, $this->sqliteDatabase); } } diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 072f51cd9e..08f8f870d5 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -640,8 +640,17 @@ class LocalFileVolume extends BaseModel continue; } - $resolvedSource = replaceLocalSource(str((string) $source), str($this->composeSourceDirectory())); - if (normalizeUnixPath($resolvedSource->value()) === normalizeUnixPath($this->fs_path)) { + $sourceDirectory = str($this->composeSourceDirectory()); + $fsPath = normalizeUnixPath($this->fs_path); + if (normalizeUnixPath(legacyReplaceLocalSource(str((string) $source), $sourceDirectory)->value()) === $fsPath) { + return true; + } + try { + $resolvedSource = replaceLocalSource(str((string) $source), $sourceDirectory)->value(); + } catch (\Throwable) { + continue; + } + if (normalizeUnixPath($resolvedSource) === $fsPath) { return true; } } diff --git a/app/Models/NotificationThrottle.php b/app/Models/NotificationThrottle.php index 46340f9fd3..b33a6ac5f7 100644 --- a/app/Models/NotificationThrottle.php +++ b/app/Models/NotificationThrottle.php @@ -4,8 +4,10 @@ namespace App\Models; use App\Contracts\ThrottledNotification; use Carbon\CarbonInterface; +use Closure; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo; +use Throwable; /** * Records when a notification was last sent for a resource, so repeated checks @@ -62,6 +64,29 @@ class NotificationThrottle extends Model ]) === 1; } + /** + * Claim the throttle, then run $send. When $send throws, the claim is released so the + * next attempt is not throttled, and the exception is rethrown. + * + * Returns false when the claim failed and $send did not run. + */ + public static function sendOnce(Model $notifiable, string $notification, ?CarbonInterface $sentBefore, Closure $send): bool + { + if (! static::claim($notifiable, $notification, $sentBefore)) { + return false; + } + + try { + $send(); + } catch (Throwable $exception) { + static::release($notifiable, $notification); + + throw $exception; + } + + return true; + } + /** * Claim the throttle a notification declares. Notifications without a throttle subject are always allowed. */ diff --git a/app/Models/OauthSetting.php b/app/Models/OauthSetting.php index 7765e41160..06be4c043a 100644 --- a/app/Models/OauthSetting.php +++ b/app/Models/OauthSetting.php @@ -82,6 +82,23 @@ class OauthSetting extends Model return __("auth.login.{$this->provider}"); } + /** + * Human-readable provider name, e.g. for "Confirm with Google". + */ + public function providerName(): string + { + if ($this->isOidc()) { + return filled($this->custom_label) ? $this->custom_label : 'SSO'; + } + + return match ($this->provider) { + 'azure' => 'Microsoft', + 'github' => 'GitHub', + 'gitlab' => 'GitLab', + default => str($this->provider)->headline()->value(), + }; + } + public function isOidc(): bool { return $this->provider === 'oidc'; diff --git a/app/Models/Server.php b/app/Models/Server.php index 28e00cc3a9..abce83cd37 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -1815,9 +1815,7 @@ $siteAddress { public function sendUnreachableNotification() { - if (NotificationThrottle::claim($this, Unreachable::class)) { - $this->team->notify(new Unreachable($this)); - } + NotificationThrottle::sendOnce($this, Unreachable::class, null, fn () => $this->team->notify(new Unreachable($this))); } public function validateConnection(bool $justCheckingNewKey = false) diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index defb842dda..4f7e76bbd6 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -22,6 +22,7 @@ use OpenApi\Attributes as OA; 'force_disabled' => ['type' => 'boolean'], 'force_server_cleanup' => ['type' => 'boolean'], 'server_role' => ['type' => 'string', 'enum' => ['deployment', 'build', 'both']], + 'is_build_server' => ['type' => 'boolean', 'deprecated' => true, 'readOnly' => true, 'description' => 'Deprecated: use server_role instead. true when server_role is build.'], 'is_cloudflare_tunnel' => ['type' => 'boolean'], 'is_jump_server' => ['type' => 'boolean'], 'is_logdrain_axiom_enabled' => ['type' => 'boolean'], diff --git a/app/Models/StandaloneSqlite.php b/app/Models/StandaloneSqlite.php index 746bc97905..81726497e9 100644 --- a/app/Models/StandaloneSqlite.php +++ b/app/Models/StandaloneSqlite.php @@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Collection; +use InvalidArgumentException; class StandaloneSqlite extends BaseModel { @@ -272,9 +273,52 @@ class StandaloneSqlite extends BaseModel return 'standalone-sqlite'; } - public function databaseFilePath(): string + /** + * The database file names configured for this database, in their configured order. + * + * @return list + */ + public function databaseFiles(): array { - return self::DATA_DIRECTORY.'/'.str($this->sqlite_databases)->before(',')->trim(); + return collect(explode(',', (string) $this->sqlite_databases)) + ->map(fn (string $file): string => trim($file)) + ->filter(fn (string $file): bool => $file !== '' && preg_match(self::DATABASES_PATTERN, $file) === 1) + ->values() + ->all(); + } + + /** + * The absolute path of one of this database's own files: the given file, or the first file. + * + * @throws InvalidArgumentException when the file is not one of this database's files + */ + public function databaseFilePath(?string $file = null): string + { + $files = $this->databaseFiles(); + $file ??= $files[0] ?? null; + + if ($file === null || ! in_array($file, $files, true)) { + throw new InvalidArgumentException('The SQLite database file is not one of the files of this database.'); + } + + return self::DATA_DIRECTORY.'/'.$file; + } + + /** + * The file a backup is restored into by default: the file whose name appears in the + * backup file name (Coolify names backups sqlite-backup--.gz), else the first file. + */ + public function defaultRestoreFile(?string $backupName): ?string + { + $files = $this->databaseFiles(); + $backupName = basename((string) $backupName); + + $matches = array_filter($files, fn (string $file): bool => $backupName === $file + || str_starts_with($backupName, $file.'.') + || str_starts_with($backupName, "sqlite-backup-{$file}-")); + usort($matches, fn (string $a, string $b): int => strlen($b) <=> strlen($a)); + + return $matches[0] ?? $files[0] ?? null; } public function environment() diff --git a/app/Models/Team.php b/app/Models/Team.php index da08882f6b..349c5b5681 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -285,14 +285,23 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen /** * Send a notification, unless it is throttled and was already sent within its interval. + * A throttle claim is released when sending throws, so a failed send is retried on the next check. */ public function notify($instance): void { - if ($instance instanceof ThrottledNotification && ! NotificationThrottle::claimFor($instance)) { + $subject = $instance instanceof ThrottledNotification ? $instance->throttleSubject() : null; + if ($subject === null) { + $this->sendNotification($instance); + return; } - $this->sendNotification($instance); + NotificationThrottle::sendOnce( + $subject, + $instance::class, + now()->subMinutes($instance->throttleIntervalMinutes()), + fn () => $this->sendNotification($instance), + ); } public function environment_variables() diff --git a/app/Models/User.php b/app/Models/User.php index 466d1a7894..a66147d941 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -10,6 +10,7 @@ use App\Notifications\TransactionalEmails\ResetPassword as TransactionalEmailsRe use App\Services\ChangelogService; use App\Traits\DeletesUserSessions; use DateTimeInterface; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; @@ -578,8 +579,35 @@ class User extends Authenticatable implements SendsEmail return ! empty($this->password); } + /** + * Whether destructive actions must be confirmed. Users with a password + * confirm with it; users with a linked OAuth identity can also confirm by + * re-authenticating through their provider. Only users with neither have + * no way to confirm. + */ public function requiresPasswordConfirmation(): bool { - return $this->hasPassword() && ! $this->hasSsoIdentity(); + return $this->hasPassword() || $this->hasSsoIdentity(); + } + + /** + * Enabled OAuth providers the user can re-authenticate with to confirm + * destructive actions. + * + * @return Collection + */ + public function oauthConfirmationProviders(): Collection + { + $providers = $this->oauthIdentities()->distinct()->pluck('provider'); + if ($providers->isEmpty()) { + return new Collection; + } + + return OauthSetting::query() + ->whereIn('provider', $providers) + ->where('enabled', true) + ->get() + ->filter(fn (OauthSetting $setting): bool => $setting->couldBeEnabled()) + ->values(); } } diff --git a/app/Services/Auth/OauthLoginService.php b/app/Services/Auth/OauthLoginService.php index b7cdfc1e4a..b9e5e82a21 100644 --- a/app/Services/Auth/OauthLoginService.php +++ b/app/Services/Auth/OauthLoginService.php @@ -28,8 +28,19 @@ class OauthLoginService ? $this->resolveOidcUser($oauthUser, $oauthSetting, $email) : $this->resolveOauthUser($oauthUser, $oauthSetting, $email); - $team = $user->currentTeam() ?? $user->teams()->first() ?? $user->recreate_personal_team(); - session(['currentTeam' => $team]); + // Choose the team like the password login: restore the last active team, + // or the sole team. A multi-team user without a valid stored choice gets + // no session team, so DecideWhatToDoWithUser shows the team selection. + $user->unsetRelation('teams'); + $team = $user->resolveStoredTeam(); + if (! $team && $user->teams->isEmpty()) { + $team = $user->recreate_personal_team(); + } + if ($team) { + session(['currentTeam' => $team]); + } else { + session()->forget('currentTeam'); + } if ($this->requiresTwoFactorChallenge($user)) { Auth::logout(); @@ -61,6 +72,50 @@ class OauthLoginService return $user->hasEnabledTwoFactorAuthentication(); } + /** + * Whether the identity returned by the provider is already linked to the + * given user. Used to confirm destructive actions through OAuth: it never + * links, updates, or creates identities or users, and never logs anyone in. + */ + public function identityBelongsToUser(User $user, OauthSetting $oauthSetting, object $oauthUser): bool + { + if ($oauthSetting->provider === 'oidc') { + [$issuer, $subject] = $this->oidcIssuerAndSubject($oauthUser); + $providerUserId = $subject; + } else { + $issuer = $oauthSetting->provider; + $providerUserId = $oauthUser->id ?? null; + if (is_int($providerUserId)) { + $providerUserId = (string) $providerUserId; + } + } + + if (! is_string($issuer) || $issuer === '' || ! is_string($providerUserId) || trim($providerUserId) === '') { + return false; + } + + return $user->oauthIdentities() + ->where('provider', $oauthSetting->provider) + ->where('issuer', $issuer) + ->where('provider_user_id', $providerUserId) + ->exists(); + } + + /** + * @return array{0: mixed, 1: mixed} + */ + private function oidcIssuerAndSubject(object $oauthUser): array + { + $issuer = $oauthUser instanceof OidcUser && filled($oauthUser->issuer) + ? $oauthUser->issuer + : data_get($oauthUser->user, 'iss'); + $subject = $oauthUser instanceof OidcUser && filled($oauthUser->subject) + ? $oauthUser->subject + : data_get($oauthUser->user, 'sub', $oauthUser->id); + + return [$issuer, $subject]; + } + private function resolveOauthUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User { $provider = $oauthSetting->provider; @@ -162,12 +217,7 @@ class OauthLoginService private function resolveOidcUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User { - $issuer = $oauthUser instanceof OidcUser && filled($oauthUser->issuer) - ? $oauthUser->issuer - : data_get($oauthUser->user, 'iss'); - $subject = $oauthUser instanceof OidcUser && filled($oauthUser->subject) - ? $oauthUser->subject - : data_get($oauthUser->user, 'sub', $oauthUser->id); + [$issuer, $subject] = $this->oidcIssuerAndSubject($oauthUser); $emailVerified = ($oauthUser instanceof OidcUser && $oauthUser->emailVerified) || data_get($oauthUser->user, 'email_verified') === true; diff --git a/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php b/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php index 7d598b46e3..5b5260c6ce 100644 --- a/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php +++ b/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php @@ -25,14 +25,19 @@ use_single_tar_member() { [ "$(find "$work" -type f | wc -l | tr -d ' ')" = 1 ] SH; - public function buildRestoreCommand(object $resource, string $path, bool $dumpAll, bool $replaceExisting = false): string + /** + * @param bool $keepOwners PostgreSQL archives: restore object owners and privileges. Off by default, + * because roles from another host (for example RDS) usually do not exist here. + * @param string|null $sqliteDatabase SQLite: the database file to restore into, validated by the resource. + */ + public function buildRestoreCommand(object $resource, string $path, bool $dumpAll, bool $replaceExisting = false, bool $keepOwners = false, ?string $sqliteDatabase = null): string { $script = match ($this->databaseType($resource)) { - 'postgresql' => $dumpAll ? $this->postgresqlDumpAll() : $this->postgresqlSingle($replaceExisting), + 'postgresql' => $dumpAll ? $this->postgresqlDumpAll() : $this->postgresqlSingle($replaceExisting, $keepOwners), 'mysql' => $this->mysql('mysql', 'MYSQL', $dumpAll), 'mariadb' => $this->mysql('mariadb', 'MARIADB', $dumpAll), 'mongodb' => $this->mongodb($replaceExisting), - 'sqlite' => $this->sqlite($resource->databaseFilePath()), + 'sqlite' => $this->sqlite($resource->databaseFilePath($sqliteDatabase)), default => throw new InvalidArgumentException('Database import is not supported for this database type.'), }; @@ -145,11 +150,14 @@ SH; * pg_dump custom and tar archives are restored with pg_restore, SQL dumps with psql. * pg_restore cannot read gzip files, so both clients receive the backup on stdin. * SQL cannot replace single objects, so replacing recreates the target database. + * Archives skip owners and privileges unless they are kept, because a single missing + * role would roll back the whole single-transaction restore. */ - private function postgresqlSingle(bool $replaceExisting): string + private function postgresqlSingle(bool $replaceExisting, bool $keepOwners): string { // Every restore runs in one transaction, so a failure part-way rolls back and leaves the // current data as it was, also when --clean dropped objects first. + $owners = $keepOwners ? '' : ' --no-owner --no-acl'; $clean = $replaceExisting ? ' --clean --if-exists' : ''; $sqlRestore = $replaceExisting ? <<<'SH' @@ -178,7 +186,7 @@ SH; return <<persistentStorages()->get(); foreach ($persistentVolumes as $volume) { $newName = ''; - if (str_starts_with($volume->name, $source->uuid)) { + if ($volume->standalone_sqlite_id !== null) { + // A mounted SQLite database volume stays connected to the source only; the clone gets its own volume. + $newName = $newApplication->uuid.'-'.$volume->name; + } elseif (str_starts_with($volume->name, $source->uuid)) { $newName = str($volume->name)->replace($source->uuid, $newApplication->uuid); } else { $newName = $newApplication->uuid.'-'.str($volume->name)->afterLast('-'); @@ -324,6 +327,7 @@ function clone_application(Application $source, $destination, array $overrides = ])->fill([ 'name' => $newName, 'resource_id' => $newApplication->id, + 'standalone_sqlite_id' => null, ]); $newPersistentVolume->save(); diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 005a4334dd..cd1f29257e 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -457,6 +457,24 @@ function composeExternalVolumeDockerName(string $key, array $declaration): strin return (string) (data_get($declaration, 'name') ?? data_get($declaration, 'external.name') ?? $key); } +/** + * The top-level declaration of a volume that a parser renamed (for example `data` to `{uuid}_data`). + * It keeps the options of the original declaration, such as `driver`, `driver_opts` and `labels`, so a + * local volume that binds a host folder still binds it. Variables in the options stay as written; + * Docker Compose resolves them from `.env`. The parser renamed the volume, so the declaration gets the + * new name, and it is not external: Docker Compose creates the renamed volume. + * + * @return array + */ +function composeRenamedVolumeDeclaration(mixed $declaration, string $name): array +{ + $renamed = is_array($declaration) ? $declaration : []; + unset($renamed['external'], $renamed['name']); + $renamed['name'] = $name; + + return $renamed; +} + /** * The Docker volumes that a Compose file declares as external. Returns an empty list for a * Compose file that is empty or not valid YAML. @@ -1383,7 +1401,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } else { $mainDirectory = str(base_configuration_dir().'/applications/'.$uuid); } - $source = replaceLocalSource($source, $mainDirectory); + $source = resolveComposeBindSource($source, $mainDirectory, $foundConfig?->fs_path); $isPreviewSuffixEnabled = $foundConfig ? (bool) data_get($foundConfig, 'is_preview_suffix_enabled', true) : true; @@ -1423,14 +1441,12 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int continue; } - if ($topLevel->get('volumes')->has($source->value())) { - $temp = $topLevel->get('volumes')->get($source->value()); - if (data_get($temp, 'driver_opts.type') === 'cifs') { - continue; - } - if (data_get($temp, 'driver_opts.type') === 'nfs') { - continue; - } + $declaration = $topLevel->get('volumes')->get($source->value()); + if (in_array(data_get($declaration, 'driver_opts.type'), ['cifs', 'nfs'], true)) { + // Network volumes are used as written. + $volumesParsed->put($index, $volume); + + continue; } $slugWithoutUuid = Str::slug($source, '-'); $name = "{$uuid}_{$slugWithoutUuid}"; @@ -1450,9 +1466,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } elseif (is_array($volume)) { data_set($volume, 'source', $name); } - $topLevel->get('volumes')->put($name, [ - 'name' => $name, - ]); + $topLevel->get('volumes')->put($name, composeRenamedVolumeDeclaration($declaration, $name)); LocalPersistentVolume::updateOrCreate( [ 'name' => $name, @@ -2749,7 +2763,7 @@ function serviceParser(Service $resource): Collection } else { $mainDirectory = str(base_configuration_dir().'/applications/'.$uuid); } - $source = replaceLocalSource($source, $mainDirectory); + $source = resolveComposeBindSource($source, $mainDirectory, $foundConfig?->fs_path); LocalFileVolume::updateOrCreate( [ 'mount_path' => $target, @@ -2779,14 +2793,12 @@ function serviceParser(Service $resource): Collection continue; } - if ($topLevel->get('volumes')->has($source->value())) { - $temp = $topLevel->get('volumes')->get($source->value()); - if (data_get($temp, 'driver_opts.type') === 'cifs') { - continue; - } - if (data_get($temp, 'driver_opts.type') === 'nfs') { - continue; - } + $declaration = $topLevel->get('volumes')->get($source->value()); + if (in_array(data_get($declaration, 'driver_opts.type'), ['cifs', 'nfs'], true)) { + // Network volumes are used as written. + $volumesParsed->put($index, $volume); + + continue; } $slugWithoutUuid = Str::slug($source, '-'); $name = "{$uuid}_{$slugWithoutUuid}"; @@ -2803,9 +2815,7 @@ function serviceParser(Service $resource): Collection } elseif (is_array($volume)) { data_set($volume, 'source', $name); } - $topLevel->get('volumes')->put($name, [ - 'name' => $name, - ]); + $topLevel->get('volumes')->put($name, composeRenamedVolumeDeclaration($declaration, $name)); LocalPersistentVolume::updateOrCreate( [ 'name' => $name, diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 336ba1f0be..5f4a3642fe 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1830,7 +1830,54 @@ function sourceIsLocal(Stringable $source) return false; } -function replaceLocalSource(Stringable $source, Stringable $replacedWith) +/** + * The host path of a local Compose bind source, relative to the resource directory $replacedWith. + * + * Sources with a `..` segment are resolved like Docker Compose resolves them, without touching the + * filesystem. Other sources keep the result of legacyReplaceLocalSource(). + * + * @throws Exception If the source resolves above `/` or the resolved path is not shell-safe + */ +function replaceLocalSource(Stringable $source, Stringable $replacedWith): Stringable +{ + $path = $source->value(); + if (! in_array('..', explode('/', $path), true)) { + return legacyReplaceLocalSource($source, $replacedWith); + } + + if (str_starts_with($path, '~')) { + $path = $replacedWith->value().substr($path, 1); + } elseif (! str_starts_with($path, '/')) { + $path = $replacedWith->value().'/'.$path; + } + + $segments = []; + foreach (explode('/', $path) as $segment) { + if ($segment === '' || $segment === '.') { + continue; + } + if ($segment === '..') { + if ($segments === []) { + throw new Exception("Volume source {$source} resolves to a path above /. Remove some ../ segments."); + } + array_pop($segments); + + continue; + } + $segments[] = $segment; + } + + $resolved = '/'.implode('/', $segments); + validateShellSafePath($resolved, 'volume source'); + + return str($resolved); +} + +/** + * The host path that Coolify used for a local Compose bind source before it resolved `..` segments. + * A leading `../` became `{directory}./`. Resources that still store that path keep it. + */ +function legacyReplaceLocalSource(Stringable $source, Stringable $replacedWith): Stringable { if ($source->startsWith('.')) { $source = $source->replaceFirst('.', $replacedWith->value()); @@ -1848,6 +1895,22 @@ function replaceLocalSource(Stringable $source, Stringable $replacedWith) return $source; } +/** + * The host path of a local Compose bind source. A mount whose storage row already has the legacy path + * (also with a preview suffix) keeps it, because its data is there. + * + * @throws Exception If a new source resolves above `/` or is not shell-safe + */ +function resolveComposeBindSource(Stringable $source, Stringable $directory, ?string $existingFsPath = null): Stringable +{ + $legacySource = legacyReplaceLocalSource($source, $directory); + if ($existingFsPath !== null && preg_match('/^'.preg_quote($legacySource->value(), '/').'(-pr-\d+)?$/', $existingFsPath) === 1) { + return $legacySource; + } + + return replaceLocalSource($source, $directory); +} + function convertToArray($collection) { if ($collection instanceof Collection) { @@ -2909,6 +2972,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal return $volume; } } + $declaration = $topLevelVolumes->get($source->value()); $slugWithoutUuid = Str::slug($source, '-'); $name = "{$savedService->service->uuid}_{$slugWithoutUuid}"; if (is_string($volume)) { @@ -2919,9 +2983,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } elseif (is_array($volume)) { data_set($volume, 'source', $name); } - $topLevelVolumes->put($name, [ - 'name' => $name, - ]); + $topLevelVolumes->put($name, composeRenamedVolumeDeclaration($declaration, $name)); LocalPersistentVolume::updateOrCreate( [ 'mount_path' => $target, @@ -3502,6 +3564,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $volume = str($volume); if ($volume->contains(':') && ! $volume->startsWith('/')) { $name = $volume->before(':'); + $volumeKey = $name->value(); $mount = $volume->after(':'); if ($name->startsWith('.') || $name->startsWith('~')) { $dir = base_configuration_dir().'/applications/'.$resource->uuid; @@ -3531,9 +3594,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } } else { - $topLevelVolumes->put($name, [ - 'name' => $name, - ]); + $topLevelVolumes->put($name, composeRenamedVolumeDeclaration($declaredTopLevelVolumes->get($volumeKey), $name)); } } else { if ($topLevelVolumes->has($name->value())) { @@ -3546,9 +3607,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } } else { - $topLevelVolumes->put($name->value(), [ - 'name' => $name->value(), - ]); + $topLevelVolumes->put($name->value(), composeRenamedVolumeDeclaration($declaredTopLevelVolumes->get($volumeKey), $name->value())); } } } @@ -3564,6 +3623,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } elseif (is_array($volume)) { $source = data_get($volume, 'source'); + $volumeKey = (string) $source; $target = data_get($volume, 'target'); $read_only = data_get($volume, 'read_only'); if ($source && $target) { @@ -3605,9 +3665,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } } else { - $topLevelVolumes->put($source, [ - 'name' => $source, - ]); + $topLevelVolumes->put($source, composeRenamedVolumeDeclaration($declaredTopLevelVolumes->get($volumeKey), $source)); } } } @@ -3628,6 +3686,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $volume = str($volume); if ($volume->contains(':') && ! $volume->startsWith('/')) { $name = $volume->before(':'); + $volumeKey = $name->value(); $mount = $volume->after(':'); if ($name->startsWith('.') || $name->startsWith('~')) { $dir = base_configuration_dir().'/applications/'.$resource->uuid; @@ -3658,9 +3717,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } } else { - $topLevelVolumes->put($name, [ - 'name' => $name, - ]); + $topLevelVolumes->put($name, composeRenamedVolumeDeclaration($declaredTopLevelVolumes->get($volumeKey), $name)); } } else { $uuid = $resource->uuid; @@ -3676,9 +3733,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } } else { - $topLevelVolumes->put($name->value(), [ - 'name' => $name->value(), - ]); + $topLevelVolumes->put($name->value(), composeRenamedVolumeDeclaration($declaredTopLevelVolumes->get($volumeKey), $name->value())); } } } @@ -3694,6 +3749,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } elseif (is_array($volume)) { $source = data_get($volume, 'source'); + $volumeKey = (string) $source; $target = data_get($volume, 'target'); $read_only = data_get($volume, 'read_only'); if ($source && $target) { @@ -3735,9 +3791,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } } else { - $topLevelVolumes->put($source, [ - 'name' => $source, - ]); + $topLevelVolumes->put($source, composeRenamedVolumeDeclaration($declaredTopLevelVolumes->get($volumeKey), $source)); } } } @@ -4868,10 +4922,15 @@ function formatContainerStatus(string $status): string } /** - * Check if password confirmation should be skipped. + * Check if the password step of a destructive action should be skipped. * Returns true if: * - Two-step confirmation is globally disabled - * - User has no usable local password confirmation (including SSO users) + * - User has neither a password nor a linked OAuth identity (no way to confirm) + * - User confirmed recently (password or OAuth re-authentication), using the + * same `auth.password_confirmed_at` session value and timeout as Laravel + * + * Users with a linked OAuth identity are never skipped silently: they confirm + * with their password or by re-authenticating through their provider. * * Used by modal-confirmation.blade.php to determine if password step should be shown. * @@ -4884,20 +4943,30 @@ function shouldSkipPasswordConfirmation(): bool return true; } - // OAuth users may have an unusable generated password, so the linked - // identity is the source of truth for whether confirmation is possible. if (! Auth::user()?->requiresPasswordConfirmation()) { return true; } - return false; + return hasRecentPasswordConfirmation(); +} + +/** + * Whether the session holds a password confirmation within `auth.password_timeout`. + */ +function hasRecentPasswordConfirmation(): bool +{ + $confirmedAt = session('auth.password_confirmed_at'); + if (! is_numeric($confirmedAt)) { + return false; + } + + return (time() - (int) $confirmedAt) < (int) config('auth.password_timeout', 10800); } /** * Verify password for two-step confirmation. - * Skips verification if: - * - Two-step confirmation is globally disabled - * - User has no usable local password confirmation (including SSO users) + * Skips verification in the cases listed in shouldSkipPasswordConfirmation(). + * Users without a password must confirm through their OAuth provider first. * * @param mixed $password The password to verify (may be array if skipped by frontend) * @param Component|null $component Optional Livewire component to add errors to @@ -4910,11 +4979,16 @@ function verifyPasswordConfirmation(mixed $password, ?Component $component = nul return true; } + $user = Auth::user(); + if (! $user->hasPassword()) { + $component?->addError('password', 'Please confirm with your sign-in provider first.'); + + return false; + } + // Verify the password - if (! Hash::check($password, Auth::user()->password)) { - if ($component) { - $component->addError('password', 'The provided password is incorrect.'); - } + if (! is_string($password) || ! Hash::check($password, $user->password)) { + $component?->addError('password', 'The provided password is incorrect.'); return false; } diff --git a/bootstrap/helpers/socialite.php b/bootstrap/helpers/socialite.php index 5f7e8d868a..1f989aaadb 100644 --- a/bootstrap/helpers/socialite.php +++ b/bootstrap/helpers/socialite.php @@ -91,3 +91,24 @@ function get_socialite_provider(string $provider) return $socialite; } + +/** + * Authorization parameters that ask the provider to re-authenticate the user + * instead of reusing an existing provider session, where the provider + * supports it. Used to confirm destructive actions through OAuth. + * + * Socialite's with() replaces earlier parameters, so provider defaults set in + * get_socialite_provider() (Google's hd) are repeated here. + * + * @return array + */ +function socialite_reauthentication_parameters(OauthSetting $oauthSetting): array +{ + return match ($oauthSetting->provider) { + 'google' => ['hd' => $oauthSetting->tenant, 'prompt' => 'select_account', 'max_age' => 0], + 'oidc', 'authentik', 'zitadel', 'clerk' => ['prompt' => 'login', 'max_age' => 0], + 'azure', 'infomaniak' => ['prompt' => 'login'], + 'github' => ['prompt' => 'select_account'], + default => [], + }; +} diff --git a/openapi.json b/openapi.json index 512769e0b7..dc85c20b75 100644 --- a/openapi.json +++ b/openapi.json @@ -16821,6 +16821,11 @@ "example": "both", "description": "Server role." }, + "is_build_server": { + "type": "boolean", + "deprecated": true, + "description": "Deprecated: use server_role instead. true sets server_role to build, false sets it to both. Must not conflict with server_role." + }, "instant_validate": { "type": "boolean", "example": false, @@ -17058,6 +17063,11 @@ ], "description": "Server role." }, + "is_build_server": { + "type": "boolean", + "deprecated": true, + "description": "Deprecated: use server_role instead. true sets server_role to build, false changes a build server to both and leaves other roles unchanged. Must not conflict with server_role." + }, "instant_validate": { "type": "boolean", "description": "Instant validate." @@ -22425,6 +22435,16 @@ "description": "Drop matching PostgreSQL objects before restoring a single-database archive.", "type": "boolean", "default": false + }, + "keep_owners": { + "description": "Restore object owners and privileges from a single-database PostgreSQL archive. By default they are skipped, because the roles of another server usually do not exist.", + "type": "boolean", + "default": false + }, + "sqlite_database": { + "description": "SQLite: the database file to restore into. Must be one of the database files. Defaults to the file named in the backup file name, else the first file.", + "type": "string", + "example": "app.db" } }, "type": "object", @@ -22457,6 +22477,16 @@ "description": "Drop matching PostgreSQL objects before restoring a single-database archive.", "type": "boolean", "default": false + }, + "keep_owners": { + "description": "Restore object owners and privileges from a single-database PostgreSQL archive. By default they are skipped, because the roles of another server usually do not exist.", + "type": "boolean", + "default": false + }, + "sqlite_database": { + "description": "SQLite: the database file to restore into. Must be one of the database files. Defaults to the file named in the backup file name, else the first file.", + "type": "string", + "example": "app.db" } }, "type": "object", @@ -22486,6 +22516,16 @@ "description": "Drop matching PostgreSQL objects before restoring a single-database archive.", "type": "boolean", "default": false + }, + "keep_owners": { + "description": "Restore object owners and privileges from a single-database PostgreSQL archive. By default they are skipped, because the roles of another server usually do not exist.", + "type": "boolean", + "default": false + }, + "sqlite_database": { + "description": "SQLite: the database file to restore into. Must be one of the database files. Defaults to the file named in the backup file name, else the first file.", + "type": "string", + "example": "app.db" } }, "type": "object", @@ -23690,6 +23730,12 @@ "both" ] }, + "is_build_server": { + "type": "boolean", + "deprecated": true, + "readOnly": true, + "description": "Deprecated: use server_role instead. true when server_role is build." + }, "is_cloudflare_tunnel": { "type": "boolean" }, diff --git a/openapi.yaml b/openapi.yaml index 7fdb29ed32..24b50d77bd 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -10644,6 +10644,10 @@ paths: enum: [deployment, build, both] example: both description: 'Server role.' + is_build_server: + type: boolean + deprecated: true + description: 'Deprecated: use server_role instead. true sets server_role to build, false sets it to both. Must not conflict with server_role.' instant_validate: type: boolean example: false @@ -10798,6 +10802,10 @@ paths: type: string enum: [deployment, build, both] description: 'Server role.' + is_build_server: + type: boolean + deprecated: true + description: 'Deprecated: use server_role instead. true sets server_role to build, false changes a build server to both and leaves other roles unchanged. Must not conflict with server_role.' instant_validate: type: boolean description: 'Instant validate.' @@ -14225,6 +14233,14 @@ components: description: 'Drop matching PostgreSQL objects before restoring a single-database archive.' type: boolean default: false + keep_owners: + description: 'Restore object owners and privileges from a single-database PostgreSQL archive. By default they are skipped, because the roles of another server usually do not exist.' + type: boolean + default: false + sqlite_database: + description: 'SQLite: the database file to restore into. Must be one of the database files. Defaults to the file named in the backup file name, else the first file.' + type: string + example: app.db type: object additionalProperties: false - @@ -14248,6 +14264,14 @@ components: description: 'Drop matching PostgreSQL objects before restoring a single-database archive.' type: boolean default: false + keep_owners: + description: 'Restore object owners and privileges from a single-database PostgreSQL archive. By default they are skipped, because the roles of another server usually do not exist.' + type: boolean + default: false + sqlite_database: + description: 'SQLite: the database file to restore into. Must be one of the database files. Defaults to the file named in the backup file name, else the first file.' + type: string + example: app.db type: object additionalProperties: false - @@ -14269,6 +14293,14 @@ components: description: 'Drop matching PostgreSQL objects before restoring a single-database archive.' type: boolean default: false + keep_owners: + description: 'Restore object owners and privileges from a single-database PostgreSQL archive. By default they are skipped, because the roles of another server usually do not exist.' + type: boolean + default: false + sqlite_database: + description: 'SQLite: the database file to restore into. Must be one of the database files. Defaults to the file named in the backup file name, else the first file.' + type: string + example: app.db type: object additionalProperties: false DatabaseImportStatus: @@ -15157,6 +15189,11 @@ components: - deployment - build - both + is_build_server: + type: boolean + deprecated: true + readOnly: true + description: 'Deprecated: use server_role instead. true when server_role is build.' is_cloudflare_tunnel: type: boolean is_jump_server: diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 427e309eb2..d09d64ea7e 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -34,8 +34,14 @@ use Illuminate\View\ComponentSlot; // Global setting to disable ALL two-step confirmation (text + password) $disableTwoStepConfirmation = data_get(InstanceSettings::get(), 'disable_two_step_confirmation'); - // Skip ONLY password confirmation for OAuth users (they have no password) + // Skip ONLY the password step (disabled globally, recently confirmed, or no way to confirm) $skipPasswordConfirmation = shouldSkipPasswordConfirmation(); + // Users with a linked OAuth identity can confirm by re-authenticating with their provider + $confirmingUser = auth()->user(); + $confirmWithPasswordInput = $confirmingUser?->hasPassword() ?? false; + $oauthConfirmationProviders = ($confirmWithPassword && ! $skipPasswordConfirmation && $confirmingUser) + ? $confirmingUser->oauthConfirmationProviders() + : collect(); if ($temporaryDisableTwoStepConfirmation) { $disableTwoStepConfirmation = false; // Password confirmation requirement is not affected by temporary two-step disable @@ -350,61 +356,96 @@ @if (!$skipPasswordConfirmation)
- Please enter your password to confirm this destructive action. + @if ($confirmWithPasswordInput && $oauthConfirmationProviders->isNotEmpty()) + Please enter your password or confirm with your sign-in provider to confirm this destructive action. + @elseif ($confirmWithPasswordInput) + Please enter your password to confirm this destructive action. + @elseif ($oauthConfirmationProviders->isNotEmpty()) + Please confirm with your sign-in provider to confirm this destructive action. + @else + Your sign-in provider is not available, so this action cannot be confirmed. Please ask an administrator. + @endif -
- @php - $passwordConfirm = Str::uuid(); - @endphp - -
- - -
-

-

- @error('password') -

{{ $message }}

- @enderror -
+ @if ($confirmWithPasswordInput) +
+ @php + $passwordConfirm = Str::uuid(); + @endphp + +
+ + +
+

+

+ @error('password') +

{{ $message }}

+ @enderror +
+ @endif + @if ($oauthConfirmationProviders->isNotEmpty()) +
+ @if ($confirmWithPasswordInput) +
Or confirm with your sign-in provider:
+ @endif +
+ @foreach ($oauthConfirmationProviders as $oauthConfirmationProvider) + + Confirm with {{ $oauthConfirmationProvider->providerName() }} + + @endforeach +
+

+ You will return to this page. Then start the action again; it will not ask for confirmation for a while. +

+ @if (! $confirmWithPasswordInput) + @error('password') +

{{ $message }}

+ @enderror + @endif +
+ @endif
Back - - - - + "> + + + + @endif
@endif diff --git a/resources/views/livewire/project/database/import-form.blade.php b/resources/views/livewire/project/database/import-form.blade.php index 9dc671247b..556f01a1ad 100644 --- a/resources/views/livewire/project/database/import-form.blade.php +++ b/resources/views/livewire/project/database/import-form.blade.php @@ -36,6 +36,7 @@ }); this.on('complete', function (file) { $wire.filename = file.name; + $wire.selectSqliteDatabaseFor(file.name); $wire.filesize = Number(file.size / 1024 / 1024).toFixed(2) + ' MB'; $wire.isUploading = false; }); @@ -79,6 +80,19 @@ helper="Archive backups: drops matching tables, functions, types, and other PostgreSQL objects before restoring them. SQL backups: recreates the database before the restore." canGate="update" :canResource="$this->resource" /> +
+ +
+ @endif + @if ($resourceDbType === 'standalone-sqlite' && count($this->sqliteDatabaseFiles) > 0) +
+ +
@endif @if ($resourceDbType === 'standalone-mongodb')
diff --git a/resources/views/livewire/project/shared/cloudflare-autoconfigure.blade.php b/resources/views/livewire/project/shared/cloudflare-autoconfigure.blade.php index 46ab71e14f..626a019862 100644 --- a/resources/views/livewire/project/shared/cloudflare-autoconfigure.blade.php +++ b/resources/views/livewire/project/shared/cloudflare-autoconfigure.blade.php @@ -171,6 +171,11 @@ Use Recheck after changing DNS. @else + @if (collect($dnsProviderProposals)->isNotEmpty() && collect($dnsHints)->contains(fn (array $record): bool => ! \App\Support\DnsRecordHints::isPublicAddress($record['value']))) + + {{ \App\Support\DnsRecordHints::NO_PUBLIC_ADDRESS_MESSAGE }} + + @endif
- @php($recordProviders = collect($dnsProviderProposals)->where('hostname', $record['name'])->where('managed', false)) + @php($recordProviders = \App\Support\DnsRecordHints::isPublicAddress($record['value']) ? collect($dnsProviderProposals)->where('hostname', $record['name'])->where('managed', false) : collect()) @foreach ($recordProviders as $provider)

Coolify matched each hostname to a zone available through your connected DNS credentials.

+ @if (collect($dnsProviderProposals)->contains(fn (array $proposal): bool => blank($proposal['target']))) + {{ \App\Support\DnsRecordHints::NO_PUBLIC_ADDRESS_MESSAGE }} + @endif @foreach ($dnsProviderProposals as $proposal) @php($key = $proposal['hostname'].'|'.$proposal['zone_id'])
{{ $proposal['hostname'] }}
-
{{ $proposal['credential'] }} · {{ $proposal['zone'] }} · {{ $proposal['target'] }}
+
{{ $proposal['credential'] }} · {{ $proposal['zone'] }}@if (filled($proposal['target'])) · {{ $proposal['target'] }}@endif
@if ($proposal['managed']) @@ -33,6 +36,8 @@ :confirmWithPassword="false" :confirmWithText="false" step2ButtonText="Replace record" canGate="update" :canResource="$dnsAuthResource" />
+ @elseif (blank($proposal['target'])) + Add manually @else middleware('thrott Route::get('/auth/{provider}/redirect', [OauthController::class, 'redirect'])->name('auth.redirect'); Route::get('/auth/{provider}/callback', [OauthController::class, 'callback'])->name('auth.callback'); +Route::get('/auth/{provider}/confirm', [OauthController::class, 'confirm'])->middleware('auth')->name('auth.confirm'); // Local/testing previews for HTTP error pages and the Laravel debug renderer (never in production). if (app()->environment(['local', 'testing'])) { diff --git a/tests/Feature/Api/ServerLegacyBuildServerApiTest.php b/tests/Feature/Api/ServerLegacyBuildServerApiTest.php new file mode 100644 index 0000000000..14602ad0a9 --- /dev/null +++ b/tests/Feature/Api/ServerLegacyBuildServerApiTest.php @@ -0,0 +1,209 @@ +set('app.maintenance.driver', 'file'); + config()->set('cache.default', 'array'); + + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $this->privateKey = PrivateKey::create([ + 'name' => 'Legacy Key', + 'private_key' => generateSSHKey('ed25519')['private'], + 'team_id' => $this->team->id, + ]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + + $this->writeToken = legacyBuildServerApiToken($this->user, $this->team, ['write']); + $this->readToken = legacyBuildServerApiToken($this->user, $this->team, ['read']); +}); + +function legacyBuildServerApiToken(User $user, Team $team, array $abilities): string +{ + $token = $user->createToken('legacy-'.implode('-', $abilities), $abilities); + $token->accessToken->forceFill(['team_id' => $team->id])->save(); + + return $token->plainTextToken; +} + +function legacyBuildServerApi(object $test, string $bearerToken): object +{ + return $test->withHeaders([ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]); +} + +function createLegacyBuildServer(object $test, array $payload): TestResponse +{ + return legacyBuildServerApi($test, $test->writeToken)->postJson('/api/v1/servers', array_merge([ + 'name' => 'Legacy Server', + 'ip' => '192.0.2.77', + 'private_key_uuid' => $test->privateKey->uuid, + ], $payload)); +} + +function updateLegacyBuildServer(object $test, Server $server, array $payload, ?string $token = null): TestResponse +{ + return legacyBuildServerApi($test, $token ?? $test->writeToken)->patchJson('/api/v1/servers/'.$server->uuid, $payload); +} + +describe('create', function () { + it('maps is_build_server to the server role', function (bool $isBuildServer, string $expectedRole) { + $response = createLegacyBuildServer($this, ['is_build_server' => $isBuildServer])->assertCreated(); + + $server = Server::whereUuid($response->json('uuid'))->firstOrFail(); + + expect($server->settings->server_role->value)->toBe($expectedRole) + ->and($server->settings->is_build_server)->toBe($isBuildServer); + })->with([ + 'build server' => [true, 'build'], + 'regular server' => [false, 'both'], + ]); + + it('accepts is_build_server together with a matching server_role', function () { + $response = createLegacyBuildServer($this, [ + 'is_build_server' => true, + 'server_role' => 'build', + ])->assertCreated(); + + expect(Server::whereUuid($response->json('uuid'))->firstOrFail()->settings->server_role->value)->toBe('build'); + }); + + it('rejects is_build_server that conflicts with server_role', function (bool $isBuildServer, string $serverRole) { + $serverCount = Server::count(); + + createLegacyBuildServer($this, [ + 'is_build_server' => $isBuildServer, + 'server_role' => $serverRole, + ])->assertUnprocessable() + ->assertJsonValidationErrors('is_build_server'); + + expect(Server::count())->toBe($serverCount); + })->with([ + 'build flag with both role' => [true, 'both'], + 'build flag with deployment role' => [true, 'deployment'], + 'regular flag with build role' => [false, 'build'], + ]); + + it('rejects a non-boolean is_build_server', function () { + createLegacyBuildServer($this, ['is_build_server' => 'yes please']) + ->assertUnprocessable() + ->assertJsonValidationErrors('is_build_server'); + }); +}); + +describe('update', function () { + it('makes an empty server a build server with is_build_server true', function () { + updateLegacyBuildServer($this, $this->server, ['is_build_server' => true])->assertCreated(); + + $settings = $this->server->settings->fresh(); + + expect($settings->server_role->value)->toBe('build') + ->and($settings->is_build_server)->toBeTrue(); + }); + + it('turns a build server back into a regular server with is_build_server false', function () { + $this->server->settings()->update(['server_role' => 'build', 'is_build_server' => true]); + + updateLegacyBuildServer($this, $this->server, ['is_build_server' => false])->assertCreated(); + + $settings = $this->server->settings->fresh(); + + expect($settings->server_role->value)->toBe('both') + ->and($settings->is_build_server)->toBeFalse(); + }); + + it('keeps a deployment only server unchanged with is_build_server false', function () { + $this->server->settings()->update(['server_role' => 'deployment', 'is_build_server' => false]); + + updateLegacyBuildServer($this, $this->server, ['is_build_server' => false])->assertCreated(); + + expect($this->server->settings->fresh()->server_role->value)->toBe('deployment'); + }); + + it('accepts is_build_server together with a matching server_role', function () { + updateLegacyBuildServer($this, $this->server, [ + 'is_build_server' => false, + 'server_role' => 'both', + ])->assertCreated(); + + expect($this->server->settings->fresh()->server_role->value)->toBe('both'); + }); + + it('rejects is_build_server that conflicts with server_role without changing the server', function () { + $this->server->settings()->update(['server_role' => 'both', 'is_build_server' => false]); + + updateLegacyBuildServer($this, $this->server, [ + 'name' => 'Renamed Server', + 'is_build_server' => false, + 'server_role' => 'build', + ])->assertUnprocessable() + ->assertJsonValidationErrors('is_build_server'); + + expect($this->server->fresh()->name)->not->toBe('Renamed Server') + ->and($this->server->settings->fresh()->server_role->value)->toBe('both'); + }); + + it('does not let a read-only token change the build server flag', function () { + updateLegacyBuildServer($this, $this->server, ['is_build_server' => true], $this->readToken) + ->assertForbidden(); + + expect($this->server->settings->fresh()->server_role->value)->toBe('both'); + }); + + it('does not update a server from another team', function () { + $otherTeam = Team::factory()->create(); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + + updateLegacyBuildServer($this, $otherServer, ['is_build_server' => true])->assertNotFound(); + + expect($otherServer->settings->fresh()->server_role->value)->toBe('both'); + }); +}); + +describe('read', function () { + it('returns a derived is_build_server flag in the server settings', function (string $role, bool $expected) { + $this->server->settings()->update(['server_role' => $role, 'is_build_server' => $role === 'build']); + + legacyBuildServerApi($this, $this->readToken)->getJson('/api/v1/servers/'.$this->server->uuid) + ->assertOk() + ->assertJsonPath('settings.server_role', $role) + ->assertJsonPath('settings.is_build_server', $expected); + + $listed = collect(legacyBuildServerApi($this, $this->readToken)->getJson('/api/v1/servers')->assertOk()->json()) + ->firstWhere('uuid', $this->server->uuid); + + expect(data_get($listed, 'settings.is_build_server'))->toBe($expected); + })->with([ + 'build' => ['build', true], + 'both' => ['both', false], + 'deployment' => ['deployment', false], + ]); + + it('derives is_build_server from the server role, not the stale legacy column', function () { + $this->server->settings()->update(['server_role' => 'both', 'is_build_server' => true]); + + legacyBuildServerApi($this, $this->readToken)->getJson('/api/v1/servers/'.$this->server->uuid) + ->assertOk() + ->assertJsonPath('settings.is_build_server', false); + }); +}); diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 6ccdf8aa53..434233e13f 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -3554,3 +3554,27 @@ it('restarts a preview dns check when the domain already has a completed result' Queue::assertPushed(CheckDomainDnsJob::class); }); + +it('shows a manual dns note instead of create buttons when the server has no public ip', function (string $address) { + $this->server->update(['ip' => $address]); + Http::fake(); + + $component = openDnsProviderModalWithZone($this->team, $this->application) + ->assertSet('showDnsProviderModal', true) + ->call('openManualDnsRecords'); + + expect($component->html()) + ->toContain('The server has no public IP address; add the DNS record manually.') + ->not->toContain('Create DNS record') + ->not->toContain('Add with Cloudflare'); + Http::assertNothingSent(); +})->with(['10.0.0.5', '100.100.1.1', 'fd00::5']); + +it('offers dns provider record creation for a public server ip', function () { + $component = openDnsProviderModalWithZone($this->team, $this->application)->call('openManualDnsRecords'); + + expect($component->html()) + ->toContain('Create DNS record') + ->toContain('Add with Cloudflare') + ->not->toContain('The server has no public IP address; add the DNS record manually.'); +}); diff --git a/tests/Feature/BuildServerFallbackPolicyTest.php b/tests/Feature/BuildServerFallbackPolicyTest.php index e698a205b4..779db2f463 100644 --- a/tests/Feature/BuildServerFallbackPolicyTest.php +++ b/tests/Feature/BuildServerFallbackPolicyTest.php @@ -299,3 +299,85 @@ test('applications on combined servers build locally unless they use a build ser expect(selectedBuildServer($job)->is($deploymentServer))->toBeTrue() ->and(usesRemoteBuildServer($job))->toBeFalse(); }); + +function markAsAdditionalServer(ApplicationDeploymentJob $job, bool $forceRebuild = false): void +{ + (new ReflectionProperty(ApplicationDeploymentJob::class, 'is_this_additional_server'))->setValue($job, true); + (new ReflectionProperty(ApplicationDeploymentJob::class, 'force_rebuild'))->setValue($job, $forceRebuild); +} + +test('additional deployments-only servers pull the registry image without a build server', function (bool $buildServerEnabled) { + $team = Team::factory()->create(['is_build_server_fallback_enabled' => false]); + $deploymentServer = makeRoleServer($team, 'deployment'); + [$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, ['docker_registry_image_name' => 'ghcr.io/coollabsio/app'], $buildServerEnabled); + markAsAdditionalServer($job); + + $deploymentQueue->shouldNotReceive('setAttribute'); + $deploymentQueue->shouldReceive('addLogEntry')->once()->with('Additional server: pulls the image from the registry, no build server needed.'); + + invokeBuildServerSelection($job); + + expect(selectedBuildServer($job)->is($deploymentServer))->toBeTrue() + ->and(usesRemoteBuildServer($job))->toBeFalse(); +})->with([ + 'application uses a build server' => [true], + 'application builds on the deployment server' => [false], +]); + +test('additional servers do not use a build server when the application enables one', function () { + $team = Team::factory()->create(); + $deploymentServer = makeRoleServer($team, 'both'); + makeRoleServer($team, 'build'); + [$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, ['docker_registry_image_name' => 'ghcr.io/coollabsio/app']); + markAsAdditionalServer($job); + + $deploymentQueue->shouldNotReceive('setAttribute'); + $deploymentQueue->shouldReceive('addLogEntry')->once()->with('Additional server: pulls the image from the registry, no build server needed.'); + + invokeBuildServerSelection($job); + + expect(selectedBuildServer($job)->is($deploymentServer))->toBeTrue() + ->and(usesRemoteBuildServer($job))->toBeFalse(); +}); + +test('additional deployments-only servers pull the image even when the build cache is disabled', function () { + $team = Team::factory()->create(); + $deploymentServer = makeRoleServer($team, 'deployment'); + [$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, ['docker_registry_image_name' => 'ghcr.io/coollabsio/app']); + markAsAdditionalServer($job, forceRebuild: true); + + $deploymentQueue->shouldReceive('addLogEntry'); + + invokeBuildServerSelection($job); + + expect((new ReflectionProperty(ApplicationDeploymentJob::class, 'force_rebuild'))->getValue($job))->toBeFalse(); +}); + +test('main deployments-only servers still require a build server', function () { + $team = Team::factory()->create(); + $deploymentServer = makeRoleServer($team, 'deployment'); + [$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, ['docker_registry_image_name' => 'ghcr.io/coollabsio/app']); + + $deploymentQueue->shouldNotReceive('addLogEntry'); + + expect(fn () => invokeBuildServerSelection($job)) + ->toThrow(DeploymentException::class, "The deployment server ({$deploymentServer->name}) is set to deployments only, and no usable build server was found."); +}); + +test('additional deployments-only servers fail instead of building when the registry image is missing', function (bool $restartOnly) { + $team = Team::factory()->create(); + $deploymentServer = makeRoleServer($team, 'deployment'); + [$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, ['docker_registry_image_name' => 'ghcr.io/coollabsio/app'], restartOnly: $restartOnly); + markAsAdditionalServer($job); + (new ReflectionProperty(ApplicationDeploymentJob::class, 'saved_outputs'))->setValue($job, collect()); + (new ReflectionProperty(ApplicationDeploymentJob::class, 'pull_request_id'))->setValue($job, 0); + (new ReflectionProperty(ApplicationDeploymentJob::class, 'production_image_name'))->setValue($job, 'ghcr.io/coollabsio/app:abc123'); + + $deploymentQueue->shouldNotReceive('addLogEntry'); + + expect(fn () => (new ReflectionMethod(ApplicationDeploymentJob::class, 'should_skip_build'))->invoke($job)) + ->toThrow(DeploymentException::class, 'Image (ghcr.io/coollabsio/app:abc123) not found in the registry; the main server must push it first.'); +})->with([ + 'deploy' => [false], + 'restart' => [true], +]); diff --git a/tests/Feature/ComposeParentBindPathTest.php b/tests/Feature/ComposeParentBindPathTest.php new file mode 100644 index 0000000000..ea28d49206 --- /dev/null +++ b/tests/Feature/ComposeParentBindPathTest.php @@ -0,0 +1,284 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + config(['constants.ssh.mux_enabled' => false]); + Bus::fake(); + Queue::fake(); + Process::fake(['*' => Process::result(output: '')]); + + $team = Team::factory()->create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + ]); + $server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + $this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $this->environment = $project->environments()->first() + ?? Environment::factory()->create(['project_id' => $project->id]); +}); + +function parentBindCompose(string $source, bool $longSyntax = false): string +{ + if ($longSyntax) { + return <<create([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => $compose, + 'environment_id' => test()->environment->id, + 'destination_id' => test()->destination->id, + 'destination_type' => test()->destination->getMorphClass(), + ]); + if ($parsingVersion !== null) { + $application->update(['compose_parsing_version' => $parsingVersion]); + } + + return $application->fresh(); +} + +function parentBindService(string $compose): Service +{ + return Service::factory()->create([ + 'docker_compose_raw' => $compose, + 'environment_id' => test()->environment->id, + 'server_id' => test()->destination->server_id, + 'destination_id' => test()->destination->id, + 'destination_type' => test()->destination->getMorphClass(), + ])->fresh(); +} + +/** + * The host path of the first volume of the `web` service in a parsed Compose file. + */ +function parentBindParsedSource(array $compose): string +{ + $volume = $compose['services']['web']['volumes'][0]; + + return is_array($volume) ? $volume['source'] : str($volume)->before(':/data')->value(); +} + +describe('applicationParser', function () { + it('resolves parent directory segments of a new bind mount', function (string $source, string $expected, bool $longSyntax) { + $application = parentBindApplication(parentBindCompose($source, $longSyntax)); + $directory = base_configuration_dir().'/applications/'.$application->uuid; + $expected = str_replace('{dir}', $directory, $expected); + + $compose = applicationParser($application)->toArray(); + + expect(parentBindParsedSource($compose))->toBe($expected) + ->and($application->fileStorages()->where('mount_path', '/data')->value('fs_path'))->toBe($expected); + })->with([ + 'parent directory' => ['../outside', base_configuration_dir().'/applications/outside', false], + 'parent directory, long syntax' => ['../outside', base_configuration_dir().'/applications/outside', true], + 'dot segment in the middle' => ['./a/../b', '{dir}/b', false], + 'two parent directories' => ['../../outside/', base_configuration_dir().'/outside', false], + 'current directory stays unchanged' => ['./data', '{dir}/data', false], + 'tilde stays unchanged' => ['~/data', '{dir}/data', false], + ]); + + it('rejects a bind mount that resolves above the root directory', function () { + $application = parentBindApplication(parentBindCompose('../../../../../../../outside')); + + expect(fn () => applicationParser($application)) + ->toThrow(Exception::class, 'resolves to a path above /'); + }); + + it('keeps the old path of an existing bind mount', function () { + $application = parentBindApplication(parentBindCompose('../outside')); + $oldPath = base_configuration_dir().'/applications/'.$application->uuid.'./outside'; + LocalFileVolume::create([ + 'fs_path' => $oldPath, + 'mount_path' => '/data', + 'is_directory' => true, + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + + $compose = applicationParser($application)->toArray(); + + expect(parentBindParsedSource($compose))->toBe($oldPath) + ->and($application->fileStorages()->where('mount_path', '/data')->pluck('fs_path')->all())->toBe([$oldPath]); + }); + + it('keeps the old path of an existing bind mount that was last written by a preview deployment', function () { + $application = parentBindApplication(parentBindCompose('../outside')); + $oldPath = base_configuration_dir().'/applications/'.$application->uuid.'./outside'; + LocalFileVolume::create([ + 'fs_path' => $oldPath.'-pr-7', + 'mount_path' => '/data', + 'is_directory' => true, + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + + $compose = applicationParser($application)->toArray(); + + expect(parentBindParsedSource($compose))->toBe($oldPath); + }); + + it('uses the resolved path when the existing row has another path', function () { + $application = parentBindApplication(parentBindCompose('../outside')); + LocalFileVolume::create([ + 'fs_path' => '/somewhere/else', + 'mount_path' => '/data', + 'is_directory' => true, + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + + $compose = applicationParser($application)->toArray(); + + expect(parentBindParsedSource($compose))->toBe(base_configuration_dir().'/applications/outside'); + }); +}); + +describe('serviceParser', function () { + it('resolves parent directory segments of a new bind mount', function (string $source, string $expected, bool $longSyntax) { + $service = parentBindService(parentBindCompose($source, $longSyntax)); + $directory = base_configuration_dir().'/services/'.$service->uuid; + $expected = str_replace('{dir}', $directory, $expected); + + $compose = serviceParser($service)->toArray(); + $web = ServiceApplication::query()->where('service_id', $service->id)->where('name', 'web')->firstOrFail(); + + expect(parentBindParsedSource($compose))->toBe($expected) + ->and($web->fileStorages()->where('mount_path', '/data')->value('fs_path'))->toBe($expected); + })->with([ + 'parent directory' => ['../outside', base_configuration_dir().'/services/outside', false], + 'parent directory, long syntax' => ['../outside', base_configuration_dir().'/services/outside', true], + 'dot segment in the middle' => ['./a/../b', '{dir}/b', false], + 'current directory stays unchanged' => ['./data', '{dir}/data', false], + 'tilde stays unchanged' => ['~/data', '{dir}/data', false], + ]); + + it('rejects a bind mount that resolves above the root directory', function () { + $service = parentBindService(parentBindCompose('../../../../../../../outside')); + + expect(fn () => serviceParser($service)) + ->toThrow(Exception::class, 'resolves to a path above /'); + }); + + it('keeps the old path of an existing bind mount', function () { + $service = parentBindService(parentBindCompose('../outside')); + $web = ServiceApplication::create(['name' => 'web', 'service_id' => $service->id, 'image' => 'nginx:alpine']); + $oldPath = base_configuration_dir().'/services/'.$service->uuid.'./outside'; + LocalFileVolume::create([ + 'fs_path' => $oldPath, + 'mount_path' => '/data', + 'is_directory' => true, + 'resource_id' => $web->id, + 'resource_type' => $web->getMorphClass(), + ]); + + $compose = serviceParser($service)->toArray(); + + expect(parentBindParsedSource($compose))->toBe($oldPath) + ->and($web->fileStorages()->where('mount_path', '/data')->pluck('fs_path')->all())->toBe([$oldPath]); + }); +}); + +describe('validation', function () { + it('accepts parent directory bind sources', function (string $compose) { + validateDockerComposeForInjection($compose); + + expect(parseDockerVolumeString('../outside:/data')['source']->value())->toBe('../outside'); + })->with([ + 'short syntax' => [parentBindCompose('../outside')], + 'long syntax' => [parentBindCompose('../outside', true)], + ]); + + it('still rejects parent directory segments in content volumes', function () { + $compose = <<<'YAML' +services: + web: + image: nginx:alpine + volumes: + - type: bind + source: ../outside/app.conf + target: /app.conf + content: hello +YAML; + + expect(fn () => validateDockerComposeForInjection($compose, '/data/coolify/services/abc')) + ->toThrow(Exception::class, 'must be inside the resource directory'); + }); + + it('rejects shell metacharacters in a resolved parent directory path', function () { + expect(fn () => replaceLocalSource(str('../out;side'), str('/data/coolify/services/abc'))) + ->toThrow(Exception::class); + }); +}); + +describe('legacy parser', function () { + it('keeps the old path in legacy applications, which never stored their bind mounts', function () { + $application = parentBindApplication(parentBindCompose('../outside'), '2'); + + $compose = parseDockerComposeFile($application)->toArray(); + + expect(parentBindParsedSource($compose)) + ->toBe(base_configuration_dir().'/applications/'.$application->uuid.'./outside'); + }); +}); + +describe('storage', function () { + it('treats both the old and the resolved path as a Compose mount of the administrator', function (string $fsPath) { + $service = parentBindService(parentBindCompose('../outside')); + $web = ServiceApplication::create(['name' => 'web', 'service_id' => $service->id, 'image' => 'nginx:alpine']); + $volume = LocalFileVolume::create([ + 'fs_path' => str_replace('{uuid}', $service->uuid, $fsPath), + 'mount_path' => '/data', + 'is_directory' => true, + 'resource_id' => $web->id, + 'resource_type' => $web->getMorphClass(), + ]); + + $method = new ReflectionMethod(LocalFileVolume::class, 'isAdminControlledComposeMount'); + + expect($method->invoke($volume->fresh()))->toBeTrue(); + })->with([ + 'resolved path' => [base_configuration_dir().'/services/outside'], + 'old path' => [base_configuration_dir().'/services/{uuid}./outside'], + ]); +}); diff --git a/tests/Feature/ComposeVolumeDriverOptionsTest.php b/tests/Feature/ComposeVolumeDriverOptionsTest.php new file mode 100644 index 0000000000..32a3904b9b --- /dev/null +++ b/tests/Feature/ComposeVolumeDriverOptionsTest.php @@ -0,0 +1,297 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + config(['constants.ssh.mux_enabled' => false]); + Bus::fake(); + Queue::fake(); + Process::fake(['*' => Process::result(output: '')]); + + $team = Team::factory()->create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + ]); + $server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + $this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $this->environment = $project->environments()->first() + ?? Environment::factory()->create(['project_id' => $project->id]); +}); + +function driverOptionsApplication(string $compose, ?string $parsingVersion = null): Application +{ + $application = Application::factory()->create([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => $compose, + 'environment_id' => test()->environment->id, + 'destination_id' => test()->destination->id, + 'destination_type' => test()->destination->getMorphClass(), + ]); + if ($parsingVersion !== null) { + $application->update(['compose_parsing_version' => $parsingVersion]); + } + + return $application->fresh(); +} + +function driverOptionsService(string $compose, ?string $parsingVersion = null): Service +{ + $service = Service::factory()->create([ + 'docker_compose_raw' => $compose, + 'environment_id' => test()->environment->id, + 'server_id' => test()->destination->server_id, + 'destination_id' => test()->destination->id, + 'destination_type' => test()->destination->getMorphClass(), + ]); + if ($parsingVersion !== null) { + $service->update(['compose_parsing_version' => $parsingVersion]); + } + + return $service->fresh(); +} + +function driverOptionsPreview(Application $application, int $pullRequestId = 42): ApplicationPreview +{ + return ApplicationPreview::create([ + 'uuid' => 'driver-options-preview-'.$pullRequestId, + 'application_id' => $application->id, + 'pull_request_id' => $pullRequestId, + 'pull_request_html_url' => 'https://github.com/example/repository/pull/'.$pullRequestId, + ]); +} + +/** + * @return array + */ +function expectedDriverOptionsVolume(string $name): array +{ + return [ + 'driver' => 'local', + 'driver_opts' => [ + 'type' => 'none', + 'o' => 'bind', + 'device' => '${OPTS_DEVICE}', + ], + 'labels' => [ + 'com.example.purpose' => 'host-folder', + ], + 'name' => $name, + ]; +} + +/** + * @param array $volumes + * @return list + */ +function driverOptionsVolumeSources(array $volumes): array +{ + return collect($volumes) + ->map(fn (mixed $volume): string => is_array($volume) ? (string) $volume['source'] : explode(':', $volume)[0]) + ->values() + ->all(); +} + +describe('applicationParser', function () { + it('keeps driver, driver_opts and labels on the renamed volume', function (string $compose) { + $application = driverOptionsApplication($compose); + $uuid = $application->uuid; + + $parsed = applicationParser($application)->toArray(); + + expect(driverOptionsVolumeSources($parsed['services']['web']['volumes']))->toBe(["{$uuid}_opts"]) + ->and($parsed['volumes']["{$uuid}_opts"])->toBe(expectedDriverOptionsVolume("{$uuid}_opts")); + })->with([ + 'short syntax' => [DRIVER_OPTIONS_SHORT_COMPOSE], + 'long syntax' => [DRIVER_OPTIONS_LONG_COMPOSE], + ]); + + it('keeps driver options on the preview volume', function () { + $application = driverOptionsApplication(DRIVER_OPTIONS_SHORT_COMPOSE); + $uuid = $application->uuid; + $preview = driverOptionsPreview($application); + + $parsed = applicationParser($application, 42, $preview->id)->toArray(); + $name = addPreviewDeploymentSuffix("{$uuid}_opts", 42); + + expect(driverOptionsVolumeSources($parsed['services']['web-pr-42']['volumes']))->toBe([$name]) + ->and($parsed['volumes'][$name])->toBe(expectedDriverOptionsVolume($name)); + }); + + it('replaces the name of the declaration with the renamed volume', function () { + $application = driverOptionsApplication(DRIVER_OPTIONS_NAMED_COMPOSE); + $uuid = $application->uuid; + + $parsed = applicationParser($application)->toArray(); + + expect($parsed['volumes']["{$uuid}_opts"])->toBe([ + 'driver' => 'local', + 'driver_opts' => ['type' => 'none', 'o' => 'bind', 'device' => '/srv/data'], + 'name' => "{$uuid}_opts", + ]); + }); + + it('keeps an nfs volume next to other volumes', function () { + $application = driverOptionsApplication(DRIVER_OPTIONS_NFS_MIXED_COMPOSE); + $uuid = $application->uuid; + + $parsed = applicationParser($application)->toArray(); + + expect($parsed['services']['web']['volumes'])->toBe(['nfs-data:/nfs', "{$uuid}_app-data:/app"]) + ->and($parsed['volumes']['nfs-data']['driver_opts']['type'])->toBe('nfs'); + }); +}); + +describe('serviceParser', function () { + it('keeps driver, driver_opts and labels on the renamed volume', function (string $compose) { + $service = driverOptionsService($compose); + $uuid = $service->uuid; + + $parsed = serviceParser($service)->toArray(); + + expect(driverOptionsVolumeSources($parsed['services']['web']['volumes']))->toBe(["{$uuid}_opts"]) + ->and($parsed['volumes']["{$uuid}_opts"])->toBe(expectedDriverOptionsVolume("{$uuid}_opts")); + })->with([ + 'short syntax' => [DRIVER_OPTIONS_SHORT_COMPOSE], + 'long syntax' => [DRIVER_OPTIONS_LONG_COMPOSE], + ]); + + it('keeps an nfs volume next to other volumes', function () { + $service = driverOptionsService(DRIVER_OPTIONS_NFS_MIXED_COMPOSE); + $uuid = $service->uuid; + + $parsed = serviceParser($service)->toArray(); + + expect($parsed['services']['web']['volumes'])->toBe(['nfs-data:/nfs', "{$uuid}_app-data:/app"]) + ->and($parsed['volumes']['nfs-data']['driver_opts']['type'])->toBe('nfs'); + }); +}); + +describe('legacy parsers', function () { + it('keeps driver options on the renamed volume of a legacy service', function (string $compose) { + $service = driverOptionsService($compose, '2'); + $uuid = $service->uuid; + + $parsed = parseDockerComposeFile($service)->toArray(); + + expect($parsed['volumes']["{$uuid}_opts"])->toBe(expectedDriverOptionsVolume("{$uuid}_opts")); + })->with([ + 'short syntax' => [DRIVER_OPTIONS_SHORT_COMPOSE], + 'long syntax' => [DRIVER_OPTIONS_LONG_COMPOSE], + ]); + + it('keeps driver options on the renamed volume of a legacy application', function (string $parsingVersion, int $pullRequestId, string $compose, string $expectedName) { + $application = driverOptionsApplication($compose, $parsingVersion); + $uuid = $application->uuid; + $previewId = $pullRequestId === 0 ? null : driverOptionsPreview($application, $pullRequestId)->id; + $expectedName = str_replace('{uuid}', $uuid, $expectedName); + + $parsed = parseDockerComposeFile($application, pull_request_id: $pullRequestId, preview_id: $previewId)->toArray(); + + expect($parsed['volumes'][$expectedName])->toBe(expectedDriverOptionsVolume($expectedName)); + })->with([ + 'v1 short preview' => ['1', 42, DRIVER_OPTIONS_SHORT_COMPOSE, 'opts-pr-42'], + 'v1 long preview' => ['1', 42, DRIVER_OPTIONS_LONG_COMPOSE, 'opts-pr-42'], + 'v2 short' => ['2', 0, DRIVER_OPTIONS_SHORT_COMPOSE, '{uuid}-opts'], + 'v2 short preview' => ['2', 42, DRIVER_OPTIONS_SHORT_COMPOSE, '{uuid}-opts-pr-42'], + 'v2 long' => ['2', 0, DRIVER_OPTIONS_LONG_COMPOSE, '{uuid}-opts'], + 'v2 long preview' => ['2', 42, DRIVER_OPTIONS_LONG_COMPOSE, '{uuid}-opts-pr-42'], + ]); + + it('keeps the volume of a legacy application as written', function () { + $application = driverOptionsApplication(DRIVER_OPTIONS_SHORT_COMPOSE, '1'); + + $parsed = parseDockerComposeFile($application)->toArray(); + + expect($parsed['services']['web']['volumes'])->toBe(['opts:/data']) + ->and($parsed['volumes']['opts']['driver_opts']['device'])->toBe('${OPTS_DEVICE}'); + }); +}); diff --git a/tests/Feature/DatabaseImportRestoreOptionsTest.php b/tests/Feature/DatabaseImportRestoreOptionsTest.php new file mode 100644 index 0000000000..23338abd7e --- /dev/null +++ b/tests/Feature/DatabaseImportRestoreOptionsTest.php @@ -0,0 +1,212 @@ +set('cache.default', 'array'); + config()->set('constants.ssh.mux_enabled', false); + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true, 'disable_two_step_confirmation' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id, 'user' => 'root']); + $this->destination = StandaloneDocker::firstOrCreate( + ['server_id' => $this->server->id, 'network' => 'coolify'], + ['uuid' => (string) Str::uuid(), 'name' => 'docker'] + ); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); + + $this->sqlite = create_standalone_sqlite($this->environment->id, $this->destination, ['sqlite_databases' => 'app.db,cache.db']); + $this->sqlite->update(['status' => 'running:healthy']); + $this->postgres = StandalonePostgresql::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'db', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'db', + 'image' => 'postgres:17', + 'status' => 'running', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + Queue::fake(); +}); + +/** + * Starts an import from a server path and returns the restore script that runs in the container. + */ +function importRestoreScript(Model $database, DatabaseImportSource $source): string +{ + Process::fake([ + '*stat -c %s*' => Process::result('2048'), + '*od -An -tx1*' => Process::result(' 1f 8b 08 00 00 00'), + '*' => Process::result(''), + ]); + + $activity = app(StartDatabaseImport::class)->handle($database, $source, test()->team->id); + preg_match("/echo '([^']+)' \\| base64 -d/", (string) RemoteProcessCommand::read($activity), $matches); + + return base64_decode($matches[1]); +} + +class ImportFormRestoreOptionsTestComponent extends ImportForm +{ + public function mount($database = null, $server = null): void + { + $this->resourceId = $database->id; + $this->resourceType = $database::class; + $this->serverId = $server->id; + $this->container = $database->uuid; + $this->resourceUuid = $database->uuid; + $this->resourceStatus = $database->status ?? ''; + $this->resourceDbType = $database->type(); + $this->loadAvailableS3Storages(); + $this->initializeRestoreOptions(); + } + + public function render() + { + return view('livewire.project.database.import-form'); + } +} + +function restoreOptionsForm(Model $database): Testable +{ + return Livewire::test(ImportFormRestoreOptionsTestComponent::class, ['database' => $database, 'server' => test()->server]); +} + +test('restores a SQLite server backup into the file named in the backup', function () { + $script = importRestoreScript($this->sqlite, new DatabaseImportSource('server', path: '/backups/sqlite-backup-cache.db-1700000000.gz')); + + expect($script)->toContain("sqlite3 -bail '/var/lib/sqlite/cache.db'") + ->not->toContain('/var/lib/sqlite/app.db'); +}); + +test('restores a SQLite backup into the selected database file', function () { + $script = importRestoreScript($this->sqlite, new DatabaseImportSource('server', path: '/backups/sqlite-backup-app.db-1.gz', sqliteDatabase: 'cache.db')); + + expect($script)->toContain("sqlite3 -bail '/var/lib/sqlite/cache.db'"); +}); + +test('rejects a SQLite restore target that is not one of the database files', function (string $target) { + Process::fake(); + + expect(fn () => app(StartDatabaseImport::class)->handle( + $this->sqlite, + new DatabaseImportSource('server', path: '/backups/backup.gz', sqliteDatabase: $target), + $this->team->id, + ))->toThrow(DatabaseImportException::class, 'not one of the files of this database'); + + Queue::assertNothingPushed(); +})->with(['unknown file' => ['other.db'], 'path traversal' => ['../app.db'], 'absolute path' => ['/etc/passwd']]); + +test('passes the keep owners option to the PostgreSQL restore script', function (bool $keepOwners) { + $script = importRestoreScript($this->postgres, new DatabaseImportSource('server', path: '/backups/app.dump', keepOwners: $keepOwners)); + + expect(str_contains($script, '--no-owner --no-acl'))->toBe(! $keepOwners); +})->with(['default' => [false], 'keep owners' => [true]]); + +test('the import form strips owners by default and keeps them when the option is checked', function () { + $activity = Activity::create(['log_name' => 'default', 'description' => 'queued', 'properties' => ['status' => 'queued']]); + StartDatabaseImport::shouldRun()->once()->withArgs(fn ($resource, DatabaseImportSource $source) => $source->keepOwners === true)->andReturn($activity); + + $form = restoreOptionsForm($this->postgres) + ->assertSet('keepOwners', false) + ->assertSee('Keep owners and privileges'); + expect($form->get('restoreCommandText'))->toContain('--no-owner --no-acl'); + + $form->set('keepOwners', true); + expect($form->get('restoreCommandText'))->not->toContain('--no-owner'); + + $form->set('filename', 'backup.dump')->set('customLocation', '/backups/backup.dump')->call('runImport'); +}); + +test('the import form lets the user pick the SQLite database file to restore into', function () { + $activity = Activity::create(['log_name' => 'default', 'description' => 'queued', 'properties' => ['status' => 'queued']]); + StartDatabaseImport::shouldRun()->once()->withArgs(fn ($resource, DatabaseImportSource $source) => $source->sqliteDatabase === 'cache.db')->andReturn($activity); + + $form = restoreOptionsForm($this->sqlite) + ->assertSet('sqliteDatabase', 'app.db') + ->assertSee('Restore into'); + expect($form->get('restoreCommandText'))->toContain("'/var/lib/sqlite/app.db'"); + + $form->call('selectSqliteDatabaseFor', 'sqlite-backup-cache.db-1700000000.gz') + ->assertSet('sqliteDatabase', 'cache.db'); + expect($form->get('restoreCommandText'))->toContain("'/var/lib/sqlite/cache.db'"); + + $form->set('filename', 'backup.gz')->set('customLocation', '/backups/backup.gz')->call('runImport'); +}); + +test('the import form does not accept a SQLite target from the client that is not a database file', function () { + $form = restoreOptionsForm($this->sqlite) + ->set('sqliteDatabase', '../../etc/passwd') + ->assertSet('sqliteDatabase', 'app.db'); + + expect($form->get('restoreCommandText'))->not->toContain('passwd'); +}); + +test('the API passes keep owners and the SQLite target to the import', function () { + // API requests authenticate with the token, not with the session user from beforeEach. + app('auth')->forgetGuards(); + $headers = ['Authorization' => 'Bearer '.$this->user->createToken('imports', ['*'])->plainTextToken]; + $activity = Activity::create(['log_name' => 'default', 'description' => 'queued', 'properties' => ['status' => 'queued']]); + $action = Mockery::mock(StartDatabaseImport::class); + $action->shouldReceive('handle')->once()->withArgs(fn ($resource, DatabaseImportSource $source) => $resource->is($this->postgres) && $source->keepOwners === true)->andReturn($activity); + $action->shouldReceive('handle')->once()->withArgs(fn ($resource, DatabaseImportSource $source) => $resource->is($this->sqlite) && $source->sqliteDatabase === 'cache.db')->andReturn($activity); + app()->instance(StartDatabaseImport::class, $action); + + $this->withHeaders($headers) + ->postJson("/api/v1/databases/{$this->postgres->uuid}/imports", ['source' => 'server', 'path' => '/tmp/backup.dump', 'keep_owners' => true]) + ->assertAccepted(); + $this->withHeaders($headers) + ->postJson("/api/v1/databases/{$this->sqlite->uuid}/imports", ['source' => 'server', 'path' => '/tmp/backup.gz', 'sqlite_database' => 'cache.db']) + ->assertAccepted(); + $this->withHeaders($headers) + ->postJson("/api/v1/databases/{$this->postgres->uuid}/imports", ['source' => 'server', 'path' => '/tmp/backup.dump', 'keep_owners' => 'yes']) + ->assertUnprocessable() + ->assertJsonValidationErrors('keep_owners'); +}); + +test('the API rejects a SQLite target that is not a database file', function () { + Process::fake(); + // API requests authenticate with the token, not with the session user from beforeEach. + app('auth')->forgetGuards(); + $headers = ['Authorization' => 'Bearer '.$this->user->createToken('imports', ['*'])->plainTextToken]; + + $this->withHeaders($headers) + ->postJson("/api/v1/databases/{$this->sqlite->uuid}/imports", ['source' => 'server', 'path' => '/tmp/backup.gz', 'sqlite_database' => '../app.db']) + ->assertUnprocessable(); + + Queue::assertNothingPushed(); +}); diff --git a/tests/Feature/DnsProviderManagementTest.php b/tests/Feature/DnsProviderManagementTest.php index 2e88e4d123..c12da66b41 100644 --- a/tests/Feature/DnsProviderManagementTest.php +++ b/tests/Feature/DnsProviderManagementTest.php @@ -143,6 +143,29 @@ test('queued dns configuration creates the record and broadcasts completion', fu && $event->hostname === 'app.example.com'); }); +test('queued dns configuration refuses a private record address without calling the provider', function (string $address) { + Event::fake([DnsRecordConfigurationFinished::class]); + $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']); + $zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']); + Http::fake(); + + $job = new ConfigureDnsRecordJob( + teamId: $token->team_id, + zoneId: $zone->id, + resourceType: null, + resourceId: null, + hostname: 'app.example.com', + content: $address, + ); + $job->handle(app(CloudflareDnsProvider::class)); + + Http::assertNothingSent(); + expect(ManagedDnsRecord::query()->where('name', 'app.example.com')->exists())->toBeFalse(); + Event::assertDispatched(DnsRecordConfigurationFinished::class, fn ($event) => $event->successful === false + && $event->hostname === 'app.example.com' + && $event->message === 'The server has no public IP address; add the DNS record manually.'); +})->with(['10.0.0.5', '100.100.1.1', '127.0.0.1', '169.254.1.1', 'fd00::5']); + test('missing zone throws from handle without broadcasting completion', function () { Event::fake([DnsRecordConfigurationFinished::class]); $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']); @@ -443,6 +466,72 @@ describe('domain DNS configuration after add', function () { && (string) $job->resourceId === (string) $webApp->id); Queue::assertNotPushed(CheckDomainDnsJob::class); }); + + test('private server addresses are never queued as dns records', function (string $address) { + $this->server->update(['ip' => $address]); + $application = Application::factory()->create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Private DNS App', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'fqdn' => null, + 'redirect' => 'both', + 'build_pack' => 'nixpacks', + ]); + $application->settings()->update(['is_container_label_readonly_enabled' => true]); + + Livewire::test(Domains::class, ['application' => $application->fresh()]) + ->set('newDomain', 'https://app.example.com') + ->call('addDomain') + ->assertHasNoErrors() + ->assertDispatched('warning', 'The server has no public IP address; add the DNS record manually.') + ->assertNotDispatched('info') + ->assertSet('showDnsProviderModal', false); + + expect(explode(',', (string) $application->fresh()->fqdn))->toContain('https://app.example.com'); + Queue::assertNotPushed(ConfigureDnsRecordJob::class); + })->with([ + 'rfc1918 10/8' => '10.0.0.5', + 'rfc1918 172.16/12' => '172.16.0.5', + 'rfc1918 192.168/16' => '192.168.1.5', + 'cgnat' => '100.64.0.5', + 'tailscale' => '100.100.1.1', + 'loopback' => '127.0.0.1', + 'link-local' => '169.254.1.1', + 'ipv6 unique local' => 'fd00::5', + 'ipv6 link-local' => 'fe80::5', + 'ipv6 loopback' => '::1', + ]); + + test('service domains never queue a dns record for a private server address', function () { + $this->server->update(['ip' => '100.100.1.1']); + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", + ]); + $webApp = ServiceApplication::create([ + 'uuid' => (string) Str::uuid(), + 'service_id' => $service->id, + 'name' => 'web', + 'human_name' => 'Web', + 'image' => 'nginx:alpine', + 'fqdn' => null, + ]); + + Livewire::test(ServiceDomains::class, ['service' => $service->fresh(['applications', 'server'])]) + ->set('newServiceApplicationId', $webApp->id) + ->set('newDomain', 'https://web.example.com') + ->call('addDomain') + ->assertHasNoErrors() + ->assertDispatched('warning', 'The server has no public IP address; add the DNS record manually.') + ->assertSet('showDnsProviderModal', false); + + Queue::assertNotPushed(ConfigureDnsRecordJob::class); + }); }); test('dns provider action controls declare update authorization against the resource', function () { @@ -659,6 +748,42 @@ test('dns records point only at an address of the resource server in a zone of t 'hostname outside the zone' => ['www.other.org', null], ]); +test('a dns record is never created for a private server address', function (string $address, ?string $content) { + ['application' => $application, 'zone' => $zone] = prepareManagedDnsApplication(); + Server::query()->whereKey($application->destination->server_id)->update(['ip' => $address]); + Http::fake(); + + Livewire::test(Domains::class, ['application' => $application->fresh()]) + ->call('createManagedDnsRecord', 'app.example.com', $zone->id, $content) + ->assertDispatched('error', 'The server has no public IP address; add the DNS record manually.'); + Http::assertNothingSent(); + expect(ManagedDnsRecord::query()->exists())->toBeFalse(); +})->with([ + 'private ipv4' => ['10.0.0.5', null], + 'private ipv4 requested explicitly' => ['10.0.0.5', '10.0.0.5'], + 'tailscale cgnat' => ['100.100.1.1', '100.100.1.1'], + 'ipv6 unique local' => ['fd00::5', 'fd00::5'], +]); + +test('replacing a conflicting dns record is refused once the server has no public address', function () { + ['application' => $application, 'zone' => $zone] = prepareManagedDnsApplication(); + Http::fake([ + 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response([ + 'success' => true, + 'result' => [['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.50']], + ]), + ]); + $component = Livewire::test(Domains::class, ['application' => $application->fresh()]) + ->call('createManagedDnsRecord', 'app.example.com', $zone->id); + + Server::query()->whereKey($application->destination->server_id)->update(['ip' => '192.168.1.5']); + $component->call('refreshDomains') + ->call('replaceManagedDnsRecord', 'app.example.com', $zone->id) + ->assertDispatched('error', 'The server has no public IP address; add the DNS record manually.'); + + Http::assertNotSent(fn ($request) => $request->method() === 'PATCH'); +}); + /** * @return array{application: Application, zone: DnsProviderZone} */ diff --git a/tests/Feature/DnsRecordHintsTest.php b/tests/Feature/DnsRecordHintsTest.php index 7236ebf0b8..9f9269a570 100644 --- a/tests/Feature/DnsRecordHintsTest.php +++ b/tests/Feature/DnsRecordHintsTest.php @@ -48,3 +48,29 @@ it('formats a BIND-compatible zone snippet for copy all', function () { "app.example.com. IN AAAA 2001:db8::1\n" ); }); + +it('treats only publicly routable addresses as public dns record targets', function (string $address, bool $public) { + expect(DnsRecordHints::isPublicAddress($address))->toBe($public); +})->with([ + 'public ipv4' => ['8.8.8.8', true], + 'public ipv6' => ['2606:4700::1111', true], + 'rfc1918 10/8' => ['10.0.0.5', false], + 'rfc1918 172.16/12' => ['172.20.1.5', false], + 'rfc1918 192.168/16' => ['192.168.1.5', false], + 'cgnat start' => ['100.64.0.1', false], + 'tailscale cgnat' => ['100.100.100.100', false], + 'cgnat end' => ['100.127.255.254', false], + 'just outside cgnat' => ['100.128.0.1', true], + 'loopback' => ['127.0.0.1', false], + 'link-local' => ['169.254.10.1', false], + 'unspecified' => ['0.0.0.0', false], + 'multicast' => ['224.0.0.1', false], + 'ipv6 unique local' => ['fd00::5', false], + 'ipv6 link-local' => ['fe80::5', false], + 'ipv6 loopback' => ['::1', false], + 'ipv6 multicast' => ['ff02::1', false], + 'ipv4-mapped private' => ['::ffff:10.0.0.5', false], + 'ipv4-mapped cgnat' => ['::ffff:100.64.0.1', false], + 'not an ip' => ['app.example.com', false], + 'empty' => ['', false], +]); diff --git a/tests/Feature/Jobs/RemoteProcessFinishEventTeamTest.php b/tests/Feature/Jobs/RemoteProcessFinishEventTeamTest.php new file mode 100644 index 0000000000..78ade563c9 --- /dev/null +++ b/tests/Feature/Jobs/RemoteProcessFinishEventTeamTest.php @@ -0,0 +1,105 @@ +set('cache.default', 'array'); + config()->set('constants.ssh.mux_enabled', false); + InstanceSettings::forceCreate(['id' => 0]); + + // The user id must differ from the team id, and another team must own the user id, + // so a team event that is sent to the user id would reach the wrong team. + User::factory()->count(2)->create(); + $this->user = User::factory()->create(); + $this->team = Team::factory()->create(); + while (Team::query()->max('id') < $this->user->id) { + Team::factory()->create(); + } + $this->otherTeam = Team::query()->findOrFail($this->user->id); + expect($this->user->id)->not->toBe($this->team->id); + + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + 'user' => 'root', + ]); + + $this->activity = activity() + ->causedBy($this->user) + ->withProperties([ + 'server_uuid' => $this->server->uuid, + 'command' => 'echo test', + 'type' => 'inline', + 'status' => ProcessStatus::QUEUED->value, + 'team_id' => $this->server->team_id, + ]) + ->event('inline') + ->log('[]'); +}); + +it('broadcasts a team event of a finished remote process to the team of the server', function () { + Event::fake([ServiceStatusChanged::class]); + Process::fake(fn () => Process::result(output: 'ok')); + + (new RunRemoteProcess(Activity::query()->findOrFail($this->activity->id), call_event_on_finish: 'ServiceStatusChanged'))(); + + Event::assertDispatched(ServiceStatusChanged::class, fn (ServiceStatusChanged $event) => $event->teamId === $this->team->id + && $event->broadcastOn()[0]->name === "private-team.{$this->team->id}"); + Event::assertNotDispatched(ServiceStatusChanged::class, fn (ServiceStatusChanged $event) => $event->teamId === $this->otherTeam->id); +}); + +it('broadcasts a team event of a failed coolify task to the team of the server', function () { + Event::fake([ServiceStatusChanged::class]); + + (new CoolifyTask(Activity::query()->findOrFail($this->activity->id), ignore_errors: false, call_event_on_finish: 'ServiceStatusChanged', call_event_data: null)) + ->failed(new RuntimeException('failed')); + + Event::assertDispatched(ServiceStatusChanged::class, fn (ServiceStatusChanged $event) => $event->teamId === $this->team->id); + Event::assertNotDispatched(ServiceStatusChanged::class, fn (ServiceStatusChanged $event) => $event->teamId === $this->otherTeam->id); +}); + +it('still sends a user event of a finished remote process to the user who started it', function () { + Event::fake([DatabaseStatusChanged::class]); + Process::fake(fn () => Process::result(output: 'ok')); + + (new RunRemoteProcess(Activity::query()->findOrFail($this->activity->id), call_event_on_finish: 'DatabaseStatusChanged'))(); + + Event::assertDispatched(DatabaseStatusChanged::class, fn (DatabaseStatusChanged $event) => $event->userId === $this->user->id); +}); + +it('keeps the explicit event data of a remote process', function () { + Event::fake([ServiceStatusChanged::class]); + Process::fake(fn () => Process::result(output: 'ok')); + + (new RunRemoteProcess(Activity::query()->findOrFail($this->activity->id), call_event_on_finish: 'ServiceStatusChanged', call_event_data: $this->otherTeam->id))(); + + Event::assertDispatched(ServiceStatusChanged::class, fn (ServiceStatusChanged $event) => $event->teamId === $this->otherTeam->id); +}); + +it('does not broadcast a team event when the activity has no team', function () { + Event::fake([ServiceStatusChanged::class]); + Process::fake(fn () => Process::result(output: 'ok')); + $activity = Activity::query()->findOrFail($this->activity->id); + $activity->properties = $activity->properties->except('team_id'); + $activity->save(); + + (new RunRemoteProcess($activity, call_event_on_finish: 'ServiceStatusChanged'))(); + + Event::assertNotDispatched(ServiceStatusChanged::class, fn (ServiceStatusChanged $event) => $event->teamId === $this->otherTeam->id); +}); diff --git a/tests/Feature/MissingDatabaseBackupNotificationTest.php b/tests/Feature/MissingDatabaseBackupNotificationTest.php index 9ec50feae3..0e458eab5f 100644 --- a/tests/Feature/MissingDatabaseBackupNotificationTest.php +++ b/tests/Feature/MissingDatabaseBackupNotificationTest.php @@ -6,6 +6,7 @@ use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackupExecution; use App\Models\Team; use App\Notifications\Database\BackupMissing; +use Illuminate\Contracts\Notifications\Dispatcher; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Notification; @@ -125,3 +126,12 @@ it('waits to mark an incident sent until a notification channel is enabled', fun Notification::assertSentTo($team, BackupMissing::class); }); + +it('releases the missing backup claim when sending the notification throws', function () { + $team = teamWithBackupFailureNotifications(); + $backup = missingBackupSchedule($team); + $this->mock(Dispatcher::class, fn ($mock) => $mock->shouldReceive('send')->once()->andThrow(new RuntimeException('SMTP is down'))); + + expect(fn () => (new CheckMissingDatabaseBackupsJob)->handle())->toThrow(RuntimeException::class, 'SMTP is down') + ->and(NotificationThrottle::wasSent($backup, BackupMissing::class))->toBeFalse(); +}); diff --git a/tests/Feature/NotificationThrottleTest.php b/tests/Feature/NotificationThrottleTest.php index b6d3d881f0..77ad73cc80 100644 --- a/tests/Feature/NotificationThrottleTest.php +++ b/tests/Feature/NotificationThrottleTest.php @@ -8,6 +8,7 @@ use App\Models\User; use App\Notifications\Server\HighDiskUsage; use App\Notifications\Server\Unreachable; use Carbon\Carbon; +use Illuminate\Contracts\Notifications\Dispatcher as NotificationDispatcher; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -80,3 +81,59 @@ it('keeps unreachable_notification_sent in the server API response', function () ->assertOk() ->assertJsonPath('unreachable_notification_sent', true); }); + +function throttledDiskUsageNotification(Server $server): HighDiskUsage +{ + return new HighDiskUsage($server, 95, 80); +} + +it('releases the throttle claim when sending the notification throws', function () { + $sends = 0; + $this->mock(NotificationDispatcher::class, function ($mock) use (&$sends) { + $mock->shouldReceive('send')->twice()->andReturnUsing(function () use (&$sends) { + $sends++; + if ($sends === 1) { + throw new RuntimeException('SMTP is down'); + } + }); + }); + + expect(fn () => $this->team->notify(throttledDiskUsageNotification($this->server))) + ->toThrow(RuntimeException::class, 'SMTP is down') + ->and(NotificationThrottle::wasSent($this->server, HighDiskUsage::class))->toBeFalse(); + + $this->team->notify(throttledDiskUsageNotification($this->server)); + + expect($sends)->toBe(2) + ->and(NotificationThrottle::wasSent($this->server, HighDiskUsage::class))->toBeTrue(); +}); + +it('throttles the notification after it was sent successfully', function () { + $this->mock(NotificationDispatcher::class, fn ($mock) => $mock->shouldReceive('send')->once()); + + $this->team->notify(throttledDiskUsageNotification($this->server)); + $this->team->notify(throttledDiskUsageNotification($this->server)); + + expect(NotificationThrottle::wasSent($this->server, HighDiskUsage::class))->toBeTrue(); +}); + +it('does not send a notification that another worker is already sending', function () { + $notification = throttledDiskUsageNotification($this->server); + $concurrentClaim = null; + $this->mock(NotificationDispatcher::class, function ($mock) use ($notification, &$concurrentClaim) { + $mock->shouldReceive('send')->once()->andReturnUsing(function () use ($notification, &$concurrentClaim) { + $concurrentClaim = NotificationThrottle::claimFor($notification); + }); + }); + + $this->team->notify($notification); + + expect($concurrentClaim)->toBeFalse(); +}); + +it('releases the unreachable claim when sending the unreachable notification throws', function () { + $this->mock(NotificationDispatcher::class, fn ($mock) => $mock->shouldReceive('send')->once()->andThrow(new RuntimeException('Discord is down'))); + + expect(fn () => $this->server->sendUnreachableNotification())->toThrow(RuntimeException::class) + ->and(NotificationThrottle::wasSent($this->server, Unreachable::class))->toBeFalse(); +}); diff --git a/tests/Feature/OauthLoginTeamSelectionTest.php b/tests/Feature/OauthLoginTeamSelectionTest.php new file mode 100644 index 0000000000..d2beeae2dc --- /dev/null +++ b/tests/Feature/OauthLoginTeamSelectionTest.php @@ -0,0 +1,134 @@ +set('app.maintenance.store', 'array'); + config()->set('app.maintenance.driver', 'file'); + + InstanceSettings::forceCreate([ + 'id' => 0, + 'is_registration_enabled' => false, + ]); + + Once::flush(); + + OauthSetting::create([ + 'provider' => 'google', + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'redirect_uri' => 'https://coolify.example.com/auth/google/callback', + 'tenant' => 'example.com', + 'enabled' => true, + ]); +}); + +/** + * @return array{0: User, 1: Team, 2: Team} + */ +function oauthUserWithTwoTeams(): array +{ + $user = User::factory()->create(['email' => 'multi-team@example.com']); + $personal = $user->teams->first(); + $personal->update(['show_boarding' => false]); + + $second = Team::factory()->create(['show_boarding' => false]); + $user->teams()->attach($second, ['role' => 'owner']); + + OauthIdentity::create([ + 'user_id' => $user->id, + 'provider' => 'google', + 'issuer' => 'google', + 'provider_user_id' => 'multi-team-google-id', + 'email' => $user->email, + ]); + + return [$user->refresh(), $personal, $second]; +} + +function fakeGoogleLoginFor(User $user, string $providerUserId): void +{ + $provider = Mockery::mock(); + $provider->shouldReceive('setConfig')->andReturnSelf(); + $provider->shouldReceive('with')->andReturnSelf(); + $provider->shouldReceive('user')->andReturn((object) [ + 'email' => $user->email, + 'name' => $user->name, + 'id' => $providerUserId, + 'user' => ['email_verified' => true, 'hd' => 'example.com'], + ]); + + Socialite::shouldReceive('driver')->with('google')->andReturn($provider); +} + +it('restores the stored last team after an OAuth login', function () { + [$user, , $second] = oauthUserWithTwoTeams(); + $user->forceFill(['current_team_id' => $second->id])->save(); + fakeGoogleLoginFor($user, 'multi-team-google-id'); + + $this->get(route('auth.callback', 'google'))->assertRedirect('/'); + + $this->assertAuthenticatedAs($user); + expect(data_get(session('currentTeam'), 'id'))->toBe($second->id); + + Cache::flush(); + $this->get('/')->assertSuccessful(); + + expect($user->fresh()->current_team_id)->toBe($second->id); +}); + +it('sends a multi-team OAuth user without a stored team to the team selection screen', function () { + [$user] = oauthUserWithTwoTeams(); + expect($user->current_team_id)->toBeNull(); + fakeGoogleLoginFor($user, 'multi-team-google-id'); + + $this->get(route('auth.callback', 'google'))->assertRedirect('/'); + + $this->assertAuthenticatedAs($user); + expect(session('currentTeam'))->toBeNull(); + + $this->get('/')->assertRedirect(route('team.select')); + + expect($user->fresh()->current_team_id)->toBeNull(); +}); + +it('does not overwrite a stored last team with the first team after an OAuth login', function () { + [$user, $personal, $second] = oauthUserWithTwoTeams(); + $user->forceFill(['current_team_id' => $second->id])->save(); + fakeGoogleLoginFor($user, 'multi-team-google-id'); + + $this->get(route('auth.callback', 'google')); + Cache::flush(); + $this->get('/'); + + expect($user->fresh()->current_team_id)->not->toBe($personal->id) + ->and($user->fresh()->current_team_id)->toBe($second->id); +}); + +it('activates the only team of a single-team OAuth user', function () { + $user = User::factory()->create(['email' => 'single-team@example.com']); + $team = $user->teams->first(); + $team->update(['show_boarding' => false]); + OauthIdentity::create([ + 'user_id' => $user->id, + 'provider' => 'google', + 'issuer' => 'google', + 'provider_user_id' => 'single-team-google-id', + 'email' => $user->email, + ]); + fakeGoogleLoginFor($user, 'single-team-google-id'); + + $this->get(route('auth.callback', 'google'))->assertRedirect('/'); + + expect(data_get(session('currentTeam'), 'id'))->toBe($team->id); +}); diff --git a/tests/Feature/OauthPasswordConfirmationTest.php b/tests/Feature/OauthPasswordConfirmationTest.php new file mode 100644 index 0000000000..6548a23214 --- /dev/null +++ b/tests/Feature/OauthPasswordConfirmationTest.php @@ -0,0 +1,369 @@ +set('app.maintenance.store', 'array'); + config()->set('app.maintenance.driver', 'file'); + + InstanceSettings::forceCreate([ + 'id' => 0, + 'is_registration_enabled' => false, + ]); + + Once::flush(); + + OauthSetting::create([ + 'provider' => 'google', + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'redirect_uri' => 'https://coolify.example.com/auth/google/callback', + 'tenant' => 'example.com', + 'enabled' => true, + ]); +}); + +function linkGoogleIdentity(User $user, string $providerUserId): OauthIdentity +{ + return OauthIdentity::create([ + 'user_id' => $user->id, + 'provider' => 'google', + 'issuer' => 'google', + 'provider_user_id' => $providerUserId, + 'email' => $user->email, + ]); +} + +function fakeGoogleConfirmation(string $providerUserId, string $email): void +{ + $provider = Mockery::mock(); + $provider->shouldReceive('setConfig')->andReturnSelf(); + $provider->shouldReceive('with')->andReturnSelf(); + $provider->shouldReceive('user')->andReturn((object) [ + 'email' => $email, + 'name' => 'Provider User', + 'id' => $providerUserId, + 'user' => ['email_verified' => true, 'hd' => 'example.com'], + ]); + + Socialite::shouldReceive('driver')->with('google')->andReturn($provider); +} + +function pendingConfirmationFor(User $user, string $provider = 'google', string $returnTo = 'http://localhost/servers'): array +{ + return [OauthController::PASSWORD_CONFIRMATION_SESSION_KEY => [ + 'user_id' => $user->id, + 'provider' => $provider, + 'return_to' => $returnTo, + ]]; +} + +describe('confirmation requirement', function () { + it('requires password confirmation for a user with a password and a linked identity', function () { + $user = User::factory()->create(['password' => Hash::make('secret-password')]); + linkGoogleIdentity($user, 'google-id'); + $this->actingAs($user); + + expect($user->requiresPasswordConfirmation())->toBeTrue() + ->and(shouldSkipPasswordConfirmation())->toBeFalse() + ->and(verifyPasswordConfirmation('wrong-password'))->toBeFalse() + ->and(verifyPasswordConfirmation('secret-password'))->toBeTrue(); + }); + + it('requires confirmation for a user with a linked identity and no password', function () { + $user = User::factory()->create(); + $user->forceFill(['password' => null])->save(); + linkGoogleIdentity($user, 'google-id'); + $this->actingAs($user); + + expect($user->requiresPasswordConfirmation())->toBeTrue() + ->and(shouldSkipPasswordConfirmation())->toBeFalse() + ->and(verifyPasswordConfirmation(''))->toBeFalse(); + }); + + it('keeps skipping confirmation for a user without a password and without an identity', function () { + $user = User::factory()->create(); + $user->forceFill(['password' => null])->save(); + $this->actingAs($user); + + expect($user->requiresPasswordConfirmation())->toBeFalse() + ->and(shouldSkipPasswordConfirmation())->toBeTrue(); + }); + + it('accepts a recent confirmation from the session', function () { + $user = User::factory()->create(); + linkGoogleIdentity($user, 'google-id'); + $this->actingAs($user); + + session()->put('auth.password_confirmed_at', now()->unix()); + + expect(shouldSkipPasswordConfirmation())->toBeTrue() + ->and(verifyPasswordConfirmation(''))->toBeTrue(); + }); + + it('rejects an expired confirmation from the session', function () { + $user = User::factory()->create(); + linkGoogleIdentity($user, 'google-id'); + $this->actingAs($user); + + session()->put('auth.password_confirmed_at', now()->subSeconds(config('auth.password_timeout') + 1)->unix()); + + expect(shouldSkipPasswordConfirmation())->toBeFalse() + ->and(verifyPasswordConfirmation(''))->toBeFalse(); + }); +}); + +describe('confirmation redirect', function () { + it('requires an authenticated user', function () { + $this->get(route('auth.confirm', 'google'))->assertRedirect(route('login')); + }); + + it('rejects a user without an identity for the provider', function () { + $user = User::factory()->create(); + + $this->actingAs($user)->get(route('auth.confirm', 'google'))->assertForbidden(); + + expect(session(OauthController::PASSWORD_CONFIRMATION_SESSION_KEY))->toBeNull(); + }); + + it('rejects a disabled provider', function () { + $user = User::factory()->create(); + linkGoogleIdentity($user, 'google-id'); + OauthSetting::where('provider', 'google')->update(['enabled' => false]); + + $this->actingAs($user)->get(route('auth.confirm', 'google'))->assertForbidden(); + }); + + it('redirects to the provider with re-authentication parameters and remembers the pending confirmation', function () { + $user = User::factory()->create(); + linkGoogleIdentity($user, 'google-id'); + + $provider = Mockery::mock(); + $provider->shouldReceive('setConfig')->andReturnSelf(); + $provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf(); + $provider->shouldReceive('with')->once()->with([ + 'hd' => 'example.com', + 'prompt' => 'select_account', + 'max_age' => 0, + ])->andReturnSelf(); + $provider->shouldReceive('redirect')->once()->andReturn(redirect('https://accounts.google.com/o/oauth2/auth')); + Socialite::shouldReceive('driver')->with('google')->andReturn($provider); + + $this->actingAs($user) + ->from('/servers') + ->get(route('auth.confirm', 'google')) + ->assertRedirect('https://accounts.google.com/o/oauth2/auth'); + + expect(session(OauthController::PASSWORD_CONFIRMATION_SESSION_KEY))->toBe([ + 'user_id' => $user->id, + 'provider' => 'google', + 'return_to' => url('/servers'), + ]); + }); + + it('does not remember an external return URL', function () { + $user = User::factory()->create(); + linkGoogleIdentity($user, 'google-id'); + $provider = Mockery::mock(); + $provider->shouldReceive('setConfig')->andReturnSelf(); + $provider->shouldReceive('with')->andReturnSelf(); + $provider->shouldReceive('redirect')->andReturn(redirect('https://accounts.google.com/o/oauth2/auth')); + Socialite::shouldReceive('driver')->with('google')->andReturn($provider); + + $this->actingAs($user) + ->withHeader('referer', 'https://evil.example.com/phish') + ->get(route('auth.confirm', 'google')); + + expect(session(OauthController::PASSWORD_CONFIRMATION_SESSION_KEY.'.return_to'))->toBe(url('/')); + }); +}); + +describe('confirmation callback', function () { + it('marks the session as password confirmed when the linked identity of the same user returns', function () { + $user = User::factory()->create(['email' => 'owner@example.com']); + $identity = linkGoogleIdentity($user, 'owner-google-id'); + fakeGoogleConfirmation('owner-google-id', 'owner@example.com'); + + $this->actingAs($user) + ->withSession(pendingConfirmationFor($user)) + ->get(route('auth.callback', 'google')) + ->assertRedirect('http://localhost/servers'); + + $this->assertAuthenticatedAs($user); + expect(session('auth.password_confirmed_at'))->toBeInt() + ->and(session(OauthController::PASSWORD_CONFIRMATION_SESSION_KEY))->toBeNull() + ->and(shouldSkipPasswordConfirmation())->toBeTrue() + ->and($identity->fresh()->user_id)->toBe($user->id); + }); + + it('rejects the identity of another user and does not switch users', function () { + $user = User::factory()->create(['email' => 'owner@example.com']); + linkGoogleIdentity($user, 'owner-google-id'); + $otherUser = User::factory()->create(['email' => 'other@example.com']); + linkGoogleIdentity($otherUser, 'other-google-id'); + fakeGoogleConfirmation('other-google-id', 'other@example.com'); + + $this->actingAs($user) + ->withSession(pendingConfirmationFor($user)) + ->get(route('auth.callback', 'google')) + ->assertForbidden(); + + $this->assertAuthenticatedAs($user); + expect(session('auth.password_confirmed_at'))->toBeNull(); + }); + + it('rejects an unknown identity without linking or creating a user', function () { + InstanceSettings::query()->update(['is_registration_enabled' => true]); + $user = User::factory()->create(['email' => 'owner@example.com']); + linkGoogleIdentity($user, 'owner-google-id'); + fakeGoogleConfirmation('unknown-google-id', 'owner@example.com'); + + $this->actingAs($user) + ->withSession(pendingConfirmationFor($user)) + ->get(route('auth.callback', 'google')) + ->assertForbidden(); + + $this->assertAuthenticatedAs($user); + expect(session('auth.password_confirmed_at'))->toBeNull() + ->and(OauthIdentity::where('provider_user_id', 'unknown-google-id')->exists())->toBeFalse() + ->and(User::count())->toBe(1); + }); + + it('rejects a pending confirmation that belongs to another user', function () { + $user = User::factory()->create(['email' => 'owner@example.com']); + linkGoogleIdentity($user, 'owner-google-id'); + $otherUser = User::factory()->create(['email' => 'other@example.com']); + linkGoogleIdentity($otherUser, 'other-google-id'); + fakeGoogleConfirmation('other-google-id', 'other@example.com'); + + $this->actingAs($user) + ->withSession(pendingConfirmationFor($otherUser)) + ->get(route('auth.callback', 'google')) + ->assertForbidden(); + + $this->assertAuthenticatedAs($user); + expect(session('auth.password_confirmed_at'))->toBeNull(); + }); + + it('rejects a pending confirmation for a different provider', function () { + OauthSetting::create([ + 'provider' => 'github', + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'enabled' => true, + ]); + $user = User::factory()->create(['email' => 'owner@example.com']); + linkGoogleIdentity($user, 'owner-google-id'); + fakeGoogleConfirmation('owner-google-id', 'owner@example.com'); + + $this->actingAs($user) + ->withSession(pendingConfirmationFor($user, 'github')) + ->get(route('auth.callback', 'google')) + ->assertForbidden(); + + expect(session('auth.password_confirmed_at'))->toBeNull(); + }); + + it('confirms an OIDC identity by issuer and subject', function () { + OauthSetting::create([ + 'provider' => 'oidc', + 'enabled' => true, + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'base_url' => 'https://idp.example.com', + 'redirect_uri' => 'https://coolify.example.com/auth/oidc/callback', + ]); + $user = User::factory()->create(['email' => 'oidc@example.com']); + OauthIdentity::create([ + 'user_id' => $user->id, + 'provider' => 'oidc', + 'issuer' => 'https://idp.example.com', + 'provider_user_id' => 'oidc-subject', + 'email' => $user->email, + ]); + $oidcUser = (new OidcUser)->setRaw([ + 'iss' => 'https://idp.example.com', + 'sub' => 'oidc-subject', + 'email' => 'oidc@example.com', + 'email_verified' => true, + ])->map(['id' => 'oidc-subject', 'email' => 'oidc@example.com', 'name' => 'OIDC User']); + $provider = Mockery::mock(); + $provider->shouldReceive('setConfig')->andReturnSelf(); + $provider->shouldReceive('user')->andReturn($oidcUser); + Socialite::shouldReceive('driver')->with('oidc')->andReturn($provider); + + $this->actingAs($user) + ->withSession(pendingConfirmationFor($user, 'oidc')) + ->get(route('auth.callback', 'oidc')) + ->assertRedirect('http://localhost/servers'); + + expect(session('auth.password_confirmed_at'))->toBeInt(); + }); + + it('keeps the normal login flow when no confirmation is pending', function () { + $user = User::factory()->create(['email' => 'owner@example.com']); + linkGoogleIdentity($user, 'owner-google-id'); + fakeGoogleConfirmation('owner-google-id', 'owner@example.com'); + + $this->get(route('auth.callback', 'google'))->assertRedirect('/'); + + $this->assertAuthenticatedAs($user); + expect(session('auth.password_confirmed_at'))->toBeNull(); + }); +}); + +describe('confirmation modal', function () { + function renderConfirmationModal(): string + { + view()->share('errors', new ViewErrorBag); + + return Blade::render(''); + } + + it('offers OAuth confirmation next to the password for a user with a linked identity', function () { + $user = User::factory()->create(); + linkGoogleIdentity($user, 'google-id'); + $this->actingAs($user); + + $html = renderConfirmationModal(); + + expect($html)->toContain('Confirm with Google') + ->and($html)->toContain(route('auth.confirm', 'google')) + ->and($html)->toContain('type="password"'); + }); + + it('offers only OAuth confirmation for a user without a password', function () { + $user = User::factory()->create(); + $user->forceFill(['password' => null])->save(); + linkGoogleIdentity($user, 'google-id'); + $this->actingAs($user); + + $html = renderConfirmationModal(); + + expect($html)->toContain('Confirm with Google') + ->and($html)->not->toContain('type="password"'); + }); + + it('does not offer OAuth confirmation for a user without an identity', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $html = renderConfirmationModal(); + + expect($html)->not->toContain('Confirm with Google') + ->and($html)->toContain('type="password"'); + }); +}); diff --git a/tests/Feature/ServerUpdatePrivateKeyApiTest.php b/tests/Feature/ServerUpdatePrivateKeyApiTest.php index ae567d2da7..18ff2f6ed0 100644 --- a/tests/Feature/ServerUpdatePrivateKeyApiTest.php +++ b/tests/Feature/ServerUpdatePrivateKeyApiTest.php @@ -127,11 +127,12 @@ it('updates the server role through the API', function (string $role) { 'deployment and build' => ['both'], ]); -it('rejects the legacy build server API field', function () { +it('accepts the deprecated build server API field as a server role', function () { patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [ 'is_build_server' => true, - ])->assertUnprocessable() - ->assertJsonValidationErrors('is_build_server'); + ])->assertCreated(); + + expect($this->server->settings->fresh()->server_role->value)->toBe('build'); }); it('requires a dedicated build server before selecting deployment only', function () { diff --git a/tests/Feature/SqliteConnectApplicationTest.php b/tests/Feature/SqliteConnectApplicationTest.php index 9936af6280..2fc7ff3a34 100644 --- a/tests/Feature/SqliteConnectApplicationTest.php +++ b/tests/Feature/SqliteConnectApplicationTest.php @@ -16,6 +16,7 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Process; +use Illuminate\Support\Facades\Queue; use Illuminate\Support\Str; use Livewire\Livewire; @@ -284,3 +285,44 @@ it('blocks deleting the sqlite volume or database while an application is connec expect($databaseVolume->fresh())->not->toBeNull() ->and(StandaloneSqlite::find($this->sqlite->id))->not->toBeNull(); }); + +it('does not connect a cloned application to the sqlite database', function () { + Process::fake(); + $this->application->update(['redirect' => 'both']); + $this->application->refresh(); + $originalVolume = connectSqliteVolume($this->application, $this->sqlite); + + $clone = clone_application($this->application, $this->destination, [ + 'environment_id' => $this->environment->id, + ]); + + $clonedVolume = $clone->persistentStorages()->sole(); + + expect($clonedVolume->standalone_sqlite_id)->toBeNull() + ->and($clonedVolume->name)->toBe($clone->uuid.'-sqlite-data-'.$this->sqlite->uuid) + ->and($clonedVolume->mount_path)->toBe(StandaloneSqlite::DATA_DIRECTORY) + ->and($clonedVolume->isSharedWithAnotherResource())->toBeFalse() + ->and($this->sqlite->connectedVolumes()->pluck('id')->all())->toBe([$originalVolume->id]) + ->and($this->sqlite->connectedApplicationNames()->all())->toBe([$this->application->name]) + ->and($originalVolume->fresh()->standalone_sqlite_id)->toBe($this->sqlite->id); +}); + +it('allows deleting the sqlite database once the original application is gone, even if it was cloned', function () { + Process::fake(); + Queue::fake(); + $this->application->update(['redirect' => 'both']); + $this->application->refresh(); + $originalVolume = connectSqliteVolume($this->application, $this->sqlite); + + clone_application($this->application, $this->destination, [ + 'environment_id' => $this->environment->id, + ]); + + $originalVolume->delete(); + + expect($this->sqlite->fresh()->hasConnectedApplications())->toBeFalse(); + + Livewire::test(Danger::class, ['resource' => $this->sqlite]) + ->call('delete', 'password') + ->assertNotDispatched('error', fn (string $name, array $params) => str_contains($params[0] ?? '', 'is mounted by')); +}); diff --git a/tests/Feature/StandaloneSqliteTest.php b/tests/Feature/StandaloneSqliteTest.php index f3a794d5fa..b63b9917e5 100644 --- a/tests/Feature/StandaloneSqliteTest.php +++ b/tests/Feature/StandaloneSqliteTest.php @@ -86,6 +86,40 @@ it('restores a gzipped backup with .restore into the first database file', funct ->toEndWith("stream > \"\$backup.db\" || fail 'The backup cannot be read. Nothing was changed.'\n[ \"\$(head -c 15 \"\$backup.db\")\" = 'SQLite format 3' ] || { rm -f \"\$backup.db\"; fail 'The backup is not a SQLite database. Nothing was changed.'; }\nsqlite3 -bail '/var/lib/sqlite/app.db' '.timeout 10000' \".restore \$backup.db\"; status=\$?; rm -f \"\$backup.db\"; exit \$status"); }); +it('lists its own database files and resolves only those as restore targets', function () { + expect($this->database->databaseFiles())->toBe(['app.db', 'jobs.db']) + ->and($this->database->databaseFilePath('jobs.db'))->toBe('/var/lib/sqlite/jobs.db') + ->and($this->database->databaseFilePath())->toBe('/var/lib/sqlite/app.db'); +}); + +it('rejects restore targets that are not one of its database files', function (string $file) { + expect(fn () => $this->database->databaseFilePath($file))->toThrow(InvalidArgumentException::class); +})->with([ + 'unknown file' => ['cache.db'], + 'path traversal' => ['../app.db'], + 'absolute path' => ['/etc/passwd'], + 'empty' => [''], +]); + +it('defaults the restore target to the file named in the backup, else the first file', function (?string $backupName, string $expected) { + expect($this->database->defaultRestoreFile($backupName))->toBe($expected); +})->with([ + 'coolify backup of the second file' => ['sqlite-backup-jobs.db-1700000000.gz', 'jobs.db'], + 'server path of the second file' => ['/backups/jobs.db.gz', 'jobs.db'], + 'plain second file' => ['jobs.db', 'jobs.db'], + 'coolify backup of the first file' => ['sqlite-backup-app.db-1700000000.gz', 'app.db'], + 'unrelated name' => ['backup.gz', 'app.db'], + 'no name' => [null, 'app.db'], +]); + +it('restores into the selected database file instead of the first one', function () { + $command = app(DatabaseImportCommandBuilder::class)->buildRestoreCommand($this->database, '/tmp/restore_1', false, sqliteDatabase: 'jobs.db'); + + expect($command)->toContain("sqlite3 -bail '/var/lib/sqlite/jobs.db'") + ->not->toContain('/var/lib/sqlite/app.db') + ->toContain("[ \"\$(head -c 15 \"\$backup.db\")\" = 'SQLite format 3' ]"); +}); + it('normalises the file list when saved from the general page', function () { Livewire::actingAs($this->user) ->test(General::class, ['database' => $this->database]) diff --git a/tests/Unit/DatabaseImport/DatabaseImportCommandBuilderTest.php b/tests/Unit/DatabaseImport/DatabaseImportCommandBuilderTest.php index a9beabb487..79e9198c04 100644 --- a/tests/Unit/DatabaseImport/DatabaseImportCommandBuilderTest.php +++ b/tests/Unit/DatabaseImport/DatabaseImportCommandBuilderTest.php @@ -136,6 +136,32 @@ test('replaces existing PostgreSQL objects when requested', function () { ->toContain('--exit-on-error'); }); +test('restores PostgreSQL archives without owners and privileges by default', function (bool $replaceExisting) { + $command = (new DatabaseImportCommandBuilder)->buildRestoreCommand(importResource(StandalonePostgresql::class), '/tmp/backup.dump', false, $replaceExisting); + + // Dumps from other hosts (for example RDS) reference roles that do not exist here, and + // --single-transaction would roll back the whole restore on the first ALTER OWNER. + expect($command)->toContain('pg_restore --exit-on-error --single-transaction --no-owner --no-acl'); +})->with(['keep existing objects' => [false], 'replace existing objects' => [true]]); + +test('keeps PostgreSQL owners and privileges when requested', function () { + $command = (new DatabaseImportCommandBuilder)->buildRestoreCommand(importResource(StandalonePostgresql::class), '/tmp/backup.dump', false, false, keepOwners: true); + + expect($command)->toContain('pg_restore --exit-on-error --single-transaction -U') + ->not->toContain('--no-owner') + ->not->toContain('--no-acl'); +}); + +test('restores SQLite backups into the selected database file', function () { + $resource = Mockery::mock(StandaloneSqlite::class); + $resource->shouldReceive('getMorphClass')->andReturn(StandaloneSqlite::class); + $resource->shouldReceive('databaseFilePath')->once()->with('cache.db')->andReturn('/var/lib/sqlite/cache.db'); + + $command = (new DatabaseImportCommandBuilder)->buildRestoreCommand($resource, '/tmp/backup', false, sqliteDatabase: 'cache.db'); + + expect($command)->toContain("sqlite3 -bail '/var/lib/sqlite/cache.db'"); +}); + test('rejects unsupported database types', function () { $builder = new DatabaseImportCommandBuilder; $redis = importResource(StandaloneRedis::class); diff --git a/tests/Unit/DatabaseImport/DatabaseImportRestoreScriptTest.php b/tests/Unit/DatabaseImport/DatabaseImportRestoreScriptTest.php index 0d722bce15..f4c749ab7b 100644 --- a/tests/Unit/DatabaseImport/DatabaseImportRestoreScriptTest.php +++ b/tests/Unit/DatabaseImport/DatabaseImportRestoreScriptTest.php @@ -172,7 +172,7 @@ function restoreScriptResource(string $engine): object /** * @return array{exit: int, stdout: string, stderr: string, calls: list, dir: string, backup: string, leftovers: list} */ -function restoreScriptRun(string $engine, string $contents, bool $dumpAll = false, bool $replaceExisting = false): array +function restoreScriptRun(string $engine, string $contents, bool $dumpAll = false, bool $replaceExisting = false, bool $keepOwners = false): array { $dir = restoreScriptTempDir(); @@ -199,7 +199,7 @@ SH); $backup = $dir.'/backup file'; file_put_contents($backup, $contents); - $script = (new DatabaseImportCommandBuilder)->buildRestoreCommand(restoreScriptResource($engine), $backup, $dumpAll, $replaceExisting); + $script = (new DatabaseImportCommandBuilder)->buildRestoreCommand(restoreScriptResource($engine), $backup, $dumpAll, $replaceExisting, $keepOwners); $result = restoreScriptProcess(['sh', '-c', $script], $dir, [ 'PATH' => $dir.'/bin'.PATH_SEPARATOR.(getenv('PATH') ?: '/usr/bin:/bin'), @@ -285,12 +285,12 @@ test('restores single PostgreSQL archives with pg_restore and SQL with psql', fu expect($run['exit'])->toBe(0, $run['stderr']); restoreScriptExpectCalls($run['calls'], $expectedCalls); })->with([ - 'custom archive' => ['pg-custom', false, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction -U postgres -d app']]]], - 'custom archive replacing existing objects' => ['pg-custom', true, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --clean --if-exists -U postgres -d app']]]], - 'gzip custom archive' => ['pg-custom-gz', false, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction -U postgres -d app']]]], - 'gzip custom archive replacing existing objects' => ['pg-custom-gz', true, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --clean --if-exists -U postgres -d app']]]], - 'tar archive' => ['pg-tar', false, [['pg_restore', 'toc.d', ['--exit-on-error --single-transaction -U postgres -d app']]]], - 'gzip tar archive' => ['pg-tar-gz', false, [['pg_restore', 'toc.d', ['--exit-on-error --single-transaction -U postgres -d app']]]], + 'custom archive' => ['pg-custom', false, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --no-owner --no-acl -U postgres -d app']]]], + 'custom archive replacing existing objects' => ['pg-custom', true, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --no-owner --no-acl --clean --if-exists -U postgres -d app']]]], + 'gzip custom archive' => ['pg-custom-gz', false, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --no-owner --no-acl -U postgres -d app']]]], + 'gzip custom archive replacing existing objects' => ['pg-custom-gz', true, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --no-owner --no-acl --clean --if-exists -U postgres -d app']]]], + 'tar archive' => ['pg-tar', false, [['pg_restore', 'toc.d', ['--exit-on-error --single-transaction --no-owner --no-acl -U postgres -d app']]]], + 'gzip tar archive' => ['pg-tar-gz', false, [['pg_restore', 'toc.d', ['--exit-on-error --single-transaction --no-owner --no-acl -U postgres -d app']]]], 'plain SQL' => ['pg-sql', false, [['psql', '-- Po', ['-v ON_ERROR_STOP=1 --single-transaction -U postgres -d app']]]], 'gzip SQL' => ['pg-sql-gz', false, [['psql', '-- Po', ['-v ON_ERROR_STOP=1 --single-transaction -U postgres -d app']]]], // SQL cannot replace single objects: it restores into a new database, and only a @@ -313,6 +313,14 @@ test('restores single PostgreSQL archives with pg_restore and SQL with psql', fu ]], ]); +test('keeps PostgreSQL owners and privileges only when requested', function (string $fixture) { + $run = restoreScriptRun('postgresql', restoreScriptFixture($fixture), keepOwners: true); + + expect($run['exit'])->toBe(0, $run['stderr']); + restoreScriptExpectCalls($run['calls'], [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction -U postgres -d app']]]); + expect($run['calls'][0]['args'])->not->toContain('--no-owner')->not->toContain('--no-acl'); +})->with(['custom archive' => ['pg-custom'], 'gzip custom archive' => ['pg-custom-gz']]); + test('rejects unsupported single PostgreSQL backups before calling any client', function (string $fixture, string $message) { $run = restoreScriptRun('postgresql', restoreScriptFixture($fixture)); diff --git a/tests/Unit/DatabaseImportOpenApiTest.php b/tests/Unit/DatabaseImportOpenApiTest.php index d3caa127f4..bbc9e7705e 100644 --- a/tests/Unit/DatabaseImportOpenApiTest.php +++ b/tests/Unit/DatabaseImportOpenApiTest.php @@ -66,9 +66,9 @@ test('constrains additional properties on each database import source branch', f expect($schema)->not->toHaveKey('additionalProperties'); $expectedProperties = [ - ['source', 'upload_id', 'dump_all', 'replace_existing'], - ['source', 's3_storage_uuid', 'path', 'dump_all', 'replace_existing'], - ['source', 'path', 'dump_all', 'replace_existing'], + ['source', 'upload_id', 'dump_all', 'replace_existing', 'keep_owners', 'sqlite_database'], + ['source', 's3_storage_uuid', 'path', 'dump_all', 'replace_existing', 'keep_owners', 'sqlite_database'], + ['source', 'path', 'dump_all', 'replace_existing', 'keep_owners', 'sqlite_database'], ]; expect($schema['oneOf'])->toHaveCount(count($expectedProperties)); @@ -77,7 +77,11 @@ test('constrains additional properties on each database import source branch', f expect($source['additionalProperties'])->toBeFalse() ->and($source['properties'])->toHaveKeys($expectedProperties[$index]) ->and($source['properties']['replace_existing']) - ->toMatchArray(['type' => 'boolean', 'default' => false]); + ->toMatchArray(['type' => 'boolean', 'default' => false]) + ->and($source['properties']['keep_owners']) + ->toMatchArray(['type' => 'boolean', 'default' => false]) + ->and($source['properties']['sqlite_database']) + ->toMatchArray(['type' => 'string']); } }); diff --git a/tests/Unit/PreviewDeploymentBindMountTest.php b/tests/Unit/PreviewDeploymentBindMountTest.php index b54eb5a9a0..57de205772 100644 --- a/tests/Unit/PreviewDeploymentBindMountTest.php +++ b/tests/Unit/PreviewDeploymentBindMountTest.php @@ -78,9 +78,9 @@ describe('replaceLocalSource', function () { expect((string) $result)->toBe('/app/scripts'); }); - it('replaces dot-dot-slash prefix with target path', function () { + it('resolves dot-dot-slash prefix against the parent of the target path', function () { $result = replaceLocalSource(str('../config'), str('/app')); - expect((string) $result)->toBe('/app./config'); + expect((string) $result)->toBe('/config'); }); it('replaces tilde prefix with target path', function () { diff --git a/tests/v4/Feature/DangerDeleteResourceTest.php b/tests/v4/Feature/DangerDeleteResourceTest.php index 5e5b6f4f74..6ce81cfc81 100644 --- a/tests/v4/Feature/DangerDeleteResourceTest.php +++ b/tests/v4/Feature/DangerDeleteResourceTest.php @@ -88,7 +88,7 @@ test('delete redirects before dispatching resource cleanup after the response', Queue::assertPushed(DeleteResourceJob::class, fn (DeleteResourceJob $job) => $job->resource->is($service)); }); -test('delete succeeds without password for an oauth user', function () { +test('delete requires confirmation for an oauth user', function () { OauthIdentity::create([ 'user_id' => $this->user->id, 'provider' => 'oidc', @@ -96,6 +96,22 @@ test('delete succeeds without password for an oauth user', function () { 'provider_user_id' => 'oauth-user-id', ]); + Livewire::test(Danger::class, ['resource' => $this->application]) + ->call('delete', '') + ->assertHasErrors('password'); + + Queue::assertNotPushed(DeleteResourceJob::class); +}); + +test('delete succeeds without password for an oauth user after confirming through the provider', function () { + OauthIdentity::create([ + 'user_id' => $this->user->id, + 'provider' => 'oidc', + 'issuer' => 'https://idp.example.com', + 'provider_user_id' => 'oauth-user-id', + ]); + session()->passwordConfirmed(); + Livewire::test(Danger::class, ['resource' => $this->application]) ->call('delete', '') ->assertHasNoErrors();