From 2feab5725bc5004db66692eb01a73260e65746c4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 23 Sep 2026 15:07:37 +0000 Subject: [PATCH] [AI-9623] Implement SDK host-side soft-mint embed origin for Angie Applications - Add embed token minting module (embed-token.ts) with caching and reminting logic - Mint embed session token before opening iframe when appId is provided - Send minted token to iframe via postMessage on ANGIE_READY event - Handle embed token refresh requests from iframe (ANGIE_EMBED_TOKEN_REQUEST) - Add new MessageEventType enums: ANGIE_EMBED_TOKEN_SET and ANGIE_EMBED_TOKEN_REQUEST - Integrate with elementor-ai API endpoint: POST /angie/embed/session - Implement token validation with 60s skew window before expiry - Coalesce concurrent mint requests for same appId - Add comprehensive unit tests for mint, cache, remint, and postMessage flows - Use logger instead of console for error/warning messages - Gracefully handle mint failures (log and continue without token) Co-authored-by: naftalis --- src/embed-token.test.ts | 286 ++++++++++++++++++++ src/embed-token.ts | 109 ++++++++ src/load-sidebar-v2/host-api-bridge.test.ts | 169 +++++++++++- src/load-sidebar-v2/host-api-bridge.ts | 37 ++- src/openSaaSPage.test.ts | 173 +++++++++++- src/openSaaSPage.ts | 23 +- src/types.ts | 6 + 7 files changed, 799 insertions(+), 4 deletions(-) create mode 100644 src/embed-token.test.ts create mode 100644 src/embed-token.ts diff --git a/src/embed-token.test.ts b/src/embed-token.test.ts new file mode 100644 index 0000000..f971cf4 --- /dev/null +++ b/src/embed-token.test.ts @@ -0,0 +1,286 @@ +import { + mintEmbedSession, + ensureEmbedToken, + clearEmbedTokenCache, + getEmbedApiBaseUrl, + MINT_SKEW_SECONDS, +} from './embed-token'; + +describe( 'embed-token', () => { + const mockAppId = 'NG-test-app-123'; + const mockIframeOrigin = 'https://angie.elementor.com'; + const mockEmbedToken = 'mock-embed-token-jwt'; + const nowSeconds = Math.floor( Date.now() / 1000 ); + const mockExp = nowSeconds + 300; + + beforeEach( () => { + clearEmbedTokenCache(); + jest.clearAllMocks(); + global.fetch = jest.fn(); + } ); + + afterEach( () => { + jest.restoreAllMocks(); + } ); + + describe( 'getEmbedApiBaseUrl', () => { + it( 'should return the iframe origin as the base URL', () => { + expect( getEmbedApiBaseUrl( mockIframeOrigin ) ).toBe( mockIframeOrigin ); + } ); + } ); + + describe( 'mintEmbedSession', () => { + it( 'should successfully mint an embed session', async () => { + ( global.fetch as jest.Mock ).mockResolvedValue( { + ok: true, + json: async () => ( { + embedToken: mockEmbedToken, + exp: mockExp, + } ), + } ); + + const result = await mintEmbedSession( mockAppId, mockIframeOrigin ); + + expect( result ).toEqual( { + embedToken: mockEmbedToken, + exp: mockExp, + } ); + + expect( global.fetch ).toHaveBeenCalledWith( + `${ mockIframeOrigin }/angie/embed/session`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify( { appId: mockAppId } ), + credentials: 'include', + } + ); + } ); + + it( 'should throw an error when the response is not ok', async () => { + ( global.fetch as jest.Mock ).mockResolvedValue( { + ok: false, + status: 403, + text: async () => 'Forbidden', + } ); + + await expect( mintEmbedSession( mockAppId, mockIframeOrigin ) ) + .rejects.toThrow( 'Embed session mint failed with status 403: Forbidden' ); + } ); + + it( 'should throw an error when the response is missing embedToken', async () => { + ( global.fetch as jest.Mock ).mockResolvedValue( { + ok: true, + json: async () => ( { + exp: mockExp, + } ), + } ); + + await expect( mintEmbedSession( mockAppId, mockIframeOrigin ) ) + .rejects.toThrow( 'Invalid embed session response: missing embedToken or exp' ); + } ); + + it( 'should throw an error when the response is missing exp', async () => { + ( global.fetch as jest.Mock ).mockResolvedValue( { + ok: true, + json: async () => ( { + embedToken: mockEmbedToken, + } ), + } ); + + await expect( mintEmbedSession( mockAppId, mockIframeOrigin ) ) + .rejects.toThrow( 'Invalid embed session response: missing embedToken or exp' ); + } ); + + it( 'should handle fetch network errors', async () => { + ( global.fetch as jest.Mock ).mockRejectedValue( new Error( 'Network error' ) ); + + await expect( mintEmbedSession( mockAppId, mockIframeOrigin ) ) + .rejects.toThrow( 'Embed session mint error: Network error' ); + } ); + } ); + + describe( 'ensureEmbedToken', () => { + it( 'should mint a new token when cache is empty', async () => { + ( global.fetch as jest.Mock ).mockResolvedValue( { + ok: true, + json: async () => ( { + embedToken: mockEmbedToken, + exp: mockExp, + } ), + } ); + + const result = await ensureEmbedToken( mockAppId, mockIframeOrigin ); + + expect( result ).toEqual( { + embedToken: mockEmbedToken, + exp: mockExp, + } ); + + expect( global.fetch ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'should return cached token when it is still valid', async () => { + ( global.fetch as jest.Mock ).mockResolvedValue( { + ok: true, + json: async () => ( { + embedToken: mockEmbedToken, + exp: mockExp, + } ), + } ); + + const firstResult = await ensureEmbedToken( mockAppId, mockIframeOrigin ); + const secondResult = await ensureEmbedToken( mockAppId, mockIframeOrigin ); + + expect( firstResult ).toEqual( secondResult ); + expect( global.fetch ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'should remint when cached token is expired', async () => { + const expiredExp = nowSeconds - 100; + const newExp = nowSeconds + 300; + + let callCount = 0; + ( global.fetch as jest.Mock ).mockImplementation( async () => { + callCount++; + return { + ok: true, + json: async () => ( { + embedToken: callCount === 1 ? 'old-token' : 'new-token', + exp: callCount === 1 ? expiredExp : newExp, + } ), + }; + } ); + + const firstResult = await ensureEmbedToken( mockAppId, mockIframeOrigin ); + expect( firstResult.embedToken ).toBe( 'old-token' ); + + const secondResult = await ensureEmbedToken( mockAppId, mockIframeOrigin ); + expect( secondResult.embedToken ).toBe( 'new-token' ); + + expect( global.fetch ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'should remint when cached token is within skew window', async () => { + const nearExpiryExp = nowSeconds + MINT_SKEW_SECONDS - 10; + const newExp = nowSeconds + 300; + + let callCount = 0; + ( global.fetch as jest.Mock ).mockImplementation( async () => { + callCount++; + return { + ok: true, + json: async () => ( { + embedToken: callCount === 1 ? 'near-expiry-token' : 'fresh-token', + exp: callCount === 1 ? nearExpiryExp : newExp, + } ), + }; + } ); + + const firstResult = await ensureEmbedToken( mockAppId, mockIframeOrigin ); + expect( firstResult.embedToken ).toBe( 'near-expiry-token' ); + + const secondResult = await ensureEmbedToken( mockAppId, mockIframeOrigin ); + expect( secondResult.embedToken ).toBe( 'fresh-token' ); + + expect( global.fetch ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'should coalesce concurrent mint requests', async () => { + ( global.fetch as jest.Mock ).mockImplementation( async () => { + await new Promise( ( resolve ) => setTimeout( resolve, 50 ) ); + return { + ok: true, + json: async () => ( { + embedToken: mockEmbedToken, + exp: mockExp, + } ), + }; + } ); + + const [ result1, result2, result3 ] = await Promise.all( [ + ensureEmbedToken( mockAppId, mockIframeOrigin ), + ensureEmbedToken( mockAppId, mockIframeOrigin ), + ensureEmbedToken( mockAppId, mockIframeOrigin ), + ] ); + + expect( result1 ).toEqual( result2 ); + expect( result2 ).toEqual( result3 ); + expect( global.fetch ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'should clear in-flight promise on error', async () => { + ( global.fetch as jest.Mock ) + .mockRejectedValueOnce( new Error( 'Network error' ) ) + .mockResolvedValueOnce( { + ok: true, + json: async () => ( { + embedToken: mockEmbedToken, + exp: mockExp, + } ), + } ); + + await expect( ensureEmbedToken( mockAppId, mockIframeOrigin ) ) + .rejects.toThrow( 'Network error' ); + + const result = await ensureEmbedToken( mockAppId, mockIframeOrigin ); + expect( result.embedToken ).toBe( mockEmbedToken ); + expect( global.fetch ).toHaveBeenCalledTimes( 2 ); + } ); + } ); + + describe( 'clearEmbedTokenCache', () => { + beforeEach( async () => { + ( global.fetch as jest.Mock ).mockResolvedValue( { + ok: true, + json: async () => ( { + embedToken: mockEmbedToken, + exp: mockExp, + } ), + } ); + + await ensureEmbedToken( mockAppId, mockIframeOrigin ); + await ensureEmbedToken( 'NG-another-app', mockIframeOrigin ); + } ); + + it( 'should clear cache for a specific appId', async () => { + clearEmbedTokenCache( mockAppId ); + + ( global.fetch as jest.Mock ).mockClear(); + ( global.fetch as jest.Mock ).mockResolvedValue( { + ok: true, + json: async () => ( { + embedToken: 'new-token', + exp: mockExp, + } ), + } ); + + await ensureEmbedToken( mockAppId, mockIframeOrigin ); + expect( global.fetch ).toHaveBeenCalledTimes( 1 ); + + await ensureEmbedToken( 'NG-another-app', mockIframeOrigin ); + expect( global.fetch ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'should clear all caches when no appId is provided', async () => { + clearEmbedTokenCache(); + + ( global.fetch as jest.Mock ).mockClear(); + ( global.fetch as jest.Mock ).mockResolvedValue( { + ok: true, + json: async () => ( { + embedToken: 'new-token', + exp: mockExp, + } ), + } ); + + await ensureEmbedToken( mockAppId, mockIframeOrigin ); + expect( global.fetch ).toHaveBeenCalledTimes( 1 ); + + await ensureEmbedToken( 'NG-another-app', mockIframeOrigin ); + expect( global.fetch ).toHaveBeenCalledTimes( 2 ); + } ); + } ); +} ); diff --git a/src/embed-token.ts b/src/embed-token.ts new file mode 100644 index 0000000..2ff7e07 --- /dev/null +++ b/src/embed-token.ts @@ -0,0 +1,109 @@ +import type { EmbedTokenPayload } from './types'; + +export interface EmbedSessionMintRequest { + appId: string; +} + +export interface EmbedSessionMintResponse { + embedToken: string; + exp: number; +} + +const MINT_SKEW_SECONDS = 60; + +const tokenCache = new Map(); +const mintPromises = new Map>(); + +export const getEmbedApiBaseUrl = ( iframeOrigin: string ): string => { + return iframeOrigin; +}; + +const isTokenValid = ( payload: EmbedTokenPayload | undefined ): boolean => { + if ( ! payload ) { + return false; + } + const nowSeconds = Math.floor( Date.now() / 1000 ); + return payload.exp > nowSeconds + MINT_SKEW_SECONDS; +}; + +export const mintEmbedSession = async ( + appId: string, + iframeOrigin: string, +): Promise => { + const baseUrl = getEmbedApiBaseUrl( iframeOrigin ); + const endpoint = `${ baseUrl }/angie/embed/session`; + + const body: EmbedSessionMintRequest = { appId }; + + try { + const response = await fetch( endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify( body ), + credentials: 'include', + } ); + + if ( ! response.ok ) { + const errorText = await response.text().catch( () => 'Unknown error' ); + throw new Error( `Embed session mint failed with status ${ response.status }: ${ errorText }` ); + } + + const data: EmbedSessionMintResponse = await response.json(); + + if ( ! data.embedToken || ! data.exp ) { + throw new Error( 'Invalid embed session response: missing embedToken or exp' ); + } + + return { + embedToken: data.embedToken, + exp: data.exp, + }; + } catch ( error ) { + throw new Error( `Embed session mint error: ${ error instanceof Error ? error.message : String( error ) }` ); + } +}; + +export const ensureEmbedToken = async ( + appId: string, + iframeOrigin: string, +): Promise => { + const cached = tokenCache.get( appId ); + + if ( cached && isTokenValid( cached ) ) { + return cached; + } + + const existingMint = mintPromises.get( appId ); + if ( existingMint ) { + return existingMint; + } + + const mintPromise = mintEmbedSession( appId, iframeOrigin ) + .then( ( payload ) => { + tokenCache.set( appId, payload ); + mintPromises.delete( appId ); + return payload; + } ) + .catch( ( error ) => { + mintPromises.delete( appId ); + tokenCache.delete( appId ); + throw error; + } ); + + mintPromises.set( appId, mintPromise ); + return mintPromise; +}; + +export const clearEmbedTokenCache = ( appId?: string ): void => { + if ( appId ) { + tokenCache.delete( appId ); + mintPromises.delete( appId ); + } else { + tokenCache.clear(); + mintPromises.clear(); + } +}; + +export { MINT_SKEW_SECONDS }; diff --git a/src/load-sidebar-v2/host-api-bridge.test.ts b/src/load-sidebar-v2/host-api-bridge.test.ts index 7c2dcd7..5b739a1 100644 --- a/src/load-sidebar-v2/host-api-bridge.test.ts +++ b/src/load-sidebar-v2/host-api-bridge.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals'; -import { HostLocalStorageEventType } from '../types'; +import { HostLocalStorageEventType, MessageEventType } from '../types'; import type { ExternalHeadersCallback } from './config'; import { GET_ANALYTICS_CONTEXT_MESSAGE_TYPE, @@ -10,6 +10,19 @@ import { } from './host-api-bridge'; import { appState } from '../config'; import { createAngieInstance, resetInstancesForTests } from '../instance-registry'; +import * as embedToken from '../embed-token'; +import logger from '../logger'; + +jest.mock( '../embed-token' ); +jest.mock( '../logger', () => ( { + __esModule: true, + default: { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, +} ) ); const IFRAME_ORIGIN = 'http://localhost:4000'; const SCOPED_KEY = ( key: string, instanceId: string ) => `${ key }::__angie::${ instanceId }`; @@ -27,6 +40,11 @@ describe( 'load-sidebar-v2/host-api-bridge', () => { jest.clearAllMocks(); resetHostApiBridgeForTests(); resetInstancesForTests(); + + ( embedToken.ensureEmbedToken as jest.MockedFunction ).mockResolvedValue( { + embedToken: 'mock-token', + exp: Math.floor( Date.now() / 1000 ) + 300, + } ); } ); it( 'should answer each instance with its own host config', async () => { @@ -546,4 +564,153 @@ describe( 'load-sidebar-v2/host-api-bridge', () => { SCOPED_KEY( 'angie_active_chat_id', 'context-menu' ), ); } ); + + describe( 'embed token handling', () => { + it( 'should handle embed token request and send token to iframe', async () => { + const ownWindow = { postMessage: jest.fn() } as unknown as Window; + const instance = createAngieInstance( { + containerId: 'container-a', + instanceId: 'test-instance', + layout: 'sidebar', + } ); + instance.iframe = { contentWindow: ownWindow } as HTMLIFrameElement; + instance.appId = 'NG-test-123'; + + initHostApiBridge( { + iframeOrigin: IFRAME_ORIGIN, + host: { appId: 'NG-test-123' }, + instance, + } ); + + window.dispatchEvent( new MessageEvent( 'message', { + data: { type: MessageEventType.ANGIE_EMBED_TOKEN_REQUEST }, + origin: IFRAME_ORIGIN, + source: ownWindow, + } ) ); + + await flushAsync(); + + expect( embedToken.ensureEmbedToken ).toHaveBeenCalledWith( 'NG-test-123', IFRAME_ORIGIN ); + expect( ownWindow.postMessage ).toHaveBeenCalledWith( + { + type: MessageEventType.ANGIE_EMBED_TOKEN_SET, + payload: { + embedToken: 'mock-token', + exp: expect.any( Number ), + }, + }, + IFRAME_ORIGIN + ); + } ); + + it( 'should use appId from instance when not in host config', async () => { + const ownWindow = { postMessage: jest.fn() } as unknown as Window; + const instance = createAngieInstance( { + containerId: 'container-a', + instanceId: 'test-instance', + layout: 'sidebar', + } ); + instance.iframe = { contentWindow: ownWindow } as HTMLIFrameElement; + instance.appId = 'NG-from-instance'; + + initHostApiBridge( { + iframeOrigin: IFRAME_ORIGIN, + instance, + } ); + + window.dispatchEvent( new MessageEvent( 'message', { + data: { type: MessageEventType.ANGIE_EMBED_TOKEN_REQUEST }, + origin: IFRAME_ORIGIN, + source: ownWindow, + } ) ); + + await flushAsync(); + + expect( embedToken.ensureEmbedToken ).toHaveBeenCalledWith( 'NG-from-instance', IFRAME_ORIGIN ); + } ); + + it( 'should not handle embed token request when appId is missing', async () => { + const ownWindow = { postMessage: jest.fn() } as unknown as Window; + const instance = createAngieInstance( { + containerId: 'container-a', + instanceId: 'test-instance', + layout: 'sidebar', + } ); + instance.iframe = { contentWindow: ownWindow } as HTMLIFrameElement; + + initHostApiBridge( { + iframeOrigin: IFRAME_ORIGIN, + instance, + } ); + + window.dispatchEvent( new MessageEvent( 'message', { + data: { type: MessageEventType.ANGIE_EMBED_TOKEN_REQUEST }, + origin: IFRAME_ORIGIN, + source: ownWindow, + } ) ); + + await flushAsync(); + + expect( embedToken.ensureEmbedToken ).not.toHaveBeenCalled(); + expect( ownWindow.postMessage ).not.toHaveBeenCalled(); + expect( logger.warn ).toHaveBeenCalledWith( 'Cannot remint embed token: appId not available' ); + } ); + + it( 'should not handle embed token request when iframe window is unavailable', async () => { + const instance = createAngieInstance( { + containerId: 'container-a', + instanceId: 'test-instance', + layout: 'sidebar', + } ); + instance.appId = 'NG-test-123'; + + initHostApiBridge( { + iframeOrigin: IFRAME_ORIGIN, + host: { appId: 'NG-test-123' }, + instance, + } ); + + window.dispatchEvent( new MessageEvent( 'message', { + data: { type: MessageEventType.ANGIE_EMBED_TOKEN_REQUEST }, + origin: IFRAME_ORIGIN, + } ) ); + + await flushAsync(); + + expect( embedToken.ensureEmbedToken ).not.toHaveBeenCalled(); + expect( logger.warn ).toHaveBeenCalledWith( 'Cannot remint embed token: iframe window not available' ); + } ); + + it( 'should handle embed token mint error gracefully', async () => { + ( embedToken.ensureEmbedToken as jest.MockedFunction ) + .mockRejectedValue( new Error( 'Mint failed' ) ); + + const ownWindow = { postMessage: jest.fn() } as unknown as Window; + const instance = createAngieInstance( { + containerId: 'container-a', + instanceId: 'test-instance', + layout: 'sidebar', + } ); + instance.iframe = { contentWindow: ownWindow } as HTMLIFrameElement; + instance.appId = 'NG-test-123'; + + initHostApiBridge( { + iframeOrigin: IFRAME_ORIGIN, + host: { appId: 'NG-test-123' }, + instance, + } ); + + window.dispatchEvent( new MessageEvent( 'message', { + data: { type: MessageEventType.ANGIE_EMBED_TOKEN_REQUEST }, + origin: IFRAME_ORIGIN, + source: ownWindow, + } ) ); + + await flushAsync(); + + expect( embedToken.ensureEmbedToken ).toHaveBeenCalled(); + expect( ownWindow.postMessage ).not.toHaveBeenCalled(); + expect( logger.error ).toHaveBeenCalledWith( 'Failed to remint embed token:', 'Mint failed' ); + } ); + } ); } ); diff --git a/src/load-sidebar-v2/host-api-bridge.ts b/src/load-sidebar-v2/host-api-bridge.ts index 027f455..99d461e 100644 --- a/src/load-sidebar-v2/host-api-bridge.ts +++ b/src/load-sidebar-v2/host-api-bridge.ts @@ -1,7 +1,9 @@ import { sendErrorMessage, sendSuccessMessage } from '../utils'; -import { HostLocalStorageEventType } from '../types'; +import { HostLocalStorageEventType, MessageEventType } from '../types'; import type { AppState } from '../config'; import type { ExternalHeadersCallback, HostConfig, HostContextProvider } from './config'; +import { ensureEmbedToken } from '../embed-token'; +import logger from '../logger'; export const GET_EXTERNAL_HEADERS_MESSAGE_TYPE = 'GET_EXTERNAL_HEADERS'; @@ -141,6 +143,34 @@ const setScopedHostStorage = ( } }; +const handleEmbedTokenRequest = async ( + event: MessageEvent, + bridgeConfig: InitHostApiBridgeArgs, +): Promise => { + const appId = bridgeConfig.host?.appId || bridgeConfig.instance.appId; + + if ( ! appId ) { + logger.warn( 'Cannot remint embed token: appId not available' ); + return; + } + + if ( ! bridgeConfig.instance.iframe?.contentWindow ) { + logger.warn( 'Cannot remint embed token: iframe window not available' ); + return; + } + + try { + const embedToken = await ensureEmbedToken( appId, bridgeConfig.iframeOrigin ); + + bridgeConfig.instance.iframe.contentWindow.postMessage( { + type: MessageEventType.ANGIE_EMBED_TOKEN_SET, + payload: embedToken, + }, bridgeConfig.iframeOrigin ); + } catch ( error ) { + logger.error( 'Failed to remint embed token:', error instanceof Error ? error.message : String( error ) ); + } +}; + const handleHostApiMessage = async ( event: MessageEvent ): Promise => { const bridgeConfig = findBridge( event ); @@ -205,6 +235,11 @@ const handleHostApiMessage = async ( event: MessageEvent ): Promise => { ); break; } + + case MessageEventType.ANGIE_EMBED_TOKEN_REQUEST: { + await handleEmbedTokenRequest( event, bridgeConfig ); + break; + } } }; diff --git a/src/openSaaSPage.test.ts b/src/openSaaSPage.test.ts index 8192ba7..35aea0d 100644 --- a/src/openSaaSPage.test.ts +++ b/src/openSaaSPage.test.ts @@ -1,6 +1,19 @@ import { describe, expect, it, beforeEach, afterEach, jest } from '@jest/globals'; import { openSaaSPage } from './openSaaSPage'; -import { HostEventType } from './types'; +import { HostEventType, MessageEventType } from './types'; +import * as embedToken from './embed-token'; +import logger from './logger'; + +jest.mock( './embed-token' ); +jest.mock( './logger', () => ( { + __esModule: true, + default: { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, +} ) ); // Mock URL constructor const originalURL = global.URL; @@ -41,6 +54,12 @@ describe('openSaaSPage', () => { beforeEach(() => { jest.clearAllMocks(); + // Mock embed token by default + ( embedToken.ensureEmbedToken as jest.MockedFunction ).mockResolvedValue( { + embedToken: 'mock-token', + exp: Math.floor( Date.now() / 1000 ) + 300, + } ); + // Mock window object mockWindow = { matchMedia: jest.fn().mockReturnValue({ matches: false }), @@ -60,6 +79,7 @@ describe('openSaaSPage', () => { setAttribute: jest.fn(), id: '', getAttribute: jest.fn(), + contentWindow: null, } as any; mockDocument = { @@ -172,6 +192,8 @@ describe('openSaaSPage', () => { appId: 'NG-ab35bf57', }); + await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + const messageListener = mockWindow.addEventListener.mock.calls.find( (call: any[]) => call[0] === 'message' )?.[1]; @@ -234,5 +256,154 @@ describe('openSaaSPage', () => { expect(styleValue).toContain('width: 400px'); expect(styleValue).toContain('height: 300px'); }); + + it( 'should mint embed token when appId is provided', async () => { + const ownWindow = { postMessage: jest.fn() }; + Object.defineProperty( mockIframe, 'contentWindow', { value: ownWindow, writable: true } ); + + const messagePromise = openSaaSPage( { + ...defaultProps, + appId: 'NG-test-123', + } ); + + await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + + expect( embedToken.ensureEmbedToken ).toHaveBeenCalledWith( + 'NG-test-123', + 'https://angie.elementor.com' + ); + + const messageListener = mockWindow.addEventListener.mock.calls.find( + ( call: any[] ) => call[ 0 ] === 'message' + )?.[1]; + + messageListener( { + origin: 'https://angie.elementor.com', + source: ownWindow, + data: { type: HostEventType.ANGIE_READY }, + } ); + + const result = await messagePromise; + expect( result.embedToken ).toEqual( { + embedToken: 'mock-token', + exp: expect.any( Number ), + } ); + } ); + + it( 'should send embed token to iframe on ANGIE_READY', async () => { + const ownWindow = { postMessage: jest.fn() }; + Object.defineProperty( mockIframe, 'contentWindow', { value: ownWindow, writable: true } ); + + const messagePromise = openSaaSPage( { + ...defaultProps, + appId: 'NG-test-123', + } ); + + await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + + const messageListener = mockWindow.addEventListener.mock.calls.find( + ( call: any[] ) => call[ 0 ] === 'message' + )?.[1]; + + messageListener( { + origin: 'https://angie.elementor.com', + source: ownWindow, + data: { type: HostEventType.ANGIE_READY }, + } ); + + await messagePromise; + + expect( ownWindow.postMessage ).toHaveBeenCalledWith( + { + type: MessageEventType.ANGIE_EMBED_TOKEN_SET, + payload: { + embedToken: 'mock-token', + exp: expect.any( Number ), + }, + }, + 'https://angie.elementor.com' + ); + } ); + + it( 'should not mint embed token when appId is not provided', async () => { + const messagePromise = openSaaSPage( defaultProps ); + + const messageListener = mockWindow.addEventListener.mock.calls.find( + ( call: any[] ) => call[ 0 ] === 'message' + )?.[1]; + + messageListener( { + origin: 'https://angie.elementor.com', + data: { type: HostEventType.ANGIE_READY }, + } ); + + const result = await messagePromise; + + expect( embedToken.ensureEmbedToken ).not.toHaveBeenCalled(); + expect( result.embedToken ).toBeUndefined(); + } ); + + it( 'should handle embed token mint failure gracefully', async () => { + ( embedToken.ensureEmbedToken as jest.MockedFunction ) + .mockRejectedValue( new Error( 'Mint failed' ) ); + + const messagePromise = openSaaSPage( { + ...defaultProps, + appId: 'NG-test-123', + } ); + + await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Failed to mint embed session token:', + 'Mint failed' + ); + + const messageListener = mockWindow.addEventListener.mock.calls.find( + ( call: any[] ) => call[ 0 ] === 'message' + )?.[1]; + + messageListener( { + origin: 'https://angie.elementor.com', + data: { type: HostEventType.ANGIE_READY }, + } ); + + const result = await messagePromise; + expect( result.embedToken ).toBeUndefined(); + } ); + + it( 'should not send embed token when mint fails', async () => { + ( embedToken.ensureEmbedToken as jest.MockedFunction ) + .mockRejectedValue( new Error( 'Mint failed' ) ); + + const ownWindow = { postMessage: jest.fn() }; + Object.defineProperty( mockIframe, 'contentWindow', { value: ownWindow, writable: true } ); + + const messagePromise = openSaaSPage( { + ...defaultProps, + appId: 'NG-test-123', + } ); + + await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + + const messageListener = mockWindow.addEventListener.mock.calls.find( + ( call: any[] ) => call[ 0 ] === 'message' + )?.[1]; + + messageListener( { + origin: 'https://angie.elementor.com', + source: ownWindow, + data: { type: HostEventType.ANGIE_READY }, + } ); + + await messagePromise; + + expect( ownWindow.postMessage ).not.toHaveBeenCalledWith( + expect.objectContaining( { + type: MessageEventType.ANGIE_EMBED_TOKEN_SET, + } ), + expect.any( String ) + ); + } ); }); }); diff --git a/src/openSaaSPage.ts b/src/openSaaSPage.ts index 0167d14..7827667 100644 --- a/src/openSaaSPage.ts +++ b/src/openSaaSPage.ts @@ -1,6 +1,9 @@ -import { HostEventType } from "./types"; +import { HostEventType, MessageEventType } from "./types"; import { generateInstanceId, isTrustedIframeMessage } from "./utils"; import type { HostEmbeddedConfigPayload } from "./load-sidebar-v2/config"; +import { ensureEmbedToken } from "./embed-token"; +import type { EmbedTokenPayload } from "./types"; +import logger from "./logger"; type OpenSaaSPageInput = { origin: string; @@ -22,6 +25,7 @@ type OpenSaaSPageInput = { type OpenSaaSPageOutput = { iframe: HTMLIFrameElement; iframeUrlObject: URL; + embedToken?: EmbedTokenPayload; }; export const openSaaSPage = async ( props: OpenSaaSPageInput ): Promise => { @@ -31,6 +35,16 @@ export const openSaaSPage = async ( props: OpenSaaSPageInput ): Promise { const iframeUrlObject = new URL( origin ); iframeUrlObject.pathname = pathUrl.pathname; @@ -79,9 +93,16 @@ export const openSaaSPage = async ( props: OpenSaaSPageInput ): Promise