@@ -87,7 +93,7 @@
Location: {{ data_get($execution, 'filename', 'N/A') }}
-
+
Backup Availability:
@@ -154,9 +160,9 @@
{{ data_get($execution, 'message') }}
@endif
-
+
@if (data_get($execution, 'status') === 'success')
- Download
@endif
@php
@@ -175,11 +181,15 @@
$deleteActions[] = 'This backup execution record will be deleted.';
}
@endphp
-
+ shortConfirmationLabel="Backup Filename">
+
+ Delete
+
+
@empty
diff --git a/resources/views/livewire/project/database/backup/execution.blade.php b/resources/views/livewire/project/database/backup/execution.blade.php
index 3e689645f..23c108e8c 100644
--- a/resources/views/livewire/project/database/backup/execution.blade.php
+++ b/resources/views/livewire/project/database/backup/execution.blade.php
@@ -6,7 +6,7 @@
-
+
diff --git a/resources/views/livewire/project/database/scheduled-backups.blade.php b/resources/views/livewire/project/database/scheduled-backups.blade.php
index 12b36ffa1..b8241569c 100644
--- a/resources/views/livewire/project/database/scheduled-backups.blade.php
+++ b/resources/views/livewire/project/database/scheduled-backups.blade.php
@@ -216,7 +216,7 @@
@if ($type === 'service-database' && $selectedBackup)
+ :available-s3-storages="$s3s" :status="data_get($database, 'status')" />
diff --git a/resources/views/livewire/project/new/select.blade.php b/resources/views/livewire/project/new/select.blade.php
index debe3326f..600433dcd 100644
--- a/resources/views/livewire/project/new/select.blade.php
+++ b/resources/views/livewire/project/new/select.blade.php
@@ -154,7 +154,7 @@
+ @elseif ($fileStorage->is_host_file)
+
+ This host file mount is bind-only. Coolify will not create, edit, load, chmod, or delete the source file.
+
@elseif ($isReadOnly)
@if ($fileStorage->is_directory)
@@ -32,7 +36,14 @@
@if (!$isReadOnly)
@can('update', $resource)
- @if ($fileStorage->is_directory)
+ @if ($fileStorage->is_host_file)
+
+ @elseif ($fileStorage->is_directory)
@@ -99,7 +110,7 @@
@endif
@else
{{-- Read-only view --}}
- @if (!$fileStorage->is_directory)
+ @if (!$fileStorage->is_directory && !$fileStorage->is_host_file)
@can('update', $resource)
Load from
diff --git a/resources/views/livewire/project/service/storage.blade.php b/resources/views/livewire/project/service/storage.blade.php
index 9e32cd22d..2f5971842 100644
--- a/resources/views/livewire/project/service/storage.blade.php
+++ b/resources/views/livewire/project/service/storage.blade.php
@@ -22,11 +22,13 @@
dropdownOpen: false,
volumeModalOpen: false,
fileModalOpen: false,
+ hostFileModalOpen: false,
directoryModalOpen: false
}"
@close-storage-modal.window="
if ($event.detail === 'volume') volumeModalOpen = false;
if ($event.detail === 'file') fileModalOpen = false;
+ if ($event.detail === 'host-file') hostFileModalOpen = false;
if ($event.detail === 'directory') directoryModalOpen = false;
">
-
+
diff --git a/resources/views/livewire/settings-dropdown.blade.php b/resources/views/livewire/settings-dropdown.blade.php
index d40d6778e..7006665b5 100644
--- a/resources/views/livewire/settings-dropdown.blade.php
+++ b/resources/views/livewire/settings-dropdown.blade.php
@@ -114,7 +114,7 @@
@if ($unreadCount > 0)
{{ $unreadCount > 9 ? '9+' : $unreadCount }}
diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php
index 544ed7d4c..3a49d0cfa 100644
--- a/resources/views/livewire/settings/advanced.blade.php
+++ b/resources/views/livewire/settings/advanced.blade.php
@@ -70,9 +70,18 @@
environments!
@endif
+ Webhook/S3 Endpoint Controls
+
+
+
+
MCP Server
-
@if ($is_mcp_server_enabled)
diff --git a/resources/views/livewire/settings/index.blade.php b/resources/views/livewire/settings/index.blade.php
index 5a6319bc8..8ffa266ee 100644
--- a/resources/views/livewire/settings/index.blade.php
+++ b/resources/views/livewire/settings/index.blade.php
@@ -81,12 +81,6 @@
placeholder="2001:db8::1" autocomplete="new-password" />
- @if($buildActivityId)
-
-
-
- @endif
@if(isDev())
@endif
+
+
Docker Registry
+
+
+
+
+
+
diff --git a/resources/views/livewire/team/index.blade.php b/resources/views/livewire/team/index.blade.php
index 8f54a57e3..0fbbb114f 100644
--- a/resources/views/livewire/team/index.blade.php
+++ b/resources/views/livewire/team/index.blade.php
@@ -13,6 +13,8 @@
+
@can('update', $team)
Save
diff --git a/routes/ai.php b/routes/ai.php
index 3a39677ff..d79ff0eba 100644
--- a/routes/ai.php
+++ b/routes/ai.php
@@ -4,4 +4,4 @@ use App\Mcp\Servers\CoolifyServer;
use Laravel\Mcp\Facades\Mcp;
Mcp::web('/mcp', CoolifyServer::class)
- ->middleware(['mcp.enabled', 'auth:sanctum', 'api.token.team']);
+ ->middleware(['mcp.enabled', 'auth:sanctum', 'api.token.team', 'mcp.team.enabled']);
diff --git a/routes/api.php b/routes/api.php
index 6129da7e7..659bc1f7e 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -4,6 +4,7 @@ use App\Http\Controllers\Api\ApplicationsController;
use App\Http\Controllers\Api\CloudProviderTokensController;
use App\Http\Controllers\Api\DatabasesController;
use App\Http\Controllers\Api\DeployController;
+use App\Http\Controllers\Api\DestinationsController;
use App\Http\Controllers\Api\GithubController;
use App\Http\Controllers\Api\HetznerController;
use App\Http\Controllers\Api\Internal\FluxResourceStatusController;
@@ -87,6 +88,13 @@ Route::group([
Route::get('/servers/{uuid}/domains', [ServersController::class, 'domains_by_server'])->middleware(['api.ability:read']);
Route::get('/servers/{uuid}/resources', [ServersController::class, 'resources_by_server'])->middleware(['api.ability:read']);
+ // Destinations — REST surface for the Coolify "Destinations" UI section (added).
+ Route::get('/destinations', [DestinationsController::class, 'index'])->middleware(['api.ability:read']);
+ Route::get('/destinations/{uuid}', [DestinationsController::class, 'show'])->middleware(['api.ability:read']);
+ Route::delete('/destinations/{uuid}', [DestinationsController::class, 'delete'])->middleware(['api.ability:write']);
+ Route::get('/servers/{server_uuid}/destinations', [DestinationsController::class, 'index_by_server'])->middleware(['api.ability:read']);
+ Route::post('/servers/{server_uuid}/destinations', [DestinationsController::class, 'create'])->middleware(['api.ability:write']);
+
Route::get('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:write']);
Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:write']);
diff --git a/scripts/install.sh b/scripts/install.sh
index 292eb5de1..96449bb79 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -9,7 +9,7 @@
## DOCKER_ADDRESS_POOL_SIZE - Custom Docker address pool size (default: 24)
## DOCKER_POOL_FORCE_OVERRIDE - Force override Docker address pool configuration (default: false)
## AUTOUPDATE - Set to "false" to disable auto-updates
-## REGISTRY_URL - Custom registry URL for Docker images (default: ghcr.io)
+## REGISTRY_URL - Custom registry URL for Docker images (default: docker.io)
set -e # Exit immediately if a command exits with a non-zero status
## $1 could be empty, so we need to disable this check
@@ -50,7 +50,7 @@ else
REGISTRY_URL=$(grep "^REGISTRY_URL=" "$ENV_FILE" | cut -d '=' -f2)
echo "Using registry URL from .env: $REGISTRY_URL"
else
- REGISTRY_URL="ghcr.io"
+ REGISTRY_URL="docker.io"
echo "Using default registry URL: $REGISTRY_URL"
fi
fi
@@ -921,9 +921,9 @@ echo -e " - Please wait."
getAJoke
if [[ $- == *x* ]]; then
- bash -x /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-ghcr.io}" "true"
+ bash -x /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true"
else
- bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-ghcr.io}" "true"
+ bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true"
fi
echo " - Coolify installed successfully."
echo " - Waiting for Coolify to be ready..."
diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh
index 3acea7781..206327f65 100644
--- a/scripts/upgrade.sh
+++ b/scripts/upgrade.sh
@@ -4,9 +4,15 @@
CDN="https://cdn.coollabs.io/coolify"
LATEST_IMAGE=${1:-latest}
LATEST_HELPER_VERSION=${2:-latest}
-REGISTRY_URL=${3:-ghcr.io}
-SKIP_BACKUP=${4:-false}
ENV_FILE="/data/coolify/source/.env"
+if [ -n "${3+x}" ]; then
+ REGISTRY_URL="$3"
+elif [ -f "$ENV_FILE" ] && grep -q "^REGISTRY_URL=" "$ENV_FILE"; then
+ REGISTRY_URL=$(grep "^REGISTRY_URL=" "$ENV_FILE" | cut -d '=' -f2- | head -n1)
+else
+ REGISTRY_URL="docker.io"
+fi
+SKIP_BACKUP=${4:-false}
STATUS_FILE="/data/coolify/source/.upgrade-status"
DATE=$(date +%Y-%m-%d-%H-%M-%S)
@@ -80,7 +86,7 @@ fi
# Get all unique images from docker compose config
# LATEST_IMAGE env var is needed for image substitution in compose files
-IMAGES=$(LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file "$ENV_FILE" $COMPOSE_FILES config --images 2>/dev/null | sort -u)
+IMAGES=$(REGISTRY_URL=${REGISTRY_URL} LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file "$ENV_FILE" $COMPOSE_FILES config --images 2>/dev/null | sort -u)
if [ -z "$IMAGES" ]; then
log "ERROR: Failed to extract images from docker-compose files"
@@ -127,8 +133,22 @@ update_env_var() {
fi
}
+set_env_var() {
+ local key="$1"
+ local value="$2"
+
+ if grep -q "^${key}=" "$ENV_FILE"; then
+ sed -i "s|^${key}=.*|${key}=${value}|" "$ENV_FILE"
+ log "Updated ${key}"
+ else
+ printf '%s=%s\n' "$key" "$value" >>"$ENV_FILE"
+ log "Added ${key}"
+ fi
+}
+
log "Checking environment variables..."
update_env_var "COOLIFY_FLUX_LARAVEL_API_TOKEN" "$(openssl rand -hex 32)"
+set_env_var "REGISTRY_URL" "$REGISTRY_URL"
update_env_var "PUSHER_APP_ID" "$(openssl rand -hex 32)"
update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)"
update_env_var "PUSHER_APP_SECRET" "$(openssl rand -hex 32)"
@@ -174,7 +194,7 @@ echo "3/6 Pulling Docker images..."
echo " This may take a few minutes depending on your connection."
# Also pull the helper image (not in compose files but needed for upgrade)
-HELPER_IMAGE="${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION}"
+HELPER_IMAGE="${REGISTRY_URL:-docker.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION}"
echo " - Pulling $HELPER_IMAGE..."
log "Pulling image: $HELPER_IMAGE"
if docker pull "$HELPER_IMAGE" >>"$LOGFILE" 2>&1; then
@@ -266,7 +286,7 @@ nohup bash -c "
fi
log 'Running docker compose up...'
- docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env \${COMPOSE_FILES} up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1
+ docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-docker.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env \${COMPOSE_FILES} up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1
log 'Docker compose up completed'
# Final log entry
diff --git a/templates/compose/inngest.yaml b/templates/compose/inngest.yaml
index c2578eb81..aaf0070c0 100644
--- a/templates/compose/inngest.yaml
+++ b/templates/compose/inngest.yaml
@@ -62,4 +62,3 @@ services:
timeout: 3s
retries: 5
restart: unless-stopped
-
diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json
index 8d1a3369b..811aee97e 100644
--- a/templates/service-templates-latest.json
+++ b/templates/service-templates-latest.json
@@ -803,7 +803,7 @@
"category": "backend",
"logo": "svgs/convex.svg",
"minversion": "0.0.0",
- "template_last_updated_at": "2026-05-09T19:26:30+05:30",
+ "template_last_updated_at": "2026-06-12T10:45:52+02:00",
"port": "6791"
},
"cryptgeon": {
@@ -1779,7 +1779,7 @@
"category": "devtools",
"logo": "svgs/gitea.svg",
"minversion": "0.0.0",
- "template_last_updated_at": "2026-06-01T07:54:27-05:00"
+ "template_last_updated_at": "2026-06-06T00:11:24+02:00"
},
"gitea-with-mariadb": {
"documentation": "https://docs.gitea.com?utm_source=coolify.io",
@@ -2361,7 +2361,7 @@
"category": "automation",
"logo": "svgs/inngest.png",
"minversion": "0.0.0",
- "template_last_updated_at": null,
+ "template_last_updated_at": "2026-06-10T13:46:21+05:30",
"port": "8288"
},
"invoice-ninja": {
diff --git a/templates/service-templates.json b/templates/service-templates.json
index 2ad3008f5..9721a52de 100644
--- a/templates/service-templates.json
+++ b/templates/service-templates.json
@@ -803,7 +803,7 @@
"category": "backend",
"logo": "svgs/convex.svg",
"minversion": "0.0.0",
- "template_last_updated_at": "2026-05-09T19:26:30+05:30",
+ "template_last_updated_at": "2026-06-12T10:45:52+02:00",
"port": "6791"
},
"cryptgeon": {
@@ -1779,7 +1779,7 @@
"category": "devtools",
"logo": "svgs/gitea.svg",
"minversion": "0.0.0",
- "template_last_updated_at": "2026-06-01T07:54:27-05:00"
+ "template_last_updated_at": "2026-06-06T00:11:24+02:00"
},
"gitea-with-mariadb": {
"documentation": "https://docs.gitea.com?utm_source=coolify.io",
@@ -2361,7 +2361,7 @@
"category": "automation",
"logo": "svgs/inngest.png",
"minversion": "0.0.0",
- "template_last_updated_at": null,
+ "template_last_updated_at": "2026-06-10T13:46:21+05:30",
"port": "8288"
},
"invoice-ninja": {
diff --git a/tests/Feature/Api/DestinationsApiTest.php b/tests/Feature/Api/DestinationsApiTest.php
new file mode 100644
index 000000000..027f4acdb
--- /dev/null
+++ b/tests/Feature/Api/DestinationsApiTest.php
@@ -0,0 +1,282 @@
+ 'array',
+ 'session.driver' => 'array',
+ 'queue.default' => 'sync',
+ '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->bearerToken = destinationsApiToken($this->user, $this->team, ['*']);
+ $this->server = Server::factory()->create(['team_id' => $this->team->id]);
+ $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
+});
+
+function destinationsApiHeaders(string $bearerToken): array
+{
+ return [
+ 'Authorization' => 'Bearer '.$bearerToken,
+ 'Content-Type' => 'application/json',
+ ];
+}
+
+function destinationsApiToken(User $user, Team $team, array $abilities): string
+{
+ $plainTextToken = Str::random(40);
+ $token = $user->tokens()->create([
+ 'name' => 'destinations-api-test-'.Str::random(6),
+ 'token' => hash('sha256', $plainTextToken),
+ 'abilities' => $abilities,
+ 'team_id' => $team->id,
+ ]);
+
+ return $token->getKey().'|'.$plainTextToken;
+}
+
+describe('GET /api/v1/destinations', function () {
+ test('lists only destinations owned by the token team', function () {
+ $otherTeam = Team::factory()->create();
+ $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
+ $otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first();
+
+ $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
+ ->getJson('/api/v1/destinations');
+
+ $response->assertOk();
+ $uuids = collect($response->json())->pluck('uuid');
+
+ expect($response->json('0'))->not->toHaveKey('id')
+ ->and($uuids)->toContain($this->destination->uuid)
+ ->not->toContain($otherDestination->uuid);
+ });
+});
+
+describe('GET /api/v1/destinations/{uuid}', function () {
+ test('does not expose another team destination', function () {
+ $otherTeam = Team::factory()->create();
+ $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
+ $otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first();
+
+ $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
+ ->getJson("/api/v1/destinations/{$otherDestination->uuid}");
+
+ $response->assertNotFound();
+ });
+});
+
+describe('GET /api/v1/servers/{server_uuid}/destinations', function () {
+ test('lists destinations for a team server', function () {
+ $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
+ ->getJson("/api/v1/servers/{$this->server->uuid}/destinations");
+
+ $response->assertOk();
+ expect($response->json())->toHaveCount(1)
+ ->and($response->json('0.uuid'))->toBe($this->destination->uuid);
+ });
+});
+
+describe('POST /api/v1/servers/{server_uuid}/destinations', function () {
+ test('requires a write token', function () {
+ $readOnlyToken = destinationsApiToken($this->user, $this->team, ['read']);
+
+ $response = $this->withHeaders(destinationsApiHeaders($readOnlyToken))
+ ->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
+ 'network' => 'new-network',
+ ]);
+
+ $response->assertForbidden();
+ });
+
+ test('rejects create requests from non-admin team members', function () {
+ $member = User::factory()->create();
+ $this->team->members()->attach($member->id, ['role' => 'member']);
+ $memberToken = destinationsApiToken($member, $this->team, ['*']);
+
+ $response = $this->withHeaders(destinationsApiHeaders($memberToken))
+ ->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
+ 'network' => 'member-network',
+ ]);
+
+ $response->assertForbidden();
+ expect(StandaloneDocker::where('server_id', $this->server->id)->where('network', 'member-network')->exists())->toBeFalse();
+ });
+
+ test('rejects non-json requests before creating a destination', function () {
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ ])->post("/api/v1/servers/{$this->server->uuid}/destinations", [
+ 'network' => 'api-swarm-network',
+ 'type' => 'swarm',
+ ]);
+
+ $response->assertStatus(400);
+ expect(SwarmDocker::where('server_id', $this->server->id)->where('network', 'api-swarm-network')->exists())->toBeFalse();
+ });
+
+ test('rejects unknown fields', function () {
+ $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
+ ->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
+ 'network' => 'new-network',
+ 'unexpected' => 'value',
+ ]);
+
+ $response->assertUnprocessable();
+ $response->assertJsonValidationErrors(['unexpected']);
+ });
+
+ test('rejects unsafe docker network names', function () {
+ $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
+ ->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
+ 'network' => 'bad;network',
+ ]);
+
+ $response->assertUnprocessable();
+ $response->assertJsonValidationErrors(['network']);
+ });
+
+ test('rejects a destination type that does not match the server mode', function () {
+ $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
+ ->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
+ 'network' => 'wrong-type-network',
+ 'type' => 'swarm',
+ ]);
+
+ $response->assertUnprocessable();
+ expect(SwarmDocker::where('server_id', $this->server->id)->where('network', 'wrong-type-network')->exists())->toBeFalse();
+ });
+
+ test('creates a swarm destination on a swarm server', function () {
+ $this->server->settings()->update(['is_swarm_manager' => true]);
+
+ $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
+ ->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
+ 'name' => 'API Swarm',
+ 'network' => 'api-swarm-network',
+ 'type' => 'swarm',
+ ]);
+
+ $response->assertCreated();
+ $response->assertJsonStructure(['uuid']);
+ expect(SwarmDocker::where('server_id', $this->server->id)->where('network', 'api-swarm-network')->exists())->toBeTrue();
+ });
+
+ test('rejects duplicate networks on the same server and type', function () {
+ $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
+ ->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
+ 'network' => $this->destination->network,
+ ]);
+
+ $response->assertStatus(409);
+ });
+
+ test('returns conflict when the database unique constraint wins a create race', function () {
+ $network = 'raced-network';
+
+ StandaloneDocker::creating(function (StandaloneDocker $destination) use ($network) {
+ if ($destination->network !== $network) {
+ return;
+ }
+
+ DB::table('standalone_dockers')->insert([
+ 'name' => 'Concurrent destination',
+ 'uuid' => (string) Str::uuid(),
+ 'network' => $network,
+ 'server_id' => $destination->server_id,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ });
+
+ $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
+ ->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
+ 'network' => $network,
+ ]);
+
+ $response->assertStatus(409)
+ ->assertJson(['message' => 'A destination with this network already exists on the server.']);
+
+ expect(StandaloneDocker::where('server_id', $this->server->id)->where('network', $network)->count())->toBe(1);
+ });
+});
+
+describe('DELETE /api/v1/destinations/{uuid}', function () {
+ test('requires a write token', function () {
+ $readOnlyToken = destinationsApiToken($this->user, $this->team, ['read']);
+
+ $response = $this->withHeaders(destinationsApiHeaders($readOnlyToken))
+ ->deleteJson("/api/v1/destinations/{$this->destination->uuid}");
+
+ $response->assertForbidden();
+ $this->assertModelExists($this->destination);
+ });
+
+ test('rejects delete requests from non-admin team members', function () {
+ $member = User::factory()->create();
+ $this->team->members()->attach($member->id, ['role' => 'member']);
+ $memberToken = destinationsApiToken($member, $this->team, ['*']);
+
+ $response = $this->withHeaders(destinationsApiHeaders($memberToken))
+ ->deleteJson("/api/v1/destinations/{$this->destination->uuid}");
+
+ $response->assertForbidden();
+ $this->assertModelExists($this->destination);
+ });
+
+ test('deletes standalone destinations after removing the docker network', function () {
+ $cleanup = Mockery::mock(RemoveStandaloneDockerNetwork::class);
+ $cleanup->shouldReceive('handle')
+ ->once()
+ ->with(Mockery::on(fn (StandaloneDocker $destination) => $destination->is($this->destination)));
+ $this->app->instance(RemoveStandaloneDockerNetwork::class, $cleanup);
+
+ $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
+ ->deleteJson("/api/v1/destinations/{$this->destination->uuid}");
+
+ $response->assertOk();
+ $this->assertModelMissing($this->destination);
+ });
+
+ test('blocks deleting a destination with an attached service', function () {
+ $project = Project::factory()->create(['team_id' => $this->team->id]);
+ $environment = $project->environments()->first();
+
+ Service::factory()->create([
+ 'environment_id' => $environment->id,
+ 'server_id' => $this->server->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
+ ->deleteJson("/api/v1/destinations/{$this->destination->uuid}");
+
+ $response->assertStatus(409);
+ $this->assertModelExists($this->destination);
+ });
+});
diff --git a/tests/Feature/Authorization/ApplicationConfigAuthorizationTest.php b/tests/Feature/Authorization/ApplicationConfigAuthorizationTest.php
index 2c3aa123f..31c30c124 100644
--- a/tests/Feature/Authorization/ApplicationConfigAuthorizationTest.php
+++ b/tests/Feature/Authorization/ApplicationConfigAuthorizationTest.php
@@ -18,7 +18,9 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
- InstanceSettings::updateOrCreate(['id' => 0]);
+ $this->withoutVite();
+
+ InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
diff --git a/tests/Feature/Authorization/CanUpdateResourceMiddlewareTest.php b/tests/Feature/Authorization/CanUpdateResourceMiddlewareTest.php
new file mode 100644
index 000000000..4d440b41a
--- /dev/null
+++ b/tests/Feature/Authorization/CanUpdateResourceMiddlewareTest.php
@@ -0,0 +1,89 @@
+ null,
+ 'database_uuid' => null,
+ 'stack_service_uuid' => null,
+ 'service_uuid' => null,
+ 'server_uuid' => null,
+ 'environment_uuid' => null,
+ 'project_uuid' => null,
+ $parameter => $value,
+ ];
+
+ $request = Mockery::mock(Request::class)->makePartial();
+ $request->shouldReceive('route')->andReturnUsing(fn (string $key): ?string => $parameters[$key] ?? null);
+
+ return $request;
+}
+
+beforeEach(function () {
+ InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
+
+ $this->team = Team::factory()->create();
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->server = Server::factory()->create(['team_id' => $this->team->id]);
+
+ $this->admin = User::factory()->create();
+ $this->admin->teams()->attach($this->team, ['role' => 'admin']);
+
+ $this->member = User::factory()->create();
+ $this->member->teams()->attach($this->team, ['role' => 'member']);
+});
+
+it('blocks members from update-only project routes before the page renders', function () {
+ $this->actingAs($this->member);
+ session(['currentTeam' => $this->team]);
+
+ (new CanUpdateResource)->handle(
+ requestWithCanUpdateResourceRouteParameter('project_uuid', $this->project->uuid),
+ fn () => response('ok')
+ );
+})->throws(HttpException::class, 'You do not have permission to update this resource.');
+
+it('allows admins through update-only project routes', function () {
+ $this->actingAs($this->admin);
+ session(['currentTeam' => $this->team]);
+
+ $response = (new CanUpdateResource)->handle(
+ requestWithCanUpdateResourceRouteParameter('project_uuid', $this->project->uuid),
+ fn () => response('ok')
+ );
+
+ expect($response->getContent())->toBe('ok');
+});
+
+it('blocks members from update-only server routes before the page renders', function () {
+ $this->actingAs($this->member);
+ session(['currentTeam' => $this->team]);
+
+ (new CanUpdateResource)->handle(
+ requestWithCanUpdateResourceRouteParameter('server_uuid', $this->server->uuid),
+ fn () => response('ok')
+ );
+})->throws(HttpException::class, 'You do not have permission to update this resource.');
+
+it('returns not found when an update-only route references an unknown resource', function () {
+ $this->actingAs($this->admin);
+ session(['currentTeam' => $this->team]);
+
+ (new CanUpdateResource)->handle(
+ requestWithCanUpdateResourceRouteParameter('project_uuid', 'not-a-project'),
+ fn () => response('ok')
+ );
+})->throws(NotFoundHttpException::class, 'Resource not found.');
diff --git a/tests/Feature/Authorization/TeamAuthorizationTest.php b/tests/Feature/Authorization/TeamAuthorizationTest.php
index 82d8d2afc..5db208e1c 100644
--- a/tests/Feature/Authorization/TeamAuthorizationTest.php
+++ b/tests/Feature/Authorization/TeamAuthorizationTest.php
@@ -126,6 +126,18 @@ test('member cannot submit team settings via policy', function () {
expect(auth()->user()->can('update', $this->team))->toBeFalse();
});
+test('owner can update team MCP setting', function () {
+ $this->actingAs($this->owner);
+ session(['currentTeam' => $this->team]);
+
+ Livewire::test(TeamIndex::class)
+ ->set('is_mcp_server_enabled', false)
+ ->call('submit')
+ ->assertDispatched('success');
+
+ expect($this->team->fresh()->is_mcp_server_enabled)->toBeFalse();
+});
+
// --- Team Index Livewire: delete ---
test('member cannot delete team via index', function () {
diff --git a/tests/Feature/BackupEditValidationTest.php b/tests/Feature/BackupEditValidationTest.php
index 8894f0f69..fe396b5da 100644
--- a/tests/Feature/BackupEditValidationTest.php
+++ b/tests/Feature/BackupEditValidationTest.php
@@ -4,6 +4,7 @@ use App\Livewire\Project\Database\BackupEdit;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
+use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\StandaloneDocker;
@@ -43,6 +44,20 @@ function createBackupForEditValidationTest(Team $team, array $overrides = []): S
], $overrides));
}
+function createS3StorageForBackupEditValidationTest(Team|int $team, string $name = 'Backup Edit S3'): S3Storage
+{
+ return S3Storage::create([
+ 'name' => $name,
+ 'region' => 'us-east-1',
+ 'key' => 'test-key',
+ 'secret' => 'test-secret',
+ 'bucket' => 'test-bucket',
+ 'endpoint' => 'https://s3.example.com',
+ 'is_usable' => true,
+ 'team_id' => $team instanceof Team ? $team->id : $team,
+ ]);
+}
+
beforeEach(function () {
if (InstanceSettings::find(0) === null) {
$settings = new InstanceSettings;
@@ -60,7 +75,7 @@ beforeEach(function () {
it('disables S3 backup when saved without a selected S3 storage', function () {
$backup = createBackupForEditValidationTest($this->team);
- Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s])
+ Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->call('submit')
->assertDispatched('success');
@@ -74,7 +89,7 @@ it('cascades to disabling local backup deletion when S3 is force-disabled', func
'disable_local_backup' => true,
]);
- Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s])
+ Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->call('submit')
->assertDispatched('success');
@@ -83,3 +98,110 @@ it('cascades to disabling local backup deletion when S3 is force-disabled', func
expect($backup->s3_storage_id)->toBeNull();
expect($backup->disable_local_backup)->toBeFalsy();
});
+
+it('keeps S3 enabled by selecting the only available team storage when none is selected yet', function () {
+ createS3StorageForBackupEditValidationTest(Team::factory()->create());
+ $s3 = createS3StorageForBackupEditValidationTest($this->team);
+ $backup = createBackupForEditValidationTest($this->team, [
+ 'save_s3' => false,
+ 's3_storage_id' => null,
+ ]);
+
+ Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
+ ->set('saveS3', true)
+ ->call('instantSave')
+ ->assertDispatched('success');
+
+ $backup->refresh();
+ expect($backup->save_s3)->toBeTruthy();
+ expect($backup->s3_storage_id)->toBe($s3->id);
+});
+
+it('defaults to the first available storage when multiple storages are available', function () {
+ $firstS3 = createS3StorageForBackupEditValidationTest($this->team, 'First S3');
+ createS3StorageForBackupEditValidationTest($this->team, 'Second S3');
+ $backup = createBackupForEditValidationTest($this->team, [
+ 'save_s3' => false,
+ 's3_storage_id' => null,
+ ]);
+
+ Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
+ ->assertSet('s3StorageId', $firstS3->id)
+ ->set('saveS3', true)
+ ->call('instantSave')
+ ->assertDispatched('success');
+
+ $backup->refresh();
+ expect($backup->save_s3)->toBeTruthy();
+ expect($backup->s3_storage_id)->toBe($firstS3->id);
+});
+
+it('accepts the S3 storage scope passed to the component', function () {
+ $s3 = createS3StorageForBackupEditValidationTest(0);
+ $backup = createBackupForEditValidationTest($this->team, [
+ 'save_s3' => false,
+ 's3_storage_id' => null,
+ ]);
+
+ Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => collect([$s3])])
+ ->set('saveS3', true)
+ ->set('s3StorageId', $s3->id)
+ ->call('instantSave')
+ ->assertDispatched('success');
+
+ $backup->refresh();
+ expect($backup->save_s3)->toBeTruthy();
+ expect($backup->s3_storage_id)->toBe($s3->id);
+});
+
+it('shows available S3 storages even when S3 backup is disabled', function () {
+ createS3StorageForBackupEditValidationTest($this->team, 'First S3');
+ createS3StorageForBackupEditValidationTest($this->team, 'Second S3');
+ $backup = createBackupForEditValidationTest($this->team, [
+ 'save_s3' => false,
+ 's3_storage_id' => null,
+ ]);
+
+ Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
+ ->assertSee('First S3')
+ ->assertSee('Second S3');
+});
+
+it('shows disabled S3 storage dropdown when no storages are available', function () {
+ $backup = createBackupForEditValidationTest($this->team, [
+ 'save_s3' => false,
+ 's3_storage_id' => null,
+ ]);
+
+ Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
+ ->assertSee('No S3 storage available');
+});
+
+it('shows when S3 backups are currently disabled', function () {
+ createS3StorageForBackupEditValidationTest($this->team);
+ $backup = createBackupForEditValidationTest($this->team, [
+ 'save_s3' => false,
+ 's3_storage_id' => null,
+ ]);
+
+ Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
+ ->assertSee('S3 Storage')
+ ->assertSee('(currently disabled)');
+});
+
+it('saves selected S3 storage immediately when it changes', function () {
+ createS3StorageForBackupEditValidationTest($this->team, 'First S3');
+ $secondS3 = createS3StorageForBackupEditValidationTest($this->team, 'Second S3');
+ $backup = createBackupForEditValidationTest($this->team, [
+ 'save_s3' => false,
+ 's3_storage_id' => null,
+ ]);
+
+ Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
+ ->set('s3StorageId', $secondS3->id)
+ ->assertDispatched('success');
+
+ $backup->refresh();
+ expect($backup->save_s3)->toBeFalsy();
+ expect($backup->s3_storage_id)->toBe($secondS3->id);
+});
diff --git a/tests/Feature/EnvironmentVariableSearchTest.php b/tests/Feature/EnvironmentVariableSearchTest.php
index 613144d53..7d139ebee 100644
--- a/tests/Feature/EnvironmentVariableSearchTest.php
+++ b/tests/Feature/EnvironmentVariableSearchTest.php
@@ -56,7 +56,7 @@ it('filters production environment variables by key case-insensitively', functio
->toBe(['API_KEY']);
});
-it('treats production environment variable search wildcards literally', function () {
+it('treats production environment variable search underscore wildcards literally', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
@@ -75,23 +75,11 @@ it('treats production environment variable search wildcards literally', function
'resourceable_id' => $application->id,
]);
- EnvironmentVariable::create([
- 'key' => 'PERCENT%KEY',
- 'value' => 'percent-secret',
- 'resourceable_type' => Application::class,
- 'resourceable_id' => $application->id,
- ]);
-
$component = Livewire::test(All::class, ['resource' => $application])
->set('search', 'api_key');
expect($component->instance()->environmentVariables->pluck('key')->all())
->toBe(['API_KEY']);
-
- $component->set('search', '%KEY');
-
- expect($component->instance()->environmentVariables->pluck('key')->all())
- ->toBe(['PERCENT%KEY']);
});
it('filters preview environment variables by key case-insensitively', function () {
@@ -142,6 +130,34 @@ YAML,
->toBe(['API_TOKEN']);
});
+it('searches service environment variables without requiring preview variables', function () {
+ $service = Service::factory()->create([
+ 'environment_id' => $this->environment->id,
+ ]);
+
+ EnvironmentVariable::create([
+ 'key' => 'API_KEY',
+ 'value' => 'secret',
+ 'resourceable_type' => Service::class,
+ 'resourceable_id' => $service->id,
+ ]);
+
+ EnvironmentVariable::create([
+ 'key' => 'DATABASE_URL',
+ 'value' => 'postgres://example',
+ 'resourceable_type' => Service::class,
+ 'resourceable_id' => $service->id,
+ ]);
+
+ $component = Livewire::test(All::class, ['resource' => $service])
+ ->set('search', 'api')
+ ->assertSee('Production Environment Variables')
+ ->assertDontSee('Preview Deployments Environment Variables');
+
+ expect($component->instance()->environmentVariables->pluck('key')->all())
+ ->toBe(['API_KEY']);
+});
+
it('does not show the empty production message when search only matches hardcoded variables', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
@@ -155,11 +171,13 @@ services:
YAML,
]);
- Livewire::test(All::class, ['resource' => $service])
+ $component = Livewire::test(All::class, ['resource' => $service])
->set('search', 'api')
->assertSee('Production Environment Variables')
- ->assertSee('API_TOKEN')
->assertDontSee('No environment variables found.');
+
+ expect($component->instance()->hardcodedEnvironmentVariables->pluck('key')->all())
+ ->toBe(['API_TOKEN']);
});
it('keeps developer view unfiltered after searching', function () {
@@ -242,10 +260,12 @@ it('hides the preview section when search filters out all preview variables', fu
'resourceable_id' => $application->id,
]);
- Livewire::test(All::class, ['resource' => $application])
+ $component = Livewire::test(All::class, ['resource' => $application])
->set('search', 'api')
->assertSee('Production Environment Variables')
- ->assertSee('API_KEY')
->assertDontSee('Preview Deployments Environment Variables')
->assertDontSee('PREVIEW_TOKEN');
+
+ expect($component->instance()->environmentVariables->pluck('key')->all())
+ ->toBe(['API_KEY']);
});
diff --git a/tests/Feature/FileStorageMountPathTest.php b/tests/Feature/FileStorageMountPathTest.php
new file mode 100644
index 000000000..2a41b9075
--- /dev/null
+++ b/tests/Feature/FileStorageMountPathTest.php
@@ -0,0 +1,123 @@
+ 'array', 'cache.default' => 'array']);
+ Bus::fake();
+ InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
+
+ $this->team = Team::factory()->create();
+ $this->admin = User::factory()->create();
+ $this->admin->teams()->attach($this->team, ['role' => 'admin']);
+
+ $keyId = DB::table('private_keys')->insertGetId([
+ 'uuid' => (string) Str::uuid(),
+ 'name' => 'Test Key',
+ 'private_key' => 'test-key',
+ 'team_id' => $this->team->id,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $this->server = Server::factory()->create([
+ 'team_id' => $this->team->id,
+ 'private_key_id' => $keyId,
+ ]);
+
+ StandaloneDocker::withoutEvents(function () {
+ $this->destination = StandaloneDocker::firstOrCreate(
+ ['server_id' => $this->server->id, 'network' => 'coolify'],
+ ['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
+ );
+ });
+
+ $this->project = Project::create([
+ 'uuid' => (string) Str::uuid(),
+ 'name' => 'Test Project',
+ 'team_id' => $this->team->id,
+ ]);
+
+ $this->environment = $this->project->environments()->first()
+ ?? Environment::factory()->create(['project_id' => $this->project->id]);
+
+ $this->application = Application::factory()->create([
+ 'uuid' => (string) Str::uuid(),
+ 'name' => 'Test App',
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $this->actingAs($this->admin);
+ session(['currentTeam' => $this->team]);
+});
+
+test('livewire file storage rejects parent segments and does not create a local file volume', function () {
+ Livewire::test(Storage::class, ['resource' => $this->application])
+ ->set('file_storage_path', '/../../../../../../etc/example.conf')
+ ->set('file_storage_content', 'owned')
+ ->call('submitFileStorage')
+ ->assertDispatched('error');
+
+ expect(LocalFileVolume::query()->count())->toBe(0);
+});
+
+test('file mount modal shows the calculated host file path above the destination input', function () {
+ Livewire::test(Storage::class, ['resource' => $this->application])
+ ->assertSeeText('This file will be created on the host, then mounted into the container.')
+ ->assertSeeText('Host file path')
+ ->assertSeeText($this->application->workdir().'/')
+ ->set('file_storage_path', '/etc/nginx/nginx.conf')
+ ->assertSeeText($this->application->workdir().'/etc/nginx/nginx.conf')
+ ->assertDontSeeText('Actual file mounted from the host system to the container.');
+});
+
+test('livewire file storage stores safe file mounts under the application configuration root', function () {
+ Livewire::test(Storage::class, ['resource' => $this->application])
+ ->set('file_storage_path', '/etc/nginx/nginx.conf')
+ ->set('file_storage_content', 'server {}')
+ ->call('submitFileStorage')
+ ->assertDispatched('success');
+
+ $volume = LocalFileVolume::query()->sole();
+
+ expect($volume->mount_path)->toBe('/etc/nginx/nginx.conf')
+ ->and($volume->fs_path)->toBe(application_configuration_dir().'/'.$this->application->uuid.'/etc/nginx/nginx.conf')
+ ->and($volume->is_directory)->toBeFalse();
+});
+
+test('livewire host file storage stores an existing host file path without managed content', function () {
+ Livewire::test(Storage::class, ['resource' => $this->application])
+ ->set('host_file_storage_source', '/etc/nginx/nginx.conf')
+ ->set('host_file_storage_destination', '/etc/nginx/nginx.conf')
+ ->call('submitHostFileStorage')
+ ->assertDispatched('success');
+
+ $volume = LocalFileVolume::query()->sole();
+
+ expect($volume->fs_path)->toBe('/etc/nginx/nginx.conf')
+ ->and($volume->mount_path)->toBe('/etc/nginx/nginx.conf')
+ ->and($volume->content)->toBeNull()
+ ->and($volume->is_host_file)->toBeTrue()
+ ->and($volume->is_directory)->toBeFalse();
+
+ Bus::assertNotDispatched(ServerStorageSaveJob::class);
+});
diff --git a/tests/Feature/Jobs/DatabaseBackupJobTest.php b/tests/Feature/Jobs/DatabaseBackupJobTest.php
index 2d549c1ca..fd55aabd2 100644
--- a/tests/Feature/Jobs/DatabaseBackupJobTest.php
+++ b/tests/Feature/Jobs/DatabaseBackupJobTest.php
@@ -262,3 +262,11 @@ test('s3 storage has scheduled backups relationship', function () {
expect($s3->scheduledBackups()->count())->toBe(1);
});
+
+test('database backup job escapes the S3 copy destination argument', function () {
+ $source = file_get_contents(app_path('Jobs/DatabaseBackupJob.php'));
+
+ expect($source)->toContain('$escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/");')
+ ->and($source)->toContain('mc cp {$escapedBackupLocation} {$escapedS3Destination}')
+ ->and($source)->not->toContain('mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/');
+});
diff --git a/tests/Feature/Livewire/ApplicationGeneralDockerRegistryImageValidationTest.php b/tests/Feature/Livewire/ApplicationGeneralDockerRegistryImageValidationTest.php
index 34c889837..3ed6b8544 100644
--- a/tests/Feature/Livewire/ApplicationGeneralDockerRegistryImageValidationTest.php
+++ b/tests/Feature/Livewire/ApplicationGeneralDockerRegistryImageValidationTest.php
@@ -2,6 +2,40 @@
use App\Livewire\Project\Application\General;
+it('uses safe domain validation rules in the application general form', function () {
+ $component = new General;
+ $method = new ReflectionMethod($component, 'rules');
+ $rules = $method->invoke($component);
+
+ $validator = validator([
+ 'fqdn' => 'http://$(whoami).example.com',
+ ], [
+ 'fqdn' => $rules['fqdn'],
+ ]);
+
+ expect($validator->fails())->toBeTrue()
+ ->and($validator->errors()->has('fqdn'))->toBeTrue();
+});
+
+it('uses safe docker compose service domain validation rules in the application general form', function () {
+ $component = new General;
+ $method = new ReflectionMethod($component, 'rules');
+ $rules = $method->invoke($component);
+
+ $validator = validator([
+ 'parsedServiceDomains' => [
+ 'app' => [
+ 'domain' => 'http://$(whoami).example.com',
+ ],
+ ],
+ ], [
+ 'parsedServiceDomains.*.domain' => $rules['parsedServiceDomains.*.domain'],
+ ]);
+
+ expect($validator->fails())->toBeTrue()
+ ->and($validator->errors()->has('parsedServiceDomains.app.domain'))->toBeTrue();
+});
+
it('uses safe docker registry image validation rules in the application general form', function () {
$component = new General;
$method = new ReflectionMethod($component, 'rules');
diff --git a/tests/Feature/Mcp/McpEndpointTest.php b/tests/Feature/Mcp/McpEndpointTest.php
index b8511afef..f70584ecb 100644
--- a/tests/Feature/Mcp/McpEndpointTest.php
+++ b/tests/Feature/Mcp/McpEndpointTest.php
@@ -90,6 +90,16 @@ test('MCP endpoint returns 404 when the instance setting is disabled', function
$response->assertStatus(404);
});
+test('MCP endpoint returns 403 when the token team has MCP disabled', function () {
+ $this->team->update(['is_mcp_server_enabled' => false]);
+ $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
+
+ $response = mcpListTools($token);
+
+ $response->assertForbidden();
+ $response->assertJson(['message' => 'MCP server is disabled for this team.']);
+});
+
test('MCP endpoint rejects unauthenticated requests', function () {
$response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
$response->assertStatus(401);
diff --git a/tests/Feature/Security/CommandInjectionSecurityTest.php b/tests/Feature/Security/CommandInjectionSecurityTest.php
index 5fa5c08d2..45101635b 100644
--- a/tests/Feature/Security/CommandInjectionSecurityTest.php
+++ b/tests/Feature/Security/CommandInjectionSecurityTest.php
@@ -130,6 +130,69 @@ describe('deployment job path field validation', function () {
});
describe('API validation rules for path fields', function () {
+ test('domains validation rejects command injection payloads', function (string $payload) {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['domains' => $payload],
+ ['domains' => $rules['domains']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ })->with([
+ 'host command substitution' => 'http://$(whoami).example.com',
+ 'path command substitution' => 'http://example.com/$(whoami)',
+ 'query command substitution' => 'http://example.com/path?next=$(id)',
+ 'host backtick substitution' => 'http://`whoami`.example.com',
+ 'path backtick substitution' => 'http://example.com/`whoami`',
+ 'semicolon command separator' => 'http://example.com/path;id',
+ 'newline injection' => "http://example.com\nwhoami",
+ 'carriage return injection' => "http://example.com\rwhoami",
+ 'pipe injection' => 'http://example.com/path|id',
+ ]);
+
+ test('domains validation rejects non http schemes', function () {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['domains' => 'ftp://example.com'],
+ ['domains' => $rules['domains']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ });
+
+ test('domains validation allows comma separated http and https urls', function () {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['domains' => 'https://app.example.com,http://api.example.com/path'],
+ ['domains' => $rules['domains']]
+ );
+
+ expect($validator->fails())->toBeFalse();
+ });
+
+ test('docker compose service domains validation rejects command injection payloads', function () {
+ $rules = [
+ 'docker_compose_domains' => 'array|nullable',
+ 'docker_compose_domains.*' => 'array:name,domain',
+ 'docker_compose_domains.*.name' => 'string|required',
+ 'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
+ ];
+
+ $validator = validator(
+ [
+ 'docker_compose_domains' => [
+ ['name' => 'app', 'domain' => 'https://app.example.com/$(whoami)'],
+ ],
+ ],
+ $rules
+ );
+
+ expect($validator->fails())->toBeTrue();
+ });
+
test('git_branch validation rejects shell metacharacters', function (string $branch) {
$rules = sharedDataApplications();
@@ -276,7 +339,45 @@ describe('deployment git command escaping', function () {
expect($coolifyVariables->getValue($instance))
->toContain("COOLIFY_BRANCH='main`id`' ")
- ->toContain('COOLIFY_RESOURCE_UUID=app-uuid ');
+ ->toContain("COOLIFY_RESOURCE_UUID='app-uuid' ");
+ });
+
+ test('coolify url and fqdn shell assignments are quoted', function () {
+ $job = new ReflectionClass(ApplicationDeploymentJob::class);
+ $instance = $job->newInstanceWithoutConstructor();
+
+ $application = new Application;
+ $application->uuid = 'app-uuid';
+ $application->git_branch = 'main';
+ $application->fqdn = 'https://app.example.com/path';
+ $application->compose_parsing_version = '3';
+
+ $settings = new ApplicationSetting;
+ $settings->include_source_commit_in_build = true;
+ $application->setRelation('settings', $settings);
+
+ foreach ([
+ 'application' => $application,
+ 'commit' => 'HEAD$(id)',
+ 'pull_request_id' => 0,
+ ] as $property => $value) {
+ $reflectionProperty = $job->getProperty($property);
+ $reflectionProperty->setAccessible(true);
+ $reflectionProperty->setValue($instance, $value);
+ }
+
+ $method = $job->getMethod('set_coolify_variables');
+ $method->setAccessible(true);
+ $method->invoke($instance);
+
+ $coolifyVariables = $job->getProperty('coolify_variables');
+ $coolifyVariables->setAccessible(true);
+
+ expect($coolifyVariables->getValue($instance))
+ ->toContain("SOURCE_COMMIT='HEAD$(id)' ")
+ ->toContain("COOLIFY_URL='https://app.example.com/path' ")
+ ->toContain("COOLIFY_FQDN='app.example.com' ")
+ ->toContain("COOLIFY_RESOURCE_UUID='app-uuid' ");
});
});
diff --git a/tests/Feature/ServiceTemplatesLastUpdatedHintTest.php b/tests/Feature/ServiceTemplatesLastUpdatedHintTest.php
index a2f87963f..2ba9b850e 100644
--- a/tests/Feature/ServiceTemplatesLastUpdatedHintTest.php
+++ b/tests/Feature/ServiceTemplatesLastUpdatedHintTest.php
@@ -87,3 +87,32 @@ it('renders the service templates last updated hint placeholder', function () {
$view->assertSee('serviceTemplatesLastUpdated');
$view->assertSee('service.templateLastUpdated');
});
+
+it('keeps service template keys for service selection and docs links', function () {
+ $services = collect((new Select)->loadServices()['services']);
+ $denoKv = $services->firstWhere('id', 'denoKV');
+
+ expect($denoKv)
+ ->not->toBeNull()
+ ->and($denoKv['docsSlug'])->toBe('denokv');
+
+ View::share('errors', new ViewErrorBag);
+
+ $view = $this->view('livewire.project.new.select', [
+ 'current_step' => 'type',
+ 'environments' => collect(),
+ ]);
+
+ $view->assertSee("setType('one-click-service-' + service.id)", false);
+ $view->assertSee('service.docsSlug || this.extractBaseServiceName(service.name)', false);
+});
+
+it('preserves one click service key casing when selecting a service template', function () {
+ $component = new Select;
+ $component->servers = collect();
+ $component->allServers = collect();
+
+ $component->setType('one-click-service-denoKV');
+
+ expect($component->type)->toBe('one-click-service-denoKV');
+});
diff --git a/tests/Feature/SettingsUpdatesAuthorizationTest.php b/tests/Feature/SettingsUpdatesAuthorizationTest.php
index 5a062101a..353606c60 100644
--- a/tests/Feature/SettingsUpdatesAuthorizationTest.php
+++ b/tests/Feature/SettingsUpdatesAuthorizationTest.php
@@ -26,7 +26,7 @@ test('non-admin user is redirected from settings updates page', function () {
test('instance admin can access settings updates page', function () {
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]);
- InstanceSettings::create(['id' => 0]);
+ InstanceSettings::forceCreate(['id' => 0]);
Once::flush();
$user = User::factory()->create();
@@ -39,3 +39,24 @@ test('instance admin can access settings updates page', function () {
->assertOk()
->assertNoRedirect();
});
+
+test('instance admin cannot save an invalid docker registry url', function () {
+ config()->set('constants.coolify.self_hosted', false);
+
+ $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
+ $settings = InstanceSettings::forceCreate(['id' => 0]);
+ Once::flush();
+
+ $user = User::factory()->create();
+ $rootTeam->members()->attach($user->id, ['role' => 'admin']);
+
+ $this->actingAs($user);
+ session(['currentTeam' => ['id' => $rootTeam->id]]);
+
+ Livewire::test(Updates::class)
+ ->set('docker_registry_url', 'docker.io; touch /tmp/pwned')
+ ->call('instantSave')
+ ->assertHasErrors(['docker_registry_url' => 'in']);
+
+ expect($settings->fresh()->docker_registry_url)->toBe('docker.io');
+});
diff --git a/tests/Feature/SidebarNavigationPreferencesTest.php b/tests/Feature/SidebarNavigationPreferencesTest.php
index c341d9529..fd667552d 100644
--- a/tests/Feature/SidebarNavigationPreferencesTest.php
+++ b/tests/Feature/SidebarNavigationPreferencesTest.php
@@ -28,6 +28,7 @@ it('keeps changelog and appearance options out of the preferences dropdown', fun
->toContain('wire:click="openWhatsNewModal"')
->toContain('class="relative text-left menu-item"')
->toContain('class="text-left menu-item-label"')
+ ->toContain('class="absolute right-2 top-1/2 -translate-y-1/2 bg-error')
->toContain("What's New")
->toContain('M9.813 15.904 9 18.75')
->not->toContain('Changelog')
diff --git a/tests/Feature/StorageApiTest.php b/tests/Feature/StorageApiTest.php
index 75357e41e..92d92f860 100644
--- a/tests/Feature/StorageApiTest.php
+++ b/tests/Feature/StorageApiTest.php
@@ -1,5 +1,6 @@
'array', 'cache.default' => 'array']);
Bus::fake();
- InstanceSettings::updateOrCreate(['id' => 0]);
+ InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@@ -61,6 +65,24 @@ function createTestDatabase($context): StandalonePostgresql
]);
}
+function createTestServiceApplication($context): array
+{
+ $service = Service::factory()->create([
+ 'environment_id' => $context->environment->id,
+ 'destination_id' => $context->destination->id,
+ 'destination_type' => $context->destination->getMorphClass(),
+ ]);
+
+ $serviceApplication = ServiceApplication::create([
+ 'uuid' => (string) Str::uuid(),
+ 'name' => 'test-service-app',
+ 'service_id' => $service->id,
+ 'image' => 'nginx:alpine',
+ ]);
+
+ return [$service, $serviceApplication];
+}
+
// ──────────────────────────────────────────────────────────────
// Application Storage Endpoints
// ──────────────────────────────────────────────────────────────
@@ -140,6 +162,56 @@ describe('POST /api/v1/applications/{uuid}/storages', function () {
expect($vol)->not->toBeNull();
expect($vol->mount_path)->toBe('/app/config.json');
expect($vol->is_directory)->toBeFalse();
+ expect($vol->fs_path)->toBe(application_configuration_dir().'/'.$app->uuid.'/app/config.json');
+ });
+
+ test('creates bind only host file storage for application', function () {
+ $app = createTestApplication($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson("/api/v1/applications/{$app->uuid}/storages", [
+ 'type' => 'file',
+ 'is_host_file' => true,
+ 'fs_path' => '/etc/nginx/nginx.conf',
+ 'mount_path' => '/etc/nginx/nginx.conf',
+ ]);
+
+ $response->assertStatus(201);
+
+ $vol = LocalFileVolume::where('resource_id', $app->id)
+ ->where('resource_type', get_class($app))
+ ->first();
+
+ expect($vol)->not->toBeNull();
+ expect($vol->fs_path)->toBe('/etc/nginx/nginx.conf');
+ expect($vol->mount_path)->toBe('/etc/nginx/nginx.conf');
+ expect($vol->content)->toBeNull();
+ expect($vol->is_host_file)->toBeTrue();
+ expect($vol->is_directory)->toBeFalse();
+
+ Bus::assertNotDispatched(ServerStorageSaveJob::class);
+ });
+
+ test('rejects file storage paths with parent segments', function () {
+ $app = createTestApplication($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson("/api/v1/applications/{$app->uuid}/storages", [
+ 'type' => 'file',
+ 'mount_path' => '/../../../../../../etc/example.conf',
+ 'content' => 'owned',
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonPath('message', 'Validation failed.');
+
+ expect(LocalFileVolume::where('resource_id', $app->id)
+ ->where('resource_type', get_class($app))
+ ->exists())->toBeFalse();
});
test('rejects persistent storage without name', function () {
@@ -331,6 +403,92 @@ describe('POST /api/v1/databases/{uuid}/storages', function () {
expect($vol)->not->toBeNull();
expect($vol->mount_path)->toBe('/extra');
});
+
+ test('creates a file storage for a database under the database configuration root', function () {
+ $db = createTestDatabase($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson("/api/v1/databases/{$db->uuid}/storages", [
+ 'type' => 'file',
+ 'mount_path' => '/postgres/postgresql.conf',
+ 'content' => 'listen_addresses = "*"',
+ ]);
+
+ $response->assertStatus(201);
+
+ $vol = LocalFileVolume::where('resource_id', $db->id)
+ ->where('resource_type', get_class($db))
+ ->first();
+
+ expect($vol)->not->toBeNull();
+ expect($vol->fs_path)->toBe(database_configuration_dir().'/'.$db->uuid.'/postgres/postgresql.conf');
+ });
+
+ test('rejects file storage paths with parent segments for a database', function () {
+ $db = createTestDatabase($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson("/api/v1/databases/{$db->uuid}/storages", [
+ 'type' => 'file',
+ 'mount_path' => '/postgres/../../../etc/shadow',
+ 'content' => 'owned',
+ ]);
+
+ $response->assertStatus(422);
+
+ expect(LocalFileVolume::where('resource_id', $db->id)
+ ->where('resource_type', get_class($db))
+ ->exists())->toBeFalse();
+ });
+});
+
+describe('POST /api/v1/services/{uuid}/storages', function () {
+ test('creates a file storage for a service resource under the service configuration root', function () {
+ [$service, $serviceApplication] = createTestServiceApplication($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson("/api/v1/services/{$service->uuid}/storages", [
+ 'type' => 'file',
+ 'resource_uuid' => $serviceApplication->uuid,
+ 'mount_path' => '/etc/nginx/nginx.conf',
+ 'content' => 'server {}',
+ ]);
+
+ $response->assertStatus(201);
+
+ $vol = LocalFileVolume::where('resource_id', $serviceApplication->id)
+ ->where('resource_type', get_class($serviceApplication))
+ ->first();
+
+ expect($vol)->not->toBeNull();
+ expect($vol->fs_path)->toBe(service_configuration_dir().'/'.$service->uuid.'/etc/nginx/nginx.conf');
+ });
+
+ test('rejects file storage paths with parent segments for a service resource', function () {
+ [$service, $serviceApplication] = createTestServiceApplication($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson("/api/v1/services/{$service->uuid}/storages", [
+ 'type' => 'file',
+ 'resource_uuid' => $serviceApplication->uuid,
+ 'mount_path' => '/../../../../../../root/.ssh/authorized_keys',
+ 'content' => 'owned',
+ ]);
+
+ $response->assertStatus(422);
+
+ expect(LocalFileVolume::where('resource_id', $serviceApplication->id)
+ ->where('resource_type', get_class($serviceApplication))
+ ->exists())->toBeFalse();
+ });
});
describe('PATCH /api/v1/databases/{uuid}/storages', function () {
diff --git a/tests/Unit/ApplicationDeploymentJobCommitResolutionTest.php b/tests/Unit/ApplicationDeploymentJobCommitResolutionTest.php
new file mode 100644
index 000000000..f11d6ac16
--- /dev/null
+++ b/tests/Unit/ApplicationDeploymentJobCommitResolutionTest.php
@@ -0,0 +1,121 @@
+setValue($job, $value);
+}
+
+function getDeploymentJobProperty(object $job, string $property): mixed
+{
+ $reflectionProperty = new ReflectionProperty(ApplicationDeploymentJob::class, $property);
+
+ return $reflectionProperty->getValue($job);
+}
+
+function invokeCheckGitIfBuildNeeded(object $job): void
+{
+ $method = new ReflectionMethod(ApplicationDeploymentJob::class, 'check_git_if_build_needed');
+ $method->invoke($job);
+}
+
+function makeDeploymentJobForCommitCheck(string $pinnedSha, string $branchHeadSha): object
+{
+ $job = new class extends ApplicationDeploymentJob
+ {
+ public function __construct() {}
+
+ public function execute_remote_command(...$commands) {}
+ };
+
+ $application = new class extends Application
+ {
+ public function generateGitImportCommands(string $deployment_uuid, int $pull_request_id = 0, ?string $git_type = null, bool $exec_in_docker = true, bool $only_checkout = false, ?string $custom_base_dir = null, ?string $commit = null)
+ {
+ return [
+ 'commands' => collect([]),
+ 'branch' => 'main',
+ 'fullRepoUrl' => 'https://github.com/coollabsio/coolify.git',
+ ];
+ }
+ };
+ $application->forceFill([
+ 'uuid' => 'application-uuid',
+ 'git_branch' => 'main',
+ ]);
+ $application->setRelation('settings', (object) [
+ 'include_source_commit_in_build' => false,
+ 'use_build_secrets' => false,
+ ]);
+ $application->setRelation('private_key', null);
+
+ $deploymentQueue = new class extends ApplicationDeploymentQueue
+ {
+ public bool $saved = false;
+
+ public function save(array $options = []): bool
+ {
+ $this->saved = true;
+
+ return true;
+ }
+ };
+ $deploymentQueue->commit = $pinnedSha;
+
+ setDeploymentJobProperty($job, 'application', $application);
+ setDeploymentJobProperty($job, 'application_deployment_queue', $deploymentQueue);
+ setDeploymentJobProperty($job, 'deployment_uuid', 'deployment-uuid');
+ setDeploymentJobProperty($job, 'pull_request_id', 0);
+ setDeploymentJobProperty($job, 'commit', $pinnedSha);
+ setDeploymentJobProperty($job, 'rollback', false);
+ setDeploymentJobProperty($job, 'git_type', 'github');
+ setDeploymentJobProperty($job, 'saved_outputs', collect([
+ 'git_commit_sha' => str("{$branchHeadSha}\trefs/heads/main"),
+ ]));
+
+ return $job;
+}
+
+function shouldResolveBranchHeadForCommit(?string $commit): bool
+{
+ $job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
+
+ $commitProperty = new ReflectionProperty($job, 'commit');
+ $commitProperty->setValue($job, $commit ?? '');
+
+ $method = new ReflectionMethod($job, 'shouldResolveBranchHeadCommit');
+
+ return $method->invoke($job);
+}
+
+describe('ApplicationDeploymentJob commit resolution', function () {
+ test('resolves branch head for HEAD deployments', function () {
+ expect(shouldResolveBranchHeadForCommit('HEAD'))->toBeTrue();
+ });
+
+ test('resolves branch head for blank deployments', function () {
+ expect(shouldResolveBranchHeadForCommit(''))->toBeTrue();
+ });
+
+ test('keeps pinned deployment commits instead of replacing them with branch head', function () {
+ expect(shouldResolveBranchHeadForCommit('abc123def456abc123def456abc123def456abc1'))->toBeFalse();
+ });
+
+ test('check git does not overwrite pinned deployment commit with branch head', function () {
+ $pinnedSha = 'abc123def456abc123def456abc123def456abc1';
+ $branchHeadSha = '111222333444555666777888999000aaabbbccc1';
+ $job = makeDeploymentJobForCommitCheck($pinnedSha, $branchHeadSha);
+
+ invokeCheckGitIfBuildNeeded($job);
+
+ $deploymentQueue = getDeploymentJobProperty($job, 'application_deployment_queue');
+
+ expect(getDeploymentJobProperty($job, 'commit'))->toBe($pinnedSha)
+ ->and($deploymentQueue->commit)->toBe($pinnedSha)
+ ->and($deploymentQueue->saved)->toBeFalse();
+ });
+});
diff --git a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php
index c67761faa..03822cfd2 100644
--- a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php
+++ b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php
@@ -218,6 +218,75 @@ it('fails fast when docker buildx is unavailable for railpack builds', function
->toThrow(DeploymentException::class, 'Railpack deployments require the Docker buildx CLI plugin');
});
+it('checks buildx inside the helper container before railpack builds', function () {
+ [$job, $reflection] = makeRailpackDeploymentJob([], [
+ 'railpack_helper_buildx_available' => 'available',
+ ]);
+
+ invokeRailpackMethod($job, $reflection, 'ensure_helper_docker_buildx_available_for_railpack');
+
+ expect($job->recordedCommands[0][0][0])
+ ->toContain('DOCKER_CONFIG=/root/.docker docker buildx version');
+});
+
+it('fails clearly when buildx is missing inside the helper container', function () {
+ [$job, $reflection] = makeRailpackDeploymentJob([], [
+ 'railpack_helper_buildx_available' => 'not-available',
+ ]);
+
+ expect(fn () => invokeRailpackMethod($job, $reflection, 'ensure_helper_docker_buildx_available_for_railpack'))
+ ->toThrow(DeploymentException::class, 'helper container');
+});
+
+it('pins docker config while running railpack buildx commands', function () {
+ [$job, $reflection] = makeRailpackDeploymentJob([
+ 'uuid' => 'application-uuid',
+ ]);
+
+ $command = invokeRailpackMethod(
+ $job,
+ $reflection,
+ 'railpack_build_command',
+ [
+ 'coollabsio/coolify:test',
+ collect([]),
+ ],
+ );
+
+ expect($command)
+ ->toContain('DOCKER_CONFIG=/root/.docker docker buildx create --name coolify-railpack')
+ ->toContain('DOCKER_CONFIG=/root/.docker docker buildx build --builder coolify-railpack');
+});
+
+it('filters reserved docker client variables from railpack build secrets', function () {
+ [$job, $reflection] = makeRailpackDeploymentJob([
+ 'uuid' => 'application-uuid',
+ ]);
+
+ $command = invokeRailpackMethod(
+ $job,
+ $reflection,
+ 'railpack_build_command',
+ [
+ 'coollabsio/coolify:test',
+ collect([
+ 'DOCKER_CONFIG' => '/tmp/no-buildx',
+ 'DOCKER_HOST' => 'tcp://invalid:2375',
+ 'RAILPACK_NODE_VERSION' => '22',
+ 'APP_ENV' => 'production',
+ ]),
+ ],
+ );
+
+ expect($command)
+ ->toContain("--secret 'id=RAILPACK_NODE_VERSION,env=RAILPACK_NODE_VERSION'")
+ ->toContain("--secret 'id=APP_ENV,env=APP_ENV'")
+ ->not->toContain('id=DOCKER_CONFIG')
+ ->not->toContain('id=DOCKER_HOST')
+ ->not->toContain("env 'DOCKER_CONFIG=")
+ ->not->toContain("env 'DOCKER_HOST=");
+});
+
it('builds railpack docker command with matching env and secret flags for all railpack variables', function () {
[$job, $reflection] = makeRailpackDeploymentJob([
'uuid' => 'application-uuid',
diff --git a/tests/Unit/FileStorageSecurityTest.php b/tests/Unit/FileStorageSecurityTest.php
index 1e08ebbe7..b5e1c7404 100644
--- a/tests/Unit/FileStorageSecurityTest.php
+++ b/tests/Unit/FileStorageSecurityTest.php
@@ -141,3 +141,77 @@ test('file storage accepts relative dot-prefixed paths', function () {
expect(fn () => validateShellSafePath('./data', 'storage path'))
->not->toThrow(Exception::class);
});
+
+test('file mount path validator rejects parent segments and unsafe separators', function (string $path) {
+ expect(fn () => validateFileMountPath($path, 'file storage path'))
+ ->toThrow(Exception::class);
+})->with([
+ 'parent segment to etc' => ['/../../etc/passwd'],
+ 'embedded parent segment' => ['/foo/../bar'],
+ 'parent segment' => ['/..'],
+ 'double slash before parent segment' => ['/foo//../bar'],
+ 'current directory segment' => ['/foo/./bar'],
+ 'backslash parent segment' => ['\\..\\etc\\passwd'],
+ 'null byte' => ["/app/config\0/../../etc/passwd"],
+]);
+
+test('file mount path validator accepts safe absolute container file paths', function (string $path, string $expected) {
+ expect(validateFileMountPath($path, 'file storage path'))->toBe($expected);
+})->with([
+ 'nginx config' => ['/etc/nginx/nginx.conf', '/etc/nginx/nginx.conf'],
+ 'app env filename' => ['/app/.env', '/app/.env'],
+ 'relative input becomes absolute' => ['config/app.yaml', '/config/app.yaml'],
+ 'duplicate slashes collapse' => ['/opt//app///config.json', '/opt/app/config.json'],
+]);
+
+test('host file mount path validator accepts absolute host file paths', function () {
+ expect(validateHostFileMountPath('/etc/nginx/nginx.conf', 'host file path'))
+ ->toBe('/etc/nginx/nginx.conf');
+});
+
+test('host file mount path validator rejects ambiguous or directory paths', function (string $path) {
+ expect(fn () => validateHostFileMountPath($path, 'host file path'))
+ ->toThrow(Exception::class);
+})->with([
+ 'relative path' => ['etc/nginx/nginx.conf'],
+ 'root directory' => ['/'],
+ 'trailing slash' => ['/etc/nginx/'],
+ 'parent segment' => ['/etc/../shadow'],
+ 'current segment' => ['/etc/./nginx.conf'],
+ 'backslash' => ['\\etc\\nginx.conf'],
+]);
+
+test('confined path resolver keeps file mounts inside their resource configuration root', function () {
+ expect(confineFileMountPath('/data/coolify/applications/app-uuid', '/etc/nginx/nginx.conf', 'file storage path'))
+ ->toBe('/data/coolify/applications/app-uuid/etc/nginx/nginx.conf');
+
+ expect(confineFileMountPath('/data/coolify/databases/db-uuid/', 'postgres/postgresql.conf', 'file storage path'))
+ ->toBe('/data/coolify/databases/db-uuid/postgres/postgresql.conf');
+
+ expect(confineFileMountPath('/data/coolify/services/service-uuid', '/config.yaml', 'file storage path'))
+ ->toBe('/data/coolify/services/service-uuid/config.yaml');
+});
+
+test('confined path resolver rejects paths that escape the resource configuration root', function (string $base, string $path) {
+ expect(fn () => confineFileMountPath($base, $path, 'file storage path'))
+ ->toThrow(Exception::class);
+})->with([
+ 'application parent segment' => ['/data/coolify/applications/app-uuid', '/../../etc/passwd'],
+ 'database parent segment' => ['/data/coolify/databases/db-uuid', '/postgres/../../../etc/shadow'],
+ 'service dot segment' => ['/data/coolify/services/service-uuid', '/./config.yaml'],
+]);
+
+test('local file volume write sink keeps saved managed file paths for compatibility', function () {
+ $source = file_get_contents(__DIR__.'/../../app/Models/LocalFileVolume.php');
+
+ expect($source)->not->toContain('confinePathToBase($workdir, $path->value(), \'storage path\')')
+ ->and($source)->toContain('tee {$escapedPath}');
+});
+
+test('host file mounts are bind-only and skipped by server storage writes', function () {
+ $source = file_get_contents(__DIR__.'/../../app/Models/LocalFileVolume.php');
+
+ expect($source)->toContain('if ($this->is_host_file) {')
+ ->and($source)->toContain('return;')
+ ->and($source)->toContain('tee {$escapedPath}');
+});
diff --git a/tests/Unit/NotificationWebhookSafetyTest.php b/tests/Unit/NotificationWebhookSafetyTest.php
new file mode 100644
index 000000000..9246c4004
--- /dev/null
+++ b/tests/Unit/NotificationWebhookSafetyTest.php
@@ -0,0 +1,36 @@
+handle();
+
+ Http::assertNothingSent();
+});
+
+it('blocks queued Discord notifications to IPv4-mapped link-local URLs', function () {
+ Http::fake();
+
+ $job = new SendMessageToDiscordJob(
+ new DiscordMessage('Test', 'Description', DiscordMessage::infoColor()),
+ 'http://[::ffff:169.254.169.254]/'
+ );
+
+ $job->handle();
+
+ Http::assertNothingSent();
+});
diff --git a/tests/Unit/Policies/TeamPolicyTest.php b/tests/Unit/Policies/TeamPolicyTest.php
new file mode 100644
index 000000000..3b341d488
--- /dev/null
+++ b/tests/Unit/Policies/TeamPolicyTest.php
@@ -0,0 +1,92 @@
+makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn(collect(
+ array_map(fn (int $teamId): object => (object) ['id' => $teamId], $teamIds)
+ ));
+
+ return $user;
+}
+
+function teamPolicyTeam(int $teamId): Team
+{
+ $team = Mockery::mock(Team::class)->makePartial();
+ $team->shouldReceive('getAttribute')->with('id')->andReturn($teamId);
+
+ return $team;
+}
+
+it('allows any authenticated user to view any teams list', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ expect((new TeamPolicy)->viewAny($user))->toBeTrue();
+});
+
+it('allows authenticated users to create teams', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ expect((new TeamPolicy)->create($user))->toBeTrue();
+});
+
+it('allows target team members to view the team', function () {
+ $user = teamPolicyUserWithTeams([1]);
+ $team = teamPolicyTeam(1);
+
+ expect((new TeamPolicy)->view($user, $team))->toBeTrue();
+});
+
+it('denies non-members from viewing the team', function () {
+ $user = teamPolicyUserWithTeams([2]);
+ $team = teamPolicyTeam(1);
+
+ expect((new TeamPolicy)->view($user, $team))->toBeFalse();
+});
+
+it('allows target team admins to perform privileged team actions', function (string $ability) {
+ $user = teamPolicyUserWithTeams([1]);
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+ $team = teamPolicyTeam(1);
+
+ expect((new TeamPolicy)->{$ability}($user, $team))->toBeTrue();
+})->with([
+ 'update',
+ 'delete',
+ 'manageMembers',
+ 'viewAdmin',
+ 'manageInvitations',
+]);
+
+it('denies target team members even when their current session role is admin elsewhere', function (string $ability) {
+ $user = teamPolicyUserWithTeams([1, 2]);
+ $user->shouldReceive('isAdmin')->andReturn(true);
+ $user->shouldReceive('isOwner')->andReturn(false);
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+ $team = teamPolicyTeam(1);
+
+ expect((new TeamPolicy)->{$ability}($user, $team))->toBeFalse();
+})->with([
+ 'update',
+ 'delete',
+ 'manageMembers',
+ 'viewAdmin',
+ 'manageInvitations',
+]);
+
+it('denies non-members from privileged team actions', function (string $ability) {
+ $user = teamPolicyUserWithTeams([2]);
+ $team = teamPolicyTeam(1);
+
+ expect((new TeamPolicy)->{$ability}($user, $team))->toBeFalse();
+})->with([
+ 'update',
+ 'delete',
+ 'manageMembers',
+ 'viewAdmin',
+ 'manageInvitations',
+]);
diff --git a/tests/Unit/Project/Database/ImportCheckFileButtonTest.php b/tests/Unit/Project/Database/ImportCheckFileButtonTest.php
index 8d8d29de8..2b816ff5e 100644
--- a/tests/Unit/Project/Database/ImportCheckFileButtonTest.php
+++ b/tests/Unit/Project/Database/ImportCheckFileButtonTest.php
@@ -37,10 +37,8 @@ test('validateBucketName accepts valid bucket names', function () {
// Valid bucket names
expect($method->invoke($component, 'my-bucket'))->toBeTrue();
- expect($method->invoke($component, 'my_bucket'))->toBeTrue();
expect($method->invoke($component, 'mybucket123'))->toBeTrue();
expect($method->invoke($component, 'my.bucket.name'))->toBeTrue();
- expect($method->invoke($component, 'Bucket-Name_123'))->toBeTrue();
});
test('validateBucketName rejects invalid bucket names', function () {
@@ -55,6 +53,9 @@ test('validateBucketName rejects invalid bucket names', function () {
expect($method->invoke($component, 'bucket&ls'))->toBeFalse();
expect($method->invoke($component, "bucket\nid"))->toBeFalse();
expect($method->invoke($component, 'bucket name'))->toBeFalse(); // Space not allowed in bucket
+ expect($method->invoke($component, 'my_bucket'))->toBeFalse();
+ expect($method->invoke($component, 'Bucket-Name'))->toBeFalse();
+ expect($method->invoke($component, '192.168.1.1'))->toBeFalse();
});
test('validateS3Path accepts valid S3 paths', function () {
diff --git a/tests/Unit/S3StorageEndpointValidationTest.php b/tests/Unit/S3StorageEndpointValidationTest.php
index 054606a25..d01769b39 100644
--- a/tests/Unit/S3StorageEndpointValidationTest.php
+++ b/tests/Unit/S3StorageEndpointValidationTest.php
@@ -1,21 +1,23 @@
$endpoint],
['endpoint' => ['required', 'max:255', new SafeWebhookUrl]],
@@ -23,8 +25,9 @@ it('rejects SSRF payloads on the S3 endpoint', function (string $endpoint) {
expect($validator->fails())->toBeTrue("Expected rejection: {$endpoint}");
})->with([
- 'AWS IMDS' => 'http://169.254.169.254/latest/meta-data/',
- 'AWS IMDS bare' => 'http://169.254.169.254',
+ 'link-local address' => 'http://169.254.169.254/',
+ 'link-local address bare' => 'http://169.254.169.254',
+ 'link-local address IPv4-mapped IPv6' => 'http://[::ffff:169.254.169.254]/',
'GCP metadata via link-local' => 'http://169.254.0.1',
'loopback v4' => 'http://127.0.0.1',
'loopback Redis' => 'http://127.0.0.1:6379',
@@ -42,19 +45,15 @@ it('rejects SSRF payloads on the S3 endpoint', function (string $endpoint) {
it('accepts real-world S3 endpoints', function (string $endpoint) {
$validator = Validator::make(
['endpoint' => $endpoint],
- ['endpoint' => ['required', 'max:255', new SafeWebhookUrl]],
+ ['endpoint' => ['required', 'max:255', new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34'])]],
);
expect($validator->passes())->toBeTrue("Expected accepted: {$endpoint}");
})->with([
'AWS S3' => 'https://s3.us-east-1.amazonaws.com',
- 'Cloudflare R2' => 'https://fake.r2.cloudflarestorage.com',
'DigitalOcean Spaces' => 'https://nyc3.digitaloceanspaces.com',
'Backblaze B2' => 'https://s3.us-west-001.backblazeb2.com',
- 'Self-hosted MinIO on 10.x' => 'http://10.0.0.5:9000',
- 'Self-hosted MinIO on 172.16.x' => 'http://172.16.0.10:9000',
- 'Self-hosted MinIO on 192.168.x' => 'http://192.168.1.50:9000',
- 'Custom domain MinIO' => 'https://minio.example.com',
+ 'Custom public domain S3-compatible endpoint' => 'https://example.com',
]);
it('blocks testConnection() on an unsafe endpoint without issuing HTTP', function () {
@@ -87,5 +86,21 @@ it('blocks testConnection() for loopback endpoints', function (string $endpoint)
'http loopback' => 'http://127.0.0.1:6379',
'localhost' => 'http://localhost:9000',
'IPv6 loopback' => 'http://[::1]',
+ 'IPv4-mapped IPv6 link-local' => 'http://[::ffff:169.254.169.254]',
'internal TLD' => 'http://backend.internal',
]);
+
+it('accepts explicitly allowlisted intranet S3 endpoints', function (string $endpoint, array $allowlist) {
+ InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allowed_internal_hosts' => $allowlist]));
+
+ $validator = Validator::make(
+ ['endpoint' => $endpoint],
+ ['endpoint' => ['required', 'max:255', new SafeWebhookUrl]],
+ );
+
+ expect($validator->passes())->toBeTrue("Expected allowlisted intranet S3 endpoint: {$endpoint}");
+})->with([
+ 'Self-hosted MinIO on 10.x CIDR' => ['http://10.0.0.5:9000', ['10.0.0.0/8']],
+ 'Self-hosted MinIO on 172.16.x CIDR' => ['http://172.16.0.10:9000', ['172.16.0.0/12']],
+ 'Self-hosted MinIO on 192.168.x exact IP' => ['http://192.168.1.50:9000', ['192.168.1.50']],
+]);
diff --git a/tests/Unit/S3StorageTest.php b/tests/Unit/S3StorageTest.php
index ddf390443..ef7744b2a 100644
--- a/tests/Unit/S3StorageTest.php
+++ b/tests/Unit/S3StorageTest.php
@@ -53,6 +53,7 @@ test('S3Storage model fillable attributes are configured correctly', function ()
$s3Storage = new S3Storage;
expect($s3Storage->getFillable())->toBe([
+ 'team_id',
'name',
'description',
'region',
@@ -74,6 +75,7 @@ test('S3Storage connection validation uses short s3 client timeouts', function (
->with(Mockery::on(function (array $config) {
expect($config['http']['connect_timeout'])->toBe(15);
expect($config['http']['timeout'])->toBe(15);
+ expect($config['http']['allow_redirects'])->toBeFalse();
return true;
}))
@@ -118,3 +120,27 @@ test('S3Storage connection validation returns friendly timeout error', function
expect($s3Storage->is_usable)->toBeFalse();
});
+
+test('S3Storage testConnection rejects invalid bucket before building client', function (string $bucket) {
+ Storage::shouldReceive('build')->never();
+
+ $s3Storage = new S3Storage;
+ $s3Storage->setRawAttributes([
+ 'name' => 'Test S3',
+ 'region' => 'us-east-1',
+ 'key' => 'AKIAEXAMPLE',
+ 'secret' => 'secret',
+ 'bucket' => $bucket,
+ 'endpoint' => 'https://s3.amazonaws.com',
+ ]);
+
+ expect(fn () => $s3Storage->testConnection())
+ ->toThrow(RuntimeException::class, 'S3 bucket name is not allowed');
+})->with([
+ 'semicolon injection' => ['lab; id; #'],
+ 'command substitution' => ['lab$(id)'],
+ 'backticks' => ['lab`id`'],
+ 'newline' => ["lab\nid"],
+ 'underscore' => ['lab_bucket'],
+ 'uppercase' => ['LabBucket'],
+]);
diff --git a/tests/Unit/SafeExternalUrlTest.php b/tests/Unit/SafeExternalUrlTest.php
index b2bc13337..c047b1923 100644
--- a/tests/Unit/SafeExternalUrlTest.php
+++ b/tests/Unit/SafeExternalUrlTest.php
@@ -11,7 +11,7 @@ it('accepts valid public URLs', function () {
$validUrls = [
'https://api.github.com',
- 'https://github.example.com/api/v3',
+ 'https://github.com/api/v3',
'https://example.com',
'http://example.com',
];
@@ -22,6 +22,14 @@ it('accepts valid public URLs', function () {
}
});
+it('accepts custom external hostnames that resolve to public IPs', function () {
+ $rule = new SafeExternalUrl(fn (string $host): array => ['93.184.216.34']);
+
+ $validator = Validator::make(['url' => 'https://github.example.com/api/v3'], ['url' => $rule]);
+
+ expect($validator->passes())->toBeTrue('Expected valid custom external hostname');
+});
+
it('rejects private IPv4 addresses', function (string $url) {
$rule = new SafeExternalUrl;
@@ -42,6 +50,34 @@ it('rejects cloud metadata IP', function () {
expect($validator->fails())->toBeTrue('Expected rejection: cloud metadata IP');
});
+it('rejects hostnames that resolve to private or reserved addresses', function (string $url, array $resolvedIps) {
+ $rule = new SafeExternalUrl(fn (string $host): array => $resolvedIps);
+
+ $validator = Validator::make(['url' => $url], ['url' => $rule]);
+
+ expect($validator->fails())->toBeTrue("Expected rejection after DNS resolution: {$url}");
+})->with([
+ 'hostname to link-local IP' => ['http://169.254.169.254.nip.io/', ['169.254.169.254']],
+ 'hostname to loopback' => ['http://loopback.example.test/', ['127.0.0.1']],
+ 'hostname to private IPv4' => ['http://private.example.test/', ['10.0.0.1']],
+ 'hostname to IPv6 loopback' => ['http://ipv6-loopback.example.test/', ['::1']],
+ 'hostname to IPv6 link-local' => ['http://ipv6-link-local.example.test/', ['fe80::1']],
+ 'hostname to IPv6 ULA' => ['http://ipv6-ula.example.test/', ['fc00::1']],
+ 'hostname to mapped private IPv4' => ['http://mapped-private.example.test/', ['::ffff:10.0.0.1']],
+]);
+
+it('rejects IPv4-mapped IPv6 literals for private or reserved IPv4 ranges', function (string $url) {
+ $rule = new SafeExternalUrl;
+
+ $validator = Validator::make(['url' => $url], ['url' => $rule]);
+
+ expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
+})->with([
+ 'mapped link-local IP' => 'http://[::ffff:169.254.169.254]/',
+ 'mapped loopback' => 'http://[::ffff:127.0.0.1]/',
+ 'mapped private' => 'http://[::ffff:10.0.0.1]/',
+]);
+
it('rejects localhost and internal hostnames', function (string $url) {
$rule = new SafeExternalUrl;
@@ -50,9 +86,12 @@ it('rejects localhost and internal hostnames', function (string $url) {
})->with([
'localhost' => 'http://localhost',
'localhost with port' => 'http://localhost:8080',
+ 'localhost with trailing dot' => 'http://localhost.',
'zero address' => 'http://0.0.0.0',
'.local domain' => 'http://myservice.local',
+ '.local domain with trailing dot' => 'http://myservice.local.',
'.internal domain' => 'http://myservice.internal',
+ '.internal domain with trailing dot' => 'http://myservice.internal.',
]);
it('rejects non-URL strings', function (string $value) {
diff --git a/tests/Unit/SafeWebhookUrlTest.php b/tests/Unit/SafeWebhookUrlTest.php
index bb5569ccf..de0c06260 100644
--- a/tests/Unit/SafeWebhookUrlTest.php
+++ b/tests/Unit/SafeWebhookUrlTest.php
@@ -1,13 +1,15 @@
['93.184.216.34']);
$validUrls = [
'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXX',
@@ -22,17 +24,6 @@ it('accepts valid public URLs', function () {
}
});
-it('accepts private network IPs for self-hosted deployments', function (string $url) {
- $rule = new SafeWebhookUrl;
-
- $validator = Validator::make(['url' => $url], ['url' => $rule]);
- expect($validator->passes())->toBeTrue("Expected valid (private IP): {$url}");
-})->with([
- '10.x range' => 'http://10.0.0.5/webhook',
- '172.16.x range' => 'http://172.16.0.1:8080/hook',
- '192.168.x range' => 'http://192.168.1.50:8080/webhook',
-]);
-
it('rejects loopback addresses', function (string $url) {
$rule = new SafeWebhookUrl;
@@ -59,6 +50,33 @@ it('rejects link-local range', function () {
expect($validator->fails())->toBeTrue('Expected rejection: link-local IP');
});
+it('rejects hostnames that resolve to blocked addresses', function (string $url, array $resolvedIps) {
+ $rule = new SafeWebhookUrl(fn (string $host): array => $resolvedIps);
+
+ $validator = Validator::make(['url' => $url], ['url' => $rule]);
+
+ expect($validator->fails())->toBeTrue("Expected rejection after DNS resolution: {$url}");
+})->with([
+ 'hostname to link-local IP' => ['http://169.254.169.254.nip.io/', ['169.254.169.254']],
+ 'hostname to loopback' => ['http://loopback.example.test/', ['127.0.0.1']],
+ 'hostname to IPv6 loopback' => ['http://ipv6-loopback.example.test/', ['::1']],
+ 'hostname to IPv6 link-local' => ['http://ipv6-link-local.example.test/', ['fe80::1']],
+ 'hostname to IPv6 ULA' => ['http://ipv6-ula.example.test/', ['fc00::1']],
+ 'hostname to mapped link-local IP' => ['http://mapped-link-local.example.test/', ['::ffff:169.254.169.254']],
+]);
+
+it('rejects IPv4-mapped IPv6 literals for blocked IPv4 ranges', function (string $url) {
+ $rule = new SafeWebhookUrl;
+
+ $validator = Validator::make(['url' => $url], ['url' => $rule]);
+
+ expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
+})->with([
+ 'mapped link-local IP' => 'http://[::ffff:169.254.169.254]/',
+ 'mapped loopback' => 'http://[::ffff:127.0.0.1]/',
+ 'mapped zero' => 'http://[::ffff:0.0.0.0]/',
+]);
+
it('rejects localhost and internal hostnames', function (string $url) {
$rule = new SafeWebhookUrl;
@@ -67,7 +85,9 @@ it('rejects localhost and internal hostnames', function (string $url) {
})->with([
'localhost' => 'http://localhost',
'localhost with port' => 'http://localhost:8080',
+ 'localhost with trailing dot' => 'http://localhost.',
'.internal domain' => 'http://myservice.internal',
+ '.internal domain with trailing dot' => 'http://myservice.internal.',
]);
it('rejects non-http schemes', function (string $value) {
@@ -88,3 +108,173 @@ it('rejects IPv6 loopback', function () {
$validator = Validator::make(['url' => 'http://[::1]'], ['url' => $rule]);
expect($validator->fails())->toBeTrue('Expected rejection: IPv6 loopback');
});
+
+it('rejects private and reserved network targets by default', function (string $url) {
+ $rule = new SafeWebhookUrl;
+
+ $validator = Validator::make(['url' => $url], ['url' => $rule]);
+
+ expect($validator->fails())->toBeTrue("Expected default rejection: {$url}");
+})->with([
+ 'private 10/8' => 'http://10.0.0.5/webhook',
+ 'private 172.16/12' => 'http://172.16.0.1:8080/hook',
+ 'private 192.168/16' => 'http://192.168.1.50:8080/webhook',
+ 'shared address space' => 'http://100.64.0.1/webhook',
+ 'zero network peer alias' => 'http://0.0.0.1/webhook',
+ 'multicast' => 'http://224.0.0.1/webhook',
+ 'benchmark range' => 'http://198.18.0.1/webhook',
+ 'documentation range' => 'http://192.0.2.10/webhook',
+]);
+
+it('rejects hostname forms that resolve to loopback', function (string $url) {
+ $rule = new SafeWebhookUrl;
+
+ $validator = Validator::make(['url' => $url], ['url' => $rule]);
+
+ expect($validator->fails())->toBeTrue("Expected loopback hostname-form rejection: {$url}");
+})->with([
+ 'decimal IPv4' => 'http://2130706433:8888/exfil',
+ 'hex IPv4' => 'http://0x7f000001:8888/exfil',
+ 'octal IPv4' => 'http://017700000001:8888/exfil',
+ 'short dotted IPv4' => 'http://127.1:8888/exfil',
+ 'IPv4-mapped IPv6 hex loopback' => 'http://[::ffff:7f00:1]:8888/exfil',
+]);
+
+it('rejects internal DNS suffixes by default', function (string $url) {
+ $rule = new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34']);
+
+ $validator = Validator::make(['url' => $url], ['url' => $rule]);
+
+ expect($validator->fails())->toBeTrue("Expected default rejection: {$url}");
+})->with([
+ '.local host' => 'http://receiver.local/webhook',
+ '.cluster.local host' => 'http://service.cluster.local/webhook',
+]);
+
+it('rejects unresolvable hostnames by default', function () {
+ $rule = new SafeWebhookUrl(fn (string $host): array => []);
+
+ $validator = Validator::make(['url' => 'http://does-not-resolve.example.test/webhook'], ['url' => $rule]);
+
+ expect($validator->fails())->toBeTrue('Expected default rejection for unresolvable host');
+});
+
+it('keeps webhook DNS resolution enabled when general DNS validation is disabled', function () {
+ InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['is_dns_validation_enabled' => false]));
+
+ $rule = new SafeWebhookUrl(fn (string $host): array => ['127.0.0.1']);
+
+ $validator = Validator::make(['url' => 'http://rebinding.example.test/webhook'], ['url' => $rule]);
+
+ expect($validator->fails())->toBeTrue('Expected webhook SSRF DNS checks to remain enabled');
+});
+
+it('reads configured custom DNS servers for webhook hostname resolution', function () {
+ InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['custom_dns_servers' => '1.1.1.1, invalid, 2606:4700:4700::1111']));
+
+ $method = new ReflectionMethod(SafeWebhookUrl::class, 'customDnsServers');
+ $method->setAccessible(true);
+
+ expect($method->invoke(new SafeWebhookUrl))
+ ->toBe(['1.1.1.1', '2606:4700:4700::1111']);
+});
+
+it('allows explicitly configured intranet webhook targets', function (string $url, array $resolvedIps, array $allowlist) {
+ InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allowed_internal_hosts' => $allowlist]));
+
+ $rule = new SafeWebhookUrl(fn (string $host): array => $resolvedIps);
+
+ $validator = Validator::make(['url' => $url], ['url' => $rule]);
+
+ expect($validator->passes())->toBeTrue("Expected configured intranet target to pass: {$url}");
+})->with([
+ 'exact .local hostname' => ['http://receiver.local/webhook', ['192.168.10.20'], ['receiver.local']],
+ 'private CIDR' => ['http://hooks.example.test/webhook', ['10.50.10.20'], ['10.50.0.0/16']],
+]);
+
+it('requires explicit localhost opt in in addition to allowlist', function () {
+ InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allowed_internal_hosts' => ['localhost']]));
+
+ $rule = new SafeWebhookUrl;
+
+ $validator = Validator::make(['url' => 'http://localhost:8080/webhook'], ['url' => $rule]);
+
+ expect($validator->fails())->toBeTrue('Expected localhost to remain blocked without explicit localhost opt in');
+
+ InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allow_localhost' => true]));
+
+ $validator = Validator::make(['url' => 'http://localhost:8080/webhook'], ['url' => $rule]);
+
+ expect($validator->passes())->toBeTrue('Expected localhost to pass only after explicit localhost opt in');
+});
+
+it('builds HTTP client options that pin resolved DNS for the request', function () {
+ InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
+ 'webhook_allowed_internal_hosts' => ['localhost'],
+ 'webhook_allow_localhost' => true,
+ ]));
+
+ $options = SafeWebhookUrl::httpClientOptions('http://localhost:8080/webhook');
+
+ expect($options['allow_redirects'])->toBeFalse();
+
+ if (defined('CURLOPT_RESOLVE')) {
+ expect($options['curl'][CURLOPT_RESOLVE])->toContain('localhost:8080:127.0.0.1');
+ }
+});
+
+it('fails closed while building HTTP options when the send-time resolution is unsafe', function () {
+ expect(fn () => SafeWebhookUrl::httpClientOptions('http://localhost:8080/webhook'))
+ ->toThrow(RuntimeException::class, 'unsafe IP address');
+});
+
+it('builds MinIO client resolve options for S3 backup uploads', function () {
+ InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
+ 'webhook_allowed_internal_hosts' => ['localhost'],
+ 'webhook_allow_localhost' => true,
+ ]));
+
+ $options = SafeWebhookUrl::minioClientResolveOptions('http://localhost:9000');
+
+ expect($options)->toContain('localhost:9000=127.0.0.1');
+});
+
+it('rejects trailing-dot hostnames to avoid DNS pinning mismatch', function () {
+ $rule = new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34']);
+
+ $validator = Validator::make(['url' => 'http://example.com./webhook'], ['url' => $rule]);
+
+ expect($validator->fails())->toBeTrue('Expected trailing-dot hostname rejection');
+
+ expect(fn () => SafeWebhookUrl::httpClientOptions('http://example.com./webhook'))
+ ->toThrow(RuntimeException::class, 'trailing dot');
+});
+
+it('rejects reserved IPv6 ranges by default', function (string $url) {
+ $rule = new SafeWebhookUrl;
+
+ $validator = Validator::make(['url' => $url], ['url' => $rule]);
+
+ expect($validator->fails())->toBeTrue("Expected reserved IPv6 rejection: {$url}");
+})->with([
+ 'documentation IPv6' => 'http://[2001:db8::1]/webhook',
+ 'IPv4/IPv6 translation prefix' => 'http://[64:ff9b::1]/webhook',
+ '6to4' => 'http://[2002::1]/webhook',
+]);
+
+it('rejects hostnames that resolve to reserved IPv6 ranges by default', function (string $resolvedIp) {
+ $rule = new SafeWebhookUrl(fn (string $host): array => [$resolvedIp]);
+
+ $validator = Validator::make(['url' => 'http://ipv6-reserved.example.test/webhook'], ['url' => $rule]);
+
+ expect($validator->fails())->toBeTrue("Expected reserved IPv6 resolution rejection: {$resolvedIp}");
+})->with([
+ '2001:db8::1',
+ '64:ff9b::1',
+ '2002::1',
+]);
+
+it('redacts webhook URLs for logs', function () {
+ expect(SafeWebhookUrl::redactedUrlForLog('https://hooks.slack.com/services/T000/B000/secret-token?foo=bar'))
+ ->toBe('https://hooks.slack.com');
+});
diff --git a/tests/Unit/SendWebhookJobTest.php b/tests/Unit/SendWebhookJobTest.php
index 688cd3bf2..dedf18e1f 100644
--- a/tests/Unit/SendWebhookJobTest.php
+++ b/tests/Unit/SendWebhookJobTest.php
@@ -2,7 +2,6 @@
use App\Jobs\SendWebhookJob;
use Illuminate\Support\Facades\Http;
-use Illuminate\Support\Facades\Log;
use Tests\TestCase;
uses(TestCase::class);
@@ -24,11 +23,6 @@ it('sends webhook to valid URLs', function () {
it('blocks webhook to loopback address', function () {
Http::fake();
- Log::shouldReceive('warning')
- ->once()
- ->withArgs(function ($message) {
- return str_contains($message, 'blocked unsafe webhook URL');
- });
$job = new SendWebhookJob(
payload: ['event' => 'test'],
@@ -42,15 +36,23 @@ it('blocks webhook to loopback address', function () {
it('blocks webhook to cloud metadata endpoint', function () {
Http::fake();
- Log::shouldReceive('warning')
- ->once()
- ->withArgs(function ($message) {
- return str_contains($message, 'blocked unsafe webhook URL');
- });
$job = new SendWebhookJob(
payload: ['event' => 'test'],
- webhookUrl: 'http://169.254.169.254/latest/meta-data/'
+ webhookUrl: 'http://169.254.169.254/'
+ );
+
+ $job->handle();
+
+ Http::assertNothingSent();
+});
+
+it('blocks webhook to IPv4-mapped IPv6 link-local endpoint', function () {
+ Http::fake();
+
+ $job = new SendWebhookJob(
+ payload: ['event' => 'test'],
+ webhookUrl: 'http://[::ffff:169.254.169.254]/'
);
$job->handle();
@@ -60,11 +62,6 @@ it('blocks webhook to cloud metadata endpoint', function () {
it('blocks webhook to localhost', function () {
Http::fake();
- Log::shouldReceive('warning')
- ->once()
- ->withArgs(function ($message) {
- return str_contains($message, 'blocked unsafe webhook URL');
- });
$job = new SendWebhookJob(
payload: ['event' => 'test'],
diff --git a/tests/Unit/ServicesControllerUrlDuplicateTest.php b/tests/Unit/ServicesControllerUrlDuplicateTest.php
new file mode 100644
index 000000000..8f3e4f9c4
--- /dev/null
+++ b/tests/Unit/ServicesControllerUrlDuplicateTest.php
@@ -0,0 +1,37 @@
+invoke($controller, $service, [
+ ['name' => 'web', 'url' => 'https://example.com/Route'],
+ ['name' => 'api', 'url' => 'HTTPS://EXAMPLE.COM/route'],
+ ], '1');
+
+ expect($result['errors'] ?? [])->toBe([
+ "Service container with 'web' not found.",
+ "Service container with 'api' not found.",
+ ]);
+});
diff --git a/tests/Unit/StorageBucketValidationRulesTest.php b/tests/Unit/StorageBucketValidationRulesTest.php
new file mode 100644
index 000000000..d8e335f96
--- /dev/null
+++ b/tests/Unit/StorageBucketValidationRulesTest.php
@@ -0,0 +1,24 @@
+setAccessible(true);
+
+ return $method->invoke($component);
+}
+
+it('uses the shared S3 bucket rule in storage create and edit forms', function (string $componentClass) {
+ $bucketRules = storageRulesFor($componentClass)['bucket'];
+
+ expect($bucketRules)->toContain('required')
+ ->and(collect($bucketRules)->contains(fn ($rule) => $rule instanceof ValidS3BucketName))->toBeTrue();
+})->with([
+ 'create form' => [Create::class],
+ 'edit form' => [Form::class],
+]);
diff --git a/tests/Unit/UpdateCoolifyTest.php b/tests/Unit/UpdateCoolifyTest.php
index b3f496d68..833fe3603 100644
--- a/tests/Unit/UpdateCoolifyTest.php
+++ b/tests/Unit/UpdateCoolifyTest.php
@@ -1,21 +1,43 @@
mockServer = Mockery::mock(Server::class)->makePartial();
- $this->mockServer->id = 0;
+uses(TestCase::class, RefreshDatabase::class);
- // Mock InstanceSettings
- $this->settings = Mockery::mock(InstanceSettings::class);
- $this->settings->is_auto_update_enabled = true;
- $this->settings->shouldReceive('save')->andReturn(true);
-});
+function updateCoolifyTestCreateRootServerAndSettings(array $settings = []): void
+{
+ Team::factory()->create(['id' => 0]);
+ Server::forceCreate([
+ 'id' => 0,
+ 'name' => 'localhost',
+ 'ip' => '127.0.0.1',
+ 'user' => 'root',
+ 'team_id' => 0,
+ 'private_key_id' => 1,
+ ]);
+ InstanceSettings::forceCreate(array_merge([
+ 'id' => 0,
+ 'is_auto_update_enabled' => true,
+ 'auto_update_frequency' => '0 0 * * *',
+ 'update_check_frequency' => '0 * * * *',
+ ], $settings));
+ Once::flush();
+}
afterEach(function () {
Mockery::close();
@@ -26,15 +48,7 @@ it('has UpdateCoolify action class', function () {
});
it('validates cache against running version before fallback', function () {
- // Mock Server::find to return our mock server
- Server::shouldReceive('find')
- ->with(0)
- ->andReturn($this->mockServer);
-
- // Mock instanceSettings
- $this->app->instance('App\Models\InstanceSettings', function () {
- return $this->settings;
- });
+ updateCoolifyTestCreateRootServerAndSettings();
// CDN fails
Http::fake(['*' => Http::response(null, 500)]);
@@ -51,7 +65,7 @@ it('validates cache against running version before fallback', function () {
try {
$action->handle(manual_update: false);
expect(false)->toBeTrue('Expected exception was not thrown');
- } catch (\Exception $e) {
+ } catch (Exception $e) {
expect($e->getMessage())->toContain('cache version');
expect($e->getMessage())->toContain('4.0.5');
expect($e->getMessage())->toContain('4.0.10');
@@ -59,15 +73,9 @@ it('validates cache against running version before fallback', function () {
});
it('uses validated cache when CDN fails and cache is newer', function () {
- // Mock Server::find
- Server::shouldReceive('find')
- ->with(0)
- ->andReturn($this->mockServer);
-
- // Mock instanceSettings
- $this->app->instance('App\Models\InstanceSettings', function () {
- return $this->settings;
- });
+ updateCoolifyTestCreateRootServerAndSettings();
+ Queue::fake();
+ config(['constants.ssh.mux_enabled' => false]);
// CDN fails
Http::fake(['*' => Http::response(null, 500)]);
@@ -78,12 +86,9 @@ it('uses validated cache when CDN fails and cache is newer', function () {
config(['constants.coolify.version' => '4.0.5']);
- // Mock the update method to prevent actual update
- $action = Mockery::mock(UpdateCoolify::class)->makePartial();
- $action->shouldReceive('update')->once();
- $action->server = $this->mockServer;
+ $action = new UpdateCoolify;
- \Illuminate\Support\Facades\Log::shouldReceive('warning')
+ Log::shouldReceive('warning')
->once()
->with('Failed to fetch fresh version from CDN, using validated cache', Mockery::type('array'));
@@ -93,16 +98,198 @@ it('uses validated cache when CDN fails and cache is newer', function () {
expect($action->latestVersion)->toBe('4.0.10');
});
-it('prevents downgrade even with manual update', function () {
- // Mock Server::find
- Server::shouldReceive('find')
- ->with(0)
- ->andReturn($this->mockServer);
+it('passes the saved registry URL to the upgrade script command', function () {
+ Queue::fake();
+ config([
+ 'app.env' => 'testing',
+ 'constants.coolify.version' => '4.0.9',
+ 'constants.coolify.helper_version' => '1.0.14',
+ 'constants.coolify.upgrade_script_url' => 'https://cdn.example.com/upgrade.sh',
+ 'constants.ssh.mux_enabled' => false,
+ ]);
- // Mock instanceSettings
- $this->app->instance('App\Models\InstanceSettings', function () {
- return $this->settings;
- });
+ updateCoolifyTestCreateRootServerAndSettings([
+ 'is_auto_update_enabled' => true,
+ 'docker_registry_url' => 'ghcr.io',
+ ]);
+
+ Http::fake([
+ '*' => Http::response([
+ 'coolify' => ['v4' => ['version' => '4.0.10']],
+ ], 200),
+ ]);
+
+ (new UpdateCoolify)->handle();
+
+ expect(Activity::query()->latest('id')->first()?->getExtraProperty('command'))->toBe(
+ "curl -fsSL https://cdn.example.com/upgrade.sh -o /data/coolify/source/upgrade.sh\n".
+ "bash /data/coolify/source/upgrade.sh '4.0.10' '1.0.14' 'ghcr.io'"
+ );
+});
+
+it('falls back to docker io for the upgrade script command when no registry is saved', function () {
+ Queue::fake();
+ config([
+ 'app.env' => 'testing',
+ 'constants.coolify.version' => '4.0.9',
+ 'constants.coolify.helper_version' => '1.0.14',
+ 'constants.coolify.registry_url' => 'ghcr.io',
+ 'constants.coolify.upgrade_script_url' => 'https://cdn.example.com/upgrade.sh',
+ 'constants.ssh.mux_enabled' => false,
+ ]);
+
+ updateCoolifyTestCreateRootServerAndSettings([
+ 'is_auto_update_enabled' => true,
+ ]);
+
+ Http::fake([
+ '*' => Http::response([
+ 'coolify' => ['v4' => ['version' => '4.0.10']],
+ ], 200),
+ ]);
+
+ (new UpdateCoolify)->handle();
+
+ expect(Activity::query()->latest('id')->first()?->getExtraProperty('command'))->toBe(
+ "curl -fsSL https://cdn.example.com/upgrade.sh -o /data/coolify/source/upgrade.sh\n".
+ "bash /data/coolify/source/upgrade.sh '4.0.10' '1.0.14' 'docker.io'"
+ );
+});
+
+it('defaults the registry setting to docker io when no registry is saved', function () {
+ config([
+ 'app.env' => 'testing',
+ 'constants.coolify.registry_url' => 'ghcr.io',
+ 'constants.coolify.self_hosted' => true,
+ ]);
+
+ updateCoolifyTestCreateRootServerAndSettings();
+
+ $rootTeam = Team::findOrFail(0);
+ $user = User::factory()->create();
+ $rootTeam->members()->attach($user->id, ['role' => 'admin']);
+
+ $this->actingAs($user);
+ session(['currentTeam' => ['id' => $rootTeam->id]]);
+
+ Livewire::test(Updates::class)
+ ->assertSet('docker_registry_url', 'docker.io');
+});
+
+it('uses the database registry for helper images when the configured helper image is default', function () {
+ config([
+ 'constants.coolify.registry_url' => 'ghcr.io',
+ 'constants.coolify.helper_image' => 'ghcr.io/coollabsio/coolify-helper',
+ ]);
+
+ updateCoolifyTestCreateRootServerAndSettings([
+ 'docker_registry_url' => 'docker.io',
+ ]);
+
+ expect(coolifyRegistryUrl())->toBe('docker.io')
+ ->and(coolifyHelperImage())->toBe('docker.io/coollabsio/coolify-helper');
+});
+
+it('preserves an explicit custom helper image override', function () {
+ config([
+ 'constants.coolify.registry_url' => 'docker.io',
+ 'constants.coolify.helper_image' => 'registry.example.com/custom/helper',
+ ]);
+
+ updateCoolifyTestCreateRootServerAndSettings([
+ 'docker_registry_url' => 'ghcr.io',
+ ]);
+
+ expect(coolifyHelperImage())->toBe('registry.example.com/custom/helper');
+});
+
+it('uses the database registry for sentinel images', function () {
+ $action = file_get_contents(app_path('Actions/Server/StartSentinel.php'));
+
+ expect($action)->toContain("\$image = coolifyRegistryUrl().'/coollabsio/sentinel:'.\$version;");
+});
+
+it('rejects invalid registry values and does not sync them', function () {
+ Process::fake();
+ config([
+ 'app.env' => 'testing',
+ 'constants.coolify.registry_url' => 'docker.io',
+ ]);
+
+ updateCoolifyTestCreateRootServerAndSettings([
+ 'is_auto_update_enabled' => true,
+ 'auto_update_frequency' => '0 0 * * *',
+ 'update_check_frequency' => '0 * * * *',
+ 'docker_registry_url' => 'docker.io',
+ ]);
+
+ $rootTeam = Team::findOrFail(0);
+ $user = User::factory()->create();
+ $rootTeam->members()->attach($user->id, ['role' => 'admin']);
+
+ $this->actingAs($user);
+ session(['currentTeam' => ['id' => $rootTeam->id]]);
+
+ Livewire::test(Updates::class)
+ ->set('docker_registry_url', 'ghcr.io; touch /tmp/pwned')
+ ->call('submit')
+ ->assertHasErrors(['docker_registry_url' => ['in']]);
+
+ expect(InstanceSettings::findOrFail(0)->docker_registry_url)->toBe('docker.io');
+ Process::assertDidntRun(fn () => true);
+});
+
+it('does not save registry changes when syncing the env file fails', function () {
+ config([
+ 'app.env' => 'testing',
+ 'constants.coolify.registry_url' => 'docker.io',
+ 'constants.coolify.self_hosted' => true,
+ ]);
+
+ updateCoolifyTestCreateRootServerAndSettings([
+ 'is_auto_update_enabled' => true,
+ 'auto_update_frequency' => '0 0 * * *',
+ 'update_check_frequency' => '0 * * * *',
+ 'docker_registry_url' => 'docker.io',
+ ]);
+
+ $rootTeam = Team::findOrFail(0);
+ $user = User::factory()->create();
+ $rootTeam->members()->attach($user->id, ['role' => 'admin']);
+
+ $this->actingAs($user);
+ session(['currentTeam' => ['id' => $rootTeam->id]]);
+
+ $component = new class extends Updates
+ {
+ protected function syncRegistryUrlToEnv(string $registryUrl): void
+ {
+ throw new RuntimeException('sync failed');
+ }
+ };
+ $component->settings = InstanceSettings::findOrFail(0);
+ $component->auto_update_frequency = '0 0 * * *';
+ $component->update_check_frequency = '0 * * * *';
+ $component->is_auto_update_enabled = true;
+ $component->docker_registry_url = 'ghcr.io';
+
+ $component->instantSave();
+
+ expect(InstanceSettings::findOrFail(0)->docker_registry_url)->toBe('docker.io');
+});
+
+it('appends registry url to env file when the key is missing', function () {
+ $component = new Updates;
+ $method = new ReflectionMethod(Updates::class, 'registryEnvSyncCommand');
+
+ expect($method->invoke($component, 'ghcr.io'))
+ ->toContain("grep -q '^REGISTRY_URL=' /data/coolify/source/.env")
+ ->toContain("sed -i 's|^REGISTRY_URL=.*|REGISTRY_URL=ghcr.io|' /data/coolify/source/.env")
+ ->toContain("printf '%s\\n' 'REGISTRY_URL=ghcr.io' >> /data/coolify/source/.env");
+});
+
+it('prevents downgrade even with manual update', function () {
+ updateCoolifyTestCreateRootServerAndSettings();
// CDN returns older version
Http::fake([
@@ -116,7 +303,7 @@ it('prevents downgrade even with manual update', function () {
$action = new UpdateCoolify;
- \Illuminate\Support\Facades\Log::shouldReceive('error')
+ Log::shouldReceive('error')
->once()
->with('Downgrade prevented', Mockery::type('array'));
@@ -124,7 +311,7 @@ it('prevents downgrade even with manual update', function () {
try {
$action->handle(manual_update: true);
expect(false)->toBeTrue('Expected exception was not thrown');
- } catch (\Exception $e) {
+ } catch (Exception $e) {
expect($e->getMessage())->toContain('Cannot downgrade');
expect($e->getMessage())->toContain('4.0.10');
expect($e->getMessage())->toContain('4.0.0');
diff --git a/tests/Unit/UpgradePostgresScriptTest.php b/tests/Unit/UpgradePostgresScriptTest.php
index e0b2bf8b7..6fe028ddb 100644
--- a/tests/Unit/UpgradePostgresScriptTest.php
+++ b/tests/Unit/UpgradePostgresScriptTest.php
@@ -56,6 +56,38 @@ it('generates a dedicated flux laravel api token during install and upgrade', fu
'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
+it('uses the selected registry url when extracting upgrade images', function (string $path) {
+ $script = file_get_contents(getcwd().'/'.$path);
+
+ expect($script)->toContain('IMAGES=$(REGISTRY_URL=${REGISTRY_URL} LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file "$ENV_FILE" $COMPOSE_FILES config --images');
+})->with([
+ 'stable upgrade' => 'scripts/upgrade.sh',
+ 'nightly upgrade' => 'other/nightly/upgrade.sh',
+]);
+
+it('persists the selected registry url during upgrades', function (string $path) {
+ $script = file_get_contents(getcwd().'/'.$path);
+
+ expect($script)->toContain('set_env_var "REGISTRY_URL" "$REGISTRY_URL"');
+})->with([
+ 'stable upgrade' => 'scripts/upgrade.sh',
+ 'nightly upgrade' => 'other/nightly/upgrade.sh',
+]);
+
+it('uses the existing env registry url when old callers do not pass a registry argument', function (string $path) {
+ $script = file_get_contents(getcwd().'/'.$path);
+
+ expect($script)
+ ->toContain('if [ -n "${3+x}" ]; then')
+ ->toContain('REGISTRY_URL="$3"')
+ ->toContain('elif [ -f "$ENV_FILE" ] && grep -q "^REGISTRY_URL=" "$ENV_FILE"; then')
+ ->toContain("REGISTRY_URL=$(grep \"^REGISTRY_URL=\" \"\$ENV_FILE\" | cut -d '=' -f2- | head -n1)")
+ ->toContain('REGISTRY_URL="docker.io"');
+})->with([
+ 'stable upgrade' => 'scripts/upgrade.sh',
+ 'nightly upgrade' => 'other/nightly/upgrade.sh',
+]);
+
it('keeps postgres upgrade compose override in future upgrade compose commands', function (string $path) {
$script = file_get_contents(getcwd().'/'.$path);
diff --git a/tests/Unit/ValidS3BucketNameTest.php b/tests/Unit/ValidS3BucketNameTest.php
new file mode 100644
index 000000000..1a38c74c7
--- /dev/null
+++ b/tests/Unit/ValidS3BucketNameTest.php
@@ -0,0 +1,48 @@
+validate('bucket', $bucket, function () use (&$failed) {
+ $failed = true;
+ });
+
+ return ! $failed;
+}
+
+it('accepts valid s3 bucket names', function (string $bucket) {
+ expect(validS3BucketNameRulePasses($bucket))->toBeTrue("Expected accepted: {$bucket}");
+})->with([
+ 'short' => ['abc'],
+ 'simple' => ['coolify-backups'],
+ 'dots' => ['coolify.backups'],
+ 'digits' => ['backup-123'],
+ 'max length' => [str_repeat('a', 63)],
+]);
+
+it('rejects invalid s3 bucket names and injection payloads', function (string $bucket) {
+ expect(validS3BucketNameRulePasses($bucket))->toBeFalse("Expected rejected: {$bucket}");
+})->with([
+ 'too short' => ['ab'],
+ 'too long' => [str_repeat('a', 64)],
+ 'uppercase' => ['CoolifyBackups'],
+ 'underscore' => ['coolify_backups'],
+ 'leading hyphen' => ['-coolify-backups'],
+ 'trailing hyphen' => ['coolify-backups-'],
+ 'leading dot' => ['.coolify-backups'],
+ 'trailing dot' => ['coolify-backups.'],
+ 'consecutive dots' => ['coolify..backups'],
+ 'dot hyphen' => ['coolify.-backups'],
+ 'hyphen dot' => ['coolify-.backups'],
+ 'ipv4 address' => ['192.168.1.1'],
+ 'semicolon injection' => ['lab; id; #'],
+ 'command substitution' => ['lab$(id)'],
+ 'backticks' => ['lab`id`'],
+ 'pipe' => ['lab|id'],
+ 'ampersand' => ['lab&id'],
+ 'space' => ['lab bucket'],
+ 'newline' => ["lab\nid"],
+]);
diff --git a/tests/Unit/ValidationPatternsTest.php b/tests/Unit/ValidationPatternsTest.php
index 2b5763177..cb9d0144d 100644
--- a/tests/Unit/ValidationPatternsTest.php
+++ b/tests/Unit/ValidationPatternsTest.php
@@ -180,3 +180,10 @@ it('normalizes environment variable keys before model validation', function () {
expect($environmentVariable->key)->toBe('APP_ENV');
});
+
+it('normalizes application domain scheme and host without lowercasing path query or fragment', function () {
+ $domains = ' HTTPS://EXAMPLE.COM/MixedCase/Path?Token=ABC#Fragment, http://Sub.EXAMPLE.com/Api/V1 ';
+
+ expect(ValidationPatterns::normalizeApplicationDomains($domains))
+ ->toBe('https://example.com/MixedCase/Path?Token=ABC#Fragment,http://sub.example.com/Api/V1');
+});