ApiAuthorization.test.tsx333 lines · main
1import { screen, waitFor } from '@testing-library/react'
2import userEvent from '@testing-library/user-event'
3import dayjs from 'dayjs'
4import { HttpResponse } from 'msw'
5import { describe, expect, test, vi } from 'vitest'
6
7import {
8 ApiAuthorizationScreen,
9 type ApiAuthorizationScreenProps,
10} from '@/components/interfaces/ApiAuthorization/ApiAuthorization'
11import type { ApiAuthorizationResponse } from '@/data/api-authorization/api-authorization-query'
12import type { ProfileContextType } from '@/lib/profile'
13import { createMockOrganization } from '@/tests/helpers'
14import { customRender } from '@/tests/lib/custom-render'
15import { addAPIMock } from '@/tests/lib/msw'
16import type { Organization } from '@/types'
17
18// --- Fixtures ---
19
20const DEFAULT_PROFILE_CONTEXT: ProfileContextType = {
21 profile: {
22 id: 1,
23 auth0_id: 'auth0|test',
24 gotrue_id: 'gotrue-test',
25 username: 'testuser',
26 primary_email: 'test@example.com',
27 first_name: null,
28 last_name: null,
29 mobile: null,
30 is_alpha_user: false,
31 is_sso_user: false,
32 disabled_features: [],
33 free_project_limit: null,
34 },
35 error: null,
36 isLoading: false,
37 isError: false,
38 isSuccess: true,
39}
40
41function createMockAuthResponse(
42 overrides: Partial<ApiAuthorizationResponse> = {}
43): ApiAuthorizationResponse {
44 return {
45 name: 'Test App',
46 website: 'https://testapp.com',
47 icon: null,
48 domain: 'testapp.com',
49 scopes: [],
50 expires_at: dayjs().add(1, 'hour').toISOString(),
51 approved_at: null,
52 registration_type: 'static',
53 ...overrides,
54 }
55}
56
57const DEFAULT_ORG = createMockOrganization({ name: 'My Org', slug: 'my-org' })
58const SECOND_ORG = createMockOrganization({ id: 2, name: 'Second Org', slug: 'second-org' })
59
60// --- MSW helpers ---
61
62// Both the auth query and the organizations query fire for any valid auth_id render.
63// Since MSW is configured with onUnhandledRequest: 'error', both must always be mocked.
64
65function mockAuthEndpoint(authResponse: ApiAuthorizationResponse) {
66 addAPIMock({
67 method: 'get',
68 path: '/platform/oauth/authorizations/:id',
69 response: () => HttpResponse.json(authResponse),
70 })
71}
72
73function mockOrgsEndpoint(orgs: Array<Organization> = [DEFAULT_ORG]) {
74 addAPIMock({
75 method: 'get',
76 path: '/platform/organizations',
77 response: () => HttpResponse.json(orgs),
78 })
79}
80
81function mockBothEndpoints(
82 authResponse: ApiAuthorizationResponse = createMockAuthResponse(),
83 orgs: Array<Organization> = [DEFAULT_ORG]
84) {
85 mockAuthEndpoint(authResponse)
86 mockOrgsEndpoint(orgs)
87}
88
89// --- Render helper ---
90
91function renderScreen(props: Partial<ApiAuthorizationScreenProps> = {}) {
92 const navigate = vi.fn()
93 const result = customRender(
94 <ApiAuthorizationScreen
95 auth_id="test-auth-id"
96 organization_slug={undefined}
97 navigate={navigate}
98 {...props}
99 />,
100 { profileContext: DEFAULT_PROFILE_CONTEXT }
101 )
102 return { ...result, navigate }
103}
104
105// --- Tests ---
106
107describe('ApiAuthorizationScreen', () => {
108 describe('when auth_id is missing', () => {
109 test('renders invalid screen when auth_id is undefined', () => {
110 renderScreen({ auth_id: undefined })
111 expect(screen.getByText('Missing parameters')).toBeInTheDocument()
112 expect(screen.getByText(/auth_id/)).toBeInTheDocument()
113 })
114 })
115
116 describe('when auth_id is provided', () => {
117 test('renders loading screen while authorization data is being fetched', () => {
118 mockOrgsEndpoint()
119 addAPIMock({
120 method: 'get',
121 path: '/platform/oauth/authorizations/:id',
122 response: () => new Promise(() => {}),
123 })
124 const { container } = renderScreen()
125 expect(screen.getByText('Loading...')).toBeInTheDocument()
126 expect(container.querySelectorAll('.shimmering-loader').length).toBeGreaterThan(0)
127 })
128
129 test('renders error screen when authorization query fails', async () => {
130 mockOrgsEndpoint()
131 addAPIMock({
132 method: 'get',
133 path: '/platform/oauth/authorizations/:id',
134 response: () => HttpResponse.json({ message: 'Not found' }, { status: 404 }),
135 })
136 renderScreen()
137 await screen.findByText('Failed to fetch details for API authorization request')
138 })
139
140 describe('when already approved', () => {
141 test('renders approved screen with matching organization name', async () => {
142 mockBothEndpoints(
143 createMockAuthResponse({
144 approved_at: '2025-01-15T10:00:00Z',
145 approved_organization_slug: 'my-org',
146 })
147 )
148 renderScreen()
149 await screen.findByText('This authorization request has been approved')
150 expect(screen.getByText(/organization "My Org"/)).toBeInTheDocument()
151 })
152
153 test('shows Unknown when approved organization is not in the user organizations list', async () => {
154 mockBothEndpoints(
155 createMockAuthResponse({
156 approved_at: '2025-01-15T10:00:00Z',
157 approved_organization_slug: 'other-org',
158 })
159 )
160 renderScreen()
161 await screen.findByText('This authorization request has been approved')
162 expect(screen.getByText(/organization "Unknown"/)).toBeInTheDocument()
163 })
164 })
165
166 describe('main authorization form', () => {
167 describe('organizations states', () => {
168 test('disables action buttons while organizations are being fetched', async () => {
169 mockAuthEndpoint(createMockAuthResponse())
170 addAPIMock({
171 method: 'get',
172 path: '/platform/organizations',
173 response: () => new Promise(() => {}),
174 })
175 renderScreen()
176 await screen.findByText('Authorize API access for Test App')
177 expect(screen.getByRole('button', { name: 'Decline' })).toBeDisabled()
178 expect(screen.getByRole('button', { name: /Authorize Test App/ })).toBeDisabled()
179 })
180
181 test('shows error notice, disables decline button, and hides accept button when organizations query fails', async () => {
182 mockAuthEndpoint(createMockAuthResponse())
183 addAPIMock({
184 method: 'get',
185 path: '/platform/organizations',
186 response: () => HttpResponse.json({ message: 'Server error' }, { status: 500 }),
187 })
188 renderScreen()
189 await screen.findByText('There was an error loading your organizations')
190 expect(screen.getByRole('button', { name: 'Decline' })).toBeDisabled()
191 expect(
192 screen.queryByRole('button', { name: /Authorize Test App/ })
193 ).not.toBeInTheDocument()
194 })
195
196 test('shows empty state when user has no organizations', async () => {
197 mockBothEndpoints(createMockAuthResponse(), [])
198 renderScreen()
199 await screen.findByText(/Your account isn't associated with any organizations/)
200 expect(screen.getByRole('link', { name: 'Create an organization' })).toBeInTheDocument()
201 expect(screen.getByRole('button', { name: 'Decline' })).toBeDisabled()
202 expect(
203 screen.queryByRole('button', { name: /Authorize Test App/ })
204 ).not.toBeInTheDocument()
205 })
206
207 test('shows not_member notice when organization_slug does not match any user organization', async () => {
208 mockBothEndpoints()
209 renderScreen({ organization_slug: 'nonexistent-org' })
210 await screen.findByText(/Your account is not a member of the pre-selected organization/)
211 expect(screen.getByRole('button', { name: 'Decline' })).toBeDisabled()
212 expect(screen.getByRole('button', { name: /Authorize Test App/ })).toBeDisabled()
213 })
214 })
215
216 describe('success state with organization selector', () => {
217 test('renders form with organization selector and action buttons', async () => {
218 mockBothEndpoints(createMockAuthResponse({ name: 'My OAuth App' }))
219 renderScreen()
220 await screen.findByText('Authorize API access for My OAuth App')
221 expect(screen.getByRole('combobox')).toBeInTheDocument()
222 expect(screen.getByRole('button', { name: /Authorize My OAuth App/ })).toBeInTheDocument()
223 expect(screen.getByRole('button', { name: 'Decline' })).toBeInTheDocument()
224 })
225
226 test('auto-selects the only organization when no organization_slug is provided', async () => {
227 mockBothEndpoints()
228 renderScreen()
229 const combobox = await screen.findByRole('combobox')
230 expect(combobox).toHaveTextContent('My Org')
231 })
232
233 test('pre-selects organization when organization_slug matches a user organization', async () => {
234 mockBothEndpoints(createMockAuthResponse(), [DEFAULT_ORG, SECOND_ORG])
235 renderScreen({ organization_slug: 'second-org' })
236 const combobox = await screen.findByRole('combobox')
237 expect(combobox).toHaveTextContent('Second Org')
238 expect(combobox).not.toHaveTextContent('My Org')
239 expect(
240 screen.getByText('This organization has been pre-selected by Test App.')
241 ).toBeInTheDocument()
242 })
243 })
244
245 describe('MCP client warning', () => {
246 test('shows MCP warning when registration_type is dynamic', async () => {
247 mockBothEndpoints(createMockAuthResponse({ registration_type: 'dynamic' }))
248 renderScreen()
249 await screen.findByText('MCP Client Connection')
250 })
251
252 test('does not show MCP warning for non-dynamic registration type', async () => {
253 mockBothEndpoints()
254 renderScreen()
255 await screen.findByText('Authorize API access for Test App')
256 expect(screen.queryByText('MCP Client Connection')).not.toBeInTheDocument()
257 })
258 })
259
260 describe('expiration', () => {
261 test('shows expiration warning and disables buttons when request has expired', async () => {
262 mockBothEndpoints(
263 createMockAuthResponse({ expires_at: dayjs().subtract(1, 'hour').toISOString() })
264 )
265 renderScreen()
266 await screen.findByText('This authorization request is expired')
267 expect(screen.getByRole('button', { name: 'Decline' })).toBeDisabled()
268 expect(screen.getByRole('button', { name: /Authorize Test App/ })).toBeDisabled()
269 })
270
271 test('does not show expiration warning when request has not expired', async () => {
272 mockBothEndpoints()
273 renderScreen()
274 await screen.findByText('Authorize API access for Test App')
275 expect(
276 screen.queryByText('This authorization request is expired')
277 ).not.toBeInTheDocument()
278 })
279 })
280
281 describe('approve action', () => {
282 test('calls approve endpoint when Authorize button is clicked', async () => {
283 const user = userEvent.setup()
284 const approveHandler = vi.fn(() =>
285 HttpResponse.json({ url: 'https://redirect.example.com' })
286 )
287 mockBothEndpoints()
288 addAPIMock({
289 method: 'post',
290 path: '/platform/organizations/:slug/oauth/authorizations/:id',
291 response: approveHandler,
292 })
293 renderScreen()
294 await screen.findByRole('button', { name: /Authorize Test App/ })
295 await user.click(screen.getByRole('button', { name: /Authorize Test App/ }))
296 await waitFor(() => expect(approveHandler).toHaveBeenCalled())
297 })
298 })
299
300 describe('decline action', () => {
301 test('navigates to /organizations after declining', async () => {
302 const user = userEvent.setup()
303 const declineHandler = vi.fn(() => HttpResponse.json({ id: 'test-auth-id' }))
304 mockBothEndpoints()
305 addAPIMock({
306 method: 'delete',
307 path: '/platform/organizations/:slug/oauth/authorizations/:id',
308 response: declineHandler,
309 })
310 const { navigate } = renderScreen()
311 await screen.findByRole('button', { name: 'Decline' })
312 await user.click(screen.getByRole('button', { name: 'Decline' }))
313 await waitFor(() => expect(declineHandler).toHaveBeenCalled())
314 await waitFor(() => expect(navigate).toHaveBeenCalledWith('/organizations'))
315 })
316 })
317
318 describe('form validation', () => {
319 test('shows validation error when Authorize is clicked without selecting an organization', async () => {
320 const user = userEvent.setup()
321 // Two orgs → no auto-selection, user must pick one manually
322 mockBothEndpoints(createMockAuthResponse(), [DEFAULT_ORG, SECOND_ORG])
323 renderScreen()
324 await screen.findByRole('button', { name: /Authorize Test App/ })
325 await user.click(screen.getByRole('button', { name: /Authorize Test App/ }))
326 expect(
327 (await screen.findAllByText('Please select an organization')).length
328 ).toBeGreaterThan(0)
329 })
330 })
331 })
332 })
333})