Merge remote-tracking branch 'origin/next' into volume-backups-server-s3

This commit is contained in:
Andras Bacsai 2026-07-16 13:47:15 +02:00
commit ddbed9f8a6
38 changed files with 1634 additions and 134 deletions

View file

@ -1,30 +1,18 @@
<?php
namespace App\Jobs;
namespace App\Actions\Stripe;
use App\Models\Subscription;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Lorisleiva\Actions\Concerns\AsAction;
use Stripe\StripeClient;
class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
class SyncStripeSubscriptions
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
use AsAction;
public int $tries = 1;
private const VALID_STRIPE_STATUSES = ['active', 'past_due'];
public int $timeout = 1800; // 30 minutes max
public function __construct(public bool $fix = false)
{
$this->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);
}
}
}

View file

@ -0,0 +1,83 @@
<?php
namespace App\Console\Commands\Cloud;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
class CleanupUnverifiedUsers extends Command
{
protected $signature = 'cloud:cleanup-unverified-users
{--yes : Delete eligible users instead of running a dry run}';
protected $description = 'Delete unverified users without Stripe subscriptions or defined resources';
public function handle(): int
{
if (! isCloud()) {
$this->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');
});
}
}

View file

@ -0,0 +1,127 @@
<?php
namespace App\Console\Commands\Cloud;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use Throwable;
class ExportUsers extends Command
{
protected $signature = 'cloud:export-users';
protected $description = 'Export subscribed and unsubscribed verified Coolify Cloud users to separate CSV files';
public function handle(): int
{
if (! isCloud()) {
$this->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<int, mixed> $fields
*/
private function writeCsvRow($output, array $fields): void
{
if (fputcsv($output, $fields, ',', '"', '') === false) {
throw new RuntimeException('Unable to write the CSV file.');
}
}
}

View file

@ -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();
}
}

View file

@ -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)) {

View file

@ -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([

View file

@ -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),

View file

@ -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);

View file

@ -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) {

View file

@ -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,

View file

@ -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);

View file

@ -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');

View file

@ -370,6 +370,6 @@ public function scheduledBackups()
public function isBackupSolutionAvailable()
{
return false;
return true;
}
}

View file

@ -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,

View file

@ -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()) {

View file

@ -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()) {

View file

@ -0,0 +1,37 @@
<?php
namespace App\Support;
final class ClickhouseBackupCommand
{
/** @return array<int, string> */
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);
}
}

View file

@ -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;

View file

@ -22,7 +22,7 @@
<div class="box-title">{{ $user->name }}</div>
<div class="box-description">{{ $user->email }}</div>
<div class="box-description">Active:
{{ $user->teams()->whereRelation('subscription', 'stripe_subscription_id', '!=', null)->exists() ? 'Yes' : 'No' }}
{{ $user->teams()->whereRelation('subscription', 'stripe_invoice_paid', true)->exists() ? 'Yes' : 'No' }}
</div>
</div>
</div>

View file

@ -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')
<x-forms.input label="Databases To Backup"
helper="Comma separated list of databases to backup. Empty will include the default one."
id="databasesToBackup" />
@endif
</div>
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">

View file

@ -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
</a>
@if (
$database->getMorphClass() === 'App\Models\StandalonePostgresql' ||
$database->getMorphClass() === 'App\Models\StandaloneMongodb' ||
$database->getMorphClass() === 'App\Models\StandaloneMysql' ||
$database->getMorphClass() === 'App\Models\StandaloneMariadb')
@if ($database->isBackupSolutionAvailable())
<a class="shrink-0 {{ request()->routeIs('project.database.backup.*') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('project.database.backup.index', $parameters) }}">
Backups
@ -210,6 +201,7 @@ class="scrollbar hidden min-h-10 w-full flex-nowrap items-center gap-6 overflow-
Terminal
</a>
@endcan
</nav>
@if ($database->destination->server->isFunctional())

View file

@ -0,0 +1,47 @@
<?php
use App\Livewire\Admin\Index as AdminIndex;
use App\Models\InstanceSettings;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('admin search only shows users with paid subscriptions as active', function () {
config()->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');
});

View file

@ -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", [

View file

@ -0,0 +1,154 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->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();
});

View file

@ -0,0 +1,101 @@
<?php
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
beforeEach(function () {
Storage::fake('backups');
config()->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',
]);
});

View file

@ -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);
});

View file

@ -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();
}
});

View file

@ -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);
}
});

View file

@ -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();
});

View file

@ -0,0 +1,296 @@
<?php
use App\Actions\Stripe\SyncStripeSubscriptions;
use App\Models\Server;
use App\Models\Subscription;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Stripe\Collection as StripeCollection;
use Stripe\Service\CustomerService;
use Stripe\Service\SubscriptionService;
use Stripe\StripeClient;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->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();
});

View file

@ -1,5 +1,6 @@
<?php
use App\Models\Subscription;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -14,3 +15,24 @@
// If we reach here, no exception was thrown
expect(true)->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();
});

View file

@ -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']);

View file

@ -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 () {

View file

@ -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();

View file

@ -0,0 +1,36 @@
<?php
use App\Models\StandaloneClickhouse;
use App\Support\ClickhouseBackupCommand;
test('clickhouse exposes scheduled backups', function () {
expect((new StandaloneClickhouse)->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);
});

View file

@ -1,11 +1,59 @@
<?php
use App\Exceptions\RateLimitException;
use App\Services\DigitalOceanService;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class);
it('maps final DigitalOcean API error responses', function (int $status, string $message) {
Http::fake([
'https://api.digitalocean.com/v2/droplets' => 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([

View file

@ -1,8 +1,12 @@
<?php
it('normalizes plus and dotted email identity variants', function () {
expect(normalize_email_identity('Ke.VinMcFadden+one@BTInternet.com'))->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) {

View file

@ -1,6 +1,8 @@
<?php
use App\Exceptions\RateLimitException;
use App\Services\VultrService;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
@ -10,6 +12,52 @@
Http::preventStrayRequests();
});
it('maps final Vultr API error responses', function (int $status, string $message) {
Http::fake([
'https://api.vultr.com/v2/instances' => 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([