Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions apps/dokploy/__test__/gitlab-webhooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { afterEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
findGitlabById: vi.fn(),
updateGitlab: vi.fn(),
}));

vi.mock("@dokploy/server/services/gitlab", () => mocks);

import { registerGitlabDeployWebhook } from "@dokploy/server/utils/providers/gitlab";

const deployWebhookUrl = "https://dokploy.example.com/api/deploy/refresh-token";

const createResponse = (
body: unknown,
init: { status?: number; statusText?: string } = {},
) =>
new Response(JSON.stringify(body), {
status: init.status ?? 200,
statusText: init.statusText ?? "OK",
headers: {
"Content-Type": "application/json",
},
});

const mockGitlabProvider = (overrides = {}) => ({
gitlabId: "gitlab-id",
gitlabUrl: "https://gitlab.example.com/",
gitlabInternalUrl: null,
applicationId: "application-id",
redirectUri: "https://dokploy.example.com/api/providers/gitlab/callback",
secret: "secret",
accessToken: "access-token",
refreshToken: "refresh-token",
groupName: null,
expiresAt: Math.floor(Date.now() / 1000) + 3600,
enableAutoDeploy: true,
gitProviderId: "git-provider-id",
...overrides,
});

describe("registerGitlabDeployWebhook", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("creates a project hook when no hook exists for the deploy URL", async () => {
mocks.findGitlabById.mockResolvedValue(mockGitlabProvider());
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(createResponse([]))
.mockResolvedValueOnce(createResponse({ id: 1, url: deployWebhookUrl }));

await registerGitlabDeployWebhook({
gitlabId: "gitlab-id",
gitlabProjectId: 123,
branch: "main",
deployWebhookUrl,
});

expect(fetchMock).toHaveBeenCalledWith(
"https://gitlab.example.com/api/v4/projects/123/hooks?per_page=100",
{
headers: {
Authorization: "Bearer access-token",
"Content-Type": "application/json",
},
},
);

const createCall = fetchMock.mock.calls[1];
expect(createCall?.[0]).toBe(
"https://gitlab.example.com/api/v4/projects/123/hooks",
);
expect(createCall?.[1]).toMatchObject({
method: "POST",
headers: {
Authorization: "Bearer access-token",
"Content-Type": "application/json",
},
});
expect(JSON.parse(createCall?.[1]?.body as string)).toEqual({
url: deployWebhookUrl,
push_events: true,
enable_ssl_verification: true,
push_events_branch_filter: "main",
branch_filter_strategy: "wildcard",
});
});

it("updates the existing project hook with the same deploy URL", async () => {
mocks.findGitlabById.mockResolvedValue(mockGitlabProvider());
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
createResponse([{ id: 456, url: deployWebhookUrl }]),
)
.mockResolvedValueOnce(
createResponse({ id: 456, url: deployWebhookUrl }),
);

await registerGitlabDeployWebhook({
gitlabId: "gitlab-id",
gitlabProjectId: 123,
branch: "production",
deployWebhookUrl,
});

const updateCall = fetchMock.mock.calls[1];
expect(updateCall?.[0]).toBe(
"https://gitlab.example.com/api/v4/projects/123/hooks/456",
);
expect(updateCall?.[1]).toMatchObject({
method: "PUT",
});
expect(JSON.parse(updateCall?.[1]?.body as string)).toMatchObject({
url: deployWebhookUrl,
push_events_branch_filter: "production",
});
});

it("uses the internal GitLab URL before the public URL", async () => {
mocks.findGitlabById.mockResolvedValue(
mockGitlabProvider({
gitlabUrl: "https://gitlab.example.com",
gitlabInternalUrl: "http://gitlab:8080/",
}),
);
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(createResponse([]))
.mockResolvedValueOnce(createResponse({ id: 1, url: deployWebhookUrl }));

await registerGitlabDeployWebhook({
gitlabId: "gitlab-id",
gitlabProjectId: 123,
branch: "main",
deployWebhookUrl,
});

expect(fetchMock.mock.calls[0]?.[0]).toBe(
"http://gitlab:8080/api/v4/projects/123/hooks?per_page=100",
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
"http://gitlab:8080/api/v4/projects/123/hooks",
);
});

