diff --git a/apps/dokploy/__test__/git-provider/gitlab-refresh-token.test.ts b/apps/dokploy/__test__/git-provider/gitlab-refresh-token.test.ts new file mode 100644 index 000000000..71b4421f3 --- /dev/null +++ b/apps/dokploy/__test__/git-provider/gitlab-refresh-token.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const store = vi.hoisted(() => ({ + provider: {} as Record, +})); + +vi.mock("@dokploy/server/services/gitlab", () => ({ + findGitlabById: vi.fn(async () => ({ ...store.provider })), + updateGitlab: vi.fn(async (_id: string, values: Record) => { + store.provider = { ...store.provider, ...values }; + }), +})); + +const { refreshGitlabToken } = await import( + "@dokploy/server/utils/providers/gitlab" +); + +const GITLAB_ID = "gitlab-1"; + +// Behaves like GitLab: a refresh returns a new pair and invalidates the refresh token it used +const createGitlab = () => { + let validRefreshToken = "refresh-1"; + let issued = 1; + let failNext = false; + + const fetch = vi.fn(async (_url: string, init: RequestInit) => { + const body = new URLSearchParams(init.body as string); + // Keeps the request open, so concurrent callers overlap as two deployments do + await new Promise((resolve) => setTimeout(resolve, 20)); + + if (failNext) { + failNext = false; + return new Response(null, { + status: 500, + statusText: "Internal Server Error", + }); + } + + if (body.get("refresh_token") !== validRefreshToken) { + return new Response(null, { status: 400, statusText: "Bad Request" }); + } + + issued++; + validRefreshToken = `refresh-${issued}`; + return Response.json({ + access_token: `access-${issued}`, + refresh_token: validRefreshToken, + expires_in: 7200, + }); + }); + + return { + fetch, + failNextRequest: () => { + failNext = true; + }, + }; +}; + +describe("refreshGitlabToken", () => { + let gitlab: ReturnType; + + beforeEach(() => { + store.provider = { + gitlabId: GITLAB_ID, + gitlabUrl: "https://gitlab.example.com", + gitlabInternalUrl: null, + applicationId: "app-id", + secret: "app-secret", + accessToken: "access-1", + refreshToken: "refresh-1", + expiresAt: 0, + }; + gitlab = createGitlab(); + vi.stubGlobal("fetch", gitlab.fetch); + }); + + it("sends one refresh request when two callers find the token expired at the same time", async () => { + await Promise.all([ + refreshGitlabToken(GITLAB_ID), + refreshGitlabToken(GITLAB_ID), + ]); + + expect(gitlab.fetch).toHaveBeenCalledTimes(1); + expect(store.provider.refreshToken).toBe("refresh-2"); + }); + + it("refreshes again after a failed refresh", async () => { + gitlab.failNextRequest(); + + await expect(refreshGitlabToken(GITLAB_ID)).rejects.toThrow( + "Failed to refresh token: Internal Server Error", + ); + await refreshGitlabToken(GITLAB_ID); + + expect(gitlab.fetch).toHaveBeenCalledTimes(2); + expect(store.provider.refreshToken).toBe("refresh-2"); + }); +}); diff --git a/packages/server/src/utils/providers/gitlab.ts b/packages/server/src/utils/providers/gitlab.ts index 6cb777b75..1221fd5a4 100644 --- a/packages/server/src/utils/providers/gitlab.ts +++ b/packages/server/src/utils/providers/gitlab.ts @@ -11,7 +11,7 @@ import { TRPCError } from "@trpc/server"; import { quote } from "shell-quote"; import type { z } from "zod"; -export const refreshGitlabToken = async (gitlabProviderId: string) => { +const refreshGitlabTokenIfExpired = async (gitlabProviderId: string) => { const gitlabProvider = await findGitlabById(gitlabProviderId); const currentTime = Math.floor(Date.now() / 1000); @@ -56,6 +56,23 @@ export const refreshGitlabToken = async (gitlabProviderId: string) => { return data; }; +// GitLab invalidates a refresh token when it is used, so concurrent callers must share one refresh +const pendingRefreshes = new Map< + string, + ReturnType +>(); + +export const refreshGitlabToken = (gitlabProviderId: string) => { + let refresh = pendingRefreshes.get(gitlabProviderId); + if (!refresh) { + refresh = refreshGitlabTokenIfExpired(gitlabProviderId).finally(() => { + pendingRefreshes.delete(gitlabProviderId); + }); + pendingRefreshes.set(gitlabProviderId, refresh); + } + return refresh; +}; + export const haveGitlabRequirements = (gitlabProvider: Gitlab) => { return !!(gitlabProvider?.accessToken && gitlabProvider?.refreshToken); };