11import { createHash } from 'node:crypto'
22import { createServer } from 'node:http'
33import { db } from '@sim/db'
4- import { member , organization , user } from '@sim/db/schema'
4+ import { credential , credentialGroupEnrollment , member , organization , user } from '@sim/db/schema'
55import { readTestRedisUrl } from '@sim/db/testing/test-infrastructure'
66import { createSessionPrincipal } from '@sim/testing/factories/principal.factory'
77import { generateId } from '@sim/utils/id'
88import { toRecord } from '@sim/utils/object'
9- import { inArray } from 'drizzle-orm'
9+ import { eq , inArray } from 'drizzle-orm'
1010import { afterAll , beforeAll , describe , expect , it , vi } from 'vitest'
1111import { env } from '@/lib/core/config/env'
1212import {
1313 resolveCurrentOutboundRoute ,
1414 runWithOutboundOrganization ,
1515} from '@/lib/core/network/context.server'
1616import { startOrganizationAccountConnection } from '@/lib/credential-groups/application/organization-accounts'
17+ import { reconnectPersonalOrganizationAccount } from '@/lib/credential-groups/application/personal-organization-accounts'
1718import { createManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-service'
1819import { consumeCredentialGroupMcpOAuthAttempt } from '@/lib/credential-groups/mcp-oauth-state'
20+ import { createViewerCredentialGroupEnrollment } from '@/lib/credential-groups/self-enrollment'
1921import { ensureWorkspaceAccountsGroup } from '@/lib/credential-groups/service'
22+ import { encryptManagedMcpTokens } from '@/lib/credentials/managed-mcp'
2023import * as oauth from '@/lib/mcp/oauth/auth'
2124import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'
2225
@@ -28,6 +31,7 @@ const outsider = generateId()
2831const directOrg = generateId ( )
2932const blockedOrg = generateId ( )
3033const servers = new Map < string , string > ( )
34+ const grants = new Map < string , { credentialId : string ; enrollmentId : string } > ( )
3135const requests : string [ ] = [ ]
3236
3337/** Real OAuth discovery and registration over a socket; only the remote destination is replaced. */
@@ -122,6 +126,28 @@ beforeAll(async () => {
122126 )
123127 )
124128 servers . set ( organizationId , mcpServer . id )
129+ const { enrollment } = await createViewerCredentialGroupEnrollment ( {
130+ organizationId,
131+ credentialGroupId : group . id ,
132+ userId,
133+ } )
134+ const credentialId = `mcp-cg-${ generateId ( ) } `
135+ await db . insert ( credential ) . values ( {
136+ id : credentialId ,
137+ organizationId,
138+ type : 'managed_mcp' ,
139+ displayName : 'OAuth fixture' ,
140+ grantedAt : new Date ( ) ,
141+ credentialGroupEnrollmentId : enrollment . id ,
142+ mcpServerId : mcpServer . id ,
143+ managedOauthStatus : 'active' ,
144+ mcpTools : [ ] ,
145+ encryptedOauthTokenSet : await encryptManagedMcpTokens ( {
146+ access_token : 'fixture-access' ,
147+ token_type : 'Bearer' ,
148+ } ) ,
149+ } )
150+ grants . set ( organizationId , { credentialId, enrollmentId : enrollment . id } )
125151 }
126152 await new Promise < void > ( ( resolve ) => providerServer . listen ( 0 , '127.0.0.1' , resolve ) )
127153 const address = providerServer . address ( )
@@ -169,8 +195,19 @@ function connect(
169195 } )
170196}
171197
172- async function verifyAuthorization ( ) {
173- const result = await connect ( directOrg )
198+ function reconnect ( organizationId : string , userId = owner ) {
199+ return reconnectPersonalOrganizationAccount . execute ( {
200+ principal : createSessionPrincipal ( { userId, sessionId : generateId ( ) } ) ,
201+ input : {
202+ credentialId : grants . get ( organizationId ) ! . credentialId ,
203+ oauthCompletionId : generateId ( ) ,
204+ } ,
205+ } )
206+ }
207+
208+ async function verifyAuthorization ( start : typeof connect | typeof reconnect ) {
209+ const result = await start ( directOrg )
210+ if ( ! result . authorizationUrl ) throw new Error ( 'OAuth authorization URL is missing' )
174211 const authorization = new URL ( result . authorizationUrl )
175212 expect ( `${ authorization . origin } ${ authorization . pathname } ` ) . toBe ( `${ ISSUER } /authorize` )
176213 expect ( authorization . searchParams . get ( 'client_id' ) ) . toBe ( 'fixture-dynamic-client' )
@@ -189,27 +226,33 @@ async function verifyAuthorization() {
189226 expect ( await consumeCredentialGroupMcpOAuthAttempt ( state ) ) . toBeNull ( )
190227}
191228
192- describe ( 'Organization account OAuth outbound ownership' , ( ) => {
229+ describe . each ( [
230+ { name : 'Connect' , start : connect } ,
231+ { name : 'Reconnect' , start : reconnect } ,
232+ ] ) ( '$name account OAuth outbound ownership' , ( { start } ) => {
193233 it ( 'starts dynamic OAuth with a bound single-use attempt when no ambient scope exists' , async ( ) => {
194- await verifyAuthorization ( )
234+ await verifyAuthorization ( start )
195235 await expect ( resolveCurrentOutboundRoute ( ) ) . rejects . toMatchObject ( { code : 'MISSING_SCOPE' } )
196236 } )
197237 it ( 'uses authorized ownership instead of an ambient blocked organization and restores the outer scope' , async ( ) => {
198238 await runWithOutboundOrganization ( blockedOrg , async ( ) => {
199- await verifyAuthorization ( )
239+ await verifyAuthorization ( start )
200240 await expect ( resolveCurrentOutboundRoute ( ) ) . rejects . toMatchObject ( { code : 'ROUTE_BLOCKED' } )
201241 } )
202242 } )
203243 it ( 'does not bypass an organization block through ambient platform scope' , async ( ) => {
204244 const before = requests . length
205245 await runWithOutboundOrganization ( null , async ( ) => {
206- await expect ( connect ( blockedOrg , blockedOwner ) ) . rejects . toMatchObject ( {
246+ await expect ( start ( blockedOrg , blockedOwner ) ) . rejects . toMatchObject ( {
207247 code : 'ROUTE_BLOCKED' ,
208248 } )
209249 expect ( await resolveCurrentOutboundRoute ( ) ) . toEqual ( { kind : 'direct' } )
210250 } )
211251 expect ( requests . length ) . toBe ( before )
212252 } )
253+ } )
254+
255+ describe ( 'Account authorization before outbound OAuth' , ( ) => {
213256 it ( 'denies nonmembers and cross-organization providers before contacting OAuth' , async ( ) => {
214257 const before = requests . length
215258 await expect ( connect ( directOrg , outsider ) ) . rejects . toMatchObject ( { code : 'not_found' } )
@@ -218,4 +261,15 @@ describe('Organization account OAuth outbound ownership', () => {
218261 } )
219262 expect ( requests . length ) . toBe ( before )
220263 } )
264+ it ( 'denies another contributor and a revoked enrollment during reconnect' , async ( ) => {
265+ const before = requests . length
266+ await expect ( reconnect ( directOrg , outsider ) ) . rejects . toMatchObject ( { code : 'not_found' } )
267+ const enrollmentId = grants . get ( directOrg ) ! . enrollmentId
268+ await db
269+ . update ( credentialGroupEnrollment )
270+ . set ( { status : 'revoked' } )
271+ . where ( eq ( credentialGroupEnrollment . id , enrollmentId ) )
272+ await expect ( reconnect ( directOrg ) ) . rejects . toMatchObject ( { code : 'forbidden' } )
273+ expect ( requests . length ) . toBe ( before )
274+ } )
221275} )
0 commit comments