diff --git a/apps/iframe-app/src/components/IframeWrapper.tsx b/apps/iframe-app/src/components/IframeWrapper.tsx index 599eb724cb0..23bc39d713f 100644 --- a/apps/iframe-app/src/components/IframeWrapper.tsx +++ b/apps/iframe-app/src/components/IframeWrapper.tsx @@ -95,10 +95,31 @@ export function IframeWrapper({ config }: IframeWrapperProps) { } try { - const { isAuthenticated, username } = await checkAuthStatus(baseUrl); - setNeedsLogin(!isAuthenticated); - if (isAuthenticated && username) { - setUserName(username); + const { isAuthenticated, isEasyAuthConfigured, username } = await checkAuthStatus(baseUrl); + + // Easy Auth is NOT configured (404 from /.auth/me) + // Let it fail naturally - the API calls will fail without apiKey + if (!isEasyAuthConfigured) { + setNeedsLogin(false); + return; + } + + // Easy Auth IS configured + // Check if identity providers are configured + const hasIdentityProviders = props.identityProviders && Object.keys(props.identityProviders).length > 0; + + if (!hasIdentityProviders) { + // No identity providers configured - skip login, show chat directly + setNeedsLogin(false); + } else if (isAuthenticated) { + // User is already authenticated + setNeedsLogin(false); + if (username) { + setUserName(username); + } + } else { + // Identity providers configured but user not authenticated - show login + setNeedsLogin(true); } } catch (error) { console.error('[Auth] Failed to check authentication status:', error); @@ -109,7 +130,7 @@ export function IframeWrapper({ config }: IframeWrapperProps) { }; checkAuth(); - }, [baseUrl, inPortal, apiKey]); + }, [baseUrl, inPortal, apiKey, props.identityProviders]); // Frame Blade integration const { isReady: isFrameBladeReady } = useFrameBlade({ diff --git a/apps/iframe-app/src/components/__tests__/IframeWrapper.contextId.test.tsx b/apps/iframe-app/src/components/__tests__/IframeWrapper.contextId.test.tsx index 4edaa677a51..30ba39ada5e 100644 --- a/apps/iframe-app/src/components/__tests__/IframeWrapper.contextId.test.tsx +++ b/apps/iframe-app/src/components/__tests__/IframeWrapper.contextId.test.tsx @@ -20,7 +20,7 @@ vi.mock('../MultiSessionChat/MultiSessionChat', () => ({ vi.mock('../../lib/authHandler', () => ({ createUnauthorizedHandler: vi.fn(() => vi.fn()), getBaseUrl: vi.fn((agentCard) => `https://base.url.from/${agentCard}`), - checkAuthStatus: vi.fn(() => Promise.resolve({ isAuthenticated: true, error: null })), + checkAuthStatus: vi.fn(() => Promise.resolve({ isAuthenticated: true, isEasyAuthConfigured: true, error: null })), openLoginPopup: vi.fn(), })); @@ -67,7 +67,7 @@ describe('IframeWrapper - contextId support', () => { vi.clearAllMocks(); // Reset checkAuthStatus to return authenticated - vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: true, error: null }); + vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: true, isEasyAuthConfigured: true, error: null }); }); afterEach(() => { diff --git a/apps/iframe-app/src/components/__tests__/IframeWrapper.test.tsx b/apps/iframe-app/src/components/__tests__/IframeWrapper.test.tsx index dd263b7abd5..937ecd46092 100644 --- a/apps/iframe-app/src/components/__tests__/IframeWrapper.test.tsx +++ b/apps/iframe-app/src/components/__tests__/IframeWrapper.test.tsx @@ -20,7 +20,7 @@ vi.mock('../MultiSessionChat/MultiSessionChat', () => ({ vi.mock('../../lib/authHandler', () => ({ createUnauthorizedHandler: vi.fn(() => vi.fn()), getBaseUrl: vi.fn((agentCard) => `https://base.url.from/${agentCard}`), - checkAuthStatus: vi.fn(() => Promise.resolve({ isAuthenticated: true, error: null })), + checkAuthStatus: vi.fn(() => Promise.resolve({ isAuthenticated: true, isEasyAuthConfigured: true, error: null })), openLoginPopup: vi.fn(), })); @@ -66,8 +66,8 @@ describe('IframeWrapper', () => { // Reset mocks - this clears call history vi.clearAllMocks(); - // Reset checkAuthStatus to default (authenticated) - vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: true, error: null }); + // Reset checkAuthStatus to default (authenticated with Easy Auth) + vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: true, isEasyAuthConfigured: true, error: null }); // Clear localStorage localStorage.clear(); @@ -343,8 +343,8 @@ describe('IframeWrapper', () => { describe('Authentication', () => { it('should show loading state during authentication check', async () => { // Create a promise that we can control - let resolveAuth: (value: { isAuthenticated: boolean; error: null }) => void; - const authPromise = new Promise<{ isAuthenticated: boolean; error: null }>((resolve) => { + let resolveAuth: (value: { isAuthenticated: boolean; isEasyAuthConfigured: boolean; error: null }) => void; + const authPromise = new Promise<{ isAuthenticated: boolean; isEasyAuthConfigured: boolean; error: null }>((resolve) => { resolveAuth = resolve; }); @@ -358,15 +358,15 @@ describe('IframeWrapper', () => { // Resolve auth check await act(async () => { - resolveAuth!({ isAuthenticated: true, error: null }); + resolveAuth!({ isAuthenticated: true, isEasyAuthConfigured: true, error: null }); }); // Should now show the chat widget await screen.findByTestId('chat-widget'); }); - it('should show LoginPrompt when checkAuthStatus returns false', async () => { - vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: false, error: null }); + it('should show LoginPrompt when checkAuthStatus returns false and Easy Auth is configured', async () => { + vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: false, isEasyAuthConfigured: true, error: null }); const configWithProviders: IframeConfig = { ...defaultConfig, @@ -389,7 +389,7 @@ describe('IframeWrapper', () => { expect(screen.getByRole('button', { name: 'Microsoft account' })).toBeInTheDocument(); }); - it('should show LoginPrompt when checkAuthStatus throws an error', async () => { + it('should show login when checkAuthStatus throws an error and identity providers are configured', async () => { vi.mocked(authHandler.checkAuthStatus).mockRejectedValue(new Error('Network error')); const configWithProviders: IframeConfig = { @@ -407,10 +407,50 @@ describe('IframeWrapper', () => { render(); - // Should show login prompt after error + // Should show login UI on error when identity providers are configured await screen.findByText('Sign in required'); }); + it('should skip login when Easy Auth is not configured (404)', async () => { + vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: false, isEasyAuthConfigured: false, error: null }); + + const configWithProviders: IframeConfig = { + ...defaultConfig, + props: { + ...defaultConfig.props, + identityProviders: { + aad: { + signInEndpoint: '/.auth/login/aad', + name: 'Microsoft', + }, + }, + }, + }; + + render(); + + // Should go directly to chat widget (let it fail naturally without apiKey) + await screen.findByTestId('chat-widget'); + }); + + it('should skip login when Easy Auth is configured but no identity providers', async () => { + vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: false, isEasyAuthConfigured: true, error: null }); + + // No identity providers configured + const configWithoutProviders: IframeConfig = { + ...defaultConfig, + props: { + ...defaultConfig.props, + identityProviders: undefined, + }, + }; + + render(); + + // Should go directly to chat widget (no sign-in required when no providers) + await screen.findByTestId('chat-widget'); + }); + it('should skip auth check when in portal mode', async () => { vi.mocked(authHandler.checkAuthStatus).mockClear(); @@ -443,7 +483,7 @@ describe('IframeWrapper', () => { }); it('should call openLoginPopup when login button is clicked', async () => { - vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: false, error: null }); + vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: false, isEasyAuthConfigured: true, error: null }); const configWithProviders: IframeConfig = { ...defaultConfig, @@ -478,7 +518,7 @@ describe('IframeWrapper', () => { }); it('should show chat widget after successful login', async () => { - vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: false, error: null }); + vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: false, isEasyAuthConfigured: true, error: null }); // Capture the onSuccess callback let onSuccessCallback: ((authInfo: authHandler.AuthInformation) => void) | undefined; @@ -513,7 +553,7 @@ describe('IframeWrapper', () => { // Simulate successful login callback with auth info await act(async () => { if (onSuccessCallback) { - onSuccessCallback({ isAuthenticated: true, error: null, username: 'Test User' }); + onSuccessCallback({ isAuthenticated: true, isEasyAuthConfigured: true, error: null, username: 'Test User' }); } }); @@ -522,7 +562,7 @@ describe('IframeWrapper', () => { }); it('should show error message when login fails', async () => { - vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: false, error: null }); + vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: false, isEasyAuthConfigured: true, error: null }); // Capture the onFailed callback let onFailedCallback: ((error: Error) => void) | undefined; diff --git a/apps/iframe-app/src/lib/__tests__/authHandler.test.ts b/apps/iframe-app/src/lib/__tests__/authHandler.test.ts index 051b86e27ab..c99db3c2317 100644 --- a/apps/iframe-app/src/lib/__tests__/authHandler.test.ts +++ b/apps/iframe-app/src/lib/__tests__/authHandler.test.ts @@ -146,10 +146,11 @@ describe('authHandler', () => { const result = await checkAuthStatus('https://example.com'); - expect(result).toEqual({ isAuthenticated: true, error: null, username: 'John Doe' }); + expect(result).toEqual({ isAuthenticated: true, isEasyAuthConfigured: true, error: null, username: 'John Doe' }); expect(mockFetch).toHaveBeenCalledWith('https://example.com/.auth/me', { method: 'GET', credentials: 'include', + redirect: 'manual', }); }); @@ -162,7 +163,7 @@ describe('authHandler', () => { const result = await checkAuthStatus('https://example.com'); - expect(result).toEqual({ isAuthenticated: true, error: null, username: undefined }); + expect(result).toEqual({ isAuthenticated: true, isEasyAuthConfigured: true, error: null, username: undefined }); }); it('should return false when user is not authenticated (empty array)', async () => { @@ -173,7 +174,7 @@ describe('authHandler', () => { const result = await checkAuthStatus('https://example.com'); - expect(result).toEqual({ isAuthenticated: false, error: null, username: undefined }); + expect(result).toEqual({ isAuthenticated: false, isEasyAuthConfigured: true, error: null, username: undefined }); }); it('should handle invalid JWT token gracefully', async () => { @@ -184,7 +185,7 @@ describe('authHandler', () => { const result = await checkAuthStatus('https://example.com'); - expect(result).toEqual({ isAuthenticated: true, error: null, username: undefined }); + expect(result).toEqual({ isAuthenticated: true, isEasyAuthConfigured: true, error: null, username: undefined }); }); it('should handle missing access_token gracefully', async () => { @@ -195,20 +196,43 @@ describe('authHandler', () => { const result = await checkAuthStatus('https://example.com'); - expect(result).toEqual({ isAuthenticated: true, error: null, username: undefined }); + expect(result).toEqual({ isAuthenticated: true, isEasyAuthConfigured: true, error: null, username: undefined }); }); - it('should return false when response is not ok', async () => { + it('should return isEasyAuthConfigured true and not authenticated when 401', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 401, + type: 'basic', }); const result = await checkAuthStatus('https://example.com'); - expect(result.isAuthenticated).toBe(false); - expect(result.error).toBeInstanceOf(Error); - expect(result.error?.message).toBe('Failed to fetch authentication status'); + expect(result).toEqual({ isAuthenticated: false, isEasyAuthConfigured: true, error: null }); + }); + + it('should return isEasyAuthConfigured true and not authenticated when 403', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + type: 'basic', + }); + + const result = await checkAuthStatus('https://example.com'); + + expect(result).toEqual({ isAuthenticated: false, isEasyAuthConfigured: true, error: null }); + }); + + it('should return isEasyAuthConfigured true and not authenticated on opaqueredirect (302)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 0, + type: 'opaqueredirect', + }); + + const result = await checkAuthStatus('https://example.com'); + + expect(result).toEqual({ isAuthenticated: false, isEasyAuthConfigured: true, error: null }); }); it('should return false on network error', async () => { @@ -217,7 +241,49 @@ describe('authHandler', () => { const result = await checkAuthStatus('https://example.com'); - expect(result).toEqual({ isAuthenticated: false, error: networkError }); + expect(result).toEqual({ isAuthenticated: false, isEasyAuthConfigured: false, error: networkError }); + }); + + it('should return isEasyAuthConfigured false when /.auth/me returns 404', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + type: 'basic', + }); + + const result = await checkAuthStatus('https://example.com'); + + expect(result).toEqual({ isAuthenticated: false, isEasyAuthConfigured: false, error: null }); + }); + + it('should return isEasyAuthConfigured true with error when 500 Internal Server Error', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + type: 'basic', + }); + + const result = await checkAuthStatus('https://example.com'); + + expect(result.isAuthenticated).toBe(false); + expect(result.isEasyAuthConfigured).toBe(true); + expect(result.error).toBeInstanceOf(Error); + expect(result.error?.message).toBe('Failed to fetch authentication status'); + }); + + it('should return isEasyAuthConfigured true with error when 503 Service Unavailable', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 503, + type: 'basic', + }); + + const result = await checkAuthStatus('https://example.com'); + + expect(result.isAuthenticated).toBe(false); + expect(result.isEasyAuthConfigured).toBe(true); + expect(result.error).toBeInstanceOf(Error); + expect(result.error?.message).toBe('Failed to fetch authentication status'); }); }); diff --git a/apps/iframe-app/src/lib/authHandler.ts b/apps/iframe-app/src/lib/authHandler.ts index 613ea82d0e1..7fe0765edf3 100644 --- a/apps/iframe-app/src/lib/authHandler.ts +++ b/apps/iframe-app/src/lib/authHandler.ts @@ -18,6 +18,14 @@ interface JwtPayload { export interface AuthInformation { isAuthenticated: boolean; + /** + * Indicates whether Azure App Service EasyAuth is configured on the Logic App. + * - `true`: EasyAuth is configured (/.auth/me returns 401, 403, 302, or 200) + * - `false`: EasyAuth is NOT configured (/.auth/me returns 404 or network error) + * + * When false, the application should skip authentication flows entirely. + */ + isEasyAuthConfigured: boolean; error: Error | null; username?: string; } @@ -139,16 +147,43 @@ export function getBaseUrl(agentCardUrl?: string): string { /** * Checks the current authentication status via EasyAuth /.auth/me endpoint * Also extracts username from the JWT token if available + * + * Easy Auth can be configured to return different status codes for unauthenticated requests: + * - 401 Unauthorized + * - 403 Forbidden + * - 404 Not Found (this means Easy Auth is NOT configured) + * - 302 Redirect + * + * Any of 401, 403, or 302 indicates Easy Auth IS configured but user is not authenticated. */ export async function checkAuthStatus(baseUrl: string): Promise { try { const response = await fetch(`${baseUrl}/.auth/me`, { method: 'GET', credentials: 'include', // Important: include cookies for cross-origin + redirect: 'manual', // Don't follow redirects - we want to detect 302 }); + const status = response.status; + + // 404 means Easy Auth is not configured on this Logic App + if (status === 404) { + return { isAuthenticated: false, isEasyAuthConfigured: false, error: null }; + } + + // 401, 403 mean Easy Auth is configured but user is not authenticated + if (status === 401 || status === 403) { + return { isAuthenticated: false, isEasyAuthConfigured: true, error: null }; + } + + // 0 status with opaqueredirect type means a 302 redirect was attempted + // This happens when redirect: 'manual' is set and server tries to redirect + if (response.type === 'opaqueredirect' || status === 0) { + return { isAuthenticated: false, isEasyAuthConfigured: true, error: null }; + } + if (!response.ok) { - return { isAuthenticated: false, error: new Error('Failed to fetch authentication status') }; + return { isAuthenticated: false, isEasyAuthConfigured: true, error: new Error('Failed to fetch authentication status') }; } const data = await response.json(); @@ -156,9 +191,10 @@ export async function checkAuthStatus(baseUrl: string): Promise const isAuthenticated = Array.isArray(data) && data.length > 0; const username = extractUsernameFromToken(data[0]?.access_token); - return { isAuthenticated, error: null, username }; + return { isAuthenticated, isEasyAuthConfigured: true, error: null, username }; } catch (error) { - return { isAuthenticated: false, error: error as Error }; + // Network errors or other failures - assume Easy Auth is not configured + return { isAuthenticated: false, isEasyAuthConfigured: false, error: error as Error }; } } diff --git a/apps/iframe-app/src/lib/utils/__tests__/config-parser.test.ts b/apps/iframe-app/src/lib/utils/__tests__/config-parser.test.ts index d376cf80ef7..9096042a54b 100644 --- a/apps/iframe-app/src/lib/utils/__tests__/config-parser.test.ts +++ b/apps/iframe-app/src/lib/utils/__tests__/config-parser.test.ts @@ -220,16 +220,13 @@ describe('parseIframeConfig', () => { expect(config.contextId).toBe('ctx-123'); }); - it('includes default identity providers when window.IDENTITY_PROVIDERS is not set', () => { + it('returns undefined for identity providers when window.IDENTITY_PROVIDERS is not set', () => { document.documentElement.dataset.agentCard = 'http://test.agent/agent-card.json'; const config = parseIframeConfig(); - expect(config.props.identityProviders).toBeDefined(); - expect(config.props.identityProviders?.microsoft).toEqual({ - signInEndpoint: '/.auth/login/aad', - name: 'Microsoft', - }); + // No fallback to default providers - undefined means no sign-in required + expect(config.props.identityProviders).toBeUndefined(); }); it('uses window.IDENTITY_PROVIDERS when set', () => { @@ -249,79 +246,67 @@ describe('parseIframeConfig', () => { delete (window as any).IDENTITY_PROVIDERS; }); - it('falls back to default identity providers when IDENTITY_PROVIDERS is invalid JSON', () => { + it('returns undefined for identity providers when IDENTITY_PROVIDERS is invalid JSON', () => { document.documentElement.dataset.agentCard = 'http://test.agent/agent-card.json'; (window as any).IDENTITY_PROVIDERS = 'not valid json'; const config = parseIframeConfig(); expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to parse IDENTITY_PROVIDERS:', expect.any(Error)); - expect(config.props.identityProviders).toBeDefined(); - expect(config.props.identityProviders?.microsoft).toEqual({ - signInEndpoint: '/.auth/login/aad', - name: 'Microsoft', - }); + // No fallback to default providers - undefined means no sign-in required + expect(config.props.identityProviders).toBeUndefined(); // Clean up delete (window as any).IDENTITY_PROVIDERS; }); - it('falls back to default identity providers when IDENTITY_PROVIDERS is empty string', () => { + it('returns undefined for identity providers when IDENTITY_PROVIDERS is empty string', () => { document.documentElement.dataset.agentCard = 'http://test.agent/agent-card.json'; (window as any).IDENTITY_PROVIDERS = ''; const config = parseIframeConfig(); - expect(config.props.identityProviders).toBeDefined(); - expect(config.props.identityProviders?.microsoft).toEqual({ - signInEndpoint: '/.auth/login/aad', - name: 'Microsoft', - }); + // No fallback to default providers - undefined means no sign-in required + expect(config.props.identityProviders).toBeUndefined(); // Clean up delete (window as any).IDENTITY_PROVIDERS; }); - it('falls back to default identity providers when IDENTITY_PROVIDERS parses to null', () => { + it('returns undefined for identity providers when IDENTITY_PROVIDERS parses to null', () => { document.documentElement.dataset.agentCard = 'http://test.agent/agent-card.json'; (window as any).IDENTITY_PROVIDERS = 'null'; const config = parseIframeConfig(); - expect(config.props.identityProviders).toBeDefined(); - expect(config.props.identityProviders?.microsoft).toEqual({ - signInEndpoint: '/.auth/login/aad', - name: 'Microsoft', - }); + // No fallback to default providers - undefined means no sign-in required + expect(config.props.identityProviders).toBeUndefined(); // Clean up delete (window as any).IDENTITY_PROVIDERS; }); - it('falls back to default identity providers when IDENTITY_PROVIDERS parses to array', () => { + it('returns undefined for identity providers when IDENTITY_PROVIDERS parses to array', () => { document.documentElement.dataset.agentCard = 'http://test.agent/agent-card.json'; (window as any).IDENTITY_PROVIDERS = '["microsoft", "google"]'; const config = parseIframeConfig(); - // Arrays are objects in JS, so this will actually be used - this tests current behavior - expect(config.props.identityProviders).toEqual(['microsoft', 'google']); + // Arrays are objects but not the expected Record format + expect(config.props.identityProviders).toBeUndefined(); // Clean up delete (window as any).IDENTITY_PROVIDERS; }); - it('falls back to default identity providers when IDENTITY_PROVIDERS parses to primitive', () => { + it('returns undefined for identity providers when IDENTITY_PROVIDERS parses to primitive', () => { document.documentElement.dataset.agentCard = 'http://test.agent/agent-card.json'; (window as any).IDENTITY_PROVIDERS = '"just a string"'; const config = parseIframeConfig(); - expect(config.props.identityProviders).toBeDefined(); - expect(config.props.identityProviders?.microsoft).toEqual({ - signInEndpoint: '/.auth/login/aad', - name: 'Microsoft', - }); + // No fallback to default providers - undefined means no sign-in required + expect(config.props.identityProviders).toBeUndefined(); // Clean up delete (window as any).IDENTITY_PROVIDERS; @@ -476,13 +461,13 @@ describe('parseIdentityProviders', () => { expect(result).toBeUndefined(); }); - it('returns array when JSON parses to array (arrays are objects)', () => { + it('returns undefined when JSON parses to array (arrays are not valid Record format)', () => { (window as any).IDENTITY_PROVIDERS = '["microsoft", "google"]'; const result = parseIdentityProviders(); - // Note: arrays pass typeof === 'object' check, so they are returned - expect(result).toEqual(['microsoft', 'google']); + // Arrays are objects but not valid Record format + expect(result).toBeUndefined(); }); it('returns empty object for empty JSON object', () => { diff --git a/apps/iframe-app/src/lib/utils/config-parser.ts b/apps/iframe-app/src/lib/utils/config-parser.ts index cbc25a11d8c..11123120f45 100644 --- a/apps/iframe-app/src/lib/utils/config-parser.ts +++ b/apps/iframe-app/src/lib/utils/config-parser.ts @@ -16,26 +16,6 @@ interface PortalValidationResult { trustedParentOrigin?: string; } -// This is a temporary workaround for identity providers until we have a proper way to configure them through server side -const DEFAULT_IDENTITY_PROVIDERS: Record = { - microsoft: { - signInEndpoint: '/.auth/login/aad', - name: 'Microsoft', - }, - google: { - signInEndpoint: '/.auth/login/google', - name: 'Google', - }, - facebook: { - signInEndpoint: '/.auth/login/facebook', - name: 'Facebook', - }, - github: { - signInEndpoint: '/.auth/login/github', - name: 'GitHub', - }, -}; - const ALLOWED_PORTAL_AUTHORITIES = [ 'df.onecloud.azure-test.net', 'portal.azure.com', @@ -228,7 +208,7 @@ export function parseIframeConfig(): IframeConfig { welcomeMessage: brandSubtitle || dataset.welcomeMessage || params.get('welcomeMessage') || undefined, metadata: parseMetadata(params, dataset), apiKey: apiKey || undefined, - identityProviders: parseIdentityProviders() || DEFAULT_IDENTITY_PROVIDERS, + identityProviders: parseIdentityProviders(), oboUserToken: oboUserToken || undefined, ...fileUploadConfig, }; @@ -276,7 +256,8 @@ export function parseIdentityProviders(): Record | und try { const parsed = JSON.parse(identityProviders); - if (typeof parsed === 'object' && parsed !== null) { + // Arrays are objects but not valid Record format + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { return parsed as Record; } } catch (e) { diff --git a/e2e/chatClient/fixtures/sse-fixtures.ts b/e2e/chatClient/fixtures/sse-fixtures.ts index 6eddc3529e2..dbea83331ad 100644 --- a/e2e/chatClient/fixtures/sse-fixtures.ts +++ b/e2e/chatClient/fixtures/sse-fixtures.ts @@ -38,7 +38,34 @@ const DEFAULT_MOCK_JWT = createMockJwt({ exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now }); +// Default identity providers for testing +const DEFAULT_IDENTITY_PROVIDERS = { + aad: { + name: 'Microsoft', + loginPath: '/.auth/login/aad', + }, +}; + async function setupSSEMocking(page: Page) { + // Intercept and modify the HTML to inject identity providers + // This replaces the placeholder BEFORE the inline script runs + await page.route('**/*', async (route) => { + const request = route.request(); + if (request.resourceType() === 'document') { + const response = await route.fetch(); + let html = await response.text(); + // Replace the placeholder with actual identity providers JSON + const providersJson = JSON.stringify(DEFAULT_IDENTITY_PROVIDERS).replace(/"/g, '\\"'); + html = html.replace( + 'window.IDENTITY_PROVIDERS = "REPLACE_ME_WITH_IDENTITY_PROVIDERS"', + `window.IDENTITY_PROVIDERS = "${providersJson}"` + ); + await route.fulfill({ response, body: html }); + } else { + await route.continue(); + } + }); + // Log browser console messages to see what's happening page.on('console', (msg) => { const type = msg.type(); diff --git a/e2e/chatClient/tests/features/authentication/auth-flow.spec.ts b/e2e/chatClient/tests/features/authentication/auth-flow.spec.ts index 5158985b686..bcd629d7855 100644 --- a/e2e/chatClient/tests/features/authentication/auth-flow.spec.ts +++ b/e2e/chatClient/tests/features/authentication/auth-flow.spec.ts @@ -20,8 +20,14 @@ test.describe('Authentication Flows', { tag: '@mock' }, () => { await page.goto(`http://localhost:3001/?agentCard=${encodeURIComponent(AGENT_CARD_URL)}`); await page.waitForLoadState('networkidle'); - // Start new chat - await page.getByRole('button', { name: /start a new chat/i }).click(); + // Wait for empty state to be visible first + await expect(page.getByText('No chats yet')).toBeVisible({ timeout: 10000 }); + + // Start new chat - wait for button to be visible and enabled before clicking + const startChatButton = page.getByRole('button', { name: /start a new chat/i }); + await expect(startChatButton).toBeVisible({ timeout: 5000 }); + await expect(startChatButton).toBeEnabled({ timeout: 5000 }); + await startChatButton.click(); await expect(page.locator('textarea').first()).toBeVisible({ timeout: 5000 }); }); @@ -153,13 +159,20 @@ test.describe('Authentication Flows', { tag: '@mock' }, () => { await expect(page.getByText(/Authentication Required/i)).toBeVisible({ timeout: 10000 }); + // Wait a moment for the UI to stabilize + await page.waitForTimeout(500); + // Find and click cancel button const cancelButton = page.getByRole('button', { name: /Cancel Authentication/i }); await expect(cancelButton).toBeVisible({ timeout: 5000 }); await cancelButton.click(); - // Should show canceled state - await expect(page.getByText(/Authentication Canceled/i)).toBeVisible({ timeout: 5000 }); + // After canceling, the authentication message should be removed from the UI + // (the parent component clears the auth required state) + await expect(page.getByText(/Authentication Required/i)).not.toBeVisible({ timeout: 5000 }); + + // The chat should remain functional - input should still be available + await expect(page.locator('textarea').first()).toBeVisible(); }); test('should disable cancel button while authenticating', async ({ page, context }) => { @@ -218,7 +231,15 @@ test.describe('Authentication Completion Flow', { tag: '@mock' }, () => { test.beforeEach(async ({ page }) => { await page.goto(`http://localhost:3001/?agentCard=${encodeURIComponent(AGENT_CARD_URL)}`); await page.waitForLoadState('networkidle'); - await page.getByRole('button', { name: /start a new chat/i }).click(); + + // Wait for empty state to be visible first + await expect(page.getByText('No chats yet')).toBeVisible({ timeout: 10000 }); + + // Start new chat - wait for button to be visible and enabled before clicking + const startChatButton = page.getByRole('button', { name: /start a new chat/i }); + await expect(startChatButton).toBeVisible({ timeout: 5000 }); + await expect(startChatButton).toBeEnabled({ timeout: 5000 }); + await startChatButton.click(); await expect(page.locator('textarea').first()).toBeVisible({ timeout: 5000 }); }); @@ -320,7 +341,15 @@ test.describe('Authentication Edge Cases', { tag: '@mock' }, () => { test.beforeEach(async ({ page }) => { await page.goto(`http://localhost:3001/?agentCard=${encodeURIComponent(AGENT_CARD_URL)}`); await page.waitForLoadState('networkidle'); - await page.getByRole('button', { name: /start a new chat/i }).click(); + + // Wait for empty state to be visible first + await expect(page.getByText('No chats yet')).toBeVisible({ timeout: 10000 }); + + // Start new chat - wait for button to be visible and enabled before clicking + const startChatButton = page.getByRole('button', { name: /start a new chat/i }); + await expect(startChatButton).toBeVisible({ timeout: 5000 }); + await expect(startChatButton).toBeEnabled({ timeout: 5000 }); + await startChatButton.click(); await expect(page.locator('textarea').first()).toBeVisible({ timeout: 5000 }); }); diff --git a/e2e/chatClient/tests/features/authentication/login-prompt.spec.ts b/e2e/chatClient/tests/features/authentication/login-prompt.spec.ts index cdd88878499..e1edc38881d 100644 --- a/e2e/chatClient/tests/features/authentication/login-prompt.spec.ts +++ b/e2e/chatClient/tests/features/authentication/login-prompt.spec.ts @@ -13,31 +13,73 @@ * - Successful authentication */ -import { test, expect } from '../../../fixtures/sse-fixtures'; -import { test as baseTest } from '@playwright/test'; +import { test as baseTest, expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; -// Agent card URL - intercepted by our fixture +// Agent card URL - intercepted by our mocks const AGENT_CARD_URL = 'http://localhost:3001/api/agents/test/.well-known/agent-card.json'; -test.describe('Login Prompt Display', { tag: '@mock' }, () => { - test('should display login prompt when agent card returns 401', async ({ page }) => { - // Override the agent card route to return 401 - await page.route('**/api/agents/test/.well-known/agent-card.json', async (route) => { - await route.fulfill({ - status: 401, - contentType: 'application/json', - body: JSON.stringify({ error: 'Unauthorized' }), - }); +// Mock identity providers to inject into the page +const MOCK_IDENTITY_PROVIDERS = { + aad: { + name: 'Microsoft', + signInEndpoint: '/.auth/login/aad', + }, +}; + +// Helper to set up login prompt test with identity providers +async function setupLoginPromptTest(page: Page) { + // Intercept and modify the HTML to inject identity providers + // This replaces the placeholder BEFORE the inline script runs + await page.route('**/*', async (route) => { + const request = route.request(); + if (request.resourceType() === 'document') { + const response = await route.fetch(); + let html = await response.text(); + // Replace the placeholder with actual identity providers JSON + const providersJson = JSON.stringify(MOCK_IDENTITY_PROVIDERS).replace(/"/g, '\\"'); + html = html.replace( + 'window.IDENTITY_PROVIDERS = "REPLACE_ME_WITH_IDENTITY_PROVIDERS"', + `window.IDENTITY_PROVIDERS = "${providersJson}"` + ); + await route.fulfill({ response, body: html }); + } else { + // Use fallback() to let other routes handle non-document requests + await route.fallback(); + } + }); + + // Override auth/me to return unauthenticated (empty array) + await page.route('**/.auth/me', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([]), }); + }); - // Also mock the auth refresh endpoint to fail - await page.route('**/.auth/refresh', async (route) => { - await route.fulfill({ - status: 401, - contentType: 'application/json', - body: JSON.stringify({ error: 'Unauthorized' }), - }); + // Override the agent card route to return 401 + await page.route('**/api/agents/test/.well-known/agent-card.json', async (route) => { + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ error: 'Unauthorized' }), }); + }); + + // Also mock the auth refresh endpoint to fail + await page.route('**/.auth/refresh', async (route) => { + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ error: 'Unauthorized' }), + }); + }); +} + +baseTest.describe('Login Prompt Display', { tag: '@mock' }, () => { + baseTest('should display login prompt when agent card returns 401', async ({ page }) => { + await setupLoginPromptTest(page); // Navigate to the app await page.goto(`http://localhost:3001/?agentCard=${encodeURIComponent(AGENT_CARD_URL)}`); @@ -49,14 +91,8 @@ test.describe('Login Prompt Display', { tag: '@mock' }, () => { await expect(page.getByRole('button', { name: 'Microsoft account' })).toBeVisible(); }); - test('should display person icon in login prompt', async ({ page }) => { - // Override routes to trigger login - await page.route('**/api/agents/test/.well-known/agent-card.json', async (route) => { - await route.fulfill({ status: 401 }); - }); - await page.route('**/.auth/refresh', async (route) => { - await route.fulfill({ status: 401 }); - }); + baseTest('should display person icon in login prompt', async ({ page }) => { + await setupLoginPromptTest(page); await page.goto(`http://localhost:3001/?agentCard=${encodeURIComponent(AGENT_CARD_URL)}`); @@ -68,13 +104,8 @@ test.describe('Login Prompt Display', { tag: '@mock' }, () => { await expect(iconContainer).toBeVisible(); }); - test('should show sign in button enabled by default', async ({ page }) => { - await page.route('**/api/agents/test/.well-known/agent-card.json', async (route) => { - await route.fulfill({ status: 401 }); - }); - await page.route('**/.auth/refresh', async (route) => { - await route.fulfill({ status: 401 }); - }); + baseTest('should show sign in button enabled by default', async ({ page }) => { + await setupLoginPromptTest(page); await page.goto(`http://localhost:3001/?agentCard=${encodeURIComponent(AGENT_CARD_URL)}`); @@ -87,18 +118,12 @@ test.describe('Login Prompt Display', { tag: '@mock' }, () => { }); }); -test.describe('Login Popup Flow', { tag: '@mock' }, () => { - test.beforeEach(async ({ page }) => { - // Set up 401 response for agent card - await page.route('**/api/agents/test/.well-known/agent-card.json', async (route) => { - await route.fulfill({ status: 401 }); - }); - await page.route('**/.auth/refresh', async (route) => { - await route.fulfill({ status: 401 }); - }); +baseTest.describe('Login Popup Flow', { tag: '@mock' }, () => { + baseTest.beforeEach(async ({ page }) => { + await setupLoginPromptTest(page); }); - test('should open login popup when sign in button is clicked', async ({ page, context }) => { + baseTest('should open login popup when sign in button is clicked', async ({ page, context }) => { await page.goto(`http://localhost:3001/?agentCard=${encodeURIComponent(AGENT_CARD_URL)}`); // Wait for login prompt @@ -120,14 +145,14 @@ test.describe('Login Popup Flow', { tag: '@mock' }, () => { await popup.close(); }); - test('should show loading state when popup is open', async ({ page, context }) => { + baseTest('should show loading state when popup is open', async ({ page, context }) => { await page.goto(`http://localhost:3001/?agentCard=${encodeURIComponent(AGENT_CARD_URL)}`); await expect(page.getByText('Sign in required')).toBeVisible({ timeout: 10000 }); // Click sign in and capture popup const [popup] = await Promise.all([ - context.waitForEvent('page', { timeout: 5000 }), + context.waitForEvent('page', { timeout: 10000 }), page.getByRole('button', { name: 'Microsoft account' }).click(), ]); @@ -141,7 +166,7 @@ test.describe('Login Popup Flow', { tag: '@mock' }, () => { await popup.close(); }); - test('should handle popup blocker gracefully', async ({ page }) => { + baseTest('should handle popup blocker gracefully', async ({ page }) => { await page.goto(`http://localhost:3001/?agentCard=${encodeURIComponent(AGENT_CARD_URL)}`); await expect(page.getByText('Sign in required')).toBeVisible({ timeout: 10000 }); @@ -164,7 +189,7 @@ test.describe('Login Popup Flow', { tag: '@mock' }, () => { await expect(page.getByRole('button', { name: 'Microsoft account' })).toBeEnabled(); }); - test('should display error message when popup is blocked', async ({ page }) => { + baseTest('should display error message when popup is blocked', async ({ page }) => { await page.goto(`http://localhost:3001/?agentCard=${encodeURIComponent(AGENT_CARD_URL)}`); await expect(page.getByText('Sign in required')).toBeVisible({ timeout: 10000 }); @@ -194,10 +219,13 @@ test.describe('Login Popup Flow', { tag: '@mock' }, () => { }); }); -test.describe('Token Refresh Flow', { tag: '@mock' }, () => { - test('should attempt token refresh before showing login prompt', async ({ page }) => { +baseTest.describe('Token Refresh Flow', { tag: '@mock' }, () => { + baseTest('should attempt token refresh before showing login prompt', async ({ page }) => { let refreshCalled = false; + // Inject identity providers via HTML modification + await injectIdentityProviders(page, MOCK_IDENTITY_PROVIDERS); + // Track refresh calls await page.route('**/.auth/refresh', async (route) => { refreshCalled = true; @@ -217,7 +245,10 @@ test.describe('Token Refresh Flow', { tag: '@mock' }, () => { expect(refreshCalled).toBe(true); }); - test('should reload page if token refresh succeeds', async ({ page }) => { + baseTest('should reload page if token refresh succeeds', async ({ page }) => { + // Inject identity providers via HTML modification + await injectIdentityProviders(page, MOCK_IDENTITY_PROVIDERS); + // eslint-disable-next-line @typescript-eslint/no-unused-vars let refreshAttempts = 0; let agentCardAttempts = 0; @@ -260,8 +291,11 @@ test.describe('Token Refresh Flow', { tag: '@mock' }, () => { }); }); -test.describe('Authentication Success Flow', { tag: '@mock' }, () => { - test('should reload page after successful authentication', async ({ page, context }) => { +baseTest.describe('Authentication Success Flow', { tag: '@mock' }, () => { + baseTest('should reload page after successful authentication', async ({ page, context }) => { + // Inject identity providers via HTML modification + await injectIdentityProviders(page, MOCK_IDENTITY_PROVIDERS); + let authMeCallCount = 0; // Set up 401 for agent card initially @@ -318,7 +352,7 @@ test.describe('Authentication Success Flow', { tag: '@mock' }, () => { // Click sign in const [popup] = await Promise.all([ - context.waitForEvent('page', { timeout: 5000 }), + context.waitForEvent('page', { timeout: 10000 }), page.getByRole('button', { name: 'Microsoft account' }).click(), ]); @@ -330,17 +364,12 @@ test.describe('Authentication Success Flow', { tag: '@mock' }, () => { }); }); -test.describe('Login Prompt Accessibility', { tag: '@mock' }, () => { - test.beforeEach(async ({ page }) => { - await page.route('**/api/agents/test/.well-known/agent-card.json', async (route) => { - await route.fulfill({ status: 401 }); - }); - await page.route('**/.auth/refresh', async (route) => { - await route.fulfill({ status: 401 }); - }); +baseTest.describe('Login Prompt Accessibility', { tag: '@mock' }, () => { + baseTest.beforeEach(async ({ page }) => { + await setupLoginPromptTest(page); }); - test('should have accessible button with proper role', async ({ page }) => { + baseTest('should have accessible button with proper role', async ({ page }) => { await page.goto(`http://localhost:3001/?agentCard=${encodeURIComponent(AGENT_CARD_URL)}`); await expect(page.getByText('Sign in required')).toBeVisible({ timeout: 10000 }); @@ -351,7 +380,7 @@ test.describe('Login Prompt Accessibility', { tag: '@mock' }, () => { await expect(signInButton).toHaveAttribute('type', 'button'); }); - test('should be keyboard navigable', async ({ page }) => { + baseTest('should be keyboard navigable', async ({ page }) => { await page.goto(`http://localhost:3001/?agentCard=${encodeURIComponent(AGENT_CARD_URL)}`); await expect(page.getByText('Sign in required')).toBeVisible({ timeout: 10000 }); @@ -466,6 +495,31 @@ baseTest.describe('Successful Login to MultiSession Chat', { tag: '@mock' }, () await route.fulfill({ status: 400 }); }); + // Inject identity providers via HTML modification (LAST to have highest priority in context.route) + // Only modify main app pages, not auth pages + await context.route('**/*', async (route) => { + const request = route.request(); + const url = request.url(); + // Skip auth URLs and API URLs - they have their own handlers + // Use fallback() to let other routes handle these + if (url.includes('/.auth/') || url.includes('/api/')) { + await route.fallback(); + return; + } + if (request.resourceType() === 'document') { + const response = await route.fetch(); + let html = await response.text(); + const providersJson = JSON.stringify(MOCK_IDENTITY_PROVIDERS).replace(/"/g, '\\"'); + html = html.replace( + 'window.IDENTITY_PROVIDERS = "REPLACE_ME_WITH_IDENTITY_PROVIDERS"', + `window.IDENTITY_PROVIDERS = "${providersJson}"` + ); + await route.fulfill({ response, body: html }); + } else { + await route.fallback(); + } + }); + // Navigate with multiSession enabled await page.goto(`http://localhost:3001/?agentCard=${encodeURIComponent(AGENT_CARD_URL)}&multiSession=true`); @@ -474,7 +528,7 @@ baseTest.describe('Successful Login to MultiSession Chat', { tag: '@mock' }, () // Click sign in const [popup] = await Promise.all([ - context.waitForEvent('page', { timeout: 5000 }), + context.waitForEvent('page', { timeout: 10000 }), page.getByRole('button', { name: 'Microsoft account' }).click(), ]); @@ -553,6 +607,31 @@ baseTest.describe('Successful Login to MultiSession Chat', { tag: '@mock' }, () }); }); + // Inject identity providers via HTML modification (LAST to have highest priority in context.route) + // Only modify main app pages, not auth pages + await context.route('**/*', async (route) => { + const request = route.request(); + const url = request.url(); + // Skip auth URLs and API URLs - they have their own handlers + // Use fallback() to let other routes handle these + if (url.includes('/.auth/') || url.includes('/api/')) { + await route.fallback(); + return; + } + if (request.resourceType() === 'document') { + const response = await route.fetch(); + let html = await response.text(); + const providersJson = JSON.stringify(MOCK_IDENTITY_PROVIDERS).replace(/"/g, '\\"'); + html = html.replace( + 'window.IDENTITY_PROVIDERS = "REPLACE_ME_WITH_IDENTITY_PROVIDERS"', + `window.IDENTITY_PROVIDERS = "${providersJson}"` + ); + await route.fulfill({ response, body: html }); + } else { + await route.fallback(); + } + }); + // Navigate without multiSession (single session mode) await page.goto(`http://localhost:3001/?agentCard=${encodeURIComponent(AGENT_CARD_URL)}`); @@ -561,7 +640,7 @@ baseTest.describe('Successful Login to MultiSession Chat', { tag: '@mock' }, () // Click sign in const [popup] = await Promise.all([ - context.waitForEvent('page', { timeout: 5000 }), + context.waitForEvent('page', { timeout: 10000 }), page.getByRole('button', { name: 'Microsoft account' }).click(), ]); @@ -624,6 +703,31 @@ baseTest.describe('Successful Login to MultiSession Chat', { tag: '@mock' }, () }); }); + // Inject identity providers via HTML modification (LAST to have highest priority in context.route) + // Only modify main app pages, not auth pages + await context.route('**/*', async (route) => { + const request = route.request(); + const url = request.url(); + // Skip auth URLs and API URLs - they have their own handlers + // Use fallback() to let other routes handle these + if (url.includes('/.auth/') || url.includes('/api/')) { + await route.fallback(); + return; + } + if (request.resourceType() === 'document') { + const response = await route.fetch(); + let html = await response.text(); + const providersJson = JSON.stringify(MOCK_IDENTITY_PROVIDERS).replace(/"/g, '\\"'); + html = html.replace( + 'window.IDENTITY_PROVIDERS = "REPLACE_ME_WITH_IDENTITY_PROVIDERS"', + `window.IDENTITY_PROVIDERS = "${providersJson}"` + ); + await route.fulfill({ response, body: html }); + } else { + await route.fallback(); + } + }); + await page.goto(`http://localhost:3001/?agentCard=${encodeURIComponent(AGENT_CARD_URL)}`); // Login prompt should appear @@ -631,7 +735,7 @@ baseTest.describe('Successful Login to MultiSession Chat', { tag: '@mock' }, () // Sign in const [popup] = await Promise.all([ - context.waitForEvent('page', { timeout: 5000 }), + context.waitForEvent('page', { timeout: 10000 }), page.getByRole('button', { name: 'Microsoft account' }).click(), ]); @@ -643,8 +747,37 @@ baseTest.describe('Successful Login to MultiSession Chat', { tag: '@mock' }, () }); }); -test.describe('Multiple Identity Providers', { tag: '@mock' }, () => { - test('should display multiple provider buttons when multiple providers are configured', async ({ page }) => { +// Helper to inject custom identity providers via HTML modification +async function injectIdentityProviders(page: Page, providers: Record) { + await page.route('**/*', async (route) => { + const request = route.request(); + if (request.resourceType() === 'document') { + const response = await route.fetch(); + let html = await response.text(); + const providersJson = JSON.stringify(providers).replace(/"/g, '\\"'); + html = html.replace( + 'window.IDENTITY_PROVIDERS = "REPLACE_ME_WITH_IDENTITY_PROVIDERS"', + `window.IDENTITY_PROVIDERS = "${providersJson}"` + ); + await route.fulfill({ response, body: html }); + } else { + // Use fallback() to let other routes handle non-document requests + await route.fallback(); + } + }); +} + +baseTest.describe('Multiple Identity Providers', { tag: '@mock' }, () => { + baseTest('should display multiple provider buttons when multiple providers are configured', async ({ page }) => { + const multipleProviders = { + aad: { signInEndpoint: '/.auth/login/aad', name: 'Microsoft' }, + google: { signInEndpoint: '/.auth/login/google', name: 'Google' }, + github: { signInEndpoint: '/.auth/login/github', name: 'GitHub' }, + }; + + // Inject multiple identity providers + await injectIdentityProviders(page, multipleProviders); + // Override auth/me to return not authenticated await page.route('**/.auth/me', async (route) => { await route.fulfill({ @@ -654,10 +787,13 @@ test.describe('Multiple Identity Providers', { tag: '@mock' }, () => { }); }); - // Set window.IDENTITY_PROVIDERS with multiple providers before page loads - await page.addInitScript(() => { - (window as any).IDENTITY_PROVIDERS = - '{"aad":{"signInEndpoint":"/.auth/login/aad","name":"Microsoft"},"google":{"signInEndpoint":"/.auth/login/google","name":"Google"},"github":{"signInEndpoint":"/.auth/login/github","name":"GitHub"}}'; + // Override agent card to return 401 (trigger login prompt) + await page.route('**/api/agents/test/.well-known/agent-card.json', async (route) => { + await route.fulfill({ status: 401 }); + }); + + await page.route('**/.auth/refresh', async (route) => { + await route.fulfill({ status: 401 }); }); await page.goto(`http://localhost:3001/?agentCard=${encodeURIComponent(AGENT_CARD_URL)}`); @@ -671,7 +807,15 @@ test.describe('Multiple Identity Providers', { tag: '@mock' }, () => { await expect(page.getByRole('button', { name: 'GitHub account' })).toBeVisible(); }); - test('should use correct sign-in endpoint for each provider', async ({ page, context }) => { + baseTest('should use correct sign-in endpoint for each provider', async ({ page, context }) => { + const multipleProviders = { + aad: { signInEndpoint: '/.auth/login/aad', name: 'Microsoft' }, + google: { signInEndpoint: '/.auth/login/google', name: 'Google' }, + }; + + // Inject multiple identity providers + await injectIdentityProviders(page, multipleProviders); + // Override auth/me to return not authenticated await page.route('**/.auth/me', async (route) => { await route.fulfill({ @@ -681,10 +825,13 @@ test.describe('Multiple Identity Providers', { tag: '@mock' }, () => { }); }); - // Set window.IDENTITY_PROVIDERS with multiple providers before page loads - await page.addInitScript(() => { - (window as any).IDENTITY_PROVIDERS = - '{"aad":{"signInEndpoint":"/.auth/login/aad","name":"Microsoft"},"google":{"signInEndpoint":"/.auth/login/google","name":"Google"}}'; + // Override agent card to return 401 (trigger login prompt) + await page.route('**/api/agents/test/.well-known/agent-card.json', async (route) => { + await route.fulfill({ status: 401 }); + }); + + await page.route('**/.auth/refresh', async (route) => { + await route.fulfill({ status: 401 }); }); await page.goto(`http://localhost:3001/?agentCard=${encodeURIComponent(AGENT_CARD_URL)}`); @@ -703,7 +850,15 @@ test.describe('Multiple Identity Providers', { tag: '@mock' }, () => { await popup.close(); }); - test('should show loading state only on clicked provider button', async ({ page, context }) => { + baseTest('should show loading state only on clicked provider button', async ({ page, context }) => { + const multipleProviders = { + aad: { signInEndpoint: '/.auth/login/aad', name: 'Microsoft' }, + google: { signInEndpoint: '/.auth/login/google', name: 'Google' }, + }; + + // Inject multiple identity providers + await injectIdentityProviders(page, multipleProviders); + // Override auth/me to return not authenticated await page.route('**/.auth/me', async (route) => { await route.fulfill({ @@ -713,10 +868,13 @@ test.describe('Multiple Identity Providers', { tag: '@mock' }, () => { }); }); - // Set window.IDENTITY_PROVIDERS with multiple providers before page loads - await page.addInitScript(() => { - (window as any).IDENTITY_PROVIDERS = - '{"aad":{"signInEndpoint":"/.auth/login/aad","name":"Microsoft"},"google":{"signInEndpoint":"/.auth/login/google","name":"Google"}}'; + // Override agent card to return 401 (trigger login prompt) + await page.route('**/api/agents/test/.well-known/agent-card.json', async (route) => { + await route.fulfill({ status: 401 }); + }); + + await page.route('**/.auth/refresh', async (route) => { + await route.fulfill({ status: 401 }); }); await page.goto(`http://localhost:3001/?agentCard=${encodeURIComponent(AGENT_CARD_URL)}`); @@ -726,7 +884,7 @@ test.describe('Multiple Identity Providers', { tag: '@mock' }, () => { // Click Google button const [popup] = await Promise.all([ - context.waitForEvent('page', { timeout: 5000 }), + context.waitForEvent('page', { timeout: 10000 }), page.getByRole('button', { name: 'Google account' }).click(), ]); @@ -742,17 +900,8 @@ test.describe('Multiple Identity Providers', { tag: '@mock' }, () => { await popup.close(); }); - test('should show configuration message when no providers configured', async ({ page }) => { - // Override auth/me to return not authenticated - await page.route('**/.auth/me', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify([]), - }); - }); - - // Intercept the HTML page and replace the IDENTITY_PROVIDERS placeholder with empty object + baseTest('should show configuration message when no providers configured', async ({ page }) => { + // Intercept the HTML page FIRST and replace the IDENTITY_PROVIDERS placeholder with empty object // This simulates server-side injection of empty providers config await page.route('**/*', async (route) => { const request = route.request(); @@ -763,10 +912,29 @@ test.describe('Multiple Identity Providers', { tag: '@mock' }, () => { html = html.replace('window.IDENTITY_PROVIDERS = "REPLACE_ME_WITH_IDENTITY_PROVIDERS"', 'window.IDENTITY_PROVIDERS = "{}"'); await route.fulfill({ response, body: html }); } else { - await route.continue(); + // Use fallback() to let other routes handle non-document requests + await route.fallback(); } }); + // Override auth/me to return not authenticated + await page.route('**/.auth/me', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([]), + }); + }); + + // Override agent card to return 401 (trigger login prompt) + await page.route('**/api/agents/test/.well-known/agent-card.json', async (route) => { + await route.fulfill({ status: 401 }); + }); + + await page.route('**/.auth/refresh', async (route) => { + await route.fulfill({ status: 401 }); + }); + await page.goto(`http://localhost:3001/?agentCard=${encodeURIComponent(AGENT_CARD_URL)}`); // Wait for login prompt @@ -779,17 +947,8 @@ test.describe('Multiple Identity Providers', { tag: '@mock' }, () => { await expect(page.getByRole('button', { name: /account/i })).not.toBeVisible(); }); - test('should handle single custom provider correctly', async ({ page, context }) => { - // Override auth/me to return not authenticated - await page.route('**/.auth/me', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify([]), - }); - }); - - // Intercept the HTML page and replace the IDENTITY_PROVIDERS placeholder with custom provider + baseTest('should handle single custom provider correctly', async ({ page, context }) => { + // Intercept the HTML page FIRST and replace the IDENTITY_PROVIDERS placeholder with custom provider // This simulates server-side injection of custom providers config await page.route('**/*', async (route) => { const request = route.request(); @@ -803,10 +962,29 @@ test.describe('Multiple Identity Providers', { tag: '@mock' }, () => { ); await route.fulfill({ response, body: html }); } else { - await route.continue(); + // Use fallback() to let other routes handle non-document requests + await route.fallback(); } }); + // Override auth/me to return not authenticated + await page.route('**/.auth/me', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([]), + }); + }); + + // Override agent card to return 401 (trigger login prompt) + await page.route('**/api/agents/test/.well-known/agent-card.json', async (route) => { + await route.fulfill({ status: 401 }); + }); + + await page.route('**/.auth/refresh', async (route) => { + await route.fulfill({ status: 401 }); + }); + await page.goto(`http://localhost:3001/?agentCard=${encodeURIComponent(AGENT_CARD_URL)}`); // Wait for login prompt