From ab35ddeffb85590a184e8374fe47d23fadff93bc Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 15 Jul 2026 09:14:14 +0000 Subject: [PATCH] [Fix] Cover GitLab OAuth public-URL redirect selection in tests Lock authorize/callback routes to R_PUBLIC_URL when set and document callback matching. --- apps/docs/providers/source-control/gitlab.mdx | 4 +- .../oauth/authorize/__tests__/route.test.ts | 118 ++++++++++++++ .../oauth/callback/__tests__/route.test.ts | 150 ++++++++++++++++++ packages/gitlab/src/__tests__/oauth.test.ts | 14 +- 4 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/api/source-control/gitlab/oauth/authorize/__tests__/route.test.ts create mode 100644 apps/web/src/app/api/source-control/gitlab/oauth/callback/__tests__/route.test.ts diff --git a/apps/docs/providers/source-control/gitlab.mdx b/apps/docs/providers/source-control/gitlab.mdx index 09d3320f..9d4a1811 100644 --- a/apps/docs/providers/source-control/gitlab.mdx +++ b/apps/docs/providers/source-control/gitlab.mdx @@ -8,7 +8,9 @@ GitLab does not have a GitHub-style App installation flow. Configure a GitLab OAuth application for Roomote's deployment, then authorize it once so Roomote can sync repositories, run tasks, add merge-request notes, and manage webhooks. -Use `` below for your stable public Roomote URL. +Use `` below for your stable public Roomote URL (`R_PUBLIC_URL` +when set, otherwise `R_APP_URL`). The GitLab OAuth application redirect URI must +match the callback Roomote builds for authorize and token exchange. ## Create a GitLab OAuth application diff --git a/apps/web/src/app/api/source-control/gitlab/oauth/authorize/__tests__/route.test.ts b/apps/web/src/app/api/source-control/gitlab/oauth/authorize/__tests__/route.test.ts new file mode 100644 index 00000000..e80753ec --- /dev/null +++ b/apps/web/src/app/api/source-control/gitlab/oauth/authorize/__tests__/route.test.ts @@ -0,0 +1,118 @@ +const { + authorizeMock, + bootstrapWebRuntimeEnvMock, + createGitLabOAuthAuthorizationUrlMock, + resolveDeploymentEnvVarMock, + resolveGitLabBaseUrlMock, +} = vi.hoisted(() => ({ + authorizeMock: vi.fn(), + bootstrapWebRuntimeEnvMock: vi.fn(), + createGitLabOAuthAuthorizationUrlMock: vi.fn(), + resolveDeploymentEnvVarMock: vi.fn(), + resolveGitLabBaseUrlMock: vi.fn(), +})); + +vi.mock('@/lib/server', () => ({ + authorize: authorizeMock, +})); + +vi.mock('@/lib/server/bootstrap-runtime-env', () => ({ + bootstrapWebRuntimeEnv: bootstrapWebRuntimeEnvMock, +})); + +vi.mock('@roomote/db/server', () => ({ + resolveDeploymentEnvVar: resolveDeploymentEnvVarMock, +})); + +vi.mock('@roomote/gitlab', async () => { + const actual = + await vi.importActual('@roomote/gitlab'); + return { + ...actual, + createGitLabOAuthAuthorizationUrl: createGitLabOAuthAuthorizationUrlMock, + resolveGitLabBaseUrl: resolveGitLabBaseUrlMock, + }; +}); + +import { GET } from '../route'; + +function getSetCookieHeaders(response: Response): string[] { + const headers = response.headers as Headers & { + getSetCookie?: () => string[]; + }; + + return headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? '']; +} + +describe('GET /api/source-control/gitlab/oauth/authorize', () => { + beforeEach(() => { + vi.clearAllMocks(); + authorizeMock.mockResolvedValue({ + success: true, + isAdmin: true, + }); + bootstrapWebRuntimeEnvMock.mockResolvedValue({ + R_APP_URL: 'http://localhost:3000', + R_PUBLIC_URL: 'https://customer.roomote.ai', + }); + resolveDeploymentEnvVarMock.mockResolvedValue('gitlab-client-id'); + resolveGitLabBaseUrlMock.mockResolvedValue('https://gitlab.com'); + createGitLabOAuthAuthorizationUrlMock.mockReturnValue({ + url: 'https://gitlab.com/oauth/authorize?state=state-1', + state: 'state-1', + }); + }); + + it('builds redirect_uri and secure cookies from R_PUBLIC_URL when set', async () => { + const response = await GET(); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe( + 'https://gitlab.com/oauth/authorize?state=state-1', + ); + expect(createGitLabOAuthAuthorizationUrlMock).toHaveBeenCalledWith({ + baseUrl: 'https://gitlab.com', + clientId: 'gitlab-client-id', + redirectUri: + 'https://customer.roomote.ai/api/source-control/gitlab/oauth/callback', + }); + + const setCookie = getSetCookieHeaders(response).join('\n'); + expect(setCookie).toContain('roomote-gitlab-oauth-state=state-1'); + expect(setCookie).toContain('Secure'); + expect(setCookie).toContain('Path=/api/source-control/gitlab/oauth'); + }); + + it('falls back to R_APP_URL when R_PUBLIC_URL is unset', async () => { + bootstrapWebRuntimeEnvMock.mockResolvedValue({ + R_APP_URL: 'http://localhost:3000', + R_PUBLIC_URL: undefined, + }); + + const response = await GET(); + + expect(createGitLabOAuthAuthorizationUrlMock).toHaveBeenCalledWith({ + baseUrl: 'https://gitlab.com', + clientId: 'gitlab-client-id', + redirectUri: + 'http://localhost:3000/api/source-control/gitlab/oauth/callback', + }); + + const setCookie = getSetCookieHeaders(response).join('\n'); + expect(setCookie).toContain('roomote-gitlab-oauth-state=state-1'); + expect(setCookie).not.toMatch(/\bSecure\b/i); + }); + + it('returns 401 when the user is not an admin', async () => { + authorizeMock.mockResolvedValue({ + success: true, + isAdmin: false, + }); + + const response = await GET(); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ error: 'unauthorized' }); + expect(createGitLabOAuthAuthorizationUrlMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/api/source-control/gitlab/oauth/callback/__tests__/route.test.ts b/apps/web/src/app/api/source-control/gitlab/oauth/callback/__tests__/route.test.ts new file mode 100644 index 00000000..a33705e4 --- /dev/null +++ b/apps/web/src/app/api/source-control/gitlab/oauth/callback/__tests__/route.test.ts @@ -0,0 +1,150 @@ +import { NextRequest } from 'next/server'; + +const { + authorizeMock, + bootstrapWebRuntimeEnvMock, + exchangeGitLabOAuthCodeMock, + resolveDeploymentEnvVarMock, + resolveGitLabBaseUrlMock, +} = vi.hoisted(() => ({ + authorizeMock: vi.fn(), + bootstrapWebRuntimeEnvMock: vi.fn(), + exchangeGitLabOAuthCodeMock: vi.fn(), + resolveDeploymentEnvVarMock: vi.fn(), + resolveGitLabBaseUrlMock: vi.fn(), +})); + +vi.mock('@/lib/server', () => ({ + authorize: authorizeMock, +})); + +vi.mock('@/lib/server/bootstrap-runtime-env', () => ({ + bootstrapWebRuntimeEnv: bootstrapWebRuntimeEnvMock, +})); + +vi.mock('@roomote/db/server', () => ({ + resolveDeploymentEnvVar: resolveDeploymentEnvVarMock, +})); + +vi.mock('@roomote/gitlab', async () => { + const actual = + await vi.importActual('@roomote/gitlab'); + return { + ...actual, + exchangeGitLabOAuthCode: exchangeGitLabOAuthCodeMock, + resolveGitLabBaseUrl: resolveGitLabBaseUrlMock, + }; +}); + +import { GET } from '../route'; + +function getSetCookieHeaders(response: Response): string[] { + const headers = response.headers as Headers & { + getSetCookie?: () => string[]; + }; + + return headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? '']; +} + +function buildRequest(query: string, cookie?: string) { + return new NextRequest( + `https://customer.roomote.ai/api/source-control/gitlab/oauth/callback${query}`, + cookie + ? { + headers: { + cookie, + }, + } + : undefined, + ); +} + +describe('GET /api/source-control/gitlab/oauth/callback', () => { + beforeEach(() => { + vi.clearAllMocks(); + authorizeMock.mockResolvedValue({ + success: true, + isAdmin: true, + }); + bootstrapWebRuntimeEnvMock.mockResolvedValue({ + R_APP_URL: 'http://localhost:3000', + R_PUBLIC_URL: 'https://customer.roomote.ai', + }); + resolveGitLabBaseUrlMock.mockResolvedValue('https://gitlab.com'); + resolveDeploymentEnvVarMock.mockImplementation(async (name: string) => { + if (name === 'GITLAB_CLIENT_ID') return 'gitlab-client-id'; + if (name === 'GITLAB_CLIENT_SECRET') return 'gitlab-client-secret'; + return null; + }); + exchangeGitLabOAuthCodeMock.mockResolvedValue(undefined); + }); + + it('exchanges the code with redirect_uri built from R_PUBLIC_URL', async () => { + const response = await GET( + buildRequest( + '?code=auth-code&state=state-1', + 'roomote-gitlab-oauth-state=state-1', + ), + ); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe( + 'https://customer.roomote.ai/setup?step=source-control-connect&gitlab=connected&sync=1', + ); + expect(exchangeGitLabOAuthCodeMock).toHaveBeenCalledWith({ + baseUrl: 'https://gitlab.com', + clientId: 'gitlab-client-id', + clientSecret: 'gitlab-client-secret', + code: 'auth-code', + redirectUri: + 'https://customer.roomote.ai/api/source-control/gitlab/oauth/callback', + }); + + const setCookie = getSetCookieHeaders(response).join('\n'); + expect(setCookie).toContain('roomote-gitlab-oauth-state='); + expect(setCookie).toContain('Secure'); + expect(setCookie).toContain('Path=/api/source-control/gitlab/oauth'); + }); + + it('redirects to R_APP_URL setup when R_PUBLIC_URL is unset', async () => { + bootstrapWebRuntimeEnvMock.mockResolvedValue({ + R_APP_URL: 'http://localhost:3000', + R_PUBLIC_URL: undefined, + }); + + const response = await GET( + buildRequest( + '?code=auth-code&state=state-1', + 'roomote-gitlab-oauth-state=state-1', + ), + ); + + expect(response.headers.get('location')).toBe( + 'http://localhost:3000/setup?step=source-control-connect&gitlab=connected&sync=1', + ); + expect(exchangeGitLabOAuthCodeMock).toHaveBeenCalledWith( + expect.objectContaining({ + redirectUri: + 'http://localhost:3000/api/source-control/gitlab/oauth/callback', + }), + ); + + const setCookie = getSetCookieHeaders(response).join('\n'); + expect(setCookie).not.toMatch(/\bSecure\b/i); + }); + + it('returns a setup error when OAuth state does not match', async () => { + const response = await GET( + buildRequest( + '?code=auth-code&state=wrong-state', + 'roomote-gitlab-oauth-state=state-1', + ), + ); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe( + 'https://customer.roomote.ai/setup?step=source-control-connect&gitlab=error', + ); + expect(exchangeGitLabOAuthCodeMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/gitlab/src/__tests__/oauth.test.ts b/packages/gitlab/src/__tests__/oauth.test.ts index 55c13a43..2be132e7 100644 --- a/packages/gitlab/src/__tests__/oauth.test.ts +++ b/packages/gitlab/src/__tests__/oauth.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { createGitLabOAuthAuthorizationUrl } from '../oauth'; +import { + buildGitLabOAuthRedirectUri, + createGitLabOAuthAuthorizationUrl, +} from '../oauth'; describe('GitLab deployment OAuth', () => { it('preserves a self-managed GitLab base path in the authorization URL', () => { @@ -15,4 +18,13 @@ describe('GitLab deployment OAuth', () => { 'https://git.example/gitlab/oauth/authorize?', ); }); + + it('builds the callback redirect URI from the app public URL', () => { + expect(buildGitLabOAuthRedirectUri('https://customer.roomote.ai')).toBe( + 'https://customer.roomote.ai/api/source-control/gitlab/oauth/callback', + ); + expect(buildGitLabOAuthRedirectUri('https://customer.roomote.ai/')).toBe( + 'https://customer.roomote.ai/api/source-control/gitlab/oauth/callback', + ); + }); });