mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Merge remote-tracking branch 'origin/main' into next
This commit is contained in:
@@ -209,6 +209,30 @@ class SshMultiplexingHelper
|
||||
}
|
||||
|
||||
public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false, ?int $commandTimeout = null): string
|
||||
{
|
||||
$sshCommand = self::sshCommandPrefix($server, $disableMultiplexing, $commandTimeout);
|
||||
|
||||
$delimiter = base64_encode(Hash::make($command));
|
||||
$command = str_replace($delimiter, '', $command);
|
||||
$remoteShellCommand = self::remoteShellCommand();
|
||||
|
||||
return $sshCommand.self::escapedUserAtHost($server)." '{$remoteShellCommand}' << \\$delimiter".PHP_EOL
|
||||
.$command.PHP_EOL
|
||||
.$delimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an SSH command that runs one short remote command and leaves stdin free for data.
|
||||
* Large content must go through stdin: Linux limits one exec argument to 128 KiB, and
|
||||
* generateSshCommand() puts the whole script into one `sh -c` argument.
|
||||
*/
|
||||
public static function generateSshStdinCommand(Server $server, string $remoteCommand, bool $disableMultiplexing = false, ?int $commandTimeout = null): string
|
||||
{
|
||||
return self::sshCommandPrefix($server, $disableMultiplexing, $commandTimeout)
|
||||
.self::escapedUserAtHost($server).' '.escapeshellarg($remoteCommand);
|
||||
}
|
||||
|
||||
private static function sshCommandPrefix(Server $server, bool $disableMultiplexing, ?int $commandTimeout): string
|
||||
{
|
||||
if ($server->settings->force_disabled) {
|
||||
throw new \RuntimeException('Server is disabled.');
|
||||
@@ -239,15 +263,7 @@ class SshMultiplexingHelper
|
||||
$sshCommand .= "-o ProxyCommand='cloudflared access ssh --hostname %h' ";
|
||||
}
|
||||
|
||||
$sshCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'));
|
||||
|
||||
$delimiter = base64_encode(Hash::make($command));
|
||||
$command = str_replace($delimiter, '', $command);
|
||||
$remoteShellCommand = self::remoteShellCommand();
|
||||
|
||||
return $sshCommand.self::escapedUserAtHost($server)." '{$remoteShellCommand}' << \\$delimiter".PHP_EOL
|
||||
.$command.PHP_EOL
|
||||
.$delimiter;
|
||||
return $sshCommand.self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1960,7 +1960,7 @@ class DatabasesController extends Controller
|
||||
return response()->json(['message' => 'You need to provide at least one of environment_name or environment_uuid.'], 422);
|
||||
}
|
||||
$serverUuid = $request->server_uuid;
|
||||
$instantDeploy = $request->instant_deploy ?? false;
|
||||
$instantDeploy = $request->boolean('instant_deploy');
|
||||
if ($request->is_public && ! $request->public_port) {
|
||||
$request->offsetSet('is_public', false);
|
||||
}
|
||||
@@ -2033,7 +2033,7 @@ class DatabasesController extends Controller
|
||||
'tags' => 'array|nullable',
|
||||
'tags.*' => 'string|min:2',
|
||||
]);
|
||||
if ($validator->failed()) {
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
|
||||
@@ -329,6 +329,12 @@ class Analytics extends Component
|
||||
|
||||
public function loadData(): void
|
||||
{
|
||||
$this->resetData();
|
||||
|
||||
foreach ($this->servers as $server) {
|
||||
$this->authorize('view', $server);
|
||||
}
|
||||
|
||||
if ($this->servers->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,12 @@ class TrafficAnalytics extends Component
|
||||
|
||||
public function loadData(): void
|
||||
{
|
||||
$this->reset('overview', 'latencyApproximate', 'uniquesApproximate', 'series');
|
||||
|
||||
foreach ($this->servers as $server) {
|
||||
$this->authorize('view', $server);
|
||||
}
|
||||
|
||||
if ($this->servers->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11,11 +11,15 @@ use App\Models\Team;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class Actions extends Component
|
||||
{
|
||||
#[Locked]
|
||||
public ?int $teamId = null;
|
||||
|
||||
public $server_limits = 0;
|
||||
|
||||
public int $quantity = UpdateSubscriptionQuantity::MIN_SERVER_LIMIT;
|
||||
@@ -46,16 +50,21 @@ class Actions extends Component
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$team = currentTeam();
|
||||
abort_unless($team && auth()->user()->isAdminOfTeam($team->id), 403);
|
||||
$this->teamId = $team->id;
|
||||
|
||||
$this->server_limits = Team::serverLimit();
|
||||
$this->quantity = (int) $this->server_limits;
|
||||
$this->billingInterval = currentTeam()->subscription?->billingInterval() ?? 'monthly';
|
||||
$this->billingInterval = $team->subscription?->billingInterval() ?? 'monthly';
|
||||
$this->canDeleteAccount = app(DeleteUserAccount::class)->blockers(Auth::user(), removeTeamResources: true) === [];
|
||||
}
|
||||
|
||||
public function loadPricePreview(int $quantity): void
|
||||
{
|
||||
$team = $this->authorizedTeam();
|
||||
$this->quantity = $quantity;
|
||||
$result = (new UpdateSubscriptionQuantity)->fetchPricePreview(currentTeam(), $quantity);
|
||||
$result = (new UpdateSubscriptionQuantity)->fetchPricePreview($team, $quantity);
|
||||
$this->pricePreview = $result['success'] ? $result['preview'] : null;
|
||||
}
|
||||
|
||||
@@ -64,6 +73,8 @@ class Actions extends Component
|
||||
// non-destructive, reversible billing adjustment (prorated by Stripe).
|
||||
public function updateQuantity(string $password = ''): bool
|
||||
{
|
||||
$team = $this->authorizedTeam();
|
||||
|
||||
if ($this->quantity < UpdateSubscriptionQuantity::MIN_SERVER_LIMIT) {
|
||||
$this->dispatch('error', 'Minimum server limit is '.UpdateSubscriptionQuantity::MIN_SERVER_LIMIT.'.');
|
||||
$this->quantity = UpdateSubscriptionQuantity::MIN_SERVER_LIMIT;
|
||||
@@ -76,7 +87,7 @@ class Actions extends Component
|
||||
}
|
||||
|
||||
$previousServerLimit = (int) $this->server_limits;
|
||||
$result = (new UpdateSubscriptionQuantity)->execute(currentTeam(), $this->quantity);
|
||||
$result = (new UpdateSubscriptionQuantity)->execute($team, $this->quantity);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->auditSubscriptionChange('quantity_updated', [
|
||||
@@ -98,23 +109,26 @@ class Actions extends Component
|
||||
|
||||
public function loadRefundEligibility(): void
|
||||
{
|
||||
$this->authorizedTeam();
|
||||
$this->checkRefundEligibility();
|
||||
$this->refundCheckLoading = false;
|
||||
}
|
||||
|
||||
public function stripeCustomerPortal(): void
|
||||
{
|
||||
$session = getStripeCustomerPortalSession(currentTeam());
|
||||
$session = getStripeCustomerPortalSession($this->authorizedTeam());
|
||||
redirect($session->url);
|
||||
}
|
||||
|
||||
public function refundSubscription(string $password): bool|string
|
||||
{
|
||||
$team = $this->authorizedTeam();
|
||||
|
||||
if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) {
|
||||
return 'Invalid password.';
|
||||
}
|
||||
|
||||
$result = app(RefundSubscription::class)->execute(currentTeam());
|
||||
$result = app(RefundSubscription::class)->execute($team);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->auditSubscriptionChange('refunded');
|
||||
@@ -131,6 +145,8 @@ class Actions extends Component
|
||||
|
||||
public function cancelImmediately(string $password, array $selectedActions = []): bool|string
|
||||
{
|
||||
$team = $this->authorizedTeam();
|
||||
|
||||
if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) {
|
||||
return 'Invalid password.';
|
||||
}
|
||||
@@ -141,7 +157,7 @@ class Actions extends Component
|
||||
}
|
||||
|
||||
$refund = in_array('refundLatestPayment', $selectedActions, true);
|
||||
if (! $this->cancelSubscriptionNow($refund)) {
|
||||
if (! $this->cancelSubscriptionNow($team, $refund)) {
|
||||
$this->dispatch('error', 'Something went wrong with the '.($refund ? 'refund' : 'cancellation').'. Please <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
|
||||
|
||||
return true;
|
||||
@@ -159,11 +175,13 @@ class Actions extends Component
|
||||
|
||||
public function cancelAtPeriodEnd(string $password): bool|string
|
||||
{
|
||||
$team = $this->authorizedTeam();
|
||||
|
||||
if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) {
|
||||
return 'Invalid password.';
|
||||
}
|
||||
|
||||
$result = (new CancelSubscriptionAtPeriodEnd)->execute(currentTeam());
|
||||
$result = (new CancelSubscriptionAtPeriodEnd)->execute($team);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->auditSubscriptionChange('cancellation_scheduled', ['cancel_at_period_end' => true]);
|
||||
@@ -179,7 +197,8 @@ class Actions extends Component
|
||||
|
||||
public function resumeSubscription(): bool
|
||||
{
|
||||
$result = (new ResumeSubscription)->execute(currentTeam());
|
||||
$team = $this->authorizedTeam();
|
||||
$result = (new ResumeSubscription)->execute($team);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->auditSubscriptionChange('resumed');
|
||||
@@ -193,10 +212,8 @@ class Actions extends Component
|
||||
return true;
|
||||
}
|
||||
|
||||
private function cancelSubscriptionNow(bool $refund): bool
|
||||
private function cancelSubscriptionNow(Team $team, bool $refund): bool
|
||||
{
|
||||
$team = currentTeam();
|
||||
|
||||
if ($refund) {
|
||||
// Eligibility is re-validated server-side inside RefundSubscription::execute()
|
||||
$result = app(RefundSubscription::class)->execute($team);
|
||||
@@ -267,6 +284,24 @@ class Actions extends Component
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the team locked at mount, rejecting the request when the session
|
||||
* switched to another team or the user is no longer an admin of that team.
|
||||
*/
|
||||
private function authorizedTeam(): Team
|
||||
{
|
||||
$team = currentTeam();
|
||||
|
||||
abort_unless(
|
||||
$this->teamId !== null
|
||||
&& $team?->id === $this->teamId
|
||||
&& auth()->user()->isAdminOfTeam($this->teamId),
|
||||
403
|
||||
);
|
||||
|
||||
return $team;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a user-triggered subscription change for the current team.
|
||||
*
|
||||
@@ -274,7 +309,7 @@ class Actions extends Component
|
||||
*/
|
||||
private function auditSubscriptionChange(string $action, array $context = []): void
|
||||
{
|
||||
$team = currentTeam();
|
||||
$team = $this->authorizedTeam();
|
||||
|
||||
auditLog("ui.subscription.{$action}", [
|
||||
'team_id' => $team?->id,
|
||||
@@ -287,13 +322,15 @@ class Actions extends Component
|
||||
|
||||
private function checkRefundEligibility(): void
|
||||
{
|
||||
if (! isCloud() || ! currentTeam()->subscription?->stripe_subscription_id) {
|
||||
$team = $this->authorizedTeam();
|
||||
|
||||
if (! isCloud() || ! $team->subscription?->stripe_subscription_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->refundAlreadyUsed = currentTeam()->subscription?->stripe_refunded_at !== null;
|
||||
$result = (new RefundSubscription)->checkEligibility(currentTeam());
|
||||
$this->refundAlreadyUsed = $team->subscription?->stripe_refunded_at !== null;
|
||||
$result = (new RefundSubscription)->checkEligibility($team);
|
||||
$this->isRefundEligible = $result['eligible'];
|
||||
$this->refundDaysRemaining = $result['days_remaining'];
|
||||
|
||||
|
||||
+5
-13
@@ -1588,10 +1588,9 @@ class Service extends BaseModel
|
||||
|
||||
$workdir = $this->workdir();
|
||||
// Absolute paths and tee, no cd or scp: a non-root SSH user cannot enter /data/coolify on the Coolify host.
|
||||
$commands = [
|
||||
"mkdir -p $workdir",
|
||||
"echo '".base64_encode($this->docker_compose)."' | base64 -d | tee $workdir/docker-compose.yml > /dev/null",
|
||||
];
|
||||
// File content goes over SSH stdin: inline in the command, a compose over ~96 KB exceeds the argument limit.
|
||||
instant_remote_process(["mkdir -p $workdir"], $this->server);
|
||||
instant_remote_write_file($this->server, "$workdir/docker-compose.yml", $this->docker_compose);
|
||||
$environmentFile = "$workdir/".new_public_id().'.env.tmp';
|
||||
|
||||
$envs = collect([]);
|
||||
@@ -1622,15 +1621,8 @@ class Service extends BaseModel
|
||||
foreach ($sorted as $env) {
|
||||
$envs->push("{$env->key}={$this->resolveSecretManagerDotenvValue($env)}");
|
||||
}
|
||||
if ($envs->count() === 0) {
|
||||
$commands[] = "touch {$environmentFile}";
|
||||
} else {
|
||||
$envs_base64 = base64_encode($envs->implode("\n"));
|
||||
$commands[] = "echo '$envs_base64' | base64 -d | tee {$environmentFile} > /dev/null";
|
||||
}
|
||||
$commands[] = "mv {$environmentFile} $workdir/.env";
|
||||
|
||||
instant_remote_process($commands, $this->server);
|
||||
instant_remote_write_file($this->server, $environmentFile, $envs->implode("\n"));
|
||||
instant_remote_process(["mv {$environmentFile} $workdir/.env"], $this->server);
|
||||
}
|
||||
|
||||
public function parse(bool $isNew = false): Collection
|
||||
|
||||
@@ -814,7 +814,7 @@ class ServerTransferImporter
|
||||
$this->importFileStorages(data_get($payload, 'file_storages', []), $application);
|
||||
$this->importScheduledTasks(data_get($payload, 'scheduled_tasks', []), $application, $teamId);
|
||||
$this->importTags(data_get($payload, 'tags', []), $application, $teamId);
|
||||
$this->importPreviews(data_get($payload, 'previews', []), $application, $preserveUuids, $adoptMode);
|
||||
$this->importPreviews(data_get($payload, 'previews', []), $application, $preserveUuids, $adoptMode, $teamId);
|
||||
|
||||
return $application;
|
||||
}
|
||||
@@ -1451,7 +1451,7 @@ class ServerTransferImporter
|
||||
/**
|
||||
* @param list<array<string, mixed>> $previews
|
||||
*/
|
||||
private function importPreviews(array $previews, Application $application, bool $preserveUuids, bool $adoptMode): void
|
||||
private function importPreviews(array $previews, Application $application, bool $preserveUuids, bool $adoptMode, int $teamId): void
|
||||
{
|
||||
foreach ($previews as $previewPayload) {
|
||||
$uuid = $preserveUuids && filled(data_get($previewPayload, 'uuid'))
|
||||
@@ -1459,6 +1459,11 @@ class ServerTransferImporter
|
||||
: new_public_id();
|
||||
|
||||
$preview = ApplicationPreview::withTrashed()->where('uuid', $uuid)->first();
|
||||
if ($preview && ! $this->previewBelongsToTeam($preview, $teamId)) {
|
||||
throw ValidationException::withMessages([
|
||||
'previews' => ['An imported preview UUID conflicts with a preview outside the target team.'],
|
||||
]);
|
||||
}
|
||||
if (! $preview) {
|
||||
$pullRequestId = data_get($previewPayload, 'pull_request_id');
|
||||
if ($pullRequestId !== null) {
|
||||
@@ -1475,17 +1480,24 @@ class ServerTransferImporter
|
||||
}
|
||||
|
||||
$fqdn = data_get($previewPayload, 'fqdn');
|
||||
// FQDN is globally unique; free it from other rows (including soft-deleted leftovers).
|
||||
// FQDN is globally unique; only release conflicts owned by the target team.
|
||||
if (filled($fqdn)) {
|
||||
ApplicationPreview::withoutEvents(function () use ($fqdn, $preview) {
|
||||
ApplicationPreview::withTrashed()
|
||||
->where('fqdn', $fqdn)
|
||||
->when($preview, fn ($q) => $q->where('id', '!=', $preview->id))
|
||||
->get()
|
||||
->each(function (ApplicationPreview $conflict) {
|
||||
$conflict->fqdn = null;
|
||||
$conflict->saveQuietly();
|
||||
});
|
||||
$conflicts = ApplicationPreview::withTrashed()
|
||||
->where('fqdn', $fqdn)
|
||||
->when($preview, fn ($q) => $q->where('id', '!=', $preview->id))
|
||||
->get();
|
||||
foreach ($conflicts as $conflict) {
|
||||
if (! $this->previewBelongsToTeam($conflict, $teamId)) {
|
||||
throw ValidationException::withMessages([
|
||||
'previews' => ['An imported preview domain conflicts with a preview outside the target team.'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
ApplicationPreview::withoutEvents(function () use ($conflicts) {
|
||||
$conflicts->each(function (ApplicationPreview $conflict) {
|
||||
$conflict->fqdn = null;
|
||||
$conflict->saveQuietly();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1530,6 +1542,14 @@ class ServerTransferImporter
|
||||
}
|
||||
}
|
||||
|
||||
private function previewBelongsToTeam(ApplicationPreview $preview, int $teamId): bool
|
||||
{
|
||||
return Application::withTrashed()
|
||||
->whereKey($preview->application_id)
|
||||
->whereRelation('environment.project', 'team_id', $teamId)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $certificates
|
||||
*/
|
||||
|
||||
@@ -22,6 +22,9 @@ is_tar() { [ "$(stream | head -c 262 | tail -c 5)" = ustar ]; }
|
||||
is_text() { [ "$(stream | head -c 65536 | tr -d '\000' | wc -c | tr -d ' ')" = "$(stream | head -c 65536 | wc -c | tr -d ' ')" ]; }
|
||||
extract_tar() { work=$(mktemp -d) && trap 'rm -rf "$work"' EXIT && stream | tar -xf - -C "$work" || fail 'The tar backup cannot be extracted.'; }
|
||||
use_single_tar_member() { [ "$(find "$work" -type f | wc -l | tr -d ' ')" = 1 ] && backup=$(find "$work" -type f); }
|
||||
if is_gzip; then
|
||||
gunzip -t "$backup" || fail 'The gzip backup is corrupt or incomplete. Nothing was changed.'
|
||||
fi
|
||||
|
||||
SH;
|
||||
|
||||
|
||||
@@ -304,7 +304,7 @@ function getCurrentApplicationContainerStatus(Server $server, Application $appli
|
||||
if ($includePullrequests) {
|
||||
return $container;
|
||||
}
|
||||
if ($pullRequestId !== null && $pullRequestId !== 0 && str($labels)->contains("coolify.pullRequestId={$pullRequestId}")) {
|
||||
if ($pullRequestId !== null && $pullRequestId !== 0 && (int) $prLabelValue === $pullRequestId) {
|
||||
return $container;
|
||||
}
|
||||
|
||||
|
||||
@@ -137,6 +137,34 @@ function instant_scp_from_server(string $remoteSource, string $localDest, Server
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write content to a file on a server through SSH stdin. The content never becomes part of a command
|
||||
* line, so it is not limited by the 128 KiB per-argument limit of Linux. A non-root SSH user writes
|
||||
* with `sudo tee` because Coolify directories such as /data/coolify are root-only.
|
||||
*/
|
||||
function instant_remote_write_file(Server $server, string $path, string $content, bool $throwError = true): void
|
||||
{
|
||||
$remoteCommand = ($server->isNonRoot() ? 'sudo ' : '').'tee '.escapeshellarg($path).' > /dev/null';
|
||||
$timeout = (int) config('constants.ssh.command_timeout');
|
||||
|
||||
SshRetryHandler::retry(
|
||||
function () use ($server, $remoteCommand, $content, $timeout) {
|
||||
$sshCommand = SshMultiplexingHelper::generateSshStdinCommand($server, $remoteCommand, commandTimeout: $timeout);
|
||||
$process = Process::timeout($timeout)->input($content)->run($sshCommand);
|
||||
|
||||
if ($process->exitCode() !== 0) {
|
||||
excludeCertainErrors($process->errorOutput(), $process->exitCode());
|
||||
}
|
||||
},
|
||||
[
|
||||
'server' => $server->ip,
|
||||
'dest' => $path,
|
||||
'function' => 'instant_remote_write_file',
|
||||
],
|
||||
$throwError
|
||||
);
|
||||
}
|
||||
|
||||
function instant_remote_process_with_timeout(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string
|
||||
{
|
||||
$command = $command instanceof Collection ? $command->toArray() : $command;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Database\StartDatabase;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['app.maintenance.driver' => 'file']);
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['is_api_enabled' => true]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
|
||||
Queue::fake();
|
||||
});
|
||||
|
||||
function databaseCreateInstantDeployHeaders(User $user, int $teamId, array $abilities): array
|
||||
{
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'database-create-instant-deploy-test',
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => $abilities,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
|
||||
return ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken];
|
||||
}
|
||||
|
||||
function databaseCreateInstantDeployPayload(object $test, array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'project_uuid' => $test->project->uuid,
|
||||
'environment_uuid' => $test->environment->uuid,
|
||||
'server_uuid' => $test->server->uuid,
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
test('write-only token with a "false" string instant_deploy does not start the database', function () {
|
||||
$this->withHeaders(databaseCreateInstantDeployHeaders($this->user, $this->team->id, ['write']))
|
||||
->postJson('/api/v1/databases/postgresql', databaseCreateInstantDeployPayload($this, ['instant_deploy' => 'false']))
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('instant_deploy');
|
||||
|
||||
expect(StandalonePostgresql::count())->toBe(0);
|
||||
StartDatabase::assertNotPushed();
|
||||
});
|
||||
|
||||
test('write-only token with instant_deploy false creates the database without starting it', function () {
|
||||
$this->withHeaders(databaseCreateInstantDeployHeaders($this->user, $this->team->id, ['write']))
|
||||
->postJson('/api/v1/databases/postgresql', databaseCreateInstantDeployPayload($this, ['instant_deploy' => false]))
|
||||
->assertCreated();
|
||||
|
||||
expect(StandalonePostgresql::count())->toBe(1);
|
||||
StartDatabase::assertNotPushed();
|
||||
});
|
||||
|
||||
test('write-only token with instant_deploy true is forbidden', function () {
|
||||
$this->withHeaders(databaseCreateInstantDeployHeaders($this->user, $this->team->id, ['write']))
|
||||
->postJson('/api/v1/databases/postgresql', databaseCreateInstantDeployPayload($this, ['instant_deploy' => true]))
|
||||
->assertForbidden();
|
||||
|
||||
expect(StandalonePostgresql::count())->toBe(0);
|
||||
StartDatabase::assertNotPushed();
|
||||
});
|
||||
|
||||
test('invalid types for shared create fields return 422', function (string $field, array $input) {
|
||||
$this->withHeaders(databaseCreateInstantDeployHeaders($this->user, $this->team->id, ['write', 'deploy']))
|
||||
->postJson('/api/v1/databases/postgresql', databaseCreateInstantDeployPayload($this, $input))
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors($field);
|
||||
|
||||
expect(StandalonePostgresql::count())->toBe(0);
|
||||
StartDatabase::assertNotPushed();
|
||||
})->with([
|
||||
'instant_deploy' => ['instant_deploy', ['instant_deploy' => 'abc']],
|
||||
'is_public' => ['is_public', ['is_public' => 'abc', 'public_port' => 15432]],
|
||||
'limits_cpu_shares' => ['limits_cpu_shares', ['limits_cpu_shares' => 'abc']],
|
||||
]);
|
||||
|
||||
test('write and deploy token with instant_deploy true starts the database', function (mixed $instantDeploy) {
|
||||
$this->withHeaders(databaseCreateInstantDeployHeaders($this->user, $this->team->id, ['write', 'deploy']))
|
||||
->postJson('/api/v1/databases/postgresql', databaseCreateInstantDeployPayload($this, ['instant_deploy' => $instantDeploy]))
|
||||
->assertCreated();
|
||||
|
||||
expect(StandalonePostgresql::count())->toBe(1);
|
||||
StartDatabase::assertPushed(1);
|
||||
})->with([
|
||||
'boolean true' => [true],
|
||||
'string "1"' => ['1'],
|
||||
]);
|
||||
@@ -93,7 +93,7 @@ it('writes service compose files without cd or scp on a non-root server', functi
|
||||
$lines = nonRootDataDirectoryRemoteLines();
|
||||
expectNoUnprivilegedDataDirectoryAccess($lines);
|
||||
expect(implode("\n", $lines))
|
||||
->toContain("| sudo tee {$workdir}/docker-compose.yml > /dev/null")
|
||||
->toContain(escapeshellarg("sudo tee '{$workdir}/docker-compose.yml' > /dev/null"))
|
||||
->toMatch('/^sudo mv '.preg_quote($workdir, '/').'\/\S+\.env\.tmp '.preg_quote($workdir, '/').'\/\.env$/m');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
InstanceSettings::forceCreate(['id' => 0, 'fqdn' => 'https://coolify.test']);
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
|
||||
$this->server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]);
|
||||
$this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
|
||||
$destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = $project->environments()->first()
|
||||
?? Environment::factory()->create(['project_id' => $project->id]);
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$uuid = $this->application->uuid;
|
||||
$container = fn (string $name, ?int $pullRequestId) => json_encode([
|
||||
'ID' => $name,
|
||||
'Names' => $name,
|
||||
'State' => 'running',
|
||||
'Labels' => $pullRequestId === null
|
||||
? "coolify.applicationUuid={$uuid},coolify.managed=true"
|
||||
: "coolify.applicationUuid={$uuid},coolify.pullRequestId={$pullRequestId},coolify.managed=true",
|
||||
]);
|
||||
$output = implode(PHP_EOL, [
|
||||
$container("{$uuid}-base", 0),
|
||||
$container("{$uuid}-legacy", null),
|
||||
$container("{$uuid}-pr-1", 1),
|
||||
$container("{$uuid}-pr-12", 12),
|
||||
$container("{$uuid}-pr-13", 13),
|
||||
$container("{$uuid}-pr-100", 100),
|
||||
]);
|
||||
|
||||
Process::fake(fn () => Process::result(output: $output));
|
||||
});
|
||||
|
||||
function previewContainerStatusNames(Server $server, Application $application, ?int $pullRequestId, bool $includePullrequests = false): array
|
||||
{
|
||||
return getCurrentApplicationContainerStatus($server->fresh(), $application, $pullRequestId, $includePullrequests)
|
||||
->pluck('Names')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
test('a preview lookup returns only the containers of that exact pull request', function () {
|
||||
$uuid = $this->application->uuid;
|
||||
|
||||
expect(previewContainerStatusNames($this->server, $this->application, 1))->toBe(["{$uuid}-pr-1"])
|
||||
->and(previewContainerStatusNames($this->server, $this->application, 12))->toBe(["{$uuid}-pr-12"])
|
||||
->and(previewContainerStatusNames($this->server, $this->application, 10))->toBe([]);
|
||||
});
|
||||
|
||||
test('a base lookup still returns only base containers, or every container when previews are included', function () {
|
||||
$uuid = $this->application->uuid;
|
||||
|
||||
expect(previewContainerStatusNames($this->server, $this->application, 0))->toBe(["{$uuid}-base", "{$uuid}-legacy"])
|
||||
->and(previewContainerStatusNames($this->server, $this->application, null))->toBe(["{$uuid}-base", "{$uuid}-legacy"])
|
||||
->and(previewContainerStatusNames($this->server, $this->application, 0, true))->toHaveCount(6);
|
||||
});
|
||||
@@ -104,18 +104,19 @@ function remoteSecretLifecyclePostgres(): StandalonePostgresql
|
||||
*/
|
||||
function remoteSecretLifecycleWrittenEnvFile(Service $service): string
|
||||
{
|
||||
$commands = collect();
|
||||
Process::fake(function ($process) use ($commands) {
|
||||
$commands->push($process->command);
|
||||
$envFileContent = '';
|
||||
Process::fake(function ($process) use (&$envFileContent) {
|
||||
// The .env content goes to `tee <workdir>/<id>.env.tmp` over SSH stdin.
|
||||
if (preg_match('/tee \S+\.env\.tmp/', $process->command)) {
|
||||
$envFileContent = (string) $process->input;
|
||||
}
|
||||
|
||||
return Process::result(output: '');
|
||||
});
|
||||
|
||||
$service->saveComposeConfigs();
|
||||
|
||||
preg_match("/echo '([A-Za-z0-9+\/=]+)' \| base64 -d \| tee [^ ]+\.env\.tmp/", $commands->implode("\n"), $matches);
|
||||
|
||||
return base64_decode($matches[1] ?? '');
|
||||
return $envFileContent;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Services\ServerTransfer\ServerTransferClaimer;
|
||||
use App\Services\ServerTransfer\ServerTransferExporter;
|
||||
use App\Services\ServerTransfer\ServerTransferImporter;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('app.env', 'local');
|
||||
config()->set('app.maintenance.driver', 'file');
|
||||
Server::flushIdentityMap();
|
||||
Storage::fake('ssh-keys');
|
||||
Storage::fake('ssh-mux');
|
||||
Process::fake();
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
$this->mock(ServerTransferClaimer::class)->shouldReceive('claim')->andReturn([]);
|
||||
});
|
||||
|
||||
/** @return array{application: Application, preview: ApplicationPreview, bundle: array} */
|
||||
function previewOwnershipFixture(Team $team, bool $trashed = false): array
|
||||
{
|
||||
$key = PrivateKey::factory()->create(['team_id' => $team->id]);
|
||||
$server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $key->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $project->environments()->firstOrFail()->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
$preview = ApplicationPreview::create([
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => 42,
|
||||
'pull_request_html_url' => 'https://github.com/example/app/pull/42',
|
||||
'fqdn' => 'https://preview.example.com',
|
||||
]);
|
||||
$bundle = app(ServerTransferExporter::class)->export($server);
|
||||
$bundle['server']['uuid'] = new_public_id();
|
||||
$bundle['server']['ip'] = '192.0.2.99';
|
||||
if ($trashed) {
|
||||
$application->delete();
|
||||
$preview->delete();
|
||||
}
|
||||
|
||||
return compact('application', 'preview', 'bundle');
|
||||
}
|
||||
|
||||
test('preview imports reject another team UUID and roll back all imported records', function (bool $trashed, string $role) {
|
||||
$fixture = previewOwnershipFixture(Team::factory()->create(), $trashed);
|
||||
$preview = $fixture['preview'];
|
||||
$volume = LocalPersistentVolume::create([
|
||||
'resource_type' => $preview->getMorphClass(),
|
||||
'resource_id' => $preview->id,
|
||||
'name' => 'victim-preview-data',
|
||||
'mount_path' => '/data',
|
||||
]);
|
||||
$bundle = $fixture['bundle'];
|
||||
$bundle['projects'][0]['environments'][0]['applications'][0]['previews'][0]['pull_request_id'] = 99;
|
||||
$bundle['projects'][0]['environments'][0]['applications'][0]['previews'][0]['fqdn'] = 'https://imported.example.com';
|
||||
$team = Team::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$team->members()->attach($user->id, ['role' => $role]);
|
||||
session(['currentTeam' => $team]);
|
||||
$token = $user->createToken('preview-import', ['write']);
|
||||
$token->accessToken->forceFill(['team_id' => $team->id])->save();
|
||||
$counts = [Server::count(), Application::withTrashed()->count(), PrivateKey::count(), Project::count()];
|
||||
|
||||
$this->withToken($token->plainTextToken)->postJson('/api/v1/servers/import', ['bundle' => $bundle])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('previews');
|
||||
|
||||
expect($preview->fresh()->application_id)->toBe($fixture['application']->id)
|
||||
->and($preview->fresh()->pull_request_id)->toBe(42)
|
||||
->and($preview->fresh()->fqdn)->toBe('https://preview.example.com')
|
||||
->and($preview->fresh()->trashed())->toBe($trashed);
|
||||
expect($volume->fresh()->resource_id)->toBe($preview->id);
|
||||
expect([Server::count(), Application::withTrashed()->count(), PrivateKey::count(), Project::count()])->toBe($counts);
|
||||
})->with([
|
||||
'active preview, owner' => [false, 'owner'],
|
||||
'deleted preview and application, admin' => [true, 'admin'],
|
||||
]);
|
||||
|
||||
test('preview imports reject another team domain without clearing it', function (bool $trashed, bool $preserveUuids) {
|
||||
$fixture = previewOwnershipFixture(Team::factory()->create(), $trashed);
|
||||
$bundle = $fixture['bundle'];
|
||||
$bundle['projects'][0]['environments'][0]['applications'][0]['previews'][0]['uuid'] = new_public_id();
|
||||
$team = Team::factory()->create();
|
||||
$serverCount = Server::count();
|
||||
|
||||
try {
|
||||
app(ServerTransferImporter::class)->import($bundle, $team->id, preserveUuids: $preserveUuids);
|
||||
$this->fail('A foreign preview domain must be rejected.');
|
||||
} catch (ValidationException $exception) {
|
||||
expect($exception->errors())->toHaveKey('previews');
|
||||
}
|
||||
|
||||
expect($fixture['preview']->fresh()->fqdn)->toBe('https://preview.example.com')
|
||||
->and($fixture['preview']->fresh()->application_id)->toBe($fixture['application']->id)
|
||||
->and($fixture['preview']->fresh()->trashed())->toBe($trashed);
|
||||
expect(Server::count())->toBe($serverCount);
|
||||
$this->assertDatabaseCount('application_previews', 1);
|
||||
})->with([
|
||||
'active, preserve UUIDs' => [false, true],
|
||||
'deleted, replace UUIDs' => [true, false],
|
||||
]);
|
||||
|
||||
test('preview imports preserve same-team UUID handoffs and preview volumes', function (bool $trashed) {
|
||||
$team = Team::factory()->create();
|
||||
$fixture = previewOwnershipFixture($team, $trashed);
|
||||
$preview = $fixture['preview'];
|
||||
$volume = LocalPersistentVolume::create([
|
||||
'resource_type' => $preview->getMorphClass(),
|
||||
'resource_id' => $preview->id,
|
||||
'name' => 'same-team-preview-data',
|
||||
'mount_path' => '/data',
|
||||
]);
|
||||
|
||||
$result = app(ServerTransferImporter::class)->import($fixture['bundle'], $team->id);
|
||||
|
||||
$imported = Server::whereUuid($result['server_uuid'])->firstOrFail()->applications()->firstOrFail();
|
||||
expect($preview->fresh()->application_id)->toBe($imported->id)
|
||||
->and($preview->fresh()->fqdn)->toBe('https://preview.example.com')
|
||||
->and($preview->fresh()->trashed())->toBeFalse();
|
||||
expect($volume->fresh()->resource_id)->toBe($preview->id);
|
||||
$this->assertDatabaseCount('application_previews', 1);
|
||||
})->with(['active' => false, 'deleted preview and application' => true]);
|
||||
|
||||
test('preview imports can release a same-team domain collision', function () {
|
||||
$team = Team::factory()->create();
|
||||
$fixture = previewOwnershipFixture($team);
|
||||
$bundle = $fixture['bundle'];
|
||||
$bundle['projects'][0]['environments'][0]['applications'][0]['previews'][0]['uuid'] = new_public_id();
|
||||
|
||||
$result = app(ServerTransferImporter::class)->import($bundle, $team->id);
|
||||
|
||||
$imported = Server::whereUuid($result['server_uuid'])->firstOrFail()->applications()->firstOrFail();
|
||||
expect($fixture['preview']->fresh()->fqdn)->toBeNull();
|
||||
expect($imported->previews()->firstOrFail()->fqdn)->toBe('https://preview.example.com');
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Process\PendingProcess;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* Linux limits one exec argument to 128 KiB (MAX_ARG_STRLEN). generateSshCommand() puts the whole
|
||||
* remote script in one `sh -c` argument, so file content must reach the server over SSH stdin.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
Storage::fake('ssh-keys');
|
||||
Storage::fake('ssh-mux');
|
||||
config(['app.maintenance.store' => 'array', 'constants.ssh.mux_enabled' => false, 'constants.ssh.max_retries' => 1]);
|
||||
|
||||
$this->processes = collect();
|
||||
Process::fake(function (PendingProcess $process) {
|
||||
$this->processes->push(['command' => $process->command, 'input' => $process->input]);
|
||||
|
||||
return Process::result();
|
||||
});
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
|
||||
]);
|
||||
$this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
|
||||
$destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$labels = collect(range(1, 4000))
|
||||
->map(fn (int $i) => " - coolify.test.label{$i}=".str_repeat('x', 40))
|
||||
->implode("\n");
|
||||
$this->compose = "services:\n app:\n image: nginx\n labels:\n{$labels}\n";
|
||||
|
||||
$this->service = Service::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'docker_compose' => $this->compose,
|
||||
]);
|
||||
});
|
||||
|
||||
it('streams a large compose file over stdin instead of the command line', function (string $user, string $tee) {
|
||||
$this->server->update(['user' => $user]);
|
||||
$workdir = $this->service->workdir();
|
||||
expect(strlen($this->compose))->toBeGreaterThan(200 * 1024);
|
||||
|
||||
$this->service->saveComposeConfigs();
|
||||
|
||||
$composeWrite = $this->processes->firstWhere('input', $this->compose);
|
||||
expect($composeWrite)->not->toBeNull()
|
||||
->and($composeWrite['command'])->toEndWith(escapeshellarg("{$tee} '{$workdir}/docker-compose.yml' > /dev/null"));
|
||||
|
||||
$envWrite = $this->processes->first(fn (array $process) => $process['input'] === 'SERVICE_NAME_APP=app');
|
||||
expect($envWrite)->not->toBeNull()
|
||||
->and($envWrite['command'])->toMatch('/'.preg_quote($tee, '/').' \S+\.env\.tmp/');
|
||||
|
||||
$this->processes->each(fn (array $process) => expect(strlen($process['command']))->toBeLessThan(8 * 1024));
|
||||
expect($this->processes->last()['command'])->toMatch('/mv \S+\.env\.tmp '.preg_quote($workdir, '/').'\/\.env/');
|
||||
})->with([
|
||||
'root' => ['root', 'tee'],
|
||||
'non-root' => ['cooluser', 'sudo tee'],
|
||||
]);
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Subscription\Actions;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
|
||||
use Livewire\Livewire;
|
||||
use Stripe\Service\SubscriptionService;
|
||||
use Stripe\Stripe;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
config()->set('subscription.provider', 'stripe');
|
||||
config()->set('subscription.stripe_api_key', 'sk_test_fake');
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
|
||||
$this->originalStripeApiBase = Stripe::$apiBase;
|
||||
Stripe::$apiBase = 'http://127.0.0.1:1';
|
||||
|
||||
$this->stripeResolved = false;
|
||||
$this->app->bind(StripeClient::class, function () {
|
||||
$this->stripeResolved = true;
|
||||
|
||||
throw new RuntimeException('Stripe must not be called.');
|
||||
});
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
|
||||
$this->ownTeam = Team::factory()->create();
|
||||
$this->ownTeam->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
Subscription::create([
|
||||
'team_id' => $this->ownTeam->id,
|
||||
'stripe_subscription_id' => 'sub_own_team',
|
||||
'stripe_customer_id' => 'cus_own_team',
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_plan_id' => 'price_test_123',
|
||||
'stripe_cancel_at_period_end' => false,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
|
||||
$this->otherTeam = Team::factory()->create();
|
||||
$this->otherTeam->members()->attach($this->user->id, ['role' => 'member']);
|
||||
|
||||
$this->otherSubscription = Subscription::create([
|
||||
'team_id' => $this->otherTeam->id,
|
||||
'stripe_subscription_id' => 'sub_other_team',
|
||||
'stripe_customer_id' => 'cus_other_team',
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_plan_id' => 'price_test_123',
|
||||
'stripe_cancel_at_period_end' => true,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Stripe::$apiBase = $this->originalStripeApiBase;
|
||||
});
|
||||
|
||||
dataset('subscription billing actions', [
|
||||
'updateQuantity' => [fn ($component) => $component->set('quantity', 5)->call('updateQuantity')],
|
||||
'loadPricePreview' => [fn ($component) => $component->call('loadPricePreview', 5)],
|
||||
'refundSubscription' => [fn ($component) => $component->call('refundSubscription', 'password')],
|
||||
'cancelImmediately' => [fn ($component) => $component->call('cancelImmediately', 'password')],
|
||||
'cancelImmediately with refund' => [fn ($component) => $component->call('cancelImmediately', 'password', ['refundLatestPayment'])],
|
||||
'cancelAtPeriodEnd' => [fn ($component) => $component->call('cancelAtPeriodEnd', 'password')],
|
||||
'resumeSubscription' => [fn ($component) => $component->call('resumeSubscription')],
|
||||
'loadRefundEligibility' => [fn ($component) => $component->call('loadRefundEligibility')],
|
||||
'stripeCustomerPortal' => [fn ($component) => $component->call('stripeCustomerPortal')],
|
||||
]);
|
||||
|
||||
function expectOtherTeamSubscriptionUntouched(): void
|
||||
{
|
||||
$subscription = test()->otherSubscription->fresh();
|
||||
|
||||
expect($subscription->stripe_subscription_id)->toBe('sub_other_team')
|
||||
->and((bool) $subscription->stripe_invoice_paid)->toBeTrue()
|
||||
->and((bool) $subscription->stripe_cancel_at_period_end)->toBeTrue()
|
||||
->and($subscription->stripe_refunded_at)->toBeNull()
|
||||
->and(test()->stripeResolved)->toBeFalse();
|
||||
}
|
||||
|
||||
test('a team member cannot mount the subscription actions', function () {
|
||||
session(['currentTeam' => $this->otherTeam]);
|
||||
|
||||
Livewire::test(Actions::class)->assertForbidden();
|
||||
|
||||
expectOtherTeamSubscriptionUntouched();
|
||||
});
|
||||
|
||||
test('a team admin can mount the subscription actions and lock the team', function (string $role) {
|
||||
$this->otherTeam->members()->updateExistingPivot($this->user->id, ['role' => $role]);
|
||||
$this->user->unsetRelation('teams');
|
||||
session(['currentTeam' => $this->otherTeam]);
|
||||
|
||||
Livewire::test(Actions::class)
|
||||
->assertOk()
|
||||
->assertSet('teamId', $this->otherTeam->id);
|
||||
})->with(['admin', 'owner']);
|
||||
|
||||
test('an admin can still resume the subscription of the locked team', function () {
|
||||
$this->otherTeam->members()->updateExistingPivot($this->user->id, ['role' => 'admin']);
|
||||
$this->user->unsetRelation('teams');
|
||||
session(['currentTeam' => $this->otherTeam]);
|
||||
|
||||
$stripe = Mockery::mock(StripeClient::class);
|
||||
$stripe->subscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$stripe->subscriptions->shouldReceive('update')->once()->with('sub_other_team', ['cancel_at_period_end' => false]);
|
||||
$this->instance(StripeClient::class, $stripe);
|
||||
|
||||
Livewire::test(Actions::class)
|
||||
->call('resumeSubscription')
|
||||
->assertOk()
|
||||
->assertDispatched('success');
|
||||
|
||||
expect((bool) $this->otherSubscription->fresh()->stripe_cancel_at_period_end)->toBeFalse();
|
||||
});
|
||||
|
||||
test('a billing action is rejected after the session switches to a team where the user is a member', function (Closure $action) {
|
||||
session(['currentTeam' => $this->ownTeam]);
|
||||
$component = Livewire::test(Actions::class)->assertOk();
|
||||
|
||||
session(['currentTeam' => $this->otherTeam]);
|
||||
|
||||
$action($component)->assertForbidden();
|
||||
|
||||
expectOtherTeamSubscriptionUntouched();
|
||||
})->with('subscription billing actions');
|
||||
|
||||
test('a billing action is rejected after the user is demoted to member of the locked team', function (Closure $action) {
|
||||
$this->otherTeam->members()->updateExistingPivot($this->user->id, ['role' => 'admin']);
|
||||
$this->user->unsetRelation('teams');
|
||||
session(['currentTeam' => $this->otherTeam]);
|
||||
$component = Livewire::test(Actions::class)->assertOk();
|
||||
|
||||
$this->otherTeam->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
|
||||
$this->user->unsetRelation('teams');
|
||||
|
||||
$action($component)->assertForbidden();
|
||||
|
||||
expectOtherTeamSubscriptionUntouched();
|
||||
})->with('subscription billing actions');
|
||||
|
||||
test('the locked team id cannot be changed by the client', function () {
|
||||
session(['currentTeam' => $this->ownTeam]);
|
||||
|
||||
Livewire::test(Actions::class)->set('teamId', $this->otherTeam->id);
|
||||
})->throws(CannotUpdateLockedPropertyException::class);
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Analytics;
|
||||
use App\Livewire\Dashboard\TrafficAnalytics;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Services\SentinelTrafficClient;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
class AuthorizationTrafficClient extends SentinelTrafficClient
|
||||
{
|
||||
public int $reads = 0;
|
||||
|
||||
protected function raw(string $url): string
|
||||
{
|
||||
$this->reads++;
|
||||
|
||||
return str_contains($url, '/traffic/overview')
|
||||
? json_encode(['requests' => 123, 'unique_visitors' => 12])
|
||||
: '[]';
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'member']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$key = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $key->id]);
|
||||
$this->server->settings()->update(['is_traffic_analytics_enabled' => true]);
|
||||
$this->client = new AuthorizationTrafficClient($this->server);
|
||||
app()->bind(SentinelTrafficClient::class, fn () => $this->client);
|
||||
});
|
||||
|
||||
test('removed members cannot refresh mounted traffic analytics', function (string $componentClass) {
|
||||
$component = loadLazy(Livewire::test($componentClass))->assertSet('overview.requests', 123);
|
||||
$reads = $this->client->reads;
|
||||
|
||||
$this->team->members()->detach($this->user->id);
|
||||
$this->actingAs($this->user->fresh());
|
||||
|
||||
$component->call('loadData')->assertForbidden();
|
||||
expect($this->client->reads)->toBe($reads);
|
||||
})->with([[Analytics::class], [TrafficAnalytics::class]]);
|
||||
|
||||
test('members can refresh mounted traffic after switching to their own team', function (string $componentClass) {
|
||||
$component = loadLazy(Livewire::test($componentClass))->assertSet('overview.requests', 123);
|
||||
$personalTeam = Team::factory()->create();
|
||||
$personalTeam->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $personalTeam]);
|
||||
|
||||
$component->call('loadData')->assertOk()->assertSet('overview.requests', 123);
|
||||
})->with([[Analytics::class], [TrafficAnalytics::class]]);
|
||||
|
||||
test('traffic analytics denies another teams mounted servers and clears previous traffic', function (string $componentClass) {
|
||||
$component = loadLazy(Livewire::test($componentClass))->assertSet('overview.requests', 123)->instance();
|
||||
$otherTeam = Team::factory()->create();
|
||||
$foreignServer = Server::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$component->servers = collect([$this->server, $foreignServer]);
|
||||
$reads = $this->client->reads;
|
||||
|
||||
expect(fn () => $component->loadData())->toThrow(AuthorizationException::class);
|
||||
expect($component->overview)->toBeNull()
|
||||
->and($component->series)->toBe([])
|
||||
->and($this->client->reads)->toBe($reads);
|
||||
|
||||
if ($component instanceof Analytics) {
|
||||
expect($component->topApps)->toBe([])
|
||||
->and($component->topHosts)->toBe([])
|
||||
->and($component->topPaths)->toBe([])
|
||||
->and($component->breakdowns)->toBe([]);
|
||||
}
|
||||
})->with([[Analytics::class], [TrafficAnalytics::class]]);
|
||||
|
||||
test('another teams owner cannot refresh the mounted traffic analytics', function (string $componentClass) {
|
||||
$component = loadLazy(Livewire::test($componentClass))->assertSet('overview.requests', 123);
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
|
||||
$this->actingAs($otherUser);
|
||||
session(['currentTeam' => $otherTeam]);
|
||||
$reads = $this->client->reads;
|
||||
|
||||
$component->call('loadData')->assertForbidden();
|
||||
expect($this->client->reads)->toBe($reads);
|
||||
})->with([[Analytics::class], [TrafficAnalytics::class]]);
|
||||
@@ -352,13 +352,13 @@ function restoreScriptNormalize(string $contents): array
|
||||
*
|
||||
* @return array{exit: int, stdout: string, stderr: string, databases: array<string, string>}
|
||||
*/
|
||||
function postgresReplacementScriptRun(string $target = 'app', string $failure = '', bool $gzip = false): array
|
||||
function postgresReplacementScriptRun(string $target = 'app', string $failure = '', bool $gzip = false, ?string $contents = null): array
|
||||
{
|
||||
$dir = restoreScriptTempDir();
|
||||
|
||||
try {
|
||||
$backup = $dir.'/backup.sql';
|
||||
file_put_contents($backup, $gzip ? gzencode('SELECT 1;') : 'SELECT 1;');
|
||||
file_put_contents($backup, $contents ?? ($gzip ? gzencode('SELECT 1;') : 'SELECT 1;'));
|
||||
$script = (new DatabaseImportCommandBuilder)->buildRestoreCommand(restoreScriptResource('postgresql'), $backup, false, true);
|
||||
preg_match('/new=(\w+)/', $script, $new);
|
||||
preg_match('/old=(\w+)/', $script, $old);
|
||||
@@ -449,6 +449,36 @@ test('failed SQL replacement preserves every existing PostgreSQL database and re
|
||||
->and($run['databases'])->toEqual(['coolify_restore_new' => 'unrelated-new', 'coolify_restore_old' => 'unrelated-old', 'app' => 'original']);
|
||||
})->with(['restore', 'swap']);
|
||||
|
||||
dataset('corrupt restore gzip', [
|
||||
'missing footer' => fn (string $contents): string => substr(gzencode($contents), 0, -8),
|
||||
'truncated later member' => fn (string $contents): string => gzencode($contents).substr(gzencode('SELECT 2;'), 0, 10),
|
||||
]);
|
||||
|
||||
test('corrupt gzip SQL replacement preserves every existing PostgreSQL database', function (Closure $corrupt) {
|
||||
$run = postgresReplacementScriptRun(contents: $corrupt('SELECT 1;'));
|
||||
|
||||
expect($run['exit'])->toBe(1)
|
||||
->and($run['stderr'])->toContain('The gzip backup is corrupt or incomplete. Nothing was changed.')
|
||||
->and($run['databases'])->toEqual(['coolify_restore_new' => 'unrelated-new', 'coolify_restore_old' => 'unrelated-old', 'app' => 'original']);
|
||||
})->with('corrupt restore gzip');
|
||||
|
||||
test('rejects corrupt gzip backups before any database client runs', function (string $engine, string $fixture, bool $dumpAll, bool $replaceExisting, Closure $corrupt) {
|
||||
$run = restoreScriptRun($engine, $corrupt(restoreScriptFixture($fixture)), $dumpAll, $replaceExisting);
|
||||
|
||||
expect($run['exit'])->toBe(1)
|
||||
->and($run['stderr'])->toContain('The gzip backup is corrupt or incomplete. Nothing was changed.')
|
||||
->and($run['calls'])->toBe([])
|
||||
->and($run['leftovers'])->toBe([]);
|
||||
})->with([
|
||||
'single PostgreSQL SQL' => ['postgresql', 'pg-sql', false, false],
|
||||
'PostgreSQL archive replacement' => ['postgresql', 'pg-custom', false, true],
|
||||
'all PostgreSQL databases' => ['postgresql', 'pg-sql', true, false],
|
||||
'single MySQL database' => ['mysql', 'mysql-sql', false, false],
|
||||
'all MySQL databases' => ['mysql', 'mysql-sql', true, false],
|
||||
'all MariaDB databases' => ['mariadb', 'mysql-sql', true, false],
|
||||
'MongoDB replacement' => ['mongodb', 'mongo-archive', false, true],
|
||||
])->with('corrupt restore gzip');
|
||||
|
||||
test('SQL replacement refuses scratch database name collisions without changing existing data', function (string $failure) {
|
||||
$run = postgresReplacementScriptRun(failure: $failure);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user