Skip to content

Commit 55ab2fd

Browse files
committed
fix(access-requests): align permission checks and pending request flows
1 parent 31ad74b commit 55ab2fd

13 files changed

Lines changed: 482 additions & 41 deletions

File tree

‎apps/sim/app/api/permission-groups/user/route.test.ts‎

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/** @vitest-environment node */
2-
import { createMockRequest } from '@sim/testing'
3-
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { createMockRequest, resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
44

55
const mocks = vi.hoisted(() => ({
66
session: vi.fn(),
@@ -22,7 +22,10 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ isOrganizationAdminOrOwne
2222
vi.mock('@/lib/billing/core/subscription', () => ({
2323
isOrganizationOnEnterprisePlan: mocks.enterprise,
2424
}))
25-
vi.mock('@/lib/permission-groups/resolve.server', () => ({ resolveWorkspaceGroup: mocks.group }))
25+
vi.mock('@/lib/permission-groups/resolve.server', async (importOriginal) => ({
26+
...(await importOriginal<typeof import('@/lib/permission-groups/resolve.server')>()),
27+
resolveWorkspaceGroup: mocks.group,
28+
}))
2629

2730
import { userPermissionConfigSchema } from '@/lib/api/contracts/permission-groups'
2831
import { OrchestrationError } from '@/lib/core/orchestration/types'
@@ -53,6 +56,7 @@ function get(query = '?workspaceId=workspace') {
5356

5457
beforeEach(() => {
5558
vi.clearAllMocks()
59+
setEnvFlags({ isHosted: true, isAccessControlEnabled: true })
5660
mocks.session.mockResolvedValue({
5761
user: { id: 'viewer' },
5862
session: { id: 'session', activeOrganizationId: 'unrelated-org' },
@@ -64,7 +68,40 @@ beforeEach(() => {
6468
mocks.group.mockResolvedValue(null)
6569
})
6670

71+
afterEach(resetEnvFlagsMock)
72+
6773
describe('user permission policy shared read', () => {
74+
it.each([
75+
{ hosted: false, accessControl: false, entitled: false },
76+
{ hosted: false, accessControl: true, entitled: true },
77+
{ hosted: true, accessControl: false, entitled: true },
78+
])(
79+
'matches the active permission regime ($hosted, $accessControl)',
80+
async ({ hosted, accessControl, entitled }) => {
81+
setEnvFlags({
82+
isHosted: hosted,
83+
isAccessControlEnabled: accessControl,
84+
isBillingEnabled: false,
85+
})
86+
mocks.admin.mockResolvedValue(true)
87+
const group = {
88+
permissionGroupId: 'group',
89+
groupName: 'Restricted',
90+
config: { ...DEFAULT_PERMISSION_GROUP_CONFIG, hideCopilot: true },
91+
}
92+
mocks.group.mockResolvedValue(group)
93+
const expected = { ...unrestricted, ...(entitled ? group : {}), entitled, isOrgAdmin: true }
94+
expect(await (await get()).json()).toEqual(expected)
95+
expect(
96+
await readUserPermissionConfig.execute({ principal, input: { workspaceId: 'workspace' } })
97+
).toEqual(expected)
98+
if (!entitled) {
99+
expect(mocks.group).not.toHaveBeenCalled()
100+
expect(mocks.enterprise).not.toHaveBeenCalled()
101+
}
102+
}
103+
)
104+
68105
it('authenticates before parsing or protected lookups', async () => {
69106
mocks.session.mockResolvedValue(null)
70107
expect((await get('')).status).toBe(401)

‎apps/sim/components/access-requests/my-access-requests.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ describe('compact requester history', () => {
101101
expect(mocks.mine).toHaveBeenCalledWith(scope, 50, undefined, false)
102102
expect(mocks.mine).toHaveBeenCalledWith(scope, 0, 'request')
103103
expect(mocks.discovery).toHaveBeenCalledWith(
104-
expect.objectContaining({ search: 'slack', offset: 50 }),
104+
expect.objectContaining({ search: 'slack', offset: 50, state: 'requestable' }),
105105
true
106106
)
107107
})

‎apps/sim/components/access-requests/request-access-action.test.tsx‎

Lines changed: 183 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
77
import { RequestAccessAction } from '@/components/access-requests/request-access-action'
88
import type { AccessRequestTarget } from '@/lib/api/contracts/access-requests'
99

10+
const mocks = vi.hoisted(() => ({
11+
discovery: vi.fn(),
12+
create: vi.fn(),
13+
push: vi.fn(),
14+
refetch: vi.fn(),
15+
}))
16+
vi.mock('next/navigation', () => ({
17+
useRouter: () => ({ push: mocks.push }),
18+
usePathname: () => '/workspace/workspace',
19+
}))
1020
vi.mock('@/hooks/queries/access-requests', () => ({
11-
useCreateAccessRequest: () => ({ mutate: vi.fn(), isPending: false, error: null }),
21+
useCreateAccessRequest: () => ({ mutate: mocks.create, isPending: false, error: null }),
22+
useDiscoverAccessRequests: mocks.discovery,
1223
}))
1324

1425
const scope = { kind: 'workspace', workspaceId: 'workspace' } as const
@@ -18,6 +29,16 @@ describe('request form lifecycle', () => {
1829
let container: HTMLDivElement
1930
let root: Root
2031
beforeEach(() => {
32+
vi.clearAllMocks()
33+
mocks.discovery.mockReturnValue({
34+
isSuccess: true,
35+
isPending: false,
36+
isError: false,
37+
data: {
38+
enabled: true,
39+
entries: [{ target: tables, state: 'requestable', pendingRequestId: null }],
40+
},
41+
})
2142
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
2243
container = document.createElement('div')
2344
document.body.appendChild(container)
@@ -57,4 +78,165 @@ describe('request form lifecycle', () => {
5778
render(undefined, { kind: 'feature', configKey: 'hideKnowledgeBaseTab' })
5879
expect(document.querySelector('[role="dialog"]')).toBeNull()
5980
})
81+
const clickDialogButton = (label: string) => {
82+
const button = Array.from(
83+
document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button')
84+
).find((button) => button.textContent === label)
85+
expect(button).toBeDefined()
86+
act(() => button!.click())
87+
}
88+
89+
it('does no target discovery until opened and looks up only the chosen target', () => {
90+
render()
91+
expect(mocks.discovery).not.toHaveBeenCalled()
92+
open()
93+
expect(mocks.discovery).toHaveBeenCalledWith({
94+
...scope,
95+
targetKind: 'feature',
96+
targetKey: 'feature:hideTablesTab',
97+
limit: 1,
98+
offset: 0,
99+
})
100+
expect(document.querySelector('textarea')).not.toBeNull()
101+
clickDialogButton('Send request')
102+
expect(mocks.create).toHaveBeenCalledWith(
103+
{ scope, target: tables, reason: '' },
104+
expect.any(Object)
105+
)
106+
})
107+
108+
it('opens the existing pending request without allowing another reason or submission', () => {
109+
mocks.discovery.mockReturnValue({
110+
isSuccess: true,
111+
data: {
112+
enabled: true,
113+
entries: [{ state: 'requestable', pendingRequestId: 'pending/request' }],
114+
},
115+
})
116+
render()
117+
open()
118+
expect(document.querySelector('textarea')).toBeNull()
119+
clickDialogButton('View request')
120+
expect(mocks.push).toHaveBeenCalledWith(
121+
'/workspace/workspace/access-requests?requestId=pending%2Frequest'
122+
)
123+
expect(mocks.create).not.toHaveBeenCalled()
124+
expect(document.querySelector('[role="dialog"]')).toBeNull()
125+
})
126+
127+
it('routes a pending member limit request in its organization scope', () => {
128+
const organizationScope = { kind: 'organization', organizationId: 'organization' } as const
129+
const limit = { kind: 'usage_limit', id: 'member' } as const
130+
mocks.discovery.mockReturnValue({
131+
isSuccess: true,
132+
data: {
133+
enabled: true,
134+
entries: [{ state: 'requestable', pendingRequestId: 'limit-request' }],
135+
},
136+
})
137+
act(() =>
138+
root.render(
139+
<RequestAccessAction scope={organizationScope} target={limit} label='Member usage limit' />
140+
)
141+
)
142+
open()
143+
expect(mocks.discovery).toHaveBeenCalledWith({
144+
...organizationScope,
145+
targetKind: 'usage_limit',
146+
targetKey: 'usage_limit:member',
147+
limit: 1,
148+
offset: 0,
149+
})
150+
clickDialogButton('View request')
151+
expect(mocks.push).toHaveBeenCalledWith(
152+
'/access-requests?requestId=limit-request&organizationId=organization'
153+
)
154+
expect(mocks.create).not.toHaveBeenCalled()
155+
})
156+
157+
it('blocks submission until discovery completes', () => {
158+
mocks.discovery.mockReturnValue({ isPending: true })
159+
render()
160+
open()
161+
clickDialogButton('Loading...')
162+
expect(document.querySelector('textarea')).toBeNull()
163+
expect(mocks.create).not.toHaveBeenCalled()
164+
})
165+
166+
it('offers retry instead of using cached eligibility after a failed refresh', () => {
167+
mocks.discovery.mockReturnValue({
168+
isSuccess: false,
169+
isError: true,
170+
error: new Error('Access check failed'),
171+
refetch: mocks.refetch,
172+
data: { enabled: true, entries: [{ state: 'requestable', pendingRequestId: null }] },
173+
})
174+
render()
175+
open()
176+
expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Access check failed')
177+
expect(document.querySelector('textarea')).toBeNull()
178+
clickDialogButton('Retry')
179+
expect(mocks.refetch).toHaveBeenCalledOnce()
180+
expect(mocks.create).not.toHaveBeenCalled()
181+
})
182+
183+
it.each([
184+
{ enabled: false, entries: [] },
185+
{ enabled: true, entries: [] },
186+
{
187+
enabled: true,
188+
entries: [
189+
{ state: 'allowed', reason: 'Access is already available.', pendingRequestId: null },
190+
],
191+
},
192+
{
193+
enabled: true,
194+
entries: [{ state: 'unavailable', reason: 'Access is unavailable.', pendingRequestId: null }],
195+
},
196+
])('does not submit when the target is not requestable: %j', (data) => {
197+
mocks.discovery.mockReturnValue({ isSuccess: true, data })
198+
render()
199+
open()
200+
clickDialogButton('Send request')
201+
expect(document.querySelector('textarea')).toBeNull()
202+
expect(mocks.create).not.toHaveBeenCalled()
203+
})
204+
it('preserves the caller navigation handler for a newly discovered pending request', () => {
205+
const onViewRequest = vi.fn()
206+
mocks.discovery.mockReturnValue({
207+
isSuccess: true,
208+
data: { enabled: true, entries: [{ state: 'requestable', pendingRequestId: 'existing' }] },
209+
})
210+
act(() =>
211+
root.render(
212+
<RequestAccessAction
213+
scope={scope}
214+
target={tables}
215+
label='Tables'
216+
onViewRequest={onViewRequest}
217+
/>
218+
)
219+
)
220+
open()
221+
clickDialogButton('View request')
222+
expect(onViewRequest).toHaveBeenCalledWith('existing')
223+
expect(mocks.push).not.toHaveBeenCalled()
224+
})
225+
226+
it('presents newly granted access as a neutral result', () => {
227+
mocks.discovery.mockReturnValue({
228+
isSuccess: true,
229+
data: {
230+
enabled: true,
231+
entries: [{ state: 'allowed', reason: null, pendingRequestId: null }],
232+
},
233+
})
234+
render()
235+
open()
236+
expect(document.querySelector('[role="dialog"]')?.textContent).toContain(
237+
'Access is already available.'
238+
)
239+
expect(document.querySelector('[role="alert"]')).toBeNull()
240+
expect(document.querySelector('textarea')).toBeNull()
241+
})
60242
})

0 commit comments

Comments
 (0)