From fd4ba1839ad4df30076555e8b77f457b0bd90962 Mon Sep 17 00:00:00 2001 From: XnCN Date: Thu, 22 Oct 2026 19:28:42 +0300 Subject: [PATCH 1/5] feat: add Azure DevOps Server and Services provider --- apps/dokploy/__test__/drop/drop.test.ts | 11 +- .../azure-devops-build-paths.test.ts | 66 + .../git-provider/azure-devops-client.test.ts | 244 + .../git-provider/azure-devops-clone.test.ts | 207 + .../git-provider/azure-devops-hooks.test.ts | 318 + .../azure-devops-provider.test.ts | 200 + .../git-provider/azure-devops-push.test.ts | 135 + .../git-provider/azure-devops-sources.test.ts | 165 + .../azure-devops-submodules.test.ts | 250 + apps/dokploy/__test__/traefik/traefik.test.ts | 7 + .../generic/save-azure-devops-provider.tsx | 108 + .../application/general/generic/show.tsx | 26 +- .../generic/unauthorized-git-provider.tsx | 9 + .../dashboard/compose/delete-service.tsx | 8 + .../save-azure-devops-provider-compose.tsx | 110 + .../compose/general/generic/show.tsx | 32 +- .../settings/git/azure-devops-provider.tsx | 196 + .../settings/git/show-git-providers.tsx | 45 +- .../shared/azure-devops-source-fields.tsx | 306 + .../components/icons/azure-devops-icon.tsx | 14 + apps/dokploy/drizzle/0198_azure_devops.sql | 42 + apps/dokploy/drizzle/meta/0198_snapshot.json | 9587 +++++++++++++++++ apps/dokploy/drizzle/meta/_journal.json | 7 + .../pages/api/deploy/[refreshToken].ts | 22 +- .../api/deploy/compose/[refreshToken].ts | 27 +- .../pages/api/providers/gitea/helper.ts | 2 +- apps/dokploy/server/api/root.ts | 2 + .../dokploy/server/api/routers/application.ts | 138 +- .../server/api/routers/azure-devops.ts | 188 + apps/dokploy/server/api/routers/compose.ts | 77 +- .../server/api/routers/git-provider.ts | 4 + apps/dokploy/server/server.ts | 2 + apps/dokploy/utils/gitea-utils.ts | 2 +- openapi.json | 138 +- packages/server/src/db/schema/application.ts | 27 +- packages/server/src/db/schema/azure-devops.ts | 107 + packages/server/src/db/schema/compose.ts | 25 +- packages/server/src/db/schema/git-provider.ts | 6 + packages/server/src/db/schema/index.ts | 1 + packages/server/src/index.ts | 4 + packages/server/src/services/application.ts | 67 +- .../server/src/services/azure-devops-hooks.ts | 441 + packages/server/src/services/azure-devops.ts | 199 + packages/server/src/services/compose.ts | 69 +- packages/server/src/services/git-provider.ts | 11 +- packages/server/src/services/patch-repo.ts | 5 +- packages/server/src/services/rollbacks.ts | 1 + .../src/services/web-server-settings.ts | 10 + packages/server/src/utils/builders/compose.ts | 2 +- .../server/src/utils/builders/docker-file.ts | 4 +- packages/server/src/utils/builders/heroku.ts | 3 +- .../server/src/utils/builders/nixpacks.ts | 2 +- packages/server/src/utils/builders/paketo.ts | 3 +- .../server/src/utils/builders/railpack.ts | 10 +- packages/server/src/utils/builders/utils.ts | 3 +- packages/server/src/utils/docker/domain.ts | 7 +- .../server/src/utils/filesystem/directory.ts | 2 + .../utils/providers/azure-devops-client.ts | 290 + .../src/utils/providers/azure-devops-push.ts | 113 + .../src/utils/providers/azure-devops.ts | 136 + 60 files changed, 14185 insertions(+), 58 deletions(-) create mode 100644 apps/dokploy/__test__/git-provider/azure-devops-build-paths.test.ts create mode 100644 apps/dokploy/__test__/git-provider/azure-devops-client.test.ts create mode 100644 apps/dokploy/__test__/git-provider/azure-devops-clone.test.ts create mode 100644 apps/dokploy/__test__/git-provider/azure-devops-hooks.test.ts create mode 100644 apps/dokploy/__test__/git-provider/azure-devops-provider.test.ts create mode 100644 apps/dokploy/__test__/git-provider/azure-devops-push.test.ts create mode 100644 apps/dokploy/__test__/git-provider/azure-devops-sources.test.ts create mode 100644 apps/dokploy/__test__/git-provider/azure-devops-submodules.test.ts create mode 100644 apps/dokploy/components/dashboard/application/general/generic/save-azure-devops-provider.tsx create mode 100644 apps/dokploy/components/dashboard/compose/general/generic/save-azure-devops-provider-compose.tsx create mode 100644 apps/dokploy/components/dashboard/settings/git/azure-devops-provider.tsx create mode 100644 apps/dokploy/components/dashboard/shared/azure-devops-source-fields.tsx create mode 100644 apps/dokploy/components/icons/azure-devops-icon.tsx create mode 100644 apps/dokploy/drizzle/0198_azure_devops.sql create mode 100644 apps/dokploy/drizzle/meta/0198_snapshot.json create mode 100644 apps/dokploy/server/api/routers/azure-devops.ts create mode 100644 packages/server/src/db/schema/azure-devops.ts create mode 100644 packages/server/src/services/azure-devops-hooks.ts create mode 100644 packages/server/src/services/azure-devops.ts create mode 100644 packages/server/src/utils/providers/azure-devops-client.ts create mode 100644 packages/server/src/utils/providers/azure-devops-push.ts create mode 100644 packages/server/src/utils/providers/azure-devops.ts diff --git a/apps/dokploy/__test__/drop/drop.test.ts b/apps/dokploy/__test__/drop/drop.test.ts index eda3b9f0dd..d5f18e468e 100644 --- a/apps/dokploy/__test__/drop/drop.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.ts @@ -11,10 +11,10 @@ const { APPLICATIONS_PATH } = paths(); vi.mock("@dokploy/server/constants", async (importOriginal) => { const actual = await importOriginal(); return { - // @ts-ignore + // @ts-expect-error ...actual, paths: () => ({ - // @ts-ignore + // @ts-expect-error ...actual.paths(), BASE_PATH: OUTPUT_BASE, APPLICATIONS_PATH: OUTPUT_BASE, @@ -30,6 +30,13 @@ if (typeof window === "undefined") { const baseApp: ApplicationNested = { railpackVersion: "0.15.4", + azureDevopsId: null, + azureDevopsProjectId: null, + azureDevopsRepositoryId: null, + azureDevopsProjectName: null, + azureDevopsRepositoryName: null, + azureDevopsBranch: null, + azureDevopsBuildPath: "/", applicationId: "", previewLabels: [], networkIds: [], diff --git a/apps/dokploy/__test__/git-provider/azure-devops-build-paths.test.ts b/apps/dokploy/__test__/git-provider/azure-devops-build-paths.test.ts new file mode 100644 index 0000000000..4d278e284c --- /dev/null +++ b/apps/dokploy/__test__/git-provider/azure-devops-build-paths.test.ts @@ -0,0 +1,66 @@ +import { dirname, join } from "node:path"; +import { azureDevopsRepositoryPath } from "@dokploy/server/db/schema"; +import type { ApplicationNested } from "@dokploy/server/utils/builders"; +import { getDockerCommand } from "@dokploy/server/utils/builders/docker-file"; +import { getHerokuCommand } from "@dokploy/server/utils/builders/heroku"; +import { getNixpacksCommand } from "@dokploy/server/utils/builders/nixpacks"; +import { getPaketoCommand } from "@dokploy/server/utils/builders/paketo"; +import { getRailpackCommand } from "@dokploy/server/utils/builders/railpack"; +import { createEnvFileCommand } from "@dokploy/server/utils/builders/utils"; +import { getBuildAppDirectory } from "@dokploy/server/utils/filesystem/directory"; +import { parse } from "shell-quote"; +import { describe, expect, it } from "vitest"; + +const application = (buildType: string) => + ({ + sourceType: "azureDevops", + appName: "azure-build", + buildType, + azureDevopsBuildPath: "nested spaces $(AZURE_INJECTION_SENTINEL) 'quoted'", + env: "", + buildArgs: "", + buildSecrets: "", + createEnvFile: true, + cleanCache: false, + dockerfile: "Dockerfile", + herokuVersion: "24", + railpackVersion: "0.15.1", + environment: { env: "", project: { env: "" } }, + }) as unknown as ApplicationNested; + +describe("Azure DevOps build paths", () => { + it.each([ + ["dockerfile", getDockerCommand], + ["nixpacks", getNixpacksCommand], + ["heroku_buildpacks", getHerokuCommand], + ["paketo_buildpacks", getPaketoCommand], + ["railpack", getRailpackCommand], + ] as const)( + "keeps paths with spaces and shell characters as one argument for %s", + (type, build) => { + const app = application(type); + const args = parse( + build(app) + .split("\n") + .filter((line) => !line.trim().startsWith("#")) + .join("\n"), + ); + expect(args).toContain(getBuildAppDirectory(app)); + expect(args).not.toContain("AZURE_INJECTION_SENTINEL"); + }, + ); + it("quotes the environment file destination without expanding repository path characters", () => { + const file = getBuildAppDirectory(application("dockerfile")); + const args = parse(createEnvFileCommand(file, "VALUE=literal")); + expect(args).toContain(join(dirname(file), ".env")); + expect(args).not.toContain("AZURE_INJECTION_SENTINEL"); + }); + it.each([ + "../outside", + "nested/../../outside", + "nested\\..\\outside", + "bad\npath", + ])("rejects paths escaping the repository: %s", (path) => + expect(azureDevopsRepositoryPath.safeParse(path).success).toBe(false), + ); +}); diff --git a/apps/dokploy/__test__/git-provider/azure-devops-client.test.ts b/apps/dokploy/__test__/git-provider/azure-devops-client.test.ts new file mode 100644 index 0000000000..136c864ce1 --- /dev/null +++ b/apps/dokploy/__test__/git-provider/azure-devops-client.test.ts @@ -0,0 +1,244 @@ +import { + AzureDevopsClient, + normalizeAzureDevopsUrl, +} from "@dokploy/server/utils/providers/azure-devops-client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const token = "test-private-pat"; +const json = (value: unknown, headers?: Record) => + new Response(JSON.stringify(value), { + headers: { "content-type": "application/json", ...headers }, + }); +afterEach(() => vi.useRealTimers()); + +describe("Azure DevOps collection URLs and authentication", () => { + it.each([ + "https://dev.azure.com/contoso", + "https://contoso.visualstudio.com", + "https://ado.example.com:8443/tfs/DefaultCollection", + "https://ado.example.com/DefaultCollection", + "https://ado.example.com/tfs/Team%20Collection", + ])("preserves %s and paginates projects", async (base) => { + const request = vi + .fn() + .mockResolvedValueOnce( + json( + { value: [{ id: "1", name: "One" }] }, + { "x-ms-continuationtoken": "next/+" }, + ), + ) + .mockResolvedValueOnce(json({ value: [{ id: "2", name: "Two" }] })); + const client = new AzureDevopsClient(`${base}/`, token, request); + expect(await client.projects()).toHaveLength(2); + const url = new URL(String(request.mock.calls[1]?.[0])); + expect(url.href).toContain(`${base}/_apis/projects`); + expect(url.searchParams.get("continuationToken")).toBe("next/+"); + expect(url.searchParams.get("api-version")).toBe("7.0"); + expect(request.mock.calls[0]?.[1]).toMatchObject({ + redirect: "manual", + headers: { + Authorization: `Basic ${Buffer.from(`:${token}`).toString("base64")}`, + }, + }); + expect(url.href).not.toContain(token); + }); + it.each([ + "http://ado.test/collection", + "https://user:secret@ado.test/collection", + "https://ado.test", + "https://dev.azure.com", + "https://visualstudio.com", + "https://contoso.visualstudio.com.evil.test", + "https://other.contoso.visualstudio.com", + "https://ado.test/c?token=x", + "https://ado.test/c#x", + "https://ado.test/c%2fother", + "https://ado.test/c\\evil", + ])("rejects unsafe URL %s", (value) => + expect(() => normalizeAzureDevopsUrl(value)).toThrow("HTTPS"), + ); + it.each([401, 403, 404, 500, 302])( + "sanitizes HTTP %s without exposing headers or bodies", + async (status) => { + const request = vi.fn().mockResolvedValue( + new Response(`secret ${token}`, { + status, + headers: { location: "https://login.test" }, + }), + ); + const client = new AzureDevopsClient( + "https://ado.test/c", + token, + request, + ); + const error = await client.projects().catch((error) => error); + expect(error.status).toBe(status); + expect(JSON.stringify(error)).not.toContain(token); + expect(String(error)).not.toContain(token); + expect(request).toHaveBeenCalledTimes(1); + }, + ); + it("rejects an HTML login page returned with HTTP 200", async () => { + const client = new AzureDevopsClient( + "https://ado.test/c", + token, + vi.fn().mockResolvedValue( + new Response("Login", { + headers: { "content-type": "text/html" }, + }), + ), + ); + await expect(client.projects()).rejects.toThrow("non-JSON"); + }); + it("sanitizes invalid JSON and TLS failures", async () => { + for (const request of [ + vi.fn().mockResolvedValue( + new Response(`{${token}`, { + headers: { "content-type": "application/json" }, + }), + ), + vi.fn().mockRejectedValue(new Error(`TLS ${token}`)), + ]) { + await expect( + new AzureDevopsClient("https://ado.test/c", token, request).projects(), + ).rejects.not.toThrow(token); + } + }); + it("times out without exposing the request", async () => { + vi.useFakeTimers(); + const request = vi + .fn() + .mockImplementation( + (_url, options) => + new Promise((_resolve, reject) => + options?.signal?.addEventListener("abort", () => + reject(new Error(token)), + ), + ), + ); + const result = new AzureDevopsClient( + "https://ado.test/c", + token, + request, + ).projects(); + const assertion = expect(result).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(20_000); + await assertion; + }); + it("paginates refs and encodes path segments", async () => { + const request = vi + .fn() + .mockResolvedValueOnce( + json( + { value: [{ name: "refs/heads/a" }] }, + { "x-ms-continuationtoken": "2" }, + ), + ) + .mockResolvedValueOnce(json({ value: [{ name: "refs/heads/b" }] })); + expect( + await new AzureDevopsClient( + "https://ado.test/tfs/c", + token, + request, + ).branches("team/name", "repo id"), + ).toHaveLength(2); + expect(String(request.mock.calls[0]?.[0])).toContain( + "/team%2Fname/_apis/git/repositories/repo%20id/refs?", + ); + }); + it("rejects repeated continuation tokens", async () => { + const request = vi + .fn() + .mockImplementation(async () => + json({ value: [] }, { "x-ms-continuationtoken": "same" }), + ); + await expect( + new AzureDevopsClient("https://ado.test/c", token, request).projects(), + ).rejects.toThrow("pagination token"); + }); + it("does not use unsupported repository page/limit parameters", async () => { + const request = vi + .fn() + .mockResolvedValue(json({ value: [] })); + expect( + await new AzureDevopsClient( + "https://ado.test/c", + token, + request, + ).repositories("project"), + ).toEqual([]); + expect(String(request.mock.calls[0]?.[0])).toBe( + "https://ado.test/c/project/_apis/git/repositories?api-version=7.0", + ); + }); + it("paginates diffs and includes old and new rename paths", async () => { + const request = vi + .fn() + .mockResolvedValueOnce( + json({ + commonCommit: "abc", + allChangesIncluded: false, + changes: [ + { item: { path: "/new/name" }, sourceServerItem: "/old/name" }, + ], + }), + ) + .mockResolvedValueOnce( + json({ + commonCommit: "abc", + allChangesIncluded: true, + changes: [{ item: { path: "/deleted" } }], + }), + ); + const paths = await new AzureDevopsClient( + "https://ado.test/c", + token, + request, + ).changedPaths("project", "repo", "before", "after"); + expect(paths).toEqual(["new/name", "old/name", "deleted"]); + expect( + new URL(String(request.mock.calls[1]?.[0])).searchParams.get("$skip"), + ).toBe("1"); + }); + it.each([ + "https://evil.test/c/r", + "https://ado.test/another/r", + "https://ado.test/collection-evil/r", + "https://ado.test/collection/../evil", + "https://u:secret@ado.test/collection/r", + "http://ado.test/collection/r", + ])("rejects out-of-scope clone URL %s", (url) => + expect(() => + new AzureDevopsClient("https://ado.test/collection", token).cloneUrl(url), + ).toThrow("outside"), + ); + it("removes Azure Services organization username from clone URLs", () => + expect( + new AzureDevopsClient("https://dev.azure.com/contoso", token).cloneUrl( + "https://contoso@dev.azure.com/contoso/project/_git/repo", + ), + ).toBe("https://dev.azure.com/contoso/project/_git/repo")); + it.each([ + "https://contoso.visualstudio.com/project/_git/repo", + "https://contoso.visualstudio.com/DefaultCollection/project/_git/repo", + ])("accepts same-organization legacy Services clone URL %s", (url) => { + const client = new AzureDevopsClient( + "https://contoso.visualstudio.com/", + token, + ); + expect(client.baseUrl).toBe("https://contoso.visualstudio.com"); + expect(client.cloneUrl(url)).toBe(url); + }); + it.each([ + "https://other.visualstudio.com/project/_git/repo", + "https://contoso.visualstudio.com.evil.test/project/_git/repo", + "https://contoso.visualstudio.com:8443/project/_git/repo", + "https://user:secret@contoso.visualstudio.com/project/_git/repo", + ])("rejects legacy Services clone URL outside its organization %s", (url) => { + const client = new AzureDevopsClient( + "https://contoso.visualstudio.com", + token, + ); + expect(() => client.cloneUrl(url)).toThrow("outside"); + }); +}); diff --git a/apps/dokploy/__test__/git-provider/azure-devops-clone.test.ts b/apps/dokploy/__test__/git-provider/azure-devops-clone.test.ts new file mode 100644 index 0000000000..4dc207218e --- /dev/null +++ b/apps/dokploy/__test__/git-provider/azure-devops-clone.test.ts @@ -0,0 +1,207 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ExecError } from "@dokploy/server/utils/process/ExecError"; +import { + cloneAzureDevopsRepository, + prepareAzureDevopsClone, +} from "@dokploy/server/utils/providers/azure-devops"; +import { AzureDevopsClient } from "@dokploy/server/utils/providers/azure-devops-client"; +import { parse } from "shell-quote"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + exec: vi.fn(), + remote: vi.fn(), + find: vi.fn(), + append: vi.fn(), +})); +vi.mock("@dokploy/server/services/azure-devops", () => ({ + findAzureDevopsById: mocks.find, +})); +vi.mock("@dokploy/server/utils/process/execAsync", async () => ({ + ...(await vi.importActual("@dokploy/server/utils/process/ExecError")), + execAsync: mocks.exec, + execAsyncRemote: mocks.remote, +})); +vi.mock("node:fs/promises", () => ({ appendFile: mocks.append })); + +const token = "private-test-pat"; +const baseUrl = "https://ado.test/tfs/Collection"; +const source = { + appName: "app", + azureDevopsId: "provider", + azureDevopsProjectId: "project", + azureDevopsRepositoryId: "repo", + azureDevopsBranch: "feature/main", + serverId: null, + enableSubmodules: true, +}; +const remoteUrl = `${baseUrl}/project/_git/renamed`; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.find.mockResolvedValue({ baseUrl, personalAccessToken: token }); + mocks.exec.mockResolvedValue({ stdout: "cloned", stderr: "" }); + mocks.remote.mockResolvedValue({ stdout: "cloned", stderr: "" }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ remoteUrl }), { + headers: { "content-type": "application/json" }, + }), + ), + ); +}); + +describe("Azure DevOps clone credential isolation", () => { + it.skipIf(process.platform === "win32").each([ + { remote: false, verbose: "0" }, + { remote: false, verbose: "1" }, + { remote: true, verbose: "0" }, + { remote: true, verbose: "1" }, + ])( + "disables inherited curl verbosity in the clone shell (remote=$remote, value=$verbose)", + async ({ remote, verbose }) => { + const directory = mkdtempSync(join(tmpdir(), "dokploy-azure-trace-")); + try { + await cloneAzureDevopsRepository({ + ...source, + serverId: remote ? "remote" : null, + outputPathOverride: join(directory, "repo"), + }); + const [command, options, input] = remote + ? [mocks.remote.mock.calls[0]![1], {}, mocks.remote.mock.calls[0]![3]] + : [ + mocks.exec.mock.calls[0]![0], + mocks.exec.mock.calls[0]![1], + undefined, + ]; + const output = execFileSync( + "/bin/sh", + [ + "-c", + `export GIT_CURL_VERBOSE=${verbose}; git() { if [ "\${GIT_CURL_VERBOSE+x}" = x ]; then echo 'curl verbosity is enabled' >&2; return 70; fi; printf 'clone completed'; }; ${command}`, + ], + { ...options, input, encoding: "utf8" }, + ); + expect(output).toBe("clone completed"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, + ); + it("scopes legacy Services credentials to the organization hostname", () => { + const client = new AzureDevopsClient( + "https://contoso.visualstudio.com", + token, + ); + const url = "https://contoso.visualstudio.com/project/_git/repo"; + const plan = prepareAzureDevopsClone(source, client, url, token); + expect(parse(plan.command)).toContain(url); + expect(plan.env.GIT_CONFIG_KEY_0).toBe( + "http.https://contoso.visualstudio.com/.extraHeader", + ); + expect(plan.command).not.toContain(token); + }); + it("uses Azure's canonical collection path casing for Git's URL-scoped header", () => { + const plan = prepareAzureDevopsClone( + source, + new AzureDevopsClient("https://ado.test/tfs/collection", token), + remoteUrl, + token, + ); + expect(plan.env.GIT_CONFIG_KEY_0).toBe(`http.${baseUrl}/.extraHeader`); + }); + it("keeps the URL/command clean and scopes the header to the collection", () => { + const plan = prepareAzureDevopsClone( + source, + new AzureDevopsClient(baseUrl, token), + remoteUrl, + token, + ); + expect(plan.command).not.toContain(token); + expect(plan.command).not.toContain( + Buffer.from(`:${token}`).toString("base64"), + ); + expect(parse(plan.command)).toContain(remoteUrl); + expect(plan.env.GIT_CONFIG_KEY_0).toBe(`http.${baseUrl}/.extraHeader`); + expect(plan.env.GIT_CONFIG_VALUE_0).toContain("Authorization: Basic"); + expect(plan.env.GIT_CONFIG_VALUE_1).toBe("false"); + expect(plan.env.GIT_TRACE_CURL).toBe("0"); + expect(plan.command).not.toContain("git config"); + }); + it("refreshes repository metadata and uses child-process environment locally", async () => { + const inheritedAuth = process.env.GIT_CONFIG_VALUE_0; + await cloneAzureDevopsRepository(source); + expect(fetch).toHaveBeenCalledWith( + expect.objectContaining({ + pathname: "/tfs/Collection/project/_apis/git/repositories/repo", + }), + expect.any(Object), + ); + const [command, options] = mocks.exec.mock.calls[0]!; + expect(parse(command)).toContain(remoteUrl); + expect(options.env.GIT_CONFIG_VALUE_0).toContain("Basic"); + expect(process.env.GIT_CONFIG_VALUE_0).toBe(inheritedAuth); + }); + it("sends remote credentials only through SSH stdin", async () => { + await cloneAzureDevopsRepository({ ...source, serverId: "remote" }); + const [, command, , input] = mocks.remote.mock.calls[0]!; + expect(command).not.toContain(token); + expect(command).not.toContain(Buffer.from(`:${token}`).toString("base64")); + expect(command).toContain("IFS= read -r"); + expect(input).toBe( + `Authorization: Basic ${Buffer.from(`:${token}`).toString("base64")}\n`, + ); + expect(mocks.exec).not.toHaveBeenCalled(); + }); + it.each([false, true])( + "sanitizes error details and logs (remote=%s)", + async (remote) => { + const base64 = Buffer.from(`:${token}`).toString("base64"); + const error = new ExecError(`leak ${token}`, { + command: `bad ${token}`, + originalError: new Error(token), + stdout: token, + stderr: `Authorization: Basic ${base64}`, + }); + (remote ? mocks.remote : mocks.exec).mockRejectedValueOnce(error); + const result = await cloneAzureDevopsRepository({ + ...source, + logPath: "/tmp/test.log", + serverId: remote ? "server" : null, + }).catch((error) => error); + expect(result).toBeInstanceOf(ExecError); + for (const secret of [token, base64]) { + expect(JSON.stringify(result)).not.toContain(secret); + expect(result.getDetailedMessage()).not.toContain(secret); + if (!remote) + expect(mocks.append.mock.calls.flat().join(" ")).not.toContain( + secret, + ); + else + expect( + mocks.remote.mock.calls.slice(1).flat().join(" "), + ).not.toContain(secret); + } + expect(result.originalError).toBeUndefined(); + }, + ); + it("quotes branch and output paths against shell injection", () => { + const plan = prepareAzureDevopsClone( + { + ...source, + azureDevopsBranch: "x'; touch /tmp/injected; #", + outputPathOverride: "/tmp/a'; touch /tmp/injected; #", + }, + new AzureDevopsClient(baseUrl, token), + remoteUrl, + token, + ); + expect(parse(plan.command)).toContain("x'; touch /tmp/injected; #"); + expect(parse(plan.command)).toContain("/tmp/a'; touch /tmp/injected; #"); + expect(parse(plan.command)).not.toContain("touch"); + }); +}); diff --git a/apps/dokploy/__test__/git-provider/azure-devops-hooks.test.ts b/apps/dokploy/__test__/git-provider/azure-devops-hooks.test.ts new file mode 100644 index 0000000000..82aa76f094 --- /dev/null +++ b/apps/dokploy/__test__/git-provider/azure-devops-hooks.test.ts @@ -0,0 +1,318 @@ +import { getTableName } from "drizzle-orm"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + service: {} as Record | undefined, + provider: {} as Record | undefined, + records: [] as Record[], + remote: [] as Record[], + locked: vi.fn(), + post: vi.fn(), + put: vi.fn(), + failDelete: false, + loseCreate: false, + missing: false, + failCreate: false, + versions: ["1.0", "2.0"], + callbackOrigin: "https://dokploy.test", +})); + +vi.mock("@dokploy/server/services/admin", () => ({ + getDokployUrl: async () => state.callbackOrigin, +})); +vi.mock("@dokploy/server/db", () => { + const query = { + applications: { findFirst: async () => state.service }, + compose: { findFirst: async () => state.service }, + azureDevops: { findFirst: async () => state.provider }, + azureDevopsHook: { + findMany: async () => state.records.map((record) => ({ ...record })), + }, + }; + const tx = { + query, + execute: state.locked, + insert: () => ({ + values: (value: Record) => ({ + onConflictDoNothing: async () => { + if ( + !state.records.some( + (record) => + record.serviceId === value.serviceId && + record.azureDevopsId === value.azureDevopsId, + ) + ) + state.records.push({ + id: "record", + status: "pending", + subscriptionId: null, + ...value, + }); + }, + returning: async () => { + const record = { + id: "record", + status: "pending", + subscriptionId: null, + ...value, + }; + state.records.push(record); + return [record]; + }, + }), + }), + update: (table: Parameters[0]) => ({ + set: (value: Record) => ({ + where: async () => { + if (getTableName(table) === "azure_devops_hook") + Object.assign(state.records[0]!, value); + }, + }), + }), + delete: () => ({ + where: async () => { + state.records = []; + }, + }), + }; + return { + db: { + ...tx, + transaction: async (callback: (tx: unknown) => unknown) => callback(tx), + }, + }; +}); + +import { syncAzureDevopsWebhook } from "@dokploy/server/services/azure-devops-hooks"; + +const project = "11111111-1111-4111-8111-111111111111"; +const repo = "22222222-2222-4222-8222-222222222222"; +const response = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }); + +beforeEach(() => { + vi.clearAllMocks(); + state.records = []; + state.remote = []; + state.failDelete = false; + state.loseCreate = false; + state.missing = false; + state.failCreate = false; + state.versions = ["1.0", "2.0"]; + state.callbackOrigin = "https://dokploy.test"; + state.service = { + sourceType: "azureDevops", + azureDevopsId: "provider", + azureDevopsProjectId: project, + azureDevopsRepositoryId: repo, + azureDevopsBranch: "main", + autoDeploy: true, + refreshToken: "callback-secret", + environment: { project: { organizationId: "org" } }, + }; + state.provider = { + azureDevopsId: "provider", + baseUrl: "https://ado.test/tfs/c", + personalAccessToken: "private-pat", + }; + vi.stubGlobal( + "fetch", + vi.fn(async (url: URL, options: RequestInit) => { + const method = options.method; + if (url.pathname.endsWith("publishers/tfs")) + return response({ + supportedEvents: [ + { id: "git.push", supportedResourceVersions: state.versions }, + ], + }); + if (method === "GET") return response({ value: state.remote }); + if (method === "POST") { + if (state.failCreate) + return response({ message: "private-pat callback-secret" }, 403); + const data = { + ...JSON.parse(options.body as string), + id: `subscription-${state.remote.length + 1}`, + }; + state.remote.push(data); + state.post(data); + if (state.loseCreate) { + state.loseCreate = false; + throw new Error("Lost response private-pat"); + } + return response(data); + } + if (method === "PUT") { + state.put(JSON.parse(options.body as string)); + if (state.missing) { + state.missing = false; + return response({}, 404); + } + return response({ id: "subscription-1" }); + } + if (method === "DELETE") { + if (state.failDelete) + return response({ error: "private-pat callback-secret" }, 403); + state.remote = []; + return new Response(null, { status: 204 }); + } + return response({}, 500); + }), + ); +}); + +describe("Azure DevOps hook lifecycle", () => { + it("does not require hook permissions when first saved in manual mode", async () => { + state.service!.autoDeploy = false; + expect( + (await syncAzureDevopsWebhook("application", "service"))[0], + ).toMatchObject({ status: "disabled" }); + expect(fetch).not.toHaveBeenCalled(); + }); + it("falls back to stable payload version 1.0", async () => { + state.versions = ["1.0"]; + await syncAzureDevopsWebhook("application", "service"); + expect(state.post).toHaveBeenCalledWith( + expect.objectContaining({ resourceVersion: "1.0" }), + ); + }); + it("reports unsupported payload versions without creating a subscription", async () => { + state.versions = ["1.0-preview.1"]; + expect( + (await syncAzureDevopsWebhook("application", "service"))[0], + ).toMatchObject({ + status: "error", + error: expect.stringContaining("payload version"), + }); + expect(state.post).not.toHaveBeenCalled(); + }); + it("retains the source and sanitizes setup permission errors", async () => { + state.failCreate = true; + const result = await syncAzureDevopsWebhook("application", "service"); + expect(result[0]).toMatchObject({ status: "error" }); + expect(state.service?.azureDevopsId).toBe("provider"); + expect(JSON.stringify(result)).not.toContain("private-pat"); + expect(JSON.stringify(result)).not.toContain("callback-secret"); + }); + it("updates callback URLs after a Dokploy domain change", async () => { + await syncAzureDevopsWebhook("application", "service"); + state.callbackOrigin = "https://changed.test"; + await syncAzureDevopsWebhook("application", "service"); + expect(state.put).toHaveBeenCalledWith( + expect.objectContaining({ + consumerInputs: expect.objectContaining({ + url: expect.stringContaining("https://changed.test/api/deploy/"), + }), + }), + ); + }); + it.each(["application", "compose"] as const)( + "creates a %s hook with filters and a stable payload version", + async (type) => { + const result = await syncAzureDevopsWebhook(type, "service"); + expect(state.locked).toHaveBeenCalled(); + expect(result[0]).toMatchObject({ + status: "active", + subscriptionId: "subscription-1", + }); + expect(state.post).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: "git.push", + resourceVersion: "2.0", + publisherInputs: { + projectId: project, + repository: repo, + branch: "main", + }, + consumerInputs: expect.objectContaining({ + url: expect.stringContaining( + type === "compose" ? "/api/deploy/compose/" : "/api/deploy/", + ), + }), + }), + ); + expect(JSON.stringify(result)).not.toContain("callback-secret"); + expect(JSON.stringify(result)).not.toContain("private-pat"); + }, + ); + it("updates the known subscription after repeated saves, branch and token changes", async () => { + await syncAzureDevopsWebhook("application", "service"); + state.service!.azureDevopsBranch = "release"; + state.service!.refreshToken = "rotated"; + await syncAzureDevopsWebhook("application", "service"); + expect(state.post).toHaveBeenCalledTimes(1); + expect(state.put).toHaveBeenCalledWith( + expect.objectContaining({ + publisherInputs: expect.objectContaining({ branch: "release" }), + consumerInputs: expect.objectContaining({ + url: expect.stringContaining("/rotated?"), + }), + }), + ); + }); + it("recovers an uncertain create without creating a duplicate", async () => { + state.loseCreate = true; + expect( + (await syncAzureDevopsWebhook("application", "service"))[0], + ).toMatchObject({ status: "error" }); + expect(JSON.stringify(state.records)).not.toContain("private-pat"); + await syncAzureDevopsWebhook("application", "service"); + expect(state.post).toHaveBeenCalledTimes(1); + expect(state.records[0]).toMatchObject({ status: "active" }); + }); + it("recreates a known subscription after Azure returns 404", async () => { + await syncAzureDevopsWebhook("application", "service"); + state.missing = true; + await syncAzureDevopsWebhook("application", "service"); + expect(state.post).toHaveBeenCalledTimes(2); + }); + it("disables and re-enables automatic deployments", async () => { + await syncAzureDevopsWebhook("compose", "service"); + state.service!.autoDeploy = false; + expect( + (await syncAzureDevopsWebhook("compose", "service"))[0], + ).toMatchObject({ status: "disabled", subscriptionId: null }); + expect(state.remote).toHaveLength(0); + state.service!.autoDeploy = true; + await syncAzureDevopsWebhook("compose", "service"); + expect(state.records[0]).toMatchObject({ status: "active" }); + }); + it("retains safe cleanup information after a service was deleted", async () => { + await syncAzureDevopsWebhook("application", "service"); + state.service = undefined; + state.failDelete = true; + const records = await syncAzureDevopsWebhook("application", "service"); + expect(records[0]).toMatchObject({ + status: "cleanupPending", + baseUrl: "https://ado.test/tfs/c", + projectId: project, + subscriptionId: "subscription-1", + }); + expect(JSON.stringify(records)).not.toContain("private-pat"); + expect(JSON.stringify(records)).not.toContain("callback-secret"); + state.failDelete = false; + await syncAzureDevopsWebhook("application", "service"); + expect(state.records).toHaveLength(0); + }); + it("cleans up an uncertain create after disconnect", async () => { + state.loseCreate = true; + await syncAzureDevopsWebhook("application", "service"); + state.service!.sourceType = "git"; + await syncAzureDevopsWebhook("application", "service"); + expect(state.remote).toHaveLength(0); + expect(state.records).toHaveLength(0); + }); + it("keeps cleanup records after provider deletion", async () => { + await syncAzureDevopsWebhook("application", "service"); + state.service = undefined; + state.provider = undefined; + expect( + (await syncAzureDevopsWebhook("application", "service"))[0], + ).toMatchObject({ + status: "cleanupPending", + subscriptionId: "subscription-1", + }); + }); +}); diff --git a/apps/dokploy/__test__/git-provider/azure-devops-provider.test.ts b/apps/dokploy/__test__/git-provider/azure-devops-provider.test.ts new file mode 100644 index 0000000000..9afe5ff8c7 --- /dev/null +++ b/apps/dokploy/__test__/git-provider/azure-devops-provider.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + provider: vi.fn(), + providers: vi.fn(), + member: vi.fn(), + app: vi.fn(), + compose: vi.fn(), + update: vi.fn(), + sync: vi.fn(), +})); +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + azureDevops: { findFirst: mocks.provider }, + gitProvider: { + findMany: mocks.providers, + findFirst: async () => (await mocks.provider()).gitProvider, + }, + member: { findFirst: mocks.member }, + applications: { findFirst: mocks.app }, + compose: { findFirst: mocks.compose }, + }, + transaction: async (callback: (tx: unknown) => unknown) => + callback({ + execute: vi.fn(), + query: { + applications: { findFirst: mocks.app }, + compose: { findFirst: mocks.compose }, + }, + update: () => ({ + set: mocks.update.mockReturnValue({ where: async () => undefined }), + }), + }), + }, +})); +vi.mock("@dokploy/server/services/azure-devops-hooks", () => ({ + reconcileAzureDevopsProvider: mocks.sync, +})); +vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ + hasValidLicense: async () => true, +})); + +import { azureDevops } from "@dokploy/server/db/schema"; +import { + assertAzureDevopsSourceAccess, + safeAzureDevops, + updateAzureDevops, +} from "@dokploy/server/services/azure-devops"; +import { assertGitProviderAccess } from "@dokploy/server/services/git-provider"; + +const provider = { + azureDevopsId: "azure", + gitProviderId: "gp", + baseUrl: "https://ado.test/tfs/c", + personalAccessToken: "private-pat", + gitProvider: { + gitProviderId: "gp", + organizationId: "org", + userId: "creator", + sharedWithOrganization: false, + providerType: "azureDevops" as const, + name: "Azure", + createdAt: "now", + }, +}; +const session = { userId: "member", activeOrganizationId: "org" }; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.provider.mockResolvedValue(provider); + mocks.providers.mockResolvedValue([provider.gitProvider]); + mocks.member.mockResolvedValue({ role: "member", accessedGitProviders: [] }); + mocks.app.mockResolvedValue(undefined); + mocks.compose.mockResolvedValue(undefined); +}); + +describe("Azure DevOps provider access and secret boundaries", () => { + it("encrypts the PAT through the shared database column type", () => { + const stored = + azureDevops.personalAccessToken.mapToDriverValue("private-pat"); + expect(stored).toMatch(/^enc:v1:/); + expect(stored).not.toContain("private-pat"); + expect(azureDevops.personalAccessToken.mapFromDriverValue(stored)).toBe( + "private-pat", + ); + }); + it("omits the PAT even for the provider owner", () => { + const safe = safeAzureDevops(provider); + expect(safe).not.toHaveProperty("personalAccessToken"); + expect(JSON.stringify(safe)).not.toContain("private-pat"); + expect(safe.isConfigured).toBe(true); + }); + it("rejects cross-organization access", async () => + expect( + assertGitProviderAccess( + { ...session, activeOrganizationId: "other" }, + provider.gitProvider, + ), + ).rejects.toMatchObject({ code: "NOT_FOUND" })); + it("rejects unassigned private providers when connecting a source", async () => + expect( + assertAzureDevopsSourceAccess( + session, + { sourceType: "git", azureDevopsId: null }, + "azure", + ), + ).rejects.toMatchObject({ code: "FORBIDDEN" })); + it.each([true, false])( + "allows shared or explicitly assigned providers for a new source (shared=%s)", + async (shared) => { + mocks.providers.mockResolvedValue([ + { ...provider.gitProvider, sharedWithOrganization: shared }, + ]); + mocks.member.mockResolvedValue({ + role: "member", + accessedGitProviders: ["gp"], + }); + await expect( + assertAzureDevopsSourceAccess( + session, + { sourceType: "git", azureDevopsId: null }, + "azure", + ), + ).resolves.toBeUndefined(); + }, + ); + it("does not grant management rights to shared-provider users", async () => { + mocks.providers.mockResolvedValue([ + { ...provider.gitProvider, sharedWithOrganization: true }, + ]); + await expect( + updateAzureDevops({ azureDevopsId: "azure", name: "renamed" }, session), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(mocks.update).not.toHaveBeenCalled(); + }); + it("does not grant existing-source editing rights to assigned-only users", async () => { + mocks.member.mockResolvedValue({ + role: "member", + accessedGitProviders: ["gp"], + }); + await expect( + assertAzureDevopsSourceAccess( + session, + { sourceType: "azureDevops", azureDevopsId: "azure" }, + "azure", + ), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + it("preserves existing-source restrictions for an admin using another user's private provider", async () => { + mocks.member.mockResolvedValue({ role: "admin" }); + await expect( + assertAzureDevopsSourceAccess(session, { + sourceType: "azureDevops", + azureDevopsId: "azure", + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + it.each(["owner", "admin"])( + "allows organization %s to rotate credentials", + async (role) => { + mocks.member.mockResolvedValue({ role }); + await expect( + updateAzureDevops( + { azureDevopsId: "azure", personalAccessToken: "rotated-pat" }, + session, + ), + ).resolves.toEqual({ success: true }); + expect(mocks.update).toHaveBeenCalledWith( + expect.objectContaining({ personalAccessToken: "rotated-pat" }), + ); + }, + ); + it("preserves the PAT when editing without a new token", async () => { + await updateAzureDevops( + { azureDevopsId: "azure", name: "Renamed" }, + { ...session, userId: "creator" }, + ); + expect(mocks.update).toHaveBeenCalledTimes(1); + expect(mocks.update).toHaveBeenCalledWith({ name: "Renamed" }); + expect( + mocks.update.mock.calls.every( + ([value]) => !Object.hasOwn(value, "personalAccessToken"), + ), + ).toBe(true); + }); + it("rejects base URL changes while either service type is connected", async () => { + for (const connected of [mocks.app, mocks.compose]) { + connected.mockResolvedValue({ id: "service" }); + await expect( + updateAzureDevops( + { azureDevopsId: "azure", baseUrl: "https://ado.test/other" }, + { ...session, userId: "creator" }, + ), + ).rejects.toMatchObject({ code: "CONFLICT" }); + connected.mockResolvedValue(undefined); + } + expect(mocks.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/__test__/git-provider/azure-devops-push.test.ts b/apps/dokploy/__test__/git-provider/azure-devops-push.test.ts new file mode 100644 index 0000000000..a3b37326c2 --- /dev/null +++ b/apps/dokploy/__test__/git-provider/azure-devops-push.test.ts @@ -0,0 +1,135 @@ +import type { AzureDevopsClient } from "@dokploy/server/utils/providers/azure-devops-client"; +import { evaluateAzureDevopsPush } from "@dokploy/server/utils/providers/azure-devops-push"; +import { describe, expect, it, vi } from "vitest"; + +const project = "11111111-1111-4111-8111-111111111111"; +const repo = "22222222-2222-4222-8222-222222222222"; +const before = "a".repeat(40); +const after = "b".repeat(40); +const source = { + sourceType: "azureDevops", + autoDeploy: true, + azureDevopsId: "provider", + azureDevopsProjectId: project, + azureDevopsRepositoryId: repo, + azureDevopsBranch: "main", + watchPaths: null, +}; +const payload = ( + refs = [{ name: "refs/heads/main", oldObjectId: before, newObjectId: after }], +) => ({ + eventType: "git.push", + resource: { + repository: { id: repo, project: { id: project } }, + refUpdates: refs, + commits: [{ commitId: after, comment: "Selected commit" }], + }, +}); +const client = (paths: string[]) => + ({ + changedPaths: vi.fn().mockResolvedValue(paths), + }) as unknown as AzureDevopsClient; + +describe("Azure DevOps push filtering", () => { + it("selects the second ref, its hash and commit message", async () => { + const getClient = vi.fn(); + const body = payload([ + { + name: "refs/heads/other", + oldObjectId: before, + newObjectId: "c".repeat(40), + }, + { name: "refs/heads/main", oldObjectId: before, newObjectId: after }, + ]); + expect( + await evaluateAzureDevopsPush("POST", body, source, getClient), + ).toMatchObject({ deploy: true, hash: after, title: "Selected commit" }); + expect(getClient).not.toHaveBeenCalled(); + }); + it.each(["GET", "PUT", undefined])("requires POST (%s)", async (method) => + expect( + await evaluateAzureDevopsPush(method, payload(), source, vi.fn()), + ).toMatchObject({ deploy: false, status: 405 }), + ); + it.each([{}, null, { eventType: "git.push", resource: {} }])( + "rejects malformed payloads", + async (body) => + expect( + await evaluateAzureDevopsPush("POST", body, source, vi.fn()), + ).toMatchObject({ deploy: false, status: 400 }), + ); + it.each(["refs/tags/main", "refs/heads/other"])("ignores %s", async (name) => + expect( + await evaluateAzureDevopsPush( + "POST", + payload([{ name, oldObjectId: before, newObjectId: after }]), + source, + vi.fn(), + ), + ).toMatchObject({ deploy: false }), + ); + it("ignores deleted branches", async () => + expect( + await evaluateAzureDevopsPush( + "POST", + payload([ + { + name: "refs/heads/main", + oldObjectId: before, + newObjectId: "0".repeat(40), + }, + ]), + source, + vi.fn(), + ), + ).toMatchObject({ deploy: false })); + it.each([ + { ...source, azureDevopsId: null }, + { ...source, autoDeploy: false }, + { ...source, azureDevopsRepositoryId: project }, + { ...source, azureDevopsProjectId: repo }, + { ...source, sourceType: "git" }, + ])( + "ignores disconnected, disabled and mismatched sources", + async (configured) => + expect( + await evaluateAzureDevopsPush("POST", payload(), configured, vi.fn()), + ).toMatchObject({ deploy: false }), + ); + it("uses a safe title when the selected commit is absent from the payload", async () => { + const body = payload(); + body.resource.commits = []; + expect( + await evaluateAzureDevopsPush("POST", body, source, vi.fn()), + ).toMatchObject({ deploy: true, title: "Azure DevOps push" }); + }); + it.each([ + ["src/a.ts", true], + ["README.md", false], + ])("uses the full diff for watch paths (%s)", async (path, deploy) => { + const api = client([path as string]); + expect( + await evaluateAzureDevopsPush( + "POST", + payload(), + { ...source, watchPaths: ["src/**"] }, + async () => api, + ), + ).toMatchObject({ deploy }); + expect(api.changedPaths).toHaveBeenCalledWith(project, repo, before, after); + }); + it("deploys new branches and unavailable/force-push diffs", async () => { + const getClient = vi.fn().mockRejectedValue(new Error("inaccessible")); + for (const old of [before, "0".repeat(40)]) + expect( + await evaluateAzureDevopsPush( + "POST", + payload([ + { name: "refs/heads/main", oldObjectId: old, newObjectId: after }, + ]), + { ...source, watchPaths: ["src/**"] }, + getClient, + ), + ).toMatchObject({ deploy: true, fallbackReason: expect.any(String) }); + }); +}); diff --git a/apps/dokploy/__test__/git-provider/azure-devops-sources.test.ts b/apps/dokploy/__test__/git-provider/azure-devops-sources.test.ts new file mode 100644 index 0000000000..947c69907b --- /dev/null +++ b/apps/dokploy/__test__/git-provider/azure-devops-sources.test.ts @@ -0,0 +1,165 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + updateApp: vi.fn(), + updateCompose: vi.fn(), + access: vi.fn(), + permission: vi.fn(), +})); +const current = vi.hoisted(() => ({ + sourceType: "azureDevops", + azureDevopsId: "azure", + azureDevopsProjectId: "11111111-1111-4111-8111-111111111111", + azureDevopsRepositoryId: "22222222-2222-4222-8222-222222222222", + azureDevopsProjectName: "Project", + azureDevopsRepositoryName: "Repository", + azureDevopsBranch: "main", + watchPaths: null, + environment: { project: { organizationId: "org" } }, + name: "Service", + applicationId: "app", + composeId: "compose", +})); +vi.mock("@dokploy/server", () => ({ + IS_CLOUD: true, + hasValidLicense: async () => false, + updateApplication: mocks.updateApp, + updateCompose: mocks.updateCompose, + findApplicationById: async () => current, + findComposeById: async () => current, +})); +vi.mock("@dokploy/server/index", () => ({ + IS_CLOUD: true, + hasValidLicense: async () => false, + updateApplication: mocks.updateApp, + updateCompose: mocks.updateCompose, + findApplicationById: async () => current, + findComposeById: async () => current, +})); +vi.mock("@dokploy/server/services/azure-devops", () => ({ + assertAzureDevopsSourceAccess: mocks.access, +})); +vi.mock("@dokploy/server/services/permission", () => ({ + checkServicePermissionAndAccess: mocks.permission, +})); +vi.mock("@dokploy/server/lib/auth", () => ({ validateRequest: vi.fn() })); +vi.mock("@/server/api/utils/audit", () => ({ audit: vi.fn() })); +vi.mock("@/server/queues/queueSetup", () => ({ + myQueue: { add: vi.fn() }, + cleanQueuesByCompose: vi.fn(), + killDockerBuild: vi.fn(), +})); + +const { applicationRouter } = await import("@/server/api/routers/application"); +const { composeRouter } = await import("@/server/api/routers/compose"); +const context = { + session: { activeOrganizationId: "org", userId: "user" }, + user: { id: "user", email: "user@test.invalid", role: "owner" }, +} as Parameters[0]; +const app = applicationRouter.createCaller(context); +const compose = composeRouter.createCaller(context); +const source = { + azureDevopsId: current.azureDevopsId, + azureDevopsProjectId: current.azureDevopsProjectId, + azureDevopsRepositoryId: current.azureDevopsRepositoryId, + azureDevopsProjectName: current.azureDevopsProjectName, + azureDevopsRepositoryName: current.azureDevopsRepositoryName, + azureDevopsBranch: current.azureDevopsBranch, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.updateApp.mockResolvedValue({ applicationId: "app" }); + mocks.updateCompose.mockResolvedValue({ composeId: "compose" }); + mocks.access.mockResolvedValue(undefined); +}); + +describe("Azure DevOps Application and Compose source mutations", () => { + it("allows automatic deployment updates with an unset watch-path list", async () => { + await app.update({ applicationId: "app", autoDeploy: false }); + await compose.update({ composeId: "compose", autoDeploy: true }); + expect(mocks.updateApp).toHaveBeenCalled(); + expect(mocks.updateCompose).toHaveBeenCalled(); + }); + it("saves the Application source without changing its status", async () => { + await app.saveAzureDevopsProvider({ + applicationId: "app", + ...source, + azureDevopsBuildPath: "/src", + }); + expect(mocks.updateApp).toHaveBeenCalledWith( + "app", + expect.objectContaining({ + sourceType: "azureDevops", + azureDevopsBuildPath: "/src", + }), + ); + expect(mocks.updateApp.mock.calls[0]?.[1]).not.toHaveProperty( + "applicationStatus", + ); + expect(mocks.updateCompose).not.toHaveBeenCalled(); + expect(mocks.access).toHaveBeenCalledWith( + { activeOrganizationId: "org", userId: "user" }, + current, + "azure", + ); + }); + it("saves Compose through its own update mutation without changing its status", async () => { + await compose.update({ + composeId: "compose", + ...source, + sourceType: "azureDevops", + composePath: "deploy/compose.yml", + }); + expect(mocks.updateCompose).toHaveBeenCalledWith( + "compose", + expect.objectContaining({ + sourceType: "azureDevops", + composePath: "deploy/compose.yml", + }), + ); + expect(mocks.updateCompose.mock.calls[0]?.[1]).not.toHaveProperty( + "composeStatus", + ); + expect(mocks.updateApp).not.toHaveBeenCalled(); + }); + it("enforces provider authorization on both general update endpoints", async () => { + mocks.access.mockRejectedValue(new Error("Forbidden")); + await expect( + app.update({ applicationId: "app", azureDevopsId: "forbidden" }), + ).rejects.toThrow("Forbidden"); + await expect( + compose.update({ composeId: "compose", azureDevopsId: "forbidden" }), + ).rejects.toThrow("Forbidden"); + expect(mocks.updateApp).not.toHaveBeenCalled(); + expect(mocks.updateCompose).not.toHaveBeenCalled(); + }); + it("prevents bypassing source edit restrictions through disconnect or another Application provider", async () => { + mocks.access.mockRejectedValue(new Error("Forbidden")); + await expect( + app.disconnectGitProvider({ applicationId: "app" }), + ).rejects.toThrow("Forbidden"); + await expect( + compose.disconnectGitProvider({ composeId: "compose" }), + ).rejects.toThrow("Forbidden"); + await expect( + app.saveDockerProvider({ + applicationId: "app", + dockerImage: "nginx", + username: "", + password: "", + registryUrl: "", + }), + ).rejects.toThrow("Forbidden"); + }); + it("validates project/repository IDs even when passed to the generic update endpoint", async () => { + await expect( + compose.update({ + composeId: "compose", + sourceType: "azureDevops", + azureDevopsRepositoryId: "invalid", + }), + ).rejects.toThrow(); + expect(mocks.updateCompose).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/__test__/git-provider/azure-devops-submodules.test.ts b/apps/dokploy/__test__/git-provider/azure-devops-submodules.test.ts new file mode 100644 index 0000000000..7ac7efc385 --- /dev/null +++ b/apps/dokploy/__test__/git-provider/azure-devops-submodules.test.ts @@ -0,0 +1,250 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer, type Server } from "node:https"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { prepareAzureDevopsClone } from "@dokploy/server/utils/providers/azure-devops"; +import { AzureDevopsClient } from "@dokploy/server/utils/providers/azure-devops-client"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const run = promisify(execFile); +const token = "synthetic-submodule-token"; +const authorization = `Basic ${Buffer.from(`:${token}`).toString("base64")}`; + +describe.skipIf(process.platform === "win32")( + "Azure clone credentials with real HTTPS submodules", + () => { + let directory: string; + let server: Server; + let origin: string; + let certificate: string; + const requests: { path: string; authorized: boolean }[] = []; + const gitEnv = { + ...process.env, + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_COUNT: "0", + GIT_TERMINAL_PROMPT: "0", + }; + const git = (args: string[], cwd?: string) => + run("git", args, { cwd, env: gitEnv }); + + beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), "dokploy-azure-https-")); + certificate = join(directory, "certificate.pem"); + const key = join(directory, "key.pem"); + await run("openssl", [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-days", + "1", + "-subj", + "/CN=localhost", + "-addext", + "subjectAltName=DNS:localhost", + "-keyout", + key, + "-out", + certificate, + ]); + server = createServer( + { key: await readFile(key), cert: await readFile(certificate) }, + (request, response) => { + const url = new URL(request.url || "/", origin); + const authorized = request.headers.authorization === authorization; + requests.push({ path: url.pathname, authorized }); + if (!authorized) { + response.writeHead(401, { + "WWW-Authenticate": 'Basic realm="fixture"', + }); + response.end(); + return; + } + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => { + const input = Buffer.concat(chunks); + const child = execFile( + "git", + ["http-backend"], + { + env: { + ...gitEnv, + GIT_PROJECT_ROOT: join(directory, "repositories"), + GIT_HTTP_EXPORT_ALL: "1", + PATH_INFO: url.pathname.replace(/^\/tfs\/Collection/, ""), + REQUEST_METHOD: request.method || "GET", + QUERY_STRING: url.search.slice(1), + CONTENT_TYPE: request.headers["content-type"] || "", + CONTENT_LENGTH: String(input.length), + REMOTE_USER: "fixture", + }, + encoding: "buffer", + }, + (error, output) => { + if (error) { + response.writeHead(500); + response.end(); + return; + } + const boundary = output.indexOf("\r\n\r\n"); + if (boundary < 0) { + response.writeHead(500); + response.end(); + return; + } + for (const line of output + .subarray(0, boundary) + .toString() + .split("\r\n")) { + const separator = line.indexOf(":"); + if (separator < 0) continue; + const name = line.slice(0, separator); + const value = line.slice(separator + 1).trim(); + if (name.toLowerCase() === "status") + response.statusCode = Number.parseInt(value, 10); + else response.setHeader(name, value); + } + response.end(output.subarray(boundary + 4)); + }, + ); + child.stdin?.end(input); + }); + }, + ); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const address = server.address(); + if (!address || typeof address === "string") + throw new Error("Missing fixture port"); + origin = `https://localhost:${address.port}`; + const childRepo = join(directory, "child"); + await git(["init", "-b", "main", childRepo]); + await writeFile(join(childRepo, "marker.txt"), "submodule checked out\n"); + await git(["add", "."], childRepo); + await git( + [ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "-m", + "fixture", + ], + childRepo, + ); + const { stdout: sha } = await git(["rev-parse", "HEAD"], childRepo); + await git([ + "clone", + "--bare", + childRepo, + join(directory, "repositories/project/_git/child"), + ]); + for (const name of ["parent", "outside"]) { + const parent = join(directory, name); + await git(["init", "-b", "main", parent]); + const collection = + name === "outside" ? "OtherCollection" : "Collection"; + await writeFile( + join(parent, ".gitmodules"), + `[submodule "child"]\npath = modules/child\nurl = ${origin}/tfs/${collection}/project/_git/child\n`, + ); + await git(["add", ".gitmodules"], parent); + await git( + [ + "update-index", + "--add", + "--cacheinfo", + `160000,${sha.trim()},modules/child`, + ], + parent, + ); + await git( + [ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "-m", + "fixture", + ], + parent, + ); + await git([ + "clone", + "--bare", + parent, + join(directory, `repositories/project/_git/${name}`), + ]); + } + }, 30_000); + + afterAll(async () => { + if (server) + await new Promise((resolve) => server.close(() => resolve())); + if (directory) await rm(directory, { recursive: true, force: true }); + }); + + const clone = async (name: string) => { + const output = join(directory, `clone-${name}`); + const plan = prepareAzureDevopsClone( + { + appName: "fixture", + azureDevopsId: "provider", + azureDevopsProjectId: "project", + azureDevopsRepositoryId: "repository", + azureDevopsBranch: "main", + serverId: null, + enableSubmodules: true, + outputPathOverride: output, + }, + new AzureDevopsClient(`${origin}/tfs/Collection`, token), + `${origin}/tfs/Collection/project/_git/${name}`, + token, + ); + const result = await run("/bin/sh", ["-c", plan.command], { + env: { ...gitEnv, ...plan.env, GIT_SSL_CAINFO: certificate }, + timeout: 20_000, + }); + return { output, result }; + }; + + it("authenticates a same-collection submodule without persisting credentials", async () => { + const { output, result } = await clone("parent"); + expect( + await readFile(join(output, "modules/child/marker.txt"), "utf8"), + ).toBe("submodule checked out\n"); + expect( + requests.some( + (request) => + request.path.includes("/_git/child/") && request.authorized, + ), + ).toBe(true); + for (const value of [ + await readFile(join(output, ".git/config"), "utf8"), + await readFile(join(output, ".git/modules/child/config"), "utf8"), + result.stdout + result.stderr, + ]) { + expect(value).not.toContain(token); + expect(value).not.toContain(authorization); + expect(value.toLowerCase()).not.toContain("extraheader"); + } + }, 30_000); + + it("does not send the collection credential to an out-of-collection submodule", async () => { + await expect(clone("outside")).rejects.toThrow(); + const outside = requests.filter((request) => + request.path.startsWith("/tfs/OtherCollection/"), + ); + expect(outside.length).toBeGreaterThan(0); + expect(outside.every((request) => !request.authorized)).toBe(true); + }, 30_000); + }, +); diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index 379c63a00c..d0bf5f1086 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -5,6 +5,13 @@ import { expect, test } from "vitest"; const baseApp: ApplicationNested = { railpackVersion: "0.15.4", rollbackActive: false, + azureDevopsId: null, + azureDevopsProjectId: null, + azureDevopsRepositoryId: null, + azureDevopsProjectName: null, + azureDevopsRepositoryName: null, + azureDevopsBranch: null, + azureDevopsBuildPath: "/", applicationId: "", previewLabels: [], networkIds: [], diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-azure-devops-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-azure-devops-provider.tsx new file mode 100644 index 0000000000..a410b0c7be --- /dev/null +++ b/apps/dokploy/components/dashboard/application/general/generic/save-azure-devops-provider.tsx @@ -0,0 +1,108 @@ +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { + AzureDevopsHookStatus, + AzureDevopsSourceFields, + azureSourceValue, + emptyAzureSource, +} from "@/components/dashboard/shared/azure-devops-source-fields"; +import { AzureDevopsIcon } from "@/components/icons/azure-devops-icon"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { api } from "@/utils/api"; + +export const SaveAzureDevopsProvider = ({ + applicationId, +}: { + applicationId: string; +}) => { + const { data } = api.application.one.useQuery({ applicationId }); + const save = api.application.saveAzureDevopsProvider.useMutation(); + const utils = api.useUtils(); + const [source, setSource] = useState(emptyAzureSource); + const [path, setPath] = useState("/"); + const [watchPaths, setWatchPaths] = useState(""); + const [enableSubmodules, setEnableSubmodules] = useState(false); + useEffect(() => { + if (data) { + setSource(azureSourceValue(data)); + setPath(data.azureDevopsBuildPath || "/"); + setWatchPaths(data.watchPaths?.join("\n") || ""); + setEnableSubmodules(data.enableSubmodules); + } + }, [data]); + return ( +
{ + event.preventDefault(); + try { + await save.mutateAsync({ + applicationId, + ...source, + azureDevopsBuildPath: path, + enableSubmodules, + watchPaths: watchPaths + .split("\n") + .map((path) => path.trim()) + .filter(Boolean), + }); + await Promise.all([ + utils.application.one.invalidate({ applicationId }), + utils.azureDevops.hookStatus.invalidate(), + ]); + toast.success("Azure DevOps source saved"); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Could not save Azure DevOps source", + ); + } + }} + > + +
+ + setPath(event.target.value)} + /> +
+
+ +