mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 03:45:06 +03:00
## What kind of change does this PR introduce? Feature + docs. Closes [DEPR-604](https://linear.app/supabase/issue/DEPR-604/define-connect-logo-asset-and-variant-contract). ## What is the current behavior? `/authorize` logo resolution trusted self-asserted requester `name` (and similar) for curated MCP marks, fell back to a letter tile when there was no usable icon, and always used theme-reactive tile chrome. This includes the scenario when pairing against unclassified uploaded OAuth app bitmaps. ## What is the new behavior? - [Documents the Connect logo asset/variant contract](https://design-system-git-danny-depr-604-connect-logo-contract-supabase.vercel.app/design-system/docs/ui-patterns/connect-interstitials#logos) (default to light, keep pairs matched, no theme-recolour of vendor SVGs). - Resolves curated partner logos from allowlisted `redirect_uri` hosts only (`claude.ai` / `anthropic.com`, `cursor.com` / `cursor.sh`, `chatgpt.com` / `openai.com`, `perplexity.ai`). - Unknown / missing / failed requester icons show `SupabaseLogo` alone (no letter tile). - Uploaded organisation OAuth app icons (unclassified bitmaps) pair with fixed light tile chrome (`border-black/10 bg-white` / `SupabaseLogo forceLight`) on both sides across Studio themes. - Curated partners keep theme-reactive tiles and may use dark assets when available. ### To test Real MCP clients (Claude, Cursor, etc.) only send users to **production** `/authorize`, so you cannot drive a local or preview Studio build from those tools. Use a Network override instead: 1. Start Studio and sign in (`pnpm dev:studio`, or use the Vercel preview once available). 2. Open `/dashboard/authorize?auth_id=foo` (any `auth_id` is fine — the real response may 404). 3. DevTools → **Network** → find `GET …/platform/oauth/authorizations/foo` (or whatever id you used). 4. Right-click → **Override content** (enable Local Overrides / pick a folder if prompted). 5. Paste one of the payloads below (status **200**), save, then reload the authorize page. 6. Keep `expires_at` in the future so the request does not look expired. The fields that matter for this PR are `name`, `icon`, and `redirect_uri`. #### Curated pair (allowlisted redirect) Expect Cursor mark + Supabase pair. Toggle light/dark: curated dark assets may swap; tiles stay theme-reactive (`bg-surface-75`). ```json { "name": "Cursor", "website": "https://cursor.com", "icon": null, "domain": "cursor.com", "redirect_uri": "https://cursor.com/callback", "expires_at": "2099-01-01T00:00:00.000Z", "scopes": ["organizations:read", "projects:read"], "approved_at": null, "registration_type": "dynamic" } ``` #### Unknown → Supabase alone Expect Supabase bolt alone. No letter tile. No curated mark even if `name` says Claude. ```json { "name": "Acme", "website": "https://acme.example", "icon": null, "domain": "acme.example", "redirect_uri": "https://acme.example/callback", "expires_at": "2099-01-01T00:00:00.000Z", "scopes": ["organizations:read", "projects:read"], "approved_at": null, "registration_type": "dynamic" } ``` #### Spoofed trusted name, non-allowlisted redirect (logo only) Expect Supabase alone (no Claude mark). This PR does **not** show the impersonation caution (that is coming in #48162). ```json { "name": "Claude", "website": "https://claude.ai", "icon": null, "domain": "claude.ai", "redirect_uri": "https://evil.com/callback", "expires_at": "2099-01-01T00:00:00.000Z", "scopes": ["organizations:read", "projects:read"], "approved_at": null, "registration_type": "dynamic" } ``` #### Uploaded OAuth app icon → forced-light pair Expect remote icon + Supabase pair with forced-light tiles (`border-black/10 bg-white`) on both sides in light and dark Studio themes. The icon URL below is the checked-in solid-colour Acme bitmap on this branch. ```json { "name": "Acme", "website": "https://acme.example", "icon": "https://raw.githubusercontent.com/supabase/supabase/danny/depr-604-connect-logo-contract/apps/design-system/public/img/icons/acme-oauth-icon.png", "domain": "acme.example", "redirect_uri": "https://acme.example/callback", "expires_at": "2099-01-01T00:00:00.000Z", "scopes": ["organizations:read", "projects:read"], "approved_at": null, "registration_type": "static" } ``` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Improved authorization interstitial branding with trusted requester logos and safer fallback behavior. * Added support for consistent light-theme treatment of uploaded OAuth app icons. * Added examples and documentation for unknown requesters, uploaded logos, and wrong-account states. * **Bug Fixes** * Prevented unverified or unavailable requester icons from being presented as trusted. * Ensured logo pairing remains visually consistent across light and dark themes. * **Tests** * Added coverage for trusted-host validation, fallback branding, icon loading failures, and theme behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
460 lines
18 KiB
TypeScript
460 lines
18 KiB
TypeScript
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import { platformComponents as components } from 'api-types'
|
|
import dayjs from 'dayjs'
|
|
import { HttpResponse } from 'msw'
|
|
import { getMcpClientIconSrc } from 'ui-patterns/McpUrlBuilder'
|
|
import { describe, expect, test, vi } from 'vitest'
|
|
|
|
import {
|
|
ApiAuthorizationScreen,
|
|
type ApiAuthorizationScreenProps,
|
|
} from '@/components/interfaces/ApiAuthorization/ApiAuthorization'
|
|
import { AuthorizeConnectLogo } from '@/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails'
|
|
import type { ApiAuthorizationResponse } from '@/data/api-authorization/api-authorization-query'
|
|
import type { ProfileContextType } from '@/lib/profile'
|
|
import { createMockOrganizationResponse } from '@/tests/helpers'
|
|
import { customRender } from '@/tests/lib/custom-render'
|
|
import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'
|
|
|
|
type OrganizationResponse = components['schemas']['OrganizationResponse']
|
|
type GetOAuthAuthorizationResponse = components['schemas']['GetOAuthAuthorizationResponse']
|
|
type ApproveAuthorizationResponse = components['schemas']['ApproveAuthorizationResponse']
|
|
type DeclineAuthorizationResponse = components['schemas']['DeclineAuthorizationResponse']
|
|
|
|
// --- Fixtures ---
|
|
|
|
const DEFAULT_PROFILE_CONTEXT: ProfileContextType = {
|
|
profile: {
|
|
id: 1,
|
|
auth0_id: 'auth0|test',
|
|
gotrue_id: 'gotrue-test',
|
|
username: 'testuser',
|
|
primary_email: 'test@example.com',
|
|
first_name: null,
|
|
last_name: null,
|
|
mobile: null,
|
|
is_alpha_user: false,
|
|
is_sso_user: false,
|
|
disabled_features: [],
|
|
free_project_limit: null,
|
|
},
|
|
error: null,
|
|
isLoading: false,
|
|
isError: false,
|
|
isSuccess: true,
|
|
}
|
|
|
|
function createMockAuthResponse(
|
|
overrides: Partial<ApiAuthorizationResponse> = {}
|
|
): ApiAuthorizationResponse {
|
|
return {
|
|
name: 'Test App',
|
|
website: 'https://testapp.com',
|
|
icon: null,
|
|
domain: 'testapp.com',
|
|
scopes: [],
|
|
expires_at: dayjs().add(1, 'hour').toISOString(),
|
|
approved_at: null,
|
|
registration_type: 'static',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
const DEFAULT_ORG = createMockOrganizationResponse({ name: 'My Org', slug: 'my-org' })
|
|
const SECOND_ORG = createMockOrganizationResponse({
|
|
id: 2,
|
|
name: 'Second Org',
|
|
slug: 'second-org',
|
|
})
|
|
|
|
// --- MSW helpers ---
|
|
|
|
// Both the auth query and the organizations query fire for any valid auth_id render.
|
|
// Since MSW is configured with onUnhandledRequest: 'error', both must always be mocked.
|
|
|
|
function mockAuthEndpoint(authResponse: ApiAuthorizationResponse) {
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/oauth/authorizations/:id',
|
|
// The frontend `ApiAuthorizationResponse` widens the OpenAPI shape
|
|
// (looser `registration_type`, nullable `icon`/`approved_at`), and the
|
|
// query layer casts via `data as ApiAuthorizationResponse`. Cast here to
|
|
// satisfy the network-boundary contract.
|
|
response: () =>
|
|
HttpResponse.json<GetOAuthAuthorizationResponse>(
|
|
authResponse as unknown as GetOAuthAuthorizationResponse
|
|
),
|
|
})
|
|
}
|
|
|
|
function mockOrgsEndpoint(orgs: OrganizationResponse[] = [DEFAULT_ORG]) {
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/organizations',
|
|
response: () => HttpResponse.json<OrganizationResponse[]>(orgs),
|
|
})
|
|
}
|
|
|
|
function mockBothEndpoints(
|
|
authResponse: ApiAuthorizationResponse = createMockAuthResponse(),
|
|
orgs: OrganizationResponse[] = [DEFAULT_ORG]
|
|
) {
|
|
mockAuthEndpoint(authResponse)
|
|
mockOrgsEndpoint(orgs)
|
|
}
|
|
|
|
// --- Render helper ---
|
|
|
|
function renderScreen(props: Partial<ApiAuthorizationScreenProps> = {}) {
|
|
const navigate = vi.fn()
|
|
const result = customRender(
|
|
<ApiAuthorizationScreen
|
|
auth_id="test-auth-id"
|
|
organization_slug={undefined}
|
|
navigate={navigate}
|
|
{...props}
|
|
/>,
|
|
{ profileContext: DEFAULT_PROFILE_CONTEXT }
|
|
)
|
|
return { ...result, navigate }
|
|
}
|
|
|
|
// --- Tests ---
|
|
|
|
describe('AuthorizeConnectLogo', () => {
|
|
test.each([
|
|
['Cursor', 'https://cursor.com/callback', 'cursor'],
|
|
['Claude', 'https://claude.ai/api/mcp/auth_callback', 'claude'],
|
|
['ChatGPT', 'https://chatgpt.com/callback', 'openai'],
|
|
['OpenAI', 'https://openai.com/callback', 'openai'],
|
|
['Perplexity', 'https://www.perplexity.ai/callback', 'perplexity'],
|
|
])('pairs %s with Supabase when redirect host is allowlisted', (name, redirectUri, iconKey) => {
|
|
customRender(<AuthorizeConnectLogo icon={null} name={name} redirectUri={redirectUri} />)
|
|
|
|
expect(screen.getByAltText(name)).toHaveAttribute(
|
|
'src',
|
|
getMcpClientIconSrc({ icon: iconKey, useDarkVariant: false })
|
|
)
|
|
expect(screen.getByAltText('Supabase')).toBeInTheDocument()
|
|
})
|
|
|
|
test('does not use a curated logo from the requester name alone', () => {
|
|
customRender(
|
|
<AuthorizeConnectLogo icon={null} name="Claude" redirectUri="https://evil.com/callback" />
|
|
)
|
|
|
|
expect(screen.getByAltText('Supabase')).toBeInTheDocument()
|
|
expect(screen.queryByAltText('Claude')).not.toBeInTheDocument()
|
|
})
|
|
|
|
test('shows Supabase alone when the requester has no icon', () => {
|
|
customRender(<AuthorizeConnectLogo icon={null} name="Acme" />)
|
|
|
|
expect(screen.getByAltText('Supabase')).toBeInTheDocument()
|
|
expect(screen.queryByAltText('Acme')).not.toBeInTheDocument()
|
|
expect(screen.queryByText('A')).not.toBeInTheDocument()
|
|
})
|
|
|
|
test('shows Supabase alone when the requester icon fails to load', () => {
|
|
customRender(
|
|
<AuthorizeConnectLogo icon="https://example.com/broken-logo.svg" name="Unknown App" />
|
|
)
|
|
|
|
fireEvent.error(screen.getByAltText('Unknown App'))
|
|
|
|
expect(screen.getByAltText('Supabase')).toBeInTheDocument()
|
|
expect(screen.queryByAltText('Unknown App')).not.toBeInTheDocument()
|
|
expect(screen.queryByText('U')).not.toBeInTheDocument()
|
|
})
|
|
|
|
test('forces light tiles when pairing an uploaded OAuth app icon', () => {
|
|
customRender(
|
|
<AuthorizeConnectLogo
|
|
icon="https://example.com/uploaded-icon.png"
|
|
name="Acme"
|
|
redirectUri="https://acme.example/callback"
|
|
/>
|
|
)
|
|
|
|
expect(screen.getByAltText('Acme').parentElement).toHaveClass('bg-white')
|
|
expect(screen.getByAltText('Acme').parentElement).toHaveClass('border-black/10')
|
|
expect(screen.getByAltText('Supabase').parentElement).toHaveClass('bg-white')
|
|
expect(screen.getByAltText('Supabase').parentElement).toHaveClass('border-black/10')
|
|
})
|
|
|
|
test('keeps theme tiles for curated partners', () => {
|
|
customRender(
|
|
<AuthorizeConnectLogo icon={null} name="Cursor" redirectUri="https://cursor.com/callback" />
|
|
)
|
|
|
|
expect(screen.getByAltText('Cursor').parentElement).toHaveClass('bg-surface-75')
|
|
expect(screen.getByAltText('Supabase').parentElement).toHaveClass('bg-surface-75')
|
|
})
|
|
})
|
|
|
|
describe('ApiAuthorizationScreen', () => {
|
|
describe('when auth_id is missing', () => {
|
|
test('renders invalid interstitial when auth_id is undefined', () => {
|
|
renderScreen({ auth_id: undefined })
|
|
expect(screen.getByText('Missing authorization link')).toBeInTheDocument()
|
|
expect(screen.getByText(/auth_id/)).toBeInTheDocument()
|
|
expect(screen.getByAltText('Supabase')).toBeInTheDocument()
|
|
expect(screen.getByRole('link', { name: 'Back to dashboard' })).toHaveAttribute('href', '/')
|
|
})
|
|
})
|
|
|
|
describe('when auth_id is provided', () => {
|
|
test('renders loading screen while authorization data is being fetched', () => {
|
|
mockOrgsEndpoint()
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/oauth/authorizations/:id',
|
|
response: () => new Promise<never>(() => {}),
|
|
})
|
|
const { container } = renderScreen()
|
|
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
|
expect(container.querySelectorAll('.shimmering-loader').length).toBeGreaterThan(0)
|
|
})
|
|
|
|
test('renders error screen when authorization query fails', async () => {
|
|
mockOrgsEndpoint()
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/oauth/authorizations/:id',
|
|
response: () => HttpResponse.json<APIErrorBody>({ message: 'Not found' }, { status: 404 }),
|
|
})
|
|
renderScreen()
|
|
await screen.findByText('Unable to load authorization')
|
|
})
|
|
|
|
test('renders error screen when authorization query returns no requester', async () => {
|
|
mockOrgsEndpoint()
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/oauth/authorizations/:id',
|
|
response: () =>
|
|
HttpResponse.json<GetOAuthAuthorizationResponse>(
|
|
null as unknown as GetOAuthAuthorizationResponse
|
|
),
|
|
})
|
|
renderScreen()
|
|
await screen.findByText('Unable to load authorization')
|
|
})
|
|
|
|
describe('when already approved', () => {
|
|
test('renders approved screen with matching organization name', async () => {
|
|
mockBothEndpoints(
|
|
createMockAuthResponse({
|
|
approved_at: '2025-01-15T10:00:00Z',
|
|
approved_organization_slug: 'my-org',
|
|
})
|
|
)
|
|
renderScreen()
|
|
await screen.findByText('Authorization approved')
|
|
expect(screen.getByText(/has access to My Org/)).toBeInTheDocument()
|
|
})
|
|
|
|
test('shows Unknown when approved organization is not in the user organizations list', async () => {
|
|
mockBothEndpoints(
|
|
createMockAuthResponse({
|
|
approved_at: '2025-01-15T10:00:00Z',
|
|
approved_organization_slug: 'other-org',
|
|
})
|
|
)
|
|
renderScreen()
|
|
await screen.findByText('Authorization approved')
|
|
expect(screen.getByText(/has access to Unknown/)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('main authorization form', () => {
|
|
describe('organizations states', () => {
|
|
test('does not show action buttons while organizations are being fetched', async () => {
|
|
mockAuthEndpoint(createMockAuthResponse())
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/organizations',
|
|
response: () => new Promise<never>(() => {}),
|
|
})
|
|
renderScreen()
|
|
await screen.findByText('Authorize API access for Test App')
|
|
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument()
|
|
expect(
|
|
screen.queryByRole('button', { name: /Authorize Test App/ })
|
|
).not.toBeInTheDocument()
|
|
})
|
|
|
|
test('shows error notice and hides action buttons when organizations query fails', async () => {
|
|
mockAuthEndpoint(createMockAuthResponse())
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/organizations',
|
|
response: () =>
|
|
HttpResponse.json<APIErrorBody>({ message: 'Server error' }, { status: 500 }),
|
|
})
|
|
renderScreen()
|
|
await screen.findByText('Unable to load organizations')
|
|
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument()
|
|
expect(
|
|
screen.queryByRole('button', { name: /Authorize Test App/ })
|
|
).not.toBeInTheDocument()
|
|
})
|
|
|
|
test('shows empty state when user has no organizations', async () => {
|
|
mockBothEndpoints(createMockAuthResponse(), [])
|
|
renderScreen()
|
|
await screen.findByText('No organizations found')
|
|
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument()
|
|
expect(
|
|
screen.queryByRole('button', { name: /Authorize Test App/ })
|
|
).not.toBeInTheDocument()
|
|
})
|
|
|
|
test('shows not_member notice when organization_slug does not match any user organization', async () => {
|
|
mockBothEndpoints()
|
|
renderScreen({ organization_slug: 'nonexistent-org' })
|
|
await screen.findByText(/Your account is not a member of the pre-selected organization/)
|
|
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument()
|
|
expect(
|
|
screen.queryByRole('button', { name: /Authorize Test App/ })
|
|
).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('success state with organization selector', () => {
|
|
test('renders form with organization selector and action buttons', async () => {
|
|
mockBothEndpoints(createMockAuthResponse({ name: 'My OAuth App' }))
|
|
renderScreen()
|
|
await screen.findByText('Authorize API access for My OAuth App')
|
|
expect(screen.getByAltText('Supabase')).toBeInTheDocument()
|
|
expect(screen.queryByText('M')).not.toBeInTheDocument()
|
|
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: /Authorize My OAuth App/ })).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
|
})
|
|
|
|
test('pairs curated MCP requesters with Supabase when redirect host is allowlisted', async () => {
|
|
mockBothEndpoints(
|
|
createMockAuthResponse({
|
|
name: 'Cursor',
|
|
icon: null,
|
|
redirect_uri: 'https://cursor.com/callback',
|
|
})
|
|
)
|
|
renderScreen()
|
|
await screen.findByText('Authorize API access for Cursor')
|
|
expect(screen.getByAltText('Cursor')).toBeInTheDocument()
|
|
expect(screen.getByAltText('Supabase')).toBeInTheDocument()
|
|
})
|
|
|
|
test('shows Supabase alone when name looks trusted but redirect host is not allowlisted', async () => {
|
|
mockBothEndpoints(
|
|
createMockAuthResponse({
|
|
name: 'Claude',
|
|
icon: null,
|
|
redirect_uri: 'https://evil.com/callback',
|
|
})
|
|
)
|
|
renderScreen()
|
|
await screen.findByText('Authorize API access for Claude')
|
|
expect(screen.queryByAltText('Claude')).not.toBeInTheDocument()
|
|
expect(screen.getByAltText('Supabase')).toBeInTheDocument()
|
|
})
|
|
|
|
test('auto-selects the only organization when no organization_slug is provided', async () => {
|
|
mockBothEndpoints()
|
|
renderScreen()
|
|
const combobox = await screen.findByRole('combobox')
|
|
expect(combobox).toHaveTextContent('My Org')
|
|
})
|
|
|
|
test('pre-selects organization when organization_slug matches a user organization', async () => {
|
|
mockBothEndpoints(createMockAuthResponse(), [DEFAULT_ORG, SECOND_ORG])
|
|
renderScreen({ organization_slug: 'second-org' })
|
|
const combobox = await screen.findByRole('combobox')
|
|
expect(combobox).toHaveTextContent('Second Org')
|
|
expect(combobox).not.toHaveTextContent('My Org')
|
|
expect(screen.getByText('Pre-selected by Test App')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('expiration', () => {
|
|
test('shows expiration warning and hides action buttons when request has expired', async () => {
|
|
mockBothEndpoints(
|
|
createMockAuthResponse({ expires_at: dayjs().subtract(1, 'hour').toISOString() })
|
|
)
|
|
renderScreen()
|
|
await screen.findByText('Authorization request expired')
|
|
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument()
|
|
expect(
|
|
screen.queryByRole('button', { name: /Authorize Test App/ })
|
|
).not.toBeInTheDocument()
|
|
})
|
|
|
|
test('does not show expiration warning when request has not expired', async () => {
|
|
mockBothEndpoints()
|
|
renderScreen()
|
|
await screen.findByText('Authorize API access for Test App')
|
|
expect(screen.queryByText('Authorization request expired')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('approve action', () => {
|
|
test('calls approve endpoint when Authorize button is clicked', async () => {
|
|
const user = userEvent.setup()
|
|
const approveHandler = vi.fn(() =>
|
|
HttpResponse.json<ApproveAuthorizationResponse>({
|
|
url: 'https://redirect.example.com',
|
|
})
|
|
)
|
|
mockBothEndpoints()
|
|
addAPIMock({
|
|
method: 'post',
|
|
path: '/platform/organizations/:slug/oauth/authorizations/:id',
|
|
response: approveHandler,
|
|
})
|
|
renderScreen()
|
|
await screen.findByRole('button', { name: /Authorize Test App/ })
|
|
await user.click(screen.getByRole('button', { name: /Authorize Test App/ }))
|
|
await waitFor(() => expect(approveHandler).toHaveBeenCalled())
|
|
})
|
|
})
|
|
|
|
describe('decline action', () => {
|
|
test('navigates to /organizations after cancelling', async () => {
|
|
const user = userEvent.setup()
|
|
const declineHandler = vi.fn(() =>
|
|
HttpResponse.json<DeclineAuthorizationResponse>({ id: 'test-auth-id' })
|
|
)
|
|
mockBothEndpoints()
|
|
addAPIMock({
|
|
method: 'delete',
|
|
path: '/platform/organizations/:slug/oauth/authorizations/:id',
|
|
response: declineHandler,
|
|
})
|
|
const { navigate } = renderScreen()
|
|
await screen.findByRole('button', { name: 'Cancel' })
|
|
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
|
await waitFor(() => expect(declineHandler).toHaveBeenCalled())
|
|
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/organizations'))
|
|
})
|
|
})
|
|
|
|
describe('form validation', () => {
|
|
test('shows validation error when Authorize is clicked without selecting an organization', async () => {
|
|
const user = userEvent.setup()
|
|
// Two orgs → no auto-selection, user must pick one manually
|
|
mockBothEndpoints(createMockAuthResponse(), [DEFAULT_ORG, SECOND_ORG])
|
|
renderScreen()
|
|
await screen.findByRole('button', { name: /Authorize Test App/ })
|
|
await user.click(screen.getByRole('button', { name: /Authorize Test App/ }))
|
|
expect(
|
|
(await screen.findAllByText('Please select an organization')).length
|
|
).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|