ApiAuthorization.test.tsx333 lines · main
| 1 | import { screen, waitFor } from '@testing-library/react' |
| 2 | import userEvent from '@testing-library/user-event' |
| 3 | import dayjs from 'dayjs' |
| 4 | import { HttpResponse } from 'msw' |
| 5 | import { describe, expect, test, vi } from 'vitest' |
| 6 | |
| 7 | import { |
| 8 | ApiAuthorizationScreen, |
| 9 | type ApiAuthorizationScreenProps, |
| 10 | } from '@/components/interfaces/ApiAuthorization/ApiAuthorization' |
| 11 | import type { ApiAuthorizationResponse } from '@/data/api-authorization/api-authorization-query' |
| 12 | import type { ProfileContextType } from '@/lib/profile' |
| 13 | import { createMockOrganization } from '@/tests/helpers' |
| 14 | import { customRender } from '@/tests/lib/custom-render' |
| 15 | import { addAPIMock } from '@/tests/lib/msw' |
| 16 | import type { Organization } from '@/types' |
| 17 | |
| 18 | // --- Fixtures --- |
| 19 | |
| 20 | const 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 | |
| 41 | function 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 | |
| 57 | const DEFAULT_ORG = createMockOrganization({ name: 'My Org', slug: 'my-org' }) |
| 58 | const 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 | |
| 65 | function mockAuthEndpoint(authResponse: ApiAuthorizationResponse) { |
| 66 | addAPIMock({ |
| 67 | method: 'get', |
| 68 | path: '/platform/oauth/authorizations/:id', |
| 69 | response: () => HttpResponse.json(authResponse), |
| 70 | }) |
| 71 | } |
| 72 | |
| 73 | function mockOrgsEndpoint(orgs: Array<Organization> = [DEFAULT_ORG]) { |
| 74 | addAPIMock({ |
| 75 | method: 'get', |
| 76 | path: '/platform/organizations', |
| 77 | response: () => HttpResponse.json(orgs), |
| 78 | }) |
| 79 | } |
| 80 | |
| 81 | function mockBothEndpoints( |
| 82 | authResponse: ApiAuthorizationResponse = createMockAuthResponse(), |
| 83 | orgs: Array<Organization> = [DEFAULT_ORG] |
| 84 | ) { |
| 85 | mockAuthEndpoint(authResponse) |
| 86 | mockOrgsEndpoint(orgs) |
| 87 | } |
| 88 | |
| 89 | // --- Render helper --- |
| 90 | |
| 91 | function 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 | |
| 107 | describe('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 | }) |