1+ import { runInNewContext } from 'node:vm'
2+ import { BroadcastChannel } from 'node:worker_threads'
13import {
24 authMockFns ,
35 dbChainMockFns ,
46 mcpOauthMock ,
57 mcpOauthMockFns ,
68 resetDbChainMock ,
79} from '@sim/testing'
10+ import { flushMicrotasks } from '@sim/testing/helpers/async'
11+ import { emcnMock } from '@sim/testing/mocks/emcn.mock'
12+ import { getMockLogger } from '@sim/testing/mocks/logger.mock'
813import { mcpServiceMock , mcpServiceMockFns } from '@sim/testing/mocks/mcp-service.mock'
914import { NextRequest } from 'next/server'
1015import { beforeEach , describe , expect , it , vi } from 'vitest'
1116import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version'
17+ import { connectCredentialGroupInPopup } from '@/lib/credential-groups/oauth-popup'
1218
1319const {
1420 mockAuthenticateEnrollment,
@@ -24,6 +30,7 @@ const {
2430
2531vi . mock ( '@/lib/mcp/oauth' , ( ) => mcpOauthMock )
2632vi . mock ( '@/lib/mcp/service' , ( ) => mcpServiceMock )
33+ vi . mock ( '@sim/emcn' , ( ) => emcnMock )
2734vi . mock ( '@/lib/credential-groups/application/enrollment-auth' , ( ) => ( {
2835 credentialGroupOAuthAttemptPrincipal : mockAuthenticateEnrollment ,
2936} ) )
@@ -88,30 +95,41 @@ describe('MCP OAuth callback route', () => {
8895 mockEnforceCallbackRateLimit . mockResolvedValue ( null )
8996 } )
9097
91- it . each ( [ undefined , 'denied' ] ) (
92- 'finishes a direct connection without the invitation form: %s' ,
93- async ( error ) => {
94- const completionId = '00000000-0000-4000-8000-000000000002'
95- mockConsumeManagedAttempt . mockResolvedValueOnce ( {
96- state : 'mcp_cg_direct' ,
97- organizationId : 'organization-1' ,
98- invitationToken : 'invitation-token' ,
99- mcpServerId : 'server-1' ,
100- completionId,
101- returnTo : 'integrations' ,
102- } )
103- const response = await GET (
104- new NextRequest (
105- `http://localhost:3000/api/mcp/oauth/callback?state=mcp_cg_direct&${ error ? 'error=denied' : 'code=code-1' } `
106- )
98+ it . each ( [
99+ [ undefined , null ] ,
100+ [ 'access_denied' , 'denied' ] ,
101+ [ 'invalid_request' , 'failed' ] ,
102+ [ 'invalid_scope' , 'failed' ] ,
103+ [ 'server_error' , 'provider_unavailable' ] ,
104+ [ 'temporarily_unavailable' , 'provider_unavailable' ] ,
105+ [ 'unexpected-secret-value' , 'failed' ] ,
106+ ] ) ( 'finishes a direct connection without the invitation form: %s' , async ( error , expected ) => {
107+ const completionId = '00000000-0000-4000-8000-000000000002'
108+ mockConsumeManagedAttempt . mockResolvedValueOnce ( {
109+ state : 'mcp_cg_direct' ,
110+ organizationId : 'organization-1' ,
111+ invitationToken : 'invitation-token' ,
112+ mcpServerId : 'server-1' ,
113+ completionId,
114+ returnTo : 'integrations' ,
115+ } )
116+ const response = await GET (
117+ new NextRequest (
118+ `http://localhost:3000/api/mcp/oauth/callback?state=mcp_cg_direct&${ error ? `error=${ error } &error_description=private-provider-detail` : 'code=code-1' } `
107119 )
108- const destination = new URL ( response . headers . get ( 'location' ) ! , 'http://localhost:3000' )
109- expect ( destination . pathname ) . toBe ( '/credential-groups/complete' )
110- expect ( destination . searchParams . get ( 'completionId' ) ) . toBe ( completionId )
111- expect ( destination . searchParams . get ( 'organizationId' ) ) . toBe ( 'organization-1' )
112- expect ( destination . searchParams . get ( 'oauth' ) ) . toBe ( error ?? null )
120+ )
121+ const destination = new URL ( response . headers . get ( 'location' ) ! , 'http://localhost:3000' )
122+ expect ( destination . pathname ) . toBe ( '/credential-groups/complete' )
123+ expect ( destination . searchParams . get ( 'completionId' ) ) . toBe ( completionId )
124+ expect ( destination . searchParams . get ( 'organizationId' ) ) . toBe ( 'organization-1' )
125+ expect ( destination . searchParams . get ( 'oauth' ) ) . toBe ( expected )
126+ if ( error ) {
127+ const warnings = JSON . stringify ( getMockLogger ( 'McpOauthCallbackAPI' ) . warn . mock . calls )
128+ expect ( warnings ) . toContain ( error === 'unexpected-secret-value' ? 'unknown' : error )
129+ expect ( warnings ) . not . toContain ( 'unexpected-secret-value' )
130+ expect ( warnings ) . not . toContain ( 'private-provider-detail' )
113131 }
114- )
132+ } )
115133
116134 it ( 'performs the token exchange through the SSRF-guarded mcpAuthGuarded wrapper' , async ( ) => {
117135 const request = new NextRequest (
@@ -185,4 +203,78 @@ describe('MCP OAuth callback route', () => {
185203 expect ( mockAuthenticateEnrollment ) . not . toHaveBeenCalled ( )
186204 expect ( mockCompleteManagedMcpOAuth ) . not . toHaveBeenCalled ( )
187205 } )
206+
207+ it . each ( [ 'missing' , 'version' ] ) (
208+ 'delivers a %s managed-state failure to only the initiating popup' ,
209+ async ( kind ) => {
210+ vi . useFakeTimers ( { toFake : [ 'setTimeout' , 'clearTimeout' ] } )
211+ vi . stubGlobal ( 'BroadcastChannel' , BroadcastChannel )
212+ vi . stubGlobal ( 'window' , {
213+ open : ( ) => ( { closed : false , opener : null , location : { replace ( ) { } } , close ( ) { } } ) ,
214+ } )
215+ const controller = new AbortController ( )
216+ const completionId = '00000000-0000-4000-8000-000000000010'
217+ const secondId = '00000000-0000-4000-8000-000000000011'
218+ const state = 'mcp_cg_first'
219+ let first : string | undefined
220+ let second : string | undefined
221+ const start = ( nonce : string ) => async ( ) => ( {
222+ invitationLink : 'http://localhost:3000/credential-groups/enroll/fixture' ,
223+ authorizationUrl : `https://oauth.example.com/authorize?state=${ nonce } ` ,
224+ } )
225+ const outcomes = [
226+ connectCredentialGroupInPopup ( completionId , start ( state ) , controller . signal ) . then (
227+ ( ) => {
228+ first = 'connected'
229+ } ,
230+ ( error : Error ) => {
231+ first = error . message
232+ }
233+ ) ,
234+ connectCredentialGroupInPopup ( secondId , start ( 'mcp_cg_second' ) , controller . signal ) . then (
235+ ( ) => {
236+ second = 'connected'
237+ } ,
238+ ( error : Error ) => {
239+ second = error . message
240+ }
241+ ) ,
242+ ]
243+ const observer = new BroadcastChannel ( 'mcp-oauth' )
244+ const success = new BroadcastChannel ( `sim:credential-group-oauth:${ secondId } ` )
245+ try {
246+ await flushMicrotasks ( )
247+ if ( kind === 'missing' ) mockConsumeManagedAttempt . mockResolvedValueOnce ( null )
248+ else
249+ mockConsumeManagedAttempt . mockRejectedValueOnce (
250+ new CredentialGroupOAuthStateVersionError ( )
251+ )
252+ const response = await GET (
253+ new NextRequest ( `http://localhost:3000/api/mcp/oauth/callback?state=${ state } &code=unused` )
254+ )
255+ const body = await response . text ( )
256+ const script = body . match ( / < s c r i p t > ( [ \s \S ] * ?) < \/ s c r i p t > / ) ?. [ 1 ]
257+ if ( ! script ) throw new Error ( 'Callback did not provide its popup handoff' )
258+ const delivered = new Promise < void > ( ( resolve ) => {
259+ observer . onmessage = ( ) => resolve ( )
260+ } )
261+ runInNewContext ( script , { BroadcastChannel, setTimeout ( ) { } } )
262+ await delivered
263+ await vi . waitFor ( ( ) =>
264+ expect ( first ) . toBe ( 'This connection attempt expired. Try connecting your account again.' )
265+ )
266+ expect ( second ) . toBeUndefined ( )
267+ expect ( mockCompleteManagedMcpOAuth ) . not . toHaveBeenCalled ( )
268+ success . postMessage ( 'connected' )
269+ await outcomes [ 1 ]
270+ expect ( second ) . toBe ( 'connected' )
271+ } finally {
272+ controller . abort ( )
273+ observer . close ( )
274+ success . close ( )
275+ await Promise . all ( outcomes )
276+ vi . useRealTimers ( )
277+ }
278+ }
279+ )
188280} )
0 commit comments