-
Notifications
You must be signed in to change notification settings - Fork 372
fix(web): show account linking before OAuth consent #1663
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
14640a4
7b81dc4
06ffeeb
f84ab54
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,7 +6,7 @@ import { OnboardGuard } from "./components/onboardGuard"; | |
| import { cookies, headers } from "next/headers"; | ||
| import { getSelectorsByUserAgent } from "react-device-detect"; | ||
| import { MobileUnsupportedSplashScreen } from "./components/mobileUnsupportedSplashScreen"; | ||
| import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME } from "@/lib/constants"; | ||
| import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "@/lib/constants"; | ||
| import { SyntaxReferenceGuide } from "./components/syntaxReferenceGuide"; | ||
| import { SyntaxGuideProvider } from "./components/syntaxGuideProvider"; | ||
| import { notFound, redirect } from "next/navigation"; | ||
|
|
@@ -18,16 +18,14 @@ import { env, getOfflineLicenseMetadata, SOURCEBOT_VERSION, isMemberApprovalRequ | |
| import { hasEntitlement, isAnonymousAccessEnabled } from "@/lib/entitlements"; | ||
| import { GcpIapAuth } from "./components/gcpIapAuth"; | ||
| import { JoinOrganizationCard } from "@/features/membership/components/joinOrganizationCard"; | ||
| import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; | ||
| import { GitHubStarToast } from "./components/githubStarToast"; | ||
| import { getLinkedAccounts } from "@/ee/features/sso/actions"; | ||
| import { BannerSlot } from "./components/banners/bannerSlot"; | ||
| import { BannerHeightObserver } from "./components/banners/bannerHeightObserver"; | ||
| import { activeOrPendingMembershipWhere } from "@/features/membership/utils"; | ||
| import { getPermissionSyncStatus } from "../api/(server)/ee/permissionSyncStatus/api"; | ||
| import { OrgRole } from "@sourcebot/db"; | ||
| import { ServiceErrorException } from "@/lib/serviceError"; | ||
| import { ConnectAccountsCard } from "@/ee/features/sso/components/connectAccountsCard"; | ||
| import { AccountLinkingGuard } from "@/ee/features/sso/components/accountLinkingGuard"; | ||
| import { SidebarProvider } from "@/components/ui/sidebar"; | ||
| import { CheckoutReturnHandler } from "@/features/billing/checkoutReturnHandler"; | ||
| import { RoleProvider } from "@/features/auth/roleProvider"; | ||
|
|
@@ -127,30 +125,6 @@ export default async function Layout(props: LayoutProps) { | |
| ) | ||
| } | ||
|
|
||
| if (session && await hasEntitlement("sso")) { | ||
| const linkedAccounts = await getLinkedAccounts(); | ||
| if (isServiceError(linkedAccounts)) { | ||
| throw new ServiceErrorException(linkedAccounts); | ||
| } | ||
|
|
||
| // First, grab a list of all unlinked providers. | ||
| const unlinkedProviders = linkedAccounts.filter(a => !a.isLinked && a.isAccountLinkingProvider); | ||
| if (unlinkedProviders.length > 0) { | ||
| const cookieStore = await cookies(); | ||
| const hasSkippedOptional = cookieStore.has(OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME); | ||
|
|
||
| const hasRequiredUnlinkedProviders = unlinkedProviders.some(a => a.required); | ||
| if (hasRequiredUnlinkedProviders || !hasSkippedOptional) { | ||
| return ( | ||
| <div className="min-h-screen flex items-center justify-center p-6"> | ||
| <LogoutEscapeHatch className="absolute top-0 right-0 p-6" /> | ||
| <ConnectAccountsCard linkedAccounts={linkedAccounts} callbackUrl="/" /> | ||
| </div> | ||
| ) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const headersList = await headers(); | ||
| const cookieStore = await cookies() | ||
| const userAgent = headersList.get('user-agent'); | ||
|
|
@@ -212,47 +186,49 @@ export default async function Layout(props: LayoutProps) { | |
| const languageModels = await getConfiguredLanguageModelsInfo(); | ||
|
|
||
| return ( | ||
| <RoleProvider role={role}> | ||
| <HasLicenseProvider | ||
| hasLicense={offlineLicense !== null || license !== null} | ||
| > | ||
| <LanguageModelProvider languageModels={languageModels}> | ||
| <SyntaxGuideProvider> | ||
| {/* Keep one guard provider above both sidebar and content so browser history is tracked before guarded routes mount. */} | ||
| <NavigationGuardProvider> | ||
| <div className="fixed inset-0 flex bg-shell"> | ||
| <SidebarProvider defaultOpen={cookieStore.get("sidebar_state")?.value !== "false"}> | ||
| {sidebar} | ||
| <div className="flex-1 min-h-0 min-w-0 flex flex-col pt-2 pb-2 pr-2 pl-2 md:pl-0"> | ||
| <div className="flex-1 min-h-0 bg-background flex flex-col border border-[#e6e6e6] dark:border-[#1d1d1f] rounded-xl overflow-hidden"> | ||
| <BannerHeightObserver> | ||
| <BannerSlot | ||
| role={role} | ||
| license={license} | ||
| offlineLicense={offlineLicense} | ||
| hasPermissionSyncEntitlement={hasPermissionSyncEntitlement} | ||
| hasPendingFirstSync={hasPendingFirstSync} | ||
| permissionSyncIssues={permissionSyncIssues} | ||
| connectionSyncCounts={connectionSyncCounts} | ||
| repositorySyncCounts={repositorySyncCounts} | ||
| currentVersion={SOURCEBOT_VERSION} | ||
| latestVersion={latestVersion} | ||
| /> | ||
| </BannerHeightObserver> | ||
| <div className="flex-1 min-h-0 overflow-y-scroll [scrollbar-gutter:stable]"> | ||
| {children} | ||
| <AccountLinkingGuard callbackUrl="/"> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: sed -n '100,245p' 'packages/web/src/app/(app)/layout.tsx'
sed -n '1,100p' packages/web/src/ee/features/sso/components/accountLinkingGuard.tsx
rg -n 'getPermissionSync|syncCount|license|languageModel|version' 'packages/web/src/app/(app)/layout.tsx'Repository: sourcebot-dev/sourcebot Length of output: 9474 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- layout imports and entry path ---'
sed -n '1,125p' 'packages/web/src/app/(app)/layout.tsx'
printf '%s\n' '--- targeted definitions/usages ---'
rg -n -C 8 'export (async )?function (getConfiguredLanguageModelsInfo|tryGetLatestSourcebotTag)|const (getConfiguredLanguageModelsInfo|tryGetLatestSourcebotTag)|function (getConfiguredLanguageModelsInfo|tryGetLatestSourcebotTag)|getConfiguredLanguageModelsInfo|tryGetLatestSourcebotTag|getRepositorySyncCounts|getConnectionSyncCounts|getPermissionSyncStatus' packages/web/src
printf '%s\n' '--- direct license access context ---'
rg -n -C 5 '__unsafePrisma\.license\.findUnique|license\.findUnique' packages/web/srcRepository: sourcebot-dev/sourcebot Length of output: 50380 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- helper files ---'
rg -l --glob '*.ts' --glob '*.tsx' 'export (async )?function getConfiguredLanguageModelsInfo|export const getConfiguredLanguageModelsInfo|export (async )?function tryGetLatestSourcebotTag|export const tryGetLatestSourcebotTag' packages/web/src
printf '%s\n' '--- language model helper ---'
f=$(rg -l --glob '*.ts' --glob '*.tsx' 'export (async )?function getConfiguredLanguageModelsInfo|export const getConfiguredLanguageModelsInfo' packages/web/src | head -n1)
test -n "$f"
printf 'FILE=%s\n' "$f"
rg -n -C 12 'getConfiguredLanguageModelsInfo' "$f"
printf '%s\n' '--- version helper ---'
f=$(rg -l --glob '*.ts' --glob '*.tsx' 'export (async )?function tryGetLatestSourcebotTag|export const tryGetLatestSourcebotTag' packages/web/src | head -n1)
test -n "$f"
printf 'FILE=%s\n' "$f"
rg -n -C 12 'tryGetLatestSourcebotTag' "$f"
printf '%s\n' '--- sync helper declarations ---'
for f in $(rg -l --glob '*.ts' --glob '*.tsx' 'export .*getRepositorySyncCounts|export .*getConnectionSyncCounts' packages/web/src); do
printf 'FILE=%s\n' "$f"
rg -n -C 8 'getRepositorySyncCounts|getConnectionSyncCounts' "$f" | head -n 120
done
printf '%s\n' '--- sew contract ---'
rg -n -C 14 'export .*function sew|export const sew|function sew' packages/web/src/middleware packages/web/src/libRepository: sourcebot-dev/sourcebot Length of output: 7516 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- helper files ---'
rg -l --glob '*.ts' --glob '*.tsx' 'export (async )?function getConfiguredLanguageModelsInfo|export const getConfiguredLanguageModelsInfo|export (async )?function tryGetLatestSourcebotTag|export const tryGetLatestSourcebotTag' packages/web/src
printf '%s\n' '--- language model helper ---'
f=$(rg -l --glob '*.ts' --glob '*.tsx' 'export (async )?function getConfiguredLanguageModelsInfo|export const getConfiguredLanguageModelsInfo' packages/web/src | head -n1)
test -n "$f"
printf 'FILE=%s\n' "$f"
rg -n -C 12 'getConfiguredLanguageModelsInfo' "$f"
printf '%s\n' '--- version helper ---'
f=$(rg -l --glob '*.ts' --glob '*.ts' 'export (async )?function tryGetLatestSourcebotTag|export const tryGetLatestSourcebotTag' packages/web/src | head -n1)
test -n "$f"
printf 'FILE=%s\n' "$f"
rg -n -C 12 'tryGetLatestSourcebotTag' "$f"
printf '%s\n' '--- sync helper declarations ---'
for f in $(rg -l --glob '*.ts' --glob '*.tsx' 'export .*getRepositorySyncCounts|export .*getConnectionSyncCounts' packages/web/src); do
printf 'FILE=%s\n' "$f"
rg -n -C 8 'getRepositorySyncCounts|getConnectionSyncCounts' "$f" | head -n 120
done
printf '%s\n' '--- sew contract ---'
rg -n -C 14 'export .*function sew|export const sew|function sew' packages/web/src/middleware packages/web/src/libRepository: sourcebot-dev/sourcebot Length of output: 7516 Render
Move these loads and the dependent provider tree into an async child passed to 🤖 Prompt for AI Agents |
||
| <RoleProvider role={role}> | ||
| <HasLicenseProvider | ||
| hasLicense={offlineLicense !== null || license !== null} | ||
| > | ||
| <LanguageModelProvider languageModels={languageModels}> | ||
| <SyntaxGuideProvider> | ||
| {/* Keep one guard provider above both sidebar and content so browser history is tracked before guarded routes mount. */} | ||
| <NavigationGuardProvider> | ||
| <div className="fixed inset-0 flex bg-shell"> | ||
| <SidebarProvider defaultOpen={cookieStore.get("sidebar_state")?.value !== "false"}> | ||
| {sidebar} | ||
| <div className="flex-1 min-h-0 min-w-0 flex flex-col pt-2 pb-2 pr-2 pl-2 md:pl-0"> | ||
| <div className="flex-1 min-h-0 bg-background flex flex-col border border-[#e6e6e6] dark:border-[#1d1d1f] rounded-xl overflow-hidden"> | ||
| <BannerHeightObserver> | ||
| <BannerSlot | ||
| role={role} | ||
| license={license} | ||
| offlineLicense={offlineLicense} | ||
| hasPermissionSyncEntitlement={hasPermissionSyncEntitlement} | ||
| hasPendingFirstSync={hasPendingFirstSync} | ||
| permissionSyncIssues={permissionSyncIssues} | ||
| connectionSyncCounts={connectionSyncCounts} | ||
| repositorySyncCounts={repositorySyncCounts} | ||
| currentVersion={SOURCEBOT_VERSION} | ||
| latestVersion={latestVersion} | ||
| /> | ||
| </BannerHeightObserver> | ||
| <div className="flex-1 min-h-0 overflow-y-scroll [scrollbar-gutter:stable]"> | ||
| {children} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </SidebarProvider> | ||
| </div> | ||
| <SyntaxReferenceGuide /> | ||
| <GitHubStarToast /> | ||
| <CheckoutReturnHandler /> | ||
| </NavigationGuardProvider> | ||
| </SyntaxGuideProvider> | ||
| </LanguageModelProvider> | ||
| </HasLicenseProvider> | ||
| </RoleProvider> | ||
| </SidebarProvider> | ||
| </div> | ||
| <SyntaxReferenceGuide /> | ||
| <GitHubStarToast /> | ||
| <CheckoutReturnHandler /> | ||
| </NavigationGuardProvider> | ||
| </SyntaxGuideProvider> | ||
| </LanguageModelProvider> | ||
| </HasLicenseProvider> | ||
| </RoleProvider> | ||
| </AccountLinkingGuard> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| import { cleanup, render, screen } from '@testing-library/react'; | ||
| import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; | ||
| import type { ComponentProps, ReactElement } from 'react'; | ||
| import { OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME, SINGLE_TENANT_ORG_ID } from '@/lib/constants'; | ||
| import { ServiceErrorException } from '@/lib/serviceError'; | ||
|
|
||
| const mocks = vi.hoisted(() => ({ | ||
| auth: vi.fn(), hasEntitlement: vi.fn(), findUnique: vi.fn(), getLinkedAccounts: vi.fn(), | ||
| connectAccountsCard: vi.fn(), consentScreen: vi.fn(), | ||
| cookieGet: vi.fn(), findMembership: vi.fn(), | ||
| })); | ||
| vi.mock('server-only', () => ({})); | ||
| vi.mock('@/auth', () => ({ auth: mocks.auth })); | ||
| vi.mock('@/lib/entitlements', () => ({ hasEntitlement: mocks.hasEntitlement })); | ||
| vi.mock('@/prisma', () => ({ | ||
| __unsafePrisma: { | ||
| oAuthClient: { findUnique: mocks.findUnique }, | ||
| userToOrg: { findUnique: mocks.findMembership }, | ||
| }, | ||
| })); | ||
| vi.mock('@/ee/features/sso/actions', () => ({ getLinkedAccounts: mocks.getLinkedAccounts })); | ||
| vi.mock('@/ee/features/oauth/dpop', () => ({ isValidDpopJkt: () => true })); | ||
| vi.mock('@/lib/utils', () => ({ | ||
| isServiceError: (value: unknown) => !!value && typeof value === 'object' && 'errorCode' in value, | ||
| })); | ||
| vi.mock('next/navigation', () => ({ redirect: (url: string) => { throw new Error(url); } })); | ||
| vi.mock('next/headers', () => ({ cookies: async () => ({ get: mocks.cookieGet }) })); | ||
| vi.mock('@/app/components/logoutEscapeHatch', () => ({ LogoutEscapeHatch: () => null })); | ||
| vi.mock('@/ee/features/sso/components/connectAccountsCard', () => ({ | ||
| ConnectAccountsCard: (props: unknown) => { mocks.connectAccountsCard(props); return <div>Link accounts</div>; }, | ||
| })); | ||
| vi.mock('./components/consentScreen', () => ({ | ||
| ConsentScreen: (props: unknown) => { mocks.consentScreen(props); return <div>OAuth consent</div>; }, | ||
| })); | ||
|
|
||
| const { default: AuthorizePage } = await import('./page'); | ||
| const { AccountLinkingGuard } = await import('@/ee/features/sso/components/accountLinkingGuard'); | ||
| type Params = Awaited<ComponentProps<typeof AuthorizePage>['searchParams']>; | ||
|
|
||
| // Resolve the async server guard before rendering its client-visible output. | ||
| const renderPage = async (requestParams: Params) => { | ||
| const page = await AuthorizePage({ searchParams: Promise.resolve(requestParams) }); | ||
| if (page.type === AccountLinkingGuard) { | ||
| const guard = page as ReactElement<ComponentProps<typeof AccountLinkingGuard>>; | ||
| return render(await AccountLinkingGuard(guard.props)); | ||
| } | ||
| return render(page); | ||
| }; | ||
|
|
||
| const params: Params = { | ||
| client_id: 'client-1', redirect_uri: 'https://client.example/callback?foo=bar', | ||
| code_challenge: 'original-challenge', code_challenge_method: 'S256', response_type: 'code', | ||
| state: 'state with + & = ?', scope: '', | ||
| resource: ['https://sourcebot.example/api/mcp', 'https://second.example/mcp'], | ||
| dpop_jkt: 'original-thumbprint', | ||
| }; | ||
| const requiredAccount = { | ||
| providerId: 'github', providerType: 'github', isLinked: false, | ||
| isAccountLinkingProvider: true, required: true, supportsPermissionSync: true, | ||
| }; | ||
|
|
||
| beforeEach(() => { | ||
| vi.resetAllMocks(); | ||
| mocks.auth.mockResolvedValue({ user: { id: 'user-1', email: 'user@example.com' } }); | ||
| mocks.hasEntitlement.mockResolvedValue(true); | ||
| mocks.findUnique.mockResolvedValue({ name: 'Client', logoUri: null, redirectUris: [params.redirect_uri] }); | ||
| mocks.findMembership.mockResolvedValue({ userId: 'user-1' }); | ||
| mocks.getLinkedAccounts.mockResolvedValue([requiredAccount]); | ||
| mocks.cookieGet.mockReturnValue(undefined); | ||
| }); | ||
| afterEach(cleanup); | ||
|
|
||
| describe('OAuth linking screen', () => { | ||
| test('shows linking before consent and resumes the original request after linking', async () => { | ||
| await renderPage(params); | ||
| expect(screen.queryByText('Link accounts')).not.toBeNull(); | ||
| expect(mocks.consentScreen).not.toHaveBeenCalled(); | ||
| const { callbackUrl } = mocks.connectAccountsCard.mock.calls[0][0]; | ||
| const url = new URL(callbackUrl, 'https://sourcebot.example'); | ||
| expect(url.pathname).toBe('/oauth/authorize'); | ||
| for (const [key, value] of Object.entries(params)) { | ||
| expect(url.searchParams.getAll(key)).toEqual(Array.isArray(value) ? value : [value]); | ||
| } | ||
|
|
||
| const resumedParams: Record<string, string | string[]> = {}; | ||
| for (const key of url.searchParams.keys()) { | ||
| const values = url.searchParams.getAll(key); | ||
| resumedParams[key] = values.length === 1 ? values[0] : values; | ||
| } | ||
| cleanup(); | ||
| mocks.getLinkedAccounts.mockResolvedValue([{ ...requiredAccount, isLinked: true }]); | ||
| await renderPage(resumedParams); | ||
| expect(screen.queryByText('OAuth consent')).not.toBeNull(); | ||
| expect(mocks.consentScreen).toHaveBeenCalledWith(expect.objectContaining({ | ||
| codeChallenge: params.code_challenge, state: params.state, requestedScope: params.scope, | ||
| redirectUri: params.redirect_uri, resource: params.resource![0], dpopJkt: params.dpop_jkt, | ||
| })); | ||
| }); | ||
|
|
||
| test('preserves repeated parameters through the login callback too', async () => { | ||
| mocks.auth.mockResolvedValue(null); | ||
| let redirectUrl = ''; | ||
| try { | ||
| await AuthorizePage({ searchParams: Promise.resolve({ ...params, scope: undefined }) }); | ||
| } catch (error) { | ||
| redirectUrl = (error as Error).message; | ||
| } | ||
| const login = new URL(redirectUrl, 'https://sourcebot.example'); | ||
| expect(login.pathname).toBe('/login'); | ||
| const callback = new URL(login.searchParams.get('callbackUrl')!, 'https://sourcebot.example'); | ||
| expect(callback.searchParams.getAll('resource')).toEqual(params.resource); | ||
| expect(callback.searchParams.get('state')).toBe(params.state); | ||
| expect(callback.searchParams.has('scope')).toBe(false); | ||
| expect(mocks.getLinkedAccounts).not.toHaveBeenCalled(); | ||
| expect(mocks.findMembership).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test('redirects home before account linking when the user has no unsuspended membership', async () => { | ||
| mocks.findMembership.mockResolvedValue(null); | ||
| await expect(renderPage(params)).rejects.toEqual(new Error('/')); | ||
| expect(mocks.findMembership).toHaveBeenCalledWith({ | ||
| where: { | ||
| orgId_userId: { orgId: SINGLE_TENANT_ORG_ID, userId: 'user-1' }, | ||
| suspendedAt: null, | ||
| }, | ||
| select: { userId: true }, | ||
| }); | ||
| expect(mocks.getLinkedAccounts).not.toHaveBeenCalled(); | ||
| expect(mocks.consentScreen).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test('shows optional providers and resumes consent after they are skipped', async () => { | ||
| mocks.getLinkedAccounts.mockResolvedValue([{ ...requiredAccount, required: false }]); | ||
| await renderPage(params); | ||
| expect(screen.queryByText('Link accounts')).not.toBeNull(); | ||
| expect(mocks.consentScreen).not.toHaveBeenCalled(); | ||
| expect(mocks.cookieGet).toHaveBeenCalledWith(OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME); | ||
|
|
||
| cleanup(); | ||
| mocks.cookieGet.mockReturnValue({ value: JSON.stringify(['github']) }); | ||
| await renderPage(params); | ||
| expect(screen.queryByText('OAuth consent')).not.toBeNull(); | ||
| expect(mocks.consentScreen).toHaveBeenCalledWith(expect.objectContaining({ | ||
| state: params.state, codeChallenge: params.code_challenge, redirectUri: params.redirect_uri, | ||
| })); | ||
| }); | ||
|
|
||
| test('the optional skip cookie does not bypass required account linking', async () => { | ||
| mocks.cookieGet.mockReturnValue({ value: JSON.stringify(['github']) }); | ||
| await renderPage(params); | ||
| expect(screen.queryByText('Link accounts')).not.toBeNull(); | ||
| expect(mocks.consentScreen).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test.each([ | ||
| ['linked providers', [{ ...requiredAccount, isLinked: true }]], | ||
| ['SSO providers', [{ ...requiredAccount, isAccountLinkingProvider: false }]], | ||
| ['no providers', []], | ||
| ])('goes directly to consent with %s', async (_name, accounts) => { | ||
| mocks.getLinkedAccounts.mockResolvedValue(accounts); | ||
| await renderPage(params); | ||
| expect(screen.queryByText('OAuth consent')).not.toBeNull(); | ||
| expect(mocks.connectAccountsCard).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test('skips account linking without the SSO entitlement', async () => { | ||
| mocks.hasEntitlement.mockImplementation(async (name) => name !== 'sso'); | ||
| await renderPage(params); | ||
| expect(screen.queryByText('OAuth consent')).not.toBeNull(); | ||
| expect(mocks.getLinkedAccounts).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test('throws to the error boundary instead of showing consent if account lookup fails', async () => { | ||
| const error = { statusCode: 500, errorCode: 'UNEXPECTED_ERROR', message: 'Lookup failed' }; | ||
| mocks.getLinkedAccounts.mockResolvedValue(error); | ||
| await expect(renderPage(params)).rejects.toEqual(new ServiceErrorException(error)); | ||
| expect(mocks.consentScreen).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test('validates the OAuth client before starting account linking', async () => { | ||
| mocks.findUnique.mockResolvedValue(null); | ||
| await renderPage(params); | ||
| expect(screen.queryByText('Authorization Error')).not.toBeNull(); | ||
| expect(mocks.getLinkedAccounts).not.toHaveBeenCalled(); | ||
| expect(mocks.findMembership).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.