diff --git a/apps/dokploy/__test__/drop/drop.test.ts b/apps/dokploy/__test__/drop/drop.test.ts index eda3b9f0dd9..cc579cabd50 100644 --- a/apps/dokploy/__test__/drop/drop.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.ts @@ -9,12 +9,11 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; const OUTPUT_BASE = "./__test__/drop/zips/output"; const { APPLICATIONS_PATH } = paths(); vi.mock("@dokploy/server/constants", async (importOriginal) => { - const actual = await importOriginal(); + const actual = + await importOriginal(); return { - // @ts-ignore ...actual, paths: () => ({ - // @ts-ignore ...actual.paths(), BASE_PATH: OUTPUT_BASE, APPLICATIONS_PATH: OUTPUT_BASE, @@ -30,6 +29,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 00000000000..4d278e284c9 --- /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 00000000000..136c864ce10 --- /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 00000000000..0e6cc82d840 --- /dev/null +++ b/apps/dokploy/__test__/git-provider/azure-devops-clone.test.ts @@ -0,0 +1,320 @@ +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")), + execAsyncStream: 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]![2], + 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.remote.mock.calls[0]![4]).toEqual({ includeStream: true }); + 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.each([false, true])( + "logs clone progress before the process exits (remote=%s)", + async (remote) => { + let finish: + | ((result: { stdout: string; stderr: string }) => void) + | undefined; + const execute = remote ? mocks.remote : mocks.exec; + execute.mockImplementationOnce((...args: unknown[]) => { + const onData = args[remote ? 2 : 1] as ( + data: string, + stream: "stdout" | "stderr", + ) => void; + onData("Receiving objects: 25%\r", "stderr"); + return new Promise((resolve) => { + finish = resolve; + }); + }); + const cloning = cloneAzureDevopsRepository({ + ...source, + logPath: "/tmp/test.log", + serverId: remote ? "server" : null, + }); + try { + await vi.waitFor(() => { + if (remote) expect(mocks.remote).toHaveBeenCalledTimes(2); + else + expect(mocks.append).toHaveBeenCalledWith( + "/tmp/test.log", + "Receiving objects: 25%\r", + ); + }); + expect(execute.mock.calls[0]![remote ? 1 : 0]).toContain("--progress"); + } finally { + finish?.({ stdout: "", stderr: "Receiving objects: 25%\r" }); + await cloning; + } + }, + ); + it.each([false, true])( + "redacts every credential split with interleaved output streams (remote=%s)", + async (remote) => { + const encoded = Buffer.from(`:${token}`).toString("base64"); + const secrets = [token, encoded, `Authorization: Basic ${encoded}`]; + (remote ? mocks.remote : mocks.exec).mockImplementationOnce( + async (...args: unknown[]) => { + const onData = args[remote ? 2 : 1] as ( + data: string, + stream: "stdout" | "stderr", + ) => void; + for (const secret of secrets) { + for (let split = 1; split < secret.length; split++) { + onData(`before ${secret.slice(0, split)}`, "stdout"); + onData("progress\n", "stderr"); + onData(`${secret.slice(split)} after\n`, "stdout"); + } + } + onData(`terminated ${token.slice(0, 7)}`, "stderr"); + return { stdout: "", stderr: "" }; + }, + ); + await cloneAzureDevopsRepository({ + ...source, + logPath: "/tmp/test.log", + serverId: remote ? "server" : null, + }); + const logged = remote + ? mocks.remote.mock.calls + .slice(1) + .map(([, command]) => { + const payload = parse(command)[2] as string; + return Buffer.from(payload, "base64").toString(); + }) + .join("") + : mocks.append.mock.calls.map(([, content]) => content).join(""); + for (const secret of secrets) expect(logged).not.toContain(secret); + expect(logged).not.toContain(token.slice(0, 7)); + expect(logged.match(/\[REDACTED\]/g)).toHaveLength( + secrets.reduce((count, secret) => count + secret.length - 1, 1), + ); + }, + ); + it("does not repeat already streamed output when cloning fails", async () => { + mocks.exec.mockImplementationOnce(async (_command, onData) => { + onData("Receiving objects\n", "stderr"); + onData(token.slice(0, 7), "stderr"); + throw new ExecError(token, { + command: "git clone", + stderr: `Receiving objects\n${token}`, + }); + }); + const error = await cloneAzureDevopsRepository({ + ...source, + logPath: "/tmp/test.log", + }).catch((error) => error); + const logged = mocks.append.mock.calls + .map(([, content]) => content) + .join(""); + expect(logged).toBe("Receiving objects\n[REDACTED]"); + expect(error.getDetailedMessage()).not.toContain(token); + }); + it("reports log write failure without exposing the underlying error", async () => { + mocks.append.mockRejectedValueOnce(new Error(token)); + mocks.exec.mockImplementationOnce(async (_command, onData) => { + onData("Receiving objects\n", "stderr"); + return { stdout: "", stderr: "Receiving objects\n" }; + }); + await expect( + cloneAzureDevopsRepository({ ...source, logPath: "/tmp/test.log" }), + ).rejects.toThrow( + "Could not write Azure DevOps clone output to deployment log.", + ); + }); + 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 00000000000..e670bc43608 --- /dev/null +++ b/apps/dokploy/__test__/git-provider/azure-devops-hooks.test.ts @@ -0,0 +1,357 @@ +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(), + transaction: 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: (callback: (tx: unknown) => unknown) => { + state.transaction(); + return 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", () => { + describe.each(["application", "compose"] as const)("%s scope", (type) => { + it.each(["github", "gitlab", "bitbucket", "gitea", "git"])( + "skips transactions for %s services without Azure hooks", + async (sourceType) => { + state.service!.sourceType = sourceType; + for (const remove of [false, true]) { + expect(await syncAzureDevopsWebhook(type, "service", remove)).toEqual( + [], + ); + } + expect(state.transaction).not.toHaveBeenCalled(); + expect(state.locked).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + it.each([false, true])( + "cleans up retained hooks after changing providers (remove=%s)", + async (remove) => { + await syncAzureDevopsWebhook(type, "service"); + state.service!.sourceType = "github"; + state.service!.azureDevopsId = null; + vi.clearAllMocks(); + expect(await syncAzureDevopsWebhook(type, "service", remove)).toEqual( + [], + ); + expect(state.transaction).toHaveBeenCalledOnce(); + expect(state.locked).toHaveBeenCalledOnce(); + expect(state.remote).toHaveLength(0); + expect(fetch).toHaveBeenCalledWith( + expect.any(URL), + expect.objectContaining({ method: "DELETE" }), + ); + }, + ); + }); + 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-form.test.ts b/apps/dokploy/__test__/git-provider/azure-devops-provider-form.test.ts new file mode 100644 index 00000000000..35792641fca --- /dev/null +++ b/apps/dokploy/__test__/git-provider/azure-devops-provider-form.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { + initialAzureDevopsProviderForm as initial, + azureDevopsProviderFormReducer as reduce, +} from "@/components/dashboard/settings/git/azure-devops-provider-form"; + +describe("Azure DevOps provider form refreshes", () => { + it("preserves edits and a replacement PAT when fresh provider data arrives", () => { + let state = reduce(initial, { + type: "hydrate", + name: "Cached name", + baseUrl: "https://ado.test/Collection", + }); + state = reduce(state, { type: "change", field: "name", value: "New name" }); + state = reduce(state, { + type: "change", + field: "token", + value: "synthetic-replacement-pat", + }); + state = reduce(state, { + type: "hydrate", + name: "Server name", + baseUrl: "https://ado.test/UpdatedCollection", + }); + expect(state.name).toBe("New name"); + expect(state.token).toBe("synthetic-replacement-pat"); + expect(state.baseUrl).toBe("https://ado.test/UpdatedCollection"); + }); + it("keeps a URL entered before the first query finishes", () => { + const edited = reduce(initial, { + type: "change", + field: "baseUrl", + value: "https://ado.test/NewCollection", + }); + const loaded = reduce(edited, { + type: "hydrate", + name: "Provider", + baseUrl: "https://ado.test/OldCollection", + }); + expect(loaded.baseUrl).toBe("https://ado.test/NewCollection"); + expect(loaded.name).toBe("Provider"); + }); + it("clears the PAT and old edits when the dialog is reopened", () => { + const edited = reduce(initial, { + type: "change", + field: "token", + value: "synthetic-replacement-pat", + }); + const reopened = reduce(reduce(edited, { type: "reset" }), { + type: "hydrate", + name: "Saved name", + baseUrl: "https://ado.test/Collection", + }); + expect(reopened.name).toBe("Saved name"); + expect(reopened.token).toBe(""); + }); +}); 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 00000000000..9afe5ff8c76 --- /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 00000000000..a3b37326c2c --- /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-service-scope.test.ts b/apps/dokploy/__test__/git-provider/azure-devops-service-scope.test.ts new file mode 100644 index 00000000000..1bf11e2d26f --- /dev/null +++ b/apps/dokploy/__test__/git-provider/azure-devops-service-scope.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + service: {} as Record, + sync: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => { + const database = { + query: { + applications: { findFirst: async () => state.service }, + compose: { findFirst: async () => state.service }, + }, + update: () => ({ + set: (value: Record) => ({ + where: () => ({ + returning: async () => { + Object.assign(state.service, value); + return [{ ...state.service }]; + }, + }), + }), + }), + execute: vi.fn(), + }; + return { + db: { + ...database, + transaction: async (callback: (tx: typeof database) => unknown) => + callback(database), + }, + }; +}); +vi.mock( + "@dokploy/server/services/azure-devops-hooks", + async (importOriginal) => ({ + ...(await importOriginal()), + syncAzureDevopsWebhook: state.sync, + }), +); + +import { updateApplication } from "@dokploy/server/services/application"; +import { updateCompose } from "@dokploy/server/services/compose"; + +beforeEach(() => { + vi.clearAllMocks(); + state.service = { + sourceType: "azureDevops", + azureDevopsId: "provider", + refreshToken: "original-token", + }; +}); + +describe.each([ + { type: "application", update: updateApplication }, + { type: "compose", update: updateCompose }, +])("$type webhook tokens", ({ type, update }) => { + it("rotates the token when disconnecting Azure", async () => { + const result = await update("service", { + sourceType: "github", + azureDevopsId: null, + }); + expect(result?.refreshToken).not.toBe("original-token"); + expect(result?.refreshToken).toEqual(expect.any(String)); + expect(state.sync).toHaveBeenCalledWith(type, "service"); + }); + it.each(["github", "gitlab", "bitbucket", "gitea", "git"] as const)( + "rotates the token when switching from Azure to %s", + async (sourceType) => { + const result = await update("service", { sourceType }); + expect(result?.refreshToken).not.toBe("original-token"); + expect(result?.azureDevopsId).toBeNull(); + }, + ); + it("rotates the token when switching Azure providers", async () => { + const result = await update("service", { azureDevopsId: "other-provider" }); + expect(result?.refreshToken).not.toBe("original-token"); + }); + it("keeps the token when saving the same Azure provider", async () => { + const result = await update("service", { + sourceType: "azureDevops", + azureDevopsId: "provider", + }); + expect(result?.refreshToken).toBe("original-token"); + }); + it.each(["github", "gitlab", "bitbucket", "gitea", "git"] as const)( + "keeps the token when disconnecting or changing a %s source", + async (sourceType) => { + for (const change of [ + { sourceType: "git" as const }, + { sourceType: "github" as const, azureDevopsId: null }, + ]) { + state.service.sourceType = sourceType; + state.service.azureDevopsId = null; + const result = await update("service", change); + expect(result?.refreshToken).toBe("original-token"); + } + }, + ); +}); 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 00000000000..a23c2535e97 --- /dev/null +++ b/apps/dokploy/__test__/git-provider/azure-devops-sources.test.ts @@ -0,0 +1,279 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + updateApp: vi.fn(), + updateCompose: vi.fn(), + access: vi.fn(), + permission: vi.fn(), + rotateToken: vi.fn(), + hookStatus: vi.fn(), + syncHook: 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", + composePath: "./docker-compose.yml", +})); +vi.mock("@dokploy/server", () => ({ + IS_CLOUD: true, + hasValidLicense: async () => false, + updateApplication: mocks.updateApp, + updateCompose: mocks.updateCompose, + findApplicationById: async () => current, + findComposeById: async () => current, + findPreviewDeploymentsByApplicationId: async () => [], +})); +vi.mock("@dokploy/server/index", () => ({ + IS_CLOUD: true, + hasValidLicense: async () => false, + updateApplication: mocks.updateApp, + updateCompose: mocks.updateCompose, + findApplicationById: async () => current, + findComposeById: async () => current, + findPreviewDeploymentsByApplicationId: async () => [], +})); +vi.mock("@dokploy/server/services/azure-devops", () => ({ + assertAzureDevopsSourceAccess: mocks.access, +})); +vi.mock("@dokploy/server/services/permission", () => ({ + checkServicePermissionAndAccess: mocks.permission, + checkServiceAccess: mocks.permission, +})); +vi.mock("@dokploy/server/services/azure-devops-hooks", () => ({ + getAzureDevopsHookStatus: mocks.hookStatus, + syncAzureDevopsWebhook: mocks.syncHook, +})); +vi.mock("@dokploy/server/db", () => ({ + db: { + update: () => ({ + set: mocks.rotateToken.mockReturnValue({ where: async () => undefined }), + }), + delete: () => ({ + where: () => ({ returning: async () => [current] }), + }), + }, +})); +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(); + current.sourceType = "azureDevops"; + mocks.hookStatus.mockResolvedValue([]); + mocks.syncHook.mockResolvedValue([]); + mocks.updateApp.mockResolvedValue({ applicationId: "app" }); + mocks.updateCompose.mockResolvedValue({ composeId: "compose" }); + mocks.access.mockResolvedValue(undefined); +}); + +describe("Azure DevOps Application and Compose source mutations", () => { + describe.each([ + { + type: "application", + disconnect: () => app.disconnectGitProvider({ applicationId: "app" }), + remove: () => app.delete({ applicationId: "app" }), + update: mocks.updateApp, + id: "app", + }, + { + type: "compose", + disconnect: () => compose.disconnectGitProvider({ composeId: "compose" }), + remove: () => + compose.delete({ composeId: "compose", deleteVolumes: false }), + update: mocks.updateCompose, + id: "compose", + }, + ])("$type cleanup scope", ({ type, disconnect, remove, update, id }) => { + it.each(["azureDevops", "github", "gitlab", "bitbucket", "gitea", "git"])( + "leaves token rotation to the service when disconnecting %s", + async (sourceType) => { + current.sourceType = sourceType; + await disconnect(); + expect(update).toHaveBeenCalledOnce(); + expect(update.mock.calls[0]?.[1]).not.toHaveProperty("refreshToken"); + }, + ); + it.each(["github", "gitlab", "bitbucket", "gitea", "git"])( + "deletes a %s service without rotating its token or syncing Azure hooks", + async (sourceType) => { + current.sourceType = sourceType; + expect(await remove()).toMatchObject({ azureDevopsCleanup: [] }); + expect(mocks.rotateToken).not.toHaveBeenCalled(); + expect(mocks.syncHook).not.toHaveBeenCalled(); + }, + ); + it.each(["azureDevops", "github"])( + "rotates the token and reports retained cleanup records when deleting %s", + async (sourceType) => { + current.sourceType = sourceType; + const records = [{ id: "hook", status: "cleanupPending" }]; + mocks.hookStatus.mockResolvedValue(records); + expect(await remove()).toMatchObject({ azureDevopsCleanup: records }); + expect(mocks.rotateToken).toHaveBeenCalledExactlyOnceWith({ + refreshToken: expect.any(String), + }); + expect(mocks.syncHook).toHaveBeenCalledExactlyOnceWith(type, id, true); + }, + ); + it("cleans up an Azure source even without a local hook record", async () => { + expect(await remove()).toMatchObject({ azureDevopsCleanup: [] }); + expect(mocks.rotateToken).toHaveBeenCalledOnce(); + expect(mocks.syncHook).toHaveBeenCalledExactlyOnceWith(type, id, true); + }); + }); + 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("authorizes path-only Compose source changes", async () => { + await compose.update({ + composeId: "compose", + composePath: "deploy/compose.yml", + }); + expect(mocks.access).toHaveBeenCalledWith( + { activeOrganizationId: "org", userId: "user" }, + current, + undefined, + ); + expect(mocks.updateCompose).toHaveBeenCalledWith("compose", { + composeId: "compose", + composePath: "deploy/compose.yml", + }); + }); + it("rejects path-only edits when the provider source cannot be edited", async () => { + mocks.access.mockRejectedValue(new Error("Forbidden")); + await expect( + compose.update({ composeId: "compose", composePath: "other.yml" }), + ).rejects.toThrow("Forbidden"); + expect(mocks.updateCompose).not.toHaveBeenCalled(); + }); + it.each([ + "../outside.yml", + "deploy/../../outside.yml", + "deploy\\..\\outside.yml", + "/outside.yml", + "C:\\outside.yml", + "\\\\host\\share\\outside.yml", + "compose.yml\nother.yml", + ])("rejects an unsafe path-only Compose update: %s", async (composePath) => { + await expect( + compose.update({ composeId: "compose", composePath }), + ).rejects.toThrow(); + 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 00000000000..7ac7efc385c --- /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 379c63a00cd..d0bf5f10863 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/__test__/utils/exec-output-stream.test.ts b/apps/dokploy/__test__/utils/exec-output-stream.test.ts new file mode 100644 index 00000000000..57ac2aade4a --- /dev/null +++ b/apps/dokploy/__test__/utils/exec-output-stream.test.ts @@ -0,0 +1,95 @@ +import { Duplex, PassThrough } from "node:stream"; +import { + execAsyncRemote, + execAsyncStream, +} from "@dokploy/server/utils/process/execAsync"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ remoteExec: vi.fn(), end: vi.fn() })); + +vi.mock("@dokploy/server/services/server", () => ({ + findServerById: vi.fn(async () => ({ sshKeyId: "test-key" })), +})); +vi.mock("ssh2", async () => { + const { EventEmitter } = await import("node:events"); + return { + Client: class extends EventEmitter { + exec = mocks.remoteExec; + end = mocks.end; + connect() { + queueMicrotask(() => this.emit("ready")); + return this; + } + }, + }; +}); + +describe("remote process output stream labels", () => { + let remoteInput: string; + let remoteStream: Duplex & { stderr: PassThrough }; + beforeEach(() => { + vi.clearAllMocks(); + remoteInput = ""; + mocks.remoteExec.mockImplementation((_command, callback) => { + remoteStream = Object.assign( + new Duplex({ + read() {}, + write(chunk, _encoding, done) { + remoteInput += chunk.toString(); + done(); + }, + }), + { stderr: new PassThrough() }, + ); + callback(null, remoteStream); + queueMicrotask(() => { + remoteStream.emit("data", "output"); + remoteStream.stderr.write("warning"); + remoteStream.emit("close", 0); + }); + }); + }); + it("labels remote output streams only when requested", async () => { + const onData = vi.fn(); + await expect( + execAsyncRemote("test-server", "cat", onData, "synthetic-input\n", { + includeStream: true, + }), + ).resolves.toEqual({ stdout: "output", stderr: "warning" }); + expect(onData.mock.calls).toEqual([ + ["output", "stdout"], + ["warning", "stderr"], + ]); + expect(remoteInput).toBe("synthetic-input\n"); + expect(remoteStream.writableEnded).toBe(true); + expect(mocks.end).toHaveBeenCalledOnce(); + }); + it("preserves the existing single-argument remote callback by default", async () => { + const onData = vi.fn(); + await execAsyncRemote("test-server", "echo hello", onData); + expect(onData.mock.calls).toEqual([["output"], ["warning"]]); + expect(remoteStream.writableEnded).toBe(false); + }); +}); + +describe.skipIf(process.platform === "win32")( + "process output stream labels", + () => { + it("identifies stdout and stderr for independent streaming redaction", async () => { + const onData = vi.fn(); + const result = await execAsyncStream( + "printf 'output'; printf 'diagnostic' >&2", + onData, + { includeStream: true }, + ); + expect(onData).toHaveBeenCalledWith("output", "stdout"); + expect(onData).toHaveBeenCalledWith("diagnostic", "stderr"); + expect(result).toEqual({ stdout: "output", stderr: "diagnostic" }); + }); + it("preserves the existing single-argument output callback by default", async () => { + const onData = vi.fn(); + await execAsyncStream("printf 'output'", onData); + expect(onData.mock.calls).toEqual([["output"]]); + }); + }, +); 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 00000000000..a410b0c7bed --- /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)} + /> +
+
+ +