11import { describe , expect , it , vi } from 'vitest'
22import { createConnectorAdapterProvider } from '../adapter-provider.js'
33import { IntegrationError } from '../index.js'
4- import type { ConnectorAdapter } from '../connectors/types.js'
4+ import type { ConnectorAdapter , TokenMetadataSource } from '../connectors/types.js'
55
66const OWNER = { type : 'user' as const , id : 'user_42' }
77const REDIRECT = 'https://app.example/oauth/callback'
88
9- function oauthAdapter ( ) : ConnectorAdapter {
9+ function oauthAdapter ( tokenMetadata ?: Record < string , TokenMetadataSource > ) : ConnectorAdapter {
1010 return {
1111 manifest : {
1212 kind : 'demo-oauth' ,
@@ -20,6 +20,7 @@ function oauthAdapter(): ConnectorAdapter {
2020 clientIdEnv : 'DEMO_CLIENT_ID' ,
2121 clientSecretEnv : 'DEMO_CLIENT_SECRET' ,
2222 extraAuthParams : { access_type : 'offline' } ,
23+ ...( tokenMetadata ? { tokenMetadata } : { } ) ,
2324 } ,
2425 capabilities : [ ] ,
2526 defaultConsistencyModel : 'authoritative' ,
@@ -223,4 +224,150 @@ describe('createConnectorAdapterProvider OAuth flow', () => {
223224 } ) ,
224225 ) . rejects . toMatchObject ( { code : 'provider_failure' } )
225226 } )
227+
228+ it ( 'completeAuth captures declared tokenMetadata fields (string + object form), merging with — and overriding same-key — request.metadata' , async ( ) => {
229+ const fetchImpl = vi . fn ( async ( ) =>
230+ tokenResponse ( {
231+ access_token : 'acc_xyz' ,
232+ token_type : 'Bearer' ,
233+ // Provider-specific fields the standard parser would otherwise drop.
234+ api_base_url_for_customer : 'https://company-17.api.gong.io' ,
235+ instance_url : 'https://eu.example.com' ,
236+ } ) ,
237+ ) as unknown as typeof fetch
238+
239+ const provider = createConnectorAdapterProvider ( {
240+ adapters : [
241+ oauthAdapter ( {
242+ // object form (required) + string shorthand
243+ apiBaseUrlForCustomer : { field : 'api_base_url_for_customer' , required : true } ,
244+ instanceUrl : 'instance_url' ,
245+ } ) ,
246+ ] ,
247+ resolveDataSource : ( ) => ( { kind : 'demo-oauth' , id : 'ds_demo' } ) as never ,
248+ resolveOAuthClient : ( ) => ( { clientId : 'cid_live' , clientSecret : 'sec_live' } ) ,
249+ fetchImpl,
250+ } )
251+
252+ const conn = await provider . completeAuth ! ( {
253+ connectorId : 'demo-oauth' ,
254+ owner : OWNER ,
255+ code : 'the_code' ,
256+ state : 'state_xyz' ,
257+ redirectUri : REDIRECT ,
258+ // `tenant` is a non-colliding key → preserved (merge, not replace).
259+ // `apiBaseUrlForCustomer` collides → the token-exchange value is
260+ // authoritative and MUST win over the stale request.metadata value.
261+ metadata : { tenant : 'acme' , apiBaseUrlForCustomer : 'https://stale.example' } ,
262+ } )
263+
264+ expect ( conn . metadata ) . toEqual ( {
265+ tenant : 'acme' ,
266+ apiBaseUrlForCustomer : 'https://company-17.api.gong.io' ,
267+ instanceUrl : 'https://eu.example.com' ,
268+ } )
269+ } )
270+
271+ it ( 'completeAuth omits a non-required tokenMetadata field that is absent (capture-if-present)' , async ( ) => {
272+ const fetchImpl = vi . fn ( async ( ) =>
273+ tokenResponse ( { access_token : 'acc_xyz' , token_type : 'Bearer' } ) ,
274+ ) as unknown as typeof fetch
275+
276+ const provider = createConnectorAdapterProvider ( {
277+ adapters : [ oauthAdapter ( { instanceUrl : 'instance_url' } ) ] ,
278+ resolveDataSource : ( ) => ( { kind : 'demo-oauth' , id : 'ds_demo' } ) as never ,
279+ resolveOAuthClient : ( ) => ( { clientId : 'cid_live' , clientSecret : 'sec_live' } ) ,
280+ fetchImpl,
281+ } )
282+
283+ const conn = await provider . completeAuth ! ( {
284+ connectorId : 'demo-oauth' ,
285+ owner : OWNER ,
286+ code : 'the_code' ,
287+ state : 'state_xyz' ,
288+ redirectUri : REDIRECT ,
289+ } )
290+
291+ expect ( conn . metadata ) . toEqual ( { } )
292+ expect ( 'instanceUrl' in ( conn . metadata ?? { } ) ) . toBe ( false )
293+ } )
294+
295+ it ( 'completeAuth fails loud (provider_failure) when a required tokenMetadata field is absent' , async ( ) => {
296+ const fetchImpl = vi . fn ( async ( ) =>
297+ tokenResponse ( { access_token : 'acc_xyz' , token_type : 'Bearer' } ) ,
298+ ) as unknown as typeof fetch
299+
300+ const provider = createConnectorAdapterProvider ( {
301+ adapters : [ oauthAdapter ( { apiBaseUrlForCustomer : { field : 'api_base_url_for_customer' , required : true } } ) ] ,
302+ resolveDataSource : ( ) => ( { kind : 'demo-oauth' , id : 'ds_demo' } ) as never ,
303+ resolveOAuthClient : ( ) => ( { clientId : 'cid_live' , clientSecret : 'sec_live' } ) ,
304+ fetchImpl,
305+ } )
306+
307+ let caught : unknown
308+ try {
309+ await provider . completeAuth ! ( {
310+ connectorId : 'demo-oauth' ,
311+ owner : OWNER ,
312+ code : 'the_code' ,
313+ state : 'state_xyz' ,
314+ redirectUri : REDIRECT ,
315+ } )
316+ } catch ( e ) {
317+ caught = e
318+ }
319+ expect ( caught ) . toBeInstanceOf ( IntegrationError )
320+ expect ( ( caught as IntegrationError ) . code ) . toBe ( 'provider_failure' )
321+ expect ( ( caught as Error ) . message ) . toMatch ( / a p i _ b a s e _ u r l _ f o r _ c u s t o m e r / )
322+ } )
323+
324+ it ( 'completeAuth treats a present-but-empty required tokenMetadata field as absent and fails loud' , async ( ) => {
325+ // Locks the `=== ''` (post-trim) emptiness guard against a regression to a
326+ // null-only check that would mint an active connection whose every call 404s.
327+ for ( const emptyish of [ '' , ' ' , '\n\t' ] ) {
328+ const fetchImpl = vi . fn ( async ( ) =>
329+ tokenResponse ( { access_token : 'acc_xyz' , token_type : 'Bearer' , api_base_url_for_customer : emptyish } ) ,
330+ ) as unknown as typeof fetch
331+
332+ const provider = createConnectorAdapterProvider ( {
333+ adapters : [ oauthAdapter ( { apiBaseUrlForCustomer : { field : 'api_base_url_for_customer' , required : true } } ) ] ,
334+ resolveDataSource : ( ) => ( { kind : 'demo-oauth' , id : 'ds_demo' } ) as never ,
335+ resolveOAuthClient : ( ) => ( { clientId : 'cid_live' , clientSecret : 'sec_live' } ) ,
336+ fetchImpl,
337+ } )
338+
339+ await expect (
340+ provider . completeAuth ! ( {
341+ connectorId : 'demo-oauth' ,
342+ owner : OWNER ,
343+ code : 'the_code' ,
344+ state : 'state_xyz' ,
345+ redirectUri : REDIRECT ,
346+ } ) ,
347+ ) . rejects . toMatchObject ( { code : 'provider_failure' } )
348+ }
349+ } )
350+
351+ it ( 'completeAuth omits a non-required tokenMetadata field that is present but empty/whitespace' , async ( ) => {
352+ const fetchImpl = vi . fn ( async ( ) =>
353+ tokenResponse ( { access_token : 'acc_xyz' , token_type : 'Bearer' , instance_url : ' ' } ) ,
354+ ) as unknown as typeof fetch
355+
356+ const provider = createConnectorAdapterProvider ( {
357+ adapters : [ oauthAdapter ( { instanceUrl : 'instance_url' } ) ] ,
358+ resolveDataSource : ( ) => ( { kind : 'demo-oauth' , id : 'ds_demo' } ) as never ,
359+ resolveOAuthClient : ( ) => ( { clientId : 'cid_live' , clientSecret : 'sec_live' } ) ,
360+ fetchImpl,
361+ } )
362+
363+ const conn = await provider . completeAuth ! ( {
364+ connectorId : 'demo-oauth' ,
365+ owner : OWNER ,
366+ code : 'the_code' ,
367+ state : 'state_xyz' ,
368+ redirectUri : REDIRECT ,
369+ } )
370+
371+ expect ( 'instanceUrl' in ( conn . metadata ?? { } ) ) . toBe ( false )
372+ } )
226373} )
0 commit comments