diff --git a/lib/PortingEmbed/__tests__/index.test.tsx b/lib/PortingEmbed/__tests__/index.test.tsx index 1eb1cab..36c54f5 100644 --- a/lib/PortingEmbed/__tests__/index.test.tsx +++ b/lib/PortingEmbed/__tests__/index.test.tsx @@ -95,10 +95,10 @@ describe('initialization', () => { }) it('throws with the wrong ConnectSession', async () => { - expect(PortingEmbed(null, { project })).rejects.toThrow(/WRONG_SESSION/i) - expect(PortingEmbed({}, { project })).rejects.toThrow(/WRONG_SESSION/i) + expect(PortingEmbed(null, { project })).rejects.toThrow(/INVALID_SESSION/) + expect(PortingEmbed({}, { project })).rejects.toThrow(/INVALID_SESSION/) expect(PortingEmbed({ secret: 'foo' }, { project })).rejects.toThrow( - /WRONG_SESSION/i, + /INVALID_SESSION/, ) }) @@ -108,7 +108,7 @@ describe('initialization', () => { .params({ intent: { type: 'foo' } }) .build() const init = PortingEmbed(csn, { project }) - expect(init).rejects.toThrow(/WRONG_INTENT/) + expect(init).rejects.toThrow(/INVALID_SESSION/) }) it('throws with a non-existing subscription', async () => { diff --git a/lib/PortingEmbed/index.tsx b/lib/PortingEmbed/index.tsx index ff5f042..5bee3af 100644 --- a/lib/PortingEmbed/index.tsx +++ b/lib/PortingEmbed/index.tsx @@ -4,7 +4,7 @@ import { render } from 'preact' import { assert } from '../core/assert' import { fetchSubscription } from '../core/subscription' import { exchangeSessionWithToken } from '../core/token' -import { ConnectSession, PortingStatus } from '../types' +import { PortingStatus } from '../types' import { CustomizableEmbedProps, PortingEmbed as PortingEmbedComponent, @@ -28,30 +28,11 @@ export async function PortingEmbed( 'NO_PROJECT: Cannot initialize PortingEmbed without a project.', ) - // Ensure a valid ConnectSession object. - // The initConnectSession argument passed into this function is intentionally - // not typed as a ConnectSession. It's more convenient in combination with - // fetch() which is also not typed. Otherwise a developer would need to cast - // the response just to be able to initialize the embed. - assert( - initConnectSession && - typeof initConnectSession === 'object' && - 'object' in initConnectSession && - 'intent' in initConnectSession && - 'url' in initConnectSession && - initConnectSession.object === 'connectSession', - 'WRONG_SESSION: The object you passed in is not a ConnectSession resoure. Make sure to pass in the complete resource.', + // Ensure that the ConnectSession is valid and obtain a token. + const { connectSession, token } = await exchangeSessionWithToken( + initConnectSession, + 'completePorting', ) - const csn = initConnectSession as ConnectSession - const { intent } = csn - - assert( - intent.type === 'completePorting', - `WRONG_INTENT: PortingEmbed must be initialized with the "completePorting" intent, but got "${intent.type}" instead.`, - ) - - // Obtain a user token and ensure that the ConnectSession is valid. - const token = await exchangeSessionWithToken(csn) let element: Element | null = null let options = initialOptions @@ -60,7 +41,7 @@ export async function PortingEmbed( // Fetch the necessary data before the embed can be mounted. While the embed // is loading, the embedder can show their own loading state. const subscription = await fetchSubscription( - intent.completePorting.subscription, + connectSession.intent.completePorting.subscription, { project, token }, ) const { porting } = subscription diff --git a/lib/core/__tests__/token.test.ts b/lib/core/__tests__/token.test.ts index 02e472b..5e50049 100644 --- a/lib/core/__tests__/token.test.ts +++ b/lib/core/__tests__/token.test.ts @@ -3,28 +3,75 @@ import { connectSessionFactory } from '@/testing/factories/connectSession' import { exchangeSessionWithToken } from '../token' it('exchanges an authenticated session with a user token', async () => { - const csn = connectSessionFactory.transient({ token: 'secret_sauce' }).build() - const token = await exchangeSessionWithToken(csn) + const csn = connectSessionFactory + .params({ intent: { type: 'completePorting' } }) + .transient({ token: 'secret_sauce' }) + .build() + const { token } = await exchangeSessionWithToken(csn, 'completePorting') expect(token).toBe('exchanged:secret_sauce') }) +it('returns the validated connectSession object', async () => { + const csn = connectSessionFactory + .params({ intent: { type: 'completePorting' } }) + .build() + const { connectSession } = await exchangeSessionWithToken( + csn, + 'completePorting', + ) + expect(connectSession).toEqual(csn) +}) + +it('throws with an invalid connectSession object', () => { + expect(exchangeSessionWithToken({}, 'completePorting')).rejects.toThrow( + /INVALID_SESSION: The object you passed in/, + ) + expect(exchangeSessionWithToken(null, 'completePorting')).rejects.toThrow( + /INVALID_SESSION: The object you passed in/, + ) + expect( + exchangeSessionWithToken(undefined, 'completePorting'), + ).rejects.toThrow(/INVALID_SESSION: The object you passed in/) + expect(exchangeSessionWithToken('secret', 'completePorting')).rejects.toThrow( + /INVALID_SESSION: The object you passed in/, + ) +}) + +it('throws with the wrong intent type', () => { + const csn = connectSessionFactory + // @ts-expect-error Assume the intent type is not supported + .params({ intent: { type: 'somethingElse' } }) + .build() + expect(exchangeSessionWithToken(csn, 'completePorting')).rejects.toThrow( + /INVALID_SESSION: PortingEmbed must be initialized with the "completePorting" intent/, + ) +}) + it('throws with an unauthenticated session', async () => { - const csn = connectSessionFactory.unauthenticated().build() - expect(exchangeSessionWithToken(csn)).rejects.toThrow( + const csn = connectSessionFactory + .params({ intent: { type: 'completePorting' } }) + .unauthenticated() + .build() + expect(exchangeSessionWithToken(csn, 'completePorting')).rejects.toThrow( /INVALID_SESSION: Session has no token./, ) }) it('throws without a url in the session', async () => { - const csn = connectSessionFactory.params({ url: null }).build() - expect(exchangeSessionWithToken(csn)).rejects.toThrow( + const csn = connectSessionFactory + .params({ url: null, intent: { type: 'completePorting' } }) + .build() + expect(exchangeSessionWithToken(csn, 'completePorting')).rejects.toThrow( /INVALID_SESSION: Session has no URL/, ) }) it('throws with an expired session', async () => { - const csn = connectSessionFactory.transient({ token: 'expired' }).build() - expect(exchangeSessionWithToken(csn)).rejects.toThrow( + const csn = connectSessionFactory + .params({ intent: { type: 'completePorting' } }) + .transient({ token: 'expired' }) + .build() + expect(exchangeSessionWithToken(csn, 'completePorting')).rejects.toThrow( /INVALID_SESSION: Session is expired/, ) }) diff --git a/lib/core/token.ts b/lib/core/token.ts index 4a1a28b..a21273b 100644 --- a/lib/core/token.ts +++ b/lib/core/token.ts @@ -1,4 +1,4 @@ -import { ConnectSession } from '../types' +import { ConnectSession, ConnectSessionIntent } from '../types' import { assert } from './assert' type Tokens = { @@ -7,18 +7,14 @@ type Tokens = { /** * Exchange an authenticated ConnectSession with a user token. + * Returns the token, together with the validated ConnectSession for the sake + * of easier type-safety. */ -export async function exchangeSessionWithToken(session: ConnectSession) { - assert( - session.url, - 'INVALID_SESSION: Session has no URL. Did you pass in the created session?', - ) - const url = new URL(session.url) - const token = url.searchParams.get('token') - assert( - token, - 'INVALID_SESSION: Session has no token. Is it an authenticated session?', - ) +export async function exchangeSessionWithToken( + session: unknown, + type: ConnectSessionIntent['type'], +) { + const { connectSession, token } = parseConnectSession(session, type) const res = await fetch(`https://connect.gigs.com/api/embeds/auth`, { method: 'POST', @@ -35,5 +31,46 @@ export async function exchangeSessionWithToken(session: ConnectSession) { 'Expected user token to be returned in response of token exchange, but was not found.', ) - return body.token.access_token + return { connectSession, token: body.token.access_token } +} + +function parseConnectSession( + session: unknown, + type: ConnectSessionIntent['type'], +) { + // The session which is used to initilialize an embed is intentionally typed + // as unknown. + // 1. For non-typescript usage, we need to ensure that the passed in object is + // correct anyways. + // 2. To not require the caller to cast the response as a ConnectSession. + // Especially since fetch returns any. Makes it a little easier to use. + + assert( + session && + typeof session === 'object' && + 'object' in session && + 'intent' in session && + 'url' in session && + session.object === 'connectSession', + 'INVALID_SESSION: The object you passed in is not a ConnectSession resoure. Make sure to pass in the complete resource.', + ) + const connectSession = session as ConnectSession + + assert( + connectSession.intent.type === type, + `INVALID_SESSION: PortingEmbed must be initialized with the "${type}" intent, but got "${connectSession.intent.type}" instead.`, + ) + assert( + connectSession.url, + 'INVALID_SESSION: Session has no URL. Did you pass in the created session?', + ) + + const url = new URL(connectSession.url) + const token = url.searchParams.get('token') + assert( + token, + 'INVALID_SESSION: Session has no token. Is it an authenticated session?', + ) + + return { connectSession, token } }