Merge remote-tracking branch 'origin/main' into next

This commit is contained in:
github-actions[bot]
2026-10-05 15:23:11 +00:00
19 changed files with 873 additions and 61 deletions
+25 -9
View File
@@ -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(),
+6
View File
@@ -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;
}
+52 -15
View File
@@ -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
View File
@@ -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;
+1 -1
View File
@@ -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;
}
+28
View File
@@ -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);