diff --git a/.github/workflows/coolify-next-build.yml b/.github/workflows/coolify-next-build.yml new file mode 100644 index 0000000000..9985edb411 --- /dev/null +++ b/.github/workflows/coolify-next-build.yml @@ -0,0 +1,152 @@ +name: Build Coolify Next + +on: + push: + branches: [next] + paths-ignore: + - .github/workflows/coolify-helper.yml + - .github/workflows/coolify-helper-next.yml + - .github/workflows/coolify-realtime.yml + - .github/workflows/coolify-realtime-next.yml + - .github/workflows/pr-quality.yaml + - docker/coolify-helper/Dockerfile + - docker/coolify-realtime/Dockerfile + - docker/testing-host/Dockerfile + - templates/** + - CHANGELOG.md + +permissions: + contents: read + packages: write + +concurrency: + group: coolify-next-build + cancel-in-progress: false + +env: + GITHUB_REGISTRY: ghcr.io + DOCKER_REGISTRY: docker.io + IMAGE_NAME: coollabsio/coolify + +jobs: + prepare: + runs-on: ubuntu-24.04 + outputs: + rc_version: ${{ steps.version.outputs.rc_version }} + short_sha: ${{ steps.version.outputs.short_sha }} + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: Resolve next version + id: version + run: | + RC_VERSION=$(jq -r '.coolify.nightly.version' versions.json) + if [[ ! "${RC_VERSION}" =~ ^[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + echo "Invalid next RC version: ${RC_VERSION}" + exit 1 + fi + + SHORT_SHA="${GITHUB_SHA::7}" + VERSION="${RC_VERSION}.${SHORT_SHA}" + echo "rc_version=${RC_VERSION}" >> "$GITHUB_OUTPUT" + echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + build: + needs: prepare + strategy: + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + - arch: aarch64 + platform: linux/aarch64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - uses: docker/setup-buildx-action@v3 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push next image (${{ matrix.arch }}) + uses: docker/build-push-action@v6 + with: + context: . + file: docker/production/Dockerfile + platforms: ${{ matrix.platform }} + push: true + build-args: | + COOLIFY_VERSION=${{ needs.prepare.outputs.version }} + tags: | + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next-build-${{ needs.prepare.outputs.short_sha }}-${{ matrix.arch }} + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next-build-${{ needs.prepare.outputs.short_sha }}-${{ matrix.arch }} + + publish: + needs: [prepare, build] + runs-on: ubuntu-24.04 + steps: + - uses: docker/setup-buildx-action@v3 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Publish next manifest on ${{ env.GITHUB_REGISTRY }} + env: + REGISTRY: ${{ env.GITHUB_REGISTRY }} + SHA: ${{ needs.prepare.outputs.short_sha }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + IMAGE="${REGISTRY}/${IMAGE_NAME}" + SOURCE="next-build-${SHA}" + docker buildx imagetools create \ + "${IMAGE}:${SOURCE}-amd64" \ + "${IMAGE}:${SOURCE}-aarch64" \ + --tag "${IMAGE}:sha-${SHA}" \ + --tag "${IMAGE}:${VERSION}" \ + --tag "${IMAGE}:next" + + - name: Publish next manifest on ${{ env.DOCKER_REGISTRY }} + env: + REGISTRY: ${{ env.DOCKER_REGISTRY }} + SHA: ${{ needs.prepare.outputs.short_sha }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + IMAGE="${REGISTRY}/${IMAGE_NAME}" + SOURCE="next-build-${SHA}" + docker buildx imagetools create \ + "${IMAGE}:${SOURCE}-amd64" \ + "${IMAGE}:${SOURCE}-aarch64" \ + --tag "${IMAGE}:sha-${SHA}" \ + --tag "${IMAGE}:${VERSION}" \ + --tag "${IMAGE}:next" diff --git a/.github/workflows/coolify-rc-release.yml b/.github/workflows/coolify-rc-release.yml new file mode 100644 index 0000000000..d7f4dc830f --- /dev/null +++ b/.github/workflows/coolify-rc-release.yml @@ -0,0 +1,304 @@ +name: Release Coolify RC +run-name: ${{ inputs.tag }} + +on: + workflow_dispatch: + inputs: + tag: + description: Existing draft prerelease tag (for example, v4.4-rc.1) + required: true + type: string + +permissions: {} + +concurrency: + group: coolify-rc-release + cancel-in-progress: false + +env: + GITHUB_REGISTRY: ghcr.io + DOCKER_REGISTRY: docker.io + IMAGE_NAME: coollabsio/coolify + +jobs: + validate: + runs-on: ubuntu-24.04 + permissions: + contents: write + outputs: + release_id: ${{ steps.draft.outputs.release_id }} + version: ${{ steps.version.outputs.version }} + steps: + - name: Reject releases outside next + if: ${{ github.ref != 'refs/heads/next' }} + run: | + echo "RC releases must run from the next branch, not ${{ github.ref }}." + exit 1 + + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Validate version + id: version + env: + TAG_NAME: ${{ inputs.tag }} + run: | + if [[ ! "${TAG_NAME}" =~ ^v[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + echo "Unsupported RC tag: ${TAG_NAME}" + exit 1 + fi + + VERSION="${TAG_NAME#v}" + CONFIG_VERSION=$(jq -r '.coolify.nightly.version' versions.json) + if [[ "${CONFIG_VERSION}" != "${VERSION}" ]]; then + echo "RC tag ${VERSION} does not match nightly version ${CONFIG_VERSION}." + exit 1 + fi + + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Validate and pin draft prerelease + id: draft + uses: actions/github-script@v8 + env: + TAG_NAME: ${{ inputs.tag }} + with: + script: | + const releases = await github.paginate(github.rest.repos.listReleases, { + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100, + }); + const release = releases.find((candidate) => candidate.tag_name === process.env.TAG_NAME); + + if (!release) { + core.setFailed(`Create a draft prerelease for ${process.env.TAG_NAME} before running this workflow.`); + return; + } + if (!release.draft) { + core.setFailed(`Release ${process.env.TAG_NAME} must still be a draft.`); + return; + } + if (!release.prerelease) { + core.setFailed(`RC release ${process.env.TAG_NAME} must be marked as a prerelease.`); + return; + } + if (!release.body?.trim()) { + core.setFailed(`Draft prerelease ${process.env.TAG_NAME} must contain reviewed release notes.`); + return; + } + + try { + await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `tags/${process.env.TAG_NAME}`, + }); + core.setFailed(`Git tag ${process.env.TAG_NAME} already exists.`); + return; + } catch (error) { + if (error.status !== 404) throw error; + } + + await github.rest.repos.updateRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: release.id, + tag_name: process.env.TAG_NAME, + target_commitish: context.sha, + prerelease: true, + }); + core.setOutput('release_id', release.id); + + build: + needs: validate + permissions: + contents: read + packages: write + strategy: + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + - arch: aarch64 + platform: linux/aarch64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - uses: docker/setup-buildx-action@v3 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push RC image (${{ matrix.arch }}) + uses: docker/build-push-action@v6 + with: + context: . + file: docker/production/Dockerfile + platforms: ${{ matrix.platform }} + push: true + build-args: | + COOLIFY_VERSION=${{ needs.validate.outputs.version }} + tags: | + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:rc-release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }} + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:rc-release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }} + + revalidate: + needs: [validate, build] + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - name: Revalidate draft prerelease + uses: actions/github-script@v8 + env: + RELEASE_ID: ${{ needs.validate.outputs.release_id }} + TAG_NAME: ${{ inputs.tag }} + with: + script: | + const releaseId = Number(process.env.RELEASE_ID); + const { data: release } = await github.rest.repos.getRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: releaseId, + }); + + if (release.tag_name !== process.env.TAG_NAME || !release.draft || !release.prerelease) { + core.setFailed(`Draft prerelease ${process.env.TAG_NAME} changed while the images were building.`); + return; + } + if (!release.body?.trim()) { + core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer contains release notes.`); + return; + } + if (release.target_commitish !== context.sha) { + core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer targets ${context.sha}.`); + return; + } + + try { + await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `tags/${process.env.TAG_NAME}`, + }); + core.setFailed(`Git tag ${process.env.TAG_NAME} was created while the images were building.`); + } catch (error) { + if (error.status !== 404) throw error; + } + + publish: + needs: [validate, build, revalidate] + runs-on: ubuntu-24.04 + permissions: + contents: write + packages: write + steps: + - uses: docker/setup-buildx-action@v3 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Publish RC and next on ${{ env.GITHUB_REGISTRY }} + env: + REGISTRY: ${{ env.GITHUB_REGISTRY }} + VERSION: ${{ needs.validate.outputs.version }} + run: | + IMAGE="${REGISTRY}/${IMAGE_NAME}" + SOURCE="rc-release-${VERSION}-${GITHUB_SHA}" + docker buildx imagetools create \ + "${IMAGE}:${SOURCE}-amd64" \ + "${IMAGE}:${SOURCE}-aarch64" \ + --tag "${IMAGE}:${VERSION}" \ + --tag "${IMAGE}:next" + + - name: Publish RC and next on ${{ env.DOCKER_REGISTRY }} + env: + REGISTRY: ${{ env.DOCKER_REGISTRY }} + VERSION: ${{ needs.validate.outputs.version }} + run: | + IMAGE="${REGISTRY}/${IMAGE_NAME}" + SOURCE="rc-release-${VERSION}-${GITHUB_SHA}" + docker buildx imagetools create \ + "${IMAGE}:${SOURCE}-amd64" \ + "${IMAGE}:${SOURCE}-aarch64" \ + --tag "${IMAGE}:${VERSION}" \ + --tag "${IMAGE}:next" + + - name: Publish reviewed draft prerelease + uses: actions/github-script@v8 + env: + RELEASE_ID: ${{ needs.validate.outputs.release_id }} + TAG_NAME: ${{ inputs.tag }} + with: + script: | + const releaseId = Number(process.env.RELEASE_ID); + const { data: release } = await github.rest.repos.getRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: releaseId, + }); + + if (release.tag_name !== process.env.TAG_NAME || !release.draft || !release.prerelease) { + core.setFailed(`Draft prerelease ${process.env.TAG_NAME} changed while the images were building.`); + return; + } + if (!release.body?.trim()) { + core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer contains release notes.`); + return; + } + if (release.target_commitish !== context.sha) { + core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer targets ${context.sha}.`); + return; + } + + try { + await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `tags/${process.env.TAG_NAME}`, + }); + core.setFailed(`Git tag ${process.env.TAG_NAME} was created while the images were building.`); + return; + } catch (error) { + if (error.status !== 404) throw error; + } + + await github.rest.repos.updateRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: Number(process.env.RELEASE_ID), + tag_name: process.env.TAG_NAME, + target_commitish: context.sha, + prerelease: true, + draft: false, + }); diff --git a/.github/workflows/coolify-staging-build.yml b/.github/workflows/coolify-staging-build.yml deleted file mode 100644 index ccbd141295..0000000000 --- a/.github/workflows/coolify-staging-build.yml +++ /dev/null @@ -1,134 +0,0 @@ -name: Staging Build - -on: - push: - branches-ignore: - - main - - v3.x - - '**v5.x**' - paths-ignore: - - .github/workflows/coolify-helper.yml - - .github/workflows/coolify-helper-next.yml - - .github/workflows/coolify-realtime.yml - - .github/workflows/coolify-realtime-next.yml - - .github/workflows/pr-quality.yaml - - docker/coolify-helper/Dockerfile - - docker/coolify-realtime/Dockerfile - - docker/testing-host/Dockerfile - - templates/** - - CHANGELOG.md - -permissions: - contents: read - packages: write - -env: - GITHUB_REGISTRY: ghcr.io - DOCKER_REGISTRY: docker.io - IMAGE_NAME: "coollabsio/coolify" - -jobs: - build-push: - strategy: - matrix: - include: - - arch: amd64 - platform: linux/amd64 - runner: ubuntu-24.04 - - arch: aarch64 - platform: linux/aarch64 - runner: ubuntu-24.04-arm - runs-on: ${{ matrix.runner }} - steps: - - uses: actions/checkout@v5 - with: - persist-credentials: false - - - name: Sanitize branch name for Docker tag - id: sanitize - run: | - # Replace slashes and other invalid characters with dashes - SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g') - echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to ${{ env.GITHUB_REGISTRY }} - uses: docker/login-action@v3 - with: - registry: ${{ env.GITHUB_REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Login to ${{ env.DOCKER_REGISTRY }} - uses: docker/login-action@v3 - with: - registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and Push Image (${{ matrix.arch }}) - uses: docker/build-push-action@v6 - with: - context: . - file: docker/production/Dockerfile - platforms: ${{ matrix.platform }} - push: true - tags: | - ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-${{ matrix.arch }} - ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-${{ matrix.arch }} - cache-from: | - type=gha,scope=build-${{ matrix.arch }} - type=registry,ref=${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=build-${{ matrix.arch }} - - merge-manifest: - runs-on: ubuntu-24.04 - needs: build-push - steps: - - uses: actions/checkout@v5 - with: - persist-credentials: false - - - name: Sanitize branch name for Docker tag - id: sanitize - run: | - # Replace slashes and other invalid characters with dashes - SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g') - echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT - - - uses: docker/setup-buildx-action@v3 - - - name: Login to ${{ env.GITHUB_REGISTRY }} - uses: docker/login-action@v3 - with: - registry: ${{ env.GITHUB_REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Login to ${{ env.DOCKER_REGISTRY }} - uses: docker/login-action@v3 - with: - registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }} - run: | - docker buildx imagetools create \ - ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-amd64 \ - ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \ - --tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }} - - - name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }} - run: | - docker buildx imagetools create \ - ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-amd64 \ - ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \ - --tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }} - - - uses: sarisia/actions-status-discord@v1 - if: always() - with: - webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }} diff --git a/.github/workflows/sync-main-to-next.yml b/.github/workflows/sync-main-to-next.yml new file mode 100644 index 0000000000..595a21e799 --- /dev/null +++ b/.github/workflows/sync-main-to-next.yml @@ -0,0 +1,62 @@ +name: Sync main to next + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: sync-main-to-next + cancel-in-progress: false + +jobs: + sync: + name: Merge main into next + runs-on: ubuntu-latest + steps: + - name: Checkout next + uses: actions/checkout@v5 + with: + ref: next + fetch-depth: 0 + + - name: Merge main into next + env: + GH_TOKEN: ${{ github.token }} + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git fetch origin main next + + if git merge --no-edit origin/main; then + git push origin HEAD:next + exit 0 + fi + + conflicts=$(git diff --name-only --diff-filter=U) + git merge --abort + + if [ -z "$conflicts" ]; then + echo 'The merge failed without conflicts, so no pull request was created.' + exit 1 + fi + + sync_branch='automation/sync-main-to-next' + existing_pr=$(gh pr list --base next --head "$sync_branch" --state open --json url --jq '.[0].url') + if [ -n "$existing_pr" ]; then + echo "A main to next pull request already exists: $existing_pr" + else + git push --force origin origin/main:"refs/heads/$sync_branch" + gh pr create \ + --base next \ + --head "$sync_branch" \ + --title 'chore: merge main into next' \ + --body 'This pull request was created automatically because main could not be merged into next without conflicts. Resolve conflicts on this temporary branch; never update main with next.' + fi + + echo 'main could not be merged into next without conflicts.' + exit 1 diff --git a/.gitignore b/.gitignore index ce9cc5dd79..9334baec45 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ docker/coolify-realtime/node_modules CHANGELOG.md /.workspaces /.superpowers/ +/docs/superpowers/plans/ tests/Browser/Screenshots tests/v4/Browser/Screenshots ref diff --git a/AGENTS.md b/AGENTS.md index 5563a18ec1..4d78dfd938 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,9 +99,30 @@ function loginAsRoot(): mixed ``` - See `tests/v4/Browser/LoginTest.php`, `tests/v4/Browser/DashboardTest.php`, and `tests/v4/Browser/RegistrationTest.php` for conventions. -- Chrome driver runs on `localhost:4444`, app on `localhost:8000` (configured in `tests/DuskTestCase.php`). - Legacy Dusk macros in `app/Providers/DuskServiceProvider.php` use the old `type()`/`press()` API — do not mix with Pest Browser Plugin's `fill()`/`click()` API. +### How Browser Tests Actually Run (no Docker, no display needed) + +`visit()` does NOT hit the dev app on `localhost:8000` and does NOT use the Dusk ChromeDriver on `:4444` (that config in `tests/DuskTestCase.php` is legacy). Instead the Pest Browser Plugin: + +1. Starts a local Playwright server (`node node_modules/.bin/playwright run-server`) and launches a **headless Chromium** from `~/.cache/ms-playwright` (install once with `npm install && npx playwright install chromium`). +2. Boots an **in-process amphp HTTP server** on a random port that serves the Laravel app from the test process itself. + +Because the "server" and the test share one PHP process, they share the phpunit env (sqlite `:memory:`, array cache) — so `config()->set(...)`, model writes, and `Cache` calls in the test are visible to browser-issued requests, and `RefreshDatabase` never touches the dev Postgres. + +`->screenshot(filename: '...')` writes real PNGs to `tests/Browser/Screenshots/` — read them to visually verify UI state (toasts, modals, stray elements). + +### Browser Test Gotchas + +- **`Class "Redis" not found` thrown by the HTTP server**: host PHP has no phpredis, and the maintenance-mode store is hard-wired to redis (`config/app.php` → `'maintenance' => ['store' => 'redis']`). Add `config()->set('app.maintenance.store', 'array');` in `beforeEach`. +- **Every path redirects to onboarding** for a fresh user (`DecideWhatToDoWithUser` + `showBoarding()`). Finish boarding before navigating: `Team::query()->update(['show_boarding' => false]); Cache::flush();` — the `Cache::flush()` is required because `User::currentTeam()` caches the Team for an hour and the in-process server shares that cache. +- **`->navigate('/path')` races form-submit redirects.** After `->click('Login')`, assert something on the destination page (e.g. `->assertSee('Welcome to Coolify')`) before calling `navigate()`. +- **Failure messages print the *initial* `visit()` URL**, not the current URL. Read the auto-saved screenshot in `tests/Browser/Screenshots/` to see where the browser actually ended up. +- **Runs hang forever**: stale Playwright servers from a previously killed run. Fix: `pkill -f "playwright run-server"` and rerun. Healthy runs take seconds. +- **Guest pages miss `DOMPurify`** (`public/js/purify.min.js` loads only `@auth` in `layouts/base.blade.php`), so toast descriptions fail on unauthenticated pages — log in first for toast-related assertions. +- Layouts that call `@livewireScripts` manually must also call `@livewireStyles`, otherwise Livewire's asset auto-injection is disabled and `[wire\:loading]`/`[x-cloak]` elements render visible. +- Run browser test files in their own `php artisan test` invocation — combining them with non-browser test paths in one command can hang the runner. + ## Architecture ### Backend Structure (app/) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 73b048f4b6..626f6d7b93 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -228,6 +228,35 @@ A: Yes, but keep in mind a PR closure is feedback, not a rejection of your effor ## Local Development To build and run Coolify locally, see: [Development](./DEVELOPMENT.md) +### Testing the Coolify Helper Locally + +Use `scripts/dev-helper` to build a local helper image and test it with the running development instance. The script requires the standard local Coolify container and the seeded Dockerfile, Docker Compose, and Nixpacks applications. + +Run the complete workflow: + +```bash +./scripts/dev-helper test my-helper-test +``` + +This builds and selects the helper image, verifies its bundled tools and Docker socket access, runs a Docker Compose smoke test, and deploys all three seeded applications. + +You can also run each step separately: + +```bash +./scripts/dev-helper build my-helper-test +./scripts/dev-helper use my-helper-test +./scripts/dev-helper verify my-helper-test +./scripts/dev-helper deploy my-helper-test +``` + +Clear the helper override when finished: + +```bash +./scripts/dev-helper reset +``` + +The default image repository is `docker.io/coollabsio/coolify-helper`. Set `HELPER_IMAGE_REPOSITORY` to test another repository, or `COOLIFY_CONTAINER` if the local Coolify container has a different name. + ### macOS Development with Lima Mac users can use [Lima](https://lima-vm.io/) to run a lightweight Linux virtual machine for local Coolify development. This is useful if you prefer a Linux-based Docker environment on macOS. diff --git a/RELEASE.md b/RELEASE.md index d278d76902..3733392a48 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -9,7 +9,7 @@ | `feature/*` | New features based on and merged into `next` | | `hotfix/X.Y.Z` | Production fixes based on `main` | -Release workflows never edit or commit versions. Set the intended version in `config/constants.php` before running a release workflow. +Release workflows never edit or commit versions. Stable versions come from `config/constants.php`; RC versions come from `coolify.nightly.version` in `versions.json` and `other/nightly/versions.json`. ## Where changes go @@ -25,11 +25,12 @@ feature/* → next → RC ``` 1. Merge feature branches into `next`. -2. Set the intended RC version on `next`, such as `4.4-rc.1`. -3. Regular builds publish `sha-`, `4.4-rc.1.`, and the moving `next` tag. +2. Set `coolify.nightly.version` in both version files to the intended RC, such as `4.4-rc.1`. +3. Regular `next` builds publish `sha-`, `4.4-rc.1.`, and the moving `next` tag. They never publish the exact `4.4-rc.1` tag. 4. Create a reviewed draft GitHub Release named `v4.4-rc.1` and mark it as a prerelease. -5. Run the RC workflow from `next`. It publishes `4.4-rc.1`, updates `next`, and publishes the draft. -6. Advance `next` to the next intended RC version. +5. Run **Release Coolify RC** manually from `next` and enter `v4.4-rc.1`. +6. The workflow validates the draft and configured nightly version, builds the exact RC, publishes `4.4-rc.1`, updates `next`, and publishes the draft prerelease. +7. Advance `coolify.nightly.version` to the next intended RC version. ## Stable release flow diff --git a/app/Actions/Application/StopApplicationOneServer.php b/app/Actions/Application/StopApplicationOneServer.php index 10f5b85f21..b25eb481b6 100644 --- a/app/Actions/Application/StopApplicationOneServer.php +++ b/app/Actions/Application/StopApplicationOneServer.php @@ -29,7 +29,7 @@ class StopApplicationOneServer instant_remote_process( [ dockerStopCommand($timeout, $containerName, $server), - "docker rm -f $containerName", + dockerRemoveCommand($containerName), ], $server ); diff --git a/app/Actions/Database/StopDatabaseProxy.php b/app/Actions/Database/StopDatabaseProxy.php index 96a1097662..e6789202f3 100644 --- a/app/Actions/Database/StopDatabaseProxy.php +++ b/app/Actions/Database/StopDatabaseProxy.php @@ -24,10 +24,10 @@ class StopDatabaseProxy { $server = data_get($database, 'destination.server'); $uuid = $database->uuid; - if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($database->getMorphClass() === ServiceDatabase::class) { $server = data_get($database, 'service.server'); } - instant_remote_process(["docker rm -f {$uuid}-proxy"], $server); + instant_remote_process([dockerRemoveCommand("{$uuid}-proxy")], $server); $database->save(); diff --git a/app/Actions/Destination/RemoveStandaloneDockerNetwork.php b/app/Actions/Destination/RemoveStandaloneDockerNetwork.php index 21c40a50ad..3e1b5380b6 100644 --- a/app/Actions/Destination/RemoveStandaloneDockerNetwork.php +++ b/app/Actions/Destination/RemoveStandaloneDockerNetwork.php @@ -11,6 +11,6 @@ class RemoveStandaloneDockerNetwork $safeNetwork = escapeshellarg($destination->network); instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $destination->server, throwError: false); - instant_remote_process(["docker network rm -f {$safeNetwork}"], $destination->server); + instant_remote_process([dockerNetworkRemoveCommand($destination->network)], $destination->server); } } diff --git a/app/Actions/Development/ConfigureDevelopmentQemuHost.php b/app/Actions/Development/ConfigureDevelopmentQemuHost.php new file mode 100644 index 0000000000..a999b77ada --- /dev/null +++ b/app/Actions/Development/ConfigureDevelopmentQemuHost.php @@ -0,0 +1,122 @@ +ensureDevelopmentEnvironment(); + $this->installDependencies(); + $this->runOrFail('systemctl enable --now libvirtd'); + $this->configureLibvirtNetwork(); + $this->configureIpForwarding(); + $this->configureStorage(); + $this->configureDockerForwarding(); + } + + private function installDependencies(): void + { + $binaries = ['curl', 'docker', 'iptables', 'qemu-img', 'virsh', 'virt-install']; + $check = collect($binaries)->map(fn (string $binary) => 'command -v '.escapeshellarg($binary))->implode(' && '); + + if (Process::run($check)->successful()) { + return; + } + + if (! File::exists('/usr/bin/apt-get')) { + throw new RuntimeException('Missing QEMU dependencies. Automatic installation currently supports apt-based development hosts.'); + } + + $this->runOrFail('apt-get update'); + $this->runOrFail('DEBIAN_FRONTEND=noninteractive apt-get install -y curl iptables libvirt-clients libvirt-daemon-system qemu-utils qemu-system-x86 virtinst'); + } + + private function configureLibvirtNetwork(): void + { + $network = config('development-qemu.libvirt_network'); + $networkInfo = Process::run('virsh net-info '.escapeshellarg($network)); + + if ($networkInfo->failed()) { + $networkXml = config('development-qemu.storage_path').'/libvirt-network.xml'; + File::ensureDirectoryExists(dirname($networkXml), 0777, true); + File::put($networkXml, $this->libvirtNetworkXml($network)); + $this->runOrFail('virsh net-define '.escapeshellarg($networkXml)); + $networkInfo = Process::result(output: 'Active: no'); + } + + if (! preg_match('/^Active:\s+yes$/m', $networkInfo->output())) { + $this->runOrFail('virsh net-start '.escapeshellarg($network)); + } + + $this->runOrFail('virsh net-autostart '.escapeshellarg($network)); + } + + private function configureIpForwarding(): void + { + $this->runOrFail("printf 'net.ipv4.ip_forward=1\\n' > /etc/sysctl.d/99-coolify-development-qemu.conf"); + $this->runOrFail('sysctl -w net.ipv4.ip_forward=1'); + } + + private function configureStorage(): void + { + $directory = config('development-qemu.storage_path'); + File::ensureDirectoryExists($directory, 0777, true); + File::chmod($directory, 0777); + } + + private function configureDockerForwarding(): void + { + $dockerNetwork = escapeshellarg(config('development-qemu.docker_network')); + $subnetResult = Process::run("docker network inspect {$dockerNetwork} --format ".escapeshellarg('{{(index .IPAM.Config 0).Subnet}}')); + $subnet = trim($subnetResult->output()); + + if ($subnetResult->failed() || $subnet === '') { + throw new RuntimeException('Unable to determine the Coolify Docker network subnet.'); + } + + $rule = sprintf('-s %s -d %s -o virbr0 -j ACCEPT', escapeshellarg($subnet), escapeshellarg(config('development-qemu.subnet'))); + + Process::run("iptables -D LIBVIRT_FWI {$rule}"); + $this->runOrFail("iptables -I LIBVIRT_FWI 1 {$rule}"); + } + + private function libvirtNetworkXml(string $network): string + { + return << + {$network} + + + + + + + + +XML; + } + + private function runOrFail(string $command): void + { + $result = Process::forever()->run($command); + + if ($result->failed()) { + throw new RuntimeException(trim($result->errorOutput()) ?: "Command failed: {$command}"); + } + } + + private function ensureDevelopmentEnvironment(): void + { + if (! in_array(config('app.env'), ['local', 'development', 'dev'], true)) { + throw new RuntimeException('QEMU host configuration may only run in development environments.'); + } + } +} diff --git a/app/Actions/Development/ManageDevelopmentQemuVm.php b/app/Actions/Development/ManageDevelopmentQemuVm.php new file mode 100644 index 0000000000..a1257cbda1 --- /dev/null +++ b/app/Actions/Development/ManageDevelopmentQemuVm.php @@ -0,0 +1,33 @@ + $profileNames */ + public function handle(string|array $profileNames): void + { + $profileNames = is_array($profileNames) ? array_values(array_unique($profileNames)) : [$profileNames]; + + foreach ($profileNames as $index => $profileName) { + StartDevelopmentQemuVm::run($profileName, $index === 0); + + try { + SeedDevelopmentQemuServer::run($profileName, $index === 0); + } catch (QueryException $exception) { + $keepOthers = $index === 0 ? '' : ' --keep-others'; + $result = Process::run('docker exec coolify php artisan dev:qemu:seed '.escapeshellarg($profileName).$keepOthers); + + if ($result->failed()) { + throw $exception; + } + } + } + } +} diff --git a/app/Actions/Development/SeedDevelopmentQemuServer.php b/app/Actions/Development/SeedDevelopmentQemuServer.php new file mode 100644 index 0000000000..10cb0b3c7d --- /dev/null +++ b/app/Actions/Development/SeedDevelopmentQemuServer.php @@ -0,0 +1,60 @@ +ensureDevelopmentEnvironment(); + $profile = config("development-qemu.profiles.{$profileName}"); + + if (! is_array($profile)) { + throw new InvalidArgumentException("Unknown development QEMU profile: {$profileName}"); + } + + $privateKey = PrivateKey::query()->find(1); + + if (! $privateKey) { + throw new RuntimeException('Development private key 1 is missing. Run the development database seeders first.'); + } + + if ($removeOtherServers) { + Server::query() + ->where('uuid', 'like', 'development-qemu-%') + ->where('uuid', '!=', $profile['uuid']) + ->delete(); + } + + $server = Server::withTrashed()->where('uuid', $profile['uuid'])->first() ?? new Server; + $server->forceFill(['uuid' => $profile['uuid']]); + $server->fill([ + 'name' => $profile['name'], + 'description' => 'Development-only QEMU virtual machine managed by dev:qemu.', + 'ip' => $profile['ip'], + 'port' => 22, + 'user' => $profile['user'], + 'team_id' => 0, + 'private_key_id' => $privateKey->id, + ]); + $server->deleted_at = null; + $server->save(); + + return $server->fresh(); + } + + private function ensureDevelopmentEnvironment(): void + { + if (! in_array(config('app.env'), ['local', 'development', 'dev'], true)) { + throw new RuntimeException('QEMU VM servers may only be seeded in development environments.'); + } + } +} diff --git a/app/Actions/Development/StartDevelopmentQemuVm.php b/app/Actions/Development/StartDevelopmentQemuVm.php new file mode 100644 index 0000000000..d6d9c00b2f --- /dev/null +++ b/app/Actions/Development/StartDevelopmentQemuVm.php @@ -0,0 +1,219 @@ +ensureDevelopmentEnvironment(); + $profiles = config('development-qemu.profiles'); + $profile = $profiles[$profileName] ?? null; + + if (! is_array($profile)) { + throw new InvalidArgumentException("Unknown development QEMU profile: {$profileName}"); + } + + ConfigureDevelopmentQemuHost::run(); + $this->configureDhcpReservation($profile); + + if ($resetManagedVms) { + foreach ($profiles as $managedProfile) { + Process::run('virsh destroy '.escapeshellarg($managedProfile['domain'])); + Process::run('virsh undefine '.escapeshellarg($managedProfile['domain'])); + $this->deleteVmData($managedProfile['domain']); + } + } + + $this->createVm($profile); + + ConfigureDevelopmentQemuHost::run(); + $this->waitForSsh($profile['ip']); + } + + /** @param array{domain: string, ip: string, user: string, mac: string, image: string, image_url: string, os_variant: string, provisioner: string} $profile */ + private function createVm(array $profile): void + { + $directory = config('development-qemu.storage_path'); + File::ensureDirectoryExists($directory); + File::chmod($directory, 0777); + $this->moveLegacyFiles($directory); + $baseImage = "{$directory}/{$profile['image']}"; + $disk = "{$directory}/{$profile['domain']}.qcow2"; + $userData = "{$directory}/{$profile['domain']}-user-data.yaml"; + $networkConfig = "{$directory}/{$profile['domain']}-network.yaml"; + + if (! File::exists($baseImage)) { + $this->runOrFail(sprintf( + 'curl --fail --location --output %s %s', + escapeshellarg($baseImage), + escapeshellarg($profile['image_url']), + )); + } + + if (! File::exists($disk)) { + $this->runOrFail(sprintf( + 'qemu-img create -f qcow2 -F qcow2 -b %s %s %s', + escapeshellarg($baseImage), + escapeshellarg($disk), + escapeshellarg(config('development-qemu.disk_size')), + )); + } + + if (File::exists($baseImage)) { + File::chmod($baseImage, 0644); + } + + if (File::exists($disk)) { + File::chmod($disk, 0666); + } + + File::put($userData, $this->userData($profile)); + File::put($networkConfig, $this->networkConfig($profile)); + + $this->runOrFail(sprintf( + 'virt-install --connect qemu:///system --name %s --memory %d --vcpus %d --import --os-variant %s --disk path=%s,format=qcow2,bus=virtio --network network=%s,model=virtio,mac=%s --cloud-init user-data=%s,network-config=%s,disable=on --noautoconsole', + escapeshellarg($profile['domain']), + config('development-qemu.memory'), + config('development-qemu.vcpus'), + escapeshellarg($profile['os_variant']), + escapeshellarg($disk), + escapeshellarg(config('development-qemu.libvirt_network')), + escapeshellarg($profile['mac']), + escapeshellarg($userData), + escapeshellarg($networkConfig), + )); + } + + private function moveLegacyFiles(string $directory): void + { + $legacyDirectory = storage_path('app/development-qemu'); + + if ($legacyDirectory === $directory || ! File::isDirectory($legacyDirectory)) { + return; + } + + foreach (File::files($legacyDirectory) as $file) { + $destination = "{$directory}/{$file->getFilename()}"; + + if (! File::exists($destination)) { + File::move($file->getPathname(), $destination); + } + } + } + + private function deleteVmData(string $domain): void + { + $directory = config('development-qemu.storage_path'); + File::delete([ + "{$directory}/{$domain}.qcow2", + "{$directory}/{$domain}-user-data.yaml", + "{$directory}/{$domain}-network.yaml", + ]); + } + + /** @param array{user: string, provisioner: string} $profile */ + private function userData(array $profile): string + { + $publicKey = config('development-qemu.public_key'); + $adminGroup = $profile['provisioner'] === 'apt' ? 'sudo' : 'wheel'; + $sudo = $profile['user'] === 'root' ? '' : " groups: [{$adminGroup}]\n sudo: ALL=(ALL) NOPASSWD:ALL\n"; + + [$packages, $startDocker] = match ($profile['provisioner']) { + 'apk' => [" - docker\n - sudo", 'rc-update add docker default && service docker start'], + 'rpm' => [" - curl\n - sudo", 'curl -fsSL https://get.docker.com | sh && systemctl enable --now docker'], + default => [" - docker.io\n - sudo", 'systemctl enable --now docker'], + }; + $addUserToDockerGroup = $profile['user'] === 'root' ? '' : "\n - usermod -aG docker {$profile['user']}"; + + return <<failed()) { + throw new RuntimeException(trim($networkXml->errorOutput()) ?: 'Unable to inspect the libvirt network.'); + } + + if (str_contains($networkXml->output(), $profile['mac']) && str_contains($networkXml->output(), $profile['ip'])) { + return; + } + + $host = sprintf("", $profile['mac'], $profile['domain'], $profile['ip']); + $this->runOrFail("virsh net-update {$network} add-last ip-dhcp-host ".escapeshellarg($host).' --live --config'); + } + + private function waitForSsh(string $ip): void + { + $container = escapeshellarg(config('development-qemu.coolify_container')); + $probe = <<<'PHP' +$deadline = time() + 120; +do { + $socket = @fsockopen($argv[1], 22, $errorCode, $errorMessage, 1); + if (is_resource($socket)) { + fclose($socket); + exit(0); + } + sleep(1); +} while (time() < $deadline); +exit(1); +PHP; + $this->runOrFail("docker exec {$container} php -r ".escapeshellarg($probe).' '.escapeshellarg($ip)); + } + + private function runOrFail(string $command): void + { + $result = Process::forever()->run($command); + + if ($result->failed()) { + throw new RuntimeException(trim($result->errorOutput()) ?: "Command failed: {$command}"); + } + + } + + private function ensureDevelopmentEnvironment(): void + { + if (! in_array(config('app.env'), ['local', 'development', 'dev'], true)) { + throw new RuntimeException('QEMU VMs may only be managed in development environments.'); + } + } +} diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php index 159f122526..d09aae802a 100644 --- a/app/Actions/Proxy/GetProxyConfiguration.php +++ b/app/Actions/Proxy/GetProxyConfiguration.php @@ -13,6 +13,8 @@ class GetProxyConfiguration { use AsAction; + public const MAX_CONFIGURATION_SIZE_BYTES = 5 * 1024 * 1024; + public function handle(Server $server, bool $forceRegenerate = false): string { $proxyType = $server->proxyType(); @@ -98,11 +100,17 @@ class GetProxyConfiguration private function backfillFromDisk(Server $server): ?string { $proxy_path = $server->proxyPath(); + $configurationPath = escapeshellarg("$proxy_path/docker-compose.yml"); + $readLimit = self::MAX_CONFIGURATION_SIZE_BYTES + 1; $result = instant_remote_process([ "mkdir -p $proxy_path", - "cat $proxy_path/docker-compose.yml 2>/dev/null", + "if [ ! -f {$configurationPath} ]; then exit 0; elif [ \"$(wc -c < {$configurationPath})\" -gt ".self::MAX_CONFIGURATION_SIZE_BYTES." ]; then echo '__COOLIFY_PROXY_CONFIG_TOO_LARGE__'; else head -c {$readLimit} {$configurationPath}; fi", ], $server, false); + if ($result === '__COOLIFY_PROXY_CONFIG_TOO_LARGE__' || strlen($result ?? '') > self::MAX_CONFIGURATION_SIZE_BYTES) { + throw new \RuntimeException('Proxy configuration exceeds the 5 MiB size limit.'); + } + if (! empty(trim($result ?? ''))) { $server->proxy->last_saved_proxy_configuration = $result; $server->save(); diff --git a/app/Actions/Service/DeleteService.php b/app/Actions/Service/DeleteService.php index 460600d699..2def132804 100644 --- a/app/Actions/Service/DeleteService.php +++ b/app/Actions/Service/DeleteService.php @@ -2,77 +2,52 @@ namespace App\Actions\Service; -use App\Actions\Server\CleanupDocker; use App\Models\Service; -use Illuminate\Support\Facades\Log; -use Lorisleiva\Actions\Concerns\AsAction; class DeleteService { - use AsAction; - - public function handle(Service $service, bool $deleteVolumes, bool $deleteConnectedNetworks, bool $deleteConfigurations, bool $dockerCleanup) + public function cleanupRemote(Service $service, bool $deleteVolumes, bool $deleteConnectedNetworks, bool $deleteConfigurations): void { - try { - $server = data_get($service, 'server'); - if ($deleteVolumes && $server->isFunctional()) { - $storagesToDelete = collect([]); - - $service->environment_variables()->delete(); - $commands = []; - foreach ($service->applications()->get() as $application) { - $storages = $application->persistentStorages()->get(); - foreach ($storages as $storage) { - $storagesToDelete->push($storage); - } - } - foreach ($service->databases()->get() as $database) { - $storages = $database->persistentStorages()->get(); - foreach ($storages as $storage) { - $storagesToDelete->push($storage); - } - } - foreach ($storagesToDelete as $storage) { + $server = data_get($service, 'server'); + if ($deleteVolumes && $server->isFunctional()) { + $commands = []; + foreach ($service->applications()->get() as $application) { + foreach ($application->persistentStorages()->get() as $storage) { $commands[] = 'docker volume rm -f '.escapeshellarg($storage->name); } - - // Execute volume deletion first, this must be done first otherwise volumes will not be deleted. - if (! empty($commands)) { - foreach ($commands as $command) { - $result = instant_remote_process([$command], $server, false); - if ($result !== null && $result !== 0) { - Log::error('Error deleting volumes: '.$result); - } - } - } - } - - if ($deleteConnectedNetworks) { - $service->deleteConnectedNetworks(); - } - - instant_remote_process(["docker rm -f $service->uuid"], $server, throwError: false); - } catch (\Exception $e) { - throw new \RuntimeException($e->getMessage()); - } finally { - if ($deleteConfigurations) { - $service->deleteConfigurations(); - } - foreach ($service->applications()->get() as $application) { - $application->forceDelete(); } foreach ($service->databases()->get() as $database) { - $database->forceDelete(); + foreach ($database->persistentStorages()->get() as $storage) { + $commands[] = 'docker volume rm -f '.escapeshellarg($storage->name); + } } - foreach ($service->scheduled_tasks as $task) { - $task->delete(); - } - $service->tags()->detach(); - $service->forceDelete(); - - if ($dockerCleanup) { - CleanupDocker::dispatch($server, false, false); + foreach ($commands as $command) { + instant_remote_process([$command], $server, false); } } + + if ($deleteConnectedNetworks) { + $service->deleteConnectedNetworks(); + } + if ($deleteConfigurations) { + $service->deleteConfigurations(); + } + instant_remote_process(["docker rm -f $service->uuid"], $server, throwError: false); + } + + public function deleteLocal(Service $service): void + { + foreach ($service->applications()->get() as $application) { + $application->forceDelete(); + } + foreach ($service->databases()->get() as $database) { + $database->forceDelete(); + } + foreach ($service->scheduled_tasks as $task) { + $task->delete(); + } + $service->environment_variables()->delete(); + $service->tags()->detach(); + $service->forceDelete(); } } diff --git a/app/Actions/Shared/CheckDomainDns.php b/app/Actions/Shared/CheckDomainDns.php new file mode 100644 index 0000000000..d0cea0fb1c --- /dev/null +++ b/app/Actions/Shared/CheckDomainDns.php @@ -0,0 +1,142 @@ + $entries + * @return array + */ + public function handle( + array $entries, + ?Server $server, + ?string $expectedIp, + bool $skipForMultipleServers = false, + int $timeoutSeconds = 5, + ): array { + if (! data_get(instanceSettings(), 'is_dns_validation_enabled')) { + return $this->sameResultForAll($entries, 'skipped', 'DNS validation is disabled in instance settings.', $expectedIp); + } + + if (! $server) { + return $this->sameResultForAll($entries, 'skipped', 'No server available for DNS validation.', null); + } + + if ($skipForMultipleServers) { + return $this->sameResultForAll($entries, 'skipped', 'DNS check skipped for multi-server applications.', $expectedIp); + } + + $deadline = hrtime(true) + ($timeoutSeconds * 1_000_000_000); + $dnsServers = str(data_get(instanceSettings(), 'custom_dns_servers')) + ->explode(',') + ->map(fn ($dnsServer) => trim((string) $dnsServer)) + ->filter() + ->values(); + $results = []; + + foreach ($entries as $key => $url) { + $results[$key] = $this->check($url, $server, $expectedIp, $dnsServers->all(), $deadline); + } + + return $results; + } + + /** + * @param array $dnsServers + * @return array{status: string, message: string, expected_ip: ?string, checked_at: string} + */ + private function check(string $url, Server $server, ?string $expectedIp, array $dnsServers, int $deadline): array + { + try { + $host = Url::fromString($url)->getHost(); + } catch (\Throwable) { + return $this->result('failed', 'Could not validate DNS for this domain.', $expectedIp); + } + if (str($host)->contains('sslip.io')) { + return $this->result('ok', 'DNS looks correct.', $expectedIp); + } + + $type = dnsRecordTypeForIp($expectedIp) === 'AAAA' ? DNSTypes::NAME_AAAA : DNSTypes::NAME_A; + + foreach ($dnsServers as $dnsServer) { + $remainingNanoseconds = $deadline - hrtime(true); + if ($remainingNanoseconds < 1_000_000_000) { + return $this->result('failed', 'Could not validate DNS for this domain.', $expectedIp); + } + + try { + $query = app()->make(DNSQuery::class, [ + 'server' => $dnsServer, + 'port' => 53, + 'timeout' => min(5, (int) floor($remainingNanoseconds / 1_000_000_000)), + ]); + $records = $query->query($host, $type); + + if ($records === false || $query->hasError()) { + continue; + } + + foreach ($records as $record) { + if ($record->getType() !== $type) { + continue; + } + + if (isCloudflareIp($record->getData()) || ($expectedIp && $record->getData() === $expectedIp)) { + return $this->result('ok', $this->successMessage($server, $expectedIp), $expectedIp); + } + } + } catch (\Throwable) { + continue; + } + } + + return $this->result('failed', dnsMismatchGuidanceMessage($expectedIp, $expectedIp), $expectedIp); + } + + private function successMessage(Server $server, ?string $expectedIp): string + { + if ( + filled($expectedIp) + && filled($server->ip) + && $server->ip !== $expectedIp + && filter_var($server->ip, FILTER_VALIDATE_IP) === false + ) { + return "DNS points to {$expectedIp} ({$server->ip}) (or Cloudflare)."; + } + + return $expectedIp ? "DNS points to {$expectedIp} (or Cloudflare)." : 'DNS looks correct.'; + } + + /** + * @return array{status: string, message: string, expected_ip: ?string, checked_at: string} + */ + private function result(string $status, string $message, ?string $expectedIp): array + { + return [ + 'status' => $status, + 'message' => $message, + 'expected_ip' => $expectedIp, + 'checked_at' => now()->toIso8601String(), + ]; + } + + /** + * @param array $entries + * @return array + */ + private function sameResultForAll(array $entries, string $status, string $message, ?string $expectedIp): array + { + $result = $this->result($status, $message, $expectedIp); + + return array_fill_keys(array_keys($entries), $result); + } +} diff --git a/app/Actions/Team/DeleteTeam.php b/app/Actions/Team/DeleteTeam.php new file mode 100644 index 0000000000..be880b7e78 --- /dev/null +++ b/app/Actions/Team/DeleteTeam.php @@ -0,0 +1,65 @@ +lockForUpdate()->findOrFail($team->id); + + $role = DB::table('team_user') + ->where('team_id', $team->id) + ->where('user_id', $user->id) + ->lockForUpdate() + ->value('role'); + + if ($role !== 'owner') { + throw new AuthorizationException('Only team owners can delete a team.'); + } + + $hasRunningApplications = Application::query() + ->whereHas('environment.project', fn ($query) => $query->where('team_id', $team->id)) + ->lockForUpdate() + ->get(['id', 'status']) + ->contains(fn (Application $application): bool => $application->isRunning()); + + if ($hasRunningApplications) { + throw new RuntimeException('Stop all running applications before deleting this team.'); + } + + if ($team->servers()->lockForUpdate()->get(['servers.id'])->isNotEmpty()) { + throw new RuntimeException('Delete all team servers before deleting this team.'); + } + + if (! $team->isEmpty()) { + throw new RuntimeException('Delete all team resources before deleting this team.'); + } + + $team->members() + ->where('users.id', '!=', $user->id) + ->get() + ->each(function (User $member) use ($team): void { + $member->teams()->detach($team); + DB::table('sessions')->where('user_id', $member->id)->delete(); + }); + + $team->delete(); + + return $user->teams()->first(); + }); + + Cache::forget("user:{$user->id}:team:{$team->id}"); + + return $newTeam; + } +} diff --git a/app/Console/Commands/ManageDevelopmentQemuVmCommand.php b/app/Console/Commands/ManageDevelopmentQemuVmCommand.php new file mode 100644 index 0000000000..a93e1779b9 --- /dev/null +++ b/app/Console/Commands/ManageDevelopmentQemuVmCommand.php @@ -0,0 +1,40 @@ +error('This command may only run in development mode.'); + + return self::FAILURE; + } + + $profiles = config('development-qemu.profiles'); + $profileNames = $this->argument('profiles') ?: multiselect( + label: 'Which QEMU servers should be started and seeded?', + options: collect($profiles)->mapWithKeys(fn (array $profile, string $key) => [$key => $profile['label']])->all(), + required: true, + ); + + ManageDevelopmentQemuVm::run($profileNames); + + foreach ($profileNames as $profileName) { + $profile = $profiles[$profileName]; + $this->info("Started and seeded {$profile['label']} at {$profile['ip']}."); + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SeedDevelopmentQemuServerCommand.php b/app/Console/Commands/SeedDevelopmentQemuServerCommand.php new file mode 100644 index 0000000000..4772317c39 --- /dev/null +++ b/app/Console/Commands/SeedDevelopmentQemuServerCommand.php @@ -0,0 +1,27 @@ +error('This command may only run in development mode.'); + + return self::FAILURE; + } + + $server = SeedDevelopmentQemuServer::run($this->argument('profile'), ! $this->option('keep-others')); + $this->info("Seeded {$server->name} at {$server->ip}."); + + return self::SUCCESS; + } +} diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index 1a9b688f40..cbb18945e2 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -87,15 +87,48 @@ class SshMultiplexingHelper return false; } - self::storeConnectionMetadata($server); - return true; } public static function removeMuxFile(Server $server): void { - Process::run(self::muxControlCommand($server, 'exit')); - self::clearConnectionMetadata($server); + $checkProcess = Process::run(self::muxControlCommand($server, 'check')); + $pid = preg_match('/pid=(\d+)/', $checkProcess->output().$checkProcess->errorOutput(), $matches) + ? $matches[1] + : null; + + if ($pid !== null) { + self::markMuxProcessAsRetiring($pid, self::muxSocket($server)); + } + + $stopProcess = Process::run(self::muxControlCommand($server, 'stop')); + + if ($pid !== null && ! $stopProcess->successful()) { + self::unmarkMuxProcessAsRetiring($pid, self::muxSocket($server)); + } + } + + public static function markMuxProcessAsRetiring(string $pid, string $muxSocket, ?string $processStartTime = null): void + { + $processStartTime ??= self::processStartTime($pid); + Cache::forever(self::muxProcessRetirementKey($pid, $muxSocket, $processStartTime), true); + } + + public static function isMuxProcessRetiring(string $pid, string $muxSocket, ?string $processStartTime = null): bool + { + $processStartTime ??= self::processStartTime($pid); + $key = self::muxProcessRetirementKey($pid, $muxSocket, $processStartTime); + if (! Cache::has($key)) { + return false; + } + + return true; + } + + public static function unmarkMuxProcessAsRetiring(string $pid, string $muxSocket, ?string $processStartTime = null): void + { + $processStartTime ??= self::processStartTime($pid); + Cache::forget(self::muxProcessRetirementKey($pid, $muxSocket, $processStartTime)); } public static function generateScpCommand(Server $server, string $source, string $dest): string @@ -242,25 +275,6 @@ class SshMultiplexingHelper return $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok'); } - public static function isConnectionExpired(Server $server): bool - { - $connectionAge = self::getConnectionAge($server); - $maxAge = config('constants.ssh.mux_max_age'); - - return $connectionAge !== null && $connectionAge > $maxAge; - } - - public static function getConnectionAge(Server $server): ?int - { - $connectionTime = Cache::get("ssh_mux_connection_time_{$server->uuid}"); - - if ($connectionTime === null) { - return null; - } - - return time() - $connectionTime; - } - public static function refreshMultiplexedConnection(Server $server): bool { self::removeMuxFile($server); @@ -273,6 +287,28 @@ class SshMultiplexingHelper return 'ssh_mux_lock_'.(gethostname() ?: 'unknown').'_'.$server->uuid; } + private static function muxProcessRetirementKey(string $pid, string $muxSocket, ?string $processStartTime): string + { + return 'ssh_mux_retiring_'.hash('sha256', self::processScope().'|'.$pid.'|'.$processStartTime.'|'.$muxSocket); + } + + private static function processScope(): string + { + return (gethostname() ?: 'unknown').'|'.(@readlink('/proc/self/ns/pid') ?: 'unknown'); + } + + private static function processStartTime(string $pid): ?string + { + $stat = @file_get_contents("/proc/{$pid}/stat"); + if ($stat === false || ! preg_match('/^\d+ \(.*\) (.*)$/', trim($stat), $matches)) { + return null; + } + + $fields = preg_split('/\s+/', $matches[1]); + + return $fields[19] ?? null; + } + private static function masterConnectionExists(Server $server): bool { return Process::run(self::muxControlCommand($server, 'check'))->exitCode() === 0; @@ -284,14 +320,6 @@ class SshMultiplexingHelper return false; } - if (self::getConnectionAge($server) === null) { - self::storeConnectionMetadata($server); - } - - if (self::isConnectionExpired($server)) { - return false; - } - if (config('constants.ssh.mux_health_check_enabled') && ! self::isConnectionHealthy($server)) { return false; } @@ -382,14 +410,4 @@ class SshMultiplexingHelper return $options.'-p '.escapeshellarg((string) $server->port).' '; } - - private static function storeConnectionMetadata(Server $server): void - { - Cache::put("ssh_mux_connection_time_{$server->uuid}", time(), config('constants.ssh.mux_persist_time') + 300); - } - - private static function clearConnectionMetadata(Server $server): void - { - Cache::forget("ssh_mux_connection_time_{$server->uuid}"); - } } diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 601c364de2..285cf8e2f1 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1604,7 +1604,12 @@ class ApplicationsController extends Controller if ($return instanceof JsonResponse) { return $return; } - $githubApp = GithubApp::whereTeamId($teamId)->where('uuid', $githubAppUuid)->first(); + $githubApp = GithubApp::where('uuid', $githubAppUuid) + ->where(function ($query) use ($teamId) { + $query->where('team_id', $teamId) + ->orWhere('is_system_wide', true); + }) + ->first(); if (! $githubApp) { return response()->json(['message' => 'Github App not found.'], 404); } diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php index 5c073e9c0a..5a0e74d0d9 100644 --- a/app/Http/Controllers/Api/GithubController.php +++ b/app/Http/Controllers/Api/GithubController.php @@ -15,9 +15,9 @@ use OpenApi\Attributes as OA; class GithubController extends Controller { - private function removeSensitiveData($githubApp) + private function removeSensitiveData(GithubApp $githubApp, int $teamId) { - if (request()->attributes->get('can_read_sensitive', false) === true) { + if (request()->attributes->get('can_read_sensitive', false) === true && $githubApp->team_id === $teamId) { $githubApp->makeVisible([ 'client_secret', 'webhook_secret', @@ -97,8 +97,8 @@ class GithubController extends Controller ->orWhere('is_system_wide', true); })->get(); - $githubApps = $githubApps->map(function ($app) { - return $this->removeSensitiveData($app); + $githubApps = $githubApps->map(function ($app) use ($teamId) { + return $this->removeSensitiveData($app, $teamId); }); return response()->json($githubApps); @@ -642,7 +642,7 @@ class GithubController extends Controller $rules['webhook_secret'] = 'string'; } if (isset($payload['private_key_uuid'])) { - $rules['private_key_uuid'] = 'string|uuid'; + $rules['private_key_uuid'] = 'string'; } if (! isCloud() && isset($payload['is_system_wide'])) { $rules['is_system_wide'] = 'boolean'; diff --git a/app/Http/Controllers/Api/GitlabController.php b/app/Http/Controllers/Api/GitlabController.php index c907af46f3..959a3067aa 100644 --- a/app/Http/Controllers/Api/GitlabController.php +++ b/app/Http/Controllers/Api/GitlabController.php @@ -13,9 +13,9 @@ use OpenApi\Attributes as OA; class GitlabController extends Controller { - private function removeSensitiveData(GitlabApp $gitlabApp) + private function removeSensitiveData(GitlabApp $gitlabApp, int $teamId) { - if (request()->attributes->get('can_read_sensitive', false) === true) { + if (request()->attributes->get('can_read_sensitive', false) === true && $gitlabApp->team_id === $teamId) { $gitlabApp->makeVisible([ 'client_secret', 'webhook_token', @@ -108,8 +108,8 @@ class GitlabController extends Controller ->orWhere('is_system_wide', true); })->get(); - $gitlabApps = $gitlabApps->map(function ($app) { - return $this->removeSensitiveData($app); + $gitlabApps = $gitlabApps->map(function ($app) use ($teamId) { + return $this->removeSensitiveData($app, $teamId); }); return response()->json($gitlabApps); @@ -280,7 +280,7 @@ class GitlabController extends Controller 'gitlab_app_name' => $gitlabApp->name, ]); - return response()->json($this->removeSensitiveData($gitlabApp->fresh()), 201); + return response()->json($this->removeSensitiveData($gitlabApp->fresh(), $teamId), 201); } catch (\Throwable $e) { return handleError($e); } @@ -441,7 +441,7 @@ class GitlabController extends Controller return response()->json([ 'message' => 'GitLab app updated successfully', - 'data' => $this->removeSensitiveData($gitlabApp->fresh()), + 'data' => $this->removeSensitiveData($gitlabApp->fresh(), $teamId), ]); } catch (ModelNotFoundException $e) { return response()->json([ diff --git a/app/Http/Controllers/Api/InstanceEmailSettingsController.php b/app/Http/Controllers/Api/InstanceEmailSettingsController.php new file mode 100644 index 0000000000..ad84b6629d --- /dev/null +++ b/app/Http/Controllers/Api/InstanceEmailSettingsController.php @@ -0,0 +1,97 @@ + []]], tags: ['Settings'], + responses: [ + new OA\Response(response: 200, description: 'Instance email settings.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Forbidden.'), + ] + )] + public function show(): JsonResponse + { + $settings = InstanceSettings::get(); + $this->authorizeRootTeam('view', $settings); + + return response()->json($this->serialize($settings)); + } + + #[OA\Patch( + summary: 'Update instance email settings', + description: 'Update instance-wide SMTP and Resend settings. Requires `write:sensitive` and a root-team token belonging to a root-team admin or owner.', + path: '/settings/email', operationId: 'update-instance-email-settings', + security: [['bearerAuth' => []]], tags: ['Settings'], + responses: [ + new OA\Response(response: 200, description: 'Updated instance email settings.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Forbidden.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function update(Request $request): JsonResponse + { + $settings = InstanceSettings::get(); + $this->authorizeRootTeam('update', $settings); + + $validator = customApiValidator($request->json()->all(), [ + 'smtp_enabled' => 'sometimes|boolean', + 'smtp_from_address' => 'sometimes|nullable|email', + 'smtp_from_name' => 'sometimes|nullable|string|max:255', + 'smtp_host' => 'sometimes|nullable|string|max:255', + 'smtp_port' => 'sometimes|nullable|integer|min:1|max:65535', + 'smtp_encryption' => 'sometimes|nullable|string|in:starttls,tls,none', + 'smtp_username' => 'sometimes|nullable|string|max:255', + 'smtp_password' => 'sometimes|nullable|string|max:255', + 'smtp_timeout' => 'sometimes|nullable|integer|min:0', + 'smtp_ehlo_domain' => ['sometimes', 'nullable', 'string', 'max:255', new ValidHostname], + 'resend_enabled' => 'sometimes|boolean', + 'resend_api_key' => 'sometimes|nullable|string|max:255', + ]); + + if ($validator->fails()) { + return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422); + } + + $settings->fill($validator->validated()); + $settings->save(); + + auditLog('api.settings.email.updated', ['changed_fields' => array_keys($validator->validated())]); + + return response()->json($this->serialize($settings->refresh())); + } + + private function authorizeRootTeam(string $ability, InstanceSettings $settings): void + { + $teamId = getTeamIdFromToken(); + abort_unless(! is_null($teamId) && (int) $teamId === 0, 403, 'Instance email settings require a root-team API token.'); + $this->authorize($ability, $settings); + } + + private function serialize(InstanceSettings $settings): array + { + exposeSensitiveFields($settings); + + return Arr::only($settings->toArray(), self::FIELDS); + } +} diff --git a/app/Http/Controllers/Api/NotificationsController.php b/app/Http/Controllers/Api/NotificationsController.php index 99d0cb8971..f5493d0249 100644 --- a/app/Http/Controllers/Api/NotificationsController.php +++ b/app/Http/Controllers/Api/NotificationsController.php @@ -11,6 +11,7 @@ use App\Models\Team; use App\Models\TelegramNotificationSettings; use App\Models\WebhookNotificationSettings; use App\Rules\SafeWebhookUrl; +use App\Rules\ValidHostname; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -37,6 +38,7 @@ class NotificationsController extends Controller 'smtp_username' => 'sometimes|nullable|string|max:255', 'smtp_password' => 'sometimes|nullable|string|max:255', 'smtp_timeout' => 'sometimes|nullable|integer|min:0', + 'smtp_ehlo_domain' => ['sometimes', 'nullable', 'string', 'max:255', new ValidHostname], 'resend_enabled' => 'sometimes|boolean', 'resend_api_key' => 'sometimes|nullable|string|max:255', 'use_instance_email_settings' => 'sometimes|boolean', @@ -283,7 +285,7 @@ class NotificationsController extends Controller #[OA\Get( summary: 'Get email notification settings', - description: 'Get the current team email notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.', + description: 'Get the current team email notification settings, including `smtp_ehlo_domain`, the hostname sent with SMTP EHLO. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.', path: '/notifications/email', operationId: 'get-current-team-email-notifications', security: [['bearerAuth' => []]], @@ -301,7 +303,7 @@ class NotificationsController extends Controller #[OA\Patch( summary: 'Update email notification settings', - description: 'Update the current team email notification settings.', + description: 'Update the current team email notification settings. Set `smtp_ehlo_domain` to a valid hostname to control the SMTP EHLO domain, or `null` to use the system default.', path: '/notifications/email', operationId: 'update-current-team-email-notifications', security: [['bearerAuth' => []]], diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index 64bf26c1bb..eb137c5349 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -158,6 +158,8 @@ class ProjectController extends Controller if (! $project) { return response()->json(['message' => 'Project not found.'], 404); } + $this->authorize('view', $project); + $environment = $project->environments()->whereName($request->environment_name_or_uuid)->first(); if (! $environment) { $environment = $project->environments()->whereUuid($request->environment_name_or_uuid)->first(); diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index d50a5226a9..f7966c71f1 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -550,11 +550,7 @@ class ServersController extends Controller } $foundServer = ModelsServer::whereIp($request->ip)->first(); if ($foundServer) { - if ($foundServer->team_id === $teamId) { - return response()->json(['message' => 'A server with this IP/Domain already exists in your team.'], 400); - } - - return response()->json(['message' => 'A server with this IP/Domain is already in use by another team.'], 400); + return response()->json(['message' => 'A server with this IP/Domain is already in use.'], 400); } $proxyType = $request->proxy_type ? str($request->proxy_type)->upper() : ProxyTypes::TRAEFIK->value; diff --git a/app/Http/Controllers/Api/TeamController.php b/app/Http/Controllers/Api/TeamController.php index 35e01c8314..b9f8572673 100644 --- a/app/Http/Controllers/Api/TeamController.php +++ b/app/Http/Controllers/Api/TeamController.php @@ -56,7 +56,7 @@ class TeamController extends Controller if (is_null($teamId)) { return invalidTokenResponse(); } - $teams = auth()->user()->teams->sortBy('id'); + $teams = auth()->user()->teams->where('id', $teamId)->values(); $teams = $teams->map(function ($team) { return $this->removeSensitiveData($team); }); @@ -100,13 +100,14 @@ class TeamController extends Controller )] public function team_by_id(Request $request) { - $id = $request->id; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - $teams = auth()->user()->teams; - $team = $teams->where('id', $id)->first(); + if ((int) $request->id !== (int) $teamId) { + return response()->json(['message' => 'Team not found.'], 404); + } + $team = auth()->user()->teams->where('id', $teamId)->first(); if (is_null($team)) { return response()->json(['message' => 'Team not found.'], 404); } @@ -159,13 +160,14 @@ class TeamController extends Controller )] public function members_by_id(Request $request) { - $id = $request->id; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - $teams = auth()->user()->teams; - $team = $teams->where('id', $id)->first(); + if ((int) $request->id !== (int) $teamId) { + return response()->json(['message' => 'Team not found.'], 404); + } + $team = auth()->user()->teams->where('id', $teamId)->first(); if (is_null($team)) { return response()->json(['message' => 'Team not found.'], 404); } diff --git a/app/Http/Controllers/Api/VolumeBackupsController.php b/app/Http/Controllers/Api/VolumeBackupsController.php index e51bf31f8e..26ff938a10 100644 --- a/app/Http/Controllers/Api/VolumeBackupsController.php +++ b/app/Http/Controllers/Api/VolumeBackupsController.php @@ -34,7 +34,7 @@ use RuntimeException; new OA\Property(property: 'retention_amount_s3', type: 'integer', default: 7, minimum: 0, maximum: 10000), new OA\Property(property: 'retention_days_s3', type: 'integer', default: 0, maximum: 2147483647, minimum: 0), new OA\Property(property: 'retention_max_storage_s3', type: 'number', format: 'float', default: 0, maximum: 9999999999, minimum: 0), - new OA\Property(property: 'timeout', type: 'integer', default: 3600, minimum: 60, maximum: 36000), + new OA\Property(property: 'timeout', type: 'integer', default: ScheduledVolumeBackup::DEFAULT_TIMEOUT, minimum: 60, maximum: 36000), ], type: 'object', additionalProperties: false, @@ -261,7 +261,7 @@ class VolumeBackupsController extends Controller string $resourceType, Model $resource, ): JsonResponse { - $backup = $storage->scheduledBackups()->updateOrCreate([], [ + $attributes = [ 'team_id' => $teamId, 'frequency' => $request->string('frequency')->toString(), 'enabled' => $request->boolean('enabled', true), @@ -275,8 +275,12 @@ class VolumeBackupsController extends Controller 'retention_amount_s3' => $request->integer('retention_amount_s3', 7), 'retention_days_s3' => $request->integer('retention_days_s3'), 'retention_max_storage_s3' => $request->float('retention_max_storage_s3'), - 'timeout' => $request->integer('timeout', 3600), - ]); + ]; + if ($request->has('timeout')) { + $attributes['timeout'] = $request->integer('timeout'); + } + + $backup = $storage->scheduledBackups()->updateOrCreate([], $attributes); $created = $backup->wasRecentlyCreated; auditLog('api.volume_backup.schedule_set', [ diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index c723d811a4..9b6b315cee 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -8,12 +8,15 @@ use App\Models\User; use App\Providers\RouteServiceProvider; use Illuminate\Auth\Events\Verified; use Illuminate\Contracts\Encryption\DecryptException; +use Illuminate\Contracts\View\View; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Validation\ValidatesRequests; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller as BaseController; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Crypt; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Password; use Illuminate\Support\Str; @@ -95,61 +98,105 @@ class Controller extends BaseController return response()->json(['message' => 'Transactional emails are not active'], 400); } - public function link() + public function link(): View|RedirectResponse { $token = request()->get('token'); - if (is_string($token) && $token !== '') { - try { - $decrypted = Crypt::decryptString($token); - } catch (DecryptException) { - return redirect()->route('login')->with('error', 'Invalid credentials.'); - } - - if (! str_contains($decrypted, '@@@')) { - return redirect()->route('login')->with('error', 'Invalid credentials.'); - } - - $payload = explode('@@@', $decrypted, 3); - if (count($payload) === 3) { - [$email, $invitationUuid, $password] = $payload; - } else { - [$email, $password] = $payload; - $invitationUuid = null; - } - - $email = Str::lower($email); - $user = User::whereEmail($email)->first(); - if (! $user) { - return redirect()->route('login'); - } - - $invitation = TeamInvitation::query() - ->where('email', $email) - ->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid)) - ->first(); - if (! $invitation || ! $this->invitationLinkMatchesToken($invitation, $token) || ! $invitation->isValid()) { - return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.'); - } - - if (Hash::check($password, $user->password)) { - $team = $invitation->team; - if (! $user->teams()->where('team_id', $team->id)->exists()) { - $user->teams()->attach($team->id, ['role' => $invitation->role]); - } - $invitation->delete(); - - $user->forceFill([ - 'password' => Hash::make(Str::random(64)), - ])->save(); - - Auth::login($user); - session(['currentTeam' => $team]); - - return redirect()->route('dashboard'); - } + $credentials = is_string($token) ? $this->magicLinkCredentials($token) : null; + if (! $credentials) { + return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.'); } - return redirect()->route('login')->with('error', 'Invalid credentials.'); + [$user, $invitation] = $credentials; + + return view('invitation.accept', [ + 'invitation' => $invitation, + 'team' => $invitation->team, + 'alreadyMember' => $user->teams()->where('team_id', $invitation->team_id)->exists(), + 'formAction' => route('auth.link.accept'), + 'token' => $token, + ]); + } + + public function acceptLink(Request $request): RedirectResponse + { + $token = $request->input('token'); + if (! is_string($token)) { + return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.'); + } + + $acceptedInvitation = DB::transaction(function () use ($token) { + $credentials = $this->magicLinkCredentials($token, lockForUpdate: true); + if (! $credentials) { + return null; + } + + [$user, $invitation] = $credentials; + $team = $invitation->team; + if (! $user->teams()->where('team_id', $team->id)->exists()) { + $user->teams()->attach($team->id, ['role' => $invitation->role]); + } + + $user->forceFill([ + 'password' => Hash::make(Str::random(64)), + ])->save(); + $invitation->delete(); + + return [$user, $team]; + }); + + if (! $acceptedInvitation) { + return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.'); + } + + [$user, $team] = $acceptedInvitation; + + Auth::login($user); + session(['currentTeam' => $team]); + + return redirect()->route('dashboard'); + } + + /** + * @return array{0: User, 1: TeamInvitation}|null + */ + private function magicLinkCredentials(string $token, bool $lockForUpdate = false): ?array + { + if ($token === '') { + return null; + } + + try { + $decrypted = Crypt::decryptString($token); + } catch (DecryptException) { + return null; + } + + $payload = explode('@@@', $decrypted, 3); + if (count($payload) === 3) { + [$email, $invitationUuid, $password] = $payload; + } elseif (count($payload) === 2) { + [$email, $password] = $payload; + $invitationUuid = null; + } else { + return null; + } + + $email = Str::lower($email); + $user = User::query()->where('email', $email)->first(); + $invitationQuery = TeamInvitation::query() + ->where('email', $email) + ->when($lockForUpdate, fn ($query) => $query->lockForUpdate()); + $invitation = $invitationUuid + ? $invitationQuery->where('uuid', $invitationUuid)->first() + : $invitationQuery->get()->first( + fn (TeamInvitation $invitation) => $this->invitationLinkMatchesToken($invitation, $token) + ); + + if (! $user || ! $invitation || $invitation->hasExpired() || ! $this->invitationLinkMatchesToken($invitation, $token)) { + return null; + } + + return Hash::check($password, $user->password) ? [$user, $invitation] : null; } private function invitationLinkMatchesToken(TeamInvitation $invitation, string $token): bool @@ -185,6 +232,7 @@ class Controller extends BaseController 'invitation' => $invitation, 'team' => $invitation->team, 'alreadyMember' => $alreadyMember, + 'formAction' => route('team.invitation.accept', $invitation->uuid), ]); } diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index e521093d7e..c9a554e2a5 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -15,6 +15,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; +use Visus\Cuid2\Cuid2; class Gitlab extends Controller { diff --git a/app/Jobs/ApiTokenExpirationWarningJob.php b/app/Jobs/ApiTokenExpirationWarningJob.php index e7b34248ed..f59b1a0b17 100644 --- a/app/Jobs/ApiTokenExpirationWarningJob.php +++ b/app/Jobs/ApiTokenExpirationWarningJob.php @@ -41,6 +41,10 @@ class ApiTokenExpirationWarningJob implements ShouldBeEncrypted, ShouldQueue, Si continue; } + if (! $team->members()->whereKey($token->tokenable_id)->exists()) { + continue; + } + $warningSentAt = now(); $team->notify(new ApiTokenExpiringNotification($token)); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 3047bcc01a..451d4377c2 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -52,6 +52,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json'; + private const CONTAINER_REMOVE_TIMEOUT_MARKER = '__COOLIFY_CONTAINER_REMOVE_TIMEOUT__'; + private const DOCKER_CLIENT_ENV_KEYS = [ 'BUILDKIT_HOST', 'BUILDX_BUILDER', @@ -612,6 +614,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $this->dockerImagePreviewTag; } + if ($this->rollback && str($this->commit)->isNotEmpty()) { + return $this->commit; + } + if (str($this->application->docker_registry_image_tag)->isNotEmpty()) { return $this->application->docker_registry_image_tag; } @@ -3977,15 +3983,45 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); ); } else { $this->execute_remote_command( - [dockerStopCommand($timeout, $containerName, $this->server), 'hidden' => true, 'ignore_errors' => true], - ["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true] + [dockerStopCommand($timeout, $containerName, $this->server), 'hidden' => true, 'ignore_errors' => true] ); + $this->removeContainerWithTimeout($containerName); } } catch (Exception $error) { $this->application_deployment_queue->addLogEntry("Error stopping container $containerName: ".$error->getMessage(), 'stderr'); } } + private function removeContainerWithTimeout(string $containerName): void + { + $outputKey = 'container_remove_'.md5($containerName); + + $this->execute_remote_command([ + dockerRemoveCommandWithTimeout($containerName), + 'hidden' => true, + 'ignore_errors' => true, + 'save' => $outputKey, + 'append' => false, + ]); + + if (! isset($this->saved_outputs)) { + return; + } + + $output = (string) $this->saved_outputs->get($outputKey, ''); + if (! str_contains($output, self::CONTAINER_REMOVE_TIMEOUT_MARKER)) { + return; + } + + $this->application_deployment_queue->addLogEntry( + "Warning: Removing container {$containerName} timed out after 60 seconds. The deployment will continue and cleanup will be retried in 5 minutes.", + 'stderr' + ); + + RemoveContainerJob::dispatch($this->server->id, $containerName) + ->delay(now()->addMinutes(5)); + } + private function stop_running_container(bool $force = false) { try { @@ -5016,9 +5052,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); // do not remove already running container for PR deployments } else { $this->application_deployment_queue->addLogEntry('Deployment failed. Removing the new version of your application.', 'stderr'); - $this->execute_remote_command( - ["docker rm -f $this->container_name >/dev/null 2>&1", 'hidden' => true, 'ignore_errors' => true] - ); + $this->removeContainerWithTimeout($this->container_name); } } } diff --git a/app/Jobs/CheckDomainDnsJob.php b/app/Jobs/CheckDomainDnsJob.php new file mode 100644 index 0000000000..1a7ceaeabc --- /dev/null +++ b/app/Jobs/CheckDomainDnsJob.php @@ -0,0 +1,90 @@ +persistResults(CheckDomainDns::run( + [$this->statusKey => $this->url], + $this->server, + $this->expectedIp, + $this->skipForMultipleServers, + )); + } + + public function failed(?\Throwable $exception): void + { + $this->persistResults([ + $this->statusKey => $this->status('failed', 'Could not validate DNS for this domain.'), + ]); + } + + /** + * @return array{status: string, message: string, expected_ip: ?string, checked_at: string} + */ + private function status(string $status, string $message): array + { + return [ + 'status' => $status, + 'message' => $message, + 'expected_ip' => $this->expectedIp, + 'checked_at' => now()->toIso8601String(), + ]; + } + + /** + * @param array $results + */ + private function persistResults(array $results): void + { + DB::transaction(function () use ($results): void { + $resource = $this->resource::query()->lockForUpdate()->find($this->resource->getKey()); + if (! $resource) { + return; + } + + $statuses = $resource->domain_dns_statuses ?? []; + + foreach ($results as $key => $result) { + if (($statuses[$key]['status'] ?? null) !== 'checking' || ($statuses[$key]['check_id'] ?? null) !== $this->checkId) { + continue; + } + + $statuses[$key] = $result; + } + + $resource->domain_dns_statuses = $statuses === [] ? null : $statuses; + $resource->save(); + }); + } +} diff --git a/app/Jobs/CheckTraefikVersionForServerJob.php b/app/Jobs/CheckTraefikVersionForServerJob.php index 91869eb12d..054a739bc6 100644 --- a/app/Jobs/CheckTraefikVersionForServerJob.php +++ b/app/Jobs/CheckTraefikVersionForServerJob.php @@ -33,10 +33,11 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue */ public function handle(): void { + $this->clearOutdatedInfo(); + // Detect current version (makes SSH call) $currentVersion = getTraefikVersionFromDockerCompose($this->server); - // Update detected version in database $this->server->update(['detected_traefik_version' => $currentVersion]); if (! $currentVersion) { @@ -113,6 +114,11 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue ProxyStatusChangedUI::dispatch($this->server->team_id); } + private function clearOutdatedInfo(): void + { + $this->server->update(['traefik_outdated_info' => null]); + } + /** * Get information about newer branches if available. */ diff --git a/app/Jobs/CleanupStaleMultiplexedConnections.php b/app/Jobs/CleanupStaleMultiplexedConnections.php index 0d3029c668..69e151d24f 100644 --- a/app/Jobs/CleanupStaleMultiplexedConnections.php +++ b/app/Jobs/CleanupStaleMultiplexedConnections.php @@ -2,8 +2,8 @@ namespace App\Jobs; +use App\Helpers\SshMultiplexingHelper; use App\Models\Server; -use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -51,7 +51,9 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue continue; } - if ($process['etimes'] >= $minAge && ! file_exists($pathMatch[1])) { + if ($process['etimes'] >= $minAge + && ! file_exists($pathMatch[1]) + && ! SshMultiplexingHelper::isMuxProcessRetiring($process['pid'], $pathMatch[1])) { $this->reapOrphan('ssh', $process); } } @@ -169,14 +171,6 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue if ($checkProcess->exitCode() !== 0) { $this->removeMultiplexFile($muxFile, 'connection_check_failed'); - } else { - $muxContent = Storage::disk('ssh-mux')->get($muxFile); - $establishedAt = Carbon::parse(substr($muxContent, 37)); - $expirationTime = $establishedAt->addSeconds(config('constants.ssh.mux_persist_time')); - - if (Carbon::now()->isAfter($expirationTime)) { - $this->removeMultiplexFile($muxFile, 'expired'); - } } } } @@ -216,8 +210,20 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue } $muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}"; - $closeCommand = "ssh -O exit -o ControlPath={$muxSocket} localhost 2>/dev/null"; - Process::run($closeCommand); + $checkProcess = Process::run("ssh -O check -o ControlPath={$muxSocket} localhost"); + $pid = preg_match('/pid=(\d+)/', $checkProcess->output().$checkProcess->errorOutput(), $matches) + ? $matches[1] + : null; + + if ($pid !== null) { + SshMultiplexingHelper::markMuxProcessAsRetiring($pid, $muxSocket); + } + + $closeCommand = "ssh -O stop -o ControlPath={$muxSocket} localhost 2>/dev/null"; + $stopProcess = Process::run($closeCommand); + if ($pid !== null && ! $stopProcess->successful()) { + SshMultiplexingHelper::unmarkMuxProcessAsRetiring($pid, $muxSocket); + } Storage::disk('ssh-mux')->delete($muxFile); Log::info('Removed stale mux file', [ diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 1838feb9e7..0b73ed0cf5 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -798,7 +798,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->add_to_error_output($e->getMessage()); throw $e; } finally { - $command = "docker rm -f backup-of-{$this->backup_log_uuid}"; + $command = dockerRemoveCommand("backup-of-{$this->backup_log_uuid}"); instant_remote_process([$command], $this->server, true, false, null, disableMultiplexing: true); } } diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index 124cc16cca..dff7d88de1 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -4,7 +4,6 @@ namespace App\Jobs; use App\Actions\Application\StopApplication; use App\Actions\Database\StopDatabase; -use App\Actions\Server\CleanupDocker; use App\Actions\Service\DeleteService; use App\Actions\Service\StopService; use App\Actions\Shared\DeleteScheduledVolumeBackup; @@ -28,6 +27,8 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue { @@ -43,20 +44,17 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue $this->onQueue('high'); } - public function handle() + public function handle(): void { - if (! $this->resource instanceof ApplicationPreview) { - $this->deleteScheduledVolumeBackups(); + if ($this->resource instanceof ApplicationPreview) { + DB::transaction(function (): void { + $this->deleteApplicationPreview(); + }); + + return; } try { - // Handle ApplicationPreview instances separately - if ($this->resource instanceof ApplicationPreview) { - $this->deleteApplicationPreview(); - - return; - } - switch ($this->resource->type()) { case 'application': StopApplication::run($this->resource, previewDeployments: true, dockerCleanup: $this->dockerCleanup); @@ -73,21 +71,71 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue break; case 'service': StopService::run($this->resource, $this->deleteConnectedNetworks, $this->dockerCleanup); - DeleteService::run($this->resource, $this->deleteVolumes, $this->deleteConnectedNetworks, $this->deleteConfigurations, $this->dockerCleanup); - - return; + app(DeleteService::class)->cleanupRemote( + $this->resource, + $this->deleteVolumes, + $this->deleteConnectedNetworks, + $this->deleteConfigurations, + ); + break; } - if ($this->deleteConfigurations) { - $this->resource->deleteConfigurations(); + if (! $this->resource instanceof Service) { + if ($this->deleteConfigurations) { + $this->resource->deleteConfigurations(); + } + if ($this->deleteVolumes) { + $this->resource->deleteVolumes(); + } + if ($this->deleteConnectedNetworks && $this->resource->type() === 'application') { + $this->resource->deleteConnectedNetworks(); + } } + } catch (\Throwable $e) { + Log::warning('Remote cleanup failed while deleting resource; continuing with local deletion.', [ + 'resource_id' => $this->resource->id, + 'resource_type' => $this->resource->type(), + 'error' => $e->getMessage(), + ]); + } + + DB::transaction(function (): void { + try { + $this->deleteScheduledVolumeBackups(); + } catch (\Throwable $e) { + Log::warning('Remote backup cleanup failed while deleting resource; continuing with local deletion.', [ + 'resource_id' => $this->resource->id, + 'resource_type' => $this->resource->type(), + 'error' => $e->getMessage(), + ]); + } + + if ($this->resource instanceof Service) { + app(DeleteService::class)->deleteLocal($this->resource); + + return; + } + if ($this->deleteVolumes) { - $this->resource->deleteVolumes(); $this->resource->persistentStorages()->delete(); } - $this->resource->fileStorages()->delete(); // these are file mounts which should probably have their own flag + $this->resource->fileStorages()->delete(); - $isDatabase = $this->resource instanceof StandalonePostgresql + if ($this->isDatabase()) { + $this->resource->sslCertificates()->delete(); + $this->resource->scheduledBackups()->delete(); + $this->resource->tags()->detach(); + } + $this->resource->environment_variables()->delete(); + $this->resource->forceDelete(); + }); + + Artisan::queue('cleanup:stucked-resources'); + } + + private function isDatabase(): bool + { + return $this->resource instanceof StandalonePostgresql || $this->resource instanceof StandaloneRedis || $this->resource instanceof StandaloneMongodb || $this->resource instanceof StandaloneMysql @@ -95,29 +143,6 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue || $this->resource instanceof StandaloneKeydb || $this->resource instanceof StandaloneDragonfly || $this->resource instanceof StandaloneClickhouse; - - if ($isDatabase) { - $this->resource->sslCertificates()->delete(); - $this->resource->scheduledBackups()->delete(); - $this->resource->tags()->detach(); - } - $this->resource->environment_variables()->delete(); - - if ($this->deleteConnectedNetworks && $this->resource->type() === 'application') { - $this->resource->deleteConnectedNetworks(); - } - } catch (\Throwable $e) { - throw $e; - } finally { - $this->resource->forceDelete(); - if ($this->dockerCleanup) { - $server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server'); - if ($server) { - CleanupDocker::dispatch($server, false, false); - } - } - Artisan::queue('cleanup:stucked-resources'); - } } private function deleteScheduledVolumeBackups(): void diff --git a/app/Jobs/DockerCleanupJob.php b/app/Jobs/DockerCleanupJob.php index 16f3d88ad9..5a7627e0a9 100644 --- a/app/Jobs/DockerCleanupJob.php +++ b/app/Jobs/DockerCleanupJob.php @@ -155,4 +155,38 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue } } } + + public function failed(?\Throwable $exception): void + { + $execution = DockerCleanupExecution::query() + ->where('server_id', $this->server->id) + ->where('status', 'running') + ->whereNull('finished_at') + ->latest('id') + ->first(); + + if (! $execution) { + return; + } + + $message = $exception?->getMessage() ?? 'Docker cleanup job failed without an exception.'; + + $updated = DockerCleanupExecution::query() + ->whereKey($execution->id) + ->where('status', 'running') + ->whereNull('finished_at') + ->update([ + 'status' => 'failed', + 'message' => $message, + 'finished_at' => Carbon::now()->toImmutable(), + ]); + + if ($updated === 0) { + return; + } + + $execution->refresh(); + event(new DockerCleanupDone($execution)); + $this->server->team?->notify(new DockerCleanupFailed($this->server, 'Docker cleanup job failed with the following error: '.$message)); + } } diff --git a/app/Jobs/RemoveContainerJob.php b/app/Jobs/RemoveContainerJob.php new file mode 100644 index 0000000000..de21603248 --- /dev/null +++ b/app/Jobs/RemoveContainerJob.php @@ -0,0 +1,49 @@ +serverId); + + instant_remote_process( + [dockerRemoveCommandWithTimeout($this->containerName)], + $server, + timeout: 75, + disableMultiplexing: true, + ); + } + + public function backoff(): array + { + return [300, 900]; + } + + public function failed(?\Throwable $exception): void + { + Log::warning('Deferred container removal failed', [ + 'server_id' => $this->serverId, + 'container' => $this->containerName, + 'error' => $exception?->getMessage(), + ]); + } +} diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index dc11ec89e7..f7bd5f933d 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -25,6 +25,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public const MAX_OUTPUT_SIZE_BYTES = 5 * 1024 * 1024; + /** * The number of times the job may be attempted. */ @@ -148,10 +150,12 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue foreach ($this->containers as $containerName) { if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) { $cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'"; - $exec = "docker exec {$containerName} {$cmd}"; + $dockerCommand = $this->server->isNonRoot() ? 'sudo docker' : 'docker'; + $execCommand = "{$dockerCommand} exec {$containerName} {$cmd}"; + $exec = $this->boundedTaskCommand($execCommand); // Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently // See: https://github.com/coollabsio/coolify/issues/6736 - $this->task_output = instant_remote_process([$exec], $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->task_output = instant_remote_process([$exec], $this->server, throwError: true, no_sudo: true, timeout: $this->timeout, disableMultiplexing: true); $this->task_log->update([ 'status' => 'success', 'message' => $this->task_output, @@ -204,6 +208,14 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue } } + private function boundedTaskCommand(string $command): string + { + $maxOutputBytes = self::MAX_OUTPUT_SIZE_BYTES; + $readLimit = $maxOutputBytes + 1; + + return "output_file=\$(mktemp); trap 'rm -f \"\$output_file\"' EXIT; set +e; set -o pipefail; {$command} 2>&1 | { head -c {$readLimit} > \"\$output_file\"; cat > /dev/null; }; exit_code=\${PIPESTATUS[0]}; if [ \"\$(wc -c < \"\$output_file\")\" -gt {$maxOutputBytes} ]; then truncate -s {$maxOutputBytes} \"\$output_file\"; printf '\n\n[... Output truncated at 5MB limit ...]' >> \"\$output_file\"; fi; if [ \"\$exit_code\" -eq 0 ]; then cat \"\$output_file\"; else cat \"\$output_file\" >&2; fi; exit \$exit_code"; + } + /** * Calculate the number of seconds to wait before retrying the job. */ diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php index 39998a1f60..b567a71b7f 100644 --- a/app/Jobs/VolumeBackupJob.php +++ b/app/Jobs/VolumeBackupJob.php @@ -28,14 +28,14 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue public int $maxExceptions = 1; - public int $timeout = 3600; + public int $timeout = ScheduledVolumeBackup::DEFAULT_TIMEOUT; private ?ScheduledVolumeBackupExecution $execution = null; public function __construct(public ScheduledVolumeBackup $backup) { $this->onQueue(crons_queue()); - $this->timeout = $backup->timeout ?? 3600; + $this->timeout = $backup->timeout ?? ScheduledVolumeBackup::DEFAULT_TIMEOUT; } public function middleware(): array diff --git a/app/Listeners/ProxyStatusChangedNotification.php b/app/Listeners/ProxyStatusChangedNotification.php index 30ecb2d8d5..9b117d4e13 100644 --- a/app/Listeners/ProxyStatusChangedNotification.php +++ b/app/Listeners/ProxyStatusChangedNotification.php @@ -61,7 +61,7 @@ class ProxyStatusChangedNotification implements ShouldQueueAfterCommit if ($status === 'created') { instant_remote_process([ - 'docker rm -f coolify-proxy', + dockerRemoveCommand('coolify-proxy'), ], $server); } } diff --git a/app/Livewire/ActivityMonitor.php b/app/Livewire/ActivityMonitor.php index 665d14ba0e..25935d88e2 100644 --- a/app/Livewire/ActivityMonitor.php +++ b/app/Livewire/ActivityMonitor.php @@ -29,7 +29,10 @@ class ActivityMonitor extends Component public static $eventDispatched = false; - protected $listeners = ['activityMonitor' => 'newMonitorActivity']; + protected $listeners = [ + 'activityMonitor' => 'newMonitorActivity', + 'processDialogClosed' => 'clearActivity', + ]; public function newMonitorActivity($activityId, $eventToDispatch = 'activityFinished', $eventData = null, $header = null) { @@ -50,6 +53,16 @@ class ActivityMonitor extends Component $this->isPollingActive = true; } + public function clearActivity(): void + { + $this->activityId = null; + $this->activity = null; + $this->isPollingActive = false; + $this->eventToDispatch = 'activityFinished'; + $this->eventData = null; + self::$eventDispatched = false; + } + public function hydrateActivity() { if ($this->activityId === null) { diff --git a/app/Livewire/Destination/Show.php b/app/Livewire/Destination/Show.php index 1b344c9056..03fa2b5109 100644 --- a/app/Livewire/Destination/Show.php +++ b/app/Livewire/Destination/Show.php @@ -81,7 +81,7 @@ class Show extends Component } $safeNetwork = escapeshellarg($this->destination->network); instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $this->destination->server, throwError: false); - instant_remote_process(["docker network rm -f {$safeNetwork}"], $this->destination->server); + instant_remote_process([dockerNetworkRemoveCommand($this->destination->network)], $this->destination->server); } $this->destination->delete(); diff --git a/app/Livewire/Dev/LivewireRequestFailurePreview.php b/app/Livewire/Dev/LivewireRequestFailurePreview.php new file mode 100644 index 0000000000..5cdda5d781 --- /dev/null +++ b/app/Livewire/Dev/LivewireRequestFailurePreview.php @@ -0,0 +1,31 @@ + + */ + public array $statuses = [502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 527, 530]; + + public function fail(int $status): never + { + abort_unless(in_array($status, $this->statuses, true), Response::HTTP_NOT_FOUND); + + throw new HttpResponseException(response( + '

Gateway time-out

cloudflare proxy error '.$status.'

', + $status, + ['Content-Type' => 'text/html'] + )); + } + + public function render(): mixed + { + return view('livewire.dev.livewire-request-failure-preview')->layout('layouts.simple'); + } +} diff --git a/app/Livewire/NavbarDeleteTeam.php b/app/Livewire/NavbarDeleteTeam.php index 52e4460add..e28cefba48 100644 --- a/app/Livewire/NavbarDeleteTeam.php +++ b/app/Livewire/NavbarDeleteTeam.php @@ -2,10 +2,8 @@ namespace App\Livewire; +use App\Actions\Team\DeleteTeam; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\DB; use Livewire\Component; class NavbarDeleteTeam extends Component @@ -28,22 +26,7 @@ class NavbarDeleteTeam extends Component $currentTeam = currentTeam(); $this->authorize('delete', $currentTeam); - - $currentTeam->members->each(function ($user) use ($currentTeam) { - if ($user->id === Auth::id()) { - return; - } - $user->teams()->detach($currentTeam); - $session = DB::table('sessions')->where('user_id', $user->id)->first(); - if ($session) { - DB::table('sessions')->where('id', $session->id)->delete(); - } - }); - - Cache::forget('user:'.Auth::id().':team:'.$currentTeam->id); - $currentTeam->delete(); - - $newTeam = Auth::user()->teams()->first(); + $newTeam = app(DeleteTeam::class)->handle($currentTeam, auth()->user()); refreshSession($newTeam); return redirect()->route('team.index'); diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php index 962c85c331..3d95668b91 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -6,6 +6,7 @@ use App\Livewire\Notifications\Concerns\TogglesNotificationEvents; use App\Models\EmailNotificationSettings; use App\Models\Team; use App\Notifications\Test; +use App\Rules\ValidHostname; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Locked; @@ -57,6 +58,9 @@ class Email extends Component #[Validate(['nullable', 'numeric'])] public ?string $smtpTimeout = null; + #[Validate(['nullable', 'string'])] + public ?string $smtpEhloDomain = null; + #[Validate(['boolean'])] public bool $resendEnabled = false; @@ -129,6 +133,7 @@ class Email extends Component { if ($toModel) { $this->validate(); + $this->validate(['smtpEhloDomain' => ['nullable', 'string', new ValidHostname]]); $this->authorize('update', $this->settings); $this->settings->smtp_enabled = $this->smtpEnabled; $this->settings->smtp_from_address = $this->smtpFromAddress; @@ -140,6 +145,7 @@ class Email extends Component $this->settings->smtp_username = $this->smtpUsername; $this->settings->smtp_password = $this->smtpPassword; $this->settings->smtp_timeout = $this->smtpTimeout; + $this->settings->smtp_ehlo_domain = $this->smtpEhloDomain; $this->settings->resend_enabled = $this->resendEnabled; $this->settings->resend_api_key = $this->resendApiKey; @@ -175,6 +181,7 @@ class Email extends Component ? $this->settings->smtp_password : null; $this->smtpTimeout = $this->settings->smtp_timeout; + $this->smtpEhloDomain = $this->settings->smtp_ehlo_domain; $this->resendEnabled = $this->settings->resend_enabled; $this->resendApiKey = auth()->user()->can('update', $this->settings) @@ -261,6 +268,7 @@ class Email extends Component 'smtpUsername' => 'nullable|string', 'smtpPassword' => 'nullable|string', 'smtpTimeout' => 'nullable|numeric', + 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], ], [ 'smtpFromAddress.required' => 'From Address is required.', 'smtpFromAddress.email' => 'Please enter a valid email address.', @@ -284,6 +292,7 @@ class Email extends Component $this->settings->smtp_username = $this->smtpUsername; $this->settings->smtp_password = $this->smtpPassword; $this->settings->smtp_timeout = $this->smtpTimeout; + $this->settings->smtp_ehlo_domain = $this->smtpEhloDomain; $this->settings->save(); $this->dispatch('success', 'SMTP settings updated.'); @@ -375,6 +384,7 @@ class Email extends Component $this->smtpUsername = $settings->smtp_username; $this->smtpPassword = $settings->smtp_password; $this->smtpTimeout = $settings->smtp_timeout; + $this->smtpEhloDomain = $settings->smtp_ehlo_domain; if ($settings->resend_enabled) { $this->resendEnabled = true; diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 45a76a4c33..2f9370871c 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -2,6 +2,8 @@ namespace App\Livewire\Project\Application; +use App\Actions\Shared\CheckDomainDns; +use App\Jobs\CheckDomainDnsJob; use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect; use App\Livewire\Project\Shared\ConfigurationChecker; use App\Models\Application; @@ -10,6 +12,7 @@ use App\Support\DomainUrlParts; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Livewire\Component; class Domains extends Component @@ -141,6 +144,39 @@ class Domains extends Component $this->loadDomainState(); } + public function pollDnsChecks(): void + { + $this->authorize('view', $this->application); + + $checkingRows = collect($this->domainRows) + ->where('dns_status', 'checking') + ->values(); + + $this->refreshDomains(); + + foreach ($checkingRows as $checkingRow) { + $row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url'] + && ($row['service'] ?? null) === ($checkingRow['service'] ?? null)); + + if (! is_array($row) || $row['dns_status'] === 'checking') { + continue; + } + + $this->dispatchDnsCheckNotification($row['url'], $row['dns_status']); + } + } + + protected function dispatchDnsCheckNotification(string $url, string $status): void + { + $host = parse_url($url, PHP_URL_HOST) ?: $url; + + match ($status) { + 'ok' => $this->dispatch('success', "DNS is configured correctly for {$host}."), + 'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record."), + default => $this->dispatch('info', "DNS check skipped for {$host}."), + }; + } + public function toggleNoindexDomain(string $domain, string|bool $indexing): void { $this->authorize('update', $this->application); @@ -464,6 +500,7 @@ class Domains extends Component 'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'), 'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp, 'checked_at' => data_get($entry, 'checked_at'), + 'check_id' => data_get($entry, 'check_id'), 'is_suggested' => false, 'suggested_for' => null, 'suggestion_label' => null, @@ -478,6 +515,7 @@ class Domains extends Component 'dns_message' => 'Not checked yet.', 'expected_ip' => $this->serverIp, 'checked_at' => null, + 'check_id' => null, 'is_suggested' => false, 'suggested_for' => null, 'suggestion_label' => null, @@ -533,6 +571,8 @@ class Domains extends Component || ! $server || $this->application->additional_servers->count() > 0; + $indexesToCheck = []; + foreach ($this->domainRows as $index => $row) { if ($skipDns) { $reason = ! $this->dnsValidationEnabled @@ -548,7 +588,11 @@ class Domains extends Component continue; } - $this->applyDnsStatus($index, $row['url'], $server); + $indexesToCheck[] = $index; + } + + if ($server && $indexesToCheck !== []) { + $this->applyDnsStatuses($indexesToCheck, $server); } $this->persistDomainDnsStatuses(); @@ -575,45 +619,50 @@ class Domains extends Component return; } - $this->applyDnsStatus($index, $this->domainRows[$index]['url'], $server); + $this->applyDnsStatus($index, $server); $this->persistDomainDnsStatuses(); } - protected function applyDnsStatus(int $index, string $url, Server $server): void + protected function applyDnsStatus(int $index, Server $server): void { - $target = $this->dnsTargetLabel(); + $this->applyDnsStatuses([$index], $server); + } - try { - $isValid = validateDNSEntry($url, $server); - if ($isValid) { - $this->domainRows[$index]['dns_status'] = 'ok'; - $this->domainRows[$index]['dns_message'] = $target - ? "DNS points to {$target} (or Cloudflare)." - : 'DNS looks correct.'; - } else { - $this->domainRows[$index]['dns_status'] = 'failed'; - $this->domainRows[$index]['dns_message'] = dnsMismatchGuidanceMessage($target, $this->serverIp); + /** + * @param array $indexes + */ + protected function applyDnsStatuses(array $indexes, Server $server): void + { + $entries = []; + + foreach ($indexes as $index) { + $entries[(string) $index] = $this->domainRows[$index]['url']; + } + + $results = CheckDomainDns::run($entries, $server, $this->serverIp); + + foreach ($results as $index => $result) { + $index = (int) $index; + $this->domainRows[$index]['dns_status'] = $result['status']; + $this->domainRows[$index]['dns_message'] = $result['message']; + + // Keep suggested-row copy short after DNS checks (no role badge). + if ($this->domainRows[$index]['is_suggested'] ?? false) { + $isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.'); + $serviceName = $this->domainRows[$index]['service'] ?? null; + $meta = $this->suggestedDomainMeta( + $isWww, + $this->serviceRedirectFor(is_string($serviceName) ? $serviceName : null) + ); + $this->domainRows[$index]['dns_message'] = $meta['pending_message']; + $this->domainRows[$index]['suggestion_label'] = null; + $this->domainRows[$index]['suggestion_role'] = $meta['role']; } - } catch (\Throwable) { - $this->domainRows[$index]['dns_status'] = 'failed'; - $this->domainRows[$index]['dns_message'] = 'Could not validate DNS for this domain.'; - } - // Keep suggested-row copy short after DNS checks (no role badge). - if ($this->domainRows[$index]['is_suggested'] ?? false) { - $isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.'); - $serviceName = $this->domainRows[$index]['service'] ?? null; - $meta = $this->suggestedDomainMeta( - $isWww, - $this->serviceRedirectFor(is_string($serviceName) ? $serviceName : null) - ); - $this->domainRows[$index]['dns_message'] = $meta['pending_message']; - $this->domainRows[$index]['suggestion_label'] = null; - $this->domainRows[$index]['suggestion_role'] = $meta['role']; + $this->domainRows[$index]['expected_ip'] = $result['expected_ip']; + $this->domainRows[$index]['checked_at'] = $result['checked_at']; + $this->domainRows[$index]['check_id'] = null; } - - $this->domainRows[$index]['expected_ip'] = $this->serverIp; - $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); } /** @@ -647,11 +696,34 @@ class Domains extends Component 'message' => (string) ($row['dns_message'] ?? ''), 'expected_ip' => $row['expected_ip'] ?? $this->serverIp, 'checked_at' => $row['checked_at'] ?? now()->toIso8601String(), + 'check_id' => $row['check_id'] ?? null, ]; } + DB::transaction(function () use (&$statuses): void { + $application = Application::query()->lockForUpdate()->findOrFail($this->application->id); + $storedStatuses = $application->domain_dns_statuses ?? []; + + foreach ($statuses as $key => $status) { + $localCheckId = $status['check_id'] ?? null; + $storedCheckId = $storedStatuses[$key]['check_id'] ?? null; + + if ($storedCheckId !== null && $localCheckId !== $storedCheckId) { + $statuses[$key] = $storedStatuses[$key]; + + continue; + } + + if ($status['status'] === 'checking' && isset($storedStatuses[$key]) && $storedStatuses[$key]['status'] !== 'checking') { + $statuses[$key] = $storedStatuses[$key]; + } + } + + $application->domain_dns_statuses = $statuses === [] ? null : $statuses; + $application->save(); + }); + $this->application->domain_dns_statuses = $statuses === [] ? null : $statuses; - $this->application->save(); } protected function pruneDomainDnsStatusesToCurrentDomains(): void @@ -804,16 +876,6 @@ class Domains extends Component } } - if (! $this->forceSaveDns && $this->shouldValidateDnsForAdd()) { - $dnsFailure = $this->findDnsFailureMessage($newUrls); - if ($dnsFailure !== null) { - $this->addDomainDnsFailed = true; - $this->addDomainDnsMessage = $dnsFailure; - - return; - } - } - $merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values(); $this->pendingAction = 'add'; if (! $this->saveDomainList($merged, $this->newDomainService)) { @@ -825,14 +887,110 @@ class Domains extends Component $serviceForCheck = $this->newDomainService; $this->resetAddDomainForm(); $this->dispatch('close-modal'); - $this->dispatch('success', 'Domain added.'); $this->refreshDomains(); - $this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), $serviceForCheck); + $urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls))); + $dnsChecks = collect($this->dnsEntriesForUrls($urlsToCheck, $serviceForCheck)) + ->map(fn (string $url, string $statusKey) => [ + 'status_key' => $statusKey, + 'url' => $url, + 'check_id' => new_public_id(), + ]); + + foreach ($dnsChecks as $dnsCheck) { + $this->markUrlsAsChecking([$dnsCheck['url']], $serviceForCheck, $dnsCheck['check_id']); + } + $this->persistDomainDnsStatuses(); + + $failedDnsChecks = 0; + foreach ($dnsChecks as $dnsCheck) { + try { + CheckDomainDnsJob::dispatch( + $this->application, + $dnsCheck['status_key'], + $dnsCheck['url'], + $this->application->destination?->server, + $this->serverIp, + $dnsCheck['check_id'], + $this->application->additional_servers->count() > 0, + ); + } catch (\Throwable) { + $failedDnsChecks++; + $this->markUrlsDnsCheckUnavailable([$dnsCheck['url']], $serviceForCheck, $dnsCheck['check_id']); + } + } + + if ($failedDnsChecks > 0) { + $this->persistDomainDnsStatuses(); + $this->dispatch('error', 'Some DNS checks could not be started. Try again from the Domains page.'); + } + + $this->dispatch('success', $failedDnsChecks === $dnsChecks->count() + ? 'Domain added.' + : 'Domain added. DNS check started.'); } catch (\Throwable $e) { handleError($e, $this); } } + /** + * @param array $urls + */ + protected function markUrlsAsChecking(array $urls, ?string $service = null, ?string $checkId = null): void + { + $indexesToCheck = []; + + foreach ($this->domainRows as $index => $row) { + if (! in_array($row['url'], $urls, true)) { + continue; + } + + if ($service !== null && ($row['service'] ?? null) !== $service) { + continue; + } + + $this->domainRows[$index]['dns_status'] = 'checking'; + $this->domainRows[$index]['dns_message'] = 'Checking DNS...'; + $this->domainRows[$index]['check_id'] = $checkId; + } + } + + /** + * @param array $urls + */ + protected function markUrlsDnsCheckUnavailable(array $urls, ?string $service = null, ?string $checkId = null): void + { + $this->markUrlsAsChecking($urls, $service, $checkId); + + foreach ($this->domainRows as $index => $row) { + if (! in_array($row['url'], $urls, true)) { + continue; + } + + if ($service !== null && ($row['service'] ?? null) !== $service) { + continue; + } + + $this->domainRows[$index]['dns_status'] = 'skipped'; + $this->domainRows[$index]['dns_message'] = 'DNS check could not be started.'; + $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); + } + } + + /** + * @param array $urls + * @return array + */ + protected function dnsEntriesForUrls(array $urls, ?string $service = null): array + { + $entries = []; + + foreach ($urls as $url) { + $entries[$this->domainDnsStatusKey($url, $service)] = $url; + } + + return $entries; + } + /** * Run a first-time DNS check for newly added/updated domain URLs and persist results. * @@ -875,7 +1033,11 @@ class Domains extends Component continue; } - $this->applyDnsStatus($index, $url, $server); + $indexesToCheck[] = $index; + } + + if ($server && $indexesToCheck !== []) { + $this->applyDnsStatuses($indexesToCheck, $server); } $this->persistDomainDnsStatuses(); @@ -909,15 +1071,11 @@ class Domains extends Component return null; } - $target = $this->dnsTargetLabel() ?? $server->ip; + $results = CheckDomainDns::run(array_combine($urls, $urls), $server, $this->serverIp); - foreach ($urls as $url) { - try { - if (! validateDNSEntry($url, $server)) { - return dnsMismatchGuidanceMessage($target, $this->serverIp); - } - } catch (\Throwable) { - return 'Could not validate DNS for this domain.'; + foreach ($results as $result) { + if ($result['status'] === 'failed') { + return $result['message']; } } diff --git a/app/Livewire/Project/Database/ImportForm.php b/app/Livewire/Project/Database/ImportForm.php index ccd3435106..5b8d90e2bb 100644 --- a/app/Livewire/Project/Database/ImportForm.php +++ b/app/Livewire/Project/Database/ImportForm.php @@ -796,6 +796,8 @@ EOD; * * Hardened against bypasses: * - decompresses gzip backups before scanning, + * - converts custom-format (PGDMP) archives to SQL with pg_restore + * before scanning, and rejects archives that cannot be inspected, * - strips `--` line comments and flattens newlines so multi-line and * comment-separated payloads (e.g. `FROM/**​/PROGRAM`) are caught, * - matches a literal `\!` shell escape and `\o|`/`\g|` pipe redirects. @@ -817,8 +819,30 @@ EOD; $escapedSqlPattern = escapeshellarg($sqlPattern); $escapedPsqlPattern = escapeshellarg($psqlPattern); $contents = "{ gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}; }"; + $scan = static fn (string $source): string => "{$source} | sed 's/--.*//' | grep -Eiq {$escapedPsqlPattern} || {$source} | sed 's/--.*//' | tr '\\n\\r\\t' ' ' | grep -Eiq {$escapedSqlPattern}"; + $customScan = $scan('pg_restore -f - "$inspect" 2>/dev/null'); + $sqlScan = $scan($contents); + $blockedProgram = 'echo \'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.\'; exit 1'; + $blockedInspect = 'echo \'Blocked PostgreSQL restore: unable to inspect custom archive.\'; exit 1'; - return "header=\$({$contents} | head -c 5); if [ \"\$header\" = 'PGDMP' ]; then exit 0; fi; if {$contents} | sed 's/--.*//' | grep -Eiq {$escapedPsqlPattern} || {$contents} | sed 's/--.*//' | tr '\n\r\t' ' ' | grep -Eiq {$escapedSqlPattern}; then echo 'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.'; exit 1; fi"; + return << "\$inspect"; then + {$blockedInspect} + fi + if ! pg_restore -l "\$inspect" >/dev/null 2>&1; then + {$blockedInspect} + fi + if {$customScan}; then + {$blockedProgram} + fi +elif {$sqlScan}; then + {$blockedProgram} +fi +SH; } private function addRestoreSafetyCheckCommand(array &$commands, string $tmpPath): void @@ -883,7 +907,7 @@ EOD; case 'postgresql': $restoreCommand = $this->postgresqlRestoreCommand; if ($this->dumpAll) { - $restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}"; + $restoreCommand .= " && if [ \"\$({ gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}; } | head -c 5)\" = 'PGDMP' ]; then pg_restore -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}} {$escapedTmpPath}; else (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}; fi"; } else { $restoreCommand .= " {$escapedTmpPath}"; } diff --git a/app/Livewire/Project/Service/Configuration.php b/app/Livewire/Project/Service/Configuration.php index caa19042b8..f3ec3ba4df 100644 --- a/app/Livewire/Project/Service/Configuration.php +++ b/app/Livewire/Project/Service/Configuration.php @@ -26,10 +26,17 @@ class Configuration extends Component public array $parameters; - protected $listeners = [ - 'refreshServices' => 'refreshServices', - 'refresh' => 'refreshServices', - ]; + public function getListeners(): array + { + $teamId = auth()->user()->currentTeam()->id; + + return [ + 'refreshServices' => 'refreshServices', + 'refresh' => 'refreshServices', + 'configurationChanged' => 'refreshServices', + "echo-private:team.{$teamId},ApplicationConfigurationChanged" => 'refreshServices', + ]; + } public function render() { diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index 4690335d86..d932e76494 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -2,6 +2,8 @@ namespace App\Livewire\Project\Service; +use App\Actions\Shared\CheckDomainDns; +use App\Jobs\CheckDomainDnsJob; use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect; use App\Livewire\Project\Shared\ConfigurationChecker; use App\Models\Server; @@ -131,6 +133,39 @@ class Domains extends Component $this->loadDomainState(); } + public function pollDnsChecks(): void + { + $this->authorize('view', $this->service); + + $checkingRows = collect($this->domainRows) + ->where('dns_status', 'checking') + ->values(); + + $this->refreshDomains(); + + foreach ($checkingRows as $checkingRow) { + $row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url'] + && (int) $row['service_application_id'] === (int) $checkingRow['service_application_id']); + + if (! is_array($row) || $row['dns_status'] === 'checking') { + continue; + } + + $this->dispatchDnsCheckNotification($row['url'], $row['dns_status']); + } + } + + protected function dispatchDnsCheckNotification(string $url, string $status): void + { + $host = parse_url($url, PHP_URL_HOST) ?: $url; + + match ($status) { + 'ok' => $this->dispatch('success', "DNS is configured correctly for {$host}."), + 'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record."), + default => $this->dispatch('info', "DNS check skipped for {$host}."), + }; + } + public function toggleNoindexDomain(int $serviceApplicationId, string $domain, string|bool $indexing): void { $application = $this->service->applications()->findOrFail($serviceApplicationId); @@ -282,6 +317,7 @@ class Domains extends Component 'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'), 'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp, 'checked_at' => data_get($entry, 'checked_at'), + 'check_id' => data_get($entry, 'check_id'), 'is_suggested' => false, 'suggested_for' => null, 'suggestion_label' => null, @@ -298,6 +334,7 @@ class Domains extends Component 'dns_message' => 'Not checked yet.', 'expected_ip' => $this->serverIp, 'checked_at' => null, + 'check_id' => null, 'is_suggested' => false, 'suggested_for' => null, 'suggestion_label' => null, @@ -404,6 +441,8 @@ class Domains extends Component $server = $this->service->server; $skipDns = ! $this->dnsValidationEnabled || ! $server; + $indexesToCheck = []; + foreach ($this->domainRows as $index => $row) { if ($skipDns) { $this->domainRows[$index]['dns_status'] = 'skipped'; @@ -415,7 +454,11 @@ class Domains extends Component continue; } - $this->applyDnsStatus($index, $row['url'], $server); + $indexesToCheck[] = $index; + } + + if ($server && $indexesToCheck !== []) { + $this->applyDnsStatuses($indexesToCheck, $server); } $this->persistAllDomainDnsStatuses(); @@ -443,33 +486,37 @@ class Domains extends Component return; } - $this->applyDnsStatus($index, $this->domainRows[$index]['url'], $server); + $this->applyDnsStatus($index, $server); $this->persistAllDomainDnsStatuses(); } - protected function applyDnsStatus(int $index, string $url, Server $server): void + protected function applyDnsStatus(int $index, Server $server): void { - $target = $this->dnsTargetLabel(); + $this->applyDnsStatuses([$index], $server); + } - try { - $isValid = validateDNSEntry($url, $server); - if ($isValid) { - $this->domainRows[$index]['dns_status'] = 'ok'; - $this->domainRows[$index]['dns_message'] = $target - ? "DNS points to {$target} (or Cloudflare)." - : 'DNS looks correct.'; - } else { - $this->domainRows[$index]['dns_status'] = 'failed'; - $this->domainRows[$index]['dns_message'] = dnsMismatchGuidanceMessage($target, $this->serverIp); - } - } catch (\Throwable) { - $this->domainRows[$index]['dns_status'] = 'failed'; - $this->domainRows[$index]['dns_message'] = 'Could not validate DNS for this domain.'; + /** + * @param array $indexes + */ + protected function applyDnsStatuses(array $indexes, Server $server): void + { + $entries = []; + + foreach ($indexes as $index) { + $entries[(string) $index] = $this->domainRows[$index]['url']; } - $this->domainRows[$index]['expected_ip'] = $this->serverIp; - $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); - $this->decorateSuggestedDomainAfterDnsCheck($index); + $results = CheckDomainDns::run($entries, $server, $this->serverIp); + + foreach ($results as $index => $result) { + $index = (int) $index; + $this->domainRows[$index]['dns_status'] = $result['status']; + $this->domainRows[$index]['dns_message'] = $result['message']; + $this->domainRows[$index]['expected_ip'] = $result['expected_ip']; + $this->domainRows[$index]['checked_at'] = $result['checked_at']; + $this->domainRows[$index]['check_id'] = null; + $this->decorateSuggestedDomainAfterDnsCheck($index); + } } /** @@ -516,6 +563,7 @@ class Domains extends Component 'message' => (string) ($row['dns_message'] ?? ''), 'expected_ip' => $row['expected_ip'] ?? $this->serverIp, 'checked_at' => $row['checked_at'] ?? now()->toIso8601String(), + 'check_id' => $row['check_id'] ?? null, ]; } @@ -528,8 +576,30 @@ class Domains extends Component ->all(); $statuses = array_intersect_key($statuses, array_flip($currentUrls)); + DB::transaction(function () use ($app, &$statuses): void { + $application = ServiceApplication::query()->lockForUpdate()->findOrFail($app->id); + $storedStatuses = $application->domain_dns_statuses ?? []; + + foreach ($statuses as $key => $status) { + $localCheckId = $status['check_id'] ?? null; + $storedCheckId = $storedStatuses[$key]['check_id'] ?? null; + + if ($storedCheckId !== null && $localCheckId !== $storedCheckId) { + $statuses[$key] = $storedStatuses[$key]; + + continue; + } + + if ($status['status'] === 'checking' && isset($storedStatuses[$key]) && $storedStatuses[$key]['status'] !== 'checking') { + $statuses[$key] = $storedStatuses[$key]; + } + } + + $application->domain_dns_statuses = $statuses === [] ? null : $statuses; + $application->save(); + }); + $app->domain_dns_statuses = $statuses === [] ? null : $statuses; - $app->save(); } $this->service->load('applications'); @@ -928,16 +998,6 @@ class Domains extends Component } } - if (! $this->forceSaveDns && $this->shouldValidateDns()) { - $dnsFailure = $this->findDnsFailureMessage($newUrls); - if ($dnsFailure !== null) { - $this->addDomainDnsFailed = true; - $this->addDomainDnsMessage = $dnsFailure; - - return; - } - } - $merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values(); $this->pendingAction = 'add'; @@ -955,14 +1015,93 @@ class Domains extends Component $this->forceRemovePort = false; $this->pendingAction = null; $this->dispatch('close-modal'); - $this->dispatch('success', 'Domain added.'); $this->refreshDomains(); - $this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), (int) $app->id); + $urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls))); + $serviceApplicationId = (int) $app->id; + $dnsChecks = collect($urlsToCheck)->map(fn (string $url) => [ + 'url' => $url, + 'check_id' => new_public_id(), + ]); + + foreach ($dnsChecks as $dnsCheck) { + $this->markUrlsAsChecking([$dnsCheck['url']], $serviceApplicationId, $dnsCheck['check_id']); + } + $this->persistAllDomainDnsStatuses(); + + $failedDnsChecks = 0; + foreach ($dnsChecks as $dnsCheck) { + try { + CheckDomainDnsJob::dispatch( + $app, + $dnsCheck['url'], + $dnsCheck['url'], + $this->service->server, + $this->serverIp, + $dnsCheck['check_id'], + ); + } catch (\Throwable) { + $failedDnsChecks++; + $this->markUrlsDnsCheckUnavailable([$dnsCheck['url']], $serviceApplicationId, $dnsCheck['check_id']); + } + } + + if ($failedDnsChecks > 0) { + $this->persistAllDomainDnsStatuses(); + $this->dispatch('error', 'Some DNS checks could not be started. Try again from the Domains page.'); + } + + $this->dispatch('success', $failedDnsChecks === $dnsChecks->count() + ? 'Domain added.' + : 'Domain added. DNS check started.'); } catch (\Throwable $e) { handleError($e, $this); } } + /** + * @param array $urls + */ + protected function markUrlsAsChecking(array $urls, int $serviceApplicationId, ?string $checkId = null): void + { + $indexesToCheck = []; + + foreach ($this->domainRows as $index => $row) { + if (! in_array($row['url'], $urls, true)) { + continue; + } + + if ((int) ($row['service_application_id'] ?? 0) !== $serviceApplicationId) { + continue; + } + + $this->domainRows[$index]['dns_status'] = 'checking'; + $this->domainRows[$index]['dns_message'] = 'Checking DNS...'; + $this->domainRows[$index]['check_id'] = $checkId; + } + } + + /** + * @param array $urls + */ + protected function markUrlsDnsCheckUnavailable(array $urls, int $serviceApplicationId, ?string $checkId = null): void + { + $this->markUrlsAsChecking($urls, $serviceApplicationId, $checkId); + + foreach ($this->domainRows as $index => $row) { + if (! in_array($row['url'], $urls, true)) { + continue; + } + + if ((int) ($row['service_application_id'] ?? 0) !== $serviceApplicationId) { + continue; + } + + $this->domainRows[$index]['dns_status'] = 'skipped'; + $this->domainRows[$index]['dns_message'] = 'DNS check could not be started.'; + $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); + } + } + public function startEdit(int $index): void { if (! isset($this->domainRows[$index]) || ($this->domainRows[$index]['is_suggested'] ?? false)) { @@ -1309,7 +1448,11 @@ class Domains extends Component continue; } - $this->applyDnsStatus($index, $url, $server); + $indexesToCheck[] = $index; + } + + if ($server && $indexesToCheck !== []) { + $this->applyDnsStatuses($indexesToCheck, $server); } $this->persistAllDomainDnsStatuses(); @@ -1330,15 +1473,11 @@ class Domains extends Component return null; } - $target = $this->dnsTargetLabel() ?? $server->ip; + $results = CheckDomainDns::run(array_combine($urls, $urls), $server, $this->serverIp); - foreach ($urls as $url) { - try { - if (! validateDNSEntry($url, $server)) { - return dnsMismatchGuidanceMessage($target, $this->serverIp); - } - } catch (\Throwable) { - return 'Could not validate DNS for this domain.'; + foreach ($results as $result) { + if ($result['status'] === 'failed') { + return $result['message']; } } diff --git a/app/Livewire/Project/Service/VolumeBackup/Create.php b/app/Livewire/Project/Service/VolumeBackup/Create.php index adb1234e69..430e89a2c1 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Create.php +++ b/app/Livewire/Project/Service/VolumeBackup/Create.php @@ -99,8 +99,8 @@ class Create extends Component $label = str($resource->name)->headline(); $targets->push(...$resource->persistentStorages()->orderBy('name')->get()->map(fn (LocalPersistentVolume $volume): array => [ 'key' => 'volume:'.$volume->id, - 'type' => 'Volume · '.$label, - 'name' => $volume->name, + 'type' => $label, + 'name' => str($volume->name)->after($this->service->uuid.'_')->value(), ])); $targets->push(...$resource->fileStorages() ->where('is_directory', true) @@ -109,8 +109,8 @@ class Create extends Component ->get() ->map(fn (LocalFileVolume $directory): array => [ 'key' => 'directory:'.$directory->id, - 'type' => 'Directory · '.$label, - 'name' => $directory->fs_path, + 'type' => $label, + 'name' => $directory->fs_path.' (directory)', ])); } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 7f37b1fc4d..cd86d8670f 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use App\Events\ApplicationConfigurationChanged; use App\Models\Application; use App\Models\Environment; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; @@ -298,6 +299,10 @@ class Show extends Component $this->dispatch('success', 'Environment variable updated.'); $this->dispatch('envsUpdated'); $this->dispatch('configurationChanged'); + + if ($this->is_required && $this->resource instanceof Service) { + event(new ApplicationConfigurationChanged($this->resource->team()->id)); + } } catch (\Exception $e) { return handleError($e); } diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index d0121bdc51..67a040ef77 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -25,6 +25,8 @@ class GetLogs extends Component { public const MAX_LOG_LINES = 50000; + public const MAX_DISPLAY_SIZE_BYTES = 5 * 1024 * 1024; + public const MAX_DOWNLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50MB public string $outputs = ''; @@ -154,14 +156,12 @@ class GetLogs extends Component $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } else { $command = "docker logs -n {$this->numberOfLines} -t {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } } else { if ($this->server->isSwarm()) { @@ -170,22 +170,39 @@ class GetLogs extends Component $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } else { $command = "docker logs -n {$this->numberOfLines} {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } } + $command = $this->boundedLogCommand($command, self::MAX_DISPLAY_SIZE_BYTES); + $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); + // Collect new logs into temporary variable first to prevent flickering // (avoids clearing output before new data is ready) // Use array accumulation + implode for O(n) instead of O(n²) string concatenation $logChunks = []; - Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks) { + $accumulatedBytes = 0; + $truncated = false; + Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks, &$accumulatedBytes, &$truncated) { + if ($truncated) { + return; + } + + $remainingBytes = self::MAX_DISPLAY_SIZE_BYTES - $accumulatedBytes; + $outputBytes = strlen($output); + if ($outputBytes > $remainingBytes) { + $logChunks[] = removeAnsiColors(substr($output, 0, max(0, $remainingBytes))); + $truncated = true; + + return; + } + $logChunks[] = removeAnsiColors($output); + $accumulatedBytes += $outputBytes; }); $newOutputs = implode('', $logChunks); @@ -198,6 +215,10 @@ class GetLogs extends Component })->join("\n"); } + if ($truncated) { + $newOutputs .= "\n\n[... Output truncated at 5MB limit ...]"; + } + // Only update outputs after new data is ready (atomic update prevents flicker) $this->outputs = $newOutputs; } @@ -239,6 +260,7 @@ class GetLogs extends Component $command = $command[0]; } + $command = $this->boundedLogCommand($command, self::MAX_DOWNLOAD_SIZE_BYTES); $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); // Use array accumulation + implode for O(n) instead of O(n²) string concatenation @@ -252,20 +274,19 @@ class GetLogs extends Component return; } - $output = removeAnsiColors($output); $outputBytes = strlen($output); if ($accumulatedBytes + $outputBytes > self::MAX_DOWNLOAD_SIZE_BYTES) { $remaining = self::MAX_DOWNLOAD_SIZE_BYTES - $accumulatedBytes; if ($remaining > 0) { - $logChunks[] = substr($output, 0, $remaining); + $logChunks[] = removeAnsiColors(substr($output, 0, $remaining)); } $truncated = true; return; } - $logChunks[] = $output; + $logChunks[] = removeAnsiColors($output); $accumulatedBytes += $outputBytes; }); @@ -287,6 +308,11 @@ class GetLogs extends Component return sanitizeLogsForExport($allLogs); } + private function boundedLogCommand(string $command, int $maxBytes): string + { + return "({$command}) 2>&1 | head -c ".($maxBytes + 1); + } + public function render() { return view('livewire.project.shared.get-logs'); diff --git a/app/Livewire/Project/Shared/ScheduledTask/Add.php b/app/Livewire/Project/Shared/ScheduledTask/Add.php index 2d6b76c25f..61bc6b0fbc 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Add.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Add.php @@ -2,7 +2,10 @@ namespace App\Livewire\Project\Shared\ScheduledTask; +use App\Models\Application; use App\Models\ScheduledTask; +use App\Models\Service; +use App\Models\StandalonePostgresql; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; @@ -59,13 +62,13 @@ class Add extends Component // Get the resource based on type and id switch ($this->type) { case 'application': - $this->resource = \App\Models\Application::findOrFail($this->id); + $this->resource = Application::ownedByCurrentTeam()->findOrFail($this->id); break; case 'service': - $this->resource = \App\Models\Service::findOrFail($this->id); + $this->resource = Service::ownedByCurrentTeam()->findOrFail($this->id); break; case 'standalone-postgresql': - $this->resource = \App\Models\StandalonePostgresql::findOrFail($this->id); + $this->resource = StandalonePostgresql::ownedByCurrentTeam()->findOrFail($this->id); break; default: throw new \Exception('Invalid resource type'); diff --git a/app/Livewire/Project/Shared/ScheduledTask/Executions.php b/app/Livewire/Project/Shared/ScheduledTask/Executions.php index ca2bbd9b45..e95fd2f5a1 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Executions.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Executions.php @@ -10,6 +10,7 @@ use Livewire\Component; class Executions extends Component { + #[Locked] public ScheduledTask $task; #[Locked] @@ -28,6 +29,7 @@ class Executions extends Component public $logsPerPage = 100; + #[Locked] public $selectedExecution = null; public $isPollingActive = false; @@ -45,7 +47,7 @@ class Executions extends Component { try { $this->taskId = $taskId; - $this->task = ScheduledTask::findOrFail($taskId); + $this->task = ScheduledTask::where('team_id', Auth::user()->currentTeam()->id)->findOrFail($taskId); $this->executions = $this->task->executions()->take(20)->get(); $this->serverTimezone = data_get($this->task, 'application.destination.server.settings.server_timezone'); if (! $this->serverTimezone) { diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index 882737f09b..11df001531 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -15,8 +15,10 @@ class Show extends Component { use AuthorizesRequests; + #[Locked] public Application|Service $resource; + #[Locked] public ScheduledTask $task; #[Locked] @@ -115,6 +117,7 @@ class Show extends Component { try { $this->authorize('update', $this->resource); + $this->authorize('update', $this->task); $this->isEnabled = ! $this->isEnabled; $this->task->enabled = $this->isEnabled; $this->task->save(); @@ -128,6 +131,7 @@ class Show extends Component { try { $this->authorize('update', $this->resource); + $this->authorize('update', $this->task); $this->syncData(true); $this->dispatch('success', 'Scheduled task updated.'); $this->refreshTasks(); @@ -140,6 +144,7 @@ class Show extends Component { try { $this->authorize('update', $this->resource); + $this->authorize('update', $this->task); $this->syncData(true); $this->dispatch('success', 'Scheduled task updated.'); } catch (\Exception $e) { @@ -160,6 +165,7 @@ class Show extends Component { try { $this->authorize('update', $this->resource); + $this->authorize('delete', $this->task); $this->task->delete(); if ($this->type === 'application') { @@ -176,6 +182,7 @@ class Show extends Component { try { $this->authorize('update', $this->resource); + $this->authorize('update', $this->task); ScheduledTaskJob::dispatch($this->task); $this->dispatch('success', 'Scheduled task executed.'); } catch (\Exception $e) { diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index a03820b4b5..a10eb5ad03 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -56,7 +56,7 @@ class VolumeBackups extends Component public string $timezone = ''; - public int $timeout = 3600; + public int $timeout = ScheduledVolumeBackup::DEFAULT_TIMEOUT; public int $perPage = 10; diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index d6bd6e54bf..5a978ac84f 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -59,7 +59,10 @@ class ApiTokens extends Component private function getTokens() { - $this->tokens = auth()->user()->tokens->sortByDesc('created_at'); + $this->tokens = auth()->user()->tokens() + ->where('team_id', currentTeam()->id) + ->latest() + ->get(); } public function updatedPermissions($permissionToUpdate) @@ -148,7 +151,10 @@ class ApiTokens extends Component public function revoke(int $id) { try { - $token = auth()->user()->tokens()->where('id', $id)->firstOrFail(); + $token = auth()->user()->tokens() + ->where('team_id', currentTeam()->id) + ->where('id', $id) + ->firstOrFail(); $this->authorize('delete', $token); $token->delete(); $this->getTokens(); diff --git a/app/Livewire/Server/DockerCleanupExecutions.php b/app/Livewire/Server/DockerCleanupExecutions.php index 56d6130644..6a739bc84c 100644 --- a/app/Livewire/Server/DockerCleanupExecutions.php +++ b/app/Livewire/Server/DockerCleanupExecutions.php @@ -2,7 +2,6 @@ namespace App\Livewire\Server; -use App\Models\DockerCleanupExecution; use App\Models\Server; use Illuminate\Support\Collection; use Livewire\Component; @@ -46,7 +45,7 @@ class DockerCleanupExecutions extends Component ->get(); if ($this->selectedKey) { - $this->selectedExecution = DockerCleanupExecution::find($this->selectedKey); + $this->selectedExecution = $this->server->dockerCleanupExecutions()->find($this->selectedKey); if ($this->selectedExecution && $this->selectedExecution->status !== 'running') { $this->isPollingActive = false; } @@ -64,7 +63,7 @@ class DockerCleanupExecutions extends Component return; } $this->selectedKey = $key; - $this->selectedExecution = DockerCleanupExecution::find($key); + $this->selectedExecution = $this->server->dockerCleanupExecutions()->find($key); $this->currentPage = 1; if ($this->selectedExecution && $this->selectedExecution->status === 'running') { diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php index 31a8578657..d9f70ea253 100644 --- a/app/Livewire/Server/Navbar.php +++ b/app/Livewire/Server/Navbar.php @@ -163,6 +163,7 @@ class Navbar extends Component $previousStatus = $this->proxyStatus; $this->server->refresh(); $this->proxyStatus = $this->server->proxy->status ?? 'unknown'; + $this->dispatchProxyConfigurationState(); // If event contains activityId, open activity monitor if ($event && isset($event['activityId'])) { @@ -227,6 +228,16 @@ class Navbar extends Component { $this->server->refresh(); $this->server->load('settings'); + $this->dispatchProxyConfigurationState(); + } + + private function dispatchProxyConfigurationState(): void + { + $this->dispatch( + 'proxy-configuration-state-changed', + pending: $this->server->hasPendingProxyConfiguration(), + traefikOutdated: $this->server->hasCurrentTraefikOutdatedInfo(), + ); } public function refreshSentinelStatus($event = null): void @@ -248,10 +259,12 @@ class Navbar extends Component return false; } - // Check if server has outdated info stored - $outdatedInfo = $this->server->traefik_outdated_info; + return $this->server->hasCurrentTraefikOutdatedInfo(); + } - return ! empty($outdatedInfo) && isset($outdatedInfo['type']); + public function getHasPendingProxyConfigurationProperty(): bool + { + return $this->server->hasPendingProxyConfiguration(); } public function render() diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 8cd4e96405..811a01eb19 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -161,6 +161,7 @@ class Proxy extends Component $this->server->proxy->redirect_url = $this->redirectUrl; $this->server->save(); $this->server->setupDefaultRedirect(); + $this->dispatch('refreshServerShow'); $this->dispatch('success', 'Proxy configuration saved.'); } catch (\Throwable $e) { return handleError($e, $this); @@ -175,6 +176,7 @@ class Proxy extends Component $this->proxySettings = GetProxyConfiguration::run($this->server, forceRegenerate: true); SaveProxyConfiguration::run($this->server, $this->proxySettings); $this->server->save(); + $this->dispatch('refreshServerShow'); $this->dispatch('success', 'Proxy configuration reset to default.'); } catch (\Throwable $e) { return handleError($e, $this); @@ -276,7 +278,9 @@ class Proxy extends Component // Check if we have outdated info stored for this server (faster than computing) $outdatedInfo = $this->server->traefik_outdated_info; - if ($outdatedInfo && isset($outdatedInfo['type']) && $outdatedInfo['type'] === 'minor_upgrade') { + $storedCurrentVersion = ltrim((string) data_get($outdatedInfo, 'current'), 'v'); + $detectedCurrentVersion = ltrim($currentVersion, 'v'); + if ($storedCurrentVersion === $detectedCurrentVersion && data_get($outdatedInfo, 'type') === 'minor_upgrade') { // Use the upgrade_target field if available (e.g., "v3.6") if (isset($outdatedInfo['upgrade_target'])) { return str_starts_with($outdatedInfo['upgrade_target'], 'v') diff --git a/app/Livewire/Server/Proxy/DynamicConfigurations.php b/app/Livewire/Server/Proxy/DynamicConfigurations.php index f824645aa6..6351dace86 100644 --- a/app/Livewire/Server/Proxy/DynamicConfigurations.php +++ b/app/Livewire/Server/Proxy/DynamicConfigurations.php @@ -11,6 +11,12 @@ class DynamicConfigurations extends Component { use AuthorizesRequests; + public const MAX_CONFIGURATION_FILE_SIZE_BYTES = 1024 * 1024; + + public const MAX_TOTAL_CONFIGURATION_SIZE_BYTES = 5 * 1024 * 1024; + + public const MAX_CONFIGURATION_FILES = 100; + public ?Server $server = null; public $parameters = []; @@ -44,15 +50,36 @@ class DynamicConfigurations extends Component return handleError($e, $this); } $proxy_path = $this->server->proxyPath(); - $files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic"], $this->server); + $fileLimit = self::MAX_CONFIGURATION_FILES + 1; + $files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic | head -n {$fileLimit}"], $this->server); $files = collect(explode("\n", $files))->filter(fn ($file) => ! empty($file)); $files = $files->map(fn ($file) => trim($file)); $files = $files->sort(); $contents = collect([]); - foreach ($files as $file) { + $skippedFiles = collect([]); + $totalBytes = 0; + if ($files->count() > self::MAX_CONFIGURATION_FILES) { + $skippedFiles->push('additional files'); + } + foreach ($files->take(self::MAX_CONFIGURATION_FILES) as $file) { $without_extension = str_replace('.', '|', $file); - $content = instant_remote_process(["cat {$proxy_path}/dynamic/{$file}"], $this->server); - $contents[$without_extension] = $content ?? ''; + $filePath = escapeshellarg("{$proxy_path}/dynamic/{$file}"); + $readLimit = self::MAX_CONFIGURATION_FILE_SIZE_BYTES + 1; + $content = instant_remote_process(["head -c {$readLimit} {$filePath}"], $this->server); + $content = $content ?? ''; + $contentBytes = strlen($content); + + if ($contentBytes > self::MAX_CONFIGURATION_FILE_SIZE_BYTES || $totalBytes + $contentBytes > self::MAX_TOTAL_CONFIGURATION_SIZE_BYTES) { + $skippedFiles->push($file); + + continue; + } + + $contents[$without_extension] = $content; + $totalBytes += $contentBytes; + } + if ($skippedFiles->isNotEmpty()) { + $this->dispatch('warning', 'Some dynamic configurations were not loaded because they exceed the safe display limits: '.$skippedFiles->implode(', ')); } $this->contents = $contents; $this->dispatch('$refresh'); diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index cd05002aae..a69eb3f807 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -146,7 +146,7 @@ class Sentinel extends Component { try { $this->syncData(true); - $this->dispatch('success', 'Sentinel settings updated.'); + $this->dispatch('success', 'Sentinel settings updated. Restarting Sentinel.'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index 91bc03d214..829fbda800 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -20,6 +20,9 @@ class Index extends Component #[Validate('nullable|string|max:255|url')] public ?string $fqdn = null; + #[Validate('boolean')] + public bool $is_dashboard_force_https_enabled = true; + #[Validate('required|integer|min:1025|max:65535')] public int $public_port_min; @@ -68,6 +71,7 @@ class Index extends Component $this->server = Server::findOrFail(0); } $this->fqdn = $this->settings->fqdn; + $this->is_dashboard_force_https_enabled = $this->settings->is_dashboard_force_https_enabled; $this->public_port_min = $this->settings->public_port_min; $this->public_port_max = $this->settings->public_port_max; $this->instance_name = $this->settings->instance_name; @@ -91,6 +95,7 @@ class Index extends Component $this->authorize('update', $this->settings); $this->validate(); $this->settings->fqdn = $this->fqdn ? trim($this->fqdn) : $this->fqdn; + $this->settings->is_dashboard_force_https_enabled = $this->is_dashboard_force_https_enabled; $this->settings->public_port_min = $this->public_port_min; $this->settings->public_port_max = $this->public_port_max; $this->settings->instance_name = $this->instance_name; @@ -207,7 +212,7 @@ class Index extends Component return; } - $imageRef = escapeshellarg("ghcr.io/coollabsio/coolify-helper:{$version}"); + $imageRef = escapeshellarg(coolifyHelperImage().":{$version}"); $buildCommand = "docker build -t {$imageRef} -f docker/coolify-helper/Dockerfile ."; $activity = remote_process( diff --git a/app/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php index 121fe92a48..9bca0db2e3 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -5,6 +5,7 @@ namespace App\Livewire; use App\Models\InstanceSettings; use App\Models\Team; use App\Notifications\TransactionalEmails\Test; +use App\Rules\ValidHostname; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Locked; @@ -50,6 +51,9 @@ class SettingsEmail extends Component #[Validate(['nullable', 'numeric'])] public ?string $smtpTimeout = null; + #[Validate(['nullable', 'string'])] + public ?string $smtpEhloDomain = null; + #[Validate(['boolean'])] public bool $resendEnabled = false; @@ -74,6 +78,7 @@ class SettingsEmail extends Component { if ($toModel) { $this->validate(); + $this->validate(['smtpEhloDomain' => ['nullable', 'string', new ValidHostname]]); $this->settings->smtp_enabled = $this->smtpEnabled; $this->settings->smtp_host = $this->smtpHost; $this->settings->smtp_port = $this->smtpPort; @@ -81,6 +86,7 @@ class SettingsEmail extends Component $this->settings->smtp_username = $this->smtpUsername; $this->settings->smtp_password = $this->smtpPassword; $this->settings->smtp_timeout = $this->smtpTimeout; + $this->settings->smtp_ehlo_domain = $this->smtpEhloDomain; $this->settings->smtp_from_address = $this->smtpFromAddress; $this->settings->smtp_from_name = $this->smtpFromName; @@ -95,6 +101,7 @@ class SettingsEmail extends Component $this->smtpUsername = $this->settings->smtp_username; $this->smtpPassword = $this->settings->smtp_password; $this->smtpTimeout = $this->settings->smtp_timeout; + $this->smtpEhloDomain = $this->settings->smtp_ehlo_domain; $this->smtpFromAddress = $this->settings->smtp_from_address; $this->smtpFromName = $this->settings->smtp_from_name; @@ -167,6 +174,7 @@ class SettingsEmail extends Component 'smtpUsername' => 'nullable|string', 'smtpPassword' => 'nullable|string', 'smtpTimeout' => 'nullable|numeric', + 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], ], [ 'smtpFromAddress.required' => 'From Address is required.', 'smtpFromAddress.email' => 'Please enter a valid email address.', @@ -184,6 +192,7 @@ class SettingsEmail extends Component $this->settings->smtp_username = $this->smtpUsername; $this->settings->smtp_password = $this->smtpPassword; $this->settings->smtp_timeout = $this->smtpTimeout; + $this->settings->smtp_ehlo_domain = $this->smtpEhloDomain; $this->settings->smtp_from_address = $this->smtpFromAddress; $this->settings->smtp_from_name = $this->smtpFromName; @@ -234,11 +243,20 @@ class SettingsEmail extends Component $this->authorize('update', $this->settings); $this->validate([ 'testEmailAddress' => 'required|email', + 'smtpFromAddress' => 'required|email', + 'smtpFromName' => 'required|string', ], [ 'testEmailAddress.required' => 'Test email address is required.', 'testEmailAddress.email' => 'Please enter a valid email address.', + 'smtpFromAddress.required' => 'From Address is required.', + 'smtpFromAddress.email' => 'Please enter a valid email address.', + 'smtpFromName.required' => 'From Name is required.', ]); + $this->settings->smtp_from_address = $this->smtpFromAddress; + $this->settings->smtp_from_name = $this->smtpFromName; + $this->settings->save(); + $executed = RateLimiter::attempt( 'test-email:'.$this->team->id, $perMinute = 0, diff --git a/app/Livewire/SwitchTeam.php b/app/Livewire/SwitchTeam.php index 145c285ab5..d50f57c141 100644 --- a/app/Livewire/SwitchTeam.php +++ b/app/Livewire/SwitchTeam.php @@ -19,7 +19,7 @@ class SwitchTeam extends Component $this->switch_to($this->selectedTeamId); } - public function switch_to($team_id) + public function switch_to($team_id, ?string $currentUrl = null) { if (! auth()->user()->teams->contains($team_id)) { return; @@ -30,6 +30,12 @@ class SwitchTeam extends Component } refreshSession($team_to_switch_to); - return redirect('dashboard'); + $parsedUrl = parse_url($currentUrl ?? '/dashboard'); + $redirectUrl = data_get($parsedUrl, 'path', '/dashboard'); + if ($query = data_get($parsedUrl, 'query')) { + $redirectUrl .= '?'.$query; + } + + return redirect($redirectUrl); } } diff --git a/app/Livewire/Team/Create.php b/app/Livewire/Team/Create.php index c459626482..1f191ec6b1 100644 --- a/app/Livewire/Team/Create.php +++ b/app/Livewire/Team/Create.php @@ -35,7 +35,7 @@ class Create extends Component 'personal_team' => false, 'is_mcp_server_enabled' => true, ]); - auth()->user()->teams()->attach($team, ['role' => 'admin']); + auth()->user()->teams()->attach($team, ['role' => 'owner']); refreshSession($team); return redirectRoute($this, 'team.index'); diff --git a/app/Livewire/Team/DangerZone.php b/app/Livewire/Team/DangerZone.php index a3f73a44f1..74cf3d7ef8 100644 --- a/app/Livewire/Team/DangerZone.php +++ b/app/Livewire/Team/DangerZone.php @@ -2,11 +2,9 @@ namespace App\Livewire\Team; +use App\Actions\Team\DeleteTeam; use App\Models\Team; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\DB; use Livewire\Component; class DangerZone extends Component @@ -25,22 +23,7 @@ class DangerZone extends Component try { $currentTeam = currentTeam(); $this->authorize('delete', $currentTeam); - $currentTeam->members->each(function ($user) use ($currentTeam): void { - if ($user->id === Auth::id()) { - return; - } - - $user->teams()->detach($currentTeam); - $session = DB::table('sessions')->where('user_id', $user->id)->first(); - if ($session) { - DB::table('sessions')->where('id', $session->id)->delete(); - } - }); - - Cache::forget('user:'.Auth::id().':team:'.$currentTeam->id); - $currentTeam->delete(); - - $newTeam = Auth::user()->teams()->first(); + $newTeam = app(DeleteTeam::class)->handle($currentTeam, auth()->user()); refreshSession($newTeam); return redirect()->route('team.index'); @@ -49,6 +32,12 @@ class DangerZone extends Component } } + public function refreshResources(): void + { + $this->team = Team::query()->findOrFail($this->team->id); + refreshSession($this->team); + } + public function render(): mixed { return view('livewire.team.danger-zone'); diff --git a/app/Livewire/Team/Invitations.php b/app/Livewire/Team/Invitations.php index 523f640b96..8ecafc417c 100644 --- a/app/Livewire/Team/Invitations.php +++ b/app/Livewire/Team/Invitations.php @@ -5,6 +5,7 @@ namespace App\Livewire\Team; use App\Models\TeamInvitation; use App\Models\User; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\DB; use Livewire\Component; class Invitations extends Component @@ -21,12 +22,14 @@ class Invitations extends Component $this->authorize('manageInvitations', currentTeam()); $invitation = TeamInvitation::ownedByCurrentTeam()->findOrFail($invitation_id); - $user = User::whereEmail($invitation->email)->first(); - if (filled($user)) { - $user->deleteIfNotVerifiedAndForcePasswordReset(); - } + DB::transaction(function () use ($invitation): void { + $user = User::whereEmail($invitation->email)->first(); + if (filled($user)) { + $user->deleteIfNotVerifiedAndForcePasswordReset(); + } - $invitation->delete(); + $invitation->delete(); + }); $this->refreshInvitations(); $this->dispatch('success', 'Invitation revoked.'); } catch (\Exception) { diff --git a/app/Livewire/Team/Member.php b/app/Livewire/Team/Member.php index 97d492d700..38c932c39d 100644 --- a/app/Livewire/Team/Member.php +++ b/app/Livewire/Team/Member.php @@ -7,6 +7,7 @@ use App\Enums\Role; use App\Models\User; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; use Livewire\Component; class Member extends Component @@ -25,8 +26,10 @@ class Member extends Component throw new \Exception('You are not authorized to perform this action.'); } $teamId = currentTeam()->id; - $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::ADMIN->value]); - RevokeUserTeamTokens::forUserTeam($this->member, $teamId); + DB::transaction(function () use ($teamId): void { + $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::ADMIN->value]); + RevokeUserTeamTokens::forUserTeam($this->member, $teamId); + }); $this->dispatch('reloadWindow'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -43,8 +46,10 @@ class Member extends Component throw new \Exception('You are not authorized to perform this action.'); } $teamId = currentTeam()->id; - $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::OWNER->value]); - RevokeUserTeamTokens::forUserTeam($this->member, $teamId); + DB::transaction(function () use ($teamId): void { + $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::OWNER->value]); + RevokeUserTeamTokens::forUserTeam($this->member, $teamId); + }); $this->dispatch('reloadWindow'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -61,8 +66,10 @@ class Member extends Component throw new \Exception('You are not authorized to perform this action.'); } $teamId = currentTeam()->id; - $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value]); - RevokeUserTeamTokens::forUserTeam($this->member, $teamId); + DB::transaction(function () use ($teamId): void { + $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value]); + RevokeUserTeamTokens::forUserTeam($this->member, $teamId); + }); $this->dispatch('reloadWindow'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -79,8 +86,10 @@ class Member extends Component throw new \Exception('You are not authorized to perform this action.'); } $teamId = currentTeam()->id; - $this->member->teams()->detach(currentTeam()); - RevokeUserTeamTokens::forUserTeam($this->member, $teamId); + DB::transaction(function () use ($teamId): void { + $this->member->teams()->detach($teamId); + RevokeUserTeamTokens::forUserTeam($this->member, $teamId); + }); // Clear cache for the removed user - both old and new key formats Cache::forget("team:{$this->member->id}"); Cache::forget("user:{$this->member->id}:team:{$teamId}"); diff --git a/app/Livewire/Terminal/Index.php b/app/Livewire/Terminal/Index.php index 6bb4c5e908..116db1eed1 100644 --- a/app/Livewire/Terminal/Index.php +++ b/app/Livewire/Terminal/Index.php @@ -47,7 +47,7 @@ class Index extends Component return [ 'name' => data_get($container, 'Names'), 'connection_name' => data_get($container, 'Names'), - 'uuid' => data_get($container, 'Names'), + 'uuid' => $server->uuid.':'.data_get($container, 'Names'), 'status' => data_get_str($container, 'State')->lower(), 'server' => $server, 'server_uuid' => $server->uuid, diff --git a/app/Livewire/Upgrade.php b/app/Livewire/Upgrade.php index aad02c7801..d548b37249 100644 --- a/app/Livewire/Upgrade.php +++ b/app/Livewire/Upgrade.php @@ -20,6 +20,8 @@ class Upgrade extends Component public bool $devMode = false; + public bool $fullButton = false; + protected $listeners = ['updateAvailable' => 'checkUpdate']; public function mount() diff --git a/app/Models/Application.php b/app/Models/Application.php index 2b203f4a91..0868bdf9cd 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -12,6 +12,7 @@ use App\Traits\HasConfiguration; use App\Traits\HasMetrics; use App\Traits\HasNoindexDomains; use App\Traits\HasSafeStringAttribute; +use Database\Factories\ApplicationFactory; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -120,7 +121,12 @@ use Symfony\Component\Yaml\Yaml; class Application extends BaseModel { - use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasConfiguration, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes; + + /** @use HasFactory */ + use HasFactory; + + public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024; private static $parserVersion = '5'; @@ -2109,6 +2115,9 @@ class Application extends BaseModel $workdir = rtrim($this->base_directory, '/'); $composeFile = $this->docker_compose_location; $fileList = collect([".$workdir$composeFile"]); + $composeFilePath = escapeshellarg(".$workdir$composeFile"); + $composeReadLimit = self::MAX_DOCKER_COMPOSE_SIZE_BYTES + 1; + $readComposeFile = "if [ \"$(wc -c < {$composeFilePath})\" -gt ".self::MAX_DOCKER_COMPOSE_SIZE_BYTES." ]; then echo '__COOLIFY_COMPOSE_TOO_LARGE__'; else head -c {$composeReadLimit} {$composeFilePath}; fi"; $gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid); if (! $gitRemoteStatus['is_accessible']) { throw new RuntimeException('Failed to read Git source. Please verify repository access and try again.'); @@ -2139,7 +2148,7 @@ class Application extends BaseModel 'git sparse-checkout init', "git sparse-checkout set {$fileList->implode(' ')}", 'git read-tree -mu HEAD', - "cat .$workdir$composeFile", + $readComposeFile, ]); } else { $commands = collect([ @@ -2151,11 +2160,14 @@ class Application extends BaseModel 'git sparse-checkout init --cone', "git sparse-checkout set {$fileList->implode(' ')}", 'git read-tree -mu HEAD', - "cat .$workdir$composeFile", + $readComposeFile, ]); } try { $composeFileContent = instant_remote_process($commands, $this->destination->server); + if ($composeFileContent === '__COOLIFY_COMPOSE_TOO_LARGE__' || strlen($composeFileContent) > self::MAX_DOCKER_COMPOSE_SIZE_BYTES) { + throw new RuntimeException('Docker Compose file exceeds the 5 MiB size limit.'); + } } catch (\Exception $e) { // Restore original values on failure only $this->docker_compose_location = $initialDockerComposeLocation; @@ -2171,6 +2183,9 @@ class Application extends BaseModel } throw new RuntimeException('Repository does not exist. Please check your repository URL and try again.'); } + if (str($e->getMessage())->contains('exceeds the 5 MiB size limit')) { + throw $e; + } throw new RuntimeException('Failed to read the Docker Compose file from the repository.'); } finally { // Cleanup only - restoration happens in catch block diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index 6998211eea..0905242753 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Support\ValidationPatterns; use Illuminate\Database\Eloquent\SoftDeletes; +use RuntimeException; use Spatie\Url\Url; class ApplicationPreview extends BaseModel @@ -28,9 +29,9 @@ class ApplicationPreview extends BaseModel 'pull_request_id' => 'integer', ]; - protected static function booted() + protected static function booted(): void { - static::forceDeleting(function ($preview) { + static::forceDeleting(function (ApplicationPreview $preview): void { $server = $preview->application->destination->server; $application = $preview->application; @@ -57,10 +58,19 @@ class ApplicationPreview extends BaseModel }); } else { // Regular application volume cleanup - $persistentStorages = $preview->persistentStorages()->get() ?? collect(); - if ($persistentStorages->count() > 0) { - foreach ($persistentStorages as $storage) { - instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false); + $persistentStorages = $application->persistentStorages() + ->get() + ->filter(fn (LocalPersistentVolume $storage): bool => blank($storage->host_path) + && $storage->is_preview_suffix_enabled); + + foreach ($persistentStorages as $storage) { + $volumeName = addPreviewDeploymentSuffix($storage->name, $preview->pull_request_id); + try { + instant_remote_process(['docker volume rm -f '.escapeshellarg($volumeName)], $server); + } catch (RuntimeException $exception) { + if (! preg_match('/\bvolume\b.*\bnot found\b/i', $exception->getMessage())) { + throw $exception; + } } } } diff --git a/app/Models/EmailNotificationSettings.php b/app/Models/EmailNotificationSettings.php index 7368bafbf0..814d053395 100644 --- a/app/Models/EmailNotificationSettings.php +++ b/app/Models/EmailNotificationSettings.php @@ -21,6 +21,7 @@ class EmailNotificationSettings extends Model 'smtp_username', 'smtp_password', 'smtp_timeout', + 'smtp_ehlo_domain', 'resend_enabled', 'resend_api_key', diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php index 7c2f8c0628..564fbcf6a4 100644 --- a/app/Models/GithubApp.php +++ b/app/Models/GithubApp.php @@ -3,9 +3,15 @@ namespace App\Models; use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Support\Facades\DB; class GithubApp extends BaseModel { + public function delete(): ?bool + { + return DB::transaction(fn () => parent::delete()); + } + protected $fillable = [ 'team_id', 'private_key_id', diff --git a/app/Models/GitlabApp.php b/app/Models/GitlabApp.php index 09a48e8b91..c6c2b84095 100644 --- a/app/Models/GitlabApp.php +++ b/app/Models/GitlabApp.php @@ -100,6 +100,7 @@ class GitlabApp extends BaseModel if ($gitlabApp->applications()->count() > 0) { throw new \RuntimeException('This source is being used by an application. Please delete all applications first.'); } + }); } diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 877fc5b12f..eb01fa7ada 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -9,6 +9,10 @@ use Spatie\Url\Url; class InstanceSettings extends Model { + protected $attributes = [ + 'is_dashboard_force_https_enabled' => true, + ]; + protected $fillable = [ 'public_ipv4', 'public_ipv6', @@ -29,6 +33,7 @@ class InstanceSettings extends Model 'smtp_username', 'smtp_password', 'smtp_timeout', + 'smtp_ehlo_domain', 'resend_enabled', 'resend_api_key', 'is_dns_validation_enabled', @@ -51,6 +56,7 @@ class InstanceSettings extends Model 'webhook_allow_localhost', 'avatar_storage_type', 'avatar_s3_storage_id', + 'is_dashboard_force_https_enabled', ]; protected $hidden = [ @@ -89,6 +95,7 @@ class InstanceSettings extends Model 'is_mcp_server_enabled' => 'boolean', 'webhook_allowed_internal_hosts' => 'array', 'webhook_allow_localhost' => 'boolean', + 'is_dashboard_force_https_enabled' => 'boolean', ]; protected static function booted(): void diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 86873d1a1c..92b7853400 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -138,9 +138,9 @@ class LocalFileVolume extends BaseModel return; } - $content = instant_remote_process(["cat {$escapedPath}"], $server, false); + $content = $this->readRemoteFileContent($escapedPath, $server); // Check if content contains binary data by looking for null bytes or non-printable characters - if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) { + if ($content !== self::TOO_LARGE_PLACEHOLDER && (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content))) { $content = self::BINARY_PLACEHOLDER; } $this->content = $content; @@ -161,6 +161,27 @@ class LocalFileVolume extends BaseModel return $size > self::MAX_CONTENT_SIZE; } + /** + * Cap the remote read itself so a file that grows after the size check + * cannot be fully slurped into PHP memory. + */ + protected function readRemoteFileContent(string $escapedPath, $server): string + { + $readLimit = self::MAX_CONTENT_SIZE + 1; + $content = instant_remote_process(["head -c {$readLimit} {$escapedPath}"], $server, false); + + return self::contentFromBoundedRead($content); + } + + public static function contentFromBoundedRead(?string $content): string + { + if (strlen((string) $content) > self::MAX_CONTENT_SIZE) { + return self::TOO_LARGE_PLACEHOLDER; + } + + return (string) $content; + } + public function deleteStorageOnServer() { if ($this->is_host_file) { @@ -253,7 +274,7 @@ class LocalFileVolume extends BaseModel if ($this->remoteFileExceedsLimit($escapedPath, $server)) { $this->content = self::TOO_LARGE_PLACEHOLDER; } else { - $this->content = instant_remote_process(["cat {$escapedPath}"], $server, false); + $this->content = $this->readRemoteFileContent($escapedPath, $server); } $this->is_directory = false; $this->save(); diff --git a/app/Models/ScheduledVolumeBackup.php b/app/Models/ScheduledVolumeBackup.php index a333681427..7f33fd92b9 100644 --- a/app/Models/ScheduledVolumeBackup.php +++ b/app/Models/ScheduledVolumeBackup.php @@ -11,6 +11,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo; class ScheduledVolumeBackup extends BaseModel { + public const int DEFAULT_TIMEOUT = 36000; + protected $fillable = [ 'uuid', 'backupable_type', diff --git a/app/Models/Server.php b/app/Models/Server.php index 0f5a35364c..f7a4bf20c0 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -731,9 +731,7 @@ class Server extends BaseModel ]; if ($schema === 'https') { - $traefik_dynamic_conf['http']['routers']['coolify-http']['middlewares'] = [ - 0 => 'redirect-to-https', - ]; + $traefik_dynamic_conf['http']['routers']['coolify-http']['middlewares'] = $this->dashboardHttpMiddlewares($settings); $traefik_dynamic_conf['http']['routers']['coolify-https'] = [ 'middlewares' => [ @@ -792,8 +790,9 @@ class Server extends BaseModel $url = Url::fromString($settings->fqdn); $host = $url->getHost(); $schema = $url->getScheme(); + $siteAddress = $this->dashboardCaddySiteAddress($settings, $schema, $host); $caddy_file = " -$schema://$host { +$siteAddress { encode zstd gzip handle /app/* { reverse_proxy coolify-realtime:6001 @@ -819,6 +818,24 @@ $schema://$host { ], $this); } + public function dashboardHttpMiddlewares(InstanceSettings $settings): array + { + if ($settings->is_dashboard_force_https_enabled) { + return ['redirect-to-https']; + } + + return ['gzip']; + } + + public function dashboardCaddySiteAddress(InstanceSettings $settings, string $schema, string $host): string + { + if ($schema === 'https' && ! $settings->is_dashboard_force_https_enabled) { + return "http://{$host}, https://{$host}"; + } + + return "{$schema}://{$host}"; + } + public function proxyPath() { $base_path = config('constants.coolify.base_config_path'); @@ -841,6 +858,33 @@ $schema://$host { return data_get($this->proxy, 'type'); } + public function hasPendingProxyConfiguration(): bool + { + if ($this->proxy->get('status') !== 'running') { + return false; + } + + $savedSettings = $this->proxy->get('last_saved_settings'); + $appliedSettings = $this->proxy->get('last_applied_settings'); + + return filled($savedSettings) && filled($appliedSettings) && $savedSettings !== $appliedSettings; + } + + public function hasCurrentTraefikOutdatedInfo(): bool + { + if ($this->proxyType() !== ProxyTypes::TRAEFIK->value) { + return false; + } + + $detectedVersion = ltrim((string) $this->detected_traefik_version, 'v'); + $storedVersion = ltrim((string) data_get($this->traefik_outdated_info, 'current'), 'v'); + $type = data_get($this->traefik_outdated_info, 'type'); + + return filled($detectedVersion) + && $storedVersion === $detectedVersion + && in_array($type, ['patch_update', 'minor_upgrade'], true); + } + public function scopeWithProxy(): Builder { return $this->proxy->modelScope(); diff --git a/app/Models/TeamInvitation.php b/app/Models/TeamInvitation.php index c322982ede..4258a82b8a 100644 --- a/app/Models/TeamInvitation.php +++ b/app/Models/TeamInvitation.php @@ -33,11 +33,9 @@ class TeamInvitation extends Model return TeamInvitation::whereTeamId(currentTeam()->id); } - public function isValid() + public function isValid(): bool { - $createdAt = $this->created_at; - $diff = $createdAt->diffInDays(now()); - if ($diff <= config('constants.invitation.link.expiration_days')) { + if (! $this->hasExpired()) { return true; } else { $this->delete(); @@ -49,4 +47,9 @@ class TeamInvitation extends Model return false; } } + + public function hasExpired(): bool + { + return $this->created_at->diffInDays(now()) > config('constants.invitation.link.expiration_days'); + } } diff --git a/app/Notifications/Channels/EmailChannel.php b/app/Notifications/Channels/EmailChannel.php index abd1155502..fd62a90720 100644 --- a/app/Notifications/Channels/EmailChannel.php +++ b/app/Notifications/Channels/EmailChannel.php @@ -4,9 +4,14 @@ namespace App\Notifications\Channels; use App\Exceptions\NonReportableException; use App\Models\Team; +use App\Support\SmtpTransportFactory; use Exception; use Illuminate\Notifications\Notification; use Resend; +use Resend\Exceptions\ErrorException; +use Resend\Exceptions\TransporterException; +use Symfony\Component\Mailer\Mailer; +use Symfony\Component\Mime\Email; class EmailChannel { @@ -70,43 +75,27 @@ class EmailChannel if ($isResendEnabled) { $resend = Resend::client($settings->resend_api_key); - $from = "{$settings->smtp_from_name} <{$settings->smtp_from_address}>"; $resend->emails->send([ - 'from' => $from, + 'from' => mail_from_formatted($settings), 'to' => $recipients, 'subject' => $mailMessage->subject, 'html' => (string) $mailMessage->render(), ]); } elseif ($isSmtpEnabled) { - $encryption = match (strtolower($settings->smtp_encryption)) { - 'starttls' => null, - 'tls' => 'tls', - 'none' => null, - default => null, - }; - - $transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport( - $settings->smtp_host, - $settings->smtp_port, - $encryption + $transport = SmtpTransportFactory::fromSettings( + $settings, + config('mail.mailers.smtp.local_domain') ); - $transport->setUsername($settings->smtp_username ?? ''); - $transport->setPassword($settings->smtp_password ?? ''); + $mailer = new Mailer($transport); - $mailer = new \Symfony\Component\Mailer\Mailer($transport); - - $fromEmail = $settings->smtp_from_address ?? 'noreply@localhost'; - $fromName = $settings->smtp_from_name ?? 'System'; - $from = new \Symfony\Component\Mime\Address($fromEmail, $fromName); - $email = (new \Symfony\Component\Mime\Email) - ->from($from) + $email = mail_from_email(new Email, $settings) ->to(...$recipients) ->subject($mailMessage->subject) ->html((string) $mailMessage->render()); $mailer->send($email); } - } catch (\Resend\Exceptions\ErrorException $e) { + } catch (ErrorException $e) { // Map HTTP status codes to user-friendly messages $userMessage = match ($e->getErrorCode()) { 403 => 'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.', @@ -131,13 +120,13 @@ class EmailChannel // Don't report expected errors (invalid keys, validation) to Sentry if (in_array($e->getErrorCode(), [403, 401, 400])) { - throw NonReportableException::fromException(new \Exception($userMessage, $e->getCode(), $e)); + throw NonReportableException::fromException(new Exception($userMessage, $e->getCode(), $e)); } - throw new \Exception($userMessage, $e->getCode(), $e); - } catch (\Resend\Exceptions\TransporterException $e) { + throw new Exception($userMessage, $e->getCode(), $e); + } catch (TransporterException $e) { send_internal_notification("Resend Transport Error: {$e->getMessage()}"); - throw new \Exception('Unable to connect to Resend API. Please check your internet connection and try again.'); + throw new Exception('Unable to connect to Resend API. Please check your internet connection and try again.'); } catch (\Throwable $e) { // Check if this is a Resend domain verification error on cloud instances if (isCloud() && str_contains($e->getMessage(), 'domain is not verified')) { diff --git a/app/Notifications/Channels/TransactionalEmailChannel.php b/app/Notifications/Channels/TransactionalEmailChannel.php index 8ab74a60b1..f4e8b294a4 100644 --- a/app/Notifications/Channels/TransactionalEmailChannel.php +++ b/app/Notifications/Channels/TransactionalEmailChannel.php @@ -30,7 +30,7 @@ class TransactionalEmailChannel Mail::send( [], [], - fn (Message $message) => $message + fn (Message $message) => mail_from_message($message, $settings) ->to($email) ->subject($mailMessage->subject) ->html((string) $mailMessage->render()) diff --git a/app/Notifications/TransactionalEmails/ResetPassword.php b/app/Notifications/TransactionalEmails/ResetPassword.php index 511818e215..cb65391068 100644 --- a/app/Notifications/TransactionalEmails/ResetPassword.php +++ b/app/Notifications/TransactionalEmails/ResetPassword.php @@ -54,7 +54,10 @@ class ResetPassword extends Notification protected function buildMailMessage($url) { + $from = mail_from_identity($this->settings); $mail = new MailMessage; + $mail->from($from['address'], $from['name']); + $mail->withSymfonyMessage(fn ($message) => prevent_mail_from_header_folding($message, $this->settings)); $mail->subject('Coolify: Reset Password'); $mail->view('emails.reset-password', ['url' => $url, 'count' => config('auth.passwords.'.config('auth.defaults.passwords').'.expire')]); diff --git a/app/Policies/ApiTokenPolicy.php b/app/Policies/ApiTokenPolicy.php index ba9bade012..e9ef4d7c9e 100644 --- a/app/Policies/ApiTokenPolicy.php +++ b/app/Policies/ApiTokenPolicy.php @@ -20,7 +20,7 @@ class ApiTokenPolicy */ public function view(User $user, PersonalAccessToken $token): bool { - return $user->id === $token->tokenable_id && $token->tokenable_type === User::class; + return $this->belongsToUserAndCurrentTeam($user, $token); } /** @@ -36,7 +36,7 @@ class ApiTokenPolicy */ public function update(User $user, PersonalAccessToken $token): bool { - return $user->id === $token->tokenable_id && $token->tokenable_type === User::class; + return $this->belongsToUserAndCurrentTeam($user, $token); } /** @@ -44,7 +44,7 @@ class ApiTokenPolicy */ public function delete(User $user, PersonalAccessToken $token): bool { - return $user->id === $token->tokenable_id && $token->tokenable_type === User::class; + return $this->belongsToUserAndCurrentTeam($user, $token); } /** @@ -86,4 +86,19 @@ class ApiTokenPolicy { return $user->isAdmin() || $user->isOwner(); } + + private function belongsToUserAndCurrentTeam(User $user, PersonalAccessToken $token): bool + { + if ($user->id !== $token->tokenable_id || $token->tokenable_type !== User::class) { + return false; + } + + $currentTeamId = $user->currentTeam()?->id; + + if ($currentTeamId === null || $token->team_id === null) { + return false; + } + + return (string) $currentTeamId === (string) $token->team_id; + } } diff --git a/app/Policies/ScheduledTaskPolicy.php b/app/Policies/ScheduledTaskPolicy.php new file mode 100644 index 0000000000..fac7e7b228 --- /dev/null +++ b/app/Policies/ScheduledTaskPolicy.php @@ -0,0 +1,70 @@ +teams->contains('id', $scheduledTask->team_id); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, ScheduledTask $scheduledTask): Response + { + if (! $user->isAdminOfTeam($scheduledTask->team_id)) { + return Response::deny('You need at least admin or owner permissions to update this scheduled task.'); + } + + return Response::allow(); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, ScheduledTask $scheduledTask): bool + { + return $user->isAdminOfTeam($scheduledTask->team_id); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, ScheduledTask $scheduledTask): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, ScheduledTask $scheduledTask): bool + { + return false; + } +} diff --git a/app/Policies/TeamPolicy.php b/app/Policies/TeamPolicy.php index cc7745b648..5b97927601 100644 --- a/app/Policies/TeamPolicy.php +++ b/app/Policies/TeamPolicy.php @@ -53,7 +53,7 @@ class TeamPolicy return false; } - return $user->isAdminOfTeam($team->id); + return $user->roleInTeam($team->id) === 'owner'; } /** diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 0adf9e2329..09b2a3e089 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -19,6 +19,7 @@ use App\Models\PrivateKey; use App\Models\Project; use App\Models\PushoverNotificationSettings; use App\Models\S3Storage; +use App\Models\ScheduledTask; use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; @@ -56,6 +57,7 @@ use App\Policies\PrivateKeyPolicy; use App\Policies\ProjectPolicy; use App\Policies\ResourceCreatePolicy; use App\Policies\S3StoragePolicy; +use App\Policies\ScheduledTaskPolicy; use App\Policies\ServerPolicy; use App\Policies\ServiceApplicationPolicy; use App\Policies\ServiceDatabasePolicy; @@ -118,6 +120,9 @@ class AuthServiceProvider extends ServiceProvider // S3 storage policy S3Storage::class => S3StoragePolicy::class, + // Scheduled task policy + ScheduledTask::class => ScheduledTaskPolicy::class, + // Team policy Team::class => TeamPolicy::class, diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 1b201fb3f9..65d9687744 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -152,6 +152,13 @@ class FortifyServiceProvider extends ServiceProvider return Limit::perMinute(5)->by($email.'|'.$realIp); }); + RateLimiter::for('magic-link', function (Request $request) { + $realIp = $request->server('REMOTE_ADDR') ?? $request->ip(); + $token = (string) $request->input('token'); + + return Limit::perMinute(5)->by(hash('sha256', $token.'|'.$realIp)); + }); + RateLimiter::for('two-factor', function (Request $request) { return Limit::perMinute(5)->by($request->session()->get('login.id')); }); diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php index c29f7fc410..9fe33b2cdd 100644 --- a/app/Providers/HorizonServiceProvider.php +++ b/app/Providers/HorizonServiceProvider.php @@ -75,12 +75,16 @@ class HorizonServiceProvider extends HorizonApplicationServiceProvider protected function gate(): void { - Gate::define('viewHorizon', function ($user) { - $root_user = User::find(0); + Gate::define('viewHorizon', function (User $user) { + if ($user->id === 0) { + return true; + } - return in_array($user->email, [ - $root_user->email, - ]); + return str(config()->string('horizon.allowed_emails')) + ->lower() + ->explode(',') + ->map(fn (string $email) => trim($email)) + ->contains($user->email); }); } } diff --git a/app/Services/ConfigurationRepository.php b/app/Services/ConfigurationRepository.php index ff2e73eed6..ead3e4a21c 100644 --- a/app/Services/ConfigurationRepository.php +++ b/app/Services/ConfigurationRepository.php @@ -2,7 +2,9 @@ namespace App\Services; +use App\Support\SmtpTransportFactory; use Illuminate\Config\Repository; +use Illuminate\Support\Facades\Mail; class ConfigurationRepository { @@ -15,40 +17,51 @@ class ConfigurationRepository public function updateMailConfig($settings): void { + $from = mail_from_identity($settings); + if ($settings->resend_enabled) { $this->config->set('mail.default', 'resend'); - $this->config->set('mail.from.address', $settings->smtp_from_address ?? 'test@example.com'); - $this->config->set('mail.from.name', $settings->smtp_from_name ?? 'Test'); + $this->applyMailFrom($from); $this->config->set('resend.api_key', $settings->resend_api_key); return; } if ($settings->smtp_enabled) { - $encryption = match (strtolower($settings->smtp_encryption)) { - 'starttls' => null, - 'tls' => 'tls', - 'none' => null, - default => null, - }; + $mailerOptions = SmtpTransportFactory::mailerOptions($settings); + $localDomain = $settings->smtp_ehlo_domain + ?? $this->config->get('mail.mailers.smtp.local_domain'); $this->config->set('mail.default', 'smtp'); - $this->config->set('mail.from.address', $settings->smtp_from_address ?? 'test@example.com'); - $this->config->set('mail.from.name', $settings->smtp_from_name ?? 'Test'); + $this->applyMailFrom($from); $this->config->set('mail.mailers.smtp', [ 'transport' => 'smtp', + 'scheme' => $mailerOptions['scheme'], 'host' => $settings->smtp_host, 'port' => $settings->smtp_port, - 'encryption' => $encryption, + 'encryption' => $mailerOptions['encryption'], 'username' => $settings->smtp_username, 'password' => $settings->smtp_password, 'timeout' => $settings->smtp_timeout, - 'local_domain' => null, - 'auto_tls' => $settings->smtp_encryption === 'none' ? '0' : '', + 'local_domain' => $localDomain, + 'auto_tls' => $mailerOptions['auto_tls'], ]); } } + /** + * @param array{address: string, name: string} $from + */ + private function applyMailFrom(array $from): void + { + $this->config->set('mail.from.address', $from['address']); + $this->config->set('mail.from.name', $from['name']); + + if (app()->bound('mail.manager')) { + Mail::purge(); + } + } + public function disableSshMux(): void { $this->config->set('constants.ssh.mux_enabled', false); diff --git a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php index 9833b5be45..ace4888338 100644 --- a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php +++ b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php @@ -17,28 +17,8 @@ class ConfigurationDiffer */ private const IGNORED_KEYS = ['build.docker_compose']; - /** - * Defaults for fields introduced after configuration snapshots were first - * stored. Older snapshots omitted these keys, which should not make an - * unchanged default look like a pending configuration change. - * - * @var array - */ - private const INTRODUCED_DEFAULTS = [ - 'build.is_static' => false, - 'build.is_spa' => false, - 'build.is_git_submodules_enabled' => true, - 'build.is_git_lfs_enabled' => true, - 'build.is_git_shallow_clone_enabled' => true, - 'build.is_env_sorting_enabled' => [false, true], - 'runtime.is_consistent_container_name_enabled' => false, - 'runtime.is_container_label_escape_enabled' => true, - 'runtime.is_container_label_readonly_enabled' => true, - 'runtime.is_log_drain_enabled' => false, - 'runtime.is_swarm_only_worker_nodes' => true, - 'runtime.is_preserve_repository_enabled' => false, - 'domains.noindex_domains' => [], - ]; + /** @var array */ + private const DYNAMIC_SECTIONS = ['environment', 'storage']; /** * @param array $previousSnapshot @@ -59,11 +39,7 @@ class ConfigurationDiffer $previous = $previousItems[$key] ?? null; $current = $currentItems[$key] ?? null; - if ( - $previous === null - && array_key_exists($key, self::INTRODUCED_DEFAULTS) - && $this->matchesIntroducedDefault($key, data_get($current, 'compare_value')) - ) { + if ($previous === null && ! in_array(data_get($current, 'section'), self::DYNAMIC_SECTIONS, true)) { continue; } @@ -127,17 +103,6 @@ class ConfigurationDiffer return ConfigurationDiff::fromChanges($changes); } - private function matchesIntroducedDefault(string $key, mixed $value): bool - { - $default = self::INTRODUCED_DEFAULTS[$key]; - - if (is_array($default) && $default !== [] && array_is_list($default)) { - return in_array($value, $default, true); - } - - return $value === $default; - } - /** * Reduce two multi-line values to only the lines that differ, so the modal * shows just the changed container labels instead of the whole block. diff --git a/app/Support/DatabaseBackupFileValidator.php b/app/Support/DatabaseBackupFileValidator.php index 2c1de948ba..84e629fe1a 100644 --- a/app/Support/DatabaseBackupFileValidator.php +++ b/app/Support/DatabaseBackupFileValidator.php @@ -90,8 +90,11 @@ class DatabaseBackupFileValidator public static function containsPostgresqlProgramExecution(string $sql): bool { + $requireStatementBoundary = true; + if (str_starts_with($sql, 'PGDMP')) { - return false; + $sql = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]+/', "\n", $sql) ?? $sql; + $requireStatementBoundary = false; } $withoutComments = self::stripSqlComments($sql); @@ -100,7 +103,9 @@ class DatabaseBackupFileValidator return true; } - return preg_match('/(?:^|;)\s*copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1; + $copyPrefix = $requireStatementBoundary ? '(?:^|;)\s*' : '\b'; + + return preg_match('/'.$copyPrefix.'copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1; } private static function extensionFor(string $name): ?string diff --git a/app/Support/DomainUrlParts.php b/app/Support/DomainUrlParts.php index 2e6da7868c..c86d5fa9a9 100644 --- a/app/Support/DomainUrlParts.php +++ b/app/Support/DomainUrlParts.php @@ -4,11 +4,11 @@ namespace App\Support; class DomainUrlParts { - public static function compose(string $scheme, string $host, string $port = '', string $path = ''): string + public static function compose(string $scheme, string $host, ?string $port = '', string $path = ''): string { $scheme = strtolower(trim($scheme)) === 'http' ? 'http' : 'https'; $host = trim($host); - $port = trim($port); + $port = trim((string) $port); $path = trim($path); if ($path !== '' && ! str_starts_with($path, '/') && ! str_starts_with($path, '?') && ! str_starts_with($path, '#')) { diff --git a/app/Support/SmtpTransportFactory.php b/app/Support/SmtpTransportFactory.php new file mode 100644 index 0000000000..43a0c5c065 --- /dev/null +++ b/app/Support/SmtpTransportFactory.php @@ -0,0 +1,68 @@ +smtp_host, + (int) $settings->smtp_port, + match ($mode) { + 'none' => false, + 'tls' => true, + default => null, + } + ); + + if ($mode === 'none') { + $transport->setAutoTls(false); + } + + $transport->setUsername($settings->smtp_username ?? ''); + $transport->setPassword($settings->smtp_password ?? ''); + + $localDomain = $settings->smtp_ehlo_domain ?? $localDomain; + if ($localDomain !== null && $localDomain !== '') { + $transport->setLocalDomain($localDomain); + } + + $stream = $transport->getStream(); + if (isset($settings->smtp_timeout) && $stream instanceof SocketStream) { + $stream->setTimeout((float) $settings->smtp_timeout); + } + + return $transport; + } + + /** + * @return array{scheme: ?string, encryption: ?string, auto_tls: string} + */ + public static function mailerOptions(object $settings): array + { + $mode = self::encryptionMode($settings); + + return [ + 'scheme' => match ($mode) { + 'none', 'starttls' => 'smtp', + 'tls' => 'smtps', + default => null, + }, + 'encryption' => $mode === 'tls' ? 'tls' : null, + 'auto_tls' => $mode === 'none' ? '0' : '', + ]; + } + + private static function encryptionMode(object $settings): ?string + { + return $settings->smtp_encryption === null + ? null + : strtolower((string) $settings->smtp_encryption); + } +} diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index ae29637554..00300d26a2 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -166,11 +166,13 @@ function format_docker_labels_to_json(string|array $rawOutput): Collection $outputArray = explode(',', $outputLine); return collect($outputArray) - ->map(function ($outputLine) { - return explode('=', $outputLine); - }) ->mapWithKeys(function ($outputLine) { - return [$outputLine[0] => $outputLine[1]]; + $label = explode('=', $outputLine, 2); + if (count($label) !== 2) { + return []; + } + + return [$label[0] => $label[1]]; }); })[0]; } @@ -263,6 +265,36 @@ function dockerStopCommand(int $timeout, string $containers, Server|string|null return $command; } + +function dockerRemoveCommandWithTimeout(string $container, int $timeout = 60, int $killAfter = 10): string +{ + $container = escapeShellValue($container); + $script = "if command -v timeout >/dev/null 2>&1; then output=\$(timeout -k {$killAfter}s {$timeout}s docker rm -f {$container} 2>&1); exit_code=\$?; else output=''; exit_code=124; fi; if [ \"\$exit_code\" -eq 124 ]; then echo '__COOLIFY_CONTAINER_REMOVE_TIMEOUT__'; elif [ \"\$exit_code\" -ne 0 ] && printf '%s' \"\$output\" | grep -q 'No such container:'; then exit 0; elif [ \"\$exit_code\" -ne 0 ]; then printf '%s\\n' \"\$output\" >&2; else printf '%s\\n' \"\$output\"; fi; exit \$exit_code"; + + return 'bash -c '.escapeShellValue($script); +} + +function dockerRemoveCommand(string $container): string +{ + $command = 'docker rm -f '.escapeShellValue($container); + + return dockerCommandIgnoringError($command, 'No such container:'); +} + +function dockerNetworkRemoveCommand(string $network): string +{ + $command = 'docker network rm '.escapeShellValue($network); + + return dockerCommandIgnoringError($command, 'network .* not found'); +} + +function dockerCommandIgnoringError(string $command, string $ignoredError): string +{ + $script = "output=\$({$command} 2>&1); exit_code=\$?; if [ \"\$exit_code\" -ne 0 ] && printf '%s' \"\$output\" | grep -Eq ".escapeShellValue($ignoredError)."; then exit 0; fi; if [ \"\$exit_code\" -ne 0 ]; then printf '%s\\n' \"\$output\" >&2; else printf '%s\\n' \"\$output\"; fi; exit \$exit_code"; + + return 'bash -c '.escapeShellValue($script); +} + function escapeShellValue(string $value): string { return "'".str_replace("'", "'\\''", $value)."'"; @@ -882,7 +914,6 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, - escape_redirect_replacement_for_compose: false, )); break; } diff --git a/bootstrap/helpers/notifications.php b/bootstrap/helpers/notifications.php index f64535ea82..e76487383d 100644 --- a/bootstrap/helpers/notifications.php +++ b/bootstrap/helpers/notifications.php @@ -5,6 +5,8 @@ use App\Notifications\Internal\GeneralNotification; use Illuminate\Mail\Message; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Facades\Mail; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; function is_transactional_emails_enabled(): bool { @@ -13,6 +15,63 @@ function is_transactional_emails_enabled(): bool return $settings->smtp_enabled || $settings->resend_enabled; } +/** + * @return array{address: string, name: string} + */ +function mail_from_identity(object $settings): array +{ + if (blank($settings->smtp_from_address ?? null)) { + throw new InvalidArgumentException('Transactional email sender address is not configured.'); + } + + $address = (string) $settings->smtp_from_address; + + $name = trim((string) ($settings->smtp_from_name ?? '')); + + return [ + 'address' => $address, + 'name' => $name !== '' ? $name : 'Coolify', + ]; +} + +function mail_from_address(object $settings): Address +{ + $identity = mail_from_identity($settings); + + return new Address($identity['address'], $identity['name']); +} + +function mail_from_formatted(object $settings): string +{ + return mail_from_address($settings)->toString(); +} + +function mail_from_email(Email $email, object $settings): Email +{ + $email->from(mail_from_address($settings)); + prevent_mail_from_header_folding($email, $settings); + + return $email; +} + +function mail_from_message(Message $message, object $settings): Message +{ + $identity = mail_from_identity($settings); + $message->from($identity['address'], $identity['name']); + prevent_mail_from_header_folding($message->getSymfonyMessage(), $settings); + + return $message; +} + +function prevent_mail_from_header_folding(Email $email, object $settings): void +{ + if (strtolower(trim((string) ($settings->smtp_host ?? ''))) !== 'smtp.protonmail.ch') { + return; + } + + $email->getHeaders()->get('From')?->setMaxLineLength(998); +} + function send_internal_notification(string $message): void { try { @@ -33,7 +92,7 @@ function send_user_an_email(MailMessage $mail, string $email, ?string $cc = null Mail::send( [], [], - fn (Message $message) => $message + fn (Message $message) => mail_from_message($message, $settings) ->to($email) ->replyTo($email) ->cc($cc) @@ -44,7 +103,7 @@ function send_user_an_email(MailMessage $mail, string $email, ?string $cc = null Mail::send( [], [], - fn (Message $message) => $message + fn (Message $message) => mail_from_message($message, $settings) ->to($email) ->subject($mail->subject) ->html((string) $mail->render()) diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index 31e55da336..fe639950be 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -123,7 +123,7 @@ function connectProxyToNetworks(Server $server) } return collect([ - 'for network in $(docker inspect $(docker ps --filter label=coolify.managed=true --format "{{.ID}}") --format=\'{{range $network, $_ := .NetworkSettings.Networks}}{{println $network}}{{end}}\' 2>/dev/null | sort -u); do', + 'for network in $(docker inspect $(docker ps -a --filter label=coolify.managed=true --format "{{.ID}}") --format=\'{{range $network, $_ := .NetworkSettings.Networks}}{{println $network}}{{end}}\' 2>/dev/null | sort -u); do', ' if [ -z "$network" ] || [ "$network" = "bridge" ] || [ "$network" = "host" ] || [ "$network" = "none" ] || [ "$network" = "default" ]; then', ' continue', ' fi', diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index d8986de3f1..07fdeb086f 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -167,13 +167,11 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli $isDir = instant_remote_process(["test -d $fileLocation && echo OK || echo NOK"], $server); if ($isFile === 'OK') { - // If its a file & exists - $filesystemContent = instant_remote_process(["cat $fileLocation"], $server); - if ($fileVolume->is_based_on_git) { - $fileVolume->content = $filesystemContent; - } $fileVolume->is_directory = false; $fileVolume->save(); + if ($fileVolume->is_based_on_git) { + $fileVolume->loadStorageOnServer(); + } } elseif ($isDir === 'OK') { // If its a directory & exists $fileVolume->content = null; @@ -235,7 +233,7 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource) // IMPORTANT: Only extract variables that are DIRECTLY DECLARED for this service, // not variables that are merely referenced from other services $serviceConfig = data_get($dockerCompose, "services.{$name}"); - $environment = data_get($serviceConfig, 'environment', []); + $environment = data_get($serviceConfig, 'environment') ?? []; $templateVariableNames = []; foreach ($environment as $key => $value) { diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 78a1f95e50..c3bd4a2238 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -2068,7 +2068,7 @@ function validateDNSEntry(string $fqdn, Server $server) $type = dnsRecordTypeForIp($ip) === 'AAAA' ? DNSTypes::NAME_AAAA : DNSTypes::NAME_A; foreach ($dns_servers as $dns_server) { try { - $query = new DNSQuery($dns_server); + $query = createDnsQuery($dns_server); $results = $query->query($host, $type); if ($results === false || $query->hasError()) { } else { @@ -2076,11 +2076,11 @@ function validateDNSEntry(string $fqdn, Server $server) if ($result->getType() == $type) { if (isCloudflareIp($result->getData())) { $found_matching_ip = true; - break; + break 2; } if ($ip && $result->getData() === $ip) { $found_matching_ip = true; - break; + break 2; } } } @@ -2092,6 +2092,15 @@ function validateDNSEntry(string $fqdn, Server $server) return $found_matching_ip; } +function createDnsQuery(string $dnsServer): DNSQuery +{ + return app()->make(DNSQuery::class, [ + 'server' => $dnsServer, + 'port' => 53, + 'timeout' => 5, + ]); +} + function isCloudflareIp(string $ip): bool { // https://www.cloudflare.com/ips/ @@ -4143,11 +4152,12 @@ function coolifyHelperImage(): string function getHelperVersion(): string { - $settings = instanceSettings(); + if (isDev()) { + $devHelperVersion = InstanceSettings::query()->whereKey(0)->value('dev_helper_version'); - // In development mode, use the dev_helper_version if set, otherwise fallback to config - if (isDev() && ! empty($settings->dev_helper_version)) { - return $settings->dev_helper_version; + if (! empty($devHelperVersion)) { + return $devHelperVersion; + } } return config('constants.coolify.helper_version'); diff --git a/config/constants.php b/config/constants.php index e0aefe8e2b..ca05db48f4 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => env('COOLIFY_VERSION') ?: '4.3.9', + 'version' => env('COOLIFY_VERSION') ?: '4.3.11', 'helper_version' => '1.0.15', 'realtime_version' => '1.0.17', 'railpack_version' => '0.23.0', @@ -71,7 +71,6 @@ return [ 'mux_persist_time' => env('SSH_MUX_PERSIST_TIME', 3600), 'mux_health_check_enabled' => env('SSH_MUX_HEALTH_CHECK_ENABLED', true), 'mux_health_check_timeout' => env('SSH_MUX_HEALTH_CHECK_TIMEOUT', 5), - 'mux_max_age' => env('SSH_MUX_MAX_AGE', 1800), // 30 minutes 'mux_lock_ttl' => env('SSH_MUX_LOCK_TTL', 30), // lock auto-release, seconds 'mux_lock_timeout' => env('SSH_MUX_LOCK_TIMEOUT', 10), // max wait for lock, seconds 'mux_orphan_min_age' => env('SSH_MUX_ORPHAN_MIN_AGE', 600), // min process age before reaping orphans, seconds diff --git a/config/development-qemu.php b/config/development-qemu.php new file mode 100644 index 0000000000..ae77c8341e --- /dev/null +++ b/config/development-qemu.php @@ -0,0 +1,124 @@ + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68', + 'storage_path' => env('DEVELOPMENT_QEMU_STORAGE_PATH', '/var/lib/libvirt/images/coolify-development'), + 'gateway' => '192.168.122.1', + 'subnet' => '192.168.122.0/24', + 'prefix' => 24, + 'dns' => '1.1.1.1', + 'memory' => 2048, + 'vcpus' => 2, + 'disk_size' => '20G', + 'libvirt_network' => 'default', + 'docker_network' => 'coolify', + 'coolify_container' => 'coolify', + 'profiles' => [ + 'ubuntu-root' => [ + 'label' => 'Ubuntu 24.04 (root)', + 'domain' => 'coolify-dev-ubuntu-root', + 'uuid' => 'development-qemu-ubuntu-root', + 'name' => 'QEMU Ubuntu (root)', + 'ip' => '192.168.122.10', + 'user' => 'root', + 'mac' => '52:54:00:ca:00:01', + 'image' => 'ubuntu-noble-amd64.qcow2', + 'image_url' => 'https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img', + 'os_variant' => 'ubuntu24.04', + 'provisioner' => 'apt', + ], + 'ubuntu-non-root' => [ + 'label' => 'Ubuntu 24.04 (non-root)', + 'domain' => 'coolify-dev-ubuntu-non-root', + 'uuid' => 'development-qemu-ubuntu-non-root', + 'name' => 'QEMU Ubuntu (non-root)', + 'ip' => '192.168.122.11', + 'user' => 'coolify', + 'mac' => '52:54:00:ca:00:02', + 'image' => 'ubuntu-noble-amd64.qcow2', + 'image_url' => 'https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img', + 'os_variant' => 'ubuntu24.04', + 'provisioner' => 'apt', + ], + 'debian-root' => [ + 'label' => 'Debian 12 (root)', + 'domain' => 'coolify-dev-debian-root', + 'uuid' => 'development-qemu-debian-root', + 'name' => 'QEMU Debian (root)', + 'ip' => '192.168.122.20', + 'user' => 'root', + 'mac' => '52:54:00:ca:00:03', + 'image' => 'debian-12-amd64.qcow2', + 'image_url' => 'https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2', + 'os_variant' => 'debian12', + 'provisioner' => 'apt', + ], + 'debian-non-root' => [ + 'label' => 'Debian 12 (non-root)', + 'domain' => 'coolify-dev-debian-non-root', + 'uuid' => 'development-qemu-debian-non-root', + 'name' => 'QEMU Debian (non-root)', + 'ip' => '192.168.122.21', + 'user' => 'coolify', + 'mac' => '52:54:00:ca:00:04', + 'image' => 'debian-12-amd64.qcow2', + 'image_url' => 'https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2', + 'os_variant' => 'debian12', + 'provisioner' => 'apt', + ], + 'centos-root' => [ + 'label' => 'CentOS Stream 9 (root)', + 'domain' => 'coolify-dev-centos-root', + 'uuid' => 'development-qemu-centos-root', + 'name' => 'QEMU CentOS Stream (root)', + 'ip' => '192.168.122.30', + 'user' => 'root', + 'mac' => '52:54:00:ca:00:05', + 'image' => 'centos-stream-9-amd64.qcow2', + 'image_url' => 'https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2', + 'os_variant' => 'centos-stream9', + 'provisioner' => 'rpm', + ], + 'centos-non-root' => [ + 'label' => 'CentOS Stream 9 (non-root)', + 'domain' => 'coolify-dev-centos-non-root', + 'uuid' => 'development-qemu-centos-non-root', + 'name' => 'QEMU CentOS Stream (non-root)', + 'ip' => '192.168.122.31', + 'user' => 'coolify', + 'mac' => '52:54:00:ca:00:06', + 'image' => 'centos-stream-9-amd64.qcow2', + 'image_url' => 'https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2', + 'os_variant' => 'centos-stream9', + 'provisioner' => 'rpm', + ], + 'alpine-root' => [ + 'label' => 'Alpine Linux 3.24 (root)', + 'domain' => 'coolify-dev-alpine-root', + 'uuid' => 'development-qemu-alpine-root', + 'name' => 'QEMU Alpine (root)', + 'ip' => '192.168.122.40', + 'user' => 'root', + 'mac' => '52:54:00:ca:00:07', + 'image' => 'alpine-3.24-amd64.qcow2', + 'image_url' => 'https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/cloud/generic_alpine-3.24.1-x86_64-bios-cloudinit-r0.qcow2', + 'os_variant' => 'generic', + 'provisioner' => 'apk', + 'interface' => 'eth0', + ], + 'alpine-non-root' => [ + 'label' => 'Alpine Linux 3.24 (non-root)', + 'domain' => 'coolify-dev-alpine-non-root', + 'uuid' => 'development-qemu-alpine-non-root', + 'name' => 'QEMU Alpine (non-root)', + 'ip' => '192.168.122.41', + 'user' => 'coolify', + 'mac' => '52:54:00:ca:00:08', + 'image' => 'alpine-3.24-amd64.qcow2', + 'image_url' => 'https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/cloud/generic_alpine-3.24.1-x86_64-bios-cloudinit-r0.qcow2', + 'os_variant' => 'generic', + 'provisioner' => 'apk', + 'interface' => 'eth0', + ], + ], +]; diff --git a/config/horizon.php b/config/horizon.php index d178038496..fe35734c2d 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -1,5 +1,6 @@ env('HORIZON_PATH', 'horizon'), + /* + |-------------------------------------------------------------------------- + | Horizon Allowed Emails + |-------------------------------------------------------------------------- + | + | A comma-separated list of email addresses that may access the Horizon + | dashboard in addition to the root user. + | + */ + + 'allowed_emails' => env('HORIZON_ALLOWED_EMAILS', ''), + /* |-------------------------------------------------------------------------- | Horizon Redis Connection @@ -190,7 +203,10 @@ return [ 'tries' => 1, 'nice' => 0, 'sleep' => 3, - 'timeout' => env('HORIZON_TIMEOUT', 36000), + 'timeout' => min( + max((int) env('HORIZON_TIMEOUT', 39600), ScheduledVolumeBackup::DEFAULT_TIMEOUT + 600), + 85800, + ), ], ], diff --git a/database/factories/ApplicationFactory.php b/database/factories/ApplicationFactory.php index ded507c56d..188d32954b 100644 --- a/database/factories/ApplicationFactory.php +++ b/database/factories/ApplicationFactory.php @@ -2,8 +2,12 @@ namespace Database\Factories; +use App\Models\Application; use Illuminate\Database\Eloquent\Factories\Factory; +/** + * @extends Factory + */ class ApplicationFactory extends Factory { public function definition(): array diff --git a/database/migrations/2026_08_15_000000_increase_default_volume_backup_timeout.php b/database/migrations/2026_08_15_000000_increase_default_volume_backup_timeout.php new file mode 100644 index 0000000000..32eccce96b --- /dev/null +++ b/database/migrations/2026_08_15_000000_increase_default_volume_backup_timeout.php @@ -0,0 +1,22 @@ +unsignedInteger('timeout')->default(36000)->change(); + }); + } + + public function down(): void + { + Schema::table('scheduled_volume_backups', function (Blueprint $table) { + $table->unsignedInteger('timeout')->default(3600)->change(); + }); + } +}; diff --git a/database/migrations/2026_08_18_104130_add_is_dashboard_force_https_enabled_to_instance_settings_table.php b/database/migrations/2026_08_18_104130_add_is_dashboard_force_https_enabled_to_instance_settings_table.php new file mode 100644 index 0000000000..d88f9be3fc --- /dev/null +++ b/database/migrations/2026_08_18_104130_add_is_dashboard_force_https_enabled_to_instance_settings_table.php @@ -0,0 +1,28 @@ +boolean('is_dashboard_force_https_enabled')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('is_dashboard_force_https_enabled'); + }); + } +}; diff --git a/database/migrations/2026_08_19_090916_add_smtp_ehlo_domain_to_email_settings.php b/database/migrations/2026_08_19_090916_add_smtp_ehlo_domain_to_email_settings.php new file mode 100644 index 0000000000..8a421156f2 --- /dev/null +++ b/database/migrations/2026_08_19_090916_add_smtp_ehlo_domain_to_email_settings.php @@ -0,0 +1,36 @@ +string('smtp_ehlo_domain')->nullable(); + }); + + Schema::table('email_notification_settings', function (Blueprint $table) { + $table->string('smtp_ehlo_domain')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('smtp_ehlo_domain'); + }); + + Schema::table('email_notification_settings', function (Blueprint $table) { + $table->dropColumn('smtp_ehlo_domain'); + }); + } +}; diff --git a/database/migrations/2026_08_20_070831_promote_first_team_member_when_team_has_no_owner.php b/database/migrations/2026_08_20_070831_promote_first_team_member_when_team_has_no_owner.php new file mode 100644 index 0000000000..1d6d5694d4 --- /dev/null +++ b/database/migrations/2026_08_20_070831_promote_first_team_member_when_team_has_no_owner.php @@ -0,0 +1,52 @@ +select('teams.id') + ->whereNotExists(function ($query): void { + $query->selectRaw('1') + ->from('team_user as owners') + ->whereColumn('owners.team_id', 'teams.id') + ->where('owners.role', 'owner'); + }) + ->orderBy('teams.id') + ->chunkById(100, function ($teams): void { + foreach ($teams as $team) { + $firstMember = DB::table('team_user') + ->where('team_id', $team->id) + ->orderByRaw("CASE WHEN role = 'admin' THEN 0 ELSE 1 END") + ->orderBy('created_at') + ->orderBy('id') + ->first(); + + if ($firstMember === null) { + continue; + } + + DB::table('team_user') + ->where('id', $firstMember->id) + ->update([ + 'role' => 'owner', + 'updated_at' => now(), + ]); + } + }, 'teams.id', 'id'); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // This data migration cannot identify which owners were promoted safely. + } +}; diff --git a/database/seeders/OauthSettingSeeder.php b/database/seeders/OauthSettingSeeder.php index 2e5e6fcc4c..2e3e63defd 100644 --- a/database/seeders/OauthSettingSeeder.php +++ b/database/seeders/OauthSettingSeeder.php @@ -4,6 +4,7 @@ namespace Database\Seeders; use App\Models\OauthSetting; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; class OauthSettingSeeder extends Seeder @@ -43,27 +44,27 @@ class OauthSettingSeeder extends Seeder return; } - $allProviders = OauthSetting::all(); - $notFoundProviders = $providers->diff($allProviders->pluck('provider')); + DB::transaction(function () use ($providers) { + $allProviders = OauthSetting::all(); + $notFoundProviders = $providers->diff($allProviders->pluck('provider')); - $allProviders->each(function ($provider) { - $provider->delete(); - }); - $allProviders->each(function ($provider) { - $provider = new OauthSetting; - $provider->provider = $provider->provider; - unset($provider->id); - $provider->save(); - }); + $allProviders->each(function ($provider) { + $provider->delete(); + }); + $allProviders->each(function ($provider) { + $newProvider = $provider->replicate(); + $newProvider->save(); + }); - foreach ($notFoundProviders as $provider) { - OauthSetting::create([ - 'provider' => $provider, - ]); - } + foreach ($notFoundProviders as $provider) { + OauthSetting::create([ + 'provider' => $provider, + ]); + } + }); } catch (\Exception $e) { - Log::error($e->getMessage()); + Log::error('OauthSettingSeeder failed: '.$e->getMessage()); } } } diff --git a/database/seeders/PersonalAccessTokenSeeder.php b/database/seeders/PersonalAccessTokenSeeder.php index 38a45219c2..bedc3b5490 100644 --- a/database/seeders/PersonalAccessTokenSeeder.php +++ b/database/seeders/PersonalAccessTokenSeeder.php @@ -6,7 +6,7 @@ use App\Models\PersonalAccessToken; use App\Models\Team; use App\Models\User; use Illuminate\Database\Seeder; -use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\DB; class PersonalAccessTokenSeeder extends Seeder { @@ -74,33 +74,35 @@ class PersonalAccessTokenSeeder extends Seeder ], ]; - // First, remove all existing development tokens for this user - $deletedCount = PersonalAccessToken::where('tokenable_id', $user->id) - ->where('tokenable_type', get_class($user)) - ->whereIn('name', array_column($testTokens, 'name')) - ->delete(); + DB::transaction(function () use ($user, $team, $testTokens) { + // First, remove all existing development tokens for this user + $deletedCount = PersonalAccessToken::where('tokenable_id', $user->id) + ->where('tokenable_type', get_class($user)) + ->whereIn('name', array_column($testTokens, 'name')) + ->delete(); - if ($deletedCount > 0) { - $this->command->info("Removed {$deletedCount} existing development token(s)."); - } + if ($deletedCount > 0) { + $this->command->info("Removed {$deletedCount} existing development token(s)."); + } - // Now create fresh tokens - foreach ($testTokens as $tokenData) { - // Create the token with a simple format: Bearer {scope} - // The token format in the database is the hash of the plain text token - $plainTextToken = $tokenData['token']; + // Now create fresh tokens + foreach ($testTokens as $tokenData) { + // Create the token with a simple format: Bearer {scope} + // The token format in the database is the hash of the plain text token + $plainTextToken = $tokenData['token']; - PersonalAccessToken::create([ - 'tokenable_type' => get_class($user), - 'tokenable_id' => $user->id, - 'name' => $tokenData['name'], - 'token' => hash('sha256', $plainTextToken), - 'abilities' => $tokenData['abilities'], - 'team_id' => $team->id, - ]); + PersonalAccessToken::create([ + 'tokenable_type' => get_class($user), + 'tokenable_id' => $user->id, + 'name' => $tokenData['name'], + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $tokenData['abilities'], + 'team_id' => $team->id, + ]); - $this->command->info("Created token '{$tokenData['name']}' with Bearer token: {$plainTextToken}"); - } + $this->command->info("Created token '{$tokenData['name']}' with Bearer token: {$plainTextToken}"); + } + }); $this->command->info(''); $this->command->info('Test API tokens created successfully!'); diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index 567cfbeebe..94330bbcec 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -2,11 +2,11 @@ # https://hub.docker.com/_/alpine ARG BASE_IMAGE=alpine:3.21 # https://download.docker.com/linux/static/stable/ -ARG DOCKER_VERSION=28.0.0 +ARG DOCKER_VERSION=29.7.2 # https://github.com/docker/compose/releases -ARG DOCKER_COMPOSE_VERSION=2.38.2 +ARG DOCKER_COMPOSE_VERSION=5.5.0 # https://github.com/docker/buildx/releases -ARG DOCKER_BUILDX_VERSION=0.25.0 +ARG DOCKER_BUILDX_VERSION=0.36.1 # https://github.com/buildpacks/pack/releases ARG PACK_VERSION=0.38.2 # https://github.com/railwayapp/nixpacks/releases diff --git a/other/nightly/upgrade.sh b/other/nightly/upgrade.sh index 94fb77607f..c5f38df6e0 100644 --- a/other/nightly/upgrade.sh +++ b/other/nightly/upgrade.sh @@ -216,6 +216,9 @@ done log "All images pulled successfully" echo " All images pulled successfully." +set_env_var "LATEST_IMAGE" "$LATEST_IMAGE" +set_env_var "COOLIFY_VERSION" "$LATEST_IMAGE" + log_section "Step 4/6: Stopping and restarting containers" write_status "4" "Stopping containers" echo "" diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 92a88ea023..d4e5b5c8c9 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.3.9" + "version": "4.3.11" }, "nightly": { - "version": "4.3.10" + "version": "4.4-rc.1" }, "helper": { "version": "1.0.15" diff --git a/resources/css/app.css b/resources/css/app.css index 636cf2ce8a..30ccf4ac02 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -398,9 +398,12 @@ html[data-theme="custom"] .animate-spin { color: var(--theme-bright-color) !important; } -/* Opt out of the brand spinner when the surrounding surface is a selected/neutral control. */ +/* Opt out of the brand spinner when the surrounding surface is a selected/neutral control + or a highlighted button, whose accent surface would camouflage a brand-colored spinner. */ .dark .animate-spin.spinner-current, -html[data-theme="custom"] .animate-spin.spinner-current { +html[data-theme="custom"] .animate-spin.spinner-current, +html[data-theme="custom"] .button-highlighted .animate-spin, +html[data-theme="custom"] button[isHighlighted] .animate-spin { color: inherit !important; } @@ -993,8 +996,7 @@ html[data-theme="custom"] { } html[data-theme="custom"] .control-selected, -html[data-theme="custom"] .logs-viewer-btn-active, -html[data-theme="custom"] .button-highlighted:hover { +html[data-theme="custom"] .logs-viewer-btn-active { color: var(--color-accent-foreground); } @@ -2119,6 +2121,36 @@ input[type="search"]::-webkit-search-results-decoration { width: 100%; } +.validation-installation-logs { + border: 1px solid var(--coollabs-fill); +} + +.checkpoint-scroll-fade::after { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 1; + width: 2rem; + background: linear-gradient(to left, var(--coollabs-base), transparent); + pointer-events: none; +} + +@media (max-width: 639px) { + .process-dialog-mobile-fullscreen { + height: 100dvh !important; + min-height: 100dvh; + max-height: 100dvh; + border-radius: 0; + box-shadow: inset 0 0 0 1px var(--coollabs-hairline) !important; + } + + .process-dialog-mobile-fullscreen .process-dialog-body { + border-radius: 0; + } +} + /* Data table (layer-card body, full-bleed) */ .data-table-header { display: grid; diff --git a/resources/css/utilities.css b/resources/css/utilities.css index 44218b0f3c..6fdd260b5f 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -126,12 +126,11 @@ } @utility button { - /* h-9 matches input-select; nowrap + shrink-0 keep side-by-side action rows equal height */ - @apply inline-flex shrink-0 gap-1.5 justify-center items-center whitespace-nowrap px-2.5 h-9 min-h-9 text-[13px] text-black normal-case rounded-md border outline-0 cursor-pointer font-medium transition-colors bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-white/[0.06] dark:text-fg dark:hover:text-fg dark:hover:bg-white/[0.1] dark:border-white/[0.08] hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-fg-faint disabled:border-neutral-200 dark:disabled:border-white/[0.06] disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent; + @apply inline-flex shrink-0 gap-1.5 justify-center items-center whitespace-nowrap px-2.5 h-8 min-h-8 text-[13px] text-black normal-case rounded-md border outline-0 cursor-pointer font-medium transition-colors bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-white/[0.06] dark:text-fg dark:hover:text-fg dark:hover:bg-white/[0.1] dark:border-white/[0.08] hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-fg-faint disabled:border-neutral-200 dark:disabled:border-white/[0.06] disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent; } @utility button-highlighted { - @apply border-coollabs-200 bg-linear-to-b from-coollabs-100 to-coollabs-200 text-white! hover:from-coollabs-100 hover:to-coollabs hover:text-white!; + @apply border-coollabs-200 bg-linear-to-b from-coollabs-100 to-coollabs-200 text-accent-foreground! hover:from-coollabs-100 hover:to-coollabs hover:text-accent-foreground!; } @utility control-selected { diff --git a/resources/js/app.js b/resources/js/app.js index bb41b7f041..11681efa03 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,4 +1,9 @@ import { initializeTerminalComponent } from './terminal.js'; +import { registerLivewireRequestFailureHandler } from './livewire-request-failure.js'; + +document.addEventListener('livewire:init', () => { + registerLivewireRequestFailureHandler(window.Livewire); +}); // Livewire 3.5.19+ re-applies `x-cloak` to morphed elements during wire:navigate // (via replaceHtmlAttributes). With `[x-cloak]{display:none}` on the app wrapper, diff --git a/resources/js/livewire-request-failure.js b/resources/js/livewire-request-failure.js new file mode 100644 index 0000000000..e1bd82583c --- /dev/null +++ b/resources/js/livewire-request-failure.js @@ -0,0 +1,68 @@ +export const INFRASTRUCTURE_FAILURE_STATUSES = new Set([502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 527, 530]); + +const USER_GESTURE_EVENTS = ['click', 'submit', 'keydown', 'input', 'change']; + +// A request sent within this window of a trusted user gesture is treated as +// user-initiated. It must cover Alpine $nextTick deferrals and wire:model +// debounces, while staying short enough to exclude most wire:poll requests. +export const GESTURE_WINDOW_MS = 2_000; + +const WARN_COOLDOWN_MS = 10_000; +const WARN_CONTENT_MAX_LENGTH = 2_000; + +export function createLivewireRequestFailureHandler({ now = Date.now } = {}) { + let lastWarnAt = Number.NEGATIVE_INFINITY; + let lastToastGestureAt = Number.NEGATIVE_INFINITY; + + return ({ status, content, preventDefault, gestureAt = Number.NEGATIVE_INFINITY }) => { + if (!INFRASTRUCTURE_FAILURE_STATUSES.has(status)) { + return; + } + + preventDefault(); + + const currentTime = now(); + if (currentTime - lastWarnAt >= WARN_COOLDOWN_MS) { + lastWarnAt = currentTime; + console.warn('Livewire request failed', { + status, + content: typeof content === 'string' ? content.slice(0, WARN_CONTENT_MAX_LENGTH) : content, + }); + } + + // One toast per user gesture: a single click that fails several + // component requests toasts once, while a retry (a new gesture) + // always toasts again. Background requests carry no gesture. + if (gestureAt > lastToastGestureAt) { + lastToastGestureAt = gestureAt; + window.toast?.('Action could not be completed', { + type: 'danger', + description: 'Coolify did not receive a response. Please try again.', + }); + } + }; +} + +export function registerLivewireRequestFailureHandler(Livewire, documentObject = document, { now = Date.now } = {}) { + let lastGestureAt = Number.NEGATIVE_INFINITY; + + const markUserGesture = (event) => { + if (event.isTrusted) { + lastGestureAt = now(); + } + }; + + USER_GESTURE_EVENTS.forEach((eventName) => { + documentObject.addEventListener(eventName, markUserGesture, true); + }); + + const handleFailure = createLivewireRequestFailureHandler({ now }); + + Livewire.hook('request', ({ fail }) => { + // Classify at send time: infrastructure failures (522/524) can arrive + // long after the gesture, so the failure timestamp is meaningless. + const gestureAt = now() - lastGestureAt <= GESTURE_WINDOW_MS ? lastGestureAt : Number.NEGATIVE_INFINITY; + + fail((failure) => handleFailure({ ...failure, gestureAt })); + }); +} diff --git a/resources/js/livewire-request-failure.test.js b/resources/js/livewire-request-failure.test.js new file mode 100644 index 0000000000..64a5cebbce --- /dev/null +++ b/resources/js/livewire-request-failure.test.js @@ -0,0 +1,146 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + GESTURE_WINDOW_MS, + INFRASTRUCTURE_FAILURE_STATUSES, + createLivewireRequestFailureHandler, + registerLivewireRequestFailureHandler, +} from './livewire-request-failure.js'; + +function createHarness({ start = 100_000 } = {}) { + let currentTime = start; + let requestHook = null; + let toasts = 0; + let warnings = []; + const listeners = {}; + + global.window = { toast: () => toasts++ }; + global.console = { warn: (...args) => warnings.push(args) }; + + registerLivewireRequestFailureHandler({ + hook(name, callback) { + assert.equal(name, 'request'); + requestHook = callback; + }, + }, { + addEventListener(name, callback) { + listeners[name] = callback; + }, + }, { now: () => currentTime }); + + return { + listeners, + advance: (ms) => currentTime += ms, + gesture: (event = { isTrusted: true }) => listeners.click(event), + fail(status) { + let prevented = false; + let failureCallback = null; + requestHook({ fail: (callback) => failureCallback = callback }); + failureCallback({ status, content: 'proxy error', preventDefault: () => prevented = true }); + return prevented; + }, + toasts: () => toasts, + warnings: () => warnings, + }; +} + +test('a failure after a trusted gesture suppresses the response and shows a toast', () => { + const harness = createHarness(); + + harness.gesture(); + const prevented = harness.fail(504); + + assert.equal(prevented, true); + assert.equal(harness.toasts(), 1); +}); + +test('background failures are suppressed and logged without a toast', () => { + const harness = createHarness(); + + const prevented = harness.fail(524); + + assert.equal(prevented, true); + assert.equal(harness.toasts(), 0); + assert.equal(harness.warnings().length, 1); + assert.deepEqual(harness.warnings()[0], ['Livewire request failed', { + status: 524, + content: 'proxy error', + }]); +}); + +test('one gesture toasts once, but a retry gesture toasts again', () => { + const harness = createHarness(); + + harness.gesture(); + harness.fail(504); + harness.fail(504); + assert.equal(harness.toasts(), 1); + + harness.advance(8_000); + harness.gesture(); + harness.fail(504); + assert.equal(harness.toasts(), 2); +}); + +test('requests sent outside the gesture window count as background', () => { + const harness = createHarness(); + + harness.gesture(); + harness.advance(GESTURE_WINDOW_MS + 1); + harness.fail(504); + + assert.equal(harness.toasts(), 0); +}); + +test('untrusted synthetic events do not count as gestures', () => { + const harness = createHarness(); + + harness.gesture({ isTrusted: false }); + harness.fail(504); + + assert.equal(harness.toasts(), 0); +}); + +test('a missing window.toast does not throw', () => { + const harness = createHarness(); + delete global.window.toast; + + harness.gesture(); + assert.doesNotThrow(() => harness.fail(504)); +}); + +test('console warnings are throttled and truncated', () => { + const harness = createHarness(); + + harness.fail(502); + harness.fail(504); + assert.equal(harness.warnings().length, 1); + + harness.advance(10_000); + harness.fail(504); + assert.equal(harness.warnings().length, 2); + + const handler = createLivewireRequestFailureHandler({ now: () => 0 }); + let logged = null; + global.console = { warn: (message, details) => logged = details }; + handler({ status: 502, content: 'x'.repeat(5_000), preventDefault() {} }); + assert.equal(logged.content.length, 2_000); +}); + +test('all supported infrastructure status codes are handled', () => { + const harness = createHarness(); + + for (const status of INFRASTRUCTURE_FAILURE_STATUSES) { + assert.equal(harness.fail(status), true, `expected ${status} to be handled`); + } +}); + +test('other failures keep Livewire default handling', () => { + const harness = createHarness(); + + harness.gesture(); + for (const status of [401, 419, 422, 429, 500]) { + assert.equal(harness.fail(status), false, `expected ${status} to be untouched`); + } + assert.equal(harness.toasts(), 0); +}); diff --git a/resources/views/components/icon-tooltip.blade.php b/resources/views/components/icon-tooltip.blade.php index f492d971b6..de62e8813d 100644 --- a/resources/views/components/icon-tooltip.blade.php +++ b/resources/views/components/icon-tooltip.blade.php @@ -38,11 +38,11 @@ this.visible = true; const rect = target.getBoundingClientRect(); this.below = rect.top < 48; - this.x = rect.left; + this.x = rect.left + rect.width / 2; this.y = this.below ? rect.bottom + 8 : rect.top - 8; this.$nextTick(() => { const width = this.$refs.tooltip?.offsetWidth || 0; - this.x = Math.max(8, Math.min(window.innerWidth - width - 8, this.x)); + this.x = Math.max(8, Math.min(window.innerWidth - width - 8, this.x - width / 2)); this.$nextTick(() => this.positioned = true); }); }, diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 0e4350f50f..d63c1953f2 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -334,12 +334,15 @@ step++; } else { submitting = true; - submitForm().then((result) => { - submitting = false; - modalOpen = false; - resetModal(); - }).catch(() => { - submitting = false; + modalOpen = false; + $nextTick(() => { + submitForm().then((result) => { + submitting = false; + resetModal(); + }).catch(() => { + submitting = false; + modalOpen = true; + }); }); } "> @@ -388,17 +391,21 @@ $wire.dispatch(dispatchEventType, dispatchEventMessage); } submitting = true; - submitForm().then((result) => { - submitting = false; - if (result === true) { - modalOpen = false; - resetModal(); - } else { - passwordError = result; - password = ''; - } - }).catch(() => { - submitting = false; + modalOpen = false; + $nextTick(() => { + submitForm().then((result) => { + submitting = false; + if (result === true) { + resetModal(); + } else { + modalOpen = true; + passwordError = result; + password = ''; + } + }).catch(() => { + submitting = false; + modalOpen = true; + }); }); "> diff --git a/resources/views/components/process-dialog.blade.php b/resources/views/components/process-dialog.blade.php index dc4f9c3b59..193e5680ce 100644 --- a/resources/views/components/process-dialog.blade.php +++ b/resources/views/components/process-dialog.blade.php @@ -1,5 +1,6 @@ @props([ 'closeWithX' => false, + 'mobileFullscreen' => false, 'open' => false, 'size' => 'lg', ]) @@ -40,7 +41,11 @@
+ @class([ + 'flex min-h-full items-center justify-center', + 'p-4 sm:p-6' => ! $mobileFullscreen, + 'p-0 sm:p-6' => $mobileFullscreen, + ])>
$mobileFullscreen, $panelWidth, // Fixed shell size so empty “waiting for process” state does not collapse. 'min-h-[min(70dvh,28rem)] h-[min(85dvh,52rem)] max-h-[calc(100dvh-2rem)]', diff --git a/resources/views/components/proxy-configuration-warning.blade.php b/resources/views/components/proxy-configuration-warning.blade.php new file mode 100644 index 0000000000..7337779d0e --- /dev/null +++ b/resources/views/components/proxy-configuration-warning.blade.php @@ -0,0 +1,37 @@ +@props(['canRestart' => false]) + +
+ + + +
diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index 006efb2cac..46e433e8a5 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -55,6 +55,8 @@ 'icon' => 'network', 'group' => 'Platform', 'visible' => ! $server->isSwarmWorker() && ! $server->settings->is_build_server, + 'warning' => $server->hasCurrentTraefikOutdatedInfo(), + 'tracks_proxy_configuration' => true, 'children' => [ ['label' => 'Configuration', 'route' => 'server.proxy', 'active' => $activeSubMenu === 'configuration', 'icon' => 'settings'], ['label' => 'Dynamic Configurations', 'route' => 'server.proxy.dynamic-confs', 'active' => $activeSubMenu === 'dynamic-confs', 'icon' => 'sliders', 'visible' => $server->proxySet()], @@ -68,6 +70,7 @@ 'icon' => 'shield-star', 'group' => 'Platform', 'visible' => $server->isFunctional() && ! $server->isSwarm() && ! $server->settings->is_build_server && auth()->user()?->can('viewSentinel', $server), + 'warning' => $server->isSentinelEnabled() && ! $server->isSentinelLive(), 'children' => [ ['label' => 'Configuration', 'route' => 'server.sentinel', 'active' => request()->routeIs('server.sentinel'), 'icon' => 'settings'], ['label' => 'Logs', 'route' => 'server.sentinel.logs', 'active' => request()->routeIs('server.sentinel.logs'), 'icon' => 'file-content'], @@ -167,7 +170,15 @@ $groupedServerMenuItems = $serverMenuItems->groupBy('group'); @endphp -
diff --git a/resources/views/invitation/accept.blade.php b/resources/views/invitation/accept.blade.php index 12c3ddbb66..7509a9969e 100644 --- a/resources/views/invitation/accept.blade.php +++ b/resources/views/invitation/accept.blade.php @@ -21,8 +21,11 @@ You are already a member of this team. Dismiss the invitation to continue. @endif -
+ @csrf + @isset($token) + + @endisset {{ $alreadyMember ? 'Dismiss invitation' : 'Accept invitation' }} diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 7e02ddb8d9..a7de0b6804 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -124,6 +124,9 @@
+ @if (isInstanceAdmin() && !isCloud()) + + @endif @@ -66,7 +66,7 @@ Teams
@foreach (auth()->user()->teams as $team) - diff --git a/resources/views/livewire/team/danger-zone.blade.php b/resources/views/livewire/team/danger-zone.blade.php index ade512a6f4..8d0c022b12 100644 --- a/resources/views/livewire/team/danger-zone.blade.php +++ b/resources/views/livewire/team/danger-zone.blade.php @@ -16,7 +16,11 @@
- @if (session('currentTeam.id') === 0) + @if (auth()->user()->roleInTeam(currentTeam()->id) !== 'owner') +

+ Only team owners can delete this team. +

+ @elseif (session('currentTeam.id') === 0)

The default team cannot be deleted.

@@ -49,6 +53,7 @@
@if ( session('currentTeam.id') !== 0 && + auth()->user()->roleInTeam(currentTeam()->id) === 'owner' && auth()->user()->teams()->count() > 1 && !auth()->user()->currentTeam()->personal_team && !currentTeam()->subscription && @@ -69,27 +74,51 @@
- @if (session('currentTeam.id') !== 0 && !currentTeam()->subscription && !currentTeam()->isEmpty()) -
- @foreach ([ - 'Projects' => currentTeam()->projects, - 'Servers' => currentTeam()->servers, - 'Private keys' => currentTeam()->privateKeys, - 'Sources' => currentTeam()->sources(), - ] as $label => $resources) - @if ($resources->isNotEmpty()) -
-

- {{ $label }} -

-
    - @foreach ($resources as $resource) -
  • {{ $resource->name }}
  • - @endforeach -
-
- @endif - @endforeach + @if (session('currentTeam.id') !== 0 && !currentTeam()->subscription && (currentTeam()->projects->isNotEmpty() || currentTeam()->servers->isNotEmpty())) +
+
+
Resources
+ + + Refresh + +
+ + + + + + + + + @foreach (currentTeam()->projects as $project) + + + + + @endforeach + @foreach (currentTeam()->servers as $server) + + + + + @endforeach + +
ResourceName
+ Project + + {{ $project->name }} +
+ Server + + {{ $server->name }} +
@endif diff --git a/resources/views/livewire/upgrade.blade.php b/resources/views/livewire/upgrade.blade.php index 97c08c0357..b1ad048e1c 100644 --- a/resources/views/livewire/upgrade.blade.php +++ b/resources/views/livewire/upgrade.blade.php @@ -6,6 +6,14 @@ })"> @if ($isUpgradeAvailable)
+ @if ($fullButton) + + Upgrade now + + + Updating… + + @else + @endif
+ @elseif ($fullButton) +

Coolify is up to date.

@endif
diff --git a/routes/api.php b/routes/api.php index 0ce9a7f11f..2d19ae96dc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -10,6 +10,7 @@ use App\Http\Controllers\Api\DigitalOceanController; use App\Http\Controllers\Api\GithubController; use App\Http\Controllers\Api\GitlabController; use App\Http\Controllers\Api\HetznerController; +use App\Http\Controllers\Api\InstanceEmailSettingsController; use App\Http\Controllers\Api\NotificationsController; use App\Http\Controllers\Api\OtherController; use App\Http\Controllers\Api\ProjectController; @@ -83,6 +84,8 @@ Route::group([ Route::patch('/notifications/pushover', [NotificationsController::class, 'update_pushover'])->middleware(['api.ability:write']); Route::get('/notifications/webhook', [NotificationsController::class, 'webhook'])->middleware(['api.ability:read']); Route::patch('/notifications/webhook', [NotificationsController::class, 'update_webhook'])->middleware(['api.ability:write']); + Route::get('/settings/email', [InstanceEmailSettingsController::class, 'show'])->middleware(['api.ability:read']); + Route::patch('/settings/email', [InstanceEmailSettingsController::class, 'update'])->middleware(['api.ability:write:sensitive']); Route::get('/team/envs', [SharedEnvironmentVariablesController::class, 'team_envs'])->middleware(['api.ability:read']); Route::post('/team/envs', [SharedEnvironmentVariablesController::class, 'team_create_env'])->middleware(['api.ability:write']); Route::patch('/team/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'team_update_env'])->middleware(['api.ability:write']); diff --git a/routes/web.php b/routes/web.php index ff3c0890c8..dddb0353bf 100644 --- a/routes/web.php +++ b/routes/web.php @@ -11,6 +11,7 @@ use App\Livewire\Dashboard; use App\Livewire\Destination\Index as DestinationIndex; use App\Livewire\Destination\Resources as DestinationResources; use App\Livewire\Destination\Show as DestinationShow; +use App\Livewire\Dev\LivewireRequestFailurePreview; use App\Livewire\ForcePasswordReset; use App\Livewire\Notifications\Discord as NotificationDiscord; use App\Livewire\Notifications\Email as NotificationEmail; @@ -112,15 +113,17 @@ Route::post('/forgot-password', [Controller::class, 'forgot_password'])->name('p Route::get('/realtime', [Controller::class, 'realtime_test'])->middleware('auth'); Route::get('/verify', [Controller::class, 'verify'])->middleware('auth')->name('verify.email'); Route::get('/email/verify/{id}/{hash}', [Controller::class, 'email_verify'])->middleware(['auth'])->name('verify.verify'); -Route::middleware(['throttle:login'])->group(function () { - Route::get('/auth/link', [Controller::class, 'link'])->name('auth.link'); -}); +Route::get('/auth/link', [Controller::class, 'link'])->name('auth.link'); +Route::post('/auth/link', [Controller::class, 'acceptLink'])->middleware('throttle:magic-link')->name('auth.link.accept'); Route::get('/auth/{provider}/redirect', [OauthController::class, 'redirect'])->name('auth.redirect'); Route::get('/auth/{provider}/callback', [OauthController::class, 'callback'])->name('auth.callback'); // Local/testing previews for HTTP error pages and the Laravel debug renderer (never in production). if (app()->environment(['local', 'testing'])) { + Route::get('/__livewire-request-failure', LivewireRequestFailurePreview::class) + ->name('dev.livewire-request-failure-preview'); + Route::get('/__exception', function () { throw new RuntimeException('Testing Laravel exception page'); })->name('dev.exception-preview'); diff --git a/scripts/dev-helper b/scripts/dev-helper new file mode 100755 index 0000000000..1e1bfae27b --- /dev/null +++ b/scripts/dev-helper @@ -0,0 +1,280 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +REPOSITORY=${HELPER_IMAGE_REPOSITORY:-docker.io/coollabsio/coolify-helper} +DEFAULT_TAG="dev-$(git -C "$ROOT_DIR" rev-parse --short HEAD)" +COOLIFY_CONTAINER=${COOLIFY_CONTAINER:-coolify} + +usage() { + cat < [tag] + +Commands: + build [tag] Build the local helper image + use [tag] Configure local Coolify to use an existing helper image + verify [tag] Verify tools and run a Docker Compose socket smoke test + deploy [tag] Deploy seeded Dockerfile, Compose, and Nixpacks applications + reset Clear the local helper version override + test [tag] Build, select, verify, and run real seeded deployments + +Default tag: $DEFAULT_TAG +EOF +} + +require_local_coolify() { + local is_dev + + if ! docker inspect "$COOLIFY_CONTAINER" >/dev/null 2>&1; then + echo "Coolify container '$COOLIFY_CONTAINER' is not running." >&2 + exit 1 + fi + + is_dev=$(docker exec "$COOLIFY_CONTAINER" php artisan tinker --execute 'echo isDev() ? "yes" : "no";' 2>/dev/null | tail -1) + if [[ $is_dev != yes ]]; then + echo "The running Coolify instance is not in development mode." >&2 + exit 1 + fi +} + +validate_tag() { + local tag=$1 + + if [[ ! $tag =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then + echo "Invalid Docker tag: $tag" >&2 + exit 1 + fi +} + +image_for() { + echo "${REPOSITORY}:$1" +} + +build_helper() { + local tag=$1 + local image + image=$(image_for "$tag") + + echo "Building $image" + docker build --progress=plain -f "$ROOT_DIR/docker/coolify-helper/Dockerfile" -t "$image" "$ROOT_DIR" +} + +use_helper() { + local tag=$1 + local image + image=$(image_for "$tag") + + require_local_coolify + docker image inspect "$image" >/dev/null + docker exec -e DEV_HELPER_VERSION="$tag" "$COOLIFY_CONTAINER" php artisan tinker --execute ' + App\Models\InstanceSettings::findOrFail(0)->update([ + "dev_helper_version" => getenv("DEV_HELPER_VERSION"), + ]); + ' >/dev/null + + local selected + selected=$(docker exec "$COOLIFY_CONTAINER" php artisan tinker --execute 'echo getHelperVersion();' 2>/dev/null | tail -1) + if [[ $selected != "$tag" ]]; then + echo "Coolify selected helper '$selected' instead of '$tag'." >&2 + exit 1 + fi + + echo "Coolify now uses $image" +} + +verify_helper() { + local tag=$1 + local image project compose + image=$(image_for "$tag") + project="coolify-helper-${tag//[^A-Za-z0-9_-]/-}-smoke" + compose=$'services:\n app:\n image: alpine:3.21\n command: ["sh", "-c", "sleep 30"]\n' + + docker image inspect "$image" >/dev/null + + docker run --rm "$image" docker --version + docker run --rm "$image" docker compose version + docker run --rm "$image" docker buildx version + docker run --rm "$image" pack version + docker run --rm "$image" nixpacks --version + docker run --rm "$image" railpack --version + + docker run --rm -v /var/run/docker.sock:/var/run/docker.sock "$image" \ + docker version --format 'client={{.Client.Version}} server={{.Server.Version}} api={{.Client.APIVersion}}/{{.Server.APIVersion}}' + + cleanup_compose() { + printf '%s' "$compose" | docker run --rm -i \ + -v /var/run/docker.sock:/var/run/docker.sock "$image" \ + docker compose -p "$project" -f - down --remove-orphans >/dev/null 2>&1 || true + } + trap cleanup_compose RETURN + + printf '%s' "$compose" | docker run --rm -i \ + -v /var/run/docker.sock:/var/run/docker.sock "$image" \ + docker compose -p "$project" -f - up -d --wait + printf '%s' "$compose" | docker run --rm -i \ + -v /var/run/docker.sock:/var/run/docker.sock "$image" \ + docker compose -p "$project" -f - ps --format json + cleanup_compose + trap - RETURN + + if [[ -n $(docker ps -aq --filter "label=com.docker.compose.project=$project") ]]; then + echo "Compose smoke-test resources were not removed." >&2 + exit 1 + fi + + echo "Helper verification passed for $image" +} + +queue_deployment() { + local application_uuid=$1 + + docker exec -e APPLICATION_UUID="$application_uuid" "$COOLIFY_CONTAINER" php artisan tinker --execute ' + $application = App\Models\Application::query() + ->where("uuid", getenv("APPLICATION_UUID")) + ->firstOrFail(); + $deploymentUuid = (string) Illuminate\Support\Str::uuid(); + queue_application_deployment( + application: $application, + deployment_uuid: $deploymentUuid, + force_rebuild: true, + no_questions_asked: true, + ); + echo "DEPLOYMENT_UUID={$deploymentUuid}"; + ' 2>/dev/null | sed -n 's/^DEPLOYMENT_UUID=//p' | tail -1 +} + +wait_for_deployment() { + local deployment_uuid=$1 + local expected_image=$2 + local status + + for _ in $(seq 1 150); do + status=$(docker exec -e DEPLOYMENT_UUID="$deployment_uuid" "$COOLIFY_CONTAINER" php artisan tinker --execute ' + $deployment = App\Models\ApplicationDeploymentQueue::query() + ->where("deployment_uuid", getenv("DEPLOYMENT_UUID")) + ->first(); + echo $deployment?->status ?? "missing"; + ' 2>/dev/null | tail -1) + + case "$status" in + finished) + break + ;; + failed|cancelled|missing) + echo "Deployment $deployment_uuid ended with status: $status" >&2 + return 1 + ;; + esac + + sleep 4 + done + + if [[ $status != finished ]]; then + echo "Deployment $deployment_uuid timed out with status: $status" >&2 + return 1 + fi + + local used_expected_helper + used_expected_helper=$(docker exec \ + -e DEPLOYMENT_UUID="$deployment_uuid" \ + -e EXPECTED_HELPER_IMAGE="$expected_image" \ + "$COOLIFY_CONTAINER" php artisan tinker --execute ' + $deployment = App\Models\ApplicationDeploymentQueue::query() + ->where("deployment_uuid", getenv("DEPLOYMENT_UUID")) + ->firstOrFail(); + $logs = collect(json_decode($deployment->logs, true)) + ->pluck("output") + ->implode("\n"); + echo str_contains( + $logs, + "Preparing container with helper image: ".getenv("EXPECTED_HELPER_IMAGE"), + ) ? "yes" : "no"; + ' 2>/dev/null | tail -1) + + if [[ $used_expected_helper != yes ]]; then + echo "Deployment $deployment_uuid did not use $expected_image." >&2 + return 1 + fi +} + +deploy_examples() { + local tag=$1 + local image application_uuid deployment_uuid fqdn + image=$(image_for "$tag") + + require_local_coolify + use_helper "$tag" + + for application_uuid in dockerfile docker-compose nodejs; do + echo "Deploying seeded application: $application_uuid" + deployment_uuid=$(queue_deployment "$application_uuid") + if [[ -z $deployment_uuid ]]; then + echo "Could not queue $application_uuid." >&2 + exit 1 + fi + + wait_for_deployment "$deployment_uuid" "$image" + + fqdn=$(docker exec -e APPLICATION_UUID="$application_uuid" "$COOLIFY_CONTAINER" php artisan tinker --execute ' + echo App\Models\Application::query() + ->where("uuid", getenv("APPLICATION_UUID")) + ->value("fqdn") ?? ""; + ' 2>/dev/null | tail -1) + + if [[ -n $fqdn ]]; then + curl --fail --silent --show-error --output /dev/null "$fqdn" + fi + + echo "Deployment passed: $application_uuid ($deployment_uuid)" + done +} + +reset_helper() { + require_local_coolify + docker exec "$COOLIFY_CONTAINER" php artisan tinker --execute ' + App\Models\InstanceSettings::findOrFail(0)->update([ + "dev_helper_version" => null, + ]); + ' >/dev/null + echo "Development helper override cleared." +} + +command=${1:-help} +tag=${2:-$DEFAULT_TAG} + +case "$command" in + build) + validate_tag "$tag" + build_helper "$tag" + ;; + use) + validate_tag "$tag" + use_helper "$tag" + ;; + verify) + validate_tag "$tag" + verify_helper "$tag" + ;; + deploy) + validate_tag "$tag" + deploy_examples "$tag" + ;; + reset) + reset_helper + ;; + test) + validate_tag "$tag" + build_helper "$tag" + use_helper "$tag" + verify_helper "$tag" + deploy_examples "$tag" + ;; + help|-h|--help) + usage + ;; + *) + usage >&2 + exit 1 + ;; +esac diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh index 516a9d7ebc..5baac23acb 100644 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -229,6 +229,9 @@ done log "All images pulled successfully" echo " All images pulled successfully." +set_env_var "LATEST_IMAGE" "$LATEST_IMAGE" +set_env_var "COOLIFY_VERSION" "$LATEST_IMAGE" + log_section "Step 4/6: Stopping and restarting containers" write_status "4" "Stopping containers" echo "" diff --git a/svgs/obsidian-livesync.svg b/svgs/obsidian-livesync.svg new file mode 100644 index 0000000000..97b77a9903 --- /dev/null +++ b/svgs/obsidian-livesync.svg @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/templates/compose/obsidian-livesync.yaml b/templates/compose/obsidian-livesync.yaml new file mode 100644 index 0000000000..1e6fce7472 --- /dev/null +++ b/templates/compose/obsidian-livesync.yaml @@ -0,0 +1,60 @@ +# documentation: https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/setup_own_server.md +# slogan: Self-hosted sync backend for Obsidian LiveSync plugin using CouchDB +# category: storage +# tags: obsidian,couchdb,sync,notes,markdown +# logo: svgs/obsidian-livesync.svg +# port: 5984 + +services: + couchdb: + image: couchdb:3.4.2 + environment: + # Tells Coolify to proxy this service on port 5984 at the assigned domain + - SERVICE_FQDN_COUCHDB_5984 + # Coolify auto-generates and persists these — find them under Environment Variables + - COUCHDB_USER=${SERVICE_USER_COUCHDB} + - COUCHDB_PASSWORD=${SERVICE_PASSWORD_64_COUCHDB} + # Max size of a single document (bytes). Default: 50MB. + - MAX_DOCUMENT_SIZE=${MAX_DOCUMENT_SIZE:-52428800} + # Max HTTP request size (bytes). Should be slightly above MAX_DOCUMENT_SIZE to allow multipart overhead. + # Raise only if you attach very large files to your notes. Default: 64MB. + - MAX_HTTP_REQUEST_SIZE=${MAX_HTTP_REQUEST_SIZE:-67108864} + volumes: + - type: bind + source: ./local.ini + target: /opt/couchdb/etc/local.ini + content: | + [couchdb] + single_node=true + max_document_size = ${MAX_DOCUMENT_SIZE} + + [chttpd] + require_valid_user = true + max_http_request_size = ${MAX_HTTP_REQUEST_SIZE} + + [chttpd_auth] + require_valid_user = true + authentication_redirect = /_utils/session.html + + [httpd] + WWW-Authenticate = Basic realm="couchdb" + enable_cors = true + + [cors] + origins = app://obsidian.md,capacitor://localhost,http://localhost + credentials = true + headers = accept, authorization, content-type, origin, referer + methods = GET, PUT, POST, HEAD, DELETE + max_age = 3600 + - couchdb-data:/opt/couchdb/data + healthcheck: + test: + - CMD-SHELL + - curl -f -u "$${COUCHDB_USER}:$${COUCHDB_PASSWORD}" http://localhost:5984/_up + interval: 30s + timeout: 10s + start_period: 40s + retries: 3 + +volumes: + couchdb-data: \ No newline at end of file diff --git a/templates/compose/vault.yaml b/templates/compose/vault.yaml new file mode 100644 index 0000000000..c0af9cd517 --- /dev/null +++ b/templates/compose/vault.yaml @@ -0,0 +1,25 @@ +# documentation: https://developer.hashicorp.com/vault/docs/deploy/run-container +# slogan: HashiCorp Vault securely stores and controls access to secrets. +# category: security +# tags: vault,hashicorp,secrets,security,encryption +# port: 8200 + +services: + vault: + image: 'hashicorp/vault:${VAULT_VERSION:-latest}' + command: server + environment: + - SERVICE_URL_VAULT_8200 + - VAULT_ADDR=http://127.0.0.1:8200 + - VAULT_API_ADDR=${SERVICE_URL_VAULT_8200} + - 'VAULT_LOCAL_CONFIG={"ui":true,"disable_mlock":true,"storage":{"file":{"path":"/vault/file"}},"listener":{"tcp":{"address":"0.0.0.0:8200","tls_disable":true}}}' + volumes: + - vault-data:/vault/file + healthcheck: + test: + - CMD-SHELL + - 'wget -qO- "http://127.0.0.1:8200/v1/sys/health?standbyok=true&sealedcode=200&uninitcode=200" >/dev/null || exit 1' + interval: 10s + timeout: 5s + retries: 12 + start_period: 15s diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index f90fa37908..00243fff09 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -5457,6 +5457,23 @@ "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "3000" }, + "vault": { + "documentation": "https://developer.hashicorp.com/vault/docs/deploy/run-container?utm_source=coolify.io", + "slogan": "HashiCorp Vault securely stores and controls access to secrets.", + "compose": "c2VydmljZXM6CiAgdmF1bHQ6CiAgICBpbWFnZTogJ2hhc2hpY29ycC92YXVsdDoke1ZBVUxUX1ZFUlNJT046LWxhdGVzdH0nCiAgICBjb21tYW5kOiBzZXJ2ZXIKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1ZBVUxUXzgyMDAKICAgICAgLSAnVkFVTFRfQUREUj1odHRwOi8vMTI3LjAuMC4xOjgyMDAnCiAgICAgIC0gJ1ZBVUxUX0FQSV9BRERSPSR7U0VSVklDRV9VUkxfVkFVTFRfODIwMH0nCiAgICAgIC0gJ1ZBVUxUX0xPQ0FMX0NPTkZJRz17InVpIjp0cnVlLCJkaXNhYmxlX21sb2NrIjp0cnVlLCJzdG9yYWdlIjp7ImZpbGUiOnsicGF0aCI6Ii92YXVsdC9maWxlIn19LCJsaXN0ZW5lciI6eyJ0Y3AiOnsiYWRkcmVzcyI6IjAuMC4wLjA6ODIwMCIsInRsc19kaXNhYmxlIjp0cnVlfX19JwogICAgdm9sdW1lczoKICAgICAgLSAndmF1bHQtZGF0YTovdmF1bHQvZmlsZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tICJodHRwOi8vMTI3LjAuMC4xOjgyMDAvdjEvc3lzL2hlYWx0aD9zdGFuZGJ5b2s9dHJ1ZSZzZWFsZWRjb2RlPTIwMCZ1bmluaXRjb2RlPTIwMCIgPi9kZXYvbnVsbCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTIKICAgICAgc3RhcnRfcGVyaW9kOiAxNXMK", + "tags": [ + "vault", + "hashicorp", + "secrets", + "security", + "encryption" + ], + "category": "security", + "logo": "svgs/default.webp", + "minversion": "0.0.0", + "template_last_updated_at": null, + "port": "8200" + }, "vaultwarden": { "documentation": "https://github.com/dani-garcia/vaultwarden?utm_source=coolify.io", "slogan": "Vaultwarden is a password manager that allows you to securely store and manage your passwords.", diff --git a/templates/service-templates.json b/templates/service-templates.json index 5a4ad35bec..c193f0934a 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -5457,6 +5457,23 @@ "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "3000" }, + "vault": { + "documentation": "https://developer.hashicorp.com/vault/docs/deploy/run-container?utm_source=coolify.io", + "slogan": "HashiCorp Vault securely stores and controls access to secrets.", + "compose": "c2VydmljZXM6CiAgdmF1bHQ6CiAgICBpbWFnZTogJ2hhc2hpY29ycC92YXVsdDoke1ZBVUxUX1ZFUlNJT046LWxhdGVzdH0nCiAgICBjb21tYW5kOiBzZXJ2ZXIKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9WQVVMVF84MjAwCiAgICAgIC0gJ1ZBVUxUX0FERFI9aHR0cDovLzEyNy4wLjAuMTo4MjAwJwogICAgICAtICdWQVVMVF9BUElfQUREUj0ke1NFUlZJQ0VfRlFETl9WQVVMVF84MjAwfScKICAgICAgLSAnVkFVTFRfTE9DQUxfQ09ORklHPXsidWkiOnRydWUsImRpc2FibGVfbWxvY2siOnRydWUsInN0b3JhZ2UiOnsiZmlsZSI6eyJwYXRoIjoiL3ZhdWx0L2ZpbGUifX0sImxpc3RlbmVyIjp7InRjcCI6eyJhZGRyZXNzIjoiMC4wLjAuMDo4MjAwIiwidGxzX2Rpc2FibGUiOnRydWV9fX0nCiAgICB2b2x1bWVzOgogICAgICAtICd2YXVsdC1kYXRhOi92YXVsdC9maWxlJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gImh0dHA6Ly8xMjcuMC4wLjE6ODIwMC92MS9zeXMvaGVhbHRoP3N0YW5kYnlvaz10cnVlJnNlYWxlZGNvZGU9MjAwJnVuaW5pdGNvZGU9MjAwIiA+L2Rldi9udWxsIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMgogICAgICBzdGFydF9wZXJpb2Q6IDE1cwo=", + "tags": [ + "vault", + "hashicorp", + "secrets", + "security", + "encryption" + ], + "category": "security", + "logo": "svgs/default.webp", + "minversion": "0.0.0", + "template_last_updated_at": null, + "port": "8200" + }, "vaultwarden": { "documentation": "https://github.com/dani-garcia/vaultwarden?utm_source=coolify.io", "slogan": "Vaultwarden is a password manager that allows you to securely store and manage your passwords.", diff --git a/tests/Feature/ActivityMonitorCrossTeamTest.php b/tests/Feature/ActivityMonitorCrossTeamTest.php index 9966ac2ddc..dc15140c6c 100644 --- a/tests/Feature/ActivityMonitorCrossTeamTest.php +++ b/tests/Feature/ActivityMonitorCrossTeamTest.php @@ -130,3 +130,23 @@ test('activityId property is locked and cannot be set from client', function () ->set('activityId', $otherActivity->id) ->assertStatus(500); })->throws(CannotUpdateLockedPropertyException::class); + +test('closing a process dialog clears the previous activity logs', function () { + $activity = Activity::create([ + 'log_name' => 'default', + 'description' => 'proxy restart activity', + 'properties' => ['team_id' => $this->team->id], + ]); + + $this->actingAs($this->user); + session(['currentTeam' => ['id' => $this->team->id]]); + + Livewire::test(ActivityMonitor::class) + ->call('newMonitorActivity', $activity->id) + ->assertSet('activityId', $activity->id) + ->assertSet('isPollingActive', true) + ->dispatch('processDialogClosed') + ->assertSet('activityId', null) + ->assertSet('activity', null) + ->assertSet('isPollingActive', false); +}); diff --git a/tests/Feature/Api/ApplicationBuildSecretsSettingApiTest.php b/tests/Feature/Api/ApplicationBuildSecretsSettingApiTest.php index 3b3721545f..1847c00221 100644 --- a/tests/Feature/Api/ApplicationBuildSecretsSettingApiTest.php +++ b/tests/Feature/Api/ApplicationBuildSecretsSettingApiTest.php @@ -254,4 +254,58 @@ describe('other application creation endpoints use_build_secrets', function () { expect($application->settings->use_build_secrets)->toBeTrue(); }); + + test('creates an application from a system-wide GitHub App owned by another team', function () { + $ownerTeam = Team::factory()->create(); + $privateKey = PrivateKey::create([ + 'name' => 'System-wide GitHub App Key', + 'private_key' => buildSecretsGithubPrivateKey(), + 'team_id' => $ownerTeam->id, + ]); + $githubApp = GithubApp::create([ + 'name' => 'System-wide GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'app_id' => 54321, + 'installation_id' => 9876, + 'client_id' => 'system-wide-client-id', + 'client_secret' => 'system-wide-client-secret', + 'webhook_secret' => 'system-wide-webhook-secret', + 'private_key_id' => $privateKey->id, + 'team_id' => $ownerTeam->id, + 'is_system_wide' => true, + 'is_public' => false, + ]); + + Http::fake([ + 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [ + 'Date' => now()->toRfc7231String(), + ]), + 'https://api.github.com/app/installations/9876/access_tokens' => Http::response([ + 'token' => 'github-installation-token', + ], 201), + 'https://api.github.com/repos/coolify/system-wide-test' => Http::response([ + 'id' => 654321, + ]), + ]); + + $response = $this->withHeaders(buildSecretsApiHeaders($this->bearerToken)) + ->postJson('/api/v1/applications/private-github-app', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'github_app_uuid' => $githubApp->uuid, + 'git_repository' => 'coolify/system-wide-test', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'autogenerate_domain' => false, + ]) + ->assertCreated(); + + $application = Application::where('uuid', $response->json('uuid'))->firstOrFail(); + + expect($application->source_id)->toBe($githubApp->id) + ->and($application->environment_id)->toBe($this->environment->id); + }); }); diff --git a/tests/Feature/Api/GithubAppsListApiTest.php b/tests/Feature/Api/GithubAppsListApiTest.php index 9a1f1f3d25..cb15cd9347 100644 --- a/tests/Feature/Api/GithubAppsListApiTest.php +++ b/tests/Feature/Api/GithubAppsListApiTest.php @@ -197,6 +197,39 @@ describe('GET /api/v1/github-apps', function () { ]); }); + test('does not return system-wide github app secrets owned by another team', function () { + $otherTeam = Team::factory()->create(); + $otherPrivateKey = PrivateKey::create([ + 'name' => 'System Key', + 'private_key' => validGithubAppsApiPrivateKey(), + 'team_id' => $otherTeam->id, + ]); + GithubApp::create([ + 'name' => 'Foreign System GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'app_id' => 11111, + 'installation_id' => 22222, + 'client_id' => 'system-client-id', + 'client_secret' => 'foreign-client-secret', + 'webhook_secret' => 'foreign-webhook-secret', + 'private_key_id' => $otherPrivateKey->id, + 'team_id' => $otherTeam->id, + 'is_system_wide' => true, + ]); + + $sensitiveToken = createGithubAppsApiToken($this, ['read', 'read:sensitive']); + + $response = $this->withToken($sensitiveToken) + ->getJson('/api/v1/github-apps') + ->assertSuccessful() + ->assertJsonFragment(['name' => 'Foreign System GitHub App']); + + expect($response->json('0')) + ->not->toHaveKey('client_secret') + ->not->toHaveKey('webhook_secret'); + }); + test('does not return other teams github apps', function () { // Create a GitHub app for this team GithubApp::create([ diff --git a/tests/Feature/Api/GitlabAppsApiTest.php b/tests/Feature/Api/GitlabAppsApiTest.php index 65332c0dd6..f18829c205 100644 --- a/tests/Feature/Api/GitlabAppsApiTest.php +++ b/tests/Feature/Api/GitlabAppsApiTest.php @@ -64,6 +64,36 @@ describe('GET /api/v1/gitlab-apps', function () { expect($response->json('0'))->not->toHaveKey('client_secret') ->and($response->json('0'))->not->toHaveKey('webhook_token'); }); + + test('does not return system-wide gitlab app secrets owned by another team', function () { + $otherTeam = Team::factory()->create(); + GitlabApp::create([ + 'name' => 'Foreign System GitLab', + 'api_url' => 'https://gitlab.com/api/v4', + 'html_url' => 'https://gitlab.com', + 'client_id' => 'foreign-client-id', + 'client_secret' => 'foreign-client-secret', + 'webhook_token' => 'foreign-webhook-token', + 'access_token' => 'foreign-access-token', + 'refresh_token' => 'foreign-refresh-token', + 'team_id' => $otherTeam->id, + 'is_system_wide' => true, + ]); + + session(['currentTeam' => $this->team]); + $sensitiveToken = $this->user->createToken('sensitive-token', ['read', 'read:sensitive'])->plainTextToken; + + $response = $this->withToken($sensitiveToken) + ->getJson('/api/v1/gitlab-apps') + ->assertSuccessful() + ->assertJsonFragment(['name' => 'Foreign System GitLab']); + + expect($response->json('0')) + ->not->toHaveKey('client_secret') + ->not->toHaveKey('webhook_token') + ->not->toHaveKey('access_token') + ->not->toHaveKey('refresh_token'); + }); }); describe('POST /api/v1/gitlab-apps', function () { diff --git a/tests/Feature/Api/InstanceEmailSettingsApiTest.php b/tests/Feature/Api/InstanceEmailSettingsApiTest.php new file mode 100644 index 0000000000..116481faa9 --- /dev/null +++ b/tests/Feature/Api/InstanceEmailSettingsApiTest.php @@ -0,0 +1,141 @@ + 'file', + 'cache.default' => 'array', + 'session.driver' => 'array', + ]); + + InstanceSettings::query()->whereKey(0)->delete(); + $settings = new InstanceSettings(['is_api_enabled' => true]); + $settings->id = 0; + $settings->save(); + Once::flush(); + + $this->rootTeam = Team::factory()->create(['id' => 0]); +}); + +function instanceEmailToken(User $user, Team $team, string $role, array $abilities): string +{ + $team->members()->attach($user->id, ['role' => $role]); + session(['currentTeam' => $team]); + + return $user->createToken('instance-email-test', $abilities)->plainTextToken; +} + +function instanceEmailHeaders(string $token): array +{ + return [ + 'Authorization' => 'Bearer '.$token, + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]; +} + +test('root team owners can get instance email settings', function () { + InstanceSettings::findOrFail(0)->update([ + 'smtp_enabled' => true, + 'smtp_ehlo_domain' => 'coolify.example.com', + ]); + $token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'owner', ['read']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->getJson('/api/v1/settings/email') + ->assertSuccessful() + ->assertJsonPath('smtp_enabled', true) + ->assertJsonPath('smtp_ehlo_domain', 'coolify.example.com') + ->assertJsonMissingPath('smtp_password'); +}); + +test('root team admins can update instance email settings', function () { + $token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'admin', ['write:sensitive']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->patchJson('/api/v1/settings/email', [ + 'smtp_enabled' => true, + 'smtp_from_address' => 'alerts@example.com', + 'smtp_from_name' => 'Coolify', + 'smtp_host' => 'smtp.example.com', + 'smtp_port' => 587, + 'smtp_encryption' => 'starttls', + 'smtp_username' => 'coolify', + 'smtp_password' => 'secret', + 'smtp_timeout' => 10, + 'smtp_ehlo_domain' => 'coolify.example.com', + ]) + ->assertSuccessful() + ->assertJsonPath('smtp_ehlo_domain', 'coolify.example.com'); + + $settings = InstanceSettings::findOrFail(0); + expect($settings->smtp_enabled)->toBeTrue() + ->and($settings->smtp_host)->toBe('smtp.example.com') + ->and($settings->smtp_ehlo_domain)->toBe('coolify.example.com'); +}); + +test('instance email settings reject non-root teams', function () { + $team = Team::factory()->create(); + $token = instanceEmailToken(User::factory()->create(), $team, 'owner', ['read', 'write']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->getJson('/api/v1/settings/email') + ->assertForbidden(); + + $this->withHeaders(instanceEmailHeaders($token)) + ->patchJson('/api/v1/settings/email', ['smtp_enabled' => true]) + ->assertForbidden(); +}); + +test('instance email settings reject root team members', function () { + $token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'member', ['read']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->getJson('/api/v1/settings/email') + ->assertForbidden(); +}); + +test('instance email settings validate the smtp ehlo domain', function () { + $token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'owner', ['write:sensitive']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->patchJson('/api/v1/settings/email', ['smtp_ehlo_domain' => 'not a hostname']) + ->assertUnprocessable() + ->assertJsonValidationErrors('smtp_ehlo_domain'); +}); + +test('updating instance email settings requires write sensitive', function () { + $token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'owner', ['write']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->patchJson('/api/v1/settings/email', ['smtp_enabled' => true]) + ->assertForbidden(); +}); + +test('root admins cannot use a token issued for another team', function () { + $user = User::factory()->create(); + $this->rootTeam->members()->attach($user->id, ['role' => 'owner']); + $team = Team::factory()->create(); + $token = instanceEmailToken($user, $team, 'owner', ['read', 'write:sensitive']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->getJson('/api/v1/settings/email') + ->assertForbidden(); +}); + +test('read sensitive exposes instance email secrets to root team admins', function () { + InstanceSettings::findOrFail(0)->update(['smtp_password' => 'secret']); + $token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'admin', ['read', 'read:sensitive']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->getJson('/api/v1/settings/email') + ->assertSuccessful() + ->assertJsonPath('smtp_password', 'secret'); +}); diff --git a/tests/Feature/Api/NotificationsApiTest.php b/tests/Feature/Api/NotificationsApiTest.php index 647c327dee..d6bb96d15a 100644 --- a/tests/Feature/Api/NotificationsApiTest.php +++ b/tests/Feature/Api/NotificationsApiTest.php @@ -59,6 +59,7 @@ describe('GET /api/v1/notifications/*', function () { $response->assertJsonStructure([ 'team_id', 'smtp_enabled', + 'smtp_ehlo_domain', 'deployment_failure_email_notifications', 'use_instance_email_settings', ]); @@ -171,6 +172,7 @@ describe('PATCH /api/v1/notifications/*', function () { 'smtp_enabled' => true, 'smtp_from_address' => 'alerts@example.com', 'smtp_host' => 'smtp.example.com', + 'smtp_ehlo_domain' => 'coolify.example.com', 'smtp_port' => 587, 'smtp_encryption' => 'starttls', 'deployment_failure_email_notifications' => false, @@ -178,16 +180,42 @@ describe('PATCH /api/v1/notifications/*', function () { $response->assertSuccessful(); $response->assertJsonPath('smtp_enabled', true); + $response->assertJsonPath('smtp_ehlo_domain', 'coolify.example.com'); $response->assertJsonPath('deployment_failure_email_notifications', false); $settings = EmailNotificationSettings::query()->where('team_id', $this->team->id)->first(); expect($settings->smtp_enabled)->toBeTrue() ->and($settings->smtp_from_address)->toBe('alerts@example.com') ->and($settings->smtp_host)->toBe('smtp.example.com') + ->and($settings->smtp_ehlo_domain)->toBe('coolify.example.com') ->and($settings->smtp_port)->toBe(587) ->and($settings->deployment_failure_email_notifications)->toBeFalse(); }); + test('validates the smtp ehlo domain', function () { + $this->withHeaders(authHeaders($this->bearerToken)) + ->patchJson('/api/v1/notifications/email', [ + 'smtp_ehlo_domain' => 'not a hostname', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('smtp_ehlo_domain'); + }); + + test('clears the smtp ehlo domain', function () { + $this->team->emailNotificationSettings->update([ + 'smtp_ehlo_domain' => 'coolify.example.com', + ]); + + $this->withHeaders(authHeaders($this->bearerToken)) + ->patchJson('/api/v1/notifications/email', [ + 'smtp_ehlo_domain' => null, + ]) + ->assertSuccessful() + ->assertJsonPath('smtp_ehlo_domain', null); + + expect($this->team->emailNotificationSettings->fresh()->smtp_ehlo_domain)->toBeNull(); + }); + test('updates discord notification settings', function () { $response = $this->withHeaders(authHeaders($this->bearerToken)) ->patchJson('/api/v1/notifications/discord', [ diff --git a/tests/Feature/Api/TeamTokenTeamApiTest.php b/tests/Feature/Api/TeamTokenTeamApiTest.php index 4f45562ea5..447579e294 100644 --- a/tests/Feature/Api/TeamTokenTeamApiTest.php +++ b/tests/Feature/Api/TeamTokenTeamApiTest.php @@ -8,6 +8,9 @@ use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); beforeEach(function () { + config()->set('app.maintenance.driver', 'file'); + config()->set('cache.default', 'array'); + InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true])); $this->team = Team::factory()->create(['name' => 'Token Team']); @@ -27,6 +30,27 @@ function teamTokenApiHeaders(string $bearerToken): array } describe('token team endpoints', function () { + test('legacy team endpoints are restricted to the token team', function () { + $otherTeam = Team::factory()->create(['name' => 'Other Team']); + $otherMember = User::factory()->create(); + $otherTeam->members()->attach($this->user->id, ['role' => 'owner']); + $otherTeam->members()->attach($otherMember->id, ['role' => 'member']); + + $this->withHeaders(teamTokenApiHeaders($this->bearerToken)) + ->getJson('/api/v1/teams') + ->assertOk() + ->assertJsonCount(1) + ->assertJsonPath('0.id', $this->team->id); + + $this->withHeaders(teamTokenApiHeaders($this->bearerToken)) + ->getJson("/api/v1/teams/{$otherTeam->id}") + ->assertNotFound(); + + $this->withHeaders(teamTokenApiHeaders($this->bearerToken)) + ->getJson("/api/v1/teams/{$otherTeam->id}/members") + ->assertNotFound(); + }); + test('GET /team returns the token team', function () { $this->withHeaders(teamTokenApiHeaders($this->bearerToken)) ->getJson('/api/v1/team') diff --git a/tests/Feature/Api/VolumeBackupScheduleApiTest.php b/tests/Feature/Api/VolumeBackupScheduleApiTest.php index 4f4bc395bf..e60f2919da 100644 --- a/tests/Feature/Api/VolumeBackupScheduleApiTest.php +++ b/tests/Feature/Api/VolumeBackupScheduleApiTest.php @@ -130,6 +130,7 @@ it('updates the existing backup schedule instead of creating another one', funct 'save_s3' => true, 'disable_local_backup' => true, 's3_storage_id' => $this->s3Storage->id, + 'timeout' => 7200, ]); $response = $this->withHeaders($this->headers) @@ -146,7 +147,8 @@ it('updates the existing backup schedule instead of creating another one', funct ->and($backup->enabled)->toBeFalse() ->and($backup->save_s3)->toBeFalse() ->and($backup->disable_local_backup)->toBeFalse() - ->and($backup->s3_storage_id)->toBeNull(); + ->and($backup->s3_storage_id)->toBeNull() + ->and($backup->timeout)->toBe(7200); }); it('sets a directory backup schedule through the API', function () { diff --git a/tests/Feature/ApiTokenExpirationWarningTest.php b/tests/Feature/ApiTokenExpirationWarningTest.php index beea1f126a..92c2076077 100644 --- a/tests/Feature/ApiTokenExpirationWarningTest.php +++ b/tests/Feature/ApiTokenExpirationWarningTest.php @@ -43,7 +43,7 @@ function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt, ?Carbon describe('ApiTokenExpirationWarningJob', function () { test('notifies team when token expires within 24h', function () { - $token = createTokenExpiring($this->user, $this->team, now()->addHours(23)); + $token = createTokenExpiring($this->user, $this->team, Carbon::now()->addHours(23)); (new ApiTokenExpirationWarningJob)->handle(); @@ -52,7 +52,7 @@ describe('ApiTokenExpirationWarningJob', function () { }); test('does not mark token as warned when notification fails', function () { - $token = createTokenExpiring($this->user, $this->team, now()->addHours(23)); + $token = createTokenExpiring($this->user, $this->team, Carbon::now()->addHours(23)); $dispatcher = Mockery::mock(Dispatcher::class); $dispatcher->shouldReceive('send') ->once() @@ -67,7 +67,7 @@ describe('ApiTokenExpirationWarningJob', function () { }); test('database marker prevents duplicate warnings on repeat runs', function () { - createTokenExpiring($this->user, $this->team, now()->addHours(12)); + createTokenExpiring($this->user, $this->team, Carbon::now()->addHours(12)); (new ApiTokenExpirationWarningJob)->handle(); (new ApiTokenExpirationWarningJob)->handle(); @@ -76,7 +76,7 @@ describe('ApiTokenExpirationWarningJob', function () { }); test('database marker prevents duplicate warnings after cache is flushed', function () { - createTokenExpiring($this->user, $this->team, now()->addHours(12)); + createTokenExpiring($this->user, $this->team, Carbon::now()->addHours(12)); (new ApiTokenExpirationWarningJob)->handle(); @@ -88,7 +88,7 @@ describe('ApiTokenExpirationWarningJob', function () { }); test('skips tokens that already have an expiration warning marker', function () { - createTokenExpiring($this->user, $this->team, now()->addHours(12), now()->subHour()); + createTokenExpiring($this->user, $this->team, Carbon::now()->addHours(12), Carbon::now()->subHour()); (new ApiTokenExpirationWarningJob)->handle(); @@ -96,8 +96,8 @@ describe('ApiTokenExpirationWarningJob', function () { }); test('notifies once for each unmarked expiring token', function () { - createTokenExpiring($this->user, $this->team, now()->addHours(12)); - createTokenExpiring($this->user, $this->team, now()->addHours(23)); + createTokenExpiring($this->user, $this->team, Carbon::now()->addHours(12)); + createTokenExpiring($this->user, $this->team, Carbon::now()->addHours(23)); (new ApiTokenExpirationWarningJob)->handle(); @@ -105,7 +105,7 @@ describe('ApiTokenExpirationWarningJob', function () { }); test('skips tokens expiring more than 24h out', function () { - createTokenExpiring($this->user, $this->team, now()->addDays(3)); + createTokenExpiring($this->user, $this->team, Carbon::now()->addDays(3)); (new ApiTokenExpirationWarningJob)->handle(); @@ -113,7 +113,7 @@ describe('ApiTokenExpirationWarningJob', function () { }); test('skips already-expired tokens', function () { - createTokenExpiring($this->user, $this->team, now()->subHour()); + createTokenExpiring($this->user, $this->team, Carbon::now()->subHour()); (new ApiTokenExpirationWarningJob)->handle(); @@ -127,4 +127,14 @@ describe('ApiTokenExpirationWarningJob', function () { Notification::assertNothingSent(); }); + + test('skips tokens whose owner is no longer a team member', function () { + $token = createTokenExpiring($this->user, $this->team, Carbon::now()->addHours(12)); + $this->team->members()->detach($this->user); + + (new ApiTokenExpirationWarningJob)->handle(); + + Notification::assertNothingSent(); + expect($token->fresh()->api_token_expiration_warning_sent_at)->toBeNull(); + }); }); diff --git a/tests/Feature/ApiTokenLivewireAuthorizationTest.php b/tests/Feature/ApiTokenLivewireAuthorizationTest.php index 2a875ca26e..4497fe2b4a 100644 --- a/tests/Feature/ApiTokenLivewireAuthorizationTest.php +++ b/tests/Feature/ApiTokenLivewireAuthorizationTest.php @@ -76,6 +76,42 @@ test('member can still create read token', function () { ->and($token->abilities)->toBe(['read']); }); +test('api token list only contains tokens for the current team', function () { + $user = User::factory()->create(); + $otherTeam = Team::factory()->create(); + $this->team->members()->attach($user->id, ['role' => 'admin']); + $otherTeam->members()->attach($user->id, ['role' => 'admin']); + + session(['currentTeam' => $this->team]); + $currentTeamToken = $user->createToken('current-team-token', ['read'])->accessToken; + + session(['currentTeam' => $otherTeam]); + $user->createToken('other-team-token', ['read']); + + $this->actingAs($user); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->assertSet('tokens', fn ($tokens) => $tokens->pluck('id')->all() === [$currentTeamToken->id]); +}); + +test('user cannot revoke a token from another team through the current team', function () { + $user = User::factory()->create(); + $otherTeam = Team::factory()->create(); + $this->team->members()->attach($user->id, ['role' => 'admin']); + $otherTeam->members()->attach($user->id, ['role' => 'admin']); + + session(['currentTeam' => $otherTeam]); + $otherTeamToken = $user->createToken('other-team-token', ['read'])->accessToken; + + $this->actingAs($user); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class)->call('revoke', $otherTeamToken->id); + + expect($user->tokens()->whereKey($otherTeamToken->id)->exists())->toBeTrue(); +}); + test('owner can create root token', function () { $owner = User::factory()->create(); $this->team->members()->attach($owner->id, ['role' => 'owner']); diff --git a/tests/Feature/ApiTokenTeamLifecycleSecurityTest.php b/tests/Feature/ApiTokenTeamLifecycleSecurityTest.php index c254a26c3d..a6698833dc 100644 --- a/tests/Feature/ApiTokenTeamLifecycleSecurityTest.php +++ b/tests/Feature/ApiTokenTeamLifecycleSecurityTest.php @@ -6,9 +6,11 @@ use App\Models\InstanceSettings; use App\Models\Project; use App\Models\Team; use App\Models\User; +use Illuminate\Database\Schema\Blueprint; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Schema; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -96,6 +98,44 @@ test('role downgrade through team member component revokes team tokens', functio expect(DB::table('personal_access_tokens')->where('id', $token->id)->exists())->toBeFalse(); }); +test('member removal rolls back when token revocation fails', function () { + $owner = User::factory()->create(); + $this->team->members()->attach($owner->id, ['role' => 'owner']); + $token = $this->user->createToken('protected-token', ['read'])->accessToken; + Schema::create('protected_personal_access_tokens', function (Blueprint $table): void { + $table->foreignId('token_id')->constrained('personal_access_tokens'); + }); + DB::table('protected_personal_access_tokens')->insert(['token_id' => $token->id]); + + $this->actingAs($owner); + session(['currentTeam' => $this->team]); + + Livewire::test(Member::class, ['member' => $this->user]) + ->call('remove') + ->assertDispatched('error'); + + expect($this->team->members()->whereKey($this->user->id)->exists())->toBeTrue(); +}); + +test('role change rolls back when token revocation fails', function () { + $owner = User::factory()->create(); + $this->team->members()->attach($owner->id, ['role' => 'owner']); + $token = $this->user->createToken('protected-token', ['write'])->accessToken; + Schema::create('protected_personal_access_tokens', function (Blueprint $table): void { + $table->foreignId('token_id')->constrained('personal_access_tokens'); + }); + DB::table('protected_personal_access_tokens')->insert(['token_id' => $token->id]); + + $this->actingAs($owner); + session(['currentTeam' => $this->team]); + + Livewire::test(Member::class, ['member' => $this->user]) + ->call('makeReadonly') + ->assertDispatched('error'); + + expect($this->user->fresh()->teams()->findOrFail($this->team->id)->pivot->role)->toBe('admin'); +}); + test('member cannot create write token through livewire token form', function () { $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']); diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 50c71fe958..cac3b29a39 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -1,5 +1,6 @@ call('addDomain') ->assertHasNoErrors() ->assertSet('addDomainDnsFailed', false) - ->assertDispatched('success') + ->assertDispatched('success', 'Domain added. DNS check started.') ->assertDispatched('close-modal'); $this->application->refresh(); @@ -290,7 +292,9 @@ it('adds multiple domains without replacing existing ones', function () { ->toContain('https://api.example.com'); }); -it('blocks adding a domain with bad dns until the user continues', function () { +it('saves a domain before checking dns in a separate request', function () { + Queue::fake(); + $settings = InstanceSettings::get(); $settings->is_dns_validation_enabled = true; $settings->save(); @@ -298,15 +302,8 @@ it('blocks adding a domain with bad dns until the user continues', function () { $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->set('newDomain', 'https://this-domain-should-not-resolve-for-coolify-tests.invalid') ->call('addDomain') - ->assertSet('addDomainDnsFailed', true) - ->assertSee('DNS is not pointing to the right IP') - ->assertSee('Are you sure you want to add it anyway'); - - $this->application->refresh(); - expect($this->application->fqdn)->toBeNull(); - - $component->call('confirmAddDomainDespiteDns') ->assertSet('addDomainDnsFailed', false) + ->assertSet('domainRows.0.dns_status', 'checking') ->assertDispatched('success') ->assertDispatched('close-modal'); @@ -315,17 +312,24 @@ it('blocks adding a domain with bad dns until the user continues', function () { 'https://this-domain-should-not-resolve-for-coolify-tests.invalid', 'https://www.this-domain-should-not-resolve-for-coolify-tests.invalid', ]); + + expect($this->application->domain_dns_statuses['https://this-domain-should-not-resolve-for-coolify-tests.invalid']['status'] ?? null) + ->toBe('checking'); + + Queue::assertPushed(CheckDomainDnsJob::class, 2); + + $jobs = Queue::pushed(CheckDomainDnsJob::class); + + expect($jobs->pluck('statusKey')->all())->toEqualCanonicalizing([ + 'https://this-domain-should-not-resolve-for-coolify-tests.invalid', + 'https://www.this-domain-should-not-resolve-for-coolify-tests.invalid', + ])->and($jobs->pluck('checkId')->unique())->toHaveCount(2); }); it('resets the dns gate when the domain input changes', function () { - $settings = InstanceSettings::get(); - $settings->is_dns_validation_enabled = true; - $settings->save(); - Livewire::test(Domains::class, ['application' => $this->application->fresh()]) - ->set('newDomain', 'https://this-domain-should-not-resolve-for-coolify-tests.invalid') - ->call('addDomain') - ->assertSet('addDomainDnsFailed', true) + ->set('addDomainDnsFailed', true) + ->set('forceSaveDns', true) ->set('newDomain', 'https://another.example.com') ->assertSet('addDomainDnsFailed', false) ->assertSet('forceSaveDns', false); @@ -727,6 +731,98 @@ it('persists dns status after checking a domain', function () { ->and($entry['checked_at'] ?? null)->not->toBeNull(); }); +it('polls a queued dns check and notifies about a mismatch', function () { + $domain = 'https://app.example.com'; + $this->application->update([ + 'fqdn' => $domain, + 'domain_dns_statuses' => [ + $domain => [ + 'status' => 'checking', + 'message' => 'Checking DNS...', + 'expected_ip' => '203.0.113.10', + 'checked_at' => null, + ], + ], + ]); + + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->assertSee('Checking DNS...') + ->assertSee('wire:poll.2000ms="pollDnsChecks"', false); + + $this->application->update([ + 'domain_dns_statuses' => [ + $domain => [ + 'status' => 'failed', + 'message' => 'Required DNS record type A pointing to 203.0.113.10', + 'expected_ip' => '203.0.113.10', + 'checked_at' => now()->toIso8601String(), + ], + ], + ]); + + $component->call('pollDnsChecks') + ->assertSet('domainRows.0.dns_status', 'failed') + ->assertDispatched('error', 'DNS is not configured for app.example.com. Review the required DNS record.'); +}); + +it('does not overwrite a completed queued dns result with stale checking state', function () { + $domain = 'https://app.example.com'; + $this->application->update([ + 'fqdn' => $domain, + 'domain_dns_statuses' => [ + $domain => [ + 'status' => 'checking', + 'message' => 'Checking DNS...', + 'expected_ip' => '203.0.113.10', + 'checked_at' => null, + ], + ], + ]); + + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]); + + $this->application->update([ + 'domain_dns_statuses' => [ + $domain => [ + 'status' => 'ok', + 'message' => 'DNS looks correct.', + 'expected_ip' => '203.0.113.10', + 'checked_at' => now()->toIso8601String(), + ], + ], + ]); + + $method = new ReflectionMethod($component->instance(), 'persistDomainDnsStatuses'); + $method->invoke($component->instance()); + + expect($this->application->fresh()->domain_dns_statuses[$domain]['status'])->toBe('ok'); +}); + +it('does not overwrite a newer queued dns check with stale completed component state', function () { + $domain = 'https://app.example.com'; + $status = [ + 'status' => 'ok', + 'message' => 'DNS looks correct.', + 'expected_ip' => '203.0.113.10', + 'checked_at' => null, + 'check_id' => null, + ]; + $this->application->update([ + 'fqdn' => $domain, + 'domain_dns_statuses' => [$domain => $status], + ]); + + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]); + + $status['check_id'] = 'newer-check'; + $this->application->update(['domain_dns_statuses' => [$domain => $status]]); + + $method = new ReflectionMethod($component->instance(), 'persistDomainDnsStatuses'); + $method->invoke($component->instance()); + + expect($this->application->fresh()->domain_dns_statuses[$domain]['check_id'])->toBe('newer-check'); +}); + it('resolves hostname server addresses to a real ip for dns messages', function () { $this->server->update(['ip' => 'localhost']); $this->application->update([ diff --git a/tests/Feature/ApplicationPreviewVolumeCleanupTest.php b/tests/Feature/ApplicationPreviewVolumeCleanupTest.php new file mode 100644 index 0000000000..7f59f689fc --- /dev/null +++ b/tests/Feature/ApplicationPreviewVolumeCleanupTest.php @@ -0,0 +1,120 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + + $team = Team::factory()->create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + 'user' => 'deploy', + ]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = $project->environments()->first() + ?? Environment::factory()->create(['project_id' => $project->id]); + + $this->application = Application::factory()->create([ + 'build_pack' => 'dockerfile', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $this->preview = ApplicationPreview::create([ + 'uuid' => 'preview-volume-cleanup-test', + 'application_id' => $this->application->id, + 'pull_request_id' => 42, + 'pull_request_html_url' => 'https://github.com/example/repository/pull/42', + ]); +}); + +it('deletes only named volumes that have the preview suffix enabled', function () { + $this->application->persistentStorages()->create([ + 'name' => 'app-data', + 'mount_path' => '/data', + 'host_path' => null, + 'is_preview_suffix_enabled' => true, + ]); + $this->application->persistentStorages()->create([ + 'name' => 'shared-cache', + 'mount_path' => '/cache', + 'host_path' => null, + 'is_preview_suffix_enabled' => false, + ]); + $this->application->persistentStorages()->create([ + 'name' => 'seed-data', + 'mount_path' => '/seed', + 'host_path' => '/srv/seed', + 'is_preview_suffix_enabled' => true, + ]); + Process::fake(['*' => Process::result(output: '')]); + + $this->preview->forceDelete(); + + Process::assertRanTimes(fn ($process) => str_contains($process->command, 'docker volume rm'), 1); + Process::assertRan(fn ($process) => str_contains($process->command, "docker volume rm -f 'app-data-pr-42'")); + Process::assertRan(fn ($process) => str_contains($process->command, "sudo docker volume rm -f 'app-data-pr-42'")); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'shared-cache')); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'seed-data')); +}); + +it('reports Docker volume removal failures and keeps the preview record', function () { + $this->application->persistentStorages()->create([ + 'name' => 'app-data', + 'mount_path' => '/data', + 'host_path' => null, + 'is_preview_suffix_enabled' => true, + ]); + Process::fake(['*' => Process::result(errorOutput: 'volume is in use', exitCode: 1)]); + + expect(fn () => $this->preview->forceDelete()) + ->toThrow(RuntimeException::class, 'volume is in use'); + + expect(ApplicationPreview::find($this->preview->id))->not->toBeNull(); +}); + +it('continues removing preview volumes when an earlier volume is already absent', function () { + $this->application->persistentStorages()->create([ + 'name' => 'already-removed', + 'mount_path' => '/removed', + 'host_path' => null, + 'is_preview_suffix_enabled' => true, + ]); + $this->application->persistentStorages()->create([ + 'name' => 'app-data', + 'mount_path' => '/data', + 'host_path' => null, + 'is_preview_suffix_enabled' => true, + ]); + Process::fake(function ($process) { + if (str_contains($process->command, "docker volume rm -f 'already-removed-pr-42'")) { + return Process::result( + errorOutput: 'Error response from daemon: volume already-removed-pr-42 not found', + exitCode: 1, + ); + } + + return Process::result(output: 'app-data-pr-42'); + }); + + $this->preview->forceDelete(); + + Process::assertRan(fn ($process) => str_contains($process->command, "docker volume rm -f 'already-removed-pr-42'")); + Process::assertRan(fn ($process) => str_contains($process->command, "docker volume rm -f 'app-data-pr-42'")); + expect(ApplicationPreview::find($this->preview->id))->toBeNull(); +}); diff --git a/tests/Feature/Authorization/TeamAuthorizationTest.php b/tests/Feature/Authorization/TeamAuthorizationTest.php index 63d5d3d264..eaae106ef3 100644 --- a/tests/Feature/Authorization/TeamAuthorizationTest.php +++ b/tests/Feature/Authorization/TeamAuthorizationTest.php @@ -1,5 +1,6 @@ user()->can('delete', $this->team))->toBeTrue(); }); -test('admin can delete team', function () { +test('admin cannot delete team', function () { $this->actingAs($this->admin); session(['currentTeam' => $this->team]); - expect(auth()->user()->can('delete', $this->team))->toBeTrue(); + expect(auth()->user()->can('delete', $this->team))->toBeFalse(); }); test('member cannot delete team', function () { @@ -156,24 +157,24 @@ test('team index mounts when is_mcp_server_enabled is null on the session team', ->assertSet('is_mcp_server_enabled', true); }); -// --- Team Index Livewire: delete --- +// --- Team Danger Zone Livewire: delete --- -test('member cannot delete team via index', function () { +test('member cannot delete team via danger zone', function () { $this->actingAs($this->member); session(['currentTeam' => $this->team]); - Livewire::test(TeamIndex::class) - ->call('delete', 'password') + Livewire::test(DangerZone::class) + ->call('delete') ->assertDispatched('error'); expect(Team::find($this->team->id))->not->toBeNull(); }); -test('admin can delete team via policy', function () { +test('admin cannot delete team via policy', function () { $this->actingAs($this->admin); session(['currentTeam' => $this->team]); - expect(auth()->user()->can('delete', $this->team))->toBeTrue(); + expect(auth()->user()->can('delete', $this->team))->toBeFalse(); }); // --- Team Member Livewire: role changes --- diff --git a/tests/Feature/ButtonHeightConsistencyTest.php b/tests/Feature/ButtonHeightConsistencyTest.php index 06b45d7825..ac72cb2ba4 100644 --- a/tests/Feature/ButtonHeightConsistencyTest.php +++ b/tests/Feature/ButtonHeightConsistencyTest.php @@ -1,10 +1,10 @@ toContain('px-2.5 h-9 min-h-9') + expect($utilities)->toContain('px-2.5 h-8 min-h-8') ->and($applicationStyles) ->not->toContain('.application-settings-workspace .button') ->not->toContain('.application-settings-form .button'); diff --git a/tests/Feature/CaddyApplicationLabelGenerationTest.php b/tests/Feature/CaddyApplicationLabelGenerationTest.php new file mode 100644 index 0000000000..b31fa29a46 --- /dev/null +++ b/tests/Feature/CaddyApplicationLabelGenerationTest.php @@ -0,0 +1,38 @@ +create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'proxy' => ['type' => ProxyTypes::CADDY->value], + ]); + $server->settings->update(['generate_exact_labels' => true]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $application = Application::factory()->createOne([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'fqdn' => 'https://example.com', + 'redirect' => 'both', + 'is_http_basic_auth_enabled' => false, + ]); + + $labels = generateLabelsApplication($application); + + expect($labels) + ->toContain('caddy_ingress_network=coolify') + ->not->toContain('traefik.enable=true'); +}); diff --git a/tests/Feature/CheckDomainDnsJobTest.php b/tests/Feature/CheckDomainDnsJobTest.php new file mode 100644 index 0000000000..74502268a0 --- /dev/null +++ b/tests/Feature/CheckDomainDnsJobTest.php @@ -0,0 +1,119 @@ + CheckDomainDns::clearFake()); + +beforeEach(function () { + InstanceSettings::unguarded(fn () => InstanceSettings::create([ + 'id' => 0, + 'is_dns_validation_enabled' => false, + ])); + + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => 1, + 'destination_type' => 'App\\Models\\StandaloneDocker', + 'fqdn' => 'https://app.example.com', + 'domain_dns_statuses' => [ + 'https://app.example.com' => [ + 'status' => 'checking', + 'message' => 'Checking DNS...', + 'expected_ip' => null, + 'checked_at' => null, + 'check_id' => 'test-check', + ], + ], + ]); +}); + +it('persists a skipped result when dns validation is disabled', function () { + (new CheckDomainDnsJob( + $this->application, + 'https://app.example.com', + 'https://app.example.com', + null, + null, + 'test-check', + ))->handle(); + + $status = $this->application->fresh()->domain_dns_statuses['https://app.example.com']; + + expect($status['status'])->toBe('skipped') + ->and($status['message'])->toBe('DNS validation is disabled in instance settings.') + ->and($status['checked_at'])->not->toBeNull(); +}); + +it('does not restore a dns status removed before the job finishes', function () { + $this->application->update(['domain_dns_statuses' => null]); + + (new CheckDomainDnsJob( + $this->application, + 'https://app.example.com', + 'https://app.example.com', + null, + null, + 'test-check', + ))->handle(); + + expect($this->application->fresh()->domain_dns_statuses)->toBeNull(); +}); + +it('uses the shared dns action', function () { + CheckDomainDns::shouldRun() + ->once() + ->andReturn([ + 'https://app.example.com' => [ + 'status' => 'ok', + 'message' => 'DNS looks correct.', + 'expected_ip' => null, + 'checked_at' => now()->toIso8601String(), + ], + ]); + + (new CheckDomainDnsJob( + $this->application, + 'https://app.example.com', + 'https://app.example.com', + null, + null, + 'test-check', + ))->handle(); + + expect($this->application->fresh()->domain_dns_statuses['https://app.example.com']['status'])->toBe('ok'); +}); + +it('does not let an older job overwrite a newer check for the same domain', function () { + $oldJob = new CheckDomainDnsJob( + $this->application, + 'https://app.example.com', + 'https://app.example.com', + null, + null, + 'test-check', + ); + + $statuses = $this->application->domain_dns_statuses; + $statuses['https://app.example.com']['check_id'] = 'newer-check'; + $this->application->update(['domain_dns_statuses' => $statuses]); + + $oldJob->handle(); + + $status = $this->application->fresh()->domain_dns_statuses['https://app.example.com']; + + expect($status['status'])->toBe('checking') + ->and($status['check_id'])->toBe('newer-check'); +}); diff --git a/tests/Feature/DashboardHttpsRedirectTest.php b/tests/Feature/DashboardHttpsRedirectTest.php new file mode 100644 index 0000000000..d709b84289 --- /dev/null +++ b/tests/Feature/DashboardHttpsRedirectTest.php @@ -0,0 +1,94 @@ +is_dashboard_force_https_enabled)->toBeTrue() + ->and($server->dashboardHttpMiddlewares($settings))->toBe(['redirect-to-https']) + ->and($server->dashboardCaddySiteAddress($settings, 'https', 'dashboard.example.com')) + ->toBe('https://dashboard.example.com'); +}); + +test('dashboard proxy accepts HTTP and HTTPS when its redirect is disabled', function () { + $settings = new InstanceSettings(['is_dashboard_force_https_enabled' => false]); + $server = new Server; + + expect($server->dashboardHttpMiddlewares($settings))->toBe(['gzip']) + ->and($server->dashboardCaddySiteAddress($settings, 'https', 'dashboard.example.com')) + ->toBe('http://dashboard.example.com, https://dashboard.example.com') + ->and($server->dashboardCaddySiteAddress($settings, 'http', 'dashboard.example.com')) + ->toBe('http://dashboard.example.com'); +}); + +test('instance administrators can configure the dashboard HTTPS redirect', function () { + $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); + Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]); + $settings = InstanceSettings::forceCreate([ + 'id' => 0, + 'fqdn' => 'https://dashboard.example.com', + ]); + Once::flush(); + + $user = User::factory()->create(); + $rootTeam->members()->attach($user->id, ['role' => 'admin']); + + $this->actingAs($user); + session(['currentTeam' => ['id' => $rootTeam->id]]); + + Livewire::test(Index::class) + ->assertSet('is_dashboard_force_https_enabled', true) + ->assertSee('Redirect HTTP to HTTPS') + ->assertSee('Keep enabled when Cloudflare uses Full or Full (Strict) SSL.') + ->set('is_dashboard_force_https_enabled', false) + ->call('instantSave') + ->assertHasNoErrors(); + + expect($settings->fresh()->is_dashboard_force_https_enabled)->toBeFalse(); +}); + +test('dashboard HTTPS redirect saves immediately when changed', function () { + $contents = file_get_contents(resource_path('views/livewire/settings/index.blade.php')); + + expect($contents) + ->toMatch('/id="is_dashboard_force_https_enabled"[\s\S]*?onChange="submit"/') + ->and($contents)->not->toContain('targets="fqdn,is_dashboard_force_https_enabled'); +}); + +test('dashboard HTTPS redirect is next to the URL on desktop', function () { + $contents = file_get_contents(resource_path('views/livewire/settings/index.blade.php')); + + expect($contents) + ->toContain("'lg:col-span-2' => !str_starts_with") + ->not->toContain('
'); +}); + +test('dashboard HTTPS redirect control is hidden for an HTTP URL', function () { + $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); + Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]); + InstanceSettings::forceCreate([ + 'id' => 0, + 'fqdn' => 'http://dashboard.example.com', + ]); + Once::flush(); + + $user = User::factory()->create(); + $rootTeam->members()->attach($user->id, ['role' => 'admin']); + + $this->actingAs($user); + session(['currentTeam' => ['id' => $rootTeam->id]]); + + Livewire::test(Index::class) + ->assertDontSee('Redirect HTTP to HTTPS'); +}); diff --git a/tests/Feature/DashboardServerMetricsChartTest.php b/tests/Feature/DashboardServerMetricsChartTest.php index 196526fcf7..4d7c64d0fd 100644 --- a/tests/Feature/DashboardServerMetricsChartTest.php +++ b/tests/Feature/DashboardServerMetricsChartTest.php @@ -1,6 +1,7 @@ assertDontSeeHtml("dashboard-server-metrics-{$disabledServer->uuid}"); }); +it('renders dashboard metrics charts on the server index grid', function () { + $enabledServer = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + $enabledServer->settings->update(['is_metrics_enabled' => true]); + + $disabledServer = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + $disabledServer->settings->update(['is_metrics_enabled' => false]); + + Livewire::test(ServerIndex::class) + ->assertSeeHtml("dashboard-server-metrics-{$enabledServer->uuid}") + ->assertDontSeeHtml("dashboard-server-metrics-{$disabledServer->uuid}"); +}); + it('configures the dashboard chart as a ten minute cpu and memory sparkline with hover details', function () { $chart = file_get_contents(resource_path('views/livewire/dashboard/server-metrics-chart.blade.php')); $component = file_get_contents(app_path('Livewire/Dashboard/ServerMetricsChart.php')); diff --git a/tests/Feature/DatabaseBackupUploadValidationTest.php b/tests/Feature/DatabaseBackupUploadValidationTest.php index 74835feefd..88978eeaba 100644 --- a/tests/Feature/DatabaseBackupUploadValidationTest.php +++ b/tests/Feature/DatabaseBackupUploadValidationTest.php @@ -18,10 +18,45 @@ function writeScanPayload(string $content, bool $gzip = false): string /** * Execute the real shell scanner snippet (as it runs inside the container) * against a local payload file and return true when the restore is blocked. + * + * @param array $env */ -function scannerBlocks(string $script): bool +function scannerBlocks(string $script, array $env = []): bool { - return Process::run(['sh', '-c', $script])->exitCode() === 1; + $command = $script; + + if ($env !== []) { + $exports = []; + foreach ($env as $name => $value) { + $exports[] = $name.'='.escapeshellarg($value); + } + $command = implode(' ', $exports).'; '.$script; + } + + return Process::run(['sh', '-c', $command])->exitCode() === 1; +} + +function fakePgRestorePath(string $sql, int $listExitCode = 0): string +{ + $dir = sys_get_temp_dir().'/coolify-fake-pg-'.uniqid(); + mkdir($dir); + + $sqlFile = $dir.'/archive.sql'; + file_put_contents($sqlFile, $sql); + + $escapedSqlFile = escapeshellarg($sqlFile); + file_put_contents($dir.'/pg_restore', <<toBeFalse(); }); -test('file scanner allows PostgreSQL custom format archives', function () { +test('file scanner detects program execution payloads inside custom format archives', function () { $archive = writeScanPayload("PGDMP\0binary COPY records FROM PROGRAM payload"); - expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($archive))->toBeFalse(); + expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($archive))->toBeTrue(); }); -test('file scanner allows gzipped PostgreSQL custom format archives', function () { +test('file scanner detects program execution payloads inside gzipped custom format archives', function () { $archive = writeScanPayload("PGDMP\0binary COPY records FROM PROGRAM payload", gzip: true); + expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($archive))->toBeTrue(); +}); + +test('file scanner allows custom format archives without program execution', function () { + $archive = writeScanPayload("PGDMP\0binary archive without restore programs"); + expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($archive))->toBeFalse(); }); @@ -226,6 +267,8 @@ test('remote postgresql scanner blocks bypass payloads', function (string $conte 'psql pipe redirect' => ["\\o | id\n", false], 'psql query pipe redirect' => ["\\g | id\n", false], 'gzipped comment bypass' => ["COPY x FROM/**/PROGRAM 'id';\n", true], + 'custom format archive' => ["PGDMP\0binary COPY records FROM PROGRAM payload", false], + 'custom format gzip archive' => ["PGDMP\0binary COPY records FROM PROGRAM payload", true], ]); test('remote postgresql scanner allows legitimate restores', function (string $content, bool $gzip) { @@ -241,11 +284,26 @@ test('remote postgresql scanner allows legitimate restores', function (string $c 'copy from stdin' => ["COPY users FROM stdin;\n1\tTaylor\n\\.\n", false], 'plain select' => ["SELECT * FROM users;\n", false], 'gzipped clean dump' => ["CREATE TABLE users (id int);\n", true], - 'custom format archive' => ["PGDMP\0binary COPY records FROM PROGRAM payload", false], - 'custom format gzip archive' => ["PGDMP\0binary COPY records FROM PROGRAM payload", true], 'copy words in table data' => ["COPY notes FROM stdin;\n1\tcopy files from program storage\n\\.\n", false], ]); +test('remote postgresql scanner inspects custom archives instead of skipping them', function () { + $component = backupValidationImportFormWithResource(StandalonePostgresql::class); + $safeArchive = writeScanPayload("PGDMP\0binary archive"); + $maliciousSql = "COPY x FROM PROGRAM 'id';\n"; + $safeSql = "CREATE TABLE users (id integer);\nCOPY users FROM stdin;\n1\tTaylor\n\\.\n"; + + $maliciousPath = fakePgRestorePath($maliciousSql); + $safePath = fakePgRestorePath($safeSql); + $unreadablePath = fakePgRestorePath($safeSql, listExitCode: 1); + $path = getenv('PATH') ?: '/usr/bin:/bin'; + + expect(scannerBlocks($component->buildPostgresRestoreScanScript($safeArchive), ['PATH' => $maliciousPath.':'.$path]))->toBeTrue() + ->and(scannerBlocks($component->buildPostgresRestoreScanScript($safeArchive), ['PATH' => $safePath.':'.$path]))->toBeFalse() + ->and(scannerBlocks($component->buildPostgresRestoreScanScript($safeArchive), ['PATH' => $unreadablePath.':'.$path]))->toBeTrue() + ->and(scannerBlocks($component->buildPostgresRestoreScanScript($safeArchive)))->toBeTrue(); +}); + test('MAX_BYTES constant is 10 GiB', function () { $constant = (new ReflectionClass(UploadController::class))->getConstant('MAX_BYTES'); expect($constant)->toBe(10 * 1024 * 1024 * 1024); diff --git a/tests/Feature/DatabaseBackupsLayoutTest.php b/tests/Feature/DatabaseBackupsLayoutTest.php index b2ab349d97..2b4941c091 100644 --- a/tests/Feature/DatabaseBackupsLayoutTest.php +++ b/tests/Feature/DatabaseBackupsLayoutTest.php @@ -50,3 +50,10 @@ it('keeps backup execution actions in the normal table flow', function () { expect($executions) ->not->toContain('sticky right-0'); }); + +it('spaces instance backup settings sections', function () { + $edit = file_get_contents(resource_path('views/livewire/project/database/backup-edit.blade.php')); + + expect($edit) + ->toStartWith('
'); +}); diff --git a/tests/Feature/DeleteResourceJobAtomicityTest.php b/tests/Feature/DeleteResourceJobAtomicityTest.php new file mode 100644 index 0000000000..11955a82cd --- /dev/null +++ b/tests/Feature/DeleteResourceJobAtomicityTest.php @@ -0,0 +1,64 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = $project->environments()->first() + ?? Environment::factory()->create(['project_id' => $project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $this->storage = $this->application->persistentStorages()->create([ + 'name' => 'delete-resource-job-test', + 'mount_path' => '/data', + 'host_path' => null, + ]); + + $this->application->delete(); + Queue::fake(); +}); + +it('deletes the Coolify resource when remote cleanup fails', function () { + Process::fake(['*' => Process::result(errorOutput: 'SSH connection timed out', exitCode: 255)]); + + (new DeleteResourceJob($this->application))->handle(); + + expect(Application::withTrashed()->find($this->application->id))->toBeNull(); +}); + +it('rolls back local metadata deletion when deleting the resource fails', function () { + Process::fake(['*' => Process::result(output: '')]); + $applicationUuid = $this->application->uuid; + Application::deleting(function (Application $application) use ($applicationUuid): void { + if ($application->uuid === $applicationUuid && $application->isForceDeleting()) { + throw new RuntimeException('Local deletion failed.'); + } + }); + + expect(fn () => (new DeleteResourceJob($this->application))->handle()) + ->toThrow(RuntimeException::class, 'Local deletion failed.'); + + expect($this->storage->fresh())->not->toBeNull() + ->and(Application::withTrashed()->find($this->application->id))->not->toBeNull(); +}); diff --git a/tests/Feature/DeploymentLogToolbarStyleTest.php b/tests/Feature/DeploymentLogToolbarStyleTest.php index a33fb5315f..24cf01376a 100644 --- a/tests/Feature/DeploymentLogToolbarStyleTest.php +++ b/tests/Feature/DeploymentLogToolbarStyleTest.php @@ -45,3 +45,14 @@ test('turning off follow logs stays off at the bottom of the log viewer', functi ->toContain('this.followManuallyDisabled = false'); } }); + +test('runtime log follow loop is cleaned up when navigating away', function () { + $view = file_get_contents(resource_path('views/livewire/project/shared/get-logs.blade.php')); + + expect($view) + ->toContain('scrollTimeout: null') + ->toContain("this.\$root.querySelector('#logsContainer')") + ->toContain('destroy() {') + ->toContain('this.destroyed = true') + ->toContain('this.cancelScrollLoop()'); +}); diff --git a/tests/Feature/DevHelperVersionValidationTest.php b/tests/Feature/DevHelperVersionValidationTest.php index 03316598c3..0a91141850 100644 --- a/tests/Feature/DevHelperVersionValidationTest.php +++ b/tests/Feature/DevHelperVersionValidationTest.php @@ -88,3 +88,22 @@ test('buildHelperImage refuses previously stored invalid version', function () { ->call('buildHelperImage') ->assertDispatched('error'); }); + +test('development helper version is read fresh for queue workers', function () { + config(['app.env' => 'local']); + + InstanceSettings::findOrFail(0)->update(['dev_helper_version' => 'first']); + expect(getHelperVersion())->toBe('first'); + + InstanceSettings::query()->whereKey(0)->update(['dev_helper_version' => 'second']); + + expect(getHelperVersion())->toBe('second'); +}); + +test('development helper build uses the configured helper repository', function () { + $component = file_get_contents(app_path('Livewire/Settings/Index.php')); + + expect($component) + ->toContain('$imageRef = escapeshellarg(coolifyHelperImage().":{$version}");') + ->not->toContain('"ghcr.io/coollabsio/coolify-helper:{$version}"'); +}); diff --git a/tests/Feature/DevelopmentQemuVmTest.php b/tests/Feature/DevelopmentQemuVmTest.php new file mode 100644 index 0000000000..18e5ff9952 --- /dev/null +++ b/tests/Feature/DevelopmentQemuVmTest.php @@ -0,0 +1,242 @@ + 'local']); + $this->seed([UserSeeder::class, TeamSeeder::class, PrivateKeySeeder::class]); +}); + +it('registers the interactive qemu command', function () { + expect(Artisan::all()) + ->toHaveKey('dev:qemu') + ->toHaveKey('dev:qemu:seed') + ->and(Artisan::all()['dev:qemu'])->toBeInstanceOf(ManageDevelopmentQemuVmCommand::class) + ->and(Artisan::all()['dev:qemu']->getDefinition()->getArgument('profiles')->isArray())->toBeTrue() + ->and(Artisan::all()['dev:qemu:seed'])->toBeInstanceOf(SeedDevelopmentQemuServerCommand::class); +}); + +it('prevents qemu commands from running outside development', function () { + config(['app.env' => 'production']); + Process::fake(); + + expect(Artisan::call('dev:qemu', ['profiles' => ['ubuntu-root']]))->toBe(Command::FAILURE) + ->and(Artisan::call('dev:qemu:seed', ['profile' => 'ubuntu-root']))->toBe(Command::FAILURE); + + Process::assertNothingRan(); +}); + +it('provides root and non-root profiles for every supported distribution', function () { + $profiles = collect(config('development-qemu.profiles')); + + expect($profiles->keys()->all())->toBe([ + 'ubuntu-root', + 'ubuntu-non-root', + 'debian-root', + 'debian-non-root', + 'centos-root', + 'centos-non-root', + 'alpine-root', + 'alpine-non-root', + ])->and($profiles->pluck('ip')->unique()->count())->toBe(8) + ->and($profiles->pluck('mac')->unique()->count())->toBe(8) + ->and($profiles->filter(fn (array $profile) => $profile['user'] === 'root')->count())->toBe(4) + ->and($profiles->filter(fn (array $profile) => $profile['user'] !== 'root')->count())->toBe(4); +}); + +it('stores vm disks in a libvirt-accessible directory', function () { + expect(config('development-qemu.storage_path'))->toStartWith('/var/lib/libvirt/images/'); +}); + +it('automatically configures the qemu host', function () { + Process::fake(function ($process) { + if (str_contains($process->command, 'command -v')) { + return Process::result(); + } + + if (str_contains($process->command, 'net-info')) { + return Process::result(exitCode: 1); + } + + if (str_contains($process->command, 'network inspect')) { + return Process::result(output: "172.18.0.0/16\n"); + } + + if (str_contains($process->command, 'iptables -C')) { + return Process::result(exitCode: 1); + } + + return Process::result(); + }); + + ConfigureDevelopmentQemuHost::run(); + + Process::assertRan(fn ($process) => str_contains($process->command, 'systemctl enable --now libvirtd')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virsh net-define')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virsh net-start')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virsh net-autostart')); + Process::assertRan(fn ($process) => str_contains($process->command, 'sysctl -w net.ipv4.ip_forward=1')); + Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -I LIBVIRT_FWI')); +}); + +it('does not restart an active libvirt network', function () { + Process::fake(function ($process) { + if (str_contains($process->command, 'net-info')) { + return Process::result(output: "Name: default\nActive: yes\n"); + } + + if (str_contains($process->command, 'network inspect')) { + return Process::result(output: "172.18.0.0/16\n"); + } + + return Process::result(); + }); + + ConfigureDevelopmentQemuHost::run(); + + Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh net-start')); + Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -D LIBVIRT_FWI')); + Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -I LIBVIRT_FWI 1')); +}); + +it('seeds one predefined root qemu server', function () { + $server = SeedDevelopmentQemuServer::run('ubuntu-root'); + + expect($server->uuid)->toBe('development-qemu-ubuntu-root') + ->and($server->ip)->toBe('192.168.122.10') + ->and($server->user)->toBe('root') + ->and($server->team_id)->toBe(0) + ->and(Server::query()->where('uuid', 'like', 'development-qemu-%')->count())->toBe(1); +}); + +it('replaces the seeded qemu server with the selected non-root equivalent', function () { + SeedDevelopmentQemuServer::run('ubuntu-root'); + $server = SeedDevelopmentQemuServer::run('ubuntu-non-root'); + + expect($server->ip)->toBe('192.168.122.11') + ->and($server->user)->toBe('coolify') + ->and(Server::query()->where('uuid', 'like', 'development-qemu-%')->count())->toBe(1); +}); + +it('deletes managed vm data and freshly creates only the selected vm', function () { + $storagePath = sys_get_temp_dir().'/coolify-qemu-reset-test-'.uniqid(); + config(['development-qemu.storage_path' => $storagePath]); + File::ensureDirectoryExists($storagePath); + File::put("{$storagePath}/coolify-dev-ubuntu-root.qcow2", 'old data'); + File::put("{$storagePath}/coolify-dev-ubuntu-non-root.qcow2", 'old data'); + + Process::fake([ + '* net-dumpxml *' => Process::result(output: ''), + '* network inspect *' => Process::result(output: "172.18.0.0/16\n"), + '* iptables -C *' => Process::result(exitCode: 1), + '* dominfo *' => Process::result(output: 'exists'), + '*' => Process::result(), + ]); + + StartDevelopmentQemuVm::run('ubuntu-non-root'); + + Process::assertRan(fn ($process) => str_contains($process->command, 'virsh destroy') && str_contains($process->command, 'coolify-dev-ubuntu-root')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virsh destroy') && str_contains($process->command, 'coolify-dev-ubuntu-non-root')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virsh undefine') && str_contains($process->command, 'coolify-dev-ubuntu-root')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virsh undefine') && str_contains($process->command, 'coolify-dev-ubuntu-non-root')); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh start')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virt-install') && str_contains($process->command, 'coolify-dev-ubuntu-non-root')); + Process::assertRan(fn ($process) => str_contains($process->command, 'net-update') && str_contains($process->command, 'ip-dhcp-host') && str_contains($process->command, '192.168.122.11')); + Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -D LIBVIRT_FWI')); + Process::assertRan(fn ($process) => str_contains($process->command, 'docker exec') && str_contains($process->command, 'coolify')); + expect(File::exists("{$storagePath}/coolify-dev-ubuntu-root.qcow2"))->toBeFalse() + ->and(File::exists("{$storagePath}/coolify-dev-ubuntu-non-root.qcow2"))->toBeFalse(); +}); + +it('rejects qemu vm management outside development', function () { + config(['app.env' => 'production']); + + expect(fn () => StartDevelopmentQemuVm::run('ubuntu-root')) + ->toThrow(RuntimeException::class, 'development environments'); +}); + +it('can create a vm without a host database connection', function () { + config([ + 'development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-test-'.uniqid(), + ]); + DB::enableQueryLog(); + Process::fake(function ($process) { + if (str_contains($process->command, 'net-dumpxml')) { + return Process::result(output: ''); + } + + if (str_contains($process->command, 'network inspect')) { + return Process::result(output: "172.18.0.0/16\n"); + } + + if (str_contains($process->command, 'virsh dominfo')) { + return Process::result(exitCode: 1); + } + + if (str_contains($process->command, 'iptables -C')) { + return Process::result(exitCode: 1); + } + + return Process::result(); + }); + + StartDevelopmentQemuVm::run('ubuntu-root'); + + expect(DB::getQueryLog())->toBeEmpty(); + Process::assertRan(fn ($process) => str_contains($process->command, 'virt-install')); + Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -I LIBVIRT_FWI')); +}); + +it('seeds through the coolify container when the host database is unavailable', function () { + config(['development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-fallback-test-'.uniqid()]); + SeedDevelopmentQemuServer::mock() + ->shouldReceive('handle') + ->once() + ->andThrow(new QueryException('pgsql', 'select 1', [], new Exception('unavailable'))); + Process::fake([ + '* net-dumpxml *' => Process::result(output: ''), + '* network inspect *' => Process::result(output: "172.18.0.0/16\n"), + '* dominfo *' => Process::result(output: 'exists'), + '*' => Process::result(), + ]); + + ManageDevelopmentQemuVm::run('ubuntu-root'); + + Process::assertRan(fn ($process) => str_contains($process->command, 'docker exec coolify php artisan dev:qemu:seed') && str_contains($process->command, 'ubuntu-root')); +}); + +it('starts and seeds root and non-root profiles together', function () { + config(['development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-multi-test-'.uniqid()]); + Process::fake([ + '* net-dumpxml *' => Process::result(output: ''), + '* network inspect *' => Process::result(output: "172.18.0.0/16\n"), + '*' => Process::result(), + ]); + + ManageDevelopmentQemuVm::run(['ubuntu-root', 'ubuntu-non-root']); + + expect(Server::query()->whereIn('uuid', [ + 'development-qemu-ubuntu-root', + 'development-qemu-ubuntu-non-root', + ])->count())->toBe(2); + Process::assertRan(fn ($process) => str_contains($process->command, 'virt-install') && str_contains($process->command, 'coolify-dev-ubuntu-root')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virt-install') && str_contains($process->command, 'coolify-dev-ubuntu-non-root')); +}); diff --git a/tests/Feature/DnsValidationTest.php b/tests/Feature/DnsValidationTest.php new file mode 100644 index 0000000000..a9ac104c71 --- /dev/null +++ b/tests/Feature/DnsValidationTest.php @@ -0,0 +1,112 @@ + InstanceSettings::query()->updateOrCreate( + ['id' => 0], + ['is_dns_validation_enabled' => false] + )); + + $result = CheckDomainDns::run( + ['https://example.com' => 'https://example.com'], + new Server(['ip' => '203.0.113.10']), + '203.0.113.10', + ); + + expect($result['https://example.com']) + ->toMatchArray([ + 'status' => 'skipped', + 'message' => 'DNS validation is disabled in instance settings.', + 'expected_ip' => '203.0.113.10', + ]) + ->and($result['https://example.com']['checked_at'])->not->toBeNull(); +}); + +it('stops querying DNS servers after finding a matching IP', function (string $resolvedIp) { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate( + ['id' => 0], + [ + 'is_dns_validation_enabled' => true, + 'custom_dns_servers' => '192.0.2.1,192.0.2.2', + ] + )); + + $queriedServers = new ArrayObject; + $targetIp = '203.0.113.10'; + + app()->bind(DNSQuery::class, function ($app, array $parameters) use ($queriedServers, $resolvedIp) { + return new class($parameters['server'], $queriedServers, $resolvedIp) extends DNSQuery + { + public function __construct( + private readonly string $dnsServer, + private readonly ArrayObject $queriedServers, + private readonly string $resolvedIp, + ) { + parent::__construct($dnsServer); + } + + public function query(string $question, string $typeName = DNSTypes::NAME_A) + { + $this->queriedServers->append($this->dnsServer); + + return [new DNSResult($typeName, 1, 'IN', 60, $this->resolvedIp, $question, '', [])]; + } + + public function hasError(): bool + { + return false; + } + }; + }); + + $server = new Server(['ip' => $targetIp]); + $server->id = 1; + + expect(validateDNSEntry('https://example.com', $server))->toBeTrue() + ->and($queriedServers->getArrayCopy())->toBe(['192.0.2.1']); + + $result = CheckDomainDns::run(['example' => 'https://example.com'], $server, $targetIp); + + expect($result['example']['status'])->toBe('ok') + ->and($queriedServers->getArrayCopy())->toBe(['192.0.2.1', '192.0.2.1']); +})->with([ + 'target server IP' => '203.0.113.10', + 'Cloudflare IP' => '104.16.0.1', +]); + +it('does not start another resolver query after the total dns budget is exhausted', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate( + ['id' => 0], + [ + 'is_dns_validation_enabled' => true, + 'custom_dns_servers' => '192.0.2.1,192.0.2.2', + ] + )); + + $queryCount = new ArrayObject; + app()->bind(DNSQuery::class, function () use ($queryCount) { + $queryCount->append(true); + + return new DNSQuery('192.0.2.1'); + }); + + $result = CheckDomainDns::run( + ['example' => 'https://example.com'], + new Server(['ip' => '203.0.113.10']), + '203.0.113.10', + timeoutSeconds: 0, + ); + + expect($result['example']['status'])->toBe('failed') + ->and($result['example']['message'])->toBe('Could not validate DNS for this domain.') + ->and($queryCount)->toHaveCount(0); +}); diff --git a/tests/Feature/DockerCleanupJobTest.php b/tests/Feature/DockerCleanupJobTest.php index fa052f6c23..081b49a520 100644 --- a/tests/Feature/DockerCleanupJobTest.php +++ b/tests/Feature/DockerCleanupJobTest.php @@ -64,3 +64,25 @@ it('creates a failed execution record when server is force disabled', function ( ->and($execution->status)->toBe('failed') ->and($execution->message)->toContain('not functional'); }); + +it('finishes the latest running execution when the job fails after a timeout', function () { + $user = User::factory()->create(); + $team = $user->teams()->first(); + $server = Server::factory()->create(['team_id' => $team->id]); + + $olderExecution = DockerCleanupExecution::create([ + 'server_id' => $server->id, + ]); + $timedOutExecution = DockerCleanupExecution::create([ + 'server_id' => $server->id, + ]); + + $job = new DockerCleanupJob($server); + $job->failed(new RuntimeException('Docker cleanup job has timed out.')); + + expect($timedOutExecution->refresh()->status)->toBe('failed') + ->and($timedOutExecution->message)->toBe('Docker cleanup job has timed out.') + ->and($timedOutExecution->finished_at)->not->toBeNull() + ->and($olderExecution->refresh()->status)->toBe('running') + ->and($olderExecution->finished_at)->toBeNull(); +}); diff --git a/tests/Feature/GetLogsCommandInjectionTest.php b/tests/Feature/GetLogsCommandInjectionTest.php index db75f7b757..2c86d73284 100644 --- a/tests/Feature/GetLogsCommandInjectionTest.php +++ b/tests/Feature/GetLogsCommandInjectionTest.php @@ -3,6 +3,7 @@ use App\Livewire\Project\Shared\GetLogs; use App\Models\Application; use App\Models\Environment; +use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; use App\Models\StandaloneDocker; @@ -10,6 +11,8 @@ use App\Models\Team; use App\Models\User; use App\Support\ValidationPatterns; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Process\FakeProcessResult; +use Illuminate\Support\Facades\Process; use Livewire\Attributes\Locked; use Livewire\Livewire; @@ -20,7 +23,11 @@ beforeEach(function () { $this->team = Team::factory()->create(); $this->user->teams()->attach($this->team, ['role' => 'owner']); - $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + ]); // Server::created auto-creates a StandaloneDocker, reuse it $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); $this->project = Project::factory()->create(['team_id' => $this->team->id]); @@ -67,6 +74,38 @@ describe('GetLogs locked properties', function () { }); describe('GetLogs Livewire action validation', function () { + test('getLogs marks ANSI-colored output truncated based on raw bytes', function () { + $this->server->settings->fill([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ])->save(); + $server = Server::with('settings')->findOrFail($this->server->id); + $output = "\e[31m".str_repeat('a', GetLogs::MAX_DISPLAY_SIZE_BYTES - 4); + + expect(strlen($output))->toBe(GetLogs::MAX_DISPLAY_SIZE_BYTES + 1); + + Process::shouldReceive('timeout')->once()->andReturnSelf(); + Process::shouldReceive('run')->andReturnUsing(function (string $command, ?callable $callback = null) use ($output): FakeProcessResult { + if ($callback) { + $callback('out', $output); + } + + return new FakeProcessResult(command: $command); + }); + + $component = new GetLogs; + $component->server = $server; + $component->resource = $this->application; + $component->container = 'test-container'; + $component->showTimeStamps = false; + $component->getLogs(true); + + expect($component->outputs) + ->toContain('[... Output truncated at 5MB limit ...]') + ->not->toContain("\e[31m"); + }); + test('getLogs rejects invalid container name', function () { // Make server functional by setting settings directly $this->server->settings->fill([ @@ -75,7 +114,7 @@ describe('GetLogs Livewire action validation', function () { 'force_disabled' => false, ])->save(); // Reload server with fresh settings to ensure casted values - $server = Server::with('settings')->find($this->server->id); + $server = Server::with('settings')->findOrFail($this->server->id); Livewire::test(GetLogs::class, [ 'server' => $server, @@ -105,7 +144,7 @@ describe('GetLogs Livewire action validation', function () { 'is_usable' => true, 'force_disabled' => false, ])->save(); - $server = Server::with('settings')->find($this->server->id); + $server = Server::with('settings')->findOrFail($this->server->id); Livewire::test(GetLogs::class, [ 'server' => $server, diff --git a/tests/Feature/GlobalIconTooltipTest.php b/tests/Feature/GlobalIconTooltipTest.php index 4ef1947f14..2cc9049299 100644 --- a/tests/Feature/GlobalIconTooltipTest.php +++ b/tests/Feature/GlobalIconTooltipTest.php @@ -36,12 +36,11 @@ it('keeps a tooltip hidden until its measured position is applied', function () ->toContain("positioned ? 'visible' : 'invisible'"); }); -it('anchors tooltips to the trigger and lets them grow toward the right', function () { +it('centers tooltips on the trigger and keeps them within the viewport', function () { $tooltip = file_get_contents(resource_path('views/components/icon-tooltip.blade.php')); expect($tooltip) - ->toContain('this.x = rect.left;') - ->toContain('Math.min(window.innerWidth - width - 8, this.x)') - ->not->toContain('rect.left + rect.width / 2') + ->toContain('this.x = rect.left + rect.width / 2;') + ->toContain('Math.min(window.innerWidth - width - 8, this.x - width / 2)') ->not->toContain('-translate-x-1/2'); }); diff --git a/tests/Feature/HighlightedButtonStylingTest.php b/tests/Feature/HighlightedButtonStylingTest.php index eae11deaff..834d792cee 100644 --- a/tests/Feature/HighlightedButtonStylingTest.php +++ b/tests/Feature/HighlightedButtonStylingTest.php @@ -10,10 +10,23 @@ test('highlighted buttons use the shared coollabs style in every color scheme', expect($utilities) ->toContain('@utility button-highlighted') - ->toContain('@apply border-coollabs-200 bg-linear-to-b from-coollabs-100 to-coollabs-200 text-white! hover:from-coollabs-100 hover:to-coollabs hover:text-white!;') + ->toContain('@apply border-coollabs-200 bg-linear-to-b from-coollabs-100 to-coollabs-200 text-accent-foreground! hover:from-coollabs-100 hover:to-coollabs hover:text-accent-foreground!;') ->and($appStyles) ->toContain('button[isHighlighted]:not(:disabled)') ->toContain('@apply button-highlighted;') ->and($views) ->not->toContain('dark:bg-warning/15! dark:text-warning! dark:ring-warning/25'); }); + +test('custom theme highlighted buttons use the computed contrasting foreground', function () { + $utilities = file_get_contents(resource_path('css/utilities.css')); + $appStyles = file_get_contents(resource_path('css/app.css')); + + expect($utilities) + ->toContain('text-accent-foreground!') + ->toContain('hover:text-accent-foreground!') + ->not->toContain('text-white! hover:') + ->and($appStyles) + ->toContain('html[data-theme="custom"] .button-highlighted .animate-spin') + ->toContain('html[data-theme="custom"] button[isHighlighted] .animate-spin'); +}); diff --git a/tests/Feature/InvitationLinkHandlingTest.php b/tests/Feature/InvitationLinkHandlingTest.php index 3b473f60e4..31081e7264 100644 --- a/tests/Feature/InvitationLinkHandlingTest.php +++ b/tests/Feature/InvitationLinkHandlingTest.php @@ -1,5 +1,6 @@ withoutVite(); $this->withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]); Once::flush(); Config::set('app.maintenance.driver', 'file'); @@ -56,10 +58,86 @@ function createInvitationLinkFixture(array $invitationAttributes = []): array return [$team, $user, $password, $token, $invitation]; } -it('accepts a valid magic link invitation only once and rotates the temporary password', function () { +it('shows a valid magic link invitation without consuming it', function () { [$team, $user, $password, $token] = createInvitationLinkFixture(); $this->get(route('auth.link', ['token' => $token])) + ->assertSuccessful() + ->assertViewIs('invitation.accept') + ->assertSee($team->name) + ->assertSee('Accept invitation'); + + $this->assertGuest(); + $this->assertDatabaseHas('team_invitations', ['email' => $user->email]); + expect($user->teams()->where('team_id', $team->id)->exists())->toBeFalse(); + + $user->refresh(); + expect(Hash::check($password, $user->password))->toBeTrue(); +}); + +it('finds the matching invitation for a legacy token when the email has multiple invitations', function () { + $team = Team::factory()->create(); + $user = User::factory()->create([ + 'email' => 'legacy-invitee@example.com', + 'password' => Hash::make($password = 'temporary-password-123'), + ]); + $legacyToken = Crypt::encryptString("{$user->email}@@@{$password}"); + + TeamInvitation::create([ + 'team_id' => Team::factory()->create()->id, + 'uuid' => (string) new Cuid2(32), + 'email' => $user->email, + 'role' => 'member', + 'link' => route('auth.link', ['token' => Crypt::encryptString("{$user->email}@@@another-password")]), + 'via' => 'link', + ]); + + TeamInvitation::create([ + 'team_id' => $team->id, + 'uuid' => (string) new Cuid2(32), + 'email' => $user->email, + 'role' => 'member', + 'link' => route('auth.link', ['token' => $legacyToken]), + 'via' => 'link', + ]); + + $this->get(route('auth.link', ['token' => $legacyToken])) + ->assertSuccessful() + ->assertViewHas('team', $team); +}); + +it('does not count confirmation requests against the acceptance throttle', function () { + [, $user, , $token] = createInvitationLinkFixture(); + + foreach (range(1, 5) as $attempt) { + $this->get(route('auth.link', ['token' => $token])) + ->assertSuccessful(); + } + + $this->post(route('auth.link.accept'), ['token' => $token]) + ->assertRedirect(route('dashboard')); + + $this->assertAuthenticatedAs($user); +}); + +it('throttles acceptance independently for different magic link tokens from the same IP', function () { + [, $user, , $token] = createInvitationLinkFixture(); + + foreach (range(1, 5) as $attempt) { + $this->post(route('auth.link.accept'), ['token' => 'another-token']) + ->assertRedirect(route('login')); + } + + $this->post(route('auth.link.accept'), ['token' => $token]) + ->assertRedirect(route('dashboard')); + + $this->assertAuthenticatedAs($user); +}); + +it('accepts a valid magic link invitation on post only once and rotates the temporary password', function () { + [$team, $user, $password, $token] = createInvitationLinkFixture(); + + $this->post(route('auth.link.accept'), ['token' => $token]) ->assertRedirect(route('dashboard')); $this->assertAuthenticatedAs($user); @@ -72,16 +150,37 @@ it('accepts a valid magic link invitation only once and rotates the temporary pa auth()->logout(); session()->flush(); - $this->get(route('auth.link', ['token' => $token])) + $this->post(route('auth.link.accept'), ['token' => $token]) ->assertRedirect(route('login')); $this->assertGuest(); }); +it('rolls back invitation redemption when password rotation fails', function () { + [$team, $user, $password, $token, $invitation] = createInvitationLinkFixture(); + $this->withoutExceptionHandling(); + + User::updating(function (User $updatingUser) use ($user) { + if ($updatingUser->is($user)) { + throw new RuntimeException('Password rotation failed.'); + } + }); + + expect(fn () => $this->post(route('auth.link.accept'), ['token' => $token])) + ->toThrow(RuntimeException::class, 'Password rotation failed.'); + + $this->assertDatabaseHas('team_invitations', ['id' => $invitation->id]); + expect($user->teams()->where('team_id', $team->id)->exists())->toBeFalse(); + + $user->refresh(); + expect(Hash::check($password, $user->password))->toBeTrue(); + $this->assertGuest(); +}); + it('accepts a magic link when opened from a different public origin', function () { [$team, $user, $password, $token] = createInvitationLinkFixture(); - $this->get('https://coolify.example.com/auth/link?token='.urlencode($token)) + $this->post('https://coolify.example.com/auth/link', ['token' => $token]) ->assertRedirect(route('dashboard')); $this->assertAuthenticatedAs($user); @@ -98,7 +197,7 @@ it('keeps the invited user authenticated after rotating the temporary password w [$team, $user, $password, $token] = createInvitationLinkFixture(); - $this->get(route('auth.link', ['token' => $token])) + $this->post(route('auth.link.accept'), ['token' => $token]) ->assertRedirect(route('dashboard')); expect(DB::table('sessions')->where('user_id', $user->id)->exists())->toBeTrue(); @@ -170,7 +269,7 @@ it('rejects a magic link when the invitation expired', function () { ->assertRedirect(route('login')); $this->assertGuest(); - $this->assertDatabaseMissing('team_invitations', ['id' => $invitation->id]); + $this->assertDatabaseHas('team_invitations', ['id' => $invitation->id]); }); it('rejects a malformed magic link token', function () { @@ -179,3 +278,16 @@ it('rejects a malformed magic link token', function () { $this->assertGuest(); }); + +it('declares the magic link method contracts', function () { + $linkReturnType = (new ReflectionMethod(Controller::class, 'link'))->getReturnType(); + $acceptLinkReturnType = (new ReflectionMethod(Controller::class, 'acceptLink'))->getReturnType(); + $credentialsMethod = new ReflectionMethod(Controller::class, 'magicLinkCredentials'); + $isValidReturnType = (new ReflectionMethod(TeamInvitation::class, 'isValid'))->getReturnType(); + + expect((string) $linkReturnType)->toContain('Illuminate\\Contracts\\View\\View') + ->and((string) $linkReturnType)->toContain('Illuminate\\Http\\RedirectResponse') + ->and((string) $acceptLinkReturnType)->toBe('Illuminate\\Http\\RedirectResponse') + ->and($credentialsMethod->getDocComment())->toContain('@return array{0: User, 1: TeamInvitation}|null') + ->and((string) $isValidReturnType)->toBe('bool'); +}); diff --git a/tests/Feature/LinkLoginEmailVerificationTest.php b/tests/Feature/LinkLoginEmailVerificationTest.php index 39ade93ebf..c962e86ece 100644 --- a/tests/Feature/LinkLoginEmailVerificationTest.php +++ b/tests/Feature/LinkLoginEmailVerificationTest.php @@ -50,7 +50,7 @@ describe('invitation link login', function () { 'via' => 'link', ]); - $this->get(route('auth.link', ['token' => $token])); + $this->post(route('auth.link.accept'), ['token' => $token]); $user->refresh(); expect($user->email_verified_at)->toBeNull(); @@ -77,7 +77,7 @@ describe('invitation link login', function () { 'via' => 'link', ]); - $this->get(route('auth.link', ['token' => $token])) + $this->post(route('auth.link.accept'), ['token' => $token]) ->assertRedirect(route('dashboard')); expect(auth()->id())->toBe($user->id); diff --git a/tests/Feature/Livewire/ConfigurationCheckerTest.php b/tests/Feature/Livewire/ConfigurationCheckerTest.php index ff1400847c..3b80ed209c 100644 --- a/tests/Feature/Livewire/ConfigurationCheckerTest.php +++ b/tests/Feature/Livewire/ConfigurationCheckerTest.php @@ -1,22 +1,29 @@ 0]); $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); @@ -121,6 +128,39 @@ it('warns when a service has missing required environment variables', function ( }); +it('broadcasts a configuration update after a required service variable is set', function () { + Event::fake([ApplicationConfigurationChanged::class]); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => $server->id, + ]); + $environmentVariable = $service->environment_variables()->create([ + 'key' => 'PLUNK_API_KEY', + 'value' => '', + 'is_required' => true, + ]); + + Livewire::test(Show::class, ['env' => $environmentVariable, 'type' => 'service']) + ->call('loadValues') + ->set('value', 'secret') + ->call('submit'); + + Event::assertDispatched( + ApplicationConfigurationChanged::class, + fn (ApplicationConfigurationChanged $event): bool => $event->teamId === $this->team->id, + ); +}); + +it('refreshes the service configuration when a websocket configuration event arrives', function () { + $listeners = app(Configuration::class)->getListeners(); + + expect($listeners) + ->toHaveKey("echo-private:team.{$this->team->id},ApplicationConfigurationChanged", 'refreshServices') + ->toHaveKey('configurationChanged', 'refreshServices'); +}); + it('marks the service environment variables menu when required values are missing', function () { $configuration = file_get_contents(resource_path('views/livewire/project/service/configuration.blade.php')); $sidebar = file_get_contents(resource_path('views/components/service/configuration-sidebar.blade.php')); diff --git a/tests/Feature/Livewire/SentinelComponentTest.php b/tests/Feature/Livewire/SentinelComponentTest.php index 41b3f2b497..01250910aa 100644 --- a/tests/Feature/Livewire/SentinelComponentTest.php +++ b/tests/Feature/Livewire/SentinelComponentTest.php @@ -36,3 +36,12 @@ it('does not repeat a disabled status badge in the sentinel empty state', functi expect($view)->not->toContain("? 'Disabled'"); }); + +it('tells the user that saving sentinel settings initiates a restart', function () { + $componentSource = file_get_contents(app_path('Livewire/Server/Sentinel.php')); + + preg_match('/public function submit\([^)]*\).*?\{(?.*?)\n \}/s', $componentSource, $matches); + + expect($matches['body'] ?? '') + ->toContain("\$this->dispatch('success', 'Sentinel settings updated. Restarting Sentinel.');"); +}); diff --git a/tests/Feature/LivewireRequestFailurePreviewTest.php b/tests/Feature/LivewireRequestFailurePreviewTest.php new file mode 100644 index 0000000000..8877d28ce7 --- /dev/null +++ b/tests/Feature/LivewireRequestFailurePreviewTest.php @@ -0,0 +1,47 @@ +set('app.maintenance.store', 'array'); + InstanceSettings::forceCreate(['id' => 0]); +}); + +it('registers the Livewire request failure preview in testing', function () { + expect(Route::has('dev.livewire-request-failure-preview'))->toBeTrue(); + + $this->get('/__livewire-request-failure') + ->assertSuccessful() + ->assertSee('Livewire request failure preview') + ->assertSee('Gateway timeout') + ->assertSee('504'); +}); + +it('returns proxy-style html for supported statuses', function () { + Livewire::test(LivewireRequestFailurePreview::class) + ->call('fail', 504) + ->assertStatus(504) + ->assertContent('

Gateway time-out

cloudflare proxy error 504

'); +}); + +it('rejects statuses outside the supported list', function () { + Livewire::test(LivewireRequestFailurePreview::class) + ->call('fail', 500) + ->assertStatus(404); +}); + +it('keeps the preview statuses in sync with the JS handler', function () { + $source = file_get_contents(resource_path('js/livewire-request-failure.js')); + + expect(preg_match('/INFRASTRUCTURE_FAILURE_STATUSES = new Set\(\[([\d,\s]+)\]\)/', $source, $matches))->toBe(1); + + $jsStatuses = array_map('intval', array_map('trim', explode(',', $matches[1]))); + + expect((new LivewireRequestFailurePreview)->statuses)->toBe($jsStatuses); +}); diff --git a/tests/Feature/ModalScrollLockTest.php b/tests/Feature/ModalScrollLockTest.php index 7863325a20..17d293519d 100644 --- a/tests/Feature/ModalScrollLockTest.php +++ b/tests/Feature/ModalScrollLockTest.php @@ -7,3 +7,11 @@ test('confirmation modal closes before dispatching an event that can open anothe '/if \(dispatchEvent\) \{\s*modalOpen = false;\s*\$nextTick\(\(\) => \$wire\.dispatch\(dispatchEventType, dispatchEventMessage\)\);/s' ); }); + +test('confirmation modal releases its scroll lock before submitting a destructive action', function () { + $modal = file_get_contents(resource_path('views/components/modal-confirmation.blade.php')); + + expect($modal) + ->toMatch('/submitting = true;\s*modalOpen = false;\s*\$nextTick\(\(\) => \{\s*submitForm\(\)/s') + ->toMatch('/if \(result === true\) \{\s*resetModal\(\);/s'); +}); diff --git a/tests/Feature/Proxy/RestartProxyTest.php b/tests/Feature/Proxy/RestartProxyTest.php index 60c0571350..16cddd36ea 100644 --- a/tests/Feature/Proxy/RestartProxyTest.php +++ b/tests/Feature/Proxy/RestartProxyTest.php @@ -72,6 +72,65 @@ test('admin can see proxy restart and stop buttons', function () { ->assertSee('Stop Proxy'); }); +test('running proxy shows pending configuration warning when saved settings differ from applied settings', function () { + [$user, $team, $server] = setupProxyUser('admin'); + makeServerProxyRunning($server); + $server->proxy->last_saved_settings = 'saved-hash'; + $server->proxy->last_applied_settings = 'applied-hash'; + $server->detected_traefik_version = '3.6.23'; + $server->traefik_outdated_info = [ + 'current' => '3.6.23', + 'latest' => '3.7.8', + 'type' => 'minor_upgrade', + 'upgrade_target' => 'v3.7', + ]; + $server->save(); + + expect($server->fresh()->hasPendingProxyConfiguration())->toBeTrue(); + + $this->actingAs($user); + session(['currentTeam' => $team]); + + $component = Livewire::test('server.navbar', ['server' => $server->fresh()]) + ->assertSee('Changes pending') + ->assertSee('The saved proxy configuration has not been applied') + ->assertSee('Restart proxy'); + + $server->refresh(); + $server->proxy->last_applied_settings = 'saved-hash'; + $server->traefik_outdated_info = null; + $server->save(); + + $component->call('showNotification') + ->assertDispatched('proxy-configuration-state-changed', pending: false, traefikOutdated: false) + ->assertDontSee('The saved proxy configuration has not been applied'); +}); + +test('running proxy hides pending configuration warning when saved settings match applied settings', function () { + [$user, $team, $server] = setupProxyUser('admin'); + makeServerProxyRunning($server); + $server->proxy->last_saved_settings = 'matching-hash'; + $server->proxy->last_applied_settings = 'matching-hash'; + $server->save(); + + expect($server->fresh()->hasPendingProxyConfiguration())->toBeFalse(); + + $this->actingAs($user); + session(['currentTeam' => $team]); + + $component = Livewire::test('server.navbar', ['server' => $server->fresh()]) + ->assertDontSee('The saved proxy configuration has not been applied'); + + $server->refresh(); + $server->proxy->last_saved_settings = 'new-saved-hash'; + $server->save(); + + $component->dispatch('refreshServerShow') + ->assertDispatched('proxy-configuration-state-changed', pending: true, traefikOutdated: false) + ->assertSee('Changes pending') + ->assertSee('The saved proxy configuration has not been applied'); +}); + test('admin can stop a proxy while it is starting', function () { [$user, $team, $server] = setupProxyUser('admin'); diff --git a/tests/Feature/Proxy/RuntimeNetworkReconciliationTest.php b/tests/Feature/Proxy/RuntimeNetworkReconciliationTest.php index 7194555399..9a2378be2f 100644 --- a/tests/Feature/Proxy/RuntimeNetworkReconciliationTest.php +++ b/tests/Feature/Proxy/RuntimeNetworkReconciliationTest.php @@ -6,14 +6,14 @@ use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); -it('discovers running container networks instead of relying on cached resource statuses', function () { +it('discovers managed container networks regardless of container status', function () { $team = Team::factory()->create(); $server = Server::factory()->create(['team_id' => $team->id]); $commands = connectProxyToNetworks($server)->implode("\n"); expect($commands) - ->toContain('docker ps --filter label=coolify.managed=true') + ->toContain('docker ps -a --filter label=coolify.managed=true') ->toContain('.NetworkSettings.Networks') ->toContain('docker network inspect "$network"') ->toContain('docker network connect "$network" coolify-proxy') diff --git a/tests/Feature/ProxyConfigurationLoadingStateTest.php b/tests/Feature/ProxyConfigurationLoadingStateTest.php new file mode 100644 index 0000000000..112f68d8cc --- /dev/null +++ b/tests/Feature/ProxyConfigurationLoadingStateTest.php @@ -0,0 +1,12 @@ +toContain('wire:loading.attr="disabled"') + ->toContain('wire:target="submit,resetProxyConfiguration"') + ->toContain('wire:loading.class="pointer-events-none opacity-50"') + ->toContain('Updating proxy configuration') + ->toContain('aria-live="polite"'); +}); diff --git a/tests/Feature/ResourceAccessConsistencyTest.php b/tests/Feature/ResourceAccessConsistencyTest.php new file mode 100644 index 0000000000..2511e14342 --- /dev/null +++ b/tests/Feature/ResourceAccessConsistencyTest.php @@ -0,0 +1,120 @@ + 'file']); + + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $this->otherTeam = Team::factory()->create(); + + session(['currentTeam' => $this->team]); + + $this->privateKey = PrivateKey::withoutEvents(fn () => PrivateKey::forceCreate([ + 'uuid' => (string) Str::uuid(), + 'name' => 'IDOR test key', + 'private_key' => 'test-private-key', + 'team_id' => $this->team->id, + ])); + + $token = $this->user->createToken('idor-hardening', ['*']); + $token->accessToken->forceFill(['team_id' => $this->team->id])->save(); + $this->token = $token->plainTextToken; +}); + +test('server creation returns a consistent duplicate address response', function () { + $ownServer = Server::factory()->create([ + 'ip' => '192.0.2.10', + 'team_id' => $this->team->id, + ]); + $otherServer = Server::factory()->create([ + 'ip' => '192.0.2.20', + 'team_id' => $this->otherTeam->id, + ]); + + $payload = fn (Server $server): array => [ + 'name' => 'Duplicate server', + 'ip' => $server->ip, + 'private_key_uuid' => $this->privateKey->uuid, + 'user' => 'root', + ]; + + $ownResponse = $this->withToken($this->token)->postJson('/api/v1/servers', $payload($ownServer)); + $otherResponse = $this->withToken($this->token)->postJson('/api/v1/servers', $payload($otherServer)); + + $ownResponse->assertBadRequest(); + $otherResponse->assertBadRequest(); + expect($ownResponse->json('message')) + ->toBe('A server with this IP/Domain is already in use.') + ->toBe($otherResponse->json('message')); +}); + +test('environment details applies the project view policy', function () { + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + Gate::before(fn (User $user, string $ability): ?bool => $ability === 'view' ? false : null); + + $this->withToken($this->token) + ->getJson("/api/v1/projects/{$project->uuid}/{$environment->uuid}") + ->assertForbidden(); +}); + +test('docker cleanup execution selection only uses the mounted server', function () { + $this->actingAs($this->user); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $otherServer = Server::factory()->create(['team_id' => $this->otherTeam->id]); + $otherExecution = DockerCleanupExecution::create([ + 'server_id' => $otherServer->id, + 'status' => 'success', + 'message' => 'other team cleanup output', + ]); + + Livewire::test(DockerCleanupExecutions::class, ['server' => $server]) + ->call('selectExecution', $otherExecution->id) + ->assertSet('selectedExecution', null); +}); + +test('scheduled task form only mounts applications from the current team', function () { + $this->actingAs($this->user); + + $server = Server::factory()->create(['team_id' => $this->otherTeam->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->otherTeam->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + Livewire::test(Add::class, [ + 'id' => (string) $application->id, + 'type' => 'application', + 'containerNames' => collect(), + ]); +})->throws(ModelNotFoundException::class); diff --git a/tests/Feature/ScheduledTaskCrossTeamIdorTest.php b/tests/Feature/ScheduledTaskCrossTeamIdorTest.php new file mode 100644 index 0000000000..b250db119b --- /dev/null +++ b/tests/Feature/ScheduledTaskCrossTeamIdorTest.php @@ -0,0 +1,125 @@ +withoutVite(); + InstanceSettings::forceCreate(['id' => 0]); + + $this->attacker = User::factory()->create(); + $this->attackerTeam = Team::factory()->create(); + $this->attacker->teams()->attach($this->attackerTeam, ['role' => 'owner']); + + $this->victim = User::factory()->create(); + $this->victimTeam = Team::factory()->create(); + $this->victim->teams()->attach($this->victimTeam, ['role' => 'owner']); + + // Attacker team gets a real server/project/env/app so Show can mount + authorize + $this->server = Server::factory()->create(['team_id' => $this->attackerTeam->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->attackerTeam->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->actingAs($this->attacker); + session(['currentTeam' => $this->attackerTeam]); +}); + +describe('ScheduledTask locked properties', function () { + test('Show component task property has Locked attribute', function () { + $property = new ReflectionProperty(Show::class, 'task'); + $attributes = $property->getAttributes(Locked::class); + + expect($attributes)->not->toBeEmpty(); + }); + + test('Show component resource property has Locked attribute', function () { + $property = new ReflectionProperty(Show::class, 'resource'); + $attributes = $property->getAttributes(Locked::class); + + expect($attributes)->not->toBeEmpty(); + }); + + test('Executions component task property has Locked attribute', function () { + $property = new ReflectionProperty(Executions::class, 'task'); + $attributes = $property->getAttributes(Locked::class); + + expect($attributes)->not->toBeEmpty(); + }); + + test('Executions component selected execution property has Locked attribute', function () { + $property = new ReflectionProperty(Executions::class, 'selectedExecution'); + $attributes = $property->getAttributes(Locked::class); + + expect($attributes)->not->toBeEmpty(); + }); +}); + +describe('ScheduledTask cross-team access', function () { + test('Executions rejects mounting another team task id', function () { + $victimTask = ScheduledTask::factory()->create([ + 'team_id' => $this->victimTeam->id, + 'command' => 'echo top-secret-victim-command', + ]); + + Livewire::test(Executions::class, ['taskId' => $victimTask->id]) + ->assertStatus(404); + }); + + test('Show policy denies updating another team task', function () { + $victimTask = ScheduledTask::factory()->create([ + 'team_id' => $this->victimTeam->id, + 'name' => 'victim-task', + 'command' => 'echo original-victim-command', + ]); + + expect( + Gate::forUser($this->attacker)->allows('update', $victimTask) + )->toBeFalse(); + }); + + test('Show policy denies deleting another team task', function () { + $victimTask = ScheduledTask::factory()->create([ + 'team_id' => $this->victimTeam->id, + 'name' => 'victim-task', + 'command' => 'echo original-victim-command', + ]); + + expect( + Gate::forUser($this->attacker)->allows('delete', $victimTask) + )->toBeFalse(); + }); + + test('Show policy allows updating own team task', function () { + $ownTask = ScheduledTask::factory()->create([ + 'team_id' => $this->attackerTeam->id, + 'application_id' => $this->application->id, + 'name' => 'own-task', + 'command' => 'echo original-command', + ]); + + expect( + Gate::forUser($this->attacker)->allows('update', $ownTask) + )->toBeTrue(); + }); +}); diff --git a/tests/Feature/SentinelUnsavedBarFlashTest.php b/tests/Feature/SentinelUnsavedBarFlashTest.php index ac8f72ddf0..0c5d196c03 100644 --- a/tests/Feature/SentinelUnsavedBarFlashTest.php +++ b/tests/Feature/SentinelUnsavedBarFlashTest.php @@ -76,9 +76,9 @@ test('unsaved bar keyboard shortcut remains visible in light mode', function () $contents = file_get_contents(resource_path('views/components/unsaved-bar.blade.php')); expect($contents) - ->toContain('border-coollabs/20 bg-coollabs/10') - ->toContain('text-coollabs-200') - ->toContain('dark:border-white/20 dark:bg-white/10 dark:text-white/75'); + ->toContain('border-current/20 bg-current/10') + ->toContain('text-current') + ->not->toContain('text-coollabs-200'); }); test('unsaved bar uses a light surface in light mode', function () { diff --git a/tests/Feature/ServerSidebarIconsTest.php b/tests/Feature/ServerSidebarIconsTest.php index 414a86381e..12d33b6e38 100644 --- a/tests/Feature/ServerSidebarIconsTest.php +++ b/tests/Feature/ServerSidebarIconsTest.php @@ -36,6 +36,13 @@ it('uses the shield-star reicon for sentinel in the server sidebar', function () ->toMatch("/'label' => 'Sentinel',\s*'route' => 'server\.sentinel',\s*'active' => request\(\)->routeIs\('server\.sentinel', 'server\.sentinel\.\*'\),\s*'icon' => 'shield-star'/s"); }); +it('shows a warning icon when sentinel is enabled but not working', function () { + $contents = file_get_contents(resource_path('views/components/server/sidebar.blade.php')); + + expect($contents) + ->toContain("'warning' => \$server->isSentinelEnabled() && ! \$server->isSentinelLive()"); +}); + it('uses the network reicon for proxy in the server sidebar', function () { $contents = file_get_contents(resource_path('views/components/server/sidebar.blade.php')); @@ -46,3 +53,17 @@ it('uses the network reicon for proxy in the server sidebar', function () { ->toMatch("/'label' => 'Proxy'[\s\S]*?'icon' => 'network'/") ->not->toMatch("/'label' => 'Proxy'[\s\S]*?'icon' => 'settings',\s*'group' => 'Platform'/"); }); + +it('shows a warning icon when a server menu item requires attention', function () { + $contents = file_get_contents(resource_path('views/components/server/sidebar.blade.php')); + + expect($contents) + ->toContain('proxyConfigurationPending: @js($server->hasPendingProxyConfiguration())') + ->toContain('proxyConfigurationPending: @js($server->hasPendingProxyConfiguration()),') + ->toContain('traefikOutdated: @js($server->hasCurrentTraefikOutdatedInfo())') + ->toContain('@proxy-configuration-state-changed.window') + ->toContain("\$menuItem['warning'] ?? false") + ->toContain('name="alert-triangle"'); + + expect(substr_count($contents, 'traefikOutdated: @js($server->hasCurrentTraefikOutdatedInfo())'))->toBe(1); +}); diff --git a/tests/Feature/ServerStatusIndicatorDesignTest.php b/tests/Feature/ServerStatusIndicatorDesignTest.php index 34e0f4cbd8..4b31a51d85 100644 --- a/tests/Feature/ServerStatusIndicatorDesignTest.php +++ b/tests/Feature/ServerStatusIndicatorDesignTest.php @@ -1,29 +1,27 @@ not->toContain('toContain("'border-emerald-500/70' => \$serverStatusType === 'success'") - ->toContain("'border-amber-500/70' => \$serverStatusType === 'warning'") - ->toContain("'border-red-500/70' => \$serverStatusType === 'error'") - ->toContain('title="{{ $serverStatus }}"') - ->toContain('aria-label="Server status: {{ $serverStatus }}"'); + ->not->toContain("'border-amber-500/70' => \$serverStatusType === 'warning'") + ->toContain('@if ($serverStatusType !== \'success\')') + ->toContain('data-tooltip="{{ $serverStatus }}"') + ->toContain('toBe(1) ->and($serverIndex) - ->toContain("\$proxyNeedsAttention = \$isReady && \$server->proxySet() && \$server->proxy->status !== 'running'") + ->toContain("&& (\$server->proxy->status !== 'running' || \$server->hasCurrentTraefikOutdatedInfo())") ->toContain('$sentinelNeedsAttention = $isReady && $server->isSentinelEnabled() && ! $server->isSentinelLive()') ->toContain("\$proxyNeedsAttention || \$sentinelNeedsAttention => 'warning'") ->toContain("\$isReady => 'success'") ->toContain("\$isTransferredAway || \$server->settings->force_disabled => 'error'") ->toContain("default => 'error'") - ->toContain("server.statusType === 'success' ? 'border-emerald-500/70'") - ->toContain("server.statusType === 'warning' ? 'border-amber-500/70'") - ->toContain("'border-red-500/70'") - ->toContain(':title="server.status"') + ->not->toContain("server.statusType === 'warning' ? 'border-amber-500/70'") + ->toContain('x-show="server.statusType !== \'success\'"') + ->toContain(':data-tooltip="server.status"') ->toContain(':aria-label="`Server status: ${server.status}`"'); }); @@ -31,11 +29,25 @@ test('dashboard server cards warn when proxy or sentinel needs attention', funct $dashboard = file_get_contents(resource_path('views/livewire/dashboard.blade.php')); expect($dashboard) - ->toContain("\$proxyNeedsAttention = \$server->proxySet() && \$server->proxy->status !== 'running'") + ->toContain("\$proxyNeedsAttention = \$server->proxySet() && (\$server->proxy->status !== 'running' || \$server->hasCurrentTraefikOutdatedInfo())") ->toContain('$sentinelNeedsAttention = $server->isSentinelEnabled() && ! $server->isSentinelLive()') ->toContain("\$proxyNeedsAttention || \$sentinelNeedsAttention => ['Attention required', 'warning']"); }); +test('server status summary uses warning indicators for proxy updates and sentinel outages', function () { + $summary = file_get_contents(resource_path('views/components/server/status-summary.blade.php')); + + expect($summary) + ->toContain('$server->hasCurrentTraefikOutdatedInfo()') + ->toContain('$proxyStatusLabel = match (true)') + ->toContain("\$proxyConfigurationPending => 'Restart required'") + ->toContain("\$traefikUpdateAvailable => 'Update available'") + ->toContain('{{ $proxyStatusLabel }}') + ->toContain("'bg-warning' => \$proxyNeedsAttention && (\$proxyUpdateAvailable") + ->toContain("'bg-warning' => \$sentinelNeedsAttention") + ->not->toContain("\$server->isSentinelLive() ? 'bg-success' : 'bg-error'"); +}); + test('server table keeps status text without a badge', function () { $serverIndex = file_get_contents(resource_path('views/livewire/server/index.blade.php')); diff --git a/tests/Feature/ServerValidationDialogTest.php b/tests/Feature/ServerValidationDialogTest.php index a22056cac4..e1c75e14af 100644 --- a/tests/Feature/ServerValidationDialogTest.php +++ b/tests/Feature/ServerValidationDialogTest.php @@ -4,12 +4,29 @@ test('server revalidation opens in the centered process dialog', function () { $view = file_get_contents(resource_path('views/livewire/server/show.blade.php')); expect($view) - ->toContain('') + ->toContain('') ->toContain(':isHighlighted="! $server->isFunctional()"') ->toContain('@click="processDialogOpen = true" wire:click.prevent="validateServer"') ->not->toContain('toContain('') + ->and($dialog) + ->toContain("'mobileFullscreen' => false") + ->toContain("'process-dialog-mobile-fullscreen' => \$mobileFullscreen") + ->and($styles) + ->toContain('@media (max-width: 639px)') + ->toContain('.process-dialog-mobile-fullscreen') + ->toContain('height: 100dvh !important') + ->toContain('box-shadow: inset 0 0 0 1px var(--coollabs-hairline) !important'); +}); + test('completed server validation shows a close action instead of empty logs', function () { $view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php')); @@ -25,11 +42,17 @@ test('completed server validation shows a close action instead of empty logs', f test('installation logs are only shown after an installation starts', function () { $view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php')); $component = file_get_contents(app_path('Livewire/Server/ValidateAndInstall.php')); + $styles = file_get_contents(resource_path('css/app.css')); - expect($view)->toContain('@elseif ($isInstalling)') + expect($view) + ->toContain('@elseif ($isInstalling)') + ->toContain('application-settings-section validation-installation-logs') ->and($component) ->toContain('public bool $isInstalling = false;') - ->toContain('$this->isInstalling = true;'); + ->toContain('$this->isInstalling = true;') + ->and($styles) + ->toContain('.validation-installation-logs') + ->toContain('border: 1px solid var(--coollabs-fill)'); }); test('server validation content scrolls within the dialog', function () { @@ -43,10 +66,22 @@ test('server validation content scrolls within the dialog', function () { test('validation checkpoints use the standard bordered list treatment', function () { $view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php')); + $styles = file_get_contents(resource_path('css/app.css')); expect($view) ->toContain('data-validation-checkpoints') - ->toContain('overflow-hidden rounded-[10px] border border-neutral-200 dark:border-white/[0.08]'); + ->toContain('shrink-0 overflow-hidden rounded-[10px] border border-neutral-200 dark:border-white/[0.08]') + ->toContain('checkpoint-scroll-fade') + ->toContain('snap-x snap-mandatory overflow-x-auto overscroll-x-contain scroll-smooth scrollbar') + ->toContain('data-checkpoint-status="{{ $checkpoint[\'status\'] }}"') + ->toContain('basis-[88%] shrink-0 snap-start sm:basis-72 lg:basis-80') + ->toContain("querySelector('[data-checkpoint-status=running]')") + ->toContain("scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })") + ->toContain('new MutationObserver') + ->toContain("attributeFilter: ['data-checkpoint-status']") + ->toContain('x-destroy="observer?.disconnect()"') + ->and($styles) + ->toContain('.checkpoint-scroll-fade::after'); }); test('all validation checkpoints remain visible while only the current phase runs', function () { diff --git a/tests/Feature/ServiceDomainsTest.php b/tests/Feature/ServiceDomainsTest.php index 62ea41d96d..76ca4f2a8a 100644 --- a/tests/Feature/ServiceDomainsTest.php +++ b/tests/Feature/ServiceDomainsTest.php @@ -1,5 +1,6 @@ webApp->update(['redirect' => 'both']); - Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) + $component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) ->set('newServiceApplicationId', $this->webApp->id) ->set('newDomain', 'https://web.example.com') ->call('addDomain') ->assertHasNoErrors() - ->assertDispatched('success') + ->assertDispatched('success'); + + $component->call('pollDnsChecks') ->assertSee('DNS skipped'); expect($this->webApp->fresh()->fqdn)->toBe('https://web.example.com'); }); it('adds a domain to a selected service application', function () { + Queue::fake(); + Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) ->set('newServiceApplicationId', $this->webApp->id) ->set('newDomain', 'https://web.example.com') ->call('addDomain') ->assertHasNoErrors() - ->assertDispatched('success') + ->assertDispatched('success', 'Domain added. DNS check started.') + ->assertSet('domainRows', fn (array $rows): bool => collect($rows)->firstWhere('url', 'https://web.example.com')['dns_status'] === 'checking') ->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->contains('https://web.example.com')) ->assertSee('https://web.example.com'); $this->webApp->refresh(); expect($this->webApp->fqdn)->toBe('https://web.example.com'); - $dnsStatuses = $this->webApp->domain_dns_statuses; + expect($this->webApp->domain_dns_statuses['https://web.example.com']['status'] ?? null)->toBe('checking'); - expect($dnsStatuses) - ->toHaveKey('https://web.example.com') - ->not->toHaveKey('https://www.web.example.com') - ->and($dnsStatuses['https://web.example.com']['status']) - ->toBe('skipped') - ->and($dnsStatuses['https://web.example.com']['checked_at']) - ->not->toBeNull(); + Queue::assertPushed(CheckDomainDnsJob::class, 1); +}); + +it('adds a domain when the compose service has an empty environment section', function () { + $this->service->update([ + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n environment:\n api:\n image: node:alpine\n", + ]); + + Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) + ->set('newServiceApplicationId', $this->webApp->id) + ->set('newDomain', 'https://web.example.com') + ->call('addDomain') + ->assertHasNoErrors() + ->assertDispatched('success'); + + expect($this->webApp->fresh()->fqdn)->toBe('https://web.example.com'); }); it('keeps a stable key for the rendered domain list', function () { @@ -442,7 +458,7 @@ it('does not restore stale dns status when a removed service domain is re-added' ], ]); - Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) + $component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) ->call('removeDomain', 0) ->set('newServiceApplicationId', $this->apiApp->id) ->set('newDomain', 'https://api.example.com') @@ -450,6 +466,8 @@ it('does not restore stale dns status when a removed service domain is re-added' ->assertHasNoErrors() ->assertDispatched('success'); + $component->call('pollDnsChecks'); + $this->apiApp->refresh(); expect(explode(',', (string) $this->apiApp->fqdn)) @@ -549,6 +567,39 @@ it('hides dns message text when service domain dns status is ok', function () { ->assertDontSee('DNS points to 203.0.113.10'); }); +it('polls a queued service dns check and notifies about success', function () { + $domain = 'https://api.example.com'; + $this->apiApp->update([ + 'domain_dns_statuses' => [ + $domain => [ + 'status' => 'checking', + 'message' => 'Checking DNS...', + 'expected_ip' => '203.0.113.10', + 'checked_at' => null, + ], + ], + ]); + + $component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) + ->assertSee('Checking DNS...') + ->assertSee('wire:poll.2000ms="pollDnsChecks"', false); + + $this->apiApp->update([ + 'domain_dns_statuses' => [ + $domain => [ + 'status' => 'ok', + 'message' => 'DNS looks correct.', + 'expected_ip' => '203.0.113.10', + 'checked_at' => now()->toIso8601String(), + ], + ], + ]); + + $component->call('pollDnsChecks') + ->assertSet('domainRows', fn (array $rows): bool => collect($rows)->firstWhere('url', $domain)['dns_status'] === 'ok') + ->assertDispatched('success', 'DNS is configured correctly for api.example.com.'); +}); + it('forbids read-only users from checking service domain dns', function (string $action, array $parameters) { $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']); diff --git a/tests/Feature/SettingsEmailSmtpSetupTest.php b/tests/Feature/SettingsEmailSmtpSetupTest.php index 654b454534..cbe401191d 100644 --- a/tests/Feature/SettingsEmailSmtpSetupTest.php +++ b/tests/Feature/SettingsEmailSmtpSetupTest.php @@ -1,5 +1,6 @@ 'smtp.example.com', 'smtpPort' => '587', 'smtpEncryption' => 'starttls', + 'smtpEhloDomain' => 'coolify.example.com', 'resendEnabled' => false, 'resendApiKey' => null, ]; @@ -56,9 +58,30 @@ test('saving smtp settings does not require a resend api key when resend is disa expect($settings->smtp_enabled)->toBeTrue() ->and($settings->smtp_host)->toBe('smtp.example.com') + ->and($settings->smtp_ehlo_domain)->toBe('coolify.example.com') ->and($settings->resend_enabled)->toBeFalse(); }); +test('team smtp settings save their own ehlo domain', function () { + setupInstanceAdminForEmailSettings(); + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'admin']); + + $this->actingAs($user); + session(['currentTeam' => $team]); + + Livewire::test(NotificationEmail::class) + ->fill(smtpSetupPayload()) + ->set('smtpEnabled', true) + ->call('submitSmtp') + ->assertHasNoErrors() + ->assertNotDispatched('error'); + + expect($team->emailNotificationSettings->fresh()->smtp_ehlo_domain) + ->toBe('coolify.example.com'); +}); + test('saving transactional email settings does not require a resend api key when resend is disabled', function () { $user = setupInstanceAdminForEmailSettings(); diff --git a/tests/Feature/SourceDeletionAtomicityTest.php b/tests/Feature/SourceDeletionAtomicityTest.php new file mode 100644 index 0000000000..46b12757fa --- /dev/null +++ b/tests/Feature/SourceDeletionAtomicityTest.php @@ -0,0 +1,39 @@ +create([ + 'team_id' => $team->id, + 'is_git_related' => true, + ]); +} + +test('github source deletion rolls back unused private key deletion when source deletion fails', function () { + $team = Team::factory()->create(); + $privateKey = sourcePrivateKey($team); + $githubApp = GithubApp::create([ + 'name' => 'GitHub', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'private_key_id' => $privateKey->id, + 'team_id' => $team->id, + ]); + + GithubApp::deleting(function (GithubApp $deletingApp) use ($githubApp): void { + if ($deletingApp->is($githubApp)) { + throw new RuntimeException('Source deletion failed.'); + } + }); + + expect(fn () => $githubApp->delete())->toThrow(RuntimeException::class, 'Source deletion failed.'); + + $this->assertModelExists($githubApp); + $this->assertModelExists($privateKey); +}); diff --git a/tests/Feature/SshMultiplexingLockTest.php b/tests/Feature/SshMultiplexingLockTest.php index f06e50c1a9..45e150dfab 100644 --- a/tests/Feature/SshMultiplexingLockTest.php +++ b/tests/Feature/SshMultiplexingLockTest.php @@ -87,25 +87,22 @@ it('reuses an existing healthy master without spawning a new one', function () { Process::assertNotRan(fn ($process) => str_contains($process->command, 'ssh -fN')); }); -it('refreshes an expired master before reuse', function () { +it('reuses a healthy master regardless of its absolute age', function () { config([ 'constants.ssh.mux_enabled' => true, 'constants.ssh.mux_health_check_enabled' => false, - 'constants.ssh.mux_max_age' => 10, ]); $server = makeMuxServer(); - Cache::put("ssh_mux_connection_time_{$server->uuid}", time() - 30, 3600); + Cache::put("ssh_mux_connection_time_{$server->uuid}", time() - 7200, 10800); Process::fake([ '*-O check*' => Process::result(exitCode: 0), - '*-O exit*' => Process::result(exitCode: 0), - '*-fN *' => Process::result(exitCode: 0), ]); expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue(); - Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -O exit')); - Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN ')); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'ssh -O stop')); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'ssh -fN ')); }); it('does not spawn a master when the per-server lock is already held', function () { @@ -241,6 +238,101 @@ it('kills only old orphaned ssh masters whose control socket no longer exists', File::delete($liveSocket); }); +it('does not reap an ssh master that is intentionally retiring', function () { + config(['constants.ssh.mux_orphan_reap_enabled' => true]); + $muxDir = storage_path('app/ssh/mux'); + $retiringSocket = $muxDir.'/mux_retiring_'.uniqid(); + SshMultiplexingHelper::markMuxProcessAsRetiring('222', $retiringSocket); + + Process::fake([ + 'ps*' => Process::result(output: "222 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$retiringSocket} root@1.2.3.4\n"), + 'kill*' => Process::result(exitCode: 0), + ]); + + $job = new CleanupStaleMultiplexedConnections; + $method = new ReflectionMethod($job, 'cleanupOrphanedSshProcesses'); + $method->setAccessible(true); + $method->invoke($job); + + Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill')); +}); + +it('does not treat a reused pid as retiring', function () { + $socket = storage_path('app/ssh/mux/mux_original'); + + SshMultiplexingHelper::markMuxProcessAsRetiring('222', $socket, '1000'); + + expect(SshMultiplexingHelper::isMuxProcessRetiring('222', $socket, '1000'))->toBeTrue() + ->and(SshMultiplexingHelper::isMuxProcessRetiring('222', $socket, '2000'))->toBeFalse(); +}); + +it('scopes retirement markers to the current host and pid namespace', function () { + $method = new ReflectionMethod(SshMultiplexingHelper::class, 'processScope'); + $method->setAccessible(true); + + expect($method->invoke(null)) + ->toBeString() + ->toStartWith((gethostname() ?: 'unknown').'|'); +}); + +it('keeps a retirement marker for long-running ssh sessions', function () { + $socket = storage_path('app/ssh/mux/mux_retired'); + + SshMultiplexingHelper::markMuxProcessAsRetiring('222', $socket); + $this->travel((int) config('constants.ssh.mux_persist_time') * 2 + 1)->seconds(); + + expect(SshMultiplexingHelper::isMuxProcessRetiring('222', $socket))->toBeTrue(); +}); + +it('reads the process start time used to distinguish pid reuse', function () { + $method = new ReflectionMethod(SshMultiplexingHelper::class, 'processStartTime'); + $method->setAccessible(true); + + expect($method->invoke(null, (string) getmypid()))->toMatch('/^\d+$/'); +}); + +it('marks a successfully stopped mux process as retiring', function () { + $server = makeMuxServer(); + Process::fake([ + '*-O check*' => Process::result(output: 'Master running (pid=222)', exitCode: 0), + '*-O stop*' => Process::result(exitCode: 0), + ]); + + SshMultiplexingHelper::removeMuxFile($server); + + expect(SshMultiplexingHelper::isMuxProcessRetiring('222', "/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}"))->toBeTrue(); +}); + +it('marks a mux process as retiring before stopping it', function () { + $server = makeMuxServer(); + Process::fake([ + '*-O check*' => Process::result(output: 'Master running (pid=555)', exitCode: 0), + '*-O stop*' => function () use ($server) { + expect(SshMultiplexingHelper::isMuxProcessRetiring('555', "/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}"))->toBeTrue(); + + return Process::result(exitCode: 0); + }, + ]); + + SshMultiplexingHelper::removeMuxFile($server); +}); + +it('does not mark a mux process as retiring when stop fails', function () { + $server = makeMuxServer(); + Process::fake([ + '*-O check*' => Process::result(output: 'Master running (pid=444)', exitCode: 0), + '*-O stop*' => function () use ($server) { + expect(SshMultiplexingHelper::isMuxProcessRetiring('444', "/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}"))->toBeTrue(); + + return Process::result(exitCode: 1); + }, + ]); + + SshMultiplexingHelper::removeMuxFile($server); + + expect(SshMultiplexingHelper::isMuxProcessRetiring('444', "/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}"))->toBeFalse(); +}); + it('kills only old orphaned cloudflared proxies whose parent ssh is gone', function () { config(['constants.ssh.mux_orphan_reap_enabled' => true]); @@ -294,7 +386,10 @@ it('removes mux files for non-existent servers when reaping is enabled', functio Storage::fake('ssh-mux'); $file = 'mux_ghost'.uniqid(); Storage::disk('ssh-mux')->put($file, 'x'); - Process::fake(); + Process::fake([ + '*-O check*' => Process::result(errorOutput: 'Master running (pid=333)', exitCode: 0), + '*-O stop*' => Process::result(exitCode: 0), + ]); $job = new CleanupStaleMultiplexedConnections; $method = new ReflectionMethod($job, 'cleanupNonExistentServerConnections'); @@ -302,6 +397,72 @@ it('removes mux files for non-existent servers when reaping is enabled', functio $method->invoke($job); expect(Storage::disk('ssh-mux')->exists($file))->toBeFalse(); + expect(SshMultiplexingHelper::isMuxProcessRetiring('333', "/var/www/html/storage/app/ssh/mux/{$file}"))->toBeTrue(); + Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -O stop')); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'ssh -O exit')); +}); + +it('marks a stale mux process as retiring before stopping it', function () { + config(['constants.ssh.mux_orphan_reap_enabled' => true]); + Storage::fake('ssh-mux'); + $file = 'mux_ghost'.uniqid(); + Storage::disk('ssh-mux')->put($file, 'x'); + Process::fake([ + '*-O check*' => Process::result(output: 'Master running (pid=666)', exitCode: 0), + '*-O stop*' => function () use ($file) { + expect(SshMultiplexingHelper::isMuxProcessRetiring('666', "/var/www/html/storage/app/ssh/mux/{$file}"))->toBeTrue(); + + return Process::result(exitCode: 0); + }, + ]); + + $job = new CleanupStaleMultiplexedConnections; + $method = new ReflectionMethod($job, 'cleanupNonExistentServerConnections'); + $method->setAccessible(true); + $method->invoke($job); +}); + +it('removes a stale mux retirement marker when stopping fails', function () { + config(['constants.ssh.mux_orphan_reap_enabled' => true]); + Storage::fake('ssh-mux'); + $file = 'mux_ghost'.uniqid(); + $muxSocket = "/var/www/html/storage/app/ssh/mux/{$file}"; + Storage::disk('ssh-mux')->put($file, 'x'); + Process::fake([ + '*-O check*' => Process::result(output: 'Master running (pid=777)', exitCode: 0), + '*-O stop*' => function () use ($muxSocket) { + expect(SshMultiplexingHelper::isMuxProcessRetiring('777', $muxSocket))->toBeTrue(); + + return Process::result(exitCode: 1); + }, + ]); + + $job = new CleanupStaleMultiplexedConnections; + $method = new ReflectionMethod($job, 'cleanupNonExistentServerConnections'); + $method->setAccessible(true); + $method->invoke($job); + + expect(SshMultiplexingHelper::isMuxProcessRetiring('777', $muxSocket))->toBeFalse(); +}); + +it('does not remove a healthy mux connection based on its absolute age', function () { + config(['constants.ssh.mux_orphan_reap_enabled' => true]); + Storage::fake('ssh-mux'); + $server = makeMuxServer(); + $file = "mux_{$server->uuid}"; + Storage::disk('ssh-mux')->put($file, str_repeat('x', 37).now()->subHours(2)->toIso8601String()); + + Process::fake([ + '*-O check*' => Process::result(exitCode: 0), + ]); + + $job = new CleanupStaleMultiplexedConnections; + $method = new ReflectionMethod($job, 'cleanupStaleConnections'); + $method->setAccessible(true); + $method->invoke($job); + + expect(Storage::disk('ssh-mux')->exists($file))->toBeTrue(); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'ssh -O stop')); }); it('keeps mux files for non-existent servers in dry-run mode', function () { diff --git a/tests/Feature/SwitchTeamTest.php b/tests/Feature/SwitchTeamTest.php new file mode 100644 index 0000000000..9c4724939a --- /dev/null +++ b/tests/Feature/SwitchTeamTest.php @@ -0,0 +1,32 @@ + 0]); + + $this->user = User::factory()->create(); + $this->currentTeam = $this->user->teams()->first(); + $this->otherTeam = Team::factory()->create(); + $this->otherTeam->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->currentTeam]); +}); + +test('switching teams keeps the current page URL', function () { + $currentUrl = route('security.api-tokens', ['page' => 2]); + + Livewire::test(SwitchTeam::class) + ->call('switch_to', $this->otherTeam->id, $currentUrl) + ->assertRedirect($currentUrl); + + expect(session('currentTeam')->is($this->otherTeam))->toBeTrue(); +}); diff --git a/tests/Feature/Team/TeamDeletionTest.php b/tests/Feature/Team/TeamDeletionTest.php index 6bdcaf4e8a..1c11d13802 100644 --- a/tests/Feature/Team/TeamDeletionTest.php +++ b/tests/Feature/Team/TeamDeletionTest.php @@ -1,11 +1,19 @@ and($sessionTeam->id)->toBe($this->personalTeam->id); }); +test('the danger zone resource list can be refreshed', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->teamToDelete]); + + Livewire::test(DangerZone::class) + ->call('refreshResources') + ->assertSuccessful(); +}); + +test('a team with a running application cannot be deleted', function () { + $server = Server::factory()->create(['team_id' => $this->teamToDelete->id]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->teamToDelete->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'status' => 'running:healthy', + ]); + + $this->actingAs($this->owner); + session(['currentTeam' => $this->teamToDelete]); + + Livewire::test(DangerZone::class) + ->call('delete') + ->assertDispatched('error'); + + expect(Team::find($this->teamToDelete->id))->not->toBeNull(); +}); + +test('a team with a server cannot be deleted', function () { + Server::factory()->create(['team_id' => $this->teamToDelete->id]); + + $this->actingAs($this->owner); + session(['currentTeam' => $this->teamToDelete]); + + Livewire::test(DangerZone::class) + ->call('delete') + ->assertDispatched('error', fn (string $event, array $params): bool => $params[0] === 'Delete all team servers before deleting this team.'); + + expect(Team::find($this->teamToDelete->id))->not->toBeNull(); +}); + +test('a team with a project but no servers cannot be deleted', function () { + Project::factory()->create(['team_id' => $this->teamToDelete->id]); + + $member = User::factory()->create(); + $this->teamToDelete->members()->attach($member->id, ['role' => 'member']); + $privateKey = PrivateKey::factory()->create(['team_id' => $this->teamToDelete->id]); + + $this->actingAs($this->owner); + session(['currentTeam' => $this->teamToDelete]); + + Livewire::test(DangerZone::class) + ->call('delete') + ->assertDispatched('error', fn (string $event, array $params): bool => $params[0] === 'Delete all team resources before deleting this team.'); + + expect(Team::find($this->teamToDelete->id))->not->toBeNull() + ->and(PrivateKey::find($privateKey->id))->not->toBeNull() + ->and($this->teamToDelete->members()->whereKey($member->id)->exists())->toBeTrue(); +}); + +test('an admin cannot delete a team through the deletion action', function () { + $admin = User::factory()->create(); + $this->teamToDelete->members()->attach($admin->id, ['role' => 'admin']); + + expect(fn () => app(DeleteTeam::class)->handle($this->teamToDelete, $admin)) + ->toThrow(AuthorizationException::class); + + expect(Team::find($this->teamToDelete->id))->not->toBeNull(); +}); + +test('a stale owner relationship cannot authorize team deletion', function () { + $this->owner->teams; + $this->owner->teams()->updateExistingPivot($this->teamToDelete->id, ['role' => 'admin']); + + expect(fn () => app(DeleteTeam::class)->handle($this->teamToDelete, $this->owner)) + ->toThrow(AuthorizationException::class); + + expect(Team::find($this->teamToDelete->id))->not->toBeNull(); +}); + test('refreshSession clears session when no team exists', function () { $user = User::factory()->create(); // Detach all teams so user has none @@ -78,3 +169,20 @@ test('deleting a team deletes github and gitlab sources with the same primary ke expect(GithubApp::find($githubApp->id))->toBeNull() ->and(GitlabApp::find($gitlabApp->id))->toBeNull(); }); + +test('team deletion rolls back all database changes when an operation fails', function () { + $member = User::factory()->create(); + $this->teamToDelete->members()->attach($member->id, ['role' => 'member']); + + Team::deleting(function (Team $deletingTeam): void { + if ($deletingTeam->id === $this->teamToDelete->id) { + throw new RuntimeException('Simulated deletion failure.'); + } + }); + + expect(fn () => app(DeleteTeam::class)->handle($this->teamToDelete, $this->owner)) + ->toThrow(RuntimeException::class, 'Simulated deletion failure.'); + + expect(Team::find($this->teamToDelete->id))->not->toBeNull() + ->and($this->teamToDelete->members()->whereKey($member->id)->exists())->toBeTrue(); +}); diff --git a/tests/Feature/TeamCreateSetsMcpDefaultTest.php b/tests/Feature/TeamCreateSetsMcpDefaultTest.php index 6f6c99c49e..b816c0609d 100644 --- a/tests/Feature/TeamCreateSetsMcpDefaultTest.php +++ b/tests/Feature/TeamCreateSetsMcpDefaultTest.php @@ -33,5 +33,6 @@ test('creating a team sets is_mcp_server_enabled to true on the model', function $created = Team::query()->where('name', 'MCP Safe Team')->first(); expect($created)->not->toBeNull() - ->and($created->is_mcp_server_enabled)->toBeTrue(); + ->and($created->is_mcp_server_enabled)->toBeTrue() + ->and($this->user->fresh()->roleInTeam($created->id))->toBe('owner'); }); diff --git a/tests/Feature/TeamInvitationUiTest.php b/tests/Feature/TeamInvitationUiTest.php index 80a4a146df..13b6de23e5 100644 --- a/tests/Feature/TeamInvitationUiTest.php +++ b/tests/Feature/TeamInvitationUiTest.php @@ -73,3 +73,32 @@ it('exposes a resilient global copyToClipboard helper', function () { ->toContain('document.execCommand(\'copy\')') ->toContain('window.isSecureContext'); }); + +it('preserves a provisional user when revoking their invitation fails', function () { + $provisionalUser = User::factory()->create([ + 'email' => 'provisional@example.com', + 'email_verified_at' => null, + 'force_password_reset' => true, + ]); + $invitation = TeamInvitation::create([ + 'team_id' => $this->team->id, + 'uuid' => 'failing-invitation-delete', + 'email' => $provisionalUser->email, + 'role' => 'member', + 'link' => 'http://example.test/invitations/failing-invitation-delete', + 'via' => 'link', + ]); + + TeamInvitation::deleting(function (): void { + throw new RuntimeException('Invitation deletion failed.'); + }); + + Livewire::test(Invitations::class, [ + 'invitations' => collect([$invitation]), + ]) + ->call('deleteInvitation', $invitation->id) + ->assertDispatched('error'); + + $this->assertDatabaseHas('users', ['id' => $provisionalUser->id]); + $this->assertDatabaseHas('team_invitations', ['id' => $invitation->id]); +}); diff --git a/tests/Feature/TeamMemberTwoFactorStatusTest.php b/tests/Feature/TeamMemberTwoFactorStatusTest.php index 0caaf4207f..d9ef52295d 100644 --- a/tests/Feature/TeamMemberTwoFactorStatusTest.php +++ b/tests/Feature/TeamMemberTwoFactorStatusTest.php @@ -91,6 +91,7 @@ test('the member row renders a disabled badge when two factor is not configured' Livewire::test(Member::class, ['member' => $memberWithoutTwoFactor]) ->assertSee('Two-factor authentication is disabled') + ->assertSeeHtml('M18.4697 19.5303') ->assertDontSee('Two-factor authentication is enabled'); }); diff --git a/tests/Feature/TeamOwnerBackfillMigrationTest.php b/tests/Feature/TeamOwnerBackfillMigrationTest.php new file mode 100644 index 0000000000..88e0a7e919 --- /dev/null +++ b/tests/Feature/TeamOwnerBackfillMigrationTest.php @@ -0,0 +1,42 @@ +create(['personal_team' => false]); + $firstMember = User::factory()->create(); + $firstAdmin = User::factory()->create(); + $teamWithoutOwner->members()->attach($firstMember, [ + 'role' => 'member', + 'created_at' => now()->subMinute(), + 'updated_at' => now()->subMinute(), + ]); + $teamWithoutOwner->members()->attach($firstAdmin, [ + 'role' => 'admin', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $teamWithoutAdmin = Team::factory()->create(['personal_team' => false]); + $fallbackMember = User::factory()->create(); + $teamWithoutAdmin->members()->attach($fallbackMember, ['role' => 'member']); + + $teamWithOwner = Team::factory()->create(['personal_team' => false]); + $existingOwner = User::factory()->create(); + $existingAdmin = User::factory()->create(); + $teamWithOwner->members()->attach($existingOwner, ['role' => 'owner']); + $teamWithOwner->members()->attach($existingAdmin, ['role' => 'admin']); + + $migration = require database_path('migrations/2026_08_20_070831_promote_first_team_member_when_team_has_no_owner.php'); + $migration->up(); + + expect($teamWithoutOwner->members()->find($firstMember->id)->pivot->role)->toBe('member') + ->and($teamWithoutOwner->members()->find($firstAdmin->id)->pivot->role)->toBe('owner') + ->and($teamWithoutAdmin->members()->find($fallbackMember->id)->pivot->role)->toBe('owner') + ->and($teamWithOwner->members()->find($existingOwner->id)->pivot->role)->toBe('owner') + ->and($teamWithOwner->members()->find($existingAdmin->id)->pivot->role)->toBe('admin'); +}); diff --git a/tests/Feature/TeamSettingsNavigationTest.php b/tests/Feature/TeamSettingsNavigationTest.php index d6b6f72cf2..da959b9d18 100644 --- a/tests/Feature/TeamSettingsNavigationTest.php +++ b/tests/Feature/TeamSettingsNavigationTest.php @@ -29,8 +29,15 @@ it('uses shared sidebar navigation for every team settings page', function () { ->toContain('Delete team') ->toContain('status="Permanent"') ->toContain('border-red-300') - ->toContain("'Sources' => currentTeam()->sources()") - ->not->toContain("'Sources' => currentTeam()->sources,"); + ->toContain('') + ->toContain('wire:click="refreshResources"') + ->not->toContain('wire:loading.class="animate-spin"') + ->toContain("route('project.show', ['project_uuid' => \$project->uuid])") + ->toContain("route('server.show', ['server_uuid' => \$server->uuid])") + ->toContain('target="_blank" rel="noopener noreferrer"') + ->toContain('Delete every server owned by this team before deleting it.') + ->toContain('currentTeam()->servers->isEmpty()') + ->not->toContain('currentTeam()->isEmpty()'); expect(file_get_contents(resource_path('views/livewire/switch-team.blade.php'))) ->toContain('New team') ->toContain('team-switcher-create-expanded') diff --git a/tests/Feature/TerminalContainerListTest.php b/tests/Feature/TerminalContainerListTest.php new file mode 100644 index 0000000000..ec64da51c9 --- /dev/null +++ b/tests/Feature/TerminalContainerListTest.php @@ -0,0 +1,39 @@ +makePartial(); + $server->forceFill(['uuid' => $uuid, 'name' => $name]); + $server->shouldReceive('isFunctional')->once()->andReturnTrue(); + $server->shouldReceive('loadAllContainers')->once()->andReturn(collect($containers)); + + return $server; +} + +it('keeps containers with the same name on different servers as distinct terminal targets', function () { + $component = new Index; + $component->servers = new Collection([ + terminalServer('pulse-uuid', 'Pulse', [ + ['Names' => 'coolify-proxy', 'State' => 'running'], + ['Names' => 'coolify-sentinel', 'State' => 'running'], + ]), + terminalServer('forge-uuid', 'Forge', [ + ['Names' => 'coolify-proxy', 'State' => 'running'], + ['Names' => 'coolify-sentinel', 'State' => 'running'], + ]), + ]); + + $component->loadContainers(); + + expect($component->containers)->toHaveCount(4) + ->and(collect($component->containers)->pluck('uuid')->all())->toBe([ + 'pulse-uuid:coolify-proxy', + 'forge-uuid:coolify-proxy', + 'pulse-uuid:coolify-sentinel', + 'forge-uuid:coolify-sentinel', + ]); +}); diff --git a/tests/Feature/TraefikVersionStateTest.php b/tests/Feature/TraefikVersionStateTest.php new file mode 100644 index 0000000000..9068683f69 --- /dev/null +++ b/tests/Feature/TraefikVersionStateTest.php @@ -0,0 +1,89 @@ + [ + 'v3.7' => '3.7.8', + 'v3.6' => '3.6.23', + ], + ]); + + $server = Server::factory()->make([ + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], + 'detected_traefik_version' => '3.7.8', + 'traefik_outdated_info' => [ + 'current' => '3.6.23', + 'latest' => '3.7.8', + 'type' => 'minor_upgrade', + 'upgrade_target' => 'v3.7', + ], + ]); + + $component = new Proxy; + $component->server = $server; + + expect($component->getNewerTraefikBranchAvailableProperty())->toBeNull(); +}); + +it('does not mark stale Traefik outdated information as a current warning', function () { + $server = Server::factory()->make([ + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], + 'detected_traefik_version' => '3.7.8', + 'traefik_outdated_info' => [ + 'current' => '3.6.23', + 'latest' => '3.7.8', + 'type' => 'minor_upgrade', + 'upgrade_target' => 'v3.7', + ], + ]); + + expect($server->hasCurrentTraefikOutdatedInfo())->toBeFalse(); +}); + +it('marks matching Traefik outdated information as a current warning', function () { + $server = Server::factory()->make([ + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], + 'detected_traefik_version' => 'v3.6.23', + 'traefik_outdated_info' => [ + 'current' => '3.6.23', + 'latest' => '3.7.8', + 'type' => 'minor_upgrade', + 'upgrade_target' => 'v3.7', + ], + ]); + + expect($server->hasCurrentTraefikOutdatedInfo())->toBeTrue(); +}); + +it('clears stale outdated information before detecting the current version', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'detected_traefik_version' => '3.6.23', + 'traefik_outdated_info' => [ + 'current' => '3.6.23', + 'latest' => '3.7.8', + 'type' => 'minor_upgrade', + 'upgrade_target' => 'v3.7', + ], + ]); + + $job = new CheckTraefikVersionForServerJob($server, ['v3.7' => '3.7.8']); + $method = new ReflectionMethod($job, 'clearOutdatedInfo'); + $method->invoke($job); + + $server->refresh(); + + expect($server->detected_traefik_version)->toBe('3.6.23') + ->and($server->traefik_outdated_info)->toBeNull(); +}); diff --git a/tests/Feature/TransactionalEmailFromNameTest.php b/tests/Feature/TransactionalEmailFromNameTest.php new file mode 100644 index 0000000000..2e5662c61f --- /dev/null +++ b/tests/Feature/TransactionalEmailFromNameTest.php @@ -0,0 +1,121 @@ +create(['id' => 0]); + InstanceSettings::forceCreate([ + 'id' => 0, + 'smtp_enabled' => true, + 'smtp_from_address' => 'admin@example.com', + 'smtp_from_name' => 'admin', + 'smtp_host' => 'coolify-mail', + 'smtp_port' => 1025, + ]); + Once::flush(); + + $user = User::factory()->create(); + $rootTeam->members()->attach($user->id, ['role' => 'admin']); + + return $user; +} + +test('saving transactional sender persists the configured from name', function () { + $user = setupTransactionalEmailFromNameAdmin(); + + $this->actingAs($user); + session(['currentTeam' => ['id' => 0]]); + + Livewire::test(SettingsEmail::class) + ->set('smtpFromName', 'Coolify') + ->set('smtpFromAddress', 'admin@example.com') + ->call('submit') + ->assertHasNoErrors(); + + Once::flush(); + + expect(instanceSettings()->smtp_from_name)->toBe('Coolify') + ->and(instanceSettings()->smtp_from_address)->toBe('admin@example.com'); +}); + +test('sending a test email persists the current from name before delivery', function () { + $user = setupTransactionalEmailFromNameAdmin(); + + $this->actingAs($user); + session(['currentTeam' => ['id' => 0]]); + + Notification::fake(); + + Livewire::test(SettingsEmail::class) + ->set('smtpFromName', 'Coolify') + ->set('smtpFromAddress', 'admin@example.com') + ->set('testEmailAddress', $user->email) + ->call('sendTestEmail') + ->assertHasNoErrors(); + + Once::flush(); + + expect(instanceSettings()->smtp_from_name)->toBe('Coolify'); +}); + +test('transactional emails send with the configured from name instead of the address local part', function () { + setupTransactionalEmailFromNameAdmin(); + + InstanceSettings::findOrFail(0)->update([ + 'smtp_from_name' => 'Coolify', + 'smtp_from_address' => 'admin@example.com', + ]); + Once::flush(); + + config([ + 'mail.default' => 'array', + 'mail.from.address' => 'hello@example.com', + 'mail.from.name' => 'Example', + ]); + Mail::purge(); + Mail::mailer(); + + $this->mock(ConfigurationRepository::class, function ($mock) { + $mock->shouldReceive('updateMailConfig')->andReturnUsing(function ($settings) { + config([ + 'mail.from.address' => $settings->smtp_from_address, + 'mail.from.name' => $settings->smtp_from_name, + ]); + }); + }); + + $user = User::factory()->create(['email' => 'recipient@example.com']); + $notification = new EmailChangeVerification( + $user, + '123456', + 'new@example.com', + now()->addMinutes(10), + ); + + $channel = new TransactionalEmailChannel; + $channel->send($user, $notification); + + $messages = app('mail.manager')->mailer('array')->getSymfonyTransport()->messages(); + + expect($messages)->not->toBeEmpty(); + + $from = $messages->first()->getOriginalMessage()->getFrom()[0]; + + expect($from->getAddress())->toBe('admin@example.com') + ->and($from->getName())->toBe('Coolify') + ->and($from->getName())->not->toBe('admin'); +}); diff --git a/tests/Feature/UpgradeComponentTest.php b/tests/Feature/UpgradeComponentTest.php index dc14516a53..306a486de7 100644 --- a/tests/Feature/UpgradeComponentTest.php +++ b/tests/Feature/UpgradeComponentTest.php @@ -64,18 +64,34 @@ it('treats a brief upgrade poll miss as a reconnect, not a lost-contact failure' ->not->toContain('Lost contact with Coolify'); }); -it('uses sidebar state css instead of nested alpine state for upgrade labels', function () { +it('renders the upgrade control in the mobile top bar', function () { + $layout = file_get_contents(resource_path('views/layouts/app.blade.php')); + + expect($layout) + ->toMatch('/MOBILE TOP BAR[\s\S]*?]*>[\s\S]*?Open sidebar/') + ->toContain('key="mobile-upgrade"'); +}); + +it('supports a full size update button for settings', function () { $upgradeView = file_get_contents(resource_path('views/livewire/upgrade.blade.php')); - $utilitiesCss = file_get_contents(resource_path('css/utilities.css')); + $settingsView = file_get_contents(resource_path('views/livewire/settings/updates.blade.php')); expect($upgradeView) - ->toContain('class="text-left menu-item-label sidebar-collapsed-label"') - ->toContain('>In progress') - ->toContain('>Upgrade') - ->not->toContain(':class="collapsed && \'lg:hidden\'"') - ->and($utilitiesCss) - ->toContain('.sidebar-collapsed .sidebar-collapsed-label') - ->toContain('display: none;'); + ->toContain('$fullButton') + ->toContain('Upgrade now') + ->and($settingsView) + ->toContain('Update Coolify') + ->toContain(':full-button="true"') + ->toContain('key="settings-upgrade"'); +}); + +it('uses compact labels that do not depend on desktop sidebar state', function () { + $upgradeView = file_get_contents(resource_path('views/livewire/upgrade.blade.php')); + + expect($upgradeView) + ->toContain('Updating') + ->toContain('Update available') + ->not->toContain(':class="collapsed'); }); it('falls back to 0.0.0 during mount when cached versions data is unavailable', function () { diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php index 4968945916..aa7da543be 100644 --- a/tests/Feature/VolumeBackupTest.php +++ b/tests/Feature/VolumeBackupTest.php @@ -6,6 +6,7 @@ use App\Jobs\VolumeBackupJob; use App\Jobs\VolumeBackupRecoveryJob; use App\Livewire\Project\Application\Backup\Create as CreateScheduledVolumeBackup; use App\Livewire\Project\Service\FileStorage; +use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup; use App\Livewire\Project\Shared\Storages\Show; use App\Livewire\Project\Shared\Storages\VolumeBackups; use App\Models\Application; @@ -20,6 +21,7 @@ use App\Models\ScheduledVolumeBackup; use App\Models\ScheduledVolumeBackupExecution; use App\Models\Server; use App\Models\Service; +use App\Models\ServiceApplication; use App\Models\ServiceDatabase; use App\Models\StandaloneDocker; use App\Models\Team; @@ -51,6 +53,43 @@ it('provides the volume backup domain classes and relationship', function () { ->and(method_exists(LocalFileVolume::class, 'scheduledBackups'))->toBeTrue(); }); +it('allows large volume backups to run for ten hours by default', function () { + $backup = new ScheduledVolumeBackup; + $job = new VolumeBackupJob($backup); + + expect($job->timeout)->toBe(36000) + ->and((new VolumeBackups)->timeout)->toBe(36000) + ->and(config('horizon.defaults.s6.timeout'))->toBeGreaterThan($job->timeout) + ->and(config('queue.connections.redis.retry_after'))->toBeGreaterThan(config('horizon.defaults.s6.timeout')); +}); + +it('changes the default volume backup timeout without changing existing timeouts', function () { + $team = Team::factory()->create(); + [$application, $defaultVolume] = createVolumeBackupApplication($team); + $customVolume = LocalPersistentVolume::create([ + 'name' => 'custom-timeout-data', + 'mount_path' => '/custom-data', + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + $defaultBackup = $defaultVolume->scheduledBackups()->create([ + 'team_id' => $team->id, + 'frequency' => 'daily', + 'timeout' => 3600, + ]); + $customBackup = $customVolume->scheduledBackups()->create([ + 'team_id' => $team->id, + 'frequency' => 'daily', + 'timeout' => 7200, + ]); + + $migration = require database_path('migrations/2026_08_15_000000_increase_default_volume_backup_timeout.php'); + $migration->up(); + + expect($defaultBackup->fresh()->timeout)->toBe(3600) + ->and($customBackup->fresh()->timeout)->toBe(7200); +}); + it('includes parallel gzip support in the Coolify helper image', function () { $dockerfile = file_get_contents(base_path('docker/coolify-helper/Dockerfile')); @@ -150,6 +189,45 @@ it('creates a scheduled backup with a preselected volume from the shared modal', ->and($backup->s3_storage_id)->toBeNull(); }); +it('shows readable service storage backup target labels', function () { + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application] = createVolumeBackupApplication($team); + $service = Service::factory()->create([ + 'environment_id' => $application->environment_id, + 'destination_id' => $application->destination_id, + 'destination_type' => $application->destination_type, + ]); + $resource = ServiceApplication::create([ + 'uuid' => new_public_id(), + 'name' => 'directus', + 'service_id' => $service->id, + ]); + LocalPersistentVolume::create([ + 'name' => $service->uuid.'_directus-templates', + 'mount_path' => '/directus/templates', + 'resource_id' => $resource->id, + 'resource_type' => $resource->getMorphClass(), + ]); + LocalFileVolume::unguarded(fn () => LocalFileVolume::withoutEvents(fn () => LocalFileVolume::create([ + 'uuid' => new_public_id(), + 'fs_path' => './uploads', + 'mount_path' => '/directus/uploads', + 'is_directory' => true, + 'is_based_on_git' => false, + 'is_preview_suffix_enabled' => true, + 'resource_id' => $resource->id, + 'resource_type' => $resource->getMorphClass(), + ]))); + + Livewire::test(CreateServiceVolumeBackup::class, ['service' => $service]) + ->assertSet('targets.0.name', 'directus-templates') + ->assertSet('targets.0.type', 'Directus') + ->assertSet('targets.1.name', './uploads (directory)') + ->assertSet('targets.1.type', 'Directus') + ->assertSee('Directus: directus-templates'); +}); + it('handles scheduled backup persistence failures', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); diff --git a/tests/Pest.php b/tests/Pest.php index 7100bfaa72..25d9440108 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -27,6 +27,23 @@ uses(TestCase::class)->in('Feature', 'v4/Feature', 'v4/Browser', 'v5/Browser'); require_once __DIR__.'/Support/BrowserTestHelpers.php'; +function remoteOutputSource(string $path): string +{ + $fixturePath = dirname(__DIR__).'/'.$path; + + if (! is_readable($fixturePath)) { + throw new RuntimeException("Unable to read source fixture: {$fixturePath}"); + } + + $source = file_get_contents($fixturePath); + + if ($source === false) { + throw new RuntimeException("Unable to read source fixture: {$fixturePath}"); + } + + return $source; +} + /* |-------------------------------------------------------------------------- | Test Hooks diff --git a/tests/Unit/Api/LogEndpointHelpersTest.php b/tests/Unit/Api/LogEndpointHelpersTest.php index a899669d67..c4173bd699 100644 --- a/tests/Unit/Api/LogEndpointHelpersTest.php +++ b/tests/Unit/Api/LogEndpointHelpersTest.php @@ -94,6 +94,18 @@ it('filters service sub containers in PHP instead of using user input in shell f ->toBe(['first', 'third']); }); +it('filters service containers when another Docker label contains commas', function () { + $containers = collect([ + [ + 'ID' => 'app', + 'Labels' => 'traefik.http.routers.app.rule=Host(`one.example.com`,`two.example.com`),coolify.name=app-service-uuid', + ], + ]); + + expect(filterServiceSubContainersByName($containers, 'app-service-uuid')->pluck('ID')->all()) + ->toBe(['app']); +}); + it('does not interpolate the requested service name into the docker ps shell command', function () { $source = file_get_contents(__DIR__.'/../../../bootstrap/helpers/docker.php'); diff --git a/tests/Unit/ConfigurationRepositoryMailConfigTest.php b/tests/Unit/ConfigurationRepositoryMailConfigTest.php new file mode 100644 index 0000000000..ebecda64ae --- /dev/null +++ b/tests/Unit/ConfigurationRepositoryMailConfigTest.php @@ -0,0 +1,55 @@ +updateMailConfig((object) [ + 'resend_enabled' => false, + 'smtp_enabled' => true, + 'smtp_encryption' => $mode, + 'smtp_host' => 'smtp.example.com', + 'smtp_port' => $port, + 'smtp_username' => 'user', + 'smtp_password' => 'secret', + 'smtp_timeout' => null, + 'smtp_from_address' => 'from@example.com', + 'smtp_from_name' => 'Coolify', + ]); + + expect($config->get('mail.mailers.smtp.scheme'))->toBe($scheme) + ->and($config->get('mail.mailers.smtp.encryption'))->toBe($encryption) + ->and($config->get('mail.mailers.smtp.auto_tls'))->toBe($autoTls); +})->with([ + 'none on port 465' => ['none', 465, 'smtp', null, '0'], + 'tls on a non-465 port' => ['tls', 587, 'smtps', 'tls', ''], +]); + +it('preserves the configured smtp ehlo domain', function () { + $config = new Repository([ + 'mail' => [ + 'mailers' => [ + 'smtp' => ['local_domain' => 'coolify.example.com'], + ], + ], + ]); + $repository = new ConfigurationRepository($config); + + $repository->updateMailConfig((object) [ + 'resend_enabled' => false, + 'smtp_enabled' => true, + 'smtp_encryption' => 'starttls', + 'smtp_host' => 'smtp-relay.gmail.com', + 'smtp_port' => 587, + 'smtp_username' => null, + 'smtp_password' => null, + 'smtp_timeout' => null, + 'smtp_from_address' => 'from@example.com', + 'smtp_from_name' => 'Coolify', + ]); + + expect($config->get('mail.mailers.smtp.local_domain'))->toBe('coolify.example.com'); +}); diff --git a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php index afad0593f3..b7901abb68 100644 --- a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php +++ b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php @@ -296,6 +296,25 @@ it('accepts the historical environment sorting default in older snapshots', func expect(app(ConfigurationDiffer::class)->diff($previousSnapshot, $currentSnapshot)->isChanged())->toBeFalse(); }); +it('does not report newly tracked static configuration as a pending change', function () { + $application = snapshotTestApplication(); + $currentSnapshot = $application->deploymentConfigurationSnapshot(); + $previousSnapshot = $currentSnapshot; + + data_set($currentSnapshot, 'sections.runtime.items', [ + ...data_get($currentSnapshot, 'sections.runtime.items'), + [ + 'key' => 'newly_tracked_setting', + 'label' => 'Newly tracked setting', + 'impact' => 'redeploy', + 'compare_value' => 'already configured', + 'display_value' => 'already configured', + ], + ]); + + expect(app(ConfigurationDiffer::class)->diff($previousSnapshot, $currentSnapshot)->isChanged())->toBeFalse(); +}); + it('detects environment variable value changes without exposing secret values', function () { $application = snapshotTestApplication(); EnvironmentVariable::create([ diff --git a/tests/Unit/DevHelperScriptTest.php b/tests/Unit/DevHelperScriptTest.php new file mode 100644 index 0000000000..eee1803826 --- /dev/null +++ b/tests/Unit/DevHelperScriptTest.php @@ -0,0 +1,19 @@ +toBeFile() + ->and(is_executable($script))->toBeTrue(); + + exec('bash -n '.escapeshellarg($script), $output, $exitCode); + + expect($exitCode)->toBe(0) + ->and(file_get_contents($script)) + ->toContain('build)') + ->toContain('use)') + ->toContain('verify)') + ->toContain('deploy)') + ->toContain('reset)') + ->toContain('test)'); +}); diff --git a/tests/Unit/DnsQueryTimeoutTest.php b/tests/Unit/DnsQueryTimeoutTest.php new file mode 100644 index 0000000000..ce0140a090 --- /dev/null +++ b/tests/Unit/DnsQueryTimeoutTest.php @@ -0,0 +1,10 @@ +getProperty('timeout')->getValue($query); + + expect($timeout)->toBe(5); +}); diff --git a/tests/Unit/DockerCliPackagingTest.php b/tests/Unit/DockerCliPackagingTest.php new file mode 100644 index 0000000000..71fa7078e1 --- /dev/null +++ b/tests/Unit/DockerCliPackagingTest.php @@ -0,0 +1,12 @@ +toContain('ARG DOCKER_VERSION=29.7.2') + ->toContain('ARG DOCKER_COMPOSE_VERSION=5.5.0') + ->toContain('ARG DOCKER_BUILDX_VERSION=0.36.1') + ->toContain('https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz') + ->toContain('https://download.docker.com/linux/static/stable/aarch64/docker-${DOCKER_VERSION}.tgz') + ->toMatch('/chmod \+x [^\n]*\/usr\/bin\/docker/'); +}); diff --git a/tests/Unit/DockerImagePreviewTagResolutionTest.php b/tests/Unit/DockerImagePreviewTagResolutionTest.php index 17f7d7b9b4..bb9a2c09dd 100644 --- a/tests/Unit/DockerImagePreviewTagResolutionTest.php +++ b/tests/Unit/DockerImagePreviewTagResolutionTest.php @@ -27,6 +27,102 @@ it('prefers the preview specific docker image tag for preview deployments', func expect($method->invoke($job))->toBe('pr_42'); }); +it('prefers the selected tag for docker image rollback deployments', function () { + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + $job = $reflection->newInstanceWithoutConstructor(); + + $pullRequestProperty = $reflection->getProperty('pull_request_id'); + $pullRequestProperty->setAccessible(true); + $pullRequestProperty->setValue($job, 0); + + $rollbackProperty = $reflection->getProperty('rollback'); + $rollbackProperty->setAccessible(true); + $rollbackProperty->setValue($job, true); + + $commitProperty = $reflection->getProperty('commit'); + $commitProperty->setAccessible(true); + $commitProperty->setValue($job, 'e487515a098c8d4f69c74712f1d0cc86e483e890'); + + $applicationProperty = $reflection->getProperty('application'); + $applicationProperty->setAccessible(true); + $applicationProperty->setValue($job, new Application([ + 'docker_registry_image_tag' => '6de68657a0e794e163ea2275c54c293a93fe065a', + ])); + + $previewTagProperty = $reflection->getProperty('dockerImagePreviewTag'); + $previewTagProperty->setAccessible(true); + $previewTagProperty->setValue($job, null); + + $method = $reflection->getMethod('resolveDockerImageTag'); + $method->setAccessible(true); + + expect($method->invoke($job))->toBe('e487515a098c8d4f69c74712f1d0cc86e483e890'); +}); + +it('falls back to the application docker image tag for rollbacks without a selected tag', function () { + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + $job = $reflection->newInstanceWithoutConstructor(); + + $pullRequestProperty = $reflection->getProperty('pull_request_id'); + $pullRequestProperty->setAccessible(true); + $pullRequestProperty->setValue($job, 0); + + $rollbackProperty = $reflection->getProperty('rollback'); + $rollbackProperty->setAccessible(true); + $rollbackProperty->setValue($job, true); + + $commitProperty = $reflection->getProperty('commit'); + $commitProperty->setAccessible(true); + $commitProperty->setValue($job, ''); + + $applicationProperty = $reflection->getProperty('application'); + $applicationProperty->setAccessible(true); + $applicationProperty->setValue($job, new Application([ + 'docker_registry_image_tag' => 'stable', + ])); + + $previewTagProperty = $reflection->getProperty('dockerImagePreviewTag'); + $previewTagProperty->setAccessible(true); + $previewTagProperty->setValue($job, null); + + $method = $reflection->getMethod('resolveDockerImageTag'); + $method->setAccessible(true); + + expect($method->invoke($job))->toBe('stable'); +}); + +it('falls back to latest for rollbacks without selected or configured tags', function () { + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + $job = $reflection->newInstanceWithoutConstructor(); + + $pullRequestProperty = $reflection->getProperty('pull_request_id'); + $pullRequestProperty->setAccessible(true); + $pullRequestProperty->setValue($job, 0); + + $rollbackProperty = $reflection->getProperty('rollback'); + $rollbackProperty->setAccessible(true); + $rollbackProperty->setValue($job, true); + + $commitProperty = $reflection->getProperty('commit'); + $commitProperty->setAccessible(true); + $commitProperty->setValue($job, ''); + + $applicationProperty = $reflection->getProperty('application'); + $applicationProperty->setAccessible(true); + $applicationProperty->setValue($job, new Application([ + 'docker_registry_image_tag' => '', + ])); + + $previewTagProperty = $reflection->getProperty('dockerImagePreviewTag'); + $previewTagProperty->setAccessible(true); + $previewTagProperty->setValue($job, null); + + $method = $reflection->getMethod('resolveDockerImageTag'); + $method->setAccessible(true); + + expect($method->invoke($job))->toBe('latest'); +}); + it('falls back to the application docker image tag for non preview deployments', function () { $reflection = new ReflectionClass(ApplicationDeploymentJob::class); $job = $reflection->newInstanceWithoutConstructor(); @@ -35,6 +131,10 @@ it('falls back to the application docker image tag for non preview deployments', $pullRequestProperty->setAccessible(true); $pullRequestProperty->setValue($job, 0); + $rollbackProperty = $reflection->getProperty('rollback'); + $rollbackProperty->setAccessible(true); + $rollbackProperty->setValue($job, false); + $applicationProperty = $reflection->getProperty('application'); $applicationProperty->setAccessible(true); $applicationProperty->setValue($job, new Application([ @@ -59,6 +159,10 @@ it('falls back to latest when neither preview nor application tags are set', fun $pullRequestProperty->setAccessible(true); $pullRequestProperty->setValue($job, 7); + $rollbackProperty = $reflection->getProperty('rollback'); + $rollbackProperty->setAccessible(true); + $rollbackProperty->setValue($job, false); + $applicationProperty = $reflection->getProperty('application'); $applicationProperty->setAccessible(true); $applicationProperty->setValue($job, new Application([ diff --git a/tests/Unit/DockerRemoveWithTimeoutTest.php b/tests/Unit/DockerRemoveWithTimeoutTest.php new file mode 100644 index 0000000000..1f09ae06cb --- /dev/null +++ b/tests/Unit/DockerRemoveWithTimeoutTest.php @@ -0,0 +1,196 @@ +toStartWith("bash -c '") + ->toContain('timeout -k 10s 60s docker rm -f') + ->toContain('__COOLIFY_CONTAINER_REMOVE_TIMEOUT__'); +}); + +it('uses timeout syntax supported by coreutils and busybox', function () { + expect(dockerRemoveCommandWithTimeout('container-name')) + ->toContain('command -v timeout') + ->toContain('timeout -k 10s 60s') + ->not->toContain('--kill-after'); +}); + +it('preserves bounded cleanup when commands are adapted for non-root servers', function () { + $server = Mockery::mock(Server::class)->makePartial(); + $server->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu'); + $server->shouldReceive('setAttribute')->andReturnSelf(); + $server->user = 'ubuntu'; + + $commands = parseCommandsByLineForSudo( + collect([dockerRemoveCommandWithTimeout('container-name')]), + $server + ); + $command = $commands[0]; + + expect($command) + ->toStartWith("sudo bash -c '") + ->toContain('timeout -k 10s 60s docker rm -f') + ->toContain('__COOLIFY_CONTAINER_REMOVE_TIMEOUT__'); +}); + +it('escapes container names in bounded removal commands', function () { + $containerName = "container'; reboot; '"; + $directory = sys_get_temp_dir().'/coolify-docker-remove-'.bin2hex(random_bytes(4)); + $captureFile = $directory.'/arguments'; + mkdir($directory); + file_put_contents($directory.'/docker', "#!/bin/sh\nprintf '%s\\n' \"\$@\" > \"\$CAPTURE_FILE\"\n"); + chmod($directory.'/docker', 0755); + + $process = new Process(['/bin/sh', '-c', dockerRemoveCommandWithTimeout($containerName)], env: [ + 'PATH' => $directory.':'.getenv('PATH'), + 'CAPTURE_FILE' => $captureFile, + ]); + $process->run(); + + expect($process->isSuccessful())->toBeTrue() + ->and(file_get_contents($captureFile))->toBe("rm\n-f\n{$containerName}\n"); + + unlink($captureFile); + unlink($directory.'/docker'); + rmdir($directory); +}); + +it('succeeds when the container was already removed', function () { + $directory = sys_get_temp_dir().'/coolify-docker-remove-'.bin2hex(random_bytes(4)); + mkdir($directory); + file_put_contents($directory.'/docker', "#!/bin/sh\necho 'Error response from daemon: No such container: container-name' >&2\nexit 1\n"); + chmod($directory.'/docker', 0755); + + $process = new Process(['/bin/sh', '-c', dockerRemoveCommandWithTimeout('container-name')], env: [ + 'PATH' => $directory.':'.getenv('PATH'), + ]); + $process->run(); + + expect($process->isSuccessful())->toBeTrue(); + + unlink($directory.'/docker'); + rmdir($directory); +}); + +it('reports a timeout when timeout output also says the container is missing', function () { + $directory = sys_get_temp_dir().'/coolify-docker-remove-'.bin2hex(random_bytes(4)); + mkdir($directory); + file_put_contents($directory.'/timeout', "#!/bin/sh\necho 'Error response from daemon: No such container: container-name'\nexit 124\n"); + chmod($directory.'/timeout', 0755); + + $process = new Process(['/bin/sh', '-c', dockerRemoveCommandWithTimeout('container-name')], env: [ + 'PATH' => $directory.':'.getenv('PATH'), + ]); + $process->run(); + + expect($process->getExitCode())->toBe(124) + ->and($process->getOutput())->toContain('__COOLIFY_CONTAINER_REMOVE_TIMEOUT__'); + + unlink($directory.'/timeout'); + rmdir($directory); +}); + +it('fails when Docker cannot remove an existing container', function () { + $directory = sys_get_temp_dir().'/coolify-docker-remove-'.bin2hex(random_bytes(4)); + mkdir($directory); + file_put_contents($directory.'/docker', "#!/bin/sh\necho 'Error response from daemon: removal already in progress' >&2\nexit 1\n"); + chmod($directory.'/docker', 0755); + + $process = new Process(['/bin/sh', '-c', dockerRemoveCommandWithTimeout('container-name')], env: [ + 'PATH' => $directory.':'.getenv('PATH'), + ]); + $process->run(); + + expect($process->isSuccessful())->toBeFalse() + ->and($process->getErrorOutput())->toContain('removal already in progress'); + + unlink($directory.'/docker'); + rmdir($directory); +}); + +it('makes regular container removal idempotent without hiding other failures', function () { + $directory = sys_get_temp_dir().'/coolify-docker-remove-'.bin2hex(random_bytes(4)); + mkdir($directory); + file_put_contents($directory.'/docker', "#!/bin/sh\necho \"\$DOCKER_ERROR\" >&2\nexit 1\n"); + chmod($directory.'/docker', 0755); + + $missingContainer = new Process(['/bin/sh', '-c', dockerRemoveCommand('container name')], env: [ + 'PATH' => $directory.':'.getenv('PATH'), + 'DOCKER_ERROR' => 'Error response from daemon: No such container: container name', + ]); + $missingContainer->run(); + $realFailure = new Process(['/bin/sh', '-c', dockerRemoveCommand('container name')], env: [ + 'PATH' => $directory.':'.getenv('PATH'), + 'DOCKER_ERROR' => 'Error response from daemon: removal already in progress', + ]); + $realFailure->run(); + + expect($missingContainer->isSuccessful())->toBeTrue() + ->and($realFailure->isSuccessful())->toBeFalse(); + + unlink($directory.'/docker'); + rmdir($directory); +}); + +it('makes network removal idempotent without hiding other failures', function () { + $directory = sys_get_temp_dir().'/coolify-docker-remove-'.bin2hex(random_bytes(4)); + mkdir($directory); + file_put_contents($directory.'/docker', "#!/bin/sh\necho \"\$DOCKER_ERROR\" >&2\nexit 1\n"); + chmod($directory.'/docker', 0755); + + $missingNetwork = new Process(['/bin/sh', '-c', dockerNetworkRemoveCommand('network name')], env: [ + 'PATH' => $directory.':'.getenv('PATH'), + 'DOCKER_ERROR' => 'Error response from daemon: network network name not found', + ]); + $missingNetwork->run(); + $realFailure = new Process(['/bin/sh', '-c', dockerNetworkRemoveCommand('network name')], env: [ + 'PATH' => $directory.':'.getenv('PATH'), + 'DOCKER_ERROR' => 'Error response from daemon: network has active endpoints', + ]); + $realFailure->run(); + + expect($missingNetwork->isSuccessful())->toBeTrue() + ->and($realFailure->isSuccessful())->toBeFalse(); + + unlink($directory.'/docker'); + rmdir($directory); +}); + +it('uses idempotent commands in strict cleanup paths', function () { + $root = dirname(__DIR__, 2); + + expect(file_get_contents($root.'/app/Jobs/DatabaseBackupJob.php')) + ->toContain('dockerRemoveCommand("backup-of-{$this->backup_log_uuid}")') + ->and(file_get_contents($root.'/app/Actions/Database/StopDatabaseProxy.php')) + ->toContain('dockerRemoveCommand("{$uuid}-proxy")') + ->and(file_get_contents($root.'/app/Actions/Destination/RemoveStandaloneDockerNetwork.php')) + ->toContain('dockerNetworkRemoveCommand($destination->network)') + ->and(file_get_contents($root.'/app/Livewire/Destination/Show.php')) + ->toContain('dockerNetworkRemoveCommand($this->destination->network)') + ->and(file_get_contents($root.'/app/Listeners/ProxyStatusChangedNotification.php')) + ->toContain("dockerRemoveCommand('coolify-proxy')") + ->and(file_get_contents($root.'/app/Actions/Application/StopApplicationOneServer.php')) + ->toContain('dockerRemoveCommand($containerName)'); +}); + +it('configures deferred removal attempts to outlive the shell timeout', function () { + $job = new RemoveContainerJob(123, 'container-name'); + + expect($job->serverId)->toBe(123) + ->and($job->containerName)->toBe('container-name') + ->and($job->timeout)->toBeGreaterThan(60) + ->and($job->tries)->toBeGreaterThan(1); +}); + +it('continues deployments and schedules deferred cleanup after a removal timeout', function () { + $source = file_get_contents(dirname(__DIR__, 2).'/app/Jobs/ApplicationDeploymentJob.php'); + + expect($source) + ->toContain('dockerRemoveCommandWithTimeout($containerName)') + ->toContain('timed out after 60 seconds. The deployment will continue') + ->toContain('RemoveContainerJob::dispatch($this->server->id, $containerName)') + ->toContain('->delay(now()->addMinutes(5))'); +}); diff --git a/tests/Unit/DomainUrlPartsTest.php b/tests/Unit/DomainUrlPartsTest.php index 086599a1a2..5c2994934c 100644 --- a/tests/Unit/DomainUrlPartsTest.php +++ b/tests/Unit/DomainUrlPartsTest.php @@ -28,3 +28,8 @@ it('defaults empty values for an invalid URL', function () { it('preserves an explicitly configured default port', function () { expect(DomainUrlParts::split('https://app.example.com:443')['port'])->toBe('443'); }); + +it('composes a domain when Livewire hydrates an empty numeric port as null', function () { + expect(DomainUrlParts::compose('https', 'app.example.com', null)) + ->toBe('https://app.example.com'); +}); diff --git a/tests/Unit/Livewire/Database/S3RestoreTest.php b/tests/Unit/Livewire/Database/S3RestoreTest.php index 77695a8d76..e961f1317c 100644 --- a/tests/Unit/Livewire/Database/S3RestoreTest.php +++ b/tests/Unit/Livewire/Database/S3RestoreTest.php @@ -16,6 +16,76 @@ function importFormWithResource(string $modelClass): ImportForm return $component; } +/** + * @return list + */ +function invokedPostgresRestoreClients(string $contents, bool $gzip = false): array +{ + $binDir = sys_get_temp_dir().'/coolify-pg-restore-'.bin2hex(random_bytes(8)); + mkdir($binDir); + + try { + $logFile = $binDir.'/invoked.log'; + file_put_contents($logFile, ''); + + foreach (['pg_restore', 'psql'] as $tool) { + $stub = <<> '{$logFile}' +if [ ! -t 0 ]; then + cat >/dev/null +fi +exit 0 +SH; + $path = $binDir.'/'.$tool; + file_put_contents($path, $stub); + chmod($path, 0755); + } + + $dumpPath = $binDir.'/backup.dump'; + $payload = $gzip ? gzencode($contents) : $contents; + expect($payload)->not->toBeFalse(); + file_put_contents($dumpPath, $payload); + + $component = importFormWithResource('App\Models\StandalonePostgresql'); + $component->dumpAll = true; + $component->postgresqlRestoreCommand = ':'; + + $process = proc_open( + ['sh', '-c', $component->buildRestoreCommand($dumpPath)], + [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], + $pipes, + $binDir, + [ + 'PATH' => $binDir.PATH_SEPARATOR.(getenv('PATH') ?: '/usr/bin:/bin'), + 'POSTGRES_USER' => 'postgres', + 'POSTGRES_DB' => 'app', + ] + ); + + expect($process)->not->toBeFalse(); + + fclose($pipes[0]); + stream_get_contents($pipes[1]); + stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + expect(proc_close($process))->toBe(0); + + return array_values(array_filter(explode("\n", trim((string) file_get_contents($logFile))))); + } finally { + foreach (glob($binDir.'/*') ?: [] as $file) { + unlink($file); + } + rmdir($binDir); + } +} + test('buildRestoreCommand handles PostgreSQL without dumpAll', function () { $component = importFormWithResource('App\Models\StandalonePostgresql'); $component->dumpAll = false; @@ -38,6 +108,27 @@ test('buildRestoreCommand handles PostgreSQL with dumpAll', function () { expect($result)->toContain('psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'); }); +test('buildRestoreCommand dump-all PostgreSQL restore is pg_restore for PGDMP otherwise psql', function () { + $component = importFormWithResource('App\Models\StandalonePostgresql'); + $component->dumpAll = true; + $component->postgresqlRestoreCommand = 'psql -U ${POSTGRES_USER} -c "cleanup"'; + + $escapedTmpPath = escapeshellarg('/tmp/test.dump'); + + expect($component->buildRestoreCommand('/tmp/test.dump'))->toBe( + 'psql -U ${POSTGRES_USER} -c "cleanup" && if [ "$({ gunzip -cf '.$escapedTmpPath.' 2>/dev/null || cat '.$escapedTmpPath.'; } | head -c 5)" = \'PGDMP\' ]; then pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}} '.$escapedTmpPath.'; else (gunzip -cf '.$escapedTmpPath.' 2>/dev/null || cat '.$escapedTmpPath.') | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}; fi' + ); +}); + +test('dump-all PostgreSQL restore selects the client for the dump format', function (string $contents, bool $gzip, string $client) { + expect(invokedPostgresRestoreClients($contents, $gzip))->toBe([$client]); +})->with([ + 'custom archive' => ['PGDMP'.str_repeat("\0", 16), false, 'pg_restore'], + 'gzip custom archive' => ['PGDMP'.str_repeat("\0", 16), true, 'pg_restore'], + 'plain SQL' => ["-- PostgreSQL database dump\nSELECT 1;\n", false, 'psql'], + 'gzip SQL' => ["-- PostgreSQL database dump\nSELECT 1;\n", true, 'psql'], +]); + test('buildRestoreCommand handles MySQL without dumpAll', function () { $component = importFormWithResource('App\Models\StandaloneMysql'); $component->dumpAll = false; diff --git a/tests/Unit/LocalFileVolumeContentSizeTest.php b/tests/Unit/LocalFileVolumeContentSizeTest.php index 1fd315884e..2a22234012 100644 --- a/tests/Unit/LocalFileVolumeContentSizeTest.php +++ b/tests/Unit/LocalFileVolumeContentSizeTest.php @@ -9,10 +9,18 @@ * payload, crashing the browser. */ +use App\Models\Application; use App\Models\LocalFileVolume; +use App\Models\PrivateKey; +use App\Models\Server; +use App\Models\User; +use Illuminate\Database\Eloquent\Relations\MorphMany; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Process; +use Illuminate\Support\Facades\Storage; use Tests\TestCase; -uses(TestCase::class); +uses(TestCase::class, RefreshDatabase::class); it('exposes a 5 MiB content size limit', function () { expect(LocalFileVolume::MAX_CONTENT_SIZE)->toBe(5_242_880); @@ -64,3 +72,85 @@ it('exposes the too-large flag via toArray for Livewire serialization', function expect($array)->toHaveKey('is_too_large'); expect($array['is_too_large'])->toBeTrue(); }); + +it('does not read regular bind-mounted file contents while loading service settings', function () { + $user = User::factory()->create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $user->teams()->first()->id]); + Storage::fake('ssh-keys'); + $server = Server::factory()->create([ + 'team_id' => $user->teams()->first()->id, + 'private_key_id' => $privateKey->id, + ]); + + $volume = Mockery::mock(LocalFileVolume::class)->makePartial(); + $volume->fs_path = '/data/large.bin'; + $volume->is_based_on_git = false; + $volume->shouldReceive('save')->once(); + $volume->shouldNotReceive('loadStorageOnServer'); + + $fileStorages = Mockery::mock(MorphMany::class); + $fileStorages->shouldReceive('get')->once()->andReturn(collect([$volume])); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->shouldReceive('getMorphClass')->andReturn(Application::class); + $application->shouldReceive('workdir')->once()->andReturn('/data/application'); + $application->shouldReceive('fileStorages')->once()->andReturn($fileStorages); + $application->setRelation('destination', (object) ['server' => $server]); + + Process::fake(function ($process) { + if (str_contains($process->command, 'test -f /data/large.bin')) { + return Process::result(output: 'OK'); + } + + if (str_contains($process->command, 'test -d /data/large.bin')) { + return Process::result(output: 'NOK'); + } + + return Process::result(); + }); + + getFilesystemVolumesFromServer($application); + + expect($volume->is_directory)->toBeFalse(); + Process::assertRan(fn ($process) => str_contains($process->command, 'test -f /data/large.bin')); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'cat /data/large.bin') || str_contains($process->command, 'head -c')); +}); + +it('bounds the remote file read itself to prevent a size-check race', function () { + $source = remoteOutputSource('app/Models/LocalFileVolume.php'); + $loadStorage = str($source) + ->after('public function loadStorageOnServer()') + ->before('public function deleteStorageOnServer()'); + + expect($loadStorage->value()) + ->toContain('head -c') + ->not->toContain('instant_remote_process(["cat {$escapedPath}"]'); +}); + +it('bounds directory-to-file conflict reads the same way', function () { + $source = remoteOutputSource('app/Models/LocalFileVolume.php'); + $saveStorage = str($source) + ->after('public function saveStorageOnServer()') + ->before('protected function plainMountPath'); + + expect($saveStorage->value()) + ->not->toContain('instant_remote_process(["cat {$escapedPath}"]'); +}); + +it('treats a bounded remote read that exceeds the limit as too large', function () { + $oversized = str_repeat('a', LocalFileVolume::MAX_CONTENT_SIZE + 1); + + expect(LocalFileVolume::contentFromBoundedRead($oversized)) + ->toBe(LocalFileVolume::TOO_LARGE_PLACEHOLDER); +}); + +it('keeps a bounded remote read that fits the limit', function () { + $maximumSizedContent = str_repeat('a', LocalFileVolume::MAX_CONTENT_SIZE); + + expect(LocalFileVolume::contentFromBoundedRead('hello')) + ->toBe('hello') + ->and(LocalFileVolume::contentFromBoundedRead($maximumSizedContent)) + ->toBe($maximumSizedContent) + ->and(LocalFileVolume::contentFromBoundedRead(null)) + ->toBe(''); +}); diff --git a/tests/Unit/MailFromIdentityTest.php b/tests/Unit/MailFromIdentityTest.php new file mode 100644 index 0000000000..d9587f9c7f --- /dev/null +++ b/tests/Unit/MailFromIdentityTest.php @@ -0,0 +1,87 @@ + 'admin@example.com', + 'smtp_from_name' => 'Coolify', + ]); + + expect($identity['address'])->toBe('admin@example.com') + ->and($identity['name'])->toBe('Coolify'); +}); + +it('does not fall back to the email local part when a from name is set', function () { + $address = mail_from_address((object) [ + 'smtp_from_address' => 'admin@example.com', + 'smtp_from_name' => 'Coolify', + ]); + + expect($address)->toBeInstanceOf(Address::class) + ->and($address->getAddress())->toBe('admin@example.com') + ->and($address->getName())->toBe('Coolify') + ->and($address->getName())->not->toBe('admin'); +}); + +it('formats the transactional sender for resend', function () { + $formattedAddress = mail_from_formatted((object) [ + 'smtp_from_address' => 'admin@example.com', + 'smtp_from_name' => 'Coolify', + ]); + + expect($formattedAddress)->toBe('"Coolify" '); +}); + +it('keeps the smtp from name and address on the same header line', function () { + $email = mail_from_email(new Email, (object) [ + 'smtp_host' => 'smtp.protonmail.ch', + 'smtp_from_address' => 'contact@advanceddigitalmarketingltda.com', + 'smtp_from_name' => 'Advanced Digital Marketing LTDA', + ]); + + expect($email->getHeaders()->get('From')?->toString()) + ->toBe('From: Advanced Digital Marketing LTDA '); +}); + +it('keeps the Laravel mail from name and address on the same header line', function () { + $message = mail_from_message(new Message(new Email), (object) [ + 'smtp_host' => 'smtp.protonmail.ch', + 'smtp_from_address' => 'contact@advanceddigitalmarketingltda.com', + 'smtp_from_name' => 'Advanced Digital Marketing LTDA', + ]); + + expect($message->getSymfonyMessage()->getHeaders()->get('From')?->toString()) + ->toBe('From: Advanced Digital Marketing LTDA '); +}); + +it('keeps Symfony header folding for other smtp providers', function () { + $email = mail_from_email(new Email, (object) [ + 'smtp_host' => 'smtp.example.com', + 'smtp_from_address' => 'contact@advanceddigitalmarketingltda.com', + 'smtp_from_name' => 'Advanced Digital Marketing LTDA', + ]); + + expect($email->getHeaders()->get('From')?->toString()) + ->toBe("From: Advanced Digital Marketing LTDA\r\n "); +}); + +it('treats a blank from name as missing instead of sending an unnamed address', function () { + $identity = mail_from_identity((object) [ + 'smtp_from_address' => 'admin@example.com', + 'smtp_from_name' => ' ', + ]); + + expect($identity['name'])->toBe('Coolify') + ->and($identity['name'])->not->toBe('admin'); +}); + +it('rejects enabled email settings without a from address', function () { + mail_from_identity((object) [ + 'smtp_enabled' => true, + 'smtp_from_address' => null, + 'smtp_from_name' => 'Coolify', + ]); +})->throws(InvalidArgumentException::class, 'Transactional email sender address is not configured.'); diff --git a/tests/Unit/ObsidianLiveSyncServiceTemplateTest.php b/tests/Unit/ObsidianLiveSyncServiceTemplateTest.php new file mode 100644 index 0000000000..52545be009 --- /dev/null +++ b/tests/Unit/ObsidianLiveSyncServiceTemplateTest.php @@ -0,0 +1,24 @@ +toBeFile(); + + $compose = file_get_contents($templatePath); + + expect($compose) + ->toContain('image: couchdb:3.4.2') + ->toContain('SERVICE_FQDN_COUCHDB_5984') + ->toContain('COUCHDB_USER=${SERVICE_USER_COUCHDB}') + ->toContain('COUCHDB_PASSWORD=${SERVICE_PASSWORD_64_COUCHDB}') + ->toContain('MAX_DOCUMENT_SIZE=${MAX_DOCUMENT_SIZE:-52428800}') + ->toContain('MAX_HTTP_REQUEST_SIZE=${MAX_HTTP_REQUEST_SIZE:-67108864}') + ->toContain('source: ./local.ini') + ->toContain('couchdb-data:/opt/couchdb/data') + ->toContain('http://localhost:5984/_up'); +}); + +it('ships the Obsidian LiveSync service icon', function () { + expect(__DIR__.'/../../svgs/obsidian-livesync.svg')->toBeFile(); +}); diff --git a/tests/Unit/Policies/ApiTokenPolicyTest.php b/tests/Unit/Policies/ApiTokenPolicyTest.php index 98c60aae8c..055297b3cd 100644 --- a/tests/Unit/Policies/ApiTokenPolicyTest.php +++ b/tests/Unit/Policies/ApiTokenPolicyTest.php @@ -1,9 +1,28 @@ makePartial(); + $token->tokenable_id = $userId; + $token->tokenable_type = User::class; + $token->team_id = $teamId; + + return $token; +} + +function apiTokenTeam(int $teamId): Team +{ + $team = new Team; + $team->id = $teamId; + + return $team; +} + it('allows any user to view any api tokens', function () { $user = Mockery::mock(User::class)->makePartial(); @@ -28,10 +47,9 @@ it('allows any user to manage api tokens', function () { it('allows owner to view their own api token', function () { $user = Mockery::mock(User::class)->makePartial(); $user->id = 1; + $user->shouldReceive('currentTeam')->andReturn(apiTokenTeam(10)); - $token = Mockery::mock(PersonalAccessToken::class)->makePartial(); - $token->tokenable_id = 1; - $token->tokenable_type = User::class; + $token = apiTokenForUserAndTeam(1, 10); $policy = new ApiTokenPolicy; expect($policy->view($user, $token))->toBeTrue(); @@ -52,10 +70,9 @@ it('denies non-owner from viewing api token', function () { it('allows owner to update their own api token', function () { $user = Mockery::mock(User::class)->makePartial(); $user->id = 1; + $user->shouldReceive('currentTeam')->andReturn(apiTokenTeam(10)); - $token = Mockery::mock(PersonalAccessToken::class)->makePartial(); - $token->tokenable_id = 1; - $token->tokenable_type = User::class; + $token = apiTokenForUserAndTeam(1, 10); $policy = new ApiTokenPolicy; expect($policy->update($user, $token))->toBeTrue(); @@ -76,10 +93,9 @@ it('denies non-owner from updating api token', function () { it('allows owner to delete their own api token', function () { $user = Mockery::mock(User::class)->makePartial(); $user->id = 1; + $user->shouldReceive('currentTeam')->andReturn(apiTokenTeam(10)); - $token = Mockery::mock(PersonalAccessToken::class)->makePartial(); - $token->tokenable_id = 1; - $token->tokenable_type = User::class; + $token = apiTokenForUserAndTeam(1, 10); $policy = new ApiTokenPolicy; expect($policy->delete($user, $token))->toBeTrue(); @@ -97,6 +113,31 @@ it('denies non-owner from deleting api token', function () { expect($policy->delete($user, $token))->toBeFalse(); }); +it('denies access to an owned api token from another team', function (string $ability) { + $user = Mockery::mock(User::class)->makePartial(); + $user->id = 1; + $user->shouldReceive('currentTeam')->andReturn(apiTokenTeam(10)); + + $token = apiTokenForUserAndTeam(1, 20); + + $policy = new ApiTokenPolicy; + expect($policy->{$ability}($user, $token))->toBeFalse(); +})->with(['view', 'update', 'delete']); + +it('denies access to an owned api token without team identifiers', function (string $ability) { + $user = Mockery::mock(User::class)->makePartial(); + $user->id = 1; + $user->shouldReceive('currentTeam')->andReturnNull(); + + $token = Mockery::mock(PersonalAccessToken::class)->makePartial(); + $token->tokenable_id = 1; + $token->tokenable_type = User::class; + $token->team_id = null; + + $policy = new ApiTokenPolicy; + expect($policy->{$ability}($user, $token))->toBeFalse(); +})->with(['view', 'update', 'delete']); + it('allows admin to use root permissions', function () { $user = Mockery::mock(User::class)->makePartial(); $user->shouldReceive('isAdmin')->andReturn(true); diff --git a/tests/Unit/Policies/TeamPolicyTest.php b/tests/Unit/Policies/TeamPolicyTest.php index 3b341d4888..9da59d767c 100644 --- a/tests/Unit/Policies/TeamPolicyTest.php +++ b/tests/Unit/Policies/TeamPolicyTest.php @@ -56,7 +56,6 @@ it('allows target team admins to perform privileged team actions', function (str expect((new TeamPolicy)->{$ability}($user, $team))->toBeTrue(); })->with([ 'update', - 'delete', 'manageMembers', 'viewAdmin', 'manageInvitations', @@ -72,7 +71,6 @@ it('denies target team members even when their current session role is admin els expect((new TeamPolicy)->{$ability}($user, $team))->toBeFalse(); })->with([ 'update', - 'delete', 'manageMembers', 'viewAdmin', 'manageInvitations', @@ -90,3 +88,15 @@ it('denies non-members from privileged team actions', function (string $ability) 'viewAdmin', 'manageInvitations', ]); + +it('only allows target team owners to delete the team', function (string $role, bool $allowed) { + $user = teamPolicyUserWithTeams([1]); + $user->shouldReceive('roleInTeam')->with(1)->andReturn($role); + $team = teamPolicyTeam(1); + + expect((new TeamPolicy)->delete($user, $team))->toBe($allowed); +})->with([ + 'owner' => ['owner', true], + 'admin' => ['admin', false], + 'member' => ['member', false], +]); diff --git a/tests/Unit/ProductionImageWorkflowTest.php b/tests/Unit/ProductionImageWorkflowTest.php index c3b5e665f3..2fafa678e8 100644 --- a/tests/Unit/ProductionImageWorkflowTest.php +++ b/tests/Unit/ProductionImageWorkflowTest.php @@ -23,9 +23,9 @@ it('publishes v4 branch builds under the commit sha with a traceable internal ve ->toContain('ARG COOLIFY_VERSION') ->toContain('ENV COOLIFY_VERSION=${COOLIFY_VERSION}') ->and($constants) - ->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.9'") - ->and($versions['coolify']['v4']['version'])->toBe('4.3.9') - ->and($versions['coolify']['nightly']['version'])->toBe('4.3.10') + ->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.11'") + ->and($versions['coolify']['v4']['version'])->toBe('4.3.11') + ->and($versions['coolify']['nightly']['version'])->toBe('4.4-rc.1') ->and($nightlyVersions)->toBe($versions); }); @@ -34,6 +34,12 @@ it('orders a maintenance development build before its stable release', function ->and(version_compare('4.3.2', '4.3.2-dev.d64cbda3e', '>'))->toBeTrue(); }); +it('orders rolling and exact release candidates before the stable release', function () { + expect(version_compare('4.4-rc.1.d64cbda', '4.4-rc.1', '<'))->toBeTrue() + ->and(version_compare('4.4-rc.1', '4.4-rc.2', '<'))->toBeTrue() + ->and(version_compare('4.4-rc.2', '4.4.0', '<'))->toBeTrue(); +}); + it('requires a reviewed draft release before building a stable version', function () { $workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-release.yml'); @@ -104,12 +110,46 @@ it('generates the production changelog from main', function () { ->not->toContain('v4.x'); }); -it('excludes main from staging builds', function () { - $workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-staging-build.yml'); +it('publishes traceable rolling builds from next without creating an exact rc tag', function () { + $workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-next-build.yml'); expect($workflow) - ->toContain(' - main') - ->not->toContain(' - v4.x'); + ->toContain('name: Build Coolify Next') + ->toContain('branches: [next]') + ->toContain('group: coolify-next-build') + ->toContain("jq -r '.coolify.nightly.version' versions.json") + ->toContain('VERSION="${RC_VERSION}.${SHORT_SHA}"') + ->toContain('COOLIFY_VERSION=${{ needs.prepare.outputs.version }}') + ->toContain('--tag "${IMAGE}:sha-${SHA}"') + ->toContain('--tag "${IMAGE}:${VERSION}"') + ->toContain('--tag "${IMAGE}:next"') + ->not->toContain('--tag "${IMAGE}:${RC_VERSION}"') + ->not->toContain('--tag "${IMAGE}:latest"'); +}); + +it('requires a reviewed draft prerelease before publishing an exact rc', function () { + $workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-rc-release.yml'); + + expect($workflow) + ->toContain('name: Release Coolify RC') + ->toContain('run-name: ${{ inputs.tag }}') + ->toContain("github.ref != 'refs/heads/next'") + ->toContain('group: coolify-rc-release') + ->toContain('^v[0-9]+\\.[0-9]+-rc\\.[0-9]+$') + ->toContain("jq -r '.coolify.nightly.version' versions.json") + ->toContain('release.draft') + ->toContain('!release.prerelease') + ->toContain('release.body?.trim()') + ->toContain('revalidate:') + ->toMatch('/revalidate:.*?permissions:\s+contents: write/s') + ->toContain('needs: [validate, build, revalidate]') + ->toContain('COOLIFY_VERSION=${{ needs.validate.outputs.version }}') + ->toContain('--tag "${IMAGE}:${VERSION}"') + ->toContain('--tag "${IMAGE}:next"') + ->toContain('prerelease: true') + ->toContain('actions/github-script@v8') + ->not->toContain('--tag "${IMAGE}:latest"') + ->not->toContain('environment:'); }); it('rebuilds stable images and publishes the reviewed draft after both architectures succeed', function () { diff --git a/tests/Unit/RemoteOutputSizeLimitsTest.php b/tests/Unit/RemoteOutputSizeLimitsTest.php new file mode 100644 index 0000000000..dda82a0fb1 --- /dev/null +++ b/tests/Unit/RemoteOutputSizeLimitsTest.php @@ -0,0 +1,183 @@ +create(); + $team = $user->teams()->first(); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + Storage::fake('ssh-keys'); + + return Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + ]); +} + +it('bounds dynamic proxy configuration files and their combined Livewire payload', function () { + $server = Mockery::mock(remoteOutputTestServer())->makePartial(); + $server->shouldReceive('proxyPath')->andReturn('/data/proxy'); + $files = collect(range(1, 101))->map(fn (int $number) => sprintf('file%03d.yml', $number))->implode("\n"); + + Process::fake(function ($process) use ($files) { + if (str_contains($process->command, 'ls -1')) { + return Process::result(output: $files); + } + + if (str_contains($process->command, 'file001.yml')) { + return Process::result(output: str_repeat('x', DynamicConfigurations::MAX_CONFIGURATION_FILE_SIZE_BYTES + 1)); + } + + if (preg_match('/file00[2-6]\.yml/', $process->command)) { + return Process::result(output: str_repeat('x', DynamicConfigurations::MAX_CONFIGURATION_FILE_SIZE_BYTES)); + } + + return Process::result(output: 'x'); + }); + + $component = Mockery::mock(DynamicConfigurations::class)->makePartial(); + $component->server = $server; + $component->shouldReceive('authorize')->once(); + $component->shouldReceive('dispatch')->andReturnSelf(); + $component->loadDynamicConfigurations(); + + expect($component->contents)->toHaveCount(5) + ->toHaveKeys(['file002|yml', 'file003|yml', 'file004|yml', 'file005|yml', 'file006|yml']) + ->not->toHaveKeys(['file001|yml', 'file007|yml', 'file101|yml']); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'file101.yml')); +}); +it('fails explicitly when a source fixture cannot be read', function () { + remoteOutputSource('missing-source-fixture.php'); +})->throws(RuntimeException::class, 'Unable to read source fixture:'); + +it('bounds regular log viewer output before it reaches PHP', function () { + $source = remoteOutputSource('app/Livewire/Project/Shared/GetLogs.php'); + + expect($source) + ->toContain('MAX_DISPLAY_SIZE_BYTES') + ->toContain('boundedLogCommand(') + ->toContain('[... Output truncated at'); + + $method = new ReflectionMethod(GetLogs::class, 'boundedLogCommand'); + $command = $method->invoke(new GetLogs, 'docker logs example', 100); + + expect(GetLogs::MAX_DISPLAY_SIZE_BYTES)->toBe(5 * 1024 * 1024) + ->and($command)->toBe('(docker logs example) 2>&1 | head -c 101'); +}); + +it('rejects oversized docker compose files loaded from git before parsing', function () { + $server = remoteOutputTestServer(); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->base_directory = '/'; + $application->docker_compose_location = '/docker-compose.yml'; + $application->setRelation('destination', (object) ['server' => $server]); + $application->shouldReceive('generateGitImportCommands')->andReturn(['commands' => collect(['git clone example checkout'])]); + $application->shouldReceive('getGitRemoteStatus')->andReturn(['is_accessible' => true]); + $application->shouldReceive('save')->once(); + + Process::fake(function ($process) { + if (str_contains($process->command, 'git --version')) { + return Process::result(output: 'git version 2.40.0'); + } + + if (str_contains($process->command, 'wc -c')) { + return Process::result(output: '__COOLIFY_COMPOSE_TOO_LARGE__'); + } + + return Process::result(); + }); + + expect(fn () => $application->loadComposeFile()) + ->toThrow(RuntimeException::class, 'Docker Compose file exceeds the 5 MiB size limit.'); + expect($application->docker_compose_raw)->toBeNull(); + Process::assertRan(fn ($process) => str_contains($process->command, 'head -c '.(Application::MAX_DOCKER_COMPOSE_SIZE_BYTES + 1))); +}); +it('bounds scheduled task output before storing or notifying', function () { + $source = remoteOutputSource('app/Jobs/ScheduledTaskJob.php'); + + expect($source) + ->toContain('MAX_OUTPUT_SIZE_BYTES') + ->toContain('head -c {$readLimit}') + ->toContain('[... Output truncated at'); + + $reflection = new ReflectionClass(ScheduledTaskJob::class); + $method = $reflection->getMethod('boundedTaskCommand'); + $command = $method->invoke($reflection->newInstanceWithoutConstructor(), 'printf hello'); + $failureCommand = $method->invoke($reflection->newInstanceWithoutConstructor(), "bash -c 'printf failure; exit 7'"); + exec('bash -n -c '.escapeshellarg($command), $output, $exitCode); + exec('bash -c '.escapeshellarg($command), $commandOutput, $commandExitCode); + exec('bash -c '.escapeshellarg($failureCommand).' 2>&1', $failureOutput, $failureExitCode); + + expect(ScheduledTaskJob::MAX_OUTPUT_SIZE_BYTES)->toBe(5 * 1024 * 1024) + ->and($command)->toContain('head -c 5242881') + ->and($exitCode)->toBe(0) + ->and($commandExitCode)->toBe(0) + ->and(implode("\n", $commandOutput))->toBe('hello') + ->and($failureExitCode)->toBe(7) + ->and(implode("\n", $failureOutput))->toBe('failure'); +}); + +it('marks scheduled task output that exceeds the limit as truncated', function () { + $reflection = new ReflectionClass(ScheduledTaskJob::class); + $method = $reflection->getMethod('boundedTaskCommand'); + $largeOutputCommand = 'head -c '.(ScheduledTaskJob::MAX_OUTPUT_SIZE_BYTES + 1).' /dev/zero | tr "\\0" "x"'; + $command = $method->invoke($reflection->newInstanceWithoutConstructor(), $largeOutputCommand); + + exec('bash -c '.escapeshellarg($command), $output, $exitCode); + $taskOutput = implode("\n", $output); + + expect($exitCode)->toBe(0) + ->and(strlen($taskOutput))->toBeGreaterThan(ScheduledTaskJob::MAX_OUTPUT_SIZE_BYTES) + ->and($taskOutput)->toEndWith('[... Output truncated at 5MB limit ...]'); +}); + +it('does not pass the scheduled task output wrapper through the sudo rewriter', function () { + $source = remoteOutputSource('app/Jobs/ScheduledTaskJob.php'); + $reflection = new ReflectionClass(ScheduledTaskJob::class); + $method = $reflection->getMethod('boundedTaskCommand'); + $command = $method->invoke($reflection->newInstanceWithoutConstructor(), 'sudo docker exec example true'); + $server = new Server(['user' => 'ubuntu']); + $rewrittenCommand = parseCommandsByLineForSudo(collect([$command]), $server)[0]; + + exec('bash -n -c '.escapeshellarg($command), $output, $exitCode); + exec('bash -n -c '.escapeshellarg($rewrittenCommand).' 2>/dev/null', $rewrittenOutput, $rewrittenExitCode); + + expect($source) + ->toContain("\$dockerCommand = \$this->server->isNonRoot() ? 'sudo docker' : 'docker'") + ->toContain('instant_remote_process([$exec], $this->server, throwError: true, no_sudo: true') + ->and($exitCode)->toBe(0) + ->and($rewrittenExitCode)->not->toBe(0); +}); + +it('rejects oversized proxy configuration backfill before persistence', function () { + $proxy = Mockery::mock(SchemalessAttributes::class); + $proxy->shouldNotReceive('set'); + $server = Mockery::mock(remoteOutputTestServer())->makePartial(); + $server->shouldReceive('proxyPath')->andReturn('/data/proxy'); + $server->shouldReceive('getAttribute')->with('proxy')->andReturn($proxy); + $server->shouldNotReceive('save'); + + Process::fake(['*' => Process::result(output: '__COOLIFY_PROXY_CONFIG_TOO_LARGE__')]); + + $method = new ReflectionMethod(GetProxyConfiguration::class, 'backfillFromDisk'); + + expect(fn () => $method->invoke(new GetProxyConfiguration, $server)) + ->toThrow(RuntimeException::class, 'Proxy configuration exceeds the 5 MiB size limit.'); + Process::assertRan(fn ($process) => str_contains($process->command, 'head -c '.(GetProxyConfiguration::MAX_CONFIGURATION_SIZE_BYTES + 1))); +}); diff --git a/tests/Unit/SmtpTransportFactoryTest.php b/tests/Unit/SmtpTransportFactoryTest.php new file mode 100644 index 0000000000..4ace54cfa4 --- /dev/null +++ b/tests/Unit/SmtpTransportFactoryTest.php @@ -0,0 +1,130 @@ + 'smtp.example.com', + 'smtp_port' => 25, + 'smtp_encryption' => 'none', + 'smtp_username' => 'user', + 'smtp_password' => 'secret', + 'smtp_timeout' => null, + ], $overrides); +} + +it('disables opportunistic STARTTLS when encryption is none', function () { + $transport = SmtpTransportFactory::fromSettings(smtpSettings([ + 'smtp_encryption' => 'none', + ])); + + expect($transport->isAutoTls())->toBeFalse() + ->and($transport->getStream())->toBeInstanceOf(SocketStream::class) + ->and($transport->getStream()->isTLS())->toBeFalse(); +}); + +it('does not issue STARTTLS for the issue 5877 anonymous port 25 relay', function () { + $transport = SmtpTransportFactory::fromSettings(smtpSettings([ + 'smtp_encryption' => 'none', + 'smtp_port' => 25, + 'smtp_username' => '', + 'smtp_password' => '', + ])); + + expect($transport->isAutoTls())->toBeFalse() + ->and($transport->getStream()->isTLS())->toBeFalse() + ->and($transport->getStream()->getPort())->toBe(25) + ->and($transport->getUsername())->toBe('') + ->and($transport->getPassword())->toBe(''); +}); + +it('does not enable implicit TLS on port 465 when encryption is none', function () { + $transport = SmtpTransportFactory::fromSettings(smtpSettings([ + 'smtp_encryption' => 'none', + 'smtp_port' => 465, + ])); + + expect($transport->isAutoTls())->toBeFalse() + ->and($transport->getStream()->isTLS())->toBeFalse(); +}); + +it('keeps opportunistic STARTTLS when encryption is starttls', function () { + $transport = SmtpTransportFactory::fromSettings(smtpSettings([ + 'smtp_encryption' => 'starttls', + ])); + + expect($transport->isAutoTls())->toBeTrue() + ->and($transport->getStream()->isTLS())->toBeFalse(); +}); + +it('uses implicit TLS when encryption is tls', function () { + $transport = SmtpTransportFactory::fromSettings(smtpSettings([ + 'smtp_encryption' => 'tls', + 'smtp_port' => 465, + ])); + + expect($transport->isAutoTls())->toBeTrue() + ->and($transport->getStream()->isTLS())->toBeTrue(); +}); + +it('infers implicit TLS on port 465 when encryption is null', function () { + $transport = SmtpTransportFactory::fromSettings(smtpSettings([ + 'smtp_encryption' => null, + 'smtp_port' => 465, + ])); + + expect($transport->getStream()->isTLS())->toBeTrue(); +}); + +it('applies a configured SMTP timeout to the transport stream', function () { + $transport = SmtpTransportFactory::fromSettings(smtpSettings([ + 'smtp_timeout' => 15, + ])); + + expect($transport->getStream()->getTimeout())->toBe(15.0); +}); + +it('applies the configured ehlo domain to the transport', function () { + $transport = SmtpTransportFactory::fromSettings( + smtpSettings(['smtp_ehlo_domain' => 'team.example.com']), + 'instance.example.com' + ); + + expect($transport->getLocalDomain())->toBe('team.example.com'); +}); + +it('falls back to the instance ehlo domain for legacy smtp settings', function () { + $transport = SmtpTransportFactory::fromSettings( + smtpSettings(), + 'instance.example.com' + ); + + expect($transport->getLocalDomain())->toBe('instance.example.com'); +}); + +it('maps none encryption to laravel mailer options that disable auto tls', function () { + expect(SmtpTransportFactory::mailerOptions(smtpSettings([ + 'smtp_encryption' => 'none', + ])))->toBe([ + 'scheme' => 'smtp', + 'encryption' => null, + 'auto_tls' => '0', + ]); +}); + +it('maps encryption to laravel mailer options', function (?string $mode, ?string $scheme, ?string $encryption) { + expect(SmtpTransportFactory::mailerOptions(smtpSettings([ + 'smtp_encryption' => $mode, + ])))->toBe([ + 'scheme' => $scheme, + 'encryption' => $encryption, + 'auto_tls' => $mode === 'none' ? '0' : '', + ]); +})->with([ + 'none' => ['none', 'smtp', null], + 'starttls' => ['starttls', 'smtp', null], + 'tls' => ['tls', 'smtps', 'tls'], + 'unset' => [null, null, null], +]); diff --git a/tests/Unit/UpgradePostgresScriptTest.php b/tests/Unit/UpgradePostgresScriptTest.php index a888bc3a41..677a8fec3e 100644 --- a/tests/Unit/UpgradePostgresScriptTest.php +++ b/tests/Unit/UpgradePostgresScriptTest.php @@ -62,6 +62,35 @@ it('persists the selected registry url during upgrades', function (string $path) 'nightly upgrade' => 'other/nightly/upgrade.sh', ]); +it('persists the target image and runtime version before recreating containers', function (string $path) { + $script = file_get_contents(getcwd().'/'.$path); + if ($script === false) { + throw new RuntimeException("Unable to read {$path}"); + } + + $position = static function (string $needle) use ($script): int { + $offset = strpos($script, $needle); + if ($offset === false) { + throw new RuntimeException("Missing marker: {$needle}"); + } + + return $offset; + }; + + $latestImagePosition = $position('set_env_var "LATEST_IMAGE" "$LATEST_IMAGE"'); + $coolifyVersionPosition = $position('set_env_var "COOLIFY_VERSION" "$LATEST_IMAGE"'); + $imagesPulledPosition = $position('log "All images pulled successfully"'); + $composeUpPosition = $position('docker compose --env-file /data/coolify/source/.env'); + + expect($latestImagePosition)->toBeGreaterThan($imagesPulledPosition) + ->and($coolifyVersionPosition)->toBeGreaterThan($imagesPulledPosition) + ->and($latestImagePosition)->toBeLessThan($composeUpPosition) + ->and($coolifyVersionPosition)->toBeLessThan($composeUpPosition); +})->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); diff --git a/tests/Unit/VaultServiceTemplateTest.php b/tests/Unit/VaultServiceTemplateTest.php new file mode 100644 index 0000000000..c758a78e9e --- /dev/null +++ b/tests/Unit/VaultServiceTemplateTest.php @@ -0,0 +1,38 @@ +toBeFile(); + + $compose = file_get_contents($templatePath); + + expect($compose) + ->toContain('hashicorp/vault:${VAULT_VERSION:-latest}') + ->toContain('SERVICE_URL_VAULT_8200') + ->toContain('VAULT_API_ADDR=${SERVICE_URL_VAULT_8200}') + ->toContain('"disable_mlock":true') + ->toContain('"storage":{"file":{"path":"/vault/file"}}') + ->toContain('vault-data:/vault/file') + ->toContain('/v1/sys/health?standbyok=true&sealedcode=200&uninitcode=200'); + + foreach (['service-templates.json', 'service-templates-latest.json'] as $templateFile) { + $templates = json_decode( + file_get_contents(__DIR__."/../../templates/{$templateFile}"), + associative: true, + flags: JSON_THROW_ON_ERROR, + ); + + expect($templates)->toHaveKey('vault'); + expect($templates['vault']['port'] ?? null)->toBe('8200'); + expect($templates['vault']['category'] ?? null)->toBe('security'); + + $generatedCompose = base64_decode($templates['vault']['compose'], strict: true); + + expect($generatedCompose) + ->toContain('hashicorp/vault:${VAULT_VERSION:-latest}') + ->toContain($templateFile === 'service-templates.json' + ? 'VAULT_API_ADDR=${SERVICE_FQDN_VAULT_8200}' + : 'VAULT_API_ADDR=${SERVICE_URL_VAULT_8200}'); + } +}); diff --git a/tests/v4/Browser/LivewireRequestFailurePreviewTest.php b/tests/v4/Browser/LivewireRequestFailurePreviewTest.php new file mode 100644 index 0000000000..f89893bc9f --- /dev/null +++ b/tests/v4/Browser/LivewireRequestFailurePreviewTest.php @@ -0,0 +1,48 @@ +set('app.maintenance.store', 'array'); + seedBrowserInstanceSettings(); + createBrowserRootUser(); +}); + +it('suppresses proxy error responses and shows a toast', function () { + $page = visit('/login') + ->fill('email', 'test@example.com') + ->fill('password', 'password') + ->click('Login') + ->assertSee('Welcome to Coolify'); + + // Boarding redirects every other path; finish it so the preview page loads. + // User::currentTeam() caches the team, so flush after the update. + Team::query()->update(['show_boarding' => false]); + Cache::flush(); + + $page->navigate('/__livewire-request-failure') + ->assertSee('Livewire request failure preview') + ->click('502') + ->assertSee('Action could not be completed') + ->assertSee('Coolify did not receive a response. Please try again.') + ->assertSee('Livewire request failure preview') + ->assertDontSee('cloudflare proxy error') + ->screenshot(filename: 'livewire-request-failure-toast'); +}); + +it('shows the preview page without a toast before any failure', function () { + $page = visit('/__livewire-request-failure'); + + $page->assertSee('Livewire request failure preview') + ->assertDontSee('Action could not be completed') + ->screenshot(filename: 'livewire-request-failure-initial'); + + // layouts.simple must render Livewire's styles, or wire:loading spinners leak. + $spinnerDisplay = $page->script('getComputedStyle(document.querySelector("[wire\\\\:loading]")).display'); + expect($spinnerDisplay)->toBe('none'); +}); diff --git a/versions.json b/versions.json index 92a88ea023..d4e5b5c8c9 100644 --- a/versions.json +++ b/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.3.9" + "version": "4.3.11" }, "nightly": { - "version": "4.3.10" + "version": "4.4-rc.1" }, "helper": { "version": "1.0.15"