Skip to content

Commit 933f4d2

Browse files
committed
test(gitlab): cover access operations
Covers the access_level enum-to-integer coercion, the /members/all default vs direct-only, the 409-duplicate-add soft success, invitation per-email error handling, user-status-action response parsing, and getGitLabResourcePath.
1 parent 12c6be5 commit 933f4d2

3 files changed

Lines changed: 311 additions & 1 deletion

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { GitLabBlock } from './gitlab'
6+
7+
const block = GitLabBlock
8+
9+
describe('GitLabBlock access operations', () => {
10+
it('routes every access operation to its matching tool id without serialization-time coercion', () => {
11+
const accessOps = [
12+
'gitlab_list_members',
13+
'gitlab_add_member',
14+
'gitlab_update_member',
15+
'gitlab_remove_member',
16+
'gitlab_invite_member',
17+
'gitlab_approve_access_request',
18+
'gitlab_search_users',
19+
'gitlab_block_user',
20+
'gitlab_add_saml_group_link',
21+
]
22+
for (const toolId of accessOps) {
23+
expect(block.tools.access).toContain(toolId)
24+
expect(block.tools.config.tool?.({ operation: toolId })).toBe(toolId)
25+
}
26+
})
27+
28+
it('exposes the named access-level dropdown with GitLab integer ids', () => {
29+
const accessLevel = block.subBlocks.find((s) => s.id === 'accessLevel')
30+
expect(accessLevel?.type).toBe('dropdown')
31+
const options = typeof accessLevel?.options === 'function' ? undefined : accessLevel?.options
32+
expect(options?.map((o) => o.id)).toEqual(['0', '5', '10', '15', '20', '25', '30', '40', '50'])
33+
expect(accessLevel?.value?.()).toBe('30')
34+
})
35+
36+
it('coerces the selected access level from the dropdown string to an integer at execution time', () => {
37+
const addParams = block.tools.config.params?.({
38+
accessToken: 'pat',
39+
operation: 'gitlab_add_member',
40+
resourceType: 'group',
41+
resourceId: '42',
42+
userId: '7',
43+
accessLevel: '40',
44+
expiresAt: '2026-12-31',
45+
memberRoleId: '5',
46+
})
47+
expect(addParams).toMatchObject({
48+
resourceType: 'group',
49+
resourceId: '42',
50+
userId: 7,
51+
accessLevel: 40,
52+
expiresAt: '2026-12-31',
53+
memberRoleId: 5,
54+
})
55+
expect(typeof addParams?.accessLevel).toBe('number')
56+
})
57+
58+
it('defaults list members to inherited members (directOnly falsy)', () => {
59+
const listParams = block.tools.config.params?.({
60+
accessToken: 'pat',
61+
operation: 'gitlab_list_members',
62+
resourceType: 'project',
63+
resourceId: 'grp/proj',
64+
})
65+
expect(listParams).toMatchObject({ resourceType: 'project', resourceId: 'grp/proj' })
66+
expect(listParams?.directOnly).toBeUndefined()
67+
68+
const directParams = block.tools.config.params?.({
69+
accessToken: 'pat',
70+
operation: 'gitlab_list_members',
71+
resourceType: 'project',
72+
resourceId: 'grp/proj',
73+
directMembersOnly: true,
74+
})
75+
expect(directParams?.directOnly).toBe(true)
76+
})
77+
78+
it('coerces the target user id for admin user actions', () => {
79+
const blockParams = block.tools.config.params?.({
80+
accessToken: 'pat',
81+
operation: 'gitlab_block_user',
82+
userId: '99',
83+
})
84+
expect(blockParams).toMatchObject({ userId: 99 })
85+
expect(typeof blockParams?.userId).toBe('number')
86+
})
87+
88+
it('optionally coerces the granted access level for approve access request', () => {
89+
const approveParams = block.tools.config.params?.({
90+
accessToken: 'pat',
91+
operation: 'gitlab_approve_access_request',
92+
resourceType: 'group',
93+
resourceId: '42',
94+
userId: '7',
95+
accessLevel: '30',
96+
})
97+
expect(approveParams).toMatchObject({ userId: 7, accessLevel: 30 })
98+
})
99+
100+
it('throws when required access fields are missing', () => {
101+
expect(() =>
102+
block.tools.config.params?.({
103+
accessToken: 'pat',
104+
operation: 'gitlab_add_member',
105+
resourceType: 'group',
106+
resourceId: '42',
107+
})
108+
).toThrow()
109+
})
110+
})
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { gitlabAddMemberTool } from '@/tools/gitlab/add_member'
6+
import { gitlabApproveAccessRequestTool } from '@/tools/gitlab/approve_access_request'
7+
import { gitlabInviteMemberTool } from '@/tools/gitlab/invite_member'
8+
import { gitlabListMembersTool } from '@/tools/gitlab/list_members'
9+
import { gitlabUpdateMemberTool } from '@/tools/gitlab/update_member'
10+
import { gitlabBlockUserTool } from '@/tools/gitlab/user_status_actions'
11+
12+
interface MockResponseOptions {
13+
ok?: boolean
14+
status?: number
15+
json?: unknown
16+
text?: string
17+
headers?: Record<string, string>
18+
}
19+
20+
function mockResponse({
21+
ok = true,
22+
status = 200,
23+
json,
24+
text = '',
25+
headers = {},
26+
}: MockResponseOptions): Response {
27+
return {
28+
ok,
29+
status,
30+
json: async () => json,
31+
text: async () => text,
32+
headers: {
33+
get: (key: string) => headers[key.toLowerCase()] ?? null,
34+
},
35+
} as unknown as Response
36+
}
37+
38+
const baseArgs = { accessToken: 'pat', resourceType: 'group' as const, resourceId: '42' }
39+
40+
describe('gitlab_list_members', () => {
41+
it('defaults to /members/all so inherited members are included', () => {
42+
const url = gitlabListMembersTool.request.url({ ...baseArgs })
43+
expect(url).toBe('https://gitlab.com/api/v4/groups/42/members/all')
44+
})
45+
46+
it('uses /members when directOnly is set', () => {
47+
const url = gitlabListMembersTool.request.url({ ...baseArgs, directOnly: true })
48+
expect(url).toBe('https://gitlab.com/api/v4/groups/42/members')
49+
})
50+
51+
it('builds a project path and forwards pagination', () => {
52+
const url = gitlabListMembersTool.request.url({
53+
...baseArgs,
54+
resourceType: 'project',
55+
resourceId: 'grp/proj',
56+
perPage: 50,
57+
page: 2,
58+
})
59+
expect(url).toBe('https://gitlab.com/api/v4/projects/grp%2Fproj/members/all?per_page=50&page=2')
60+
})
61+
})
62+
63+
describe('gitlab_add_member', () => {
64+
it('sends integer user_id and access_level in the body', () => {
65+
const body = gitlabAddMemberTool.request.body?.({
66+
...baseArgs,
67+
userId: 7,
68+
accessLevel: 30,
69+
expiresAt: '2026-12-31',
70+
memberRoleId: 5,
71+
})
72+
expect(body).toEqual({
73+
user_id: 7,
74+
access_level: 30,
75+
expires_at: '2026-12-31',
76+
member_role_id: 5,
77+
})
78+
})
79+
80+
it('treats a 409 as a soft success so workflows are re-runnable', async () => {
81+
const result = await gitlabAddMemberTool.transformResponse!(
82+
mockResponse({ ok: false, status: 409, json: { message: 'Member already exists' } }),
83+
{} as never
84+
)
85+
expect(result.success).toBe(true)
86+
expect(result.output.alreadyMember).toBe(true)
87+
})
88+
89+
it('returns the created member on success', async () => {
90+
const result = await gitlabAddMemberTool.transformResponse!(
91+
mockResponse({ ok: true, status: 201, json: { id: 7, access_level: 30 } }),
92+
{} as never
93+
)
94+
expect(result.success).toBe(true)
95+
expect(result.output.alreadyMember).toBe(false)
96+
expect(result.output.member).toEqual({ id: 7, access_level: 30 })
97+
})
98+
99+
it('surfaces other errors as hard failures', async () => {
100+
const result = await gitlabAddMemberTool.transformResponse!(
101+
mockResponse({ ok: false, status: 403, text: 'Forbidden' }),
102+
{} as never
103+
)
104+
expect(result.success).toBe(false)
105+
})
106+
})
107+
108+
describe('gitlab_update_member', () => {
109+
it('sends the new access_level integer and expires_at', () => {
110+
const body = gitlabUpdateMemberTool.request.body?.({
111+
...baseArgs,
112+
userId: 7,
113+
accessLevel: 40,
114+
expiresAt: '2027-01-01',
115+
})
116+
expect(body).toEqual({ access_level: 40, expires_at: '2027-01-01' })
117+
})
118+
})
119+
120+
describe('gitlab_approve_access_request', () => {
121+
it('passes the granted access_level as an integer query param', () => {
122+
const url = gitlabApproveAccessRequestTool.request.url({
123+
...baseArgs,
124+
userId: 7,
125+
accessLevel: 40,
126+
})
127+
expect(url).toBe(
128+
'https://gitlab.com/api/v4/groups/42/access_requests/7/approve?access_level=40'
129+
)
130+
})
131+
132+
it('omits access_level when not provided (GitLab defaults to Developer)', () => {
133+
const url = gitlabApproveAccessRequestTool.request.url({ ...baseArgs, userId: 7 })
134+
expect(url).toBe('https://gitlab.com/api/v4/groups/42/access_requests/7/approve')
135+
})
136+
})
137+
138+
describe('gitlab_invite_member', () => {
139+
it('reports a per-email failure even when GitLab returns 200 with status:error', async () => {
140+
const result = await gitlabInviteMemberTool.transformResponse!(
141+
mockResponse({
142+
ok: true,
143+
status: 201,
144+
json: { status: 'error', message: { 'a@b.com': 'Already invited' } },
145+
}),
146+
{} as never
147+
)
148+
expect(result.success).toBe(false)
149+
expect(result.output.status).toBe('error')
150+
})
151+
152+
it('reports success when GitLab accepts the invite', async () => {
153+
const result = await gitlabInviteMemberTool.transformResponse!(
154+
mockResponse({ ok: true, status: 201, json: { status: 'success' } }),
155+
{} as never
156+
)
157+
expect(result.success).toBe(true)
158+
expect(result.output.status).toBe('success')
159+
})
160+
})
161+
162+
describe('gitlab user status actions', () => {
163+
it('returns success with no user object when GitLab responds with a bare true', async () => {
164+
const result = await gitlabBlockUserTool.transformResponse!(
165+
mockResponse({ ok: true, status: 201, json: true }),
166+
{} as never
167+
)
168+
expect(result.success).toBe(true)
169+
expect(result.output.success).toBe(true)
170+
expect(result.output.user).toBeUndefined()
171+
})
172+
173+
it('surfaces the updated user object when GitLab returns one', async () => {
174+
const result = await gitlabBlockUserTool.transformResponse!(
175+
mockResponse({ ok: true, status: 201, json: { id: 9, state: 'blocked' } }),
176+
{} as never
177+
)
178+
expect(result.success).toBe(true)
179+
expect(result.output.user).toEqual({ id: 9, state: 'blocked' })
180+
})
181+
})

