feat: surface slack mcp oauth logins#24569
Conversation
This stack of pull requests is managed by Graphite. Learn more about stacking. |
🧪 Test Selection✅ Tests that will run
⏭️ Tests skipped (no relevant file changes detected)
|
4d02732 to
98f1ee6
Compare
5ccfac8 to
f40025e
Compare
98f1ee6 to
c9d92a6
Compare
|
Your preview environment pr-24569 has been deployed with errors. Preview environment endpoints are available at: |
Preview Environment🌐 URL: https://lightdash-preview-pr-24569.lightdash.okteto.dev 📋 Logs: View in GCP Console 🔧 SSH: |
c9d92a6 to
be66710
Compare
| @@ -109,4 +157,73 @@ describe('AiAgentService :eyes: ack reaction lifecycle', () => { | |||
| service.replyToSlackPrompt('prompt-1'), | |||
| ).resolves.toBeUndefined(); | |||
| }); | |||
|
|
|||
| it('posts one MCP OAuth login ephemeral per unavailable OAuth server', async () => { | |||
| const { service, slackClient, webClient } = buildService(); | |||
| const startOAuth = jest | |||
| .spyOn(service, 'startMcpOAuthConnection') | |||
| .mockImplementation( | |||
| async (_user, _projectUuid, mcpServerUuid) => | |||
| `https://oauth.example/${mcpServerUuid}`, | |||
| ); | |||
|
|
|||
| await postMcpOAuthLoginMessages(service, { | |||
| mcpServers: [ | |||
| { | |||
| uuid: 'mcp-1', | |||
| name: 'Linear', | |||
| authType: 'oauth', | |||
| }, | |||
| { | |||
| uuid: 'mcp-2', | |||
| name: 'GitHub', | |||
| authType: 'oauth', | |||
| }, | |||
| { | |||
| uuid: 'mcp-3', | |||
| name: 'Docs', | |||
| authType: 'bearer', | |||
| }, | |||
| ], | |||
| unavailableMcpServers: [ | |||
| { | |||
| serverUuid: 'mcp-1', | |||
| serverName: 'Linear', | |||
| message: 'Unauthorized', | |||
| status: 'not_connected', | |||
| }, | |||
| { | |||
| serverUuid: 'mcp-2', | |||
| serverName: 'GitHub', | |||
| message: 'Unauthorized', | |||
| status: 'not_connected', | |||
| }, | |||
| { | |||
| serverUuid: 'mcp-3', | |||
| serverName: 'Docs', | |||
| message: 'Broken', | |||
| status: 'error', | |||
| }, | |||
| ], | |||
| }); | |||
|
|
|||
| expect(slackClient.getWebClient).toHaveBeenCalledWith('org-1'); | |||
| expect(startOAuth).toHaveBeenCalledTimes(2); | |||
| expect(webClient.chat.postEphemeral).toHaveBeenCalledTimes(2); | |||
| expect(webClient.chat.postEphemeral).toHaveBeenNthCalledWith( | |||
| 1, | |||
| expect.objectContaining({ | |||
| channel: 'C123', | |||
| user: 'U123', | |||
| thread_ts: '1700000000.000000', | |||
| text: 'Linear MCP needs you to log in before I can use it.', | |||
| }), | |||
| ); | |||
| expect(webClient.chat.postEphemeral).toHaveBeenNthCalledWith( | |||
| 2, | |||
| expect.objectContaining({ | |||
| text: 'GitHub MCP needs you to log in before I can use it.', | |||
| }), | |||
| ); | |||
| }); | |||
There was a problem hiding this comment.
The test directly invokes a private method (postSlackMcpOAuthLoginMessages) via type-casting and asserts on Slack client call counts and arguments. Per the testing guidelines: 'Don't write unit tests that test implementation details' — bad tests include 'database calls, implementation details, data transformation'. Testing a private method's internal Slack API interactions is an implementation detail test. The test should instead cover the business logic (e.g., the filtering/deduplication logic in getSlackMcpOAuthLoginServers) in isolation, or be restructured to test observable outcomes rather than internal wiring.
| const getSlackMcpOAuthLoginServers = ( | |
| mcpServers: { uuid: string; name: string; authType: string }[], | |
| unavailableMcpServers: { | |
| serverUuid: string; | |
| serverName: string; | |
| message: string; | |
| status: string; | |
| }[], | |
| ) => { | |
| const unavailableOAuthServerUuids = new Set( | |
| unavailableMcpServers | |
| .filter((s) => s.status === 'not_connected') | |
| .map((s) => s.serverUuid), | |
| ); | |
| return mcpServers.filter( | |
| (s) => | |
| s.authType === 'oauth' && | |
| unavailableOAuthServerUuids.has(s.uuid), | |
| ); | |
| }; | |
| describe('AiAgentService :eyes: ack reaction lifecycle', () => { | |
| afterEach(() => { | |
| jest.restoreAllMocks(); | |
| }); | |
| it('returns only unavailable OAuth servers that need login (filters out bearer and error-status servers)', () => { | |
| const mcpServers = [ | |
| { uuid: 'mcp-1', name: 'Linear', authType: 'oauth' }, | |
| { uuid: 'mcp-2', name: 'GitHub', authType: 'oauth' }, | |
| { uuid: 'mcp-3', name: 'Docs', authType: 'bearer' }, | |
| ]; | |
| const unavailableMcpServers = [ | |
| { | |
| serverUuid: 'mcp-1', | |
| serverName: 'Linear', | |
| message: 'Unauthorized', | |
| status: 'not_connected', | |
| }, | |
| { | |
| serverUuid: 'mcp-2', | |
| serverName: 'GitHub', | |
| message: 'Unauthorized', | |
| status: 'not_connected', | |
| }, | |
| { | |
| serverUuid: 'mcp-3', | |
| serverName: 'Docs', | |
| message: 'Broken', | |
| status: 'error', | |
| }, | |
| ]; | |
| const result = getSlackMcpOAuthLoginServers( | |
| mcpServers, | |
| unavailableMcpServers, | |
| ); | |
| expect(result).toHaveLength(2); | |
| expect(result.map((s) => s.name)).toEqual(['Linear', 'GitHub']); | |
| }); | |
| it('returns empty list when all unavailable servers use bearer auth', () => { | |
| const mcpServers = [ | |
| { uuid: 'mcp-1', name: 'Docs', authType: 'bearer' }, | |
| ]; | |
| const unavailableMcpServers = [ | |
| { | |
| serverUuid: 'mcp-1', | |
| serverName: 'Docs', | |
| message: 'Unauthorized', | |
| status: 'not_connected', | |
| }, | |
| ]; | |
| const result = getSlackMcpOAuthLoginServers( | |
| mcpServers, | |
| unavailableMcpServers, | |
| ); | |
| expect(result).toHaveLength(0); | |
| }); | |
| it('returns empty list when unavailable OAuth servers have error status (not not_connected)', () => { | |
| const mcpServers = [ | |
| { uuid: 'mcp-1', name: 'Linear', authType: 'oauth' }, | |
| ]; | |
| const unavailableMcpServers = [ | |
| { | |
| serverUuid: 'mcp-1', | |
| serverName: 'Linear', | |
| message: 'Broken', | |
| status: 'error', | |
| }, | |
| ]; | |
| const result = getSlackMcpOAuthLoginServers( | |
| mcpServers, | |
| unavailableMcpServers, | |
| ); | |
| expect(result).toHaveLength(0); | |
| }); | |
| it('returns only the single unavailable OAuth server needing login', () => { | |
| const mcpServers = [ | |
| { uuid: 'mcp-1', name: 'Linear', authType: 'oauth' }, | |
| ]; | |
| const unavailableMcpServers = [ | |
| { | |
| serverUuid: 'mcp-1', | |
| serverName: 'Linear', | |
| message: 'Unauthorized', | |
| status: 'not_connected', | |
| }, | |
| ]; | |
| const result = getSlackMcpOAuthLoginServers( | |
| mcpServers, | |
| unavailableMcpServers, | |
| ); | |
| expect(result).toHaveLength(1); | |
| expect(result[0].name).toBe('Linear'); | |
| }); | |
Spotted by Graphite (based on custom rule: packages/backend rules)
Is this helpful? React 👍 or 👎 to let us know.
f40025e to
e9ddc16
Compare
be66710 to
e5e8287
Compare
e5e8287 to
1b6b7b9
Compare
1b6b7b9 to
b57d9f5
Compare

Description
not_connectedwhile preserving OAuth callback state.Closes: PROD-7877