diff --git a/tests/e2e/features/mcp-registry/mcp-registry.spec.ts b/tests/e2e/features/mcp-registry/mcp-registry.spec.ts index ca939ff8454..a9c1c7d0743 100644 --- a/tests/e2e/features/mcp-registry/mcp-registry.spec.ts +++ b/tests/e2e/features/mcp-registry/mcp-registry.spec.ts @@ -11,6 +11,42 @@ import { const hasSSEHeaders = Boolean(process.env.MCP_SSE_HEADERS) +async function completeOAuthFlow(page: { context: () => any; request: any }, flow: { + authorize_url: string + oauth_config_id: string + complete_url?: string + status_url?: string +}) { + const popup = await page.context().newPage() + await popup.goto(flow.authorize_url) + await popup.locator('#user').fill('demo-user') + await popup.getByRole('button', { name: /Sign in/i }).click() + await popup.waitForLoadState('networkidle').catch(() => {}) + await popup.close().catch(() => {}) + + const statusUrl = flow.status_url ?? `/api/oauth/config/${flow.oauth_config_id}/status` + let authorized = false + for (let i = 0; i < 30; i++) { + const statusResponse = await page.request.get(statusUrl) + if (!statusResponse.ok()) { + await new Promise((resolve) => setTimeout(resolve, 500)) + continue + } + const statusBody = await statusResponse.json().catch(() => null) + if (statusBody?.status === 'authorized') { + authorized = true + break + } + await new Promise((resolve) => setTimeout(resolve, 500)) + } + + expect(authorized).toBe(true) + + const completeUrl = flow.complete_url ?? `/api/mcp/client/${flow.oauth_config_id}/complete-oauth` + const completeResponse = await page.request.post(completeUrl) + expect(completeResponse.ok()).toBe(true) +} + // Track created clients for cleanup const createdClients: string[] = [] @@ -384,6 +420,7 @@ test.describe('MCP Registry', () => { await expect(mcpRegistryPage.sheet).toBeVisible() await mcpRegistryPage.selectAuthType('oauth') + await mcpRegistryPage.expandOAuthAdvancedIfCollapsed() // All fields are optional — auto-discovered from server metadata await expect(mcpRegistryPage.oauthClientIdInput).toBeVisible() @@ -398,25 +435,9 @@ test.describe('MCP Registry', () => { const clientData = createOAuthClientData() createdClients.push(clientData.name) - const created = await mcpRegistryPage.createClient(clientData) - expect(created).toBe(true) - - // OAuth Authorization dialog appears — click "Open Authorization Window" - const openWindowBtn = mcpRegistryPage.page.locator('[data-testid="oauth-open-window-btn"]') - await expect(openWindowBtn).toBeVisible({ timeout: 10000 }) - - const [popup] = await Promise.all([ - mcpRegistryPage.page.waitForEvent('popup'), - openWindowBtn.click(), - ]) - - // Complete login in popup (oauth-demo-server login form) - await popup.waitForLoadState() - await popup.locator('#user').fill('demo-user') - await popup.getByRole('button', { name: /Sign in/i }).click() - - // Popup redirects to Bifrost callback then closes - await popup.waitForEvent('close', { timeout: 15000 }).catch(() => {}) + const flow = await mcpRegistryPage.createOAuthClient(clientData) + await completeOAuthFlow(mcpRegistryPage.page, flow) + await mcpRegistryPage.goto() // Client should now be connected and visible in table const exists = await mcpRegistryPage.clientExists(clientData.name) @@ -430,6 +451,7 @@ test.describe('MCP Registry', () => { await expect(mcpRegistryPage.sheet).toBeVisible() await mcpRegistryPage.selectAuthType('per_user_oauth') + await mcpRegistryPage.expandOAuthAdvancedIfCollapsed() await expect(mcpRegistryPage.oauthClientIdInput).toBeVisible() await expect(mcpRegistryPage.oauthClientSecretInput).toBeVisible() @@ -443,30 +465,13 @@ test.describe('MCP Registry', () => { const clientData = createPerUserOAuthClientData() createdClients.push(clientData.name) - const created = await mcpRegistryPage.createClient(clientData) - expect(created).toBe(true) - - // "Test OAuth Configuration" dialog appears. Per-user OAuth opens the - // authorization popup directly from this confirmation click. - const confirmBtn = mcpRegistryPage.page.locator('[data-testid="per-user-oauth-confirm"]') - await expect(confirmBtn).toBeVisible({ timeout: 10000 }) - - const [popup] = await Promise.all([ - mcpRegistryPage.page.waitForEvent('popup'), - confirmBtn.click(), - ]) - - // Complete login in popup (oauth-demo-server login form) - await popup.waitForLoadState() - await popup.locator('#user').fill('demo-user') - await popup.getByRole('button', { name: /Sign in/i }).click() - - // Popup redirects to Bifrost callback then closes - await popup.waitForEvent('close', { timeout: 15000 }).catch(() => {}) + const flow = await mcpRegistryPage.createOAuthClient(clientData) + await completeOAuthFlow(mcpRegistryPage.page, flow) + await mcpRegistryPage.goto() // Client should be visible in table const exists = await mcpRegistryPage.clientExists(clientData.name) expect(exists).toBe(true) }) }) -}) +}) \ No newline at end of file diff --git a/tests/e2e/features/mcp-registry/pages/mcp-registry.page.ts b/tests/e2e/features/mcp-registry/pages/mcp-registry.page.ts index bdf1d2f69ac..791dc61cd04 100644 --- a/tests/e2e/features/mcp-registry/pages/mcp-registry.page.ts +++ b/tests/e2e/features/mcp-registry/pages/mcp-registry.page.ts @@ -52,6 +52,7 @@ export class MCPRegistryPage extends BasePage { readonly cancelBtn: Locator readonly connectionTypeSelect: Locator readonly authTypeSelect: Locator + readonly authScopeSelect: Locator readonly connectionUrlInput: Locator readonly codeModeSwitch: Locator readonly pingAvailableSwitch: Locator @@ -89,6 +90,7 @@ export class MCPRegistryPage extends BasePage { // Connection type and auth this.connectionTypeSelect = page.locator('[data-testid="connection-type-select"]') this.authTypeSelect = page.locator('[data-testid="auth-type-select"]') + this.authScopeSelect = page.locator('[data-testid="auth-scope-select"]') // Use placeholder as primary selector for EnvVarInput (more reliable) this.connectionUrlInput = this.sheet.getByPlaceholder(/http:\/\/your-mcp-server/i).or( page.locator('[data-testid="connection-url-input"]') @@ -104,11 +106,11 @@ export class MCPRegistryPage extends BasePage { this.envsInput = page.locator('[data-testid="stdio-envs-input"]') // OAuth inputs - this.oauthClientIdInput = this.sheet.getByPlaceholder(/your-client-id/i) - this.oauthClientSecretInput = this.sheet.getByPlaceholder(/your-client-secret/i) - this.oauthAuthorizeUrlInput = this.sheet.getByPlaceholder(/oauth\/authorize/i) - this.oauthTokenUrlInput = this.sheet.getByPlaceholder(/oauth\/token/i) - this.oauthScopesInput = this.sheet.getByPlaceholder(/read, write, admin/i) + this.oauthClientIdInput = this.sheet.locator('[data-testid="mcp-oauth-client-id"]').or(this.sheet.getByPlaceholder(/your-client-id/i)) + this.oauthClientSecretInput = this.sheet.locator('[data-testid="mcp-oauth-client-secret"]').or(this.sheet.getByPlaceholder(/your-client-secret/i)) + this.oauthAuthorizeUrlInput = this.sheet.locator('[data-testid="mcp-oauth-authorize-url"]').or(this.sheet.getByPlaceholder(/oauth\/authorize/i)) + this.oauthTokenUrlInput = this.sheet.locator('[data-testid="mcp-oauth-token-url"]').or(this.sheet.getByPlaceholder(/oauth\/token/i)) + this.oauthScopesInput = this.sheet.locator('[data-testid="mcp-oauth-scopes-input"]').or(this.sheet.getByPlaceholder(/read, write, admin/i)) } async goto(): Promise { @@ -188,14 +190,58 @@ export class MCPRegistryPage extends BasePage { await expect(selectTrigger).toBeVisible({ timeout: 5000 }) await selectTrigger.click() - // Select the option by data-testid (per_user_oauth uses kebab-case in testid) - const optionTestId = `auth-type-${type.replace(/_/g, '-')}` + // The UI now splits auth configuration into auth type + auth scope. + const authKind = type === 'per_user_oauth' ? 'oauth' : type + const optionTestId = `auth-type-${authKind.replace(/_/g, '-')}` const option = this.page.locator(`[data-testid="${optionTestId}"]`) await expect(option).toBeVisible({ timeout: 5000 }) await option.click() // Wait for dropdown to close await expect(option).not.toBeVisible({ timeout: 3000 }).catch(() => {}) + + if (type === 'per_user_oauth') { + await this.selectAuthScope('per_user') + } + } + + async selectAuthScope(scope: 'shared' | 'per_user'): Promise { + const selectTrigger = this.page.locator('[data-testid="auth-scope-select"]') + await expect(selectTrigger).toBeVisible({ timeout: 5000 }) + await selectTrigger.click() + + const optionTestId = `auth-scope-${scope.replace(/_/g, '-')}` + const option = this.page.locator(`[data-testid="${optionTestId}"]`) + await expect(option).toBeVisible({ timeout: 5000 }) + await option.click() + + await expect(option).not.toBeVisible({ timeout: 3000 }).catch(() => {}) + } + + async expandOAuthAdvancedIfCollapsed(): Promise { + const trigger = this.sheet.locator('[data-testid="oauth-advanced-trigger"]') + const visible = await trigger.isVisible().catch(() => false) + if (!visible) return + + const clientIdVisible = await this.oauthClientIdInput.isVisible().catch(() => false) + if (clientIdVisible) return + + await trigger.click() + await expect(this.oauthClientIdInput).toBeVisible({ timeout: 5000 }) + } + + async waitForOAuthAuthorizerAction(timeout = 10000): Promise { + const continueBtn = this.page.locator('[data-testid="per-user-oauth-confirm"]') + const openWindowBtn = this.page.locator('[data-testid="oauth-open-window-btn"]') + + const deadline = Date.now() + timeout + while (Date.now() < deadline) { + if (await continueBtn.isVisible().catch(() => false)) return continueBtn + if (await openWindowBtn.isVisible().catch(() => false)) return openWindowBtn + await this.page.waitForTimeout(200) + } + + throw new Error('OAuth authorizer action was not shown') } /** @@ -281,6 +327,7 @@ export class MCPRegistryPage extends BasePage { // Handle OAuth config (oauth and per_user_oauth share the same fields) if (config.authType === 'oauth' || config.authType === 'per_user_oauth') { + await this.expandOAuthAdvancedIfCollapsed() if (config.oauthClientId) { await this.oauthClientIdInput.fill(config.oauthClientId) } @@ -360,6 +407,13 @@ export class MCPRegistryPage extends BasePage { throw new Error(`Create MCP client failed: ${response.status()} ${body}`) } + const responseBody = response ? await response.text().catch(() => '') : '' + const pendingOAuth = responseBody.includes('"status":"pending_oauth"') + if (pendingOAuth) { + await this.waitForOAuthAuthorizerAction() + return true + } + // Success: backend returned 2xx. Wait for create form to close (short timeout; UI usually updates quickly). const createFormHeading = this.page.getByRole('heading', { name: 'New MCP Server' }) await createFormHeading.waitFor({ state: 'hidden', timeout: 15000 }).catch(() => null) @@ -423,12 +477,54 @@ export class MCPRegistryPage extends BasePage { throw new Error('No toast appeared and sheet did not close - form submission may have failed') } + async createOAuthClient(config: MCPClientConfig): Promise<{ + status: string + authorize_url: string + oauth_config_id: string + complete_url?: string + status_url?: string + mcp_client_id: string + }> { + await this.dismissToasts() + await this.createBtn.click() + await expect(this.sheet).toBeVisible({ timeout: 5000 }) + + await this.fillClientForm(config) + await this.page.waitForTimeout(1500) + await expect(this.saveBtn).toBeEnabled({ timeout: 10000 }) + + const responsePromise = this.page.waitForResponse( + (response) => { + const url = response.url() + const method = response.request().method() + return url.includes('/mcp/client') && !url.endsWith('/mcp/clients') && method === 'POST' + }, + { timeout: 60000 } + ) + + await this.saveBtn.click({ force: true }) + const response = await responsePromise + if (!response.ok()) { + const body = await response.text().catch(() => '') + throw new Error(`Create OAuth MCP client failed: ${response.status()} ${body}`) + } + + const body = await response.json() + if (body?.status !== 'pending_oauth' || !body?.authorize_url || !body?.oauth_config_id) { + throw new Error(`Expected pending_oauth response, got: ${JSON.stringify(body)}`) + } + + return body + } + /** - * View client details by clicking on the row + * View client details from the actions menu */ async viewClientDetails(name: string): Promise { - const row = this.getClientRow(name) - await row.click() + await this.openClientActions(name) + const editItem = this.page.getByRole('menuitem', { name: /Edit/i }) + await expect(editItem).toBeVisible({ timeout: 5000 }) + await editItem.click() await expect(this.detailSheet).toBeVisible({ timeout: 5000 }) } @@ -723,4 +819,4 @@ export class MCPRegistryPage extends BasePage { const emptyMessage = this.page.getByText(/No clients found/i) return await emptyMessage.isVisible().catch(() => false) } -} +} \ No newline at end of file