diff --git a/.claude/skills/frontend-design/SKILL.md b/.claude/skills/frontend-design/SKILL.md new file mode 100644 index 000000000..600b6db41 --- /dev/null +++ b/.claude/skills/frontend-design/SKILL.md @@ -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. \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index c7e76afc7..70df73ce4 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -17,17 +17,17 @@ "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", + "tsx": "^4.22.4", "typescript": "^5.8.3" }, "packageManager": "pnpm@10.22.0", diff --git a/apps/dokploy/__test__/backups/redact-credentials.test.ts b/apps/dokploy/__test__/backups/redact-credentials.test.ts new file mode 100644 index 000000000..5fff508cc --- /dev/null +++ b/apps/dokploy/__test__/backups/redact-credentials.test.ts @@ -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]"); + }); +}); diff --git a/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts b/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts new file mode 100644 index 000000000..e25bd425e --- /dev/null +++ b/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts @@ -0,0 +1,323 @@ +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(), +})); + +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: vi.fn(), + createSecurityBlockedComment: vi.fn(), + findGithubById: vi.fn(), + findPreviewDeploymentByApplicationId: vi.fn(), + 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; + json: ReturnType; + }; + + 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" }); + }); +}); diff --git a/apps/dokploy/__test__/permissions/check-permission.test.ts b/apps/dokploy/__test__/permissions/check-permission.test.ts index 03f47d050..b9f19d984 100644 --- a/apps/dokploy/__test__/permissions/check-permission.test.ts +++ b/apps/dokploy/__test__/permissions/check-permission.test.ts @@ -183,4 +183,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(); + }); }); diff --git a/apps/dokploy/__test__/permissions/resolve-permissions.test.ts b/apps/dokploy/__test__/permissions/resolve-permissions.test.ts index 759c8dad8..c85bde189 100644 --- a/apps/dokploy/__test__/permissions/resolve-permissions.test.ts +++ b/apps/dokploy/__test__/permissions/resolve-permissions.test.ts @@ -143,6 +143,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", () => { diff --git a/apps/dokploy/__test__/queues/concurrency.test.ts b/apps/dokploy/__test__/queues/concurrency.test.ts new file mode 100644 index 000000000..c4e9d319f --- /dev/null +++ b/apps/dokploy/__test__/queues/concurrency.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const hasValidLicense = vi.fn(); +const getWebServerSettings = vi.fn(); +const findFirstOrg = vi.fn(); +const findFirstServer = vi.fn(); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + organization: { + findFirst: (...args: unknown[]) => findFirstOrg(...args), + }, + server: { + findFirst: (...args: unknown[]) => findFirstServer(...args), + }, + }, + }, +})); + +vi.mock("@dokploy/server/db/schema", () => ({ + organization: {}, + server: {}, +})); + +vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ + hasValidLicense: (...args: unknown[]) => hasValidLicense(...args), +})); + +vi.mock("@dokploy/server/services/web-server-settings", () => ({ + getWebServerSettings: (...args: unknown[]) => getWebServerSettings(...args), +})); + +vi.mock("drizzle-orm", () => ({ eq: vi.fn() })); + +import { + assertBuildsConcurrencyAllowed, + resolveBuildsConcurrency, +} from "../../server/queues/concurrency"; +import { LOCAL_PARTITION } from "../../server/queues/in-memory-queue"; + +describe("resolveBuildsConcurrency (enterprise gating)", () => { + beforeEach(() => { + vi.clearAllMocks(); + findFirstOrg.mockResolvedValue({ id: "org-1" }); + }); + + describe("local web server partition", () => { + it("returns the configured concurrency when licensed", async () => { + getWebServerSettings.mockResolvedValue({ buildsConcurrency: 5 }); + hasValidLicense.mockResolvedValue(true); + + await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(5); + }); + + it("clamps to the free max (2) when there is no valid license", async () => { + getWebServerSettings.mockResolvedValue({ buildsConcurrency: 10 }); + hasValidLicense.mockResolvedValue(false); + + await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(2); + }); + + it("allows the free max (2) without a license", async () => { + getWebServerSettings.mockResolvedValue({ buildsConcurrency: 2 }); + hasValidLicense.mockResolvedValue(false); + + await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(2); + }); + + it("does not cap the value when licensed (N allowed)", async () => { + getWebServerSettings.mockResolvedValue({ buildsConcurrency: 999 }); + hasValidLicense.mockResolvedValue(true); + + await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe( + 999, + ); + }); + + it("defaults to 1 when settings are missing", async () => { + getWebServerSettings.mockResolvedValue(undefined); + hasValidLicense.mockResolvedValue(true); + + await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1); + }); + }); + + describe("remote server partition", () => { + it("returns the server concurrency when its org is licensed", async () => { + findFirstServer.mockResolvedValue({ + buildsConcurrency: 4, + organizationId: "org-1", + }); + hasValidLicense.mockResolvedValue(true); + + await expect(resolveBuildsConcurrency("server-1")).resolves.toBe(4); + expect(hasValidLicense).toHaveBeenCalledWith("org-1"); + }); + + it("clamps to the free max (2) when the server org is not licensed", async () => { + findFirstServer.mockResolvedValue({ + buildsConcurrency: 8, + organizationId: "org-1", + }); + hasValidLicense.mockResolvedValue(false); + + await expect(resolveBuildsConcurrency("server-1")).resolves.toBe(2); + }); + + 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); + }); +}); + +describe("assertBuildsConcurrencyAllowed", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("allows up to the free max (2) without checking the license", async () => { + await expect( + assertBuildsConcurrencyAllowed(2, "org-1"), + ).resolves.toBeUndefined(); + expect(hasValidLicense).not.toHaveBeenCalled(); + }); + + it("allows more than 2 when licensed", async () => { + hasValidLicense.mockResolvedValue(true); + await expect( + assertBuildsConcurrencyAllowed(5, "org-1"), + ).resolves.toBeUndefined(); + }); + + it("rejects more than 2 without a license", async () => { + hasValidLicense.mockResolvedValue(false); + await expect(assertBuildsConcurrencyAllowed(3, "org-1")).rejects.toThrow( + /enterprise license/i, + ); + }); +}); diff --git a/apps/dokploy/__test__/queues/in-memory-queue.test.ts b/apps/dokploy/__test__/queues/in-memory-queue.test.ts new file mode 100644 index 000000000..04850bee7 --- /dev/null +++ b/apps/dokploy/__test__/queues/in-memory-queue.test.ts @@ -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((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>(); + 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> = []; + 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>(); + + // 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>(); + + // 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> = []; + 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>(); + + // 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>(); + 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"]); + }); +}); diff --git a/apps/dokploy/__test__/registry/registry-schema.test.ts b/apps/dokploy/__test__/registry/registry-schema.test.ts new file mode 100644 index 000000000..e5ff60a6c --- /dev/null +++ b/apps/dokploy/__test__/registry/registry-schema.test.ts @@ -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"); + } + }); +}); diff --git a/apps/dokploy/__test__/server/server-setup.test.ts b/apps/dokploy/__test__/server/server-setup.test.ts new file mode 100644 index 000000000..6a23aa8f9 --- /dev/null +++ b/apps/dokploy/__test__/server/server-setup.test.ts @@ -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"', + ); + }, + ); +}); diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts index eda4dace5..ba09c2c80 100644 --- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts +++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts @@ -25,6 +25,7 @@ const baseSettings: WebServerSettings = { letsEncryptEmail: null, sshPrivateKey: null, enableDockerCleanup: false, + buildsConcurrency: 1, logCleanupCron: null, metricsConfig: { containers: { diff --git a/apps/dokploy/__test__/utils/hostname-validation.test.ts b/apps/dokploy/__test__/utils/hostname-validation.test.ts new file mode 100644 index 000000000..c0e734282 --- /dev/null +++ b/apps/dokploy/__test__/utils/hostname-validation.test.ts @@ -0,0 +1,46 @@ +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", + ])("accepts valid hostname %s", (host) => { + expect(VALID_HOSTNAME_REGEX.test(host)).toBe(true); + }); + + it.each([ + "bbn_client.example.com", + "-example.com", + "example-.com", + "example", + "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); + }); +}); diff --git a/apps/dokploy/components.json b/apps/dokploy/components.json index 81104c1e9..26f5eb141 100644 --- a/apps/dokploy/components.json +++ b/apps/dokploy/components.json @@ -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": {} } diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx index 7d214716e..a2d4c4ac9 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx @@ -156,7 +156,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
{/* Left Column - Menu */} -
+