import { PluginBaseCfg, PluginIframeMessageType, PluginManifest, } from '@super-productivity/plugin-api'; import { PluginBridgeService } from '../plugin-bridge.service'; import { buildPluginIframeHtml, createPluginApiScript, handlePluginMessage, PluginIframeConfig, PLUGIN_IFRAME_SANDBOX, } from './plugin-iframe.util'; describe('handlePluginMessage()', () => { const createConfig = ( pluginBridge: PluginBridgeService, boundMethods?: PluginIframeConfig['boundMethods'], ): PluginIframeConfig => ({ pluginId: 'test-plugin', manifest: { id: 'test-plugin', name: 'Test Plugin', manifestVersion: 1, version: '1.0.0', minSupVersion: '1.0.0', hooks: [], permissions: [], } as PluginManifest, indexHtml: '', baseCfg: { theme: 'light', appVersion: '1.0.0', platform: 'web', isDev: false, } as PluginBaseCfg, pluginBridge, bridgeToken: 'test-bridge-token', bridgeGeneration: 4, boundMethods, }); it('rebuilds iframe dialog button handlers and returns the selected result', async () => { let bridgedDialogCfg: | { buttons?: Array>; } | undefined; const sourceWindow = window; const postMessageSpy = spyOn(sourceWindow, 'postMessage') as jasmine.Spy; const pluginBridge = { createBoundMethods: () => ({}), openDialog: async (dialogCfg: { buttons?: Array> }) => { bridgedDialogCfg = dialogCfg; const onClick = dialogCfg.buttons?.[0].onClick as | (() => Promise) | undefined; const clickPromise = onClick?.(); window.dispatchEvent( new MessageEvent('message', { source: sourceWindow as unknown as MessageEventSource, data: { type: PluginIframeMessageType.DIALOG_BUTTON_RESPONSE, bridgeToken: 'test-bridge-token', bridgeGeneration: 4, dialogCallId: 7, buttonIndex: 0, result: undefined, }, }), ); await clickPromise; return 'Confirm'; }, } as unknown as PluginBridgeService; const config = createConfig(pluginBridge); await handlePluginMessage( { data: { type: PluginIframeMessageType.API_CALL, bridgeToken: 'test-bridge-token', bridgeGeneration: 4, method: 'openDialog', callId: 7, args: [ { buttons: [ { label: 'Confirm', __hasDialogButtonHandler: true, }, ], }, ], }, source: sourceWindow, } as unknown as MessageEvent, config, ); expect(bridgedDialogCfg?.buttons?.[0].onClick).toEqual(jasmine.any(Function)); expect(bridgedDialogCfg?.buttons?.[0].__hasDialogButtonHandler).toBeUndefined(); expect(postMessageSpy).toHaveBeenCalledWith( { type: PluginIframeMessageType.DIALOG_BUTTON_CLICK, buttonIndex: 0, dialogCallId: 7, }, { targetOrigin: '*' }, ); expect(postMessageSpy).toHaveBeenCalledWith( { type: PluginIframeMessageType.API_RESPONSE, callId: 7, result: 'Confirm', }, '*', ); }); it('reports iframe dialog button handler errors as API errors', async () => { const sourceWindow = window; const postMessageSpy = spyOn(sourceWindow, 'postMessage') as jasmine.Spy; const pluginBridge = { createBoundMethods: () => ({}), openDialog: async (dialogCfg: { buttons?: Array> }) => { const onClick = dialogCfg.buttons?.[0].onClick as | (() => Promise) | undefined; const clickPromise = onClick?.(); window.dispatchEvent( new MessageEvent('message', { source: sourceWindow as unknown as MessageEventSource, data: { type: PluginIframeMessageType.DIALOG_BUTTON_RESPONSE, bridgeToken: 'test-bridge-token', bridgeGeneration: 4, dialogCallId: 8, buttonIndex: 0, error: 'Button failed', }, }), ); await clickPromise; }, } as unknown as PluginBridgeService; await handlePluginMessage( { data: { type: PluginIframeMessageType.API_CALL, bridgeToken: 'test-bridge-token', bridgeGeneration: 4, method: 'openDialog', callId: 8, args: [ { buttons: [ { label: 'Confirm', __hasDialogButtonHandler: true, }, ], }, ], }, source: sourceWindow, } as unknown as MessageEvent, createConfig(pluginBridge), ); expect(postMessageSpy).toHaveBeenCalledWith( { type: PluginIframeMessageType.API_ERROR, callId: 8, error: 'Button failed', }, '*', ); }); it('ignores dialog button responses from a different iframe source', async () => { let dialogButtonResult: unknown; const sourceWindow = window; spyOn(sourceWindow, 'postMessage'); const otherWindow = new MessageChannel().port1; const pluginBridge = { createBoundMethods: () => ({}), openDialog: async (dialogCfg: { buttons?: Array> }) => { const onClick = dialogCfg.buttons?.[0].onClick as | (() => Promise) | undefined; const clickPromise = onClick?.(); window.dispatchEvent( new MessageEvent('message', { source: otherWindow as unknown as MessageEventSource, data: { type: PluginIframeMessageType.DIALOG_BUTTON_RESPONSE, bridgeToken: 'test-bridge-token', bridgeGeneration: 3, dialogCallId: 10, buttonIndex: 0, result: 'spoofed', }, }), ); window.dispatchEvent( new MessageEvent('message', { source: sourceWindow as unknown as MessageEventSource, data: { type: PluginIframeMessageType.DIALOG_BUTTON_RESPONSE, bridgeToken: 'test-bridge-token', bridgeGeneration: 4, dialogCallId: 10, buttonIndex: 0, result: 'trusted', }, }), ); dialogButtonResult = await clickPromise; return 'Confirm'; }, } as unknown as PluginBridgeService; await handlePluginMessage( { data: { type: PluginIframeMessageType.API_CALL, bridgeToken: 'test-bridge-token', bridgeGeneration: 4, method: 'openDialog', callId: 10, args: [ { buttons: [ { label: 'Confirm', __hasDialogButtonHandler: true, }, ], }, ], }, source: sourceWindow, } as unknown as MessageEvent, createConfig(pluginBridge), ); expect(dialogButtonResult).toBe('trusted'); }); it('routes iframe i18n API calls through plugin-bound methods', async () => { const sourceWindow = jasmine.createSpyObj<{ postMessage: jasmine.Spy }>( 'sourceWindow', ['postMessage'], ); const translate = jasmine .createSpy('translate') .withArgs('DATE.YESTERDAY', { days: 1 }) .and.returnValue('Gestern'); const pluginBridge = { createBoundMethods: () => ({ translate, }), } as unknown as PluginBridgeService; await handlePluginMessage( { data: { type: PluginIframeMessageType.API_CALL, bridgeToken: 'test-bridge-token', bridgeGeneration: 4, method: 'translate', callId: 11, args: ['DATE.YESTERDAY', { days: 1 }], }, source: sourceWindow, } as unknown as MessageEvent, createConfig(pluginBridge), ); expect(translate).toHaveBeenCalledOnceWith('DATE.YESTERDAY', { days: 1 }); expect(sourceWindow.postMessage).toHaveBeenCalledWith( { type: PluginIframeMessageType.API_RESPONSE, callId: 11, result: 'Gestern', }, '*', ); }); it('routes getFocusedTask iframe API calls through plugin-bound methods', async () => { const sourceWindow = jasmine.createSpyObj<{ postMessage: jasmine.Spy }>( 'sourceWindow', ['postMessage'], ); const focusedTask = { id: 'focused-task', title: 'Focused Task' }; const getFocusedTask = jasmine.createSpy('getFocusedTask').and.resolveTo(focusedTask); const pluginBridge = { createBoundMethods: () => ({ getFocusedTask, }), } as unknown as PluginBridgeService; await handlePluginMessage( { data: { type: PluginIframeMessageType.API_CALL, bridgeToken: 'test-bridge-token', bridgeGeneration: 4, method: 'getFocusedTask', callId: 15, args: [], }, source: sourceWindow, } as unknown as MessageEvent, createConfig(pluginBridge), ); expect(getFocusedTask).toHaveBeenCalledTimes(1); expect(sourceWindow.postMessage).toHaveBeenCalledWith( { type: PluginIframeMessageType.API_RESPONSE, callId: 15, result: focusedTask, }, '*', ); }); it('routes getSelectedTask iframe API calls through plugin-bound methods', async () => { const sourceWindow = jasmine.createSpyObj<{ postMessage: jasmine.Spy }>( 'sourceWindow', ['postMessage'], ); const selectedTask = { id: 'selected-task', title: 'Selected Task' }; const getSelectedTask = jasmine .createSpy('getSelectedTask') .and.resolveTo(selectedTask); const pluginBridge = { createBoundMethods: () => ({ getSelectedTask, }), } as unknown as PluginBridgeService; await handlePluginMessage( { data: { type: PluginIframeMessageType.API_CALL, bridgeToken: 'test-bridge-token', bridgeGeneration: 4, method: 'getSelectedTask', callId: 16, args: [], }, source: sourceWindow, } as unknown as MessageEvent, createConfig(pluginBridge), ); expect(getSelectedTask).toHaveBeenCalledTimes(1); expect(sourceWindow.postMessage).toHaveBeenCalledWith( { type: PluginIframeMessageType.API_RESPONSE, callId: 16, result: selectedTask, }, '*', ); }); it('generates iframe code that waits for async dialog button handlers', () => { const script = createPluginApiScript( createConfig({ createBoundMethods: () => ({}) } as unknown as PluginBridgeService), ); expect(script).toContain('Promise.resolve()'); expect(script).toContain('.then(() => handler())'); expect(script).toContain('Unknown dialog button error'); expect(script).toContain('const bridgeToken = "test-bridge-token"'); expect(script).toContain('const bridgeGeneration = 4'); expect(script).toContain("getSelectedTask: () => callApi('getSelectedTask')"); expect(script).toContain("getFocusedTask: () => callApi('getFocusedTask')"); expect(script).toContain( "registerHeaderButton: unsupportedIframeRegistration('registerHeaderButton')", ); }); it('keeps iframe plugins on an opaque sandbox origin', () => { expect(PLUGIN_IFRAME_SANDBOX).toContain('allow-scripts'); expect(PLUGIN_IFRAME_SANDBOX).not.toContain('allow-same-origin'); }); describe('buildPluginIframeHtml()', () => { const pluginBridge = { createBoundMethods: () => ({}), } as unknown as PluginBridgeService; it('returns an inline HTML document (not a blob: URL) for srcdoc', () => { const html = buildPluginIframeHtml({ ...createConfig(pluginBridge), indexHtml: '
', }); expect(typeof html).toBe('string'); expect(html.startsWith('blob:')).toBe(false); // plugin content is preserved expect(html).toContain('
'); }); it('injects the API bridge script (with the bridge token) before ', () => { const html = buildPluginIframeHtml({ ...createConfig(pluginBridge), indexHtml: '', }); expect(html).toContain('const bridgeToken = "test-bridge-token"'); expect(html).toContain('window.PluginAPI'); // script is injected inside the body, before its closing tag expect(html.indexOf('window.PluginAPI')).toBeLessThan(html.indexOf('')); }); }); it('rejects raw iframe calls to bridge methods outside the iframe API allowlist', async () => { const sourceWindow = jasmine.createSpyObj<{ postMessage: jasmine.Spy }>( 'sourceWindow', ['postMessage'], ); const pluginBridge = { createBoundMethods: () => ({}), requestNodeExecutionGrant: jasmine.createSpy('requestNodeExecutionGrant'), } as unknown as PluginBridgeService; await handlePluginMessage( { data: { type: PluginIframeMessageType.API_CALL, bridgeToken: 'test-bridge-token', bridgeGeneration: 4, method: 'requestNodeExecutionGrant', callId: 9, args: ['sync-md'], }, source: sourceWindow, } as unknown as MessageEvent, createConfig(pluginBridge), ); expect( ( pluginBridge as unknown as { requestNodeExecutionGrant: jasmine.Spy; } ).requestNodeExecutionGrant, ).not.toHaveBeenCalled(); expect(sourceWindow.postMessage).toHaveBeenCalledWith( { type: PluginIframeMessageType.API_ERROR, callId: 9, error: 'Unknown API method: requestNodeExecutionGrant', }, '*', ); }); it('ignores iframe API calls without the bridge token', async () => { const sourceWindow = jasmine.createSpyObj<{ postMessage: jasmine.Spy }>( 'sourceWindow', ['postMessage'], ); const pluginBridge = { createBoundMethods: () => ({}), getTasks: jasmine.createSpy('getTasks'), } as unknown as PluginBridgeService; await handlePluginMessage( { data: { type: PluginIframeMessageType.API_CALL, method: 'getTasks', callId: 12, }, source: sourceWindow, } as unknown as MessageEvent, createConfig(pluginBridge), ); expect( ( pluginBridge as unknown as { getTasks: jasmine.Spy; } ).getTasks, ).not.toHaveBeenCalled(); expect(sourceWindow.postMessage).not.toHaveBeenCalled(); }); it('ignores iframe API calls from a stale bridge generation', async () => { const sourceWindow = jasmine.createSpyObj<{ postMessage: jasmine.Spy }>( 'sourceWindow', ['postMessage'], ); const pluginBridge = { createBoundMethods: () => ({}), getTasks: jasmine.createSpy('getTasks'), } as unknown as PluginBridgeService; await handlePluginMessage( { data: { type: PluginIframeMessageType.API_CALL, bridgeToken: 'test-bridge-token', bridgeGeneration: 3, method: 'getTasks', callId: 13, }, source: sourceWindow, } as unknown as MessageEvent, createConfig(pluginBridge), ); expect( ( pluginBridge as unknown as { getTasks: jasmine.Spy; } ).getTasks, ).not.toHaveBeenCalled(); expect(sourceWindow.postMessage).not.toHaveBeenCalled(); }); it('rejects raw iframe registration APIs that need callback proxying', async () => { const sourceWindow = jasmine.createSpyObj<{ postMessage: jasmine.Spy }>( 'sourceWindow', ['postMessage'], ); const pluginBridge = { createBoundMethods: () => ({}), registerHeaderButton: jasmine.createSpy('registerHeaderButton'), } as unknown as PluginBridgeService; await handlePluginMessage( { data: { type: PluginIframeMessageType.API_CALL, bridgeToken: 'test-bridge-token', bridgeGeneration: 4, method: 'registerHeaderButton', callId: 14, args: [{ label: 'Run' }], }, source: sourceWindow, } as unknown as MessageEvent, createConfig(pluginBridge), ); expect( ( pluginBridge as unknown as { registerHeaderButton: jasmine.Spy; } ).registerHeaderButton, ).not.toHaveBeenCalled(); expect(sourceWindow.postMessage).toHaveBeenCalledWith( { type: PluginIframeMessageType.API_ERROR, callId: 14, error: 'Unknown API method: registerHeaderButton', }, '*', ); }); it('rejects raw iframe registerHook calls with legacy string handlers', async () => { const sourceWindow = jasmine.createSpyObj<{ postMessage: jasmine.Spy }>( 'sourceWindow', ['postMessage'], ); const pluginBridge = { createBoundMethods: () => ({}), registerHook: jasmine.createSpy('registerHook'), } as unknown as PluginBridgeService; await handlePluginMessage( { data: { type: PluginIframeMessageType.API_CALL, bridgeToken: 'test-bridge-token', bridgeGeneration: 4, method: 'registerHook', callId: 11, args: ['TASK_UPDATE', '() => window.__unexpectedParentHandler = true'], }, source: sourceWindow, } as unknown as MessageEvent, createConfig(pluginBridge), ); expect( ( pluginBridge as unknown as { registerHook: jasmine.Spy; } ).registerHook, ).not.toHaveBeenCalled(); expect(sourceWindow.postMessage).toHaveBeenCalledWith( { type: PluginIframeMessageType.API_ERROR, callId: 11, error: 'Iframe registerHook calls must use IFRAME_HANDLER', }, '*', ); }); it('forwards legacy iframe plugin messages without requiring a bridge token', async () => { const sourceWindow = jasmine.createSpyObj<{ postMessage: jasmine.Spy }>( 'sourceWindow', ['postMessage'], ); const pluginBridge = { createBoundMethods: () => ({}), sendMessageToPlugin: jasmine .createSpy('sendMessageToPlugin') .and.resolveTo({ ok: true }), } as unknown as PluginBridgeService; await handlePluginMessage( { data: { type: PluginIframeMessageType.MESSAGE, messageId: 'msg-1', message: { type: 'getConfig' }, }, source: sourceWindow, } as unknown as MessageEvent, createConfig(pluginBridge), ); expect( ( pluginBridge as unknown as { sendMessageToPlugin: jasmine.Spy; } ).sendMessageToPlugin, ).toHaveBeenCalledOnceWith('test-plugin', { type: 'getConfig' }); expect(sourceWindow.postMessage).toHaveBeenCalledWith( { type: PluginIframeMessageType.MESSAGE_RESPONSE, messageId: 'msg-1', result: { ok: true }, }, '*', ); }); });