Skip to content

Commit 150df22

Browse files
feat(gitlab): Add frontend implementation for GitLab integration pipeline (#112153)
Add the GitLab pipeline step components and register them in the pipeline registry. The config step uses GuidedSteps to walk users through creating a GitLab OAuth application, then collects instance URL, group path, and OAuth credentials. The OAuth step reuses the shared OAuthLoginStep component. https://github.com/user-attachments/assets/c15891f3-a5c7-43bd-bf8b-cdc31def871f Refs [VDY-39: GitLab: API-driven integration setup](https://linear.app/getsentry/issue/VDY-39/gitlab-api-driven-integration-setup)
1 parent 6283b5d commit 150df22

File tree

3 files changed

+662
-0
lines changed

3 files changed

+662
-0
lines changed
Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
import {act, render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
2+
3+
import {gitlabIntegrationPipeline} from './pipelineIntegrationGitLab';
4+
import type {PipelineStepProps} from './types';
5+
6+
const InstallationConfigStep = gitlabIntegrationPipeline.steps[0].component;
7+
const GitLabOAuthLoginStep = gitlabIntegrationPipeline.steps[1].component;
8+
9+
function makeStepProps<D, A>(
10+
overrides: Partial<PipelineStepProps<D, A>> & {stepData: D}
11+
): PipelineStepProps<D, A> {
12+
return {
13+
advance: jest.fn(),
14+
advanceError: null,
15+
isAdvancing: false,
16+
stepIndex: 0,
17+
totalSteps: 2,
18+
...overrides,
19+
};
20+
}
21+
22+
let mockPopup: Window;
23+
24+
function dispatchPipelineMessage({
25+
data,
26+
origin = document.location.origin,
27+
source = mockPopup,
28+
}: {
29+
data: Record<string, string>;
30+
origin?: string;
31+
source?: Window | MessageEventSource | null;
32+
}) {
33+
act(() => {
34+
const event = new MessageEvent('message', {data, origin});
35+
Object.defineProperty(event, 'source', {value: source});
36+
window.dispatchEvent(event);
37+
});
38+
}
39+
40+
beforeEach(() => {
41+
mockPopup = {
42+
closed: false,
43+
close: jest.fn(),
44+
focus: jest.fn(),
45+
} as unknown as Window;
46+
jest.spyOn(window, 'open').mockReturnValue(mockPopup);
47+
});
48+
49+
afterEach(() => {
50+
jest.restoreAllMocks();
51+
});
52+
53+
describe('InstallationConfigStep', () => {
54+
it('renders the guided steps and config form', () => {
55+
render(
56+
<InstallationConfigStep
57+
{...makeStepProps({
58+
stepData: {
59+
setupValues: [
60+
{
61+
label: 'Redirect URI',
62+
value: 'https://sentry.io/extensions/gitlab/setup/',
63+
},
64+
],
65+
},
66+
})}
67+
/>
68+
);
69+
70+
expect(
71+
screen.getByText(
72+
'To connect Sentry with your GitLab instance, you need to create an OAuth application in GitLab.'
73+
)
74+
).toBeInTheDocument();
75+
76+
expect(screen.getByText('Open GitLab application settings')).toBeInTheDocument();
77+
expect(screen.getByText('Create a new application')).toBeInTheDocument();
78+
expect(screen.getByText('Configure the integration')).toBeInTheDocument();
79+
});
80+
81+
it('renders setup values in the create step', async () => {
82+
render(
83+
<InstallationConfigStep
84+
{...makeStepProps({
85+
stepData: {
86+
setupValues: [
87+
{
88+
label: 'Redirect URI',
89+
value: 'https://sentry.io/extensions/gitlab/setup/',
90+
},
91+
{label: 'Scopes', value: 'api'},
92+
],
93+
},
94+
})}
95+
/>
96+
);
97+
98+
// Navigate to the create step
99+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
100+
101+
expect(screen.getByText('Redirect URI')).toBeInTheDocument();
102+
expect(
103+
screen.getByText('https://sentry.io/extensions/gitlab/setup/')
104+
).toBeInTheDocument();
105+
expect(screen.getByText('Scopes')).toBeInTheDocument();
106+
expect(screen.getByText('api')).toBeInTheDocument();
107+
});
108+
109+
it('submits config with required fields and calls advance', async () => {
110+
const advance = jest.fn();
111+
render(
112+
<InstallationConfigStep
113+
{...makeStepProps({
114+
stepData: {setupValues: []},
115+
advance,
116+
})}
117+
/>
118+
);
119+
120+
// Navigate through guided steps to the configure step
121+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
122+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
123+
124+
await userEvent.type(
125+
screen.getByRole('textbox', {name: 'GitLab Application ID'}),
126+
'my-app-id'
127+
);
128+
await userEvent.type(screen.getByLabelText('GitLab Application Secret'), 'my-secret');
129+
130+
await userEvent.click(screen.getByRole('button', {name: 'Continue'}));
131+
132+
await waitFor(() => {
133+
expect(advance).toHaveBeenCalledWith({
134+
url: undefined,
135+
verify_ssl: undefined,
136+
group: '',
137+
include_subgroups: undefined,
138+
client_id: 'my-app-id',
139+
client_secret: 'my-secret',
140+
});
141+
});
142+
});
143+
144+
it('submits with group and include_subgroups when group is set', async () => {
145+
const advance = jest.fn();
146+
render(
147+
<InstallationConfigStep
148+
{...makeStepProps({
149+
stepData: {setupValues: []},
150+
advance,
151+
})}
152+
/>
153+
);
154+
155+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
156+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
157+
158+
await userEvent.type(
159+
screen.getByRole('textbox', {name: 'GitLab Application ID'}),
160+
'my-app-id'
161+
);
162+
await userEvent.type(screen.getByLabelText('GitLab Application Secret'), 'my-secret');
163+
await userEvent.type(
164+
screen.getByRole('textbox', {name: 'GitLab Group Path'}),
165+
'my-group/sub'
166+
);
167+
168+
// Include Subgroups toggle should now be visible
169+
await userEvent.click(screen.getByRole('checkbox', {name: 'Include Subgroups'}));
170+
171+
await userEvent.click(screen.getByRole('button', {name: 'Continue'}));
172+
173+
await waitFor(() => {
174+
expect(advance).toHaveBeenCalledWith(
175+
expect.objectContaining({
176+
group: 'my-group/sub',
177+
include_subgroups: true,
178+
client_id: 'my-app-id',
179+
client_secret: 'my-secret',
180+
})
181+
);
182+
});
183+
});
184+
185+
it('does not show include_subgroups toggle when group is empty', async () => {
186+
render(<InstallationConfigStep {...makeStepProps({stepData: {setupValues: []}})} />);
187+
188+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
189+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
190+
191+
expect(
192+
screen.queryByRole('checkbox', {name: 'Include Subgroups'})
193+
).not.toBeInTheDocument();
194+
});
195+
196+
it('shows self-hosted fields when self-hosted toggle is enabled', async () => {
197+
render(<InstallationConfigStep {...makeStepProps({stepData: {setupValues: []}})} />);
198+
199+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
200+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
201+
202+
// Self-hosted fields should not be visible initially
203+
expect(screen.queryByRole('textbox', {name: 'GitLab URL'})).not.toBeInTheDocument();
204+
205+
await userEvent.click(screen.getByRole('checkbox', {name: 'Self-Hosted Instance'}));
206+
207+
expect(screen.getByRole('textbox', {name: 'GitLab URL'})).toBeInTheDocument();
208+
expect(screen.getByRole('checkbox', {name: 'Verify SSL'})).toBeInTheDocument();
209+
});
210+
211+
it('submits self-hosted config with URL and verify_ssl', async () => {
212+
const advance = jest.fn();
213+
render(
214+
<InstallationConfigStep
215+
{...makeStepProps({
216+
stepData: {setupValues: []},
217+
advance,
218+
})}
219+
/>
220+
);
221+
222+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
223+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
224+
225+
await userEvent.type(
226+
screen.getByRole('textbox', {name: 'GitLab Application ID'}),
227+
'my-app-id'
228+
);
229+
await userEvent.type(screen.getByLabelText('GitLab Application Secret'), 'my-secret');
230+
231+
await userEvent.click(screen.getByRole('checkbox', {name: 'Self-Hosted Instance'}));
232+
233+
await userEvent.type(
234+
screen.getByRole('textbox', {name: 'GitLab URL'}),
235+
'https://gitlab.example.com/'
236+
);
237+
238+
// Verify SSL is on by default, turn it off
239+
await userEvent.click(screen.getByRole('checkbox', {name: 'Verify SSL'}));
240+
241+
await userEvent.click(screen.getByRole('button', {name: 'Continue'}));
242+
243+
await waitFor(() => {
244+
expect(advance).toHaveBeenCalledWith(
245+
expect.objectContaining({
246+
url: 'https://gitlab.example.com',
247+
verify_ssl: false,
248+
client_id: 'my-app-id',
249+
client_secret: 'my-secret',
250+
})
251+
);
252+
});
253+
});
254+
255+
it('strips trailing slashes from self-hosted URL', async () => {
256+
const advance = jest.fn();
257+
render(
258+
<InstallationConfigStep
259+
{...makeStepProps({
260+
stepData: {setupValues: []},
261+
advance,
262+
})}
263+
/>
264+
);
265+
266+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
267+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
268+
269+
await userEvent.type(
270+
screen.getByRole('textbox', {name: 'GitLab Application ID'}),
271+
'id'
272+
);
273+
await userEvent.type(screen.getByLabelText('GitLab Application Secret'), 'secret');
274+
275+
await userEvent.click(screen.getByRole('checkbox', {name: 'Self-Hosted Instance'}));
276+
277+
await userEvent.type(
278+
screen.getByRole('textbox', {name: 'GitLab URL'}),
279+
'https://gitlab.example.com///'
280+
);
281+
282+
await userEvent.click(screen.getByRole('button', {name: 'Continue'}));
283+
284+
await waitFor(() => {
285+
expect(advance).toHaveBeenCalledWith(
286+
expect.objectContaining({
287+
url: 'https://gitlab.example.com',
288+
})
289+
);
290+
});
291+
});
292+
293+
it('shows submitting state when isAdvancing is true', async () => {
294+
render(
295+
<InstallationConfigStep
296+
{...makeStepProps({
297+
stepData: {setupValues: []},
298+
isAdvancing: true,
299+
})}
300+
/>
301+
);
302+
303+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
304+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
305+
306+
expect(screen.getByRole('button', {name: 'Submitting...'})).toBeDisabled();
307+
});
308+
});
309+
310+
describe('GitLabOAuthLoginStep', () => {
311+
it('renders the OAuth login step for GitLab', () => {
312+
render(
313+
<GitLabOAuthLoginStep
314+
{...makeStepProps({stepData: {oauthUrl: 'https://gitlab.com/oauth/authorize'}})}
315+
/>
316+
);
317+
318+
expect(screen.getByRole('button', {name: 'Authorize GitLab'})).toBeInTheDocument();
319+
});
320+
321+
it('calls advance with code and state on OAuth callback', async () => {
322+
const advance = jest.fn();
323+
render(
324+
<GitLabOAuthLoginStep
325+
{...makeStepProps({
326+
stepData: {oauthUrl: 'https://gitlab.com/oauth/authorize'},
327+
advance,
328+
})}
329+
/>
330+
);
331+
332+
await userEvent.click(screen.getByRole('button', {name: 'Authorize GitLab'}));
333+
334+
dispatchPipelineMessage({
335+
data: {
336+
_pipeline_source: 'sentry-pipeline',
337+
code: 'auth-code-123',
338+
state: 'state-xyz',
339+
},
340+
});
341+
342+
expect(advance).toHaveBeenCalledWith({
343+
code: 'auth-code-123',
344+
state: 'state-xyz',
345+
});
346+
});
347+
348+
it('shows loading state when isAdvancing is true', () => {
349+
render(
350+
<GitLabOAuthLoginStep
351+
{...makeStepProps({
352+
stepData: {oauthUrl: 'https://gitlab.com/oauth/authorize'},
353+
isAdvancing: true,
354+
})}
355+
/>
356+
);
357+
358+
expect(screen.getByRole('button', {name: 'Authorizing...'})).toBeDisabled();
359+
});
360+
361+
it('disables authorize button when oauthUrl is not provided', () => {
362+
render(<GitLabOAuthLoginStep {...makeStepProps({stepData: {}})} />);
363+
364+
expect(screen.getByRole('button', {name: 'Authorize GitLab'})).toBeDisabled();
365+
});
366+
});

0 commit comments

Comments
 (0)