diff --git a/src/apis/html-page-api.js b/src/apis/html-page-api.js index c3643b5..c285ea5 100644 --- a/src/apis/html-page-api.js +++ b/src/apis/html-page-api.js @@ -2,24 +2,28 @@ import axios from 'axios'; class HTMLPageAPI { async initWithAccountToken({ server, accountToken, appUuid }) { - this.initServer(server); - this.appUuid = appUuid; - if (this.server && this.appUuid) { - try { - const res = await axios.get(`${this.server}api/v2.1/universal-apps/${this.appUuid}/access-token/`, { - headers: { Authorization: 'Token ' + accountToken } - }); - this.accessToken = res.data?.access_token || ''; - this.createReq(); - } catch (error) { - // eslint-disable-next-line - console.log('Authorization failed'); - } + if (!server || !accountToken || !appUuid) { + throw new Error('Failed to get access token: missing server, accountToken, or appUuid'); + } + + let res; + try { + res = await axios.get(`${server}api/v2.1/universal-apps/${appUuid}/access-token/`, { + headers: { Authorization: 'Token ' + accountToken } + }); + } catch (error) { + throw new Error(`Failed to get access token: ${error.message}`); + } + + const accessToken = res.data?.access_token; + if (!accessToken) { + throw new Error('Failed to get access token: access_token missing'); } + this.accessToken = accessToken; } init({ server, accessToken, appUuid }) { - this.initServer(server); + this.server = server; this.accessToken = accessToken || ''; this.appUuid = appUuid; if (this.accessToken && this.server && this.appUuid) { @@ -27,9 +31,21 @@ class HTMLPageAPI { } } - initServer(server) { - if (!server) return; - this.server = server.endsWith('/') ? server : `${server}/`; + async getParentOrigin({ server, accessToken, appUuid }) { + if (!server || !accessToken || !appUuid) { + throw new Error('Failed to get parentOrigin: missing server, accessToken, or appUuid'); + } + + try { + const response = await axios.post( + `${server}api/v2.1/universal-apps/bootstrap/`, + { app_uuid: appUuid }, + { headers: { Authorization: 'Token ' + accessToken } }, + ); + return response.data?.parentOrigin || ''; + } catch (error) { + throw new Error(`Failed to get parentOrigin: ${error.message}`); + } } createReq() { diff --git a/src/iframe-adapter.js b/src/iframe-adapter.js index 1a524ae..e17d832 100644 --- a/src/iframe-adapter.js +++ b/src/iframe-adapter.js @@ -13,6 +13,12 @@ export const POST_MESSAGE_REQUEST_TYPE = { GET_PREVIEW_TABLE_CONFIGS: 'get_preview_table_configs', }; +const BOOTSTRAP_REQUEST_TYPES = new Set([ + POST_MESSAGE_REQUEST_TYPE.GET_SERVER, + POST_MESSAGE_REQUEST_TYPE.GET_ACCESS_TOKEN, + POST_MESSAGE_REQUEST_TYPE.GET_APP_UUID, +]); + const WINDOW_EVENT_SOURCE_TYPE = { APP: 'app', IFRAME: 'iframe', @@ -27,6 +33,20 @@ const hasOwnProperty = (obj, key) => { return Object.prototype.hasOwnProperty.call(obj, key); }; +const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); + +const normalizeOrigin = (origin) => { + if (typeof origin !== 'string' || !origin) return null; + + try { + const url = new URL(origin); + if (!['http:', 'https:'].includes(url.protocol) || url.origin === 'null') return null; + return url.origin; + } catch (error) { + return null; + } +}; + const generatorBase64Code = (keyLength = 4) => { let possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz0123456789'; let key = ''; @@ -77,10 +97,11 @@ export class IframeAdapter { constructor(options) { this.options = options || {}; this.selfWindow = window.parent === window.self; - this.targetOrigin = this.options.targetOrigin || '*'; + this.targetOrigin = null; this.pendingRequests = {}; this.eventHandlers = {}; this.timeout = this.options.timeout || 10000; + this._handleMessage = this.handleMessage.bind(this); this.setupMessageListener(); } @@ -94,12 +115,20 @@ export class IframeAdapter { setupMessageListener() { if (this.selfWindow) return; - window.addEventListener('message', this.handleMessage.bind(this)); + window.addEventListener('message', this._handleMessage); this.setEventsListener(); } + setTargetOrigin(origin) { + const targetOrigin = normalizeOrigin(origin); + if (!targetOrigin) { + throw new Error('Invalid trusted target origin'); + } + this.targetOrigin = targetOrigin; + } + postWindowEvent(eventData) { - if (!eventData) return; + if (!eventData || !this.targetOrigin) return; window.parent.postMessage({ type: POST_MESSAGE_TYPE.WINDOW_EVENT, params: { @@ -153,23 +182,39 @@ export class IframeAdapter { } async request(method, params) { - if (this.selfWindow) { - return new Promise((resolve) => { - resolve(null); - }); + if (this.selfWindow) return null; + if (!this.targetOrigin) { + throw new Error('Trusted target origin has not been configured'); + } + return this._request(method, params, this.targetOrigin); + } + + async bootstrapRequest(method, params) { + if (this.selfWindow) return null; + + // Initial server, access-token, and app-UUID requests run before the trusted + // parent origin is known. Responses still have to originate from window.parent + // and match the generated request ID. All later traffic requires the configured origin. + if (this.targetOrigin) { + return this._request(method, params, this.targetOrigin); } + if (!BOOTSTRAP_REQUEST_TYPES.has(method)) { + throw new Error(`Unsupported bootstrap request: ${method}`); + } + return this._request(method, params, '*'); + } + + _request(method, params, targetOrigin) { const requestId = this.generatorRequestId(); return new Promise((resolve, reject) => { - this.pendingRequests[requestId] = { resolve, reject }; + this.pendingRequests[requestId] = { resolve, reject, targetOrigin }; window.parent.postMessage({ type: POST_MESSAGE_TYPE.HTML_PAGE_REQUEST, requestId, method, params - }, this.targetOrigin); + }, targetOrigin); - // request timeout - // reject and clear the pending request const timeoutId = setTimeout(() => { if (hasOwnProperty(this.pendingRequests, requestId)) { delete this.pendingRequests[requestId]; @@ -177,7 +222,6 @@ export class IframeAdapter { } }, this.timeout); - // save timeoutId for the pending request const pending = this.pendingRequests[requestId]; if (pending) { pending.timeoutId = timeoutId; @@ -185,19 +229,43 @@ export class IframeAdapter { }); } + isMessageFromParent(event) { + return event && event.source === window.parent; + } + + isTrustedMessage(event) { + return Boolean(this.targetOrigin && this.isMessageFromParent(event) && event.origin === this.targetOrigin); + } + + isExpectedResponse(event, pending) { + // Bootstrap responses may come from any origin because the trusted origin is + // not known yet, but every response must come from window.parent. After + // bootstrap, the response origin must match the origin used for the request. + if (!this.isMessageFromParent(event)) return false; + return pending.targetOrigin === '*' || event.origin === pending.targetOrigin; + } + handleMessage(event) { + if (!isObject(event?.data)) return; + const { type, requestId, data, error, eventType, payload } = event.data; if (type === POST_MESSAGE_TYPE.HTML_PAGE_RESPONSE) { const pending = this.pendingRequests[requestId]; - if (pending) { - clearTimeout(pending.timeoutId); - delete this.pendingRequests[requestId]; - if (error) { - pending.reject(new Error(error)); - } else { + if (!pending || !this.isExpectedResponse(event, pending)) return; + + clearTimeout(pending.timeoutId); + delete this.pendingRequests[requestId]; + if (error) { + pending.reject(new Error(error)); + } else { + try { pending.resolve(data ? JSON.parse(data) : null); + } catch (parseError) { + pending.reject(new Error('Invalid response payload')); } } + } else if (!this.isTrustedMessage(event)) { + return; } else if (type === POST_MESSAGE_TYPE.HTML_PAGE_EVENT) { this.emitEvent(eventType, payload); } else if (type === POST_MESSAGE_TYPE.WINDOW_EVENT) { @@ -289,7 +357,7 @@ export class IframeAdapter { } destroy() { - this.pendingRequests.forEach(pending => { + Object.values(this.pendingRequests).forEach(pending => { clearTimeout(pending.timeoutId); pending.reject(new Error('Adapter destroyed')); }); diff --git a/src/sdk.js b/src/sdk.js index b46cc18..f49ae0d 100644 --- a/src/sdk.js +++ b/src/sdk.js @@ -1,41 +1,70 @@ import HTMLPageAPI from './apis/html-page-api'; import { IframeAdapter, POST_MESSAGE_REQUEST_TYPE } from './iframe-adapter'; +const AI_AGENT_PAGE_ID = 'ai_agent'; + export class HTMLPageSDK { constructor(options) { - this.options = options || {}; - this.iframeAdapter = new IframeAdapter(options); + const sdkOptions = { ...(options || {}) }; + delete sdkOptions.accessToken; + this.options = sdkOptions; + this.iframeAdapter = new IframeAdapter(sdkOptions); } async init() { - if (!this.options) { - this.options = {}; - } this.htmlPageAPI = new HTMLPageAPI(); - if (!this.options.server) { - this.options.server = await this.iframeAdapter.request(POST_MESSAGE_REQUEST_TYPE.GET_SERVER); + if (Object.prototype.hasOwnProperty.call(this.options, 'accountToken')) { + await this._initDevelopment(); + return; } - if (!this.options.appUuid) { - this.options.appUuid = await this.iframeAdapter.request(POST_MESSAGE_REQUEST_TYPE.GET_APP_UUID); + await this._initProduction(); + } + + async _initDevelopment() { + const server = this._normalizeServer(this.options.server); + if (!server) { + throw new Error('Missing server configuration'); } - if (!this.options.pageId) { - this.options.pageId = await this.iframeAdapter.request(POST_MESSAGE_REQUEST_TYPE.GET_PAGE_ID); + + this.options.server = server; + const { accountToken, appUuid } = this.options; + await this.htmlPageAPI.initWithAccountToken({ server, accountToken, appUuid }); + + const accessToken = this.htmlPageAPI.accessToken; + this.htmlPageAPI.init({ server, accessToken, appUuid }); + } + + async _initProduction() { + const server = this._normalizeServer( + await this.iframeAdapter.bootstrapRequest(POST_MESSAGE_REQUEST_TYPE.GET_SERVER) + ); + if (!server) { + throw new Error('Missing server configuration'); } - if (this.options.pageId === 'ai_agent' && !Array.isArray(this.options.previewTableConfigs)) { + this.options.server = server; + + const accessToken = await this.iframeAdapter.bootstrapRequest(POST_MESSAGE_REQUEST_TYPE.GET_ACCESS_TOKEN); + const appUuid = await this.iframeAdapter.bootstrapRequest(POST_MESSAGE_REQUEST_TYPE.GET_APP_UUID); + this.options.appUuid = appUuid; + await this._configureTrustedOrigin({ server, accessToken, appUuid }); + + this.options.pageId = await this.iframeAdapter.request(POST_MESSAGE_REQUEST_TYPE.GET_PAGE_ID); + if (this.options.pageId === AI_AGENT_PAGE_ID) { const previewTableConfigs = await this.iframeAdapter.request(POST_MESSAGE_REQUEST_TYPE.GET_PREVIEW_TABLE_CONFIGS); this.options.previewTableConfigs = Array.isArray(previewTableConfigs) ? previewTableConfigs : []; } - if (this.options.accountToken) { - // dev: try to get access-token via accountToken - const { server, accountToken, appUuid } = this.options; - await this.htmlPageAPI.initWithAccountToken({ server, accountToken, appUuid }); - } else { - if (!this.options.accessToken) { - this.options.accessToken = await this.iframeAdapter.request(POST_MESSAGE_REQUEST_TYPE.GET_ACCESS_TOKEN); - } - const { server, accessToken, appUuid } = this.options; - this.htmlPageAPI.init({ server, accessToken, appUuid }); - } + + this.htmlPageAPI.init({ server, accessToken, appUuid: this.options.appUuid }); + } + + async _configureTrustedOrigin({ server, accessToken, appUuid }) { + const parentOrigin = await this.htmlPageAPI.getParentOrigin({ server, accessToken, appUuid }); + this.iframeAdapter.setTargetOrigin(parentOrigin); + } + + _normalizeServer(server) { + if (!server) return ''; + return server.endsWith('/') ? server : `${server}/`; } listRows({ tableName, start, limit }) { @@ -49,7 +78,7 @@ export class HTMLPageSDK { } _getPreviewTableConfig({ tableName }) { - if (this.options.pageId !== 'ai_agent' || !Array.isArray(this.options.previewTableConfigs)) return undefined; + if (this.options.pageId !== AI_AGENT_PAGE_ID || !Array.isArray(this.options.previewTableConfigs)) return undefined; const tableConfig = this.options.previewTableConfigs.find(config => tableName && config?.table_name === tableName); if (!tableConfig) return undefined; return { diff --git a/tests/html-page-api.test.js b/tests/html-page-api.test.js index 1e52332..2706b45 100644 --- a/tests/html-page-api.test.js +++ b/tests/html-page-api.test.js @@ -6,6 +6,7 @@ jest.mock('axios', () => ({ default: { create: jest.fn(), get: jest.fn(), + post: jest.fn(), }, })); @@ -23,7 +24,7 @@ function createApi() { const api = new HTMLPageAPI(); api.init({ - server: 'https://example.com', + server: 'https://example.com/', accessToken: 'token', appUuid: 'app-uuid', }); @@ -98,7 +99,7 @@ describe('HTMLPageAPI.listRows', () => { const api = new HTMLPageAPI(); api.init({ - server: 'https://example.com', + server: 'https://example.com/', accessToken: 'token', appUuid: 'app-uuid', }); @@ -415,7 +416,7 @@ describe('HTMLPageAPI.upload', () => { const api = new HTMLPageAPI(); api.init({ - server: 'https://example.com', + server: 'https://example.com/', accessToken: 'token', appUuid: 'app-uuid', }); @@ -455,7 +456,7 @@ describe('HTMLPageAPI.upload', () => { const api = new HTMLPageAPI(); api.init({ - server: 'https://example.com', + server: 'https://example.com/', accessToken: 'token', appUuid: 'app-uuid', }); @@ -485,3 +486,89 @@ describe('HTMLPageAPI.upload', () => { }); }); }); + +describe('HTMLPageAPI.initWithAccountToken', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('rejects with the access-token request error', async () => { + axios.get.mockRejectedValue(new Error('Request failed with status code 401')); + const api = new HTMLPageAPI(); + + await expect(api.initWithAccountToken({ + server: 'https://custom-app-server.example.com/', + accountToken: 'invalid-account-token', + appUuid: 'app-uuid', + })).rejects.toThrow('Failed to get access token: Request failed with status code 401'); + }); + + it('requires development access-token configuration', async () => { + const api = new HTMLPageAPI(); + + await expect(api.initWithAccountToken({ + server: 'https://custom-app-server.example.com/', + accountToken: 'account-token', + appUuid: '', + })).rejects.toThrow('Failed to get access token: missing server, accountToken, or appUuid'); + expect(axios.get).not.toHaveBeenCalled(); + }); + + it('rejects when the access-token response does not contain an access token', async () => { + axios.get.mockResolvedValue({ data: {} }); + const api = new HTMLPageAPI(); + + const error = await api.initWithAccountToken({ + server: 'https://custom-app-server.example.com/', + accountToken: 'account-token', + appUuid: 'app-uuid', + }).catch(error => error); + + expect(error.message).toBe('Failed to get access token: access_token missing'); + }); +}); + +describe('HTMLPageAPI.getParentOrigin', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('uses the custom-app-server bootstrap endpoint with the app access token', async () => { + axios.post.mockResolvedValue({ data: { parentOrigin: 'https://app.example.com' } }); + const api = new HTMLPageAPI(); + + await expect(api.getParentOrigin({ + server: 'https://custom-app-server.example.com/', + accessToken: 'access-token', + appUuid: 'app-uuid', + })).resolves.toBe('https://app.example.com'); + + expect(axios.post).toHaveBeenCalledWith( + 'https://custom-app-server.example.com/api/v2.1/universal-apps/bootstrap/', + { app_uuid: 'app-uuid' }, + { headers: { Authorization: 'Token access-token' } }, + ); + }); + + it('adds parentOrigin context when the bootstrap request fails', async () => { + axios.post.mockRejectedValue(new Error('Request failed with status code 401')); + const api = new HTMLPageAPI(); + + await expect(api.getParentOrigin({ + server: 'https://custom-app-server.example.com/', + accessToken: 'invalid-access-token', + appUuid: 'app-uuid', + })).rejects.toThrow('Failed to get parentOrigin: Request failed with status code 401'); + }); + + it('requires bootstrap configuration', async () => { + const api = new HTMLPageAPI(); + + await expect(api.getParentOrigin({ + server: 'https://custom-app-server.example.com/', + accessToken: 'access-token', + appUuid: '', + })).rejects.toThrow('Failed to get parentOrigin: missing server, accessToken, or appUuid'); + expect(axios.post).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/iframe-adapter.test.js b/tests/iframe-adapter.test.js index 8e0da4c..f861e54 100644 --- a/tests/iframe-adapter.test.js +++ b/tests/iframe-adapter.test.js @@ -27,11 +27,12 @@ describe('IframeAdapter', () => { expect(global.window.addEventListener).not.toHaveBeenCalled(); }); - it('posts requests and resolves responses in iframe mode', async () => { + it('uses a wildcard only for server and access-token bootstrap requests', async () => { jest.useFakeTimers(); const addEventListener = jest.fn(); const postMessage = jest.fn(); + const parent = { postMessage }; const target = { dispatchEvent: jest.fn() }; global.document = { @@ -39,38 +40,206 @@ describe('IframeAdapter', () => { body: target, elementFromPoint: jest.fn().mockReturnValue(target), }; - global.window = { self: {}, - parent: { - postMessage, - }, + parent, addEventListener, }; - const adapter = new IframeAdapter({ targetOrigin: 'https://example.com', timeout: 1000 }); - const requestPromise = adapter.request('get_server', { foo: 'bar' }); + const adapter = new IframeAdapter({ + timeout: 1000, + targetOrigin: 'https://unverified-parent.example.com', + }); + expect(adapter.targetOrigin).toBeNull(); + + const serverPromise = adapter.bootstrapRequest('get_server'); + const serverRequestId = postMessage.mock.calls[0][0].requestId; + expect(postMessage).toHaveBeenNthCalledWith(1, { + type: 'HTML_PAGE_REQUEST', + requestId: expect.any(String), + method: 'get_server', + params: undefined, + }, '*'); - expect(addEventListener).toHaveBeenCalledWith('message', expect.any(Function)); - expect(postMessage).toHaveBeenCalledWith( - { - type: 'HTML_PAGE_REQUEST', - requestId: expect.any(String), - method: 'get_server', - params: { foo: 'bar' }, + adapter.handleMessage({ + source: parent, + origin: 'https://unverified-parent.example.com', + data: { + type: 'HTML_PAGE_RESPONSE', + requestId: serverRequestId, + data: JSON.stringify('https://custom-app-server.example.com'), }, - 'https://example.com', - ); + }); + await expect(serverPromise).resolves.toBe('https://custom-app-server.example.com'); + + const accessTokenPromise = adapter.bootstrapRequest('get_access_token'); + const accessTokenRequestId = postMessage.mock.calls[1][0].requestId; + expect(postMessage).toHaveBeenNthCalledWith(2, { + type: 'HTML_PAGE_REQUEST', + requestId: expect.any(String), + method: 'get_access_token', + params: undefined, + }, '*'); + + adapter.handleMessage({ + source: parent, + origin: 'https://unverified-parent.example.com', + data: { + type: 'HTML_PAGE_RESPONSE', + requestId: accessTokenRequestId, + data: JSON.stringify('access-token'), + }, + }); + await expect(accessTokenPromise).resolves.toBe('access-token'); + + const appUuidPromise = adapter.bootstrapRequest('get_app_uuid'); + const appUuidRequestId = postMessage.mock.calls[2][0].requestId; + expect(postMessage).toHaveBeenNthCalledWith(3, { + type: 'HTML_PAGE_REQUEST', + requestId: expect.any(String), + method: 'get_app_uuid', + params: undefined, + }, '*'); + + adapter.handleMessage({ + source: parent, + origin: 'https://unverified-parent.example.com', + data: { + type: 'HTML_PAGE_RESPONSE', + requestId: appUuidRequestId, + data: JSON.stringify('app-uuid'), + }, + }); + await expect(appUuidPromise).resolves.toBe('app-uuid'); + + adapter.setTargetOrigin('https://app.example.com/path-that-is-not-part-of-an-origin'); + expect(adapter.targetOrigin).toBe('https://app.example.com'); + + const requestPromise = adapter.request('get_page_id'); + const requestId = postMessage.mock.calls[3][0].requestId; + expect(postMessage).toHaveBeenNthCalledWith(4, { + type: 'HTML_PAGE_REQUEST', + requestId: expect.any(String), + method: 'get_page_id', + params: undefined, + }, 'https://app.example.com'); + + adapter.handleMessage({ + source: parent, + origin: 'https://unverified-parent.example.com', + data: { + type: 'HTML_PAGE_RESPONSE', + requestId, + data: JSON.stringify('page-1'), + }, + }); + expect(adapter.pendingRequests[requestId]).toBeDefined(); - const requestId = postMessage.mock.calls[0][0].requestId; adapter.handleMessage({ + source: {}, + origin: 'https://app.example.com', data: { type: 'HTML_PAGE_RESPONSE', requestId, - data: JSON.stringify({ server: 'https://example.com' }), + data: JSON.stringify('page-1'), }, }); + expect(adapter.pendingRequests[requestId]).toBeDefined(); - await expect(requestPromise).resolves.toEqual({ server: 'https://example.com' }); + adapter.handleMessage({ + source: parent, + origin: 'https://app.example.com', + data: { + type: 'HTML_PAGE_RESPONSE', + requestId, + data: JSON.stringify('page-1'), + }, + }); + await expect(requestPromise).resolves.toBe('page-1'); + }); + + + it('rejects non-bootstrap request types before the trusted origin is configured', async () => { + const postMessage = jest.fn(); + global.window = { + self: {}, + parent: { postMessage }, + addEventListener: jest.fn(), + }; + + const adapter = new IframeAdapter({ timeout: 1 }); + + await expect(adapter.bootstrapRequest('get_page_id')).rejects.toThrow( + 'Unsupported bootstrap request: get_page_id' + ); + expect(postMessage).not.toHaveBeenCalled(); + }); + + it('does not send events or accept non-bootstrap messages before a target origin is configured', async () => { + const addEventListener = jest.fn(); + const postMessage = jest.fn(); + const parent = { postMessage }; + const target = { dispatchEvent: jest.fn() }; + + global.document = { + activeElement: null, + body: target, + elementFromPoint: jest.fn().mockReturnValue(target), + }; + global.window = { + self: {}, + parent, + addEventListener, + }; + + const adapter = new IframeAdapter(); + const handler = jest.fn(); + adapter.on('event', handler); + adapter.postWindowEvent({ type: 'click' }); + adapter.handleMessage({ + source: parent, + origin: 'https://unverified-parent.example.com', + data: { + type: 'HTML_PAGE_EVENT', + eventType: 'event', + payload: { value: 1 }, + }, + }); + + expect(postMessage).not.toHaveBeenCalled(); + expect(handler).not.toHaveBeenCalled(); + await expect(adapter.request('get_server')).rejects.toThrow('Trusted target origin has not been configured'); + }); + + it('rejects and clears pending requests when destroyed', async () => { + jest.useFakeTimers(); + + global.window = { + self: {}, + parent: { postMessage: jest.fn() }, + addEventListener: jest.fn(), + }; + + const adapter = new IframeAdapter({ timeout: 1000 }); + adapter.setTargetOrigin('https://app.example.com'); + const requestPromise = adapter.request('get_page_id'); + + adapter.destroy(); + + await expect(requestPromise).rejects.toThrow('Adapter destroyed'); + expect(adapter.pendingRequests).toEqual({}); + expect(adapter.eventHandlers).toEqual({}); + }); + + it('rejects invalid target origins', () => { + global.window = { + self: {}, + parent: { postMessage: jest.fn() }, + addEventListener: jest.fn(), + }; + + const adapter = new IframeAdapter(); + expect(() => adapter.setTargetOrigin('*')).toThrow('Invalid trusted target origin'); + expect(() => adapter.setTargetOrigin('javascript:alert(1)')).toThrow('Invalid trusted target origin'); }); }); diff --git a/tests/sdk-init.test.js b/tests/sdk-init.test.js index a0b347f..34b854d 100644 --- a/tests/sdk-init.test.js +++ b/tests/sdk-init.test.js @@ -1,6 +1,9 @@ import { HTMLPageSDK } from '../src/sdk'; const mockRequest = jest.fn(); +const mockBootstrapRequest = jest.fn(); +const mockSetTargetOrigin = jest.fn(); +const mockGetParentOrigin = jest.fn(); const mockInitWithAccountToken = jest.fn(function initWithAccountToken() { this.accessToken = 'access-token'; this.req = {}; @@ -10,6 +13,7 @@ const mockInit = jest.fn(); jest.mock('../src/apis/html-page-api', () => { return jest.fn().mockImplementation(() => ({ + getParentOrigin: mockGetParentOrigin, initWithAccountToken: mockInitWithAccountToken, init: mockInit, })); @@ -17,7 +21,9 @@ jest.mock('../src/apis/html-page-api', () => { jest.mock('../src/iframe-adapter', () => ({ IframeAdapter: jest.fn().mockImplementation(() => ({ + bootstrapRequest: mockBootstrapRequest, request: mockRequest, + setTargetOrigin: mockSetTargetOrigin, })), POST_MESSAGE_REQUEST_TYPE: { GET_SERVER: 'get_server', @@ -30,35 +36,121 @@ jest.mock('../src/iframe-adapter', () => ({ describe('HTMLPageSDK.init', () => { beforeEach(() => { - jest.clearAllMocks(); + mockRequest.mockReset(); + mockBootstrapRequest.mockReset(); + mockSetTargetOrigin.mockReset(); + mockGetParentOrigin.mockReset(); + mockInit.mockReset(); + mockInitWithAccountToken.mockReset().mockImplementation(function initWithAccountToken() { + this.accessToken = 'access-token'; + this.req = {}; + return Promise.resolve(); + }); }); - it('production: initializes with access token', async () => { - mockRequest - .mockResolvedValueOnce('https://example.com') - .mockResolvedValueOnce('app-uuid') - .mockResolvedValueOnce('page-1') - .mockResolvedValueOnce('access-token'); + it('production: gets and normalizes server before the other initialization data', async () => { + mockBootstrapRequest + .mockResolvedValueOnce('https://custom-app-server.example.com') + .mockResolvedValueOnce('access-token') + .mockResolvedValueOnce('app-uuid'); + mockGetParentOrigin.mockResolvedValue('https://app.example.com'); + mockRequest.mockResolvedValueOnce('page-1'); const sdk = new HTMLPageSDK(); await sdk.init(); - expect(mockRequest).toHaveBeenNthCalledWith(1, 'get_server'); - expect(mockRequest).toHaveBeenNthCalledWith(2, 'get_app_uuid'); - expect(mockRequest).toHaveBeenNthCalledWith(3, 'get_page_id'); - expect(mockRequest).toHaveBeenNthCalledWith(4, 'get_access_token'); + expect(mockBootstrapRequest).toHaveBeenNthCalledWith(1, 'get_server'); + expect(mockBootstrapRequest).toHaveBeenNthCalledWith(2, 'get_access_token'); + expect(mockBootstrapRequest).toHaveBeenNthCalledWith(3, 'get_app_uuid'); + expect(mockGetParentOrigin).toHaveBeenCalledWith({ + server: 'https://custom-app-server.example.com/', + accessToken: 'access-token', + appUuid: 'app-uuid', + }); + expect(mockSetTargetOrigin).toHaveBeenCalledWith('https://app.example.com'); + expect(mockRequest).toHaveBeenNthCalledWith(1, 'get_page_id'); + expect(mockSetTargetOrigin.mock.invocationCallOrder[0]).toBeLessThan(mockRequest.mock.invocationCallOrder[0]); expect(mockInit).toHaveBeenCalledWith({ - server: 'https://example.com', + server: 'https://custom-app-server.example.com/', accessToken: 'access-token', appUuid: 'app-uuid', }); - expect(sdk.options.server).toBe('https://example.com'); - expect(sdk.options.appUuid).toBe('app-uuid'); - expect(sdk.options.pageId).toBe('page-1'); - expect(sdk.options.accessToken).toBe('access-token'); + expect(sdk.options).toMatchObject({ + server: 'https://custom-app-server.example.com/', + appUuid: 'app-uuid', + pageId: 'page-1', + }); + expect(sdk.options.accessToken).toBeUndefined(); + }); + + it('production: gets initialization data from iframeAdapter instead of SDK options', async () => { + mockBootstrapRequest + .mockResolvedValueOnce('https://iframe-server.example.com') + .mockResolvedValueOnce('bootstrapped-access-token') + .mockResolvedValueOnce('iframe-app-uuid'); + mockGetParentOrigin.mockResolvedValue('https://app.example.com'); + mockRequest.mockResolvedValueOnce('iframe-page-id'); + + const sdk = new HTMLPageSDK({ + server: 'https://options-server.example.com', + appUuid: 'options-app-uuid', + pageId: 'options-page-id', + accessToken: 'options-access-token', + }); + await sdk.init(); + + expect(mockBootstrapRequest).toHaveBeenNthCalledWith(1, 'get_server'); + expect(mockBootstrapRequest).toHaveBeenNthCalledWith(2, 'get_access_token'); + expect(mockBootstrapRequest).toHaveBeenNthCalledWith(3, 'get_app_uuid'); + expect(mockGetParentOrigin).toHaveBeenCalledWith({ + server: 'https://iframe-server.example.com/', + accessToken: 'bootstrapped-access-token', + appUuid: 'iframe-app-uuid', + }); + expect(mockInit).toHaveBeenCalledWith({ + server: 'https://iframe-server.example.com/', + accessToken: 'bootstrapped-access-token', + appUuid: 'iframe-app-uuid', + }); + expect(sdk.options).toMatchObject({ + server: 'https://iframe-server.example.com/', + appUuid: 'iframe-app-uuid', + pageId: 'iframe-page-id', + }); + expect(sdk.options.accessToken).toBeUndefined(); + }); + + it('production: stops initialization when GET_SERVER returns no server', async () => { + mockBootstrapRequest.mockResolvedValueOnce(''); + + const sdk = new HTMLPageSDK(); + + await expect(sdk.init()).rejects.toThrow('Missing server configuration'); + expect(mockBootstrapRequest).toHaveBeenCalledTimes(1); + expect(mockBootstrapRequest).toHaveBeenCalledWith('get_server'); + expect(mockGetParentOrigin).not.toHaveBeenCalled(); + expect(mockSetTargetOrigin).not.toHaveBeenCalled(); + expect(mockRequest).not.toHaveBeenCalled(); + expect(mockInit).not.toHaveBeenCalled(); + }); + + it('production: does not request trusted app data when parent-origin bootstrap fails', async () => { + mockBootstrapRequest + .mockResolvedValueOnce('https://custom-app-server.example.com') + .mockResolvedValueOnce('access-token') + .mockResolvedValueOnce('app-uuid'); + mockGetParentOrigin.mockRejectedValue(new Error('Permission denied.')); + + const sdk = new HTMLPageSDK(); + + await expect(sdk.init()).rejects.toThrow('Permission denied.'); + expect(mockSetTargetOrigin).not.toHaveBeenCalled(); + expect(mockRequest).not.toHaveBeenCalled(); + expect(mockInit).not.toHaveBeenCalled(); }); - it('development: exchanges accountToken for accessToken', async () => { + it('development: initializes from options and APIs without iframe requests', async () => { + mockGetParentOrigin.mockResolvedValue('https://app.example.com'); const sdk = new HTMLPageSDK({ server: 'https://example.com', appUuid: 'app-uuid', @@ -67,33 +159,60 @@ describe('HTMLPageSDK.init', () => { }); await sdk.init(); + expect(mockBootstrapRequest).not.toHaveBeenCalled(); expect(mockRequest).not.toHaveBeenCalled(); expect(mockInitWithAccountToken).toHaveBeenCalledWith({ - server: 'https://example.com', + server: 'https://example.com/', accountToken: 'account-token', appUuid: 'app-uuid', }); + expect(mockGetParentOrigin).not.toHaveBeenCalled(); + expect(mockSetTargetOrigin).not.toHaveBeenCalled(); + expect(mockInit).toHaveBeenCalledWith({ + server: 'https://example.com/', + accessToken: 'access-token', + appUuid: 'app-uuid', + }); + expect(sdk.options.server).toBe('https://example.com/'); expect(sdk.htmlPageAPI.accessToken).toBe('access-token'); - expect(sdk.options.server).toBe('https://example.com'); - expect(sdk.options.appUuid).toBe('app-uuid'); - expect(sdk.options.pageId).toBe('page-1'); - expect(sdk.options.accountToken).toBe('account-token'); }); - it('loads table permission configs for ai_agent preview', async () => { + it('development: uses development initialization when accountToken option exists but is empty', async () => { + const sdk = new HTMLPageSDK({ + server: 'https://example.com', + appUuid: 'app-uuid', + accountToken: '', + }); + await sdk.init(); + + expect(mockBootstrapRequest).not.toHaveBeenCalled(); + expect(mockRequest).not.toHaveBeenCalled(); + expect(mockInitWithAccountToken).toHaveBeenCalledWith({ + server: 'https://example.com/', + accountToken: '', + appUuid: 'app-uuid', + }); + }); + + it('production: loads ai_agent preview configs after configuring the trusted origin', async () => { const previewTableConfigs = [{ table_id: 'REW7', permissions: {} }]; - mockRequest + mockBootstrapRequest .mockResolvedValueOnce('https://example.com') - .mockResolvedValueOnce('app-uuid') + .mockResolvedValueOnce('access-token') + .mockResolvedValueOnce('app-uuid'); + mockGetParentOrigin.mockResolvedValue('https://app.example.com'); + mockRequest .mockResolvedValueOnce('ai_agent') - .mockResolvedValueOnce(previewTableConfigs) - .mockResolvedValueOnce('access-token'); + .mockResolvedValueOnce(previewTableConfigs); const sdk = new HTMLPageSDK(); await sdk.init(); - expect(mockRequest).toHaveBeenNthCalledWith(4, 'get_preview_table_configs'); - expect(mockRequest).toHaveBeenNthCalledWith(5, 'get_access_token'); + expect(mockBootstrapRequest).toHaveBeenNthCalledWith(1, 'get_server'); + expect(mockBootstrapRequest).toHaveBeenNthCalledWith(2, 'get_access_token'); + expect(mockBootstrapRequest).toHaveBeenNthCalledWith(3, 'get_app_uuid'); + expect(mockSetTargetOrigin).toHaveBeenCalledWith('https://app.example.com'); + expect(mockRequest).toHaveBeenNthCalledWith(2, 'get_preview_table_configs'); expect(sdk.options.previewTableConfigs).toEqual(previewTableConfigs); }); });