apps/sim/tools/gitlab/utils.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { getGitLabApiBase, normalizeGitLabHost, UnsafeGitLabHostError } from '@/tools/gitlab/utils'
5+
import {
6+
getGitLabApiBase,
7+
getGitLabResourcePath,
8+
normalizeGitLabHost,
9+
UnsafeGitLabHostError,
10+
} from '@/tools/gitlab/utils'
611

712
describe('normalizeGitLabHost', () => {
813
it('defaults to gitlab.com when the host is empty, blank, or not a string', () => {
@@ -69,3 +74,17 @@ describe('getGitLabApiBase', () => {
6974
expect(() => getGitLabApiBase('legit.com@evil.com')).toThrow(UnsafeGitLabHostError)
7075
})
7176
})
77+
78+
describe('getGitLabResourcePath', () => {
79+
it('builds project and group path segments', () => {
80+
expect(getGitLabResourcePath('project', 42)).toBe('projects/42')
81+
expect(getGitLabResourcePath('group', 7)).toBe('groups/7')
82+
})
83+
84+
it('URL-encodes namespaced paths and trims whitespace', () => {
85+
expect(getGitLabResourcePath('project', ' mygroup/myproject ')).toBe(
86+
'projects/mygroup%2Fmyproject'
87+
)
88+
expect(getGitLabResourcePath('group', 'parent/child')).toBe('groups/parent%2Fchild')
89+
})
90+
})

0 commit comments

Comments
 (0)