it("skips project hook registration when automatic deployments are disabled", async () => {
mocks.findGitlabById.mockResolvedValue(
mockGitlabProvider({
enableAutoDeploy: false,
}),
);
const fetchMock = vi.spyOn(globalThis, "fetch");

const result = await registerGitlabDeployWebhook({
gitlabId: "gitlab-id",
gitlabProjectId: 123,
branch: "main",
deployWebhookUrl,
});

expect(result).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";

Expand All @@ -51,6 +52,7 @@ const Schema = z.object({
message: "Redirect URI is required",
}),
groupName: z.string().optional(),
enableAutoDeploy: z.boolean().default(true),
});

type Schema = z.infer<typeof Schema>;
Expand All @@ -72,6 +74,7 @@ export const AddGitlabProvider = () => {
name: "",
gitlabUrl: "https://gitlab.com",
gitlabInternalUrl: "",
enableAutoDeploy: true,
},
resolver: zodResolver(Schema),
});
Expand All @@ -87,6 +90,7 @@ export const AddGitlabProvider = () => {
name: "",
gitlabUrl: "https://gitlab.com",
gitlabInternalUrl: "",
enableAutoDeploy: true,
});
}, [form, isOpen]);

Expand All @@ -100,6 +104,7 @@ export const AddGitlabProvider = () => {
redirectUri: data.redirectUri || "",
gitlabUrl: data.gitlabUrl || "https://gitlab.com",
gitlabInternalUrl: data.gitlabInternalUrl || undefined,
enableAutoDeploy: data.enableAutoDeploy,
})
.then(async () => {
await utils.gitProvider.getAll.invalidate();
Expand Down Expand Up @@ -292,6 +297,29 @@ export const AddGitlabProvider = () => {
)}
/>

<FormField
control={form.control}
name="enableAutoDeploy"
render={({ field }) => (
<FormItem className="flex items-center justify-between gap-4 rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Enable Automatic Deployments</FormLabel>
<FormDescription>
Automatically configure deploy webhooks when this
GitLab provider is used by an application or compose
service.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>

<Button isLoading={form.formState.isSubmitting}>
Configure GitLab App
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";

const Schema = z.object({
Expand All @@ -39,6 +40,7 @@ const Schema = z.object({
.optional()
.transform((v) => (v === "" ? undefined : v)),
groupName: z.string().optional(),
enableAutoDeploy: z.boolean().default(true),
});

type Schema = z.infer<typeof Schema>;
Expand Down Expand Up @@ -67,6 +69,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
name: "",
gitlabUrl: "https://gitlab.com",
gitlabInternalUrl: "",
enableAutoDeploy: true,
},
resolver: zodResolver(Schema),
});
Expand All @@ -79,6 +82,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
name: gitlab?.gitProvider.name || "",
gitlabUrl: gitlab?.gitlabUrl || "",
gitlabInternalUrl: gitlab?.gitlabInternalUrl || "",
enableAutoDeploy: gitlab?.enableAutoDeploy ?? true,
});
}, [form, isOpen]);

Expand All @@ -90,6 +94,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
name: data.name || "",
gitlabUrl: data.gitlabUrl || "",
gitlabInternalUrl: data.gitlabInternalUrl ?? null,
enableAutoDeploy: data.enableAutoDeploy,
})
.then(async () => {
await utils.gitProvider.getAll.invalidate();
Expand Down Expand Up @@ -201,6 +206,29 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
)}
/>

<FormField
control={form.control}
name="enableAutoDeploy"
render={({ field }) => (
<FormItem className="flex items-center justify-between gap-4 rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Enable Automatic Deployments</FormLabel>
<FormDescription>
Automatically configure deploy webhooks when this
GitLab provider is used by an application or compose
service.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>

<div className="flex w-full justify-between gap-4 mt-4">
<Button
type="button"
Expand Down
1 change: 1 addition & 0 deletions apps/dokploy/drizzle/0172_brainy_tag.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "gitlab" ADD COLUMN "enableAutoDeploy" boolean DEFAULT true NOT NULL;
Loading
Loading