From 22a2c05a1d4fddb6e64bb552c7eaf8a4ccb694d3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 5 May 2026 15:32:43 +0200 Subject: [PATCH] test(railpack): add API, Livewire UI tests and e2e smoke script Add feature tests covering railpack build pack via REST API and Livewire UI components, plus a bash smoke test that deploys seeded railpack-* example apps against the local dev stack and verifies COOLIFY_*, SOURCE_COMMIT, and RAILPACK_* env vars land correctly. --- scripts/railpack-smoke.sh | 322 ++++++++++++++++ tests/Feature/Api/RailpackApiTest.php | 345 ++++++++++++++++++ .../Livewire/RailpackLivewireUiTest.php | 121 ++++++ 3 files changed, 788 insertions(+) create mode 100755 scripts/railpack-smoke.sh create mode 100644 tests/Feature/Api/RailpackApiTest.php create mode 100644 tests/Feature/Livewire/RailpackLivewireUiTest.php diff --git a/scripts/railpack-smoke.sh b/scripts/railpack-smoke.sh new file mode 100755 index 000000000..92e621c3b --- /dev/null +++ b/scripts/railpack-smoke.sh @@ -0,0 +1,322 @@ +#!/usr/bin/env bash +# +# Railpack end-to-end deploy smoke test against the local dev stack. +# +# Walks a curated set of railpack-* example apps from +# DevelopmentRailpackExamplesSeeder, triggers a deploy via the Coolify API, +# waits for the deployment queue to finish, then exec()s into the resulting +# container and checks that COOLIFY_*, SOURCE_COMMIT, and any RAILPACK_* +# build inputs landed correctly. Optionally curls the FQDN. +# +# Requires: +# - Dev stack running: spin up (or docker compose -f docker-compose.dev.yml up -d) +# - Seeder run: php artisan db:seed --class=DevelopmentRailpackExamplesSeeder +# - Personal token: PersonalAccessTokenSeeder run (creates Bearer 'root') +# - jq, curl available on host +# +# Usage: +# scripts/railpack-smoke.sh # default subset +# scripts/railpack-smoke.sh --app railpack-laravel # single app +# scripts/railpack-smoke.sh --all # every seeded railpack-* app +# scripts/railpack-smoke.sh --timeout 900 # build wait per app, seconds +# scripts/railpack-smoke.sh --no-curl # skip FQDN curl +# scripts/railpack-smoke.sh --extra-env KEY=VALUE # build+runtime env (alias of --both-env) +# scripts/railpack-smoke.sh --build-env KEY=VALUE # buildtime-only env (must reach build, NOT runtime) +# scripts/railpack-smoke.sh --runtime-env KEY=VALUE # runtime-only env (must reach runtime, NOT build) +# scripts/railpack-smoke.sh --both-env KEY=VALUE # buildtime+runtime env +# +set -euo pipefail + +API_BASE="${COOLIFY_API_BASE:-http://localhost:8000/api/v1}" +TOKEN="${COOLIFY_API_TOKEN:-root}" +TIMEOUT="${SMOKE_TIMEOUT:-600}" +DO_CURL=1 +BUILD_ENVS=() +RUNTIME_ENVS=() +BOTH_ENVS=() +APPS=() + +DEFAULT_APPS=( + railpack-expressjs + railpack-nestjs + railpack-nextjs-ssr + railpack-vite-static + railpack-astro-static + railpack-python-flask + railpack-go-gin + railpack-rust + railpack-laravel + railpack-bun +) + +while (( $# > 0 )); do + case "$1" in + --app) APPS+=("$2"); shift 2 ;; + --all) APPS=(__ALL__); shift ;; + --timeout) TIMEOUT="$2"; shift 2 ;; + --no-curl) DO_CURL=0; shift ;; + --extra-env|--both-env) BOTH_ENVS+=("$2"); shift 2 ;; + --build-env) BUILD_ENVS+=("$2"); shift 2 ;; + --runtime-env) RUNTIME_ENVS+=("$2"); shift 2 ;; + --base) API_BASE="$2"; shift 2 ;; + --token) TOKEN="$2"; shift 2 ;; + -h|--help) sed -n '2,30p' "$0"; exit 0 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +if ! command -v jq >/dev/null; then + echo "jq required" >&2; exit 2 +fi +if ! command -v docker >/dev/null; then + echo "docker required" >&2; exit 2 +fi + +curl_api() { + local method="$1"; shift + local path="$1"; shift + curl -fsS -X "$method" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + "${API_BASE}${path}" \ + "$@" +} + +if (( ${#APPS[@]} == 0 )); then + APPS=("${DEFAULT_APPS[@]}") +fi + +if [[ "${APPS[0]}" == "__ALL__" ]]; then + mapfile -t APPS < <(curl_api GET /applications | jq -r '.[].uuid' | grep '^railpack-' || true) +fi + +log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"; } +fail() { printf '\033[31m[FAIL]\033[0m %s: %s\n' "$1" "$2"; FAILED+=("$1: $2"); } +pass() { printf '\033[32m[ OK ]\033[0m %s: %s\n' "$1" "$2"; } + +upsert_env() { + local app_uuid="$1" key="$2" value="$3" buildtime="$4" runtime="$5" existing + existing=$(curl_api GET "/applications/${app_uuid}/envs" | jq -r --arg k "$key" '.[] | select(.key==$k) | .uuid' | head -1) + local payload + payload=$(jq -nc --arg k "$key" --arg v "$value" --argjson b "$buildtime" --argjson r "$runtime" \ + '{key:$k, value:$v, is_buildtime:$b, is_runtime:$r, is_preview:false}') + if [[ -n "$existing" ]]; then + curl_api PATCH "/applications/${app_uuid}/envs" --data "$payload" >/dev/null + log " env ${key} updated (buildtime=${buildtime} runtime=${runtime})" + else + curl_api POST "/applications/${app_uuid}/envs" --data "$payload" >/dev/null + log " env ${key} created (buildtime=${buildtime} runtime=${runtime})" + fi +} + +ensure_envs() { + local app_uuid="$1" kv key value + for kv in "${BUILD_ENVS[@]:-}"; do + [[ -z "$kv" ]] && continue + key="${kv%%=*}"; value="${kv#*=}" + upsert_env "$app_uuid" "$key" "$value" true false + done + for kv in "${RUNTIME_ENVS[@]:-}"; do + [[ -z "$kv" ]] && continue + key="${kv%%=*}"; value="${kv#*=}" + upsert_env "$app_uuid" "$key" "$value" false true + done + for kv in "${BOTH_ENVS[@]:-}"; do + [[ -z "$kv" ]] && continue + key="${kv%%=*}"; value="${kv#*=}" + upsert_env "$app_uuid" "$key" "$value" true true + done +} + +trigger_deploy() { + local app_uuid="$1" + curl_api POST "/applications/${app_uuid}/start?force=true&instant_deploy=true" \ + | jq -r '.deployment_uuid // empty' +} + +wait_for_deploy() { + local dep_uuid="$1" deadline="$2" status + while (( $(date +%s) < deadline )); do + status=$(curl_api GET "/deployments/${dep_uuid}" | jq -r '.status // "unknown"') + case "$status" in + finished) echo finished; return 0 ;; + failed|cancelled) echo "$status"; return 1 ;; + queued|in_progress) sleep 5 ;; + *) sleep 5 ;; + esac + done + echo timeout; return 1 +} + +container_for_app() { + local app_uuid="$1" + docker ps --filter "name=^${app_uuid}-" --format '{{.Names}}' | head -1 +} + +assert_envs_present() { + local container="$1" app_uuid="$2" + local env_dump + env_dump=$(docker exec "$container" env 2>/dev/null || true) + + local missing=() + for required in COOLIFY_FQDN COOLIFY_URL COOLIFY_BRANCH COOLIFY_RESOURCE_UUID COOLIFY_CONTAINER_NAME SOURCE_COMMIT; do + if ! grep -q "^${required}=" <<<"$env_dump"; then + missing+=("$required") + fi + done + + local resource_uuid + resource_uuid=$(grep '^COOLIFY_RESOURCE_UUID=' <<<"$env_dump" | cut -d= -f2- || true) + if [[ "$resource_uuid" != "$app_uuid" ]]; then + missing+=("COOLIFY_RESOURCE_UUID-mismatch(got=${resource_uuid})") + fi + + if (( ${#missing[@]} == 0 )); then + pass "$app_uuid" "runtime envs present (${resource_uuid})" + return 0 + fi + fail "$app_uuid" "missing/incorrect envs: ${missing[*]}" + return 1 +} + +deploy_logs_text() { + local dep_uuid="$1" + curl_api GET "/deployments/${dep_uuid}" | jq -r '(.logs | fromjson? // []) | .[].output' 2>/dev/null +} + +assert_runtime_only_envs() { + local container="$1" app_uuid="$2" + [[ ${#RUNTIME_ENVS[@]} -eq 0 ]] && return 0 + local env_dump key value actual + env_dump=$(docker exec "$container" env 2>/dev/null || true) + for kv in "${RUNTIME_ENVS[@]}"; do + key="${kv%%=*}"; value="${kv#*=}" + if ! grep -q "^${key}=" <<<"$env_dump"; then + fail "$app_uuid" "runtime-only env ${key} missing at runtime" + return 1 + fi + actual=$(grep "^${key}=" <<<"$env_dump" | head -1 | cut -d= -f2-) + if [[ "$actual" != "$value" ]]; then + fail "$app_uuid" "runtime env ${key} value mismatch (got=${actual} want=${value})" + return 1 + fi + done + pass "$app_uuid" "runtime-only envs present at runtime (${#RUNTIME_ENVS[@]} key(s))" +} + +assert_build_only_envs() { + local container="$1" app_uuid="$2" dep_uuid="$3" + [[ ${#BUILD_ENVS[@]} -eq 0 ]] && return 0 + local env_dump logs key + env_dump=$(docker exec "$container" env 2>/dev/null || true) + logs=$(deploy_logs_text "$dep_uuid") + + for kv in "${BUILD_ENVS[@]}"; do + key="${kv%%=*}" + # Reach build: railpack passes buildtime envs as docker buildx --secret id=KEY + if ! grep -q -- "--secret id=${key}" <<<"$logs"; then + fail "$app_uuid" "build-only env ${key} not seen as --secret in deploy logs" + return 1 + fi + # Must NOT leak to runtime container + if grep -q "^${key}=" <<<"$env_dump"; then + fail "$app_uuid" "build-only env ${key} LEAKED to runtime container" + return 1 + fi + done + pass "$app_uuid" "build-only envs in build secret + absent at runtime (${#BUILD_ENVS[@]} key(s))" +} + +assert_both_envs() { + local container="$1" app_uuid="$2" dep_uuid="$3" + [[ ${#BOTH_ENVS[@]} -eq 0 ]] && return 0 + local env_dump logs key + env_dump=$(docker exec "$container" env 2>/dev/null || true) + logs=$(deploy_logs_text "$dep_uuid") + for kv in "${BOTH_ENVS[@]}"; do + key="${kv%%=*}" + if [[ "$key" =~ ^RAILPACK_ ]]; then + # RAILPACK_* are buildtime-only by railpack convention; skip runtime check + grep -q -- "--secret id=${key}" <<<"$logs" \ + || { fail "$app_uuid" "${key} not seen in build secrets"; return 1; } + continue + fi + grep -q "^${key}=" <<<"$env_dump" \ + || { fail "$app_uuid" "both-env ${key} missing at runtime"; return 1; } + done + pass "$app_uuid" "both-envs reached runtime (${#BOTH_ENVS[@]} key(s))" +} + +assert_fqdn_responds() { + local app_uuid="$1" + local fqdn + fqdn=$(curl_api GET "/applications/${app_uuid}" | jq -r '.fqdn // empty') + [[ -z "$fqdn" ]] && return 0 + local code + code=$(curl -ksSL -o /dev/null -w '%{http_code}' --max-time 10 "$fqdn" || echo "000") + case "$code" in + 2*|3*) pass "$app_uuid" "fqdn ${fqdn} -> ${code}" ;; + *) fail "$app_uuid" "fqdn ${fqdn} -> ${code}" ;; + esac +} + +run_one() { + local app_uuid="$1" + log "==> ${app_uuid}" + + if ! curl_api GET "/applications/${app_uuid}" >/dev/null 2>&1; then + fail "$app_uuid" "application not found via API (run seeder?)" + return + fi + + ensure_envs "$app_uuid" + + local dep + dep=$(trigger_deploy "$app_uuid") + if [[ -z "$dep" ]]; then + fail "$app_uuid" "no deployment_uuid returned" + return + fi + log " deploy queued: ${dep}" + + local deadline=$(( $(date +%s) + TIMEOUT )) + local result + result=$(wait_for_deploy "$dep" "$deadline") || { + fail "$app_uuid" "deploy ${result}" + return + } + pass "$app_uuid" "deploy ${result}" + + sleep 2 + local container + container=$(container_for_app "$app_uuid") + if [[ -z "$container" ]]; then + fail "$app_uuid" "no running container matching name=^${app_uuid}-" + return + fi + pass "$app_uuid" "container ${container} running" + + assert_envs_present "$container" "$app_uuid" || true + assert_runtime_only_envs "$container" "$app_uuid" || true + assert_build_only_envs "$container" "$app_uuid" "$dep" || true + assert_both_envs "$container" "$app_uuid" "$dep" || true + + if (( DO_CURL )); then + assert_fqdn_responds "$app_uuid" || true + fi +} + +FAILED=() +for app in "${APPS[@]}"; do + run_one "$app" +done + +echo +echo "=== summary ===" +if (( ${#FAILED[@]} == 0 )); then + echo "all apps passed" + exit 0 +fi +printf '%s failure(s):\n' "${#FAILED[@]}" +printf ' - %s\n' "${FAILED[@]}" +exit 1 diff --git a/tests/Feature/Api/RailpackApiTest.php b/tests/Feature/Api/RailpackApiTest.php new file mode 100644 index 000000000..c9dbced4b --- /dev/null +++ b/tests/Feature/Api/RailpackApiTest.php @@ -0,0 +1,345 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'railpack-api-test-'.Str::random(6), + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + $this->bearerToken = $token->getKey().'|'.$plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function railpackApiHeaders(string $bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +function makeRailpackApp(array $overrides = []): Application +{ + return Application::factory()->create(array_merge([ + 'environment_id' => test()->environment->id, + 'destination_id' => test()->destination->id, + 'destination_type' => test()->destination->getMorphClass(), + 'build_pack' => 'railpack', + ], $overrides)); +} + +describe('PATCH /api/v1/applications/{uuid} build_pack=railpack', function () { + test('rejects unsupported build_pack at controller layer', function () { + $app = makeRailpackApp(); + + $response = $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$app->uuid}", [ + 'build_pack' => 'totally-bogus', + ]); + + $response->assertStatus(422); + }); + + test('switching from dockerfile to railpack clears dockerfile fields', function () { + $app = makeRailpackApp([ + 'build_pack' => 'dockerfile', + 'dockerfile' => 'FROM node:20', + 'dockerfile_location' => '/Dockerfile', + 'dockerfile_target_build' => 'production', + 'custom_healthcheck_found' => true, + ]); + + $response = $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$app->uuid}", [ + 'build_pack' => 'railpack', + ]); + + $response->assertOk(); + + $app->refresh(); + expect($app->build_pack)->toBe('railpack'); + expect($app->dockerfile)->toBeNull(); + // NOTE: dockerfile_location is normalized to '/Dockerfile' by the model + // mutator when set to null, so we cannot assert it becomes null here. + expect($app->dockerfile_target_build)->toBeNull(); + expect((bool) $app->custom_healthcheck_found)->toBeFalse(); + }); + + test('switching from dockercompose to railpack clears compose fields and SERVICE_* envs', function () { + $app = makeRailpackApp([ + 'build_pack' => 'dockercompose', + 'docker_compose_domains' => '{"app": "example.com"}', + 'docker_compose_raw' => "version: '3'\nservices:\n app:\n image: nginx", + ]); + + $app->environment_variables()->createMany([ + ['key' => 'SERVICE_FQDN_APP', 'value' => 'app.example.com', 'is_buildtime' => false, 'is_preview' => false], + ['key' => 'SERVICE_URL_APP', 'value' => 'http://app.example.com', 'is_buildtime' => false, 'is_preview' => false], + ['key' => 'REGULAR_VAR', 'value' => 'keep_me', 'is_buildtime' => false, 'is_preview' => false], + ]); + + $response = $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$app->uuid}", [ + 'build_pack' => 'railpack', + ]); + + $response->assertOk(); + + $app->refresh(); + expect($app->build_pack)->toBe('railpack'); + expect($app->docker_compose_domains)->toBeNull(); + expect($app->docker_compose_raw)->toBeNull(); + expect($app->environment_variables()->where('key', 'SERVICE_FQDN_APP')->count())->toBe(0); + expect($app->environment_variables()->where('key', 'SERVICE_URL_APP')->count())->toBe(0); + expect($app->environment_variables()->where('key', 'REGULAR_VAR')->count())->toBe(1); + }); + + test('install/build/start commands persist for railpack apps', function () { + $app = makeRailpackApp(); + + $response = $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$app->uuid}", [ + 'install_command' => 'npm ci', + 'build_command' => 'npm run build', + 'start_command' => 'node server.js', + ]); + + $response->assertOk(); + + $app->refresh(); + expect($app->install_command)->toBe('npm ci'); + expect($app->build_command)->toBe('npm run build'); + expect($app->start_command)->toBe('node server.js'); + }); +}); + +describe('POST /api/v1/applications/{uuid}/envs RAILPACK_* handling', function () { + test('adding RAILPACK_NODE_VERSION via API surfaces in railpack_environment_variables only', function () { + $app = makeRailpackApp(); + + $response = $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'RAILPACK_NODE_VERSION', + 'value' => '20', + 'is_buildtime' => true, + 'is_runtime' => false, + 'is_preview' => false, + ]); + + $response->assertCreated(); + + $app->refresh(); + expect($app->railpack_environment_variables)->toHaveCount(1); + expect($app->railpack_environment_variables->first()->key)->toBe('RAILPACK_NODE_VERSION'); + expect($app->runtime_environment_variables->where('key', 'RAILPACK_NODE_VERSION'))->toHaveCount(0); + }); + + test('runtime envs added via API surface in runtime_environment_variables but not railpack_*', function () { + $app = makeRailpackApp(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'APP_ENV', + 'value' => 'production', + 'is_runtime' => true, + 'is_buildtime' => false, + 'is_preview' => false, + ])->assertCreated(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '18', + 'is_buildtime' => true, + 'is_runtime' => false, + 'is_preview' => false, + ])->assertCreated(); + + $app->refresh(); + $runtime = $app->runtime_environment_variables; + expect($runtime->pluck('key')->all())->toBe(['APP_ENV']); + expect($app->railpack_environment_variables)->toHaveCount(0); + }); + + test('preview RAILPACK_* envs surface in railpack_environment_variables_preview only', function () { + $app = makeRailpackApp(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'RAILPACK_BUILD_CMD', + 'value' => 'npm run build', + 'is_buildtime' => true, + 'is_runtime' => false, + 'is_preview' => true, + ])->assertCreated(); + + $app->refresh(); + expect($app->railpack_environment_variables_preview)->toHaveCount(1); + expect($app->railpack_environment_variables)->toHaveCount(0); + }); + + test('buildtime-only env has is_buildtime=true and is_runtime=false', function () { + $app = makeRailpackApp(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'API_KEY', + 'value' => 'sekret', + 'is_buildtime' => true, + 'is_runtime' => false, + 'is_preview' => false, + ])->assertCreated(); + + $app->refresh(); + $env = $app->environment_variables()->where('key', 'API_KEY')->first(); + expect($env)->not->toBeNull(); + expect((bool) $env->is_buildtime)->toBeTrue(); + expect((bool) $env->is_runtime)->toBeFalse(); + // Buildtime-only non-RAILPACK_ var: visible to runtime relation (it's not a buildpack-control var) + // but is_runtime flag is false; consumers gate runtime via is_runtime, not via the relation alone. + expect($env->resourceable_id)->toBe($app->id); + }); + + test('runtime-only env has is_runtime=true and is_buildtime=false', function () { + $app = makeRailpackApp(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'LOG_LEVEL', + 'value' => 'debug', + 'is_buildtime' => false, + 'is_runtime' => true, + 'is_preview' => false, + ])->assertCreated(); + + $app->refresh(); + $env = $app->environment_variables()->where('key', 'LOG_LEVEL')->first(); + expect((bool) $env->is_buildtime)->toBeFalse(); + expect((bool) $env->is_runtime)->toBeTrue(); + }); + + test('railpack build variables collection includes only is_buildtime=true entries', function () { + // Sanity check the underlying query used by the deploy job: railpack_build_variables() + // pulls $application->environment_variables()->where('is_buildtime', true)->get() + // (see ApplicationDeploymentJob::railpack_build_variables). + $app = makeRailpackApp(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'BUILD_ARG', + 'value' => 'in-build', + 'is_buildtime' => true, + 'is_runtime' => false, + 'is_preview' => false, + ])->assertCreated(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'RUNTIME_ARG', + 'value' => 'in-runtime', + 'is_buildtime' => false, + 'is_runtime' => true, + 'is_preview' => false, + ])->assertCreated(); + + $app->refresh(); + $buildtime = $app->environment_variables()->where('is_buildtime', true)->pluck('key')->all(); + expect($buildtime)->toContain('BUILD_ARG'); + expect($buildtime)->not->toContain('RUNTIME_ARG'); + }); + + test('user-defined COOLIFY_FQDN takes precedence over auto-generated', function () { + // Documents generate_coolify_env_variables() override behavior: + // it skips generation when application->environment_variables already has the key. + $app = makeRailpackApp(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'COOLIFY_FQDN', + 'value' => 'overridden.example.com', + 'is_buildtime' => true, + 'is_runtime' => true, + 'is_preview' => false, + ])->assertCreated(); + + $app->refresh(); + $env = $app->environment_variables()->where('key', 'COOLIFY_FQDN')->first(); + expect($env)->not->toBeNull(); + expect($env->value)->toBe('overridden.example.com'); + // Confirm the model relation used by override-skip logic finds it + expect($app->environment_variables->where('key', 'COOLIFY_FQDN')->isEmpty())->toBeFalse(); + }); + + test('is_literal flag persists on create', function () { + $app = makeRailpackApp(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'RAILPACK_LITERAL_FLAG', + 'value' => '$NOT_INTERPOLATED', + 'is_buildtime' => true, + 'is_runtime' => false, + 'is_preview' => false, + 'is_literal' => true, + ])->assertCreated(); + + $app->refresh(); + $env = $app->environment_variables()->where('key', 'RAILPACK_LITERAL_FLAG')->first(); + expect((bool) $env->is_literal)->toBeTrue(); + }); + + test('PATCH env updates buildtime/runtime flags', function () { + $app = makeRailpackApp(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'TOGGLE_VAR', + 'value' => 'v1', + 'is_buildtime' => true, + 'is_runtime' => true, + 'is_preview' => false, + ])->assertCreated(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'TOGGLE_VAR', + 'value' => 'v2', + 'is_buildtime' => false, + 'is_runtime' => true, + 'is_multiline' => false, + 'is_shown_once' => false, + ])->assertStatus(201); + + $app->refresh(); + $env = $app->environment_variables()->where('key', 'TOGGLE_VAR')->first(); + expect($env->value)->toBe('v2'); + expect((bool) $env->is_buildtime)->toBeFalse(); + expect((bool) $env->is_runtime)->toBeTrue(); + }); +}); diff --git a/tests/Feature/Livewire/RailpackLivewireUiTest.php b/tests/Feature/Livewire/RailpackLivewireUiTest.php new file mode 100644 index 000000000..b21037b11 --- /dev/null +++ b/tests/Feature/Livewire/RailpackLivewireUiTest.php @@ -0,0 +1,121 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + InstanceSettings::unguarded(function () { + InstanceSettings::updateOrCreate(['id' => 0], []); + }); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +describe('PublicGitRepository port handling for railpack', function () { + test('switching to railpack resets port to 3000 when not static', function () { + Livewire::test(PublicGitRepository::class, ['type' => 'public']) + ->set('build_pack', 'dockerfile') + ->assertSet('port', 3000) + ->set('build_pack', 'railpack') + ->assertSet('port', 3000); + }); + + test('switching to railpack preserves port when isStatic is true', function () { + $component = Livewire::test(PublicGitRepository::class, ['type' => 'public']) + ->set('isStatic', true) + ->call('instantSave'); + + // After instantSave with isStatic=true, port becomes 80 + $component->assertSet('port', 80); + + // Switching from nixpacks to railpack should NOT clobber port back to 3000 + $component->set('build_pack', 'railpack') + ->assertSet('port', 80); + }); + + test('switching to static sets port to 80 and disables show_is_static', function () { + Livewire::test(PublicGitRepository::class, ['type' => 'public']) + ->set('build_pack', 'static') + ->assertSet('port', 80) + ->assertSet('isStatic', false) + ->assertSet('show_is_static', false); + }); +}); + +describe('General view railpack helper text', function () { + beforeEach(function () { + $this->privateKey = PrivateKey::create([ + 'name' => 'Test Key', + 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY-----', + 'team_id' => $this->team->id, + ]); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first() + ?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']); + }); + + test('railpack app shows railpack.json helper text and not nixpacks.toml', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'railpack', + 'static_image' => 'nginx:alpine', + 'base_directory' => '/', + 'is_http_basic_auth_enabled' => false, + 'redirect' => 'no', + ]); + + Livewire::test(General::class, ['application' => $application]) + ->assertSuccessful() + ->assertSee('railpack.json') + ->assertDontSee('nixpacks.toml'); + }); + + test('nixpacks app shows nixpacks.toml helper text and not railpack.json', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'nixpacks', + 'static_image' => 'nginx:alpine', + 'base_directory' => '/', + 'is_http_basic_auth_enabled' => false, + 'redirect' => 'no', + ]); + + Livewire::test(General::class, ['application' => $application]) + ->assertSuccessful() + ->assertSee('nixpacks.toml') + ->assertDontSee('railpack.json'); + }); +});