super-productivity/src/app/plugins/util/plugin-iframe.util.spec.ts
Meh-S-Eze 3db96fd8a3
fix(plugins): expose focused task API to iframe plugins (#8291)
Co-authored-by: Shem Freeze <whatamehs@Shems-MacBook-Air.local>
2026-06-15 14:57:34 +02:00

656 lines
19 KiB
TypeScript

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<Record<string, unknown>>;
}
| undefined;
const sourceWindow = window;
const postMessageSpy = spyOn(sourceWindow, 'postMessage') as jasmine.Spy;
const pluginBridge = {
createBoundMethods: () => ({}),
openDialog: async (dialogCfg: { buttons?: Array<Record<string, unknown>> }) => {
bridgedDialogCfg = dialogCfg;
const onClick = dialogCfg.buttons?.[0].onClick as
| (() => Promise<void>)
| 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<Record<string, unknown>> }) => {
const onClick = dialogCfg.buttons?.[0].onClick as
| (() => Promise<void>)
| 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<Record<string, unknown>> }) => {
const onClick = dialogCfg.buttons?.[0].onClick as
| (() => Promise<unknown>)
| 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: '<html><head></head><body><div id="app"></div></body></html>',
});
expect(typeof html).toBe('string');
expect(html.startsWith('blob:')).toBe(false);
// plugin content is preserved
expect(html).toContain('<div id="app"></div>');
});
it('injects the API bridge script (with the bridge token) before </body>', () => {
const html = buildPluginIframeHtml({
...createConfig(pluginBridge),
indexHtml: '<html><head></head><body></body></html>',
});
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('</body>'));
});
});
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 },
},
'*',
);
});
});