diff --git a/app/Jobs/SyncStripeSubscriptionsJob.php b/app/Actions/Stripe/SyncStripeSubscriptions.php similarity index 61% rename from app/Jobs/SyncStripeSubscriptionsJob.php rename to app/Actions/Stripe/SyncStripeSubscriptions.php index 572d6e78c..977007961 100644 --- a/app/Jobs/SyncStripeSubscriptionsJob.php +++ b/app/Actions/Stripe/SyncStripeSubscriptions.php @@ -1,30 +1,18 @@ onQueue('high'); - } - - public function handle(?\Closure $onProgress = null): array + public function handle(bool $fix = false, ?\Closure $onProgress = null): array { if (! isCloud() || ! isStripe()) { return ['error' => 'Not running on Cloud or Stripe not configured']; @@ -34,7 +22,9 @@ public function handle(?\Closure $onProgress = null): array ->where('stripe_invoice_paid', true) ->get(); - $stripe = app(StripeClient::class); + $stripe = app()->bound(StripeClient::class) + ? app(StripeClient::class) + : new StripeClient(config('subscription.stripe_api_key')); // Bulk fetch all valid subscription IDs from Stripe (active + past_due) $validStripeIds = $this->fetchValidStripeSubscriptionIds($stripe, $onProgress); @@ -43,13 +33,20 @@ public function handle(?\Closure $onProgress = null): array $staleSubscriptions = $subscriptions->filter( fn (Subscription $sub) => ! in_array($sub->stripe_subscription_id, $validStripeIds) ); + $staleSubscriptionCount = $staleSubscriptions->count(); + + $onProgress?->__invoke('checking', 0, $staleSubscriptionCount); // For each stale subscription, get the exact Stripe status and check for resubscriptions $discrepancies = []; $resubscribed = []; $errors = []; + $fixedCount = 0; + $manualReviewCount = 0; + + foreach ($staleSubscriptions->values() as $index => $subscription) { + $onProgress?->__invoke('checking', $index + 1, $staleSubscriptionCount); - foreach ($staleSubscriptions as $subscription) { try { $stripeSubscription = $stripe->subscriptions->retrieve( $subscription->stripe_subscription_id @@ -66,8 +63,18 @@ public function handle(?\Closure $onProgress = null): array continue; } - // Check if this user resubscribed under a different customer/subscription + if (in_array($stripeStatus, self::VALID_STRIPE_STATUSES, true)) { + continue; + } + $activeSub = $this->findActiveSubscriptionByEmail($stripe, $stripeSubscription->customer); + $validReplacement = Subscription::query() + ->where('team_id', $subscription->team_id) + ->where('id', '!=', $subscription->id) + ->where('stripe_invoice_paid', true) + ->whereIn('stripe_subscription_id', $validStripeIds) + ->first(); + if ($activeSub) { $resubscribed[] = [ 'subscription_id' => $subscription->id, @@ -78,33 +85,69 @@ public function handle(?\Closure $onProgress = null): array 'new_stripe_subscription_id' => $activeSub['subscription_id'], 'new_stripe_customer_id' => $activeSub['customer_id'], 'new_status' => $activeSub['status'], + 'linked_to_team' => $validReplacement?->stripe_subscription_id === $activeSub['subscription_id'], ]; - - continue; } + $inactiveSubscription = null; + if (! $validReplacement && ! $activeSub) { + $inactiveSubscription = Subscription::query() + ->where('team_id', $subscription->team_id) + ->where('id', '!=', $subscription->id) + ->where('stripe_invoice_paid', false) + ->first(); + } + + $resolution = match (true) { + (bool) $validReplacement => 'delete_stale', + (bool) $activeSub => 'manual_review', + (bool) $inactiveSubscription => 'delete_stale', + default => 'end_subscription', + }; + $discrepancies[] = [ 'subscription_id' => $subscription->id, 'team_id' => $subscription->team_id, 'stripe_subscription_id' => $subscription->stripe_subscription_id, 'stripe_status' => $stripeStatus, + 'resolution' => $resolution, ]; - if ($this->fix) { - $subscription->update([ - 'stripe_invoice_paid' => false, - 'stripe_past_due' => false, - ]); + if ($fix) { + $team = $subscription->team; - if ($stripeStatus === 'canceled') { - $subscription->team?->subscriptionEnded(); + if ($resolution === 'manual_review') { + $manualReviewCount++; + + continue; } + + if ($resolution === 'delete_stale') { + if (! $validReplacement && $inactiveSubscription && $team) { + $team->subscriptionEnded($inactiveSubscription); + } + + $subscription->delete(); + $fixedCount++; + + continue; + } + + if ($team) { + $team->subscriptionEnded($subscription); + } else { + $subscription->update([ + 'stripe_invoice_paid' => false, + 'stripe_past_due' => false, + ]); + } + $fixedCount++; } } - if ($this->fix && count($discrepancies) > 0) { + if ($fix && $fixedCount > 0) { send_internal_notification( - 'SyncStripeSubscriptionsJob: Fixed '.count($discrepancies)." discrepancies:\n". + "SyncStripeSubscriptions: Fixed {$fixedCount} discrepancies:\n". json_encode($discrepancies, JSON_PRETTY_PRINT) ); } @@ -114,7 +157,9 @@ public function handle(?\Closure $onProgress = null): array 'discrepancies' => $discrepancies, 'resubscribed' => $resubscribed, 'errors' => $errors, - 'fixed' => $this->fix, + 'fixed' => $fix, + 'fixed_count' => $fixedCount, + 'manual_review_count' => $manualReviewCount, ]; } @@ -183,13 +228,13 @@ private function fetchValidStripeSubscriptionIds(StripeClient $stripe, ?\Closure $validIds = []; $fetched = 0; - foreach (['active', 'past_due'] as $status) { + foreach (self::VALID_STRIPE_STATUSES as $status) { foreach ($stripe->subscriptions->all(['status' => $status, 'limit' => 100])->autoPagingIterator() as $sub) { $validIds[] = $sub->id; $fetched++; if ($onProgress) { - $onProgress($fetched); + $onProgress('fetching', $fetched, null); } } } diff --git a/app/Console/Commands/Cloud/CleanupUnverifiedUsers.php b/app/Console/Commands/Cloud/CleanupUnverifiedUsers.php new file mode 100644 index 000000000..bf63edce5 --- /dev/null +++ b/app/Console/Commands/Cloud/CleanupUnverifiedUsers.php @@ -0,0 +1,83 @@ +error('This command can only be run on Coolify Cloud.'); + + return self::FAILURE; + } + + $eligibleUsers = $this->eligibleUsers(); + $eligibleCount = $eligibleUsers->count(); + + $this->info("Found {$eligibleCount} ".Str::plural('unverified user', $eligibleCount).' eligible for deletion.'); + $shouldDelete = (bool) $this->option('yes'); + + if (! $shouldDelete) { + $this->warn('Dry run only. Use --yes to delete eligible users.'); + } + + $deletedCount = 0; + + if ($eligibleCount > 0) { + $progressAction = $shouldDelete ? 'Deleting' : 'Checking'; + $progressBar = $this->output->createProgressBar($eligibleCount); + $progressBar->setFormat("{$progressAction} eligible users: %current%/%max% [%bar%] %percent:3s%%"); + $progressBar->start(); + + foreach ($eligibleUsers->lazyById(100) as $user) { + if ($shouldDelete && $user->delete()) { + $deletedCount++; + } + + $progressBar->advance(); + } + + $progressBar->finish(); + $this->newLine(2); + } + + if ($shouldDelete) { + $this->info("Deleted {$deletedCount} ".Str::plural('unverified user', $deletedCount).'.'); + } + + return self::SUCCESS; + } + + private function eligibleUsers(): Builder + { + return User::query() + ->where('id', '!=', 0) + ->whereNull('email_verified_at') + ->whereDoesntHave('teams', fn (Builder $query) => $query->whereKey(0)) + ->whereDoesntHave('teams.subscription') + ->whereDoesntHave('teams.servers') + ->whereDoesntHave('teams', function (Builder $query) { + $query->whereHas('projects.applications') + ->orWhereHas('projects.postgresqls') + ->orWhereHas('projects.redis') + ->orWhereHas('projects.mongodbs') + ->orWhereHas('projects.mysqls') + ->orWhereHas('projects.mariadbs') + ->orWhereHas('projects.keydbs') + ->orWhereHas('projects.dragonflies') + ->orWhereHas('projects.clickhouses') + ->orWhereHas('projects.services'); + }); + } +} diff --git a/app/Console/Commands/Cloud/ExportUsers.php b/app/Console/Commands/Cloud/ExportUsers.php new file mode 100644 index 000000000..5e383c4ce --- /dev/null +++ b/app/Console/Commands/Cloud/ExportUsers.php @@ -0,0 +1,127 @@ +error('This command can only be run on Coolify Cloud.'); + + return self::FAILURE; + } + + $backups = Storage::disk('backups'); + $backups->delete('cloud-users.csv'); + + $subscribedPath = $backups->path('cloud-users-subscribed.csv'); + $unsubscribedPath = $backups->path('cloud-users-unsubscribed.csv'); + $subscribedOutput = fopen($subscribedPath, 'wb'); + + if ($subscribedOutput === false) { + $this->error("Unable to open {$subscribedPath} for writing."); + + return self::FAILURE; + } + + $unsubscribedOutput = fopen($unsubscribedPath, 'wb'); + + if ($unsubscribedOutput === false) { + fclose($subscribedOutput); + $this->error("Unable to open {$unsubscribedPath} for writing."); + + return self::FAILURE; + } + + $subscribedCount = 0; + $unsubscribedCount = 0; + + try { + $header = [ + 'email', + 'first_name', + 'last_name', + 'lifetime_value_currency', + 'lifetime_value_amount', + 'utm_campaign', + 'utm_source', + 'utm_medium', + 'utm_content', + 'utm_term', + 'phone', + ]; + + $this->writeCsvRow($subscribedOutput, $header); + $this->writeCsvRow($unsubscribedOutput, $header); + + foreach (User::query() + ->select(['id', 'email', 'name']) + ->where('id', '!=', 0) + ->whereNotNull('email_verified_at') + ->withExists([ + 'teams as is_subscribed' => fn ($query) => $query + ->whereRelation('subscription', 'stripe_invoice_paid', true), + ]) + ->lazyById(500) as $user) { + $nameParts = preg_split('/\s+/u', trim((string) $user->name), 2) ?: []; + [$firstName, $lastName] = array_pad($nameParts, 2, ''); + + $row = [ + $user->email, + $firstName, + $lastName, + '', + '', + '', + '', + '', + '', + '', + '', + ]; + + if ($user->is_subscribed) { + $this->writeCsvRow($subscribedOutput, $row); + $subscribedCount++; + } else { + $this->writeCsvRow($unsubscribedOutput, $row); + $unsubscribedCount++; + } + } + } catch (Throwable $exception) { + $this->error("Unable to export users: {$exception->getMessage()}"); + + return self::FAILURE; + } finally { + fclose($subscribedOutput); + fclose($unsubscribedOutput); + } + + $this->info("Exported {$subscribedCount} subscribed verified users to {$subscribedPath}"); + $this->info("Exported {$unsubscribedCount} unsubscribed verified users to {$unsubscribedPath}"); + + return self::SUCCESS; + } + + /** + * @param resource $output + * @param array $fields + */ + private function writeCsvRow($output, array $fields): void + { + if (fputcsv($output, $fields, ',', '"', '') === false) { + throw new RuntimeException('Unable to write the CSV file.'); + } + } +} diff --git a/app/Console/Commands/Cloud/SyncStripeSubscriptions.php b/app/Console/Commands/Cloud/SyncStripeSubscriptions.php index 46f6b4edd..cedacfbeb 100644 --- a/app/Console/Commands/Cloud/SyncStripeSubscriptions.php +++ b/app/Console/Commands/Cloud/SyncStripeSubscriptions.php @@ -2,7 +2,7 @@ namespace App\Console\Commands\Cloud; -use App\Jobs\SyncStripeSubscriptionsJob; +use App\Actions\Stripe\SyncStripeSubscriptions as SyncStripeSubscriptionsAction; use Illuminate\Console\Command; class SyncStripeSubscriptions extends Command @@ -35,14 +35,18 @@ public function handle(): int $this->newLine(); - $job = new SyncStripeSubscriptionsJob($fix); - $fetched = 0; - $result = $job->handle(function (int $count) use (&$fetched): void { - $fetched = $count; - $this->output->write("\r Fetching subscriptions from Stripe... {$fetched}"); + $progressShown = false; + $result = SyncStripeSubscriptionsAction::run($fix, function (string $stage, int $current, ?int $total) use (&$progressShown): void { + $progressShown = true; + $message = match ($stage) { + 'checking' => " Checking stale subscriptions against Stripe... {$current}/{$total}", + default => " Fetching valid subscriptions from Stripe... {$current}", + }; + + $this->output->write("\r".str_pad($message, 80)); }); - if ($fetched > 0) { - $this->output->write("\r".str_repeat(' ', 60)."\r"); + if ($progressShown) { + $this->output->write("\r".str_repeat(' ', 80)."\r"); } if (isset($result['error'])) { @@ -63,13 +67,22 @@ public function handle(): int $this->line(" Team ID: {$discrepancy['team_id']}"); $this->line(" Stripe ID: {$discrepancy['stripe_subscription_id']}"); $this->line(" Stripe Status: {$discrepancy['stripe_status']}"); + $resolution = match ($discrepancy['resolution']) { + 'delete_stale' => 'Delete stale local row', + 'manual_review' => 'Manual review required', + default => 'End local subscription', + }; + $this->line(" Resolution: {$resolution}"); $this->newLine(); } if ($fix) { - $this->info('All discrepancies have been fixed.'); + $this->info("Automatic corrections applied: {$result['fixed_count']}"); + if ($result['manual_review_count'] > 0) { + $this->warn("Skipped for manual review: {$result['manual_review_count']}"); + } } else { - $this->comment('Run with --fix to correct these discrepancies.'); + $this->comment('Run with --fix to apply automatic corrections.'); } } else { $this->info('No discrepancies found. All subscriptions are in sync.'); @@ -84,6 +97,7 @@ public function handle(): int $this->line(" - Team ID: {$resub['team_id']} | Email: {$resub['email']}"); $this->line(" Old: {$resub['old_stripe_subscription_id']} (cus: {$resub['old_stripe_customer_id']})"); $this->line(" New: {$resub['new_stripe_subscription_id']} (cus: {$resub['new_stripe_customer_id']}) [{$resub['new_status']}]"); + $this->line(' Linked to this team: '.($resub['linked_to_team'] ? 'Yes' : 'No')); $this->newLine(); } } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 3761effd7..d06e2eac5 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -850,6 +850,12 @@ public function create_backup(Request $request) $this->authorize('manageBackups', $database); + if (! $database->isBackupSolutionAvailable()) { + return response()->json([ + 'message' => 'Scheduled backups are not supported for this database type.', + ], 422); + } + // Validate frequency is a valid cron expression $isValid = validate_cron_expression($request->frequency); if (! $isValid) { @@ -915,6 +921,8 @@ public function create_backup(Request $request) $backupData['databases_to_backup'] = $database->mysql_database; } elseif ($database->type() === 'standalone-mariadb') { $backupData['databases_to_backup'] = $database->mariadb_database; + } elseif ($database->type() === 'standalone-clickhouse') { + $backupData['databases_to_backup'] = $database->clickhouse_db; } } @@ -3016,7 +3024,7 @@ public function list_backup_executions(Request $request) ), ] )] - public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse + public function move_by_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php index 74b6c9db1..e1df34903 100644 --- a/app/Http/Controllers/Api/ServiceApplicationsController.php +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -490,7 +490,7 @@ public function logs_by_uuid(Request $request): JsonResponse ], 400); } - $lines = (int) ($request->query('lines', 100) ?: 100); + $lines = normalizeLogLines($request->query('lines')); $logs = getContainerLogs($server, $containerName, $lines); return response()->json([ diff --git a/app/Http/Controllers/Api/ServiceDatabasesController.php b/app/Http/Controllers/Api/ServiceDatabasesController.php index 7befc3273..480ff4e55 100644 --- a/app/Http/Controllers/Api/ServiceDatabasesController.php +++ b/app/Http/Controllers/Api/ServiceDatabasesController.php @@ -311,7 +311,7 @@ public function logs(Request $request): JsonResponse return response()->json(['message' => 'Service database container is not running.'], 400); } - $lines = (int) ($request->query('lines', 100) ?: 100); + $lines = normalizeLogLines($request->query('lines')); return response()->json([ 'logs' => getContainerLogs($server, $containerName, $lines), diff --git a/app/Http/Controllers/Api/VultrController.php b/app/Http/Controllers/Api/VultrController.php index 60b9d0a0a..51fad6a0b 100644 --- a/app/Http/Controllers/Api/VultrController.php +++ b/app/Http/Controllers/Api/VultrController.php @@ -15,6 +15,7 @@ use App\Services\VultrService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use OpenApi\Attributes as OA; class VultrController extends Controller @@ -286,6 +287,10 @@ public function createServer(Request $request): JsonResponse return response()->json(['message' => 'Private key not found.'], 404); } + $vultrService = null; + $vultrInstanceId = null; + $server = null; + try { $vultrService = new VultrService($token->token); $publicKey = $privateKey->getPublicKey(); @@ -317,33 +322,41 @@ public function createServer(Request $request): JsonResponse } $vultrInstance = $vultrService->createInstance($params); + $vultrInstanceId = (string) $vultrInstance['id']; $ipAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6) ?? Server::PLACEHOLDER_IP; - $server = Server::create([ - 'name' => $normalizedServerName, - 'ip' => $ipAddress, - 'user' => 'root', - 'port' => 22, - 'team_id' => $teamId, - 'private_key_id' => $privateKey->id, - 'cloud_provider_token_id' => $token->id, - 'vultr_instance_id' => $vultrInstance['id'], - 'vultr_instance_status' => $vultrInstance['status'] ?? null, - ]); - - $vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6); - $assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6); - if ($assignedIpAddress && $assignedIpAddress !== $server->ip) { - $ipAddress = $assignedIpAddress; - $server->update([ - 'ip' => $assignedIpAddress, - 'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status, + $server = DB::transaction(function () use ($normalizedServerName, $ipAddress, $teamId, $privateKey, $token, $vultrInstanceId, $vultrInstance): Server { + $server = Server::create([ + 'name' => $normalizedServerName, + 'ip' => $ipAddress, + 'user' => 'root', + 'port' => 22, + 'team_id' => $teamId, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => $token->id, + 'vultr_instance_id' => $vultrInstanceId, + 'vultr_instance_status' => $vultrInstance['status'] ?? null, ]); - } - $server->proxy->set('status', 'exited'); - $server->proxy->set('type', ProxyTypes::TRAEFIK->value); - $server->save(); + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + return $server; + }); + + try { + $vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6); + $assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6); + if ($assignedIpAddress && $assignedIpAddress !== $server->ip) { + $server->update([ + 'ip' => $assignedIpAddress, + 'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status, + ]); + } + } catch (\Throwable $e) { + report($e); + } if ($request->instant_validate) { ValidateServer::dispatch($server); @@ -353,27 +366,48 @@ public function createServer(Request $request): JsonResponse 'team_id' => $teamId, 'server_uuid' => $server->uuid, 'server_name' => $server->name, - 'vultr_instance_id' => $vultrInstance['id'], - 'ip' => $ipAddress, + 'vultr_instance_id' => $vultrInstanceId, + 'ip' => $server->ip, ]); return response()->json([ 'uuid' => $server->uuid, - 'vultr_instance_id' => $vultrInstance['id'], - 'ip' => $ipAddress, + 'vultr_instance_id' => $vultrInstanceId, + 'ip' => $server->ip, ])->setStatusCode(201); } catch (RateLimitException $e) { + $this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server); + $response = response()->json(['message' => $e->getMessage()], 429); if ($e->retryAfter !== null) { $response->header('Retry-After', $e->retryAfter); } return $response; - } catch (\Throwable) { + } catch (\Throwable $e) { + $this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server); + + logger()->error('Failed to create Vultr server', [ + 'error' => $e->getMessage(), + ]); + return response()->json(['message' => 'Failed to create Vultr server.'], 500); } } + private function deleteUntrackedInstance(?VultrService $vultrService, ?string $vultrInstanceId, ?Server $server): void + { + if (! $vultrService || ! $vultrInstanceId || $server) { + return; + } + + try { + $vultrService->deleteInstance($vultrInstanceId); + } catch (\Throwable $e) { + report($e); + } + } + private function findMatchingSshKey(array $sshKeys, string $publicKey): ?array { $normalizedPublicKey = $this->normalizePublicKey($publicKey); diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index e9e1f9105..f25158d34 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -8,6 +8,7 @@ use App\Models\ScheduledDatabaseBackupExecution; use App\Models\Server; use App\Models\ServiceDatabase; +use App\Models\StandaloneClickhouse; use App\Models\StandaloneMariadb; use App\Models\StandaloneMongodb; use App\Models\StandaloneMysql; @@ -17,6 +18,7 @@ use App\Notifications\Database\BackupSuccess; use App\Notifications\Database\BackupSuccessWithS3Warning; use App\Rules\SafeWebhookUrl; +use App\Support\ClickhouseBackupCommand; use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; @@ -39,7 +41,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue public Server $server; - public StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|ServiceDatabase $database; + public StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneClickhouse|ServiceDatabase $database; public ?string $container_name = null; @@ -271,6 +273,8 @@ public function handle(): void $databasesToBackup = [$this->database->mysql_database]; } elseif (str($databaseType)->contains('mariadb')) { $databasesToBackup = [$this->database->mariadb_database]; + } elseif ($this->database instanceof StandaloneClickhouse) { + $databasesToBackup = [$this->database->clickhouse_db]; } else { return; } @@ -294,6 +298,10 @@ public function handle(): void // Format: db1,db2,db3 $databasesToBackup = explode(',', $databasesToBackup); $databasesToBackup = array_map('trim', $databasesToBackup); + } elseif ($this->database instanceof StandaloneClickhouse) { + // Format: db1,db2,db3 + $databasesToBackup = explode(',', $databasesToBackup); + $databasesToBackup = array_map('trim', $databasesToBackup); } else { return; } @@ -386,6 +394,17 @@ public function handle(): void 'local_storage_deleted' => false, ]); $this->backup_standalone_mariadb($database); + } elseif ($this->database instanceof StandaloneClickhouse) { + $this->backup_file = '/clickhouse-backup-'.Carbon::now()->timestamp."-{$this->backup_log_uuid}.zip"; + $this->backup_location = $this->backup_dir.$this->backup_file; + $this->backup_log = ScheduledDatabaseBackupExecution::create([ + 'uuid' => $this->backup_log_uuid, + 'database_name' => $database, + 'filename' => $this->backup_location, + 'scheduled_database_backup_id' => $this->backup->id, + 'local_storage_deleted' => false, + ]); + $this->backup_standalone_clickhouse($database); } else { throw new \Exception('Unsupported database type'); } @@ -400,6 +419,9 @@ public function handle(): void } } catch (Throwable $e) { // Local backup failed + if ($this->database instanceof StandaloneClickhouse) { + deleteBackupsLocally($this->backup_location, $this->server); + } if ($this->backup_log) { $this->backup_log->update([ 'status' => 'failed', @@ -642,6 +664,32 @@ private function backup_standalone_mariadb(string $database): void } } + private function backup_standalone_clickhouse(string $database): void + { + $archiveName = ltrim($this->backup_file, '/'); + + try { + $commands = ClickhouseBackupCommand::make( + containerName: $this->container_name, + database: $database, + archiveName: $archiveName, + backupDirectory: $this->backup_dir, + ); + + $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->backup_output = trim($this->backup_output); + if ($this->backup_output === '') { + $this->backup_output = null; + } + } catch (Throwable $e) { + $this->add_to_error_output($e->getMessage()); + throw $e; + } finally { + $cleanupCommand = ClickhouseBackupCommand::cleanup($this->container_name, $archiveName); + instant_remote_process([$cleanupCommand], $this->server, false, false, null, disableMultiplexing: true); + } + } + private function add_to_backup_output($output): void { if ($this->backup_output) { diff --git a/app/Livewire/Project/Database/Backup/Index.php b/app/Livewire/Project/Database/Backup/Index.php index 2df32ec7b..71380754f 100644 --- a/app/Livewire/Project/Database/Backup/Index.php +++ b/app/Livewire/Project/Database/Backup/Index.php @@ -22,13 +22,7 @@ public function mount() if (! $database) { return redirect()->route('dashboard'); } - // No backups - if ( - $database->getMorphClass() === \App\Models\StandaloneRedis::class || - $database->getMorphClass() === \App\Models\StandaloneKeydb::class || - $database->getMorphClass() === \App\Models\StandaloneDragonfly::class || - $database->getMorphClass() === \App\Models\StandaloneClickhouse::class - ) { + if (! $database->isBackupSolutionAvailable()) { return redirect()->route('project.database.configuration', [ 'project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, diff --git a/app/Livewire/Project/Database/CreateScheduledBackup.php b/app/Livewire/Project/Database/CreateScheduledBackup.php index 0f37bf802..e72277e80 100644 --- a/app/Livewire/Project/Database/CreateScheduledBackup.php +++ b/app/Livewire/Project/Database/CreateScheduledBackup.php @@ -26,6 +26,12 @@ public function submit() try { $this->authorize('manageBackups', $this->database); + if (! $this->database->isBackupSolutionAvailable()) { + $this->dispatch('error', 'Scheduled backups are not supported for this database type.'); + + return; + } + $this->validate(); $isValid = validate_cron_expression($this->frequency); @@ -51,6 +57,8 @@ public function submit() $payload['databases_to_backup'] = $this->database->mysql_database; } elseif ($this->database->type() === 'standalone-mariadb') { $payload['databases_to_backup'] = $this->database->mariadb_database; + } elseif ($this->database->type() === 'standalone-clickhouse') { + $payload['databases_to_backup'] = $this->database->clickhouse_db; } $databaseBackup = ScheduledDatabaseBackup::create($payload); diff --git a/app/Livewire/Server/New/ByVultr.php b/app/Livewire/Server/New/ByVultr.php index fd0aa4c34..4246d5836 100644 --- a/app/Livewire/Server/New/ByVultr.php +++ b/app/Livewire/Server/New/ByVultr.php @@ -14,6 +14,7 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Livewire\Attributes\Locked; use Livewire\Component; @@ -377,9 +378,8 @@ private function providerDataErrorMessage(string $providerName, \Throwable $e, s return "{$providerName} API error: {$details}"; } - private function createVultrServer(string $token): array + private function createVultrServer(VultrService $vultrService): array { - $vultrService = new VultrService($token); $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); $publicKey = $privateKey->getPublicKey(); $existingKey = $this->findMatchingSshKey($vultrService->getSshKeys(), $publicKey); @@ -419,6 +419,10 @@ public function submit(): mixed return null; } + $vultrService = null; + $vultrInstanceId = null; + $server = null; + try { $this->authorize('create', Server::class); @@ -437,20 +441,29 @@ public function submit(): mixed } $vultrService = new VultrService($this->getVultrToken()); - $vultrInstance = $this->createVultrServer($this->getVultrToken()); + $vultrInstance = $this->createVultrServer($vultrService); + $vultrInstanceId = (string) $vultrInstance['id']; $ipAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6) ?? Server::PLACEHOLDER_IP; - $server = Server::create([ - 'name' => strtolower(trim($this->server_name)), - 'ip' => $ipAddress, - 'user' => 'root', - 'port' => 22, - 'team_id' => currentTeam()->id, - 'private_key_id' => $this->private_key_id, - 'cloud_provider_token_id' => $this->selected_token_id, - 'vultr_instance_id' => $vultrInstance['id'], - 'vultr_instance_status' => $vultrInstance['status'] ?? null, - ]); + $server = DB::transaction(function () use ($ipAddress, $vultrInstanceId, $vultrInstance): Server { + $server = Server::create([ + 'name' => strtolower(trim($this->server_name)), + 'ip' => $ipAddress, + 'user' => 'root', + 'port' => 22, + 'team_id' => currentTeam()->id, + 'private_key_id' => $this->private_key_id, + 'cloud_provider_token_id' => $this->selected_token_id, + 'vultr_instance_id' => $vultrInstanceId, + 'vultr_instance_status' => $vultrInstance['status'] ?? null, + ]); + + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + return $server; + }); try { $vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6); @@ -466,10 +479,6 @@ public function submit(): mixed report($e); } - $server->proxy->set('status', 'exited'); - $server->proxy->set('type', ProxyTypes::TRAEFIK->value); - $server->save(); - if ($this->from_onboarding) { currentTeam()->update([ 'show_boarding' => false, @@ -479,10 +488,25 @@ public function submit(): mixed return redirectRoute($this, 'server.show', [$server->uuid]); } catch (\Throwable $e) { + $this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server); + return handleError($e, $this); } } + private function deleteUntrackedInstance(?VultrService $vultrService, ?string $vultrInstanceId, ?Server $server): void + { + if (! $vultrService || ! $vultrInstanceId || $server) { + return; + } + + try { + $vultrService->deleteInstance($vultrInstanceId); + } catch (\Throwable $e) { + report($e); + } + } + public function render() { return view('livewire.server.new.by-vultr'); diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 9db5f21b7..7ca45cc3b 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -370,6 +370,6 @@ public function scheduledBackups() public function isBackupSolutionAvailable() { - return false; + return true; } } diff --git a/app/Models/Team.php b/app/Models/Team.php index a979b44fb..2b468bf4c 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -219,13 +219,15 @@ public function isAnyNotificationEnabled() $this->getNotificationSettings('webhook')?->isEnabled(); } - public function subscriptionEnded() + public function subscriptionEnded(?Subscription $subscription = null): void { - if (! $this->subscription) { + $subscription ??= $this->subscription; + + if (! $subscription) { return; } - $this->subscription->update([ + $subscription->update([ 'stripe_subscription_id' => null, 'stripe_cancel_at_period_end' => false, 'stripe_invoice_paid' => false, diff --git a/app/Services/DigitalOceanService.php b/app/Services/DigitalOceanService.php index c8292e864..da1af942f 100644 --- a/app/Services/DigitalOceanService.php +++ b/app/Services/DigitalOceanService.php @@ -28,7 +28,7 @@ private function request(string $method, string $endpoint, array $data = []): ar } return $attempt * 100; - }) + }, throw: false) ->{$method}($this->baseUrl.$endpoint, $data); if (! $response->successful()) { diff --git a/app/Services/VultrService.php b/app/Services/VultrService.php index 0e335d3e0..347b7e3fb 100644 --- a/app/Services/VultrService.php +++ b/app/Services/VultrService.php @@ -17,7 +17,7 @@ private function request(string $method, string $endpoint, array $data = []): ar 'Authorization' => 'Bearer '.$this->token, ]) ->timeout(30) - ->retry(3, fn (int $attempt) => $attempt * 100) + ->retry(3, fn (int $attempt) => $attempt * 100, throw: false) ->{$method}($this->baseUrl.$endpoint, $data); if (! $response->successful()) { diff --git a/app/Support/ClickhouseBackupCommand.php b/app/Support/ClickhouseBackupCommand.php new file mode 100644 index 000000000..69e93d5c3 --- /dev/null +++ b/app/Support/ClickhouseBackupCommand.php @@ -0,0 +1,37 @@ + */ + public static function make( + string $containerName, + string $database, + string $archiveName, + string $backupDirectory, + ): array { + validateShellSafePath($database, 'database name'); + validateFilenameSafe($archiveName, 'ClickHouse backup archive'); + + $backupDirectory = rtrim($backupDirectory, '/'); + $containerBackupPath = '/var/lib/clickhouse/backups/'.$archiveName; + $backupLocation = $backupDirectory.'/'.$archiveName; + $query = "BACKUP DATABASE `{$database}` TO File('{$archiveName}')"; + + return [ + 'mkdir -p '.escapeshellarg($backupDirectory), + 'docker exec '.escapeshellarg($containerName).' clickhouse-client --query '.escapeshellarg($query), + 'docker cp '.escapeshellarg($containerName.':'.$containerBackupPath).' '.escapeshellarg($backupLocation), + ]; + } + + public static function cleanup(string $containerName, string $archiveName): string + { + validateFilenameSafe($archiveName, 'ClickHouse backup archive'); + + $containerBackupPath = '/var/lib/clickhouse/backups/'.$archiveName; + + return 'docker exec '.escapeshellarg($containerName).' rm -f '.escapeshellarg($containerBackupPath); + } +} diff --git a/bootstrap/helpers/email.php b/bootstrap/helpers/email.php index a0b8ba67f..a4a311e04 100644 --- a/bootstrap/helpers/email.php +++ b/bootstrap/helpers/email.php @@ -8,9 +8,12 @@ function normalize_email_identity(?string $email): ?string return null; } - [$localPart, $domain] = explode('@', Str::lower($email), 2); - $localPart = Str::before($localPart, '+'); - $localPart = str_replace('.', '', $localPart); + [$localPart, $domain] = explode('@', Str::lower(trim($email)), 2); + + if (in_array($domain, ['gmail.com', 'googlemail.com'], true)) { + $localPart = Str::before($localPart, '+'); + $localPart = str_replace('.', '', $localPart); + } if (blank($localPart) || blank($domain)) { return null; diff --git a/resources/views/livewire/admin/index.blade.php b/resources/views/livewire/admin/index.blade.php index acba3acce..8abb92413 100644 --- a/resources/views/livewire/admin/index.blade.php +++ b/resources/views/livewire/admin/index.blade.php @@ -22,7 +22,7 @@
{{ $user->name }}
{{ $user->email }}
Active: - {{ $user->teams()->whereRelation('subscription', 'stripe_subscription_id', '!=', null)->exists() ? 'Yes' : 'No' }} + {{ $user->teams()->whereRelation('subscription', 'stripe_invoice_paid', true)->exists() ? 'Yes' : 'No' }}
diff --git a/resources/views/livewire/project/database/backup-edit/general.blade.php b/resources/views/livewire/project/database/backup-edit/general.blade.php index a9052118a..ba6102ee7 100644 --- a/resources/views/livewire/project/database/backup-edit/general.blade.php +++ b/resources/views/livewire/project/database/backup-edit/general.blade.php @@ -49,6 +49,10 @@ helper="Comma separated list of databases to backup. Empty will include the default one." id="databasesToBackup" /> @endif + @elseif ($backup->database_type === 'App\Models\StandaloneClickhouse') + @endif
diff --git a/resources/views/livewire/project/database/heading.blade.php b/resources/views/livewire/project/database/heading.blade.php index bae7151e3..78b533ec0 100644 --- a/resources/views/livewire/project/database/heading.blade.php +++ b/resources/views/livewire/project/database/heading.blade.php @@ -6,12 +6,7 @@ 'label' => 'Backups', 'route' => 'project.database.backup.index', 'active' => request()->routeIs('project.database.backup.*'), - 'visible' => in_array($database->getMorphClass(), [ - 'App\Models\StandalonePostgresql', - 'App\Models\StandaloneMongodb', - 'App\Models\StandaloneMysql', - 'App\Models\StandaloneMariadb', - ]), + 'visible' => $database->isBackupSolutionAvailable(), ], ['label' => 'Logs', 'route' => 'project.database.logs', 'active' => request()->routeIs('project.database.logs')], ['label' => 'Terminal', 'route' => 'project.database.command', 'active' => request()->routeIs('project.database.command'), 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')], @@ -190,11 +185,7 @@ class="scrollbar hidden min-h-10 w-full flex-nowrap items-center gap-6 overflow- Configuration - @if ( - $database->getMorphClass() === 'App\Models\StandalonePostgresql' || - $database->getMorphClass() === 'App\Models\StandaloneMongodb' || - $database->getMorphClass() === 'App\Models\StandaloneMysql' || - $database->getMorphClass() === 'App\Models\StandaloneMariadb') + @if ($database->isBackupSolutionAvailable()) Backups @@ -210,6 +201,7 @@ class="scrollbar hidden min-h-10 w-full flex-nowrap items-center gap-6 overflow- Terminal @endcan + @if ($database->destination->server->isFunctional()) diff --git a/tests/Feature/AdminSubscriptionStatusTest.php b/tests/Feature/AdminSubscriptionStatusTest.php new file mode 100644 index 000000000..94e160935 --- /dev/null +++ b/tests/Feature/AdminSubscriptionStatusTest.php @@ -0,0 +1,47 @@ +set('cache.default', 'array'); + config()->set('constants.coolify.self_hosted', false); + + InstanceSettings::unguarded( + fn () => InstanceSettings::query()->firstOrCreate(['id' => 0]) + ); + + $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); + $rootUser = User::find(0) ?? User::factory()->create(['id' => 0]); + $rootTeam->members()->syncWithoutDetaching([ + $rootUser->id => ['role' => 'admin'], + ]); + + $inactiveUser = User::factory()->create(['email' => 'inactive@example.com']); + $inactiveTeam = Team::factory()->create(); + $inactiveTeam->members()->attach($inactiveUser->id, ['role' => 'owner']); + Subscription::create([ + 'team_id' => $inactiveTeam->id, + 'stripe_subscription_id' => 'sub_stale', + 'stripe_invoice_paid' => false, + ]); + + $this->actingAs($rootUser); + session(['currentTeam' => ['id' => $rootTeam->id]]); + + Livewire::test(AdminIndex::class) + ->set([ + 'foundUsers' => collect(), + 'search' => 'inactive@example.com', + ]) + ->call('submitSearch') + ->assertSee('No') + ->assertDontSee('Yes'); +}); diff --git a/tests/Feature/Api/DatabaseBackupCreationApiTest.php b/tests/Feature/Api/DatabaseBackupCreationApiTest.php index 8882fec39..cb0c5cf73 100644 --- a/tests/Feature/Api/DatabaseBackupCreationApiTest.php +++ b/tests/Feature/Api/DatabaseBackupCreationApiTest.php @@ -5,8 +5,10 @@ use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\Server; +use App\Models\StandaloneClickhouse; use App\Models\StandaloneDocker; use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -15,7 +17,7 @@ uses(RefreshDatabase::class); beforeEach(function () { - InstanceSettings::updateOrCreate(['id' => 0]); + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); $this->team = Team::factory()->create(); $this->user = User::factory()->create(); @@ -77,6 +79,56 @@ function backupHeaders(): array } describe('POST /api/v1/databases/{uuid}/backups', function () { + test('rejects backup configurations for unsupported database types', function () { + $database = StandaloneRedis::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Redis DB', + 'status' => 'running', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(backupHeaders()) + ->postJson("/api/v1/databases/{$database->uuid}/backups", [ + 'frequency' => 'daily', + ]); + + $response->assertUnprocessable() + ->assertJson([ + 'message' => 'Scheduled backups are not supported for this database type.', + ]); + + expect(ScheduledDatabaseBackup::count())->toBe(0); + }); + + test('defaults clickhouse backups to its configured database', function () { + $database = StandaloneClickhouse::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'ClickHouse DB', + 'clickhouse_admin_user' => 'default', + 'clickhouse_admin_password' => 'password', + 'clickhouse_db' => 'analytics', + 'image' => 'clickhouse/clickhouse-server:25.11', + 'status' => 'running', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(backupHeaders()) + ->postJson("/api/v1/databases/{$database->uuid}/backups", [ + 'frequency' => 'daily', + ]); + + $response->assertCreated(); + + $backup = ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->firstOrFail(); + + expect($backup->databases_to_backup)->toBe('analytics') + ->and($backup->database_type)->toBe(StandaloneClickhouse::class); + }); + test('creates backup configuration with valid frequency', function () { $response = $this->withHeaders(backupHeaders()) ->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ diff --git a/tests/Feature/CleanupUnverifiedUsersCommandTest.php b/tests/Feature/CleanupUnverifiedUsersCommandTest.php new file mode 100644 index 000000000..39d5c67ba --- /dev/null +++ b/tests/Feature/CleanupUnverifiedUsersCommandTest.php @@ -0,0 +1,154 @@ +set('constants.coolify.self_hosted', false); +}); + +test('it previews eligible unverified users without deleting them', function () { + $user = User::factory()->unverified()->create([ + 'email' => 'unverified@example.com', + ]); + + $this->artisan('cloud:cleanup-unverified-users') + ->expectsOutput('Found 1 unverified user eligible for deletion.') + ->expectsOutput('Dry run only. Use --yes to delete eligible users.') + ->assertSuccessful(); + + $this->assertModelExists($user); +}); + +test('it reports dry run progress', function () { + $users = User::factory()->unverified()->count(2)->create(); + + $exitCode = Artisan::call('cloud:cleanup-unverified-users'); + + expect($exitCode)->toBe(0) + ->and(Artisan::output())->toContain('Checking eligible users: 2/2'); + + $users->each(fn (User $user) => $this->assertModelExists($user)); +}); + +test('it deletes eligible unverified users with the yes option', function () { + $user = User::factory()->unverified()->create([ + 'email' => 'unverified@example.com', + ]); + + $this->artisan('cloud:cleanup-unverified-users', ['--yes' => true]) + ->expectsOutput('Deleted 1 unverified user.') + ->assertSuccessful(); + + $this->assertModelMissing($user); +}); + +test('it reports deletion progress', function () { + User::factory()->unverified()->count(2)->create(); + + $exitCode = Artisan::call('cloud:cleanup-unverified-users', ['--yes' => true]); + + expect($exitCode)->toBe(0) + ->and(Artisan::output())->toContain('Deleting eligible users: 2/2'); +}); + +test('it keeps verified users', function () { + $user = User::factory()->create([ + 'email' => 'verified@example.com', + ]); + + $this->artisan('cloud:cleanup-unverified-users', ['--yes' => true]) + ->expectsOutput('Deleted 0 unverified users.') + ->assertSuccessful(); + + $this->assertModelExists($user); +}); + +test('it keeps unverified users with a Stripe subscription record', function () { + $user = User::factory()->unverified()->create([ + 'email' => 'subscribed@example.com', + ]); + + Subscription::create([ + 'team_id' => $user->teams()->firstOrFail()->id, + 'stripe_invoice_paid' => false, + ]); + + $this->artisan('cloud:cleanup-unverified-users', ['--yes' => true]) + ->expectsOutput('Deleted 0 unverified users.') + ->assertSuccessful(); + + $this->assertModelExists($user); +}); + +test('it keeps unverified users with defined resources', function () { + $user = User::factory()->unverified()->create([ + 'email' => 'resource-owner@example.com', + ]); + + $project = Project::factory()->create([ + 'team_id' => $user->teams()->firstOrFail()->id, + ]); + $environment = Environment::factory()->create([ + 'project_id' => $project->id, + ]); + Application::factory()->create([ + 'environment_id' => $environment->id, + ]); + + $this->artisan('cloud:cleanup-unverified-users', ['--yes' => true]) + ->expectsOutput('Deleted 0 unverified users.') + ->assertSuccessful(); + + $this->assertModelExists($user); +}); + +test('it keeps unverified users with servers', function () { + $user = User::factory()->unverified()->create([ + 'email' => 'server-owner@example.com', + ]); + + Server::factory()->create([ + 'team_id' => $user->teams()->firstOrFail()->id, + ]); + + $this->artisan('cloud:cleanup-unverified-users', ['--yes' => true]) + ->expectsOutput('Deleted 0 unverified users.') + ->assertSuccessful(); + + $this->assertModelExists($user); +}); + +test('it keeps unverified root team members', function () { + $user = User::factory()->unverified()->create([ + 'email' => 'root-member@example.com', + ]); + $rootTeam = Team::factory()->create([ + 'id' => 0, + 'name' => 'Root Team', + ]); + $rootTeam->members()->attach($user->id, ['role' => 'admin']); + + $this->artisan('cloud:cleanup-unverified-users', ['--yes' => true]) + ->expectsOutput('Deleted 0 unverified users.') + ->assertSuccessful(); + + $this->assertModelExists($user); +}); + +test('it only runs on Coolify Cloud', function () { + config()->set('constants.coolify.self_hosted', true); + + $this->artisan('cloud:cleanup-unverified-users') + ->expectsOutput('This command can only be run on Coolify Cloud.') + ->assertFailed(); +}); diff --git a/tests/Feature/CloudExportUsersCommandTest.php b/tests/Feature/CloudExportUsersCommandTest.php new file mode 100644 index 000000000..45dda4959 --- /dev/null +++ b/tests/Feature/CloudExportUsersCommandTest.php @@ -0,0 +1,101 @@ +set('constants.coolify.self_hosted', false); +}); + +test('it exports subscribed and unsubscribed verified users to separate files without tags', function () { + User::factory()->create([ + 'id' => 0, + 'name' => 'Root User', + 'email' => 'root@example.com', + ]); + + $subscribedUser = User::factory()->create([ + 'name' => 'Ada Lovelace', + 'email' => 'ada@example.com', + ]); + + Subscription::create([ + 'team_id' => $subscribedUser->teams()->firstOrFail()->id, + 'stripe_invoice_paid' => true, + 'stripe_subscription_id' => 'sub_active', + ]); + + User::factory()->create([ + 'name' => 'Grace', + 'email' => 'grace@example.com', + ]); + + User::factory()->unverified()->create([ + 'name' => 'Unverified User', + 'email' => 'unverified@example.com', + ]); + + Storage::disk('backups')->put('cloud-users.csv', 'old export'); + + $this->artisan('cloud:export-users')->assertSuccessful(); + + Storage::disk('backups')->assertExists([ + 'cloud-users-subscribed.csv', + 'cloud-users-unsubscribed.csv', + ]); + Storage::disk('backups')->assertMissing('cloud-users.csv'); + + $readCsv = function (string $filename): array { + $output = fopen(Storage::disk('backups')->path($filename), 'rb'); + $rows = []; + + while (($row = fgetcsv($output, null, ',', '"', '')) !== false) { + $rows[] = $row; + } + + fclose($output); + + return $rows; + }; + + $header = [ + 'email', + 'first_name', + 'last_name', + 'lifetime_value_currency', + 'lifetime_value_amount', + 'utm_campaign', + 'utm_source', + 'utm_medium', + 'utm_content', + 'utm_term', + 'phone', + ]; + + expect($readCsv('cloud-users-subscribed.csv'))->toBe([ + $header, + ['ada@example.com', 'Ada', 'Lovelace', '', '', '', '', '', '', '', ''], + ])->and($readCsv('cloud-users-unsubscribed.csv'))->toBe([ + $header, + ['grace@example.com', 'Grace', '', '', '', '', '', '', '', '', ''], + ]); +}); + +test('it only runs on Coolify Cloud', function () { + config()->set('constants.coolify.self_hosted', true); + + $this->artisan('cloud:export-users') + ->expectsOutput('This command can only be run on Coolify Cloud.') + ->assertFailed(); + + Storage::disk('backups')->assertMissing([ + 'cloud-users.csv', + 'cloud-users-subscribed.csv', + 'cloud-users-unsubscribed.csv', + ]); +}); diff --git a/tests/Feature/CreateScheduledBackupValidationTest.php b/tests/Feature/CreateScheduledBackupValidationTest.php index bb46f8f78..3a5ea6a37 100644 --- a/tests/Feature/CreateScheduledBackupValidationTest.php +++ b/tests/Feature/CreateScheduledBackupValidationTest.php @@ -7,8 +7,10 @@ use App\Models\Server; use App\Models\Service; use App\Models\ServiceDatabase; +use App\Models\StandaloneClickhouse; use App\Models\StandaloneDocker; use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -91,3 +93,55 @@ expect($backup->save_s3)->toBeFalsy() ->and($backup->s3_storage_id)->toBeNull(); }); + +it('creates a clickhouse backup for its configured database', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $database = StandaloneClickhouse::create([ + 'name' => 'clickhouse-scheduled-backup', + 'clickhouse_admin_user' => 'default', + 'clickhouse_admin_password' => 'password', + 'clickhouse_db' => 'analytics', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $component = Livewire::test(CreateScheduledBackup::class, ['database' => $database]) + ->set('frequency', 'daily') + ->call('submit'); + + $backup = ScheduledDatabaseBackup::firstOrFail(); + + $component->assertRedirectToRoute('project.database.backup.execution', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'database_uuid' => $database->uuid, + 'backup_uuid' => $backup->uuid, + ]); + + expect($backup->database_type)->toBe(StandaloneClickhouse::class) + ->and($backup->databases_to_backup)->toBe('analytics'); +}); + +it('rejects scheduled backups for unsupported database types', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $database = StandaloneRedis::create([ + 'name' => 'redis-without-backups', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + Livewire::test(CreateScheduledBackup::class, ['database' => $database]) + ->set('frequency', 'daily') + ->call('submit') + ->assertDispatched('error'); + + expect(ScheduledDatabaseBackup::count())->toBe(0); +}); diff --git a/tests/Feature/ForgotPasswordRateLimitTest.php b/tests/Feature/ForgotPasswordRateLimitTest.php index aaff953ba..02dbce650 100644 --- a/tests/Feature/ForgotPasswordRateLimitTest.php +++ b/tests/Feature/ForgotPasswordRateLimitTest.php @@ -27,9 +27,9 @@ it('rate limits dotted plus-address forgot password variants of the same email identity across ips', function () { $emails = [ - 'ke.vinmcfadden+one@btinternet.com', - 'kevin.mcfadden+two@btinternet.com', - 'k.e.v.i.n.m.c.f.a.d.d.e.n+three@btinternet.com', + 'ke.vinmcfadden+one@gmail.com', + 'kevin.mcfadden+two@gmail.com', + 'k.e.v.i.n.m.c.f.a.d.d.e.n+three@gmail.com', ]; foreach ($emails as $index => $email) { @@ -42,7 +42,24 @@ $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.99']) ->post('/forgot-password', [ - 'email' => 'k.evin.mcfadden+four@btinternet.com', + 'email' => 'k.evin.mcfadden+four@gmail.com', ]) ->assertTooManyRequests(); }); + +it('keeps distinct dotted and plus-addressed mailboxes in separate forgot password buckets on ordinary domains', function () { + $emails = [ + 'john.smith@example.com', + 'johnsmith@example.com', + 'johnsmith+one@example.com', + 'johnsmith+two@example.com', + ]; + + foreach ($emails as $index => $email) { + $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.'.($index + 120)]) + ->post('/forgot-password', [ + 'email' => $email, + ]) + ->assertSessionHasNoErrors(); + } +}); diff --git a/tests/Feature/RegistrationRateLimitTest.php b/tests/Feature/RegistrationRateLimitTest.php index 6288a85b8..7130ed36e 100644 --- a/tests/Feature/RegistrationRateLimitTest.php +++ b/tests/Feature/RegistrationRateLimitTest.php @@ -3,6 +3,7 @@ use App\Models\InstanceSettings; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\RateLimiter; uses(RefreshDatabase::class); @@ -42,9 +43,9 @@ it('rate limits dotted plus-address variants of the same email identity across ips', function () { $emails = [ - 'ke.vinmcfadden+one@btinternet.com', - 'kevin.mcfadden+two@btinternet.com', - 'k.e.v.i.n.m.c.f.a.d.d.e.n+three@btinternet.com', + 'ke.vinmcfadden+one@gmail.com', + 'kevin.mcfadden+two@gmail.com', + 'k.e.v.i.n.m.c.f.a.d.d.e.n+three@gmail.com', ]; foreach ($emails as $index => $email) { @@ -64,9 +65,33 @@ $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.99']) ->post('/register', [ 'name' => 'Blocked User', - 'email' => 'k.evin.mcfadden+four@btinternet.com', + 'email' => 'k.evin.mcfadden+four@gmail.com', 'password' => 'Password1!@', 'password_confirmation' => 'Password1!@', ]) ->assertTooManyRequests(); }); + +it('keeps distinct dotted and plus-addressed mailboxes in separate rate limit buckets on ordinary domains', function () { + $registrationIpKey = 'registration:ip:'.sha1('127.0.0.1'); + $emails = [ + 'john.smith@example.com', + 'johnsmith@example.com', + 'johnsmith+one@example.com', + 'johnsmith+two@example.com', + ]; + + foreach ($emails as $index => $email) { + $this->post('/register', [ + 'name' => "Distinct User {$index}", + 'email' => $email, + 'password' => 'Password1!@', + 'password_confirmation' => 'Password1!@', + ]) + ->assertRedirect(); + + auth()->logout(); + $this->flushSession(); + RateLimiter::clear($registrationIpKey); + } +}); diff --git a/tests/Feature/ScheduleOnOneServerTest.php b/tests/Feature/ScheduleOnOneServerTest.php index 167d16bfa..49d592689 100644 --- a/tests/Feature/ScheduleOnOneServerTest.php +++ b/tests/Feature/ScheduleOnOneServerTest.php @@ -48,3 +48,13 @@ ); }); }); + +it('does not schedule Stripe subscription reconciliation automatically', function () { + $schedule = app(Schedule::class); + + $event = collect($schedule->events())->first( + fn ($event) => str_contains((string) $event->description, 'SyncStripeSubscriptions') + ); + + expect($event)->toBeNull(); +}); diff --git a/tests/Feature/Subscription/SyncStripeSubscriptionsActionTest.php b/tests/Feature/Subscription/SyncStripeSubscriptionsActionTest.php new file mode 100644 index 000000000..31070053d --- /dev/null +++ b/tests/Feature/Subscription/SyncStripeSubscriptionsActionTest.php @@ -0,0 +1,296 @@ +set('constants.coolify.self_hosted', false); + config()->set('subscription.provider', 'stripe'); + + $this->team = Team::factory()->create(); + $this->subscription = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_stale', + 'stripe_customer_id' => 'cus_stale', + 'stripe_invoice_paid' => true, + ]); +}); + +afterEach(function () { + SyncStripeSubscriptions::clearFake(); +}); + +function stripeSubscriptionCollection(array $subscriptions = []): StripeCollection +{ + return StripeCollection::constructFrom([ + 'data' => $subscriptions, + 'has_more' => false, + 'url' => '/v1/subscriptions', + ]); +} + +test('fix mode fully ends a locally active subscription with an invalid Stripe status', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'unreachable_count' => 0, + 'unreachable_notification_sent' => false, + ]); + + $stripe = Mockery::mock(StripeClient::class); + $subscriptions = Mockery::mock(SubscriptionService::class); + $customers = Mockery::mock(CustomerService::class); + $stripe->subscriptions = $subscriptions; + $stripe->customers = $customers; + + $subscriptions->shouldReceive('all')->twice()->andReturn(stripeSubscriptionCollection()); + $subscriptions->shouldReceive('retrieve')->with('sub_stale')->andReturn((object) [ + 'status' => 'unpaid', + 'customer' => 'cus_stale', + ]); + $customers->shouldReceive('retrieve')->with('cus_stale')->andThrow(new RuntimeException('Customer unavailable')); + + $this->instance(StripeClient::class, $stripe); + + $progress = []; + + SyncStripeSubscriptions::run( + fix: true, + onProgress: function (string $stage, int $current, ?int $total) use (&$progress): void { + $progress[] = [$stage, $current, $total]; + }, + ); + + $this->subscription->refresh(); + $server->refresh(); + + expect($this->subscription->stripe_invoice_paid)->toBeFalsy() + ->and($this->subscription->stripe_subscription_id)->toBeNull() + ->and($server->unreachable_count)->toBe(3) + ->and($server->unreachable_notification_sent)->toBeTrue() + ->and($progress)->toContain(['checking', 0, 1]) + ->and($progress)->toContain(['checking', 1, 1]); +}); + +test('a same-email subscription not linked to the team is left for manual review', function () { + $stripe = Mockery::mock(StripeClient::class); + $subscriptions = Mockery::mock(SubscriptionService::class); + $customers = Mockery::mock(CustomerService::class); + $stripe->subscriptions = $subscriptions; + $stripe->customers = $customers; + + $subscriptions->shouldReceive('all') + ->with(Mockery::on(fn (array $parameters) => isset($parameters['status']))) + ->twice() + ->andReturn(stripeSubscriptionCollection()); + $subscriptions->shouldReceive('retrieve')->with('sub_stale')->andReturn((object) [ + 'status' => 'canceled', + 'customer' => 'cus_stale', + ]); + $customers->shouldReceive('retrieve')->with('cus_stale')->andReturn((object) [ + 'email' => 'customer@example.com', + ]); + $customers->shouldReceive('all')->andReturn((object) [ + 'data' => [(object) ['id' => 'cus_active']], + ]); + $subscriptions->shouldReceive('all')->with([ + 'customer' => 'cus_active', + 'limit' => 10, + ])->andReturn((object) [ + 'data' => [(object) [ + 'id' => 'sub_active_elsewhere', + 'status' => 'active', + ]], + ]); + + $this->instance(StripeClient::class, $stripe); + + $result = SyncStripeSubscriptions::run(fix: true); + + $this->subscription->refresh(); + + expect($result['resubscribed'])->toHaveCount(1) + ->and($result['discrepancies'])->toHaveCount(1) + ->and($result['discrepancies'][0]['resolution'])->toBe('manual_review') + ->and($this->subscription->stripe_invoice_paid)->toBeTruthy() + ->and($this->subscription->stripe_subscription_id)->toBe('sub_stale'); +}); + +test('a stale local subscription is deleted when its team has a valid replacement', function () { + $replacement = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_active_elsewhere', + 'stripe_customer_id' => 'cus_active', + 'stripe_invoice_paid' => true, + ]); + $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->settings->update([ + 'is_reachable' => true, + 'is_usable' => true, + ]); + + $stripe = Mockery::mock(StripeClient::class); + $subscriptions = Mockery::mock(SubscriptionService::class); + $customers = Mockery::mock(CustomerService::class); + $stripe->subscriptions = $subscriptions; + $stripe->customers = $customers; + + $subscriptions->shouldReceive('all') + ->with(['status' => 'active', 'limit' => 100]) + ->andReturn(stripeSubscriptionCollection([ + ['id' => 'sub_active_elsewhere'], + ])); + $subscriptions->shouldReceive('all') + ->with(['status' => 'past_due', 'limit' => 100]) + ->andReturn(stripeSubscriptionCollection()); + $subscriptions->shouldReceive('retrieve')->with('sub_stale')->andReturn((object) [ + 'status' => 'canceled', + 'customer' => 'cus_stale', + ]); + $customers->shouldReceive('retrieve')->with('cus_stale')->andReturn((object) [ + 'email' => 'customer@example.com', + ]); + $customers->shouldReceive('all')->andReturn((object) [ + 'data' => [(object) ['id' => 'cus_active']], + ]); + $subscriptions->shouldReceive('all')->with([ + 'customer' => 'cus_active', + 'limit' => 10, + ])->andReturn((object) [ + 'data' => [(object) [ + 'id' => 'sub_active_elsewhere', + 'status' => 'active', + ]], + ]); + + $this->instance(StripeClient::class, $stripe); + + $result = SyncStripeSubscriptions::run(fix: true); + + expect($result['discrepancies'][0]['resolution'])->toBe('delete_stale') + ->and($this->subscription->fresh())->toBeNull() + ->and($replacement->fresh())->not->toBeNull() + ->and($replacement->fresh()->stripe_invoice_paid)->toBeTruthy() + ->and($server->settings->fresh()->is_reachable)->toBeTruthy() + ->and($server->settings->fresh()->is_usable)->toBeTruthy(); +}); + +test('a stale duplicate is deleted when its team already has an inactive subscription row', function () { + $inactiveSubscription = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_customer_id' => 'cus_stale', + 'stripe_invoice_paid' => false, + ]); + $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->settings->update([ + 'is_reachable' => true, + 'is_usable' => true, + ]); + + $stripe = Mockery::mock(StripeClient::class); + $subscriptions = Mockery::mock(SubscriptionService::class); + $customers = Mockery::mock(CustomerService::class); + $stripe->subscriptions = $subscriptions; + $stripe->customers = $customers; + + $subscriptions->shouldReceive('all')->twice()->andReturn(stripeSubscriptionCollection()); + $subscriptions->shouldReceive('retrieve')->with('sub_stale')->andReturn((object) [ + 'status' => 'canceled', + 'customer' => 'cus_stale', + ]); + $customers->shouldReceive('retrieve')->with('cus_stale')->andThrow(new RuntimeException('Customer unavailable')); + + $this->instance(StripeClient::class, $stripe); + + $result = SyncStripeSubscriptions::run(fix: true); + + expect($result['discrepancies'][0]['resolution'])->toBe('delete_stale') + ->and($this->subscription->fresh())->toBeNull() + ->and($inactiveSubscription->fresh())->not->toBeNull() + ->and($server->settings->fresh()->is_reachable)->toBeFalsy() + ->and($server->settings->fresh()->is_usable)->toBeFalsy(); +}); + +test('valid Stripe subscriptions remain active', function (string $status) { + $stripe = Mockery::mock(StripeClient::class); + $subscriptions = Mockery::mock(SubscriptionService::class); + $stripe->subscriptions = $subscriptions; + + $subscriptions->shouldReceive('all') + ->with(['status' => $status, 'limit' => 100]) + ->andReturn(stripeSubscriptionCollection([ + ['id' => 'sub_stale'], + ])); + $subscriptions->shouldReceive('all') + ->with(['status' => $status === 'active' ? 'past_due' : 'active', 'limit' => 100]) + ->andReturn(stripeSubscriptionCollection()); + $subscriptions->shouldNotReceive('retrieve'); + + $this->instance(StripeClient::class, $stripe); + + $result = SyncStripeSubscriptions::run(fix: true); + + $this->subscription->refresh(); + + expect($result['discrepancies'])->toBeEmpty() + ->and($this->subscription->stripe_invoice_paid)->toBeTruthy() + ->and($this->subscription->stripe_subscription_id)->toBe('sub_stale'); +})->with(['active', 'past_due']); + +test('the terminal command runs the reconciliation action synchronously', function () { + SyncStripeSubscriptions::shouldRun() + ->once() + ->withArgs(fn (bool $fix, ?Closure $onProgress) => $fix === false && $onProgress instanceof Closure) + ->andReturnUsing(function (bool $fix, Closure $onProgress): array { + $onProgress('checking', 2, 10); + + return [ + 'total_checked' => 0, + 'discrepancies' => [], + 'resubscribed' => [], + 'errors' => [], + 'fixed' => false, + ]; + }); + + $this->artisan('cloud:sync-stripe-subscriptions') + ->expectsOutputToContain('Checking stale subscriptions against Stripe... 2/10') + ->expectsOutput('Total subscriptions checked: 0') + ->assertSuccessful(); +}); + +test('the terminal command passes the fix option to the reconciliation action', function () { + SyncStripeSubscriptions::shouldRun() + ->once() + ->withArgs(fn (bool $fix, ?Closure $onProgress) => $fix === true && $onProgress instanceof Closure) + ->andReturn([ + 'total_checked' => 1, + 'discrepancies' => [[ + 'subscription_id' => 1, + 'team_id' => 2, + 'stripe_subscription_id' => 'sub_stale', + 'stripe_status' => 'canceled', + 'resolution' => 'manual_review', + ]], + 'resubscribed' => [], + 'errors' => [], + 'fixed' => true, + 'fixed_count' => 0, + 'manual_review_count' => 1, + ]); + + $this->artisan('cloud:sync-stripe-subscriptions', ['--fix' => true]) + ->expectsOutput('Total subscriptions checked: 1') + ->expectsOutput(' Resolution: Manual review required') + ->expectsOutput('Automatic corrections applied: 0') + ->expectsOutput('Skipped for manual review: 1') + ->assertSuccessful(); +}); diff --git a/tests/Feature/Subscription/TeamSubscriptionEndedTest.php b/tests/Feature/Subscription/TeamSubscriptionEndedTest.php index 55d59e0e6..bc0f0a6f2 100644 --- a/tests/Feature/Subscription/TeamSubscriptionEndedTest.php +++ b/tests/Feature/Subscription/TeamSubscriptionEndedTest.php @@ -1,5 +1,6 @@ toBeTrue(); }); + +test('subscriptionEnded updates the exact subscription when duplicate rows exist', function () { + $team = Team::factory()->create(); + $otherSubscription = Subscription::create([ + 'team_id' => $team->id, + 'stripe_subscription_id' => 'sub_other', + 'stripe_invoice_paid' => true, + ]); + $subscriptionToEnd = Subscription::create([ + 'team_id' => $team->id, + 'stripe_subscription_id' => 'sub_stale', + 'stripe_invoice_paid' => true, + ]); + + $team->subscriptionEnded($subscriptionToEnd); + + expect($subscriptionToEnd->fresh()->stripe_subscription_id)->toBeNull() + ->and($subscriptionToEnd->fresh()->stripe_invoice_paid)->toBeFalsy() + ->and($otherSubscription->fresh()->stripe_subscription_id)->toBe('sub_other') + ->and($otherSubscription->fresh()->stripe_invoice_paid)->toBeTruthy(); +}); diff --git a/tests/Feature/VultrApiTest.php b/tests/Feature/VultrApiTest.php index 9eb135baf..e9c0ee24a 100644 --- a/tests/Feature/VultrApiTest.php +++ b/tests/Feature/VultrApiTest.php @@ -301,6 +301,104 @@ function testPublicKey(): string ]); }); + test('deletes the Vultr instance when local server persistence fails', function () { + Http::fake([ + 'https://api.vultr.com/v2/ssh-keys' => Http::response([ + 'ssh_key' => ['id' => 'key-1', 'ssh_key' => testPublicKey()], + ], 201), + 'https://api.vultr.com/v2/ssh-keys*' => Http::response([ + 'ssh_keys' => [], + 'meta' => ['links' => ['next' => null]], + ], 200), + 'https://api.vultr.com/v2/instances' => Http::response([ + 'instance' => [ + 'id' => 'instance-persistence-fails', + 'main_ip' => '203.0.113.10', + 'status' => 'pending', + ], + ], 202), + 'https://api.vultr.com/v2/instances/instance-persistence-fails' => Http::response(null, 204), + ]); + + $eventDispatcher = Server::getEventDispatcher(); + Server::setEventDispatcher(clone $eventDispatcher); + Server::created(function (): void { + throw new RuntimeException('local persistence failed'); + }); + + try { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/vultr', [ + 'cloud_provider_token_id' => $this->vultrToken->uuid, + 'region' => 'ewr', + 'plan' => 'vc2-1c-1gb', + 'os_id' => 2284, + 'name' => 'persistence-fails', + 'private_key_uuid' => $this->privateKey->uuid, + ]); + } finally { + Server::setEventDispatcher($eventDispatcher); + } + + $response->assertServerError(); + $response->assertJson(['message' => 'Failed to create Vultr server.']); + $this->assertDatabaseMissing('servers', [ + 'vultr_instance_id' => 'instance-persistence-fails', + ]); + Http::assertSent(fn ($request) => $request->method() === 'DELETE' + && $request->url() === 'https://api.vultr.com/v2/instances/instance-persistence-fails'); + }); + + test('keeps the tracked Vultr server when public IP polling fails', function () { + Http::fake([ + 'https://api.vultr.com/v2/ssh-keys' => Http::response([ + 'ssh_key' => ['id' => 'key-1', 'ssh_key' => testPublicKey()], + ], 201), + 'https://api.vultr.com/v2/ssh-keys*' => Http::response([ + 'ssh_keys' => [], + 'meta' => ['links' => ['next' => null]], + ], 200), + 'https://api.vultr.com/v2/instances' => Http::response([ + 'instance' => [ + 'id' => 'instance-polling-fails', + 'main_ip' => '0.0.0.0', + 'status' => 'pending', + ], + ], 202), + 'https://api.vultr.com/v2/instances/instance-polling-fails' => Http::response([ + 'error' => 'temporary polling failure', + ], 500), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/vultr', [ + 'cloud_provider_token_id' => $this->vultrToken->uuid, + 'region' => 'ewr', + 'plan' => 'vc2-1c-1gb', + 'os_id' => 2284, + 'name' => 'polling-fails', + 'private_key_uuid' => $this->privateKey->uuid, + ]); + + $response->assertCreated(); + $response->assertJsonFragment([ + 'vultr_instance_id' => 'instance-polling-fails', + 'ip' => Server::PLACEHOLDER_IP, + ]); + $this->assertDatabaseHas('servers', [ + 'name' => 'polling-fails', + 'ip' => Server::PLACEHOLDER_IP, + 'team_id' => $this->team->id, + 'vultr_instance_id' => 'instance-polling-fails', + 'vultr_instance_status' => 'pending', + ]); + Http::assertNotSent(fn ($request) => $request->method() === 'DELETE'); + }); + test('server creation authorizes access to the stored Vultr token', function () { $member = User::factory()->create(); $this->team->members()->attach($member->id, ['role' => 'member']); diff --git a/tests/Feature/VultrServerCreationTest.php b/tests/Feature/VultrServerCreationTest.php index d8e5af321..9474b697f 100644 --- a/tests/Feature/VultrServerCreationTest.php +++ b/tests/Feature/VultrServerCreationTest.php @@ -8,6 +8,7 @@ use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; use Livewire\Livewire; @@ -170,6 +171,56 @@ function vultrLivewireTestPublicKey(): string 'vultr_instance_id' => 'instance-1', 'vultr_instance_status' => 'pending', ]); + + Http::assertNotSent(fn (Request $request): bool => $request->method() === 'DELETE' + && $request->url() === 'https://api.vultr.com/v2/instances/instance-1'); +}); + +it('deletes the Vultr instance when local server persistence fails', function () { + Http::fake([ + 'https://api.vultr.com/v2/ssh-keys' => Http::response([ + 'ssh_key' => ['id' => 'key-1', 'ssh_key' => vultrLivewireTestPublicKey()], + ], 201), + 'https://api.vultr.com/v2/ssh-keys*' => Http::response([ + 'ssh_keys' => [], + 'meta' => ['links' => ['next' => null]], + ], 200), + 'https://api.vultr.com/v2/instances' => Http::response([ + 'instance' => [ + 'id' => 'instance-1', + 'label' => 'persistence-fails', + 'main_ip' => '192.0.2.10', + 'v6_main_ip' => '2001:db8::1', + 'status' => 'pending', + ], + ], 202), + 'https://api.vultr.com/v2/instances/instance-1' => Http::response(null, 204), + ]); + + $eventDispatcher = Server::getEventDispatcher(); + Server::setEventDispatcher(clone $eventDispatcher); + Server::created(function (): void { + throw new RuntimeException('local persistence failed'); + }); + + try { + Livewire::test(ByVultr::class, ['selectedTokenUuid' => $this->vultrToken->uuid]) + ->set('server_name', 'persistence-fails') + ->set('selected_region', 'ewr') + ->set('selected_plan', 'vc2-1c-1gb') + ->set('selected_os_id', 2284) + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertDispatched('error', 'local persistence failed'); + } finally { + Server::setEventDispatcher($eventDispatcher); + } + + $this->assertDatabaseMissing('servers', [ + 'vultr_instance_id' => 'instance-1', + ]); + Http::assertSent(fn (Request $request): bool => $request->method() === 'DELETE' + && $request->url() === 'https://api.vultr.com/v2/instances/instance-1'); }); it('requires IPv6 when public IPv4 is disabled', function () { diff --git a/tests/Unit/Api/LogEndpointHelpersTest.php b/tests/Unit/Api/LogEndpointHelpersTest.php index 0b0107e73..a899669d6 100644 --- a/tests/Unit/Api/LogEndpointHelpersTest.php +++ b/tests/Unit/Api/LogEndpointHelpersTest.php @@ -18,6 +18,20 @@ ->and(normalizeLogLines('50000'))->toBe(10000); }); +it('normalizes service resource log line counts before invoking Docker', function (string $controller, mixed $lines, int $expectedLines) { + $source = file_get_contents(__DIR__."/../../../app/Http/Controllers/Api/{$controller}.php"); + + expect($source)->toContain('$lines = normalizeLogLines($request->query(\'lines\'));') + ->and(normalizeLogLines($lines))->toBe($expectedLines); +})->with([ + 'application invalid value' => ['ServiceApplicationsController', 'invalid', 100], + 'application negative value' => ['ServiceApplicationsController', '-5', 100], + 'application value above limit' => ['ServiceApplicationsController', '50000', 10000], + 'database invalid value' => ['ServiceDatabasesController', 'invalid', 100], + 'database negative value' => ['ServiceDatabasesController', '-5', 100], + 'database value above limit' => ['ServiceDatabasesController', '50000', 10000], +]); + it('parses show_timestamps query values as booleans', function () { if (! function_exists('parseLogTimestampFlag')) { expect(function_exists('parseLogTimestampFlag'))->toBeTrue(); diff --git a/tests/Unit/ClickhouseBackupCommandTest.php b/tests/Unit/ClickhouseBackupCommandTest.php new file mode 100644 index 000000000..0130a9dd6 --- /dev/null +++ b/tests/Unit/ClickhouseBackupCommandTest.php @@ -0,0 +1,36 @@ +isBackupSolutionAvailable())->toBeTrue(); +}); + +test('builds a native clickhouse backup command using container credentials', function () { + $commands = ClickhouseBackupCommand::make( + containerName: 'clickhouse-uuid', + database: 'analytics', + archiveName: 'backup-uuid.zip', + backupDirectory: '/data/coolify/backups/clickhouse', + ); + + expect($commands)->toHaveCount(3) + ->and($commands[0])->toBe("mkdir -p '/data/coolify/backups/clickhouse'") + ->and($commands[1])->toContain("docker exec 'clickhouse-uuid' clickhouse-client") + ->and($commands[1])->not->toContain('--password') + ->and($commands[1])->toContain("BACKUP DATABASE `analytics` TO File('") + ->and($commands[1])->toContain('backup-uuid.zip') + ->and($commands[2])->toBe("docker cp 'clickhouse-uuid:/var/lib/clickhouse/backups/backup-uuid.zip' '/data/coolify/backups/clickhouse/backup-uuid.zip'") + ->and(ClickhouseBackupCommand::cleanup('clickhouse-uuid', 'backup-uuid.zip')) + ->toBe("docker exec 'clickhouse-uuid' rm -f '/var/lib/clickhouse/backups/backup-uuid.zip'"); +}); + +test('rejects unsafe clickhouse database names', function () { + expect(fn () => ClickhouseBackupCommand::make( + containerName: 'clickhouse-uuid', + database: 'analytics`; DROP DATABASE default; --', + archiveName: 'backup-uuid.zip', + backupDirectory: '/data/coolify/backups/clickhouse', + ))->toThrow(Exception::class); +}); diff --git a/tests/Unit/DigitalOceanServiceTest.php b/tests/Unit/DigitalOceanServiceTest.php index 784926838..ad687b546 100644 --- a/tests/Unit/DigitalOceanServiceTest.php +++ b/tests/Unit/DigitalOceanServiceTest.php @@ -1,11 +1,59 @@ Http::response(['message' => $message], $status), + ]); + + $exception = null; + + try { + (new DigitalOceanService('test-token'))->createDroplet([]); + } catch (Throwable $caught) { + $exception = $caught; + } + + expect($exception)->toBeInstanceOf(Exception::class) + ->and($exception)->not->toBeInstanceOf(RequestException::class) + ->and($exception->getMessage())->toBe('DigitalOcean API error: '.$message) + ->and($exception->getCode())->toBe($status); + + Http::assertSentCount(3); +})->with([ + 'unauthorized' => [401, 'Unable to authenticate you'], + 'unprocessable entity' => [422, 'Droplet name is invalid'], +]); + +it('maps a final DigitalOcean rate-limit response with Retry-After', function () { + Http::fake([ + 'https://api.digitalocean.com/v2/droplets' => Http::sequence() + ->push(['message' => 'Temporary provider error'], 500) + ->push(['message' => 'Temporary provider error'], 500) + ->push(['message' => 'Too many requests'], 429, ['Retry-After' => '45']), + ]); + + try { + (new DigitalOceanService('test-token'))->createDroplet([]); + } catch (RateLimitException $exception) { + expect($exception->getMessage())->toBe('Rate limit exceeded. Please try again later.') + ->and($exception->retryAfter)->toBe(45); + + Http::assertSentCount(3); + + return; + } + + test()->fail('Expected a RateLimitException to be thrown.'); +}); + it('fetches paginated regions from DigitalOcean', function () { Http::fake([ 'https://api.digitalocean.com/v2/regions?page=1&per_page=50' => Http::response([ diff --git a/tests/Unit/EmailIdentityHelperTest.php b/tests/Unit/EmailIdentityHelperTest.php index 475c51009..3b27f6ff6 100644 --- a/tests/Unit/EmailIdentityHelperTest.php +++ b/tests/Unit/EmailIdentityHelperTest.php @@ -1,8 +1,12 @@ toBe('kevinmcfadden@btinternet.com'); - expect(normalize_email_identity('k.e.v.i.n.m.c.f.a.d.d.e.n+two@btinternet.com'))->toBe('kevinmcfadden@btinternet.com'); + expect(normalize_email_identity('Ke.VinMcFadden+one@Gmail.com'))->toBe('kevinmcfadden@gmail.com'); + expect(normalize_email_identity('k.e.v.i.n.m.c.f.a.d.d.e.n+two@googlemail.com'))->toBe('kevinmcfadden@googlemail.com'); +}); + +it('preserves dots and plus suffixes for ordinary email providers', function () { + expect(normalize_email_identity(' John.Smith+alerts@Example.com '))->toBe('john.smith+alerts@example.com'); }); it('returns null for blank or malformed email identities', function (?string $email) { diff --git a/tests/Unit/VultrServiceTest.php b/tests/Unit/VultrServiceTest.php index a912b8907..dc778e1b2 100644 --- a/tests/Unit/VultrServiceTest.php +++ b/tests/Unit/VultrServiceTest.php @@ -1,6 +1,8 @@ Http::response(['error' => $message], $status), + ]); + + $exception = null; + + try { + (new VultrService('fake-token'))->createInstance([]); + } catch (Throwable $caught) { + $exception = $caught; + } + + expect($exception)->toBeInstanceOf(Exception::class) + ->and($exception)->not->toBeInstanceOf(RequestException::class) + ->and($exception->getMessage())->toBe('Vultr API error: '.$message) + ->and($exception->getCode())->toBe($status); + + Http::assertSentCount(3); +})->with([ + 'unauthorized' => [401, 'Unauthorized'], + 'unprocessable entity' => [422, 'Invalid instance parameters'], +]); + +it('maps a final Vultr rate-limit response with Retry-After', function () { + Http::fake([ + 'https://api.vultr.com/v2/instances' => Http::sequence() + ->push(['error' => 'Temporary provider error'], 500) + ->push(['error' => 'Temporary provider error'], 500) + ->push(['error' => 'Too many requests'], 429, ['Retry-After' => '30']), + ]); + + try { + (new VultrService('fake-token'))->createInstance([]); + } catch (RateLimitException $exception) { + expect($exception->getMessage())->toBe('Rate limit exceeded. Please try again later.') + ->and($exception->retryAfter)->toBe(30); + + Http::assertSentCount(3); + + return; + } + + test()->fail('Expected a RateLimitException to be thrown.'); +}); + it('gets instances from Vultr API', function () { Http::fake([ 'https://api.vultr.com/v2/instances*' => Http::response([