// FE-PLUGINS-FRAME-001 to 004
import { render, cleanup, waitFor } from '@testing-library/react';
import PluginFrame from './PluginFrame';
const navigate = vi.fn();
const toast = { info: vi.fn(), success: vi.fn(), warning: vi.fn(), error: vi.fn() };
const invoke = vi.fn((..._args: unknown[]) => Promise.resolve({ ok: true }));
vi.mock('react-router-dom', () => ({ useNavigate: () => navigate }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en' }) }));
vi.mock('../../store/authStore', () => ({ useAuthStore: (sel: (s: unknown) => unknown) => sel({ user: { id: 7 } }) }));
vi.mock('../../api/client', () => ({ pluginsApi: { invoke: (id: string, sub: string, init?: unknown) => invoke(id, sub, init) } }));
function fromFrame(frame: HTMLIFrameElement, data: unknown) {
window.dispatchEvent(new MessageEvent('message', { source: frame.contentWindow, data } as MessageEventInit));
}
afterEach(() => {
cleanup();
navigate.mockClear();
Object.values(toast).forEach((f) => f.mockClear());
invoke.mockClear();
});
describe('PluginFrame', () => {
it('FE-PLUGINS-FRAME-001: renders an opaque sandboxed iframe (no allow-same-origin)', () => {
const { container } = render();
const iframe = container.querySelector('iframe')!;
expect(iframe.getAttribute('src')).toBe('/plugin-frame/demo/index.html');
const sandbox = iframe.getAttribute('sandbox') || '';
expect(sandbox).toContain('allow-scripts');
expect(sandbox).not.toContain('allow-same-origin');
});
it('FE-PLUGINS-FRAME-002: authenticates messages by sender window — a foreign source is ignored', () => {
const { container } = render();
const iframe = container.querySelector('iframe')!;
// message NOT from our iframe -> ignored
window.dispatchEvent(new MessageEvent('message', { source: window, data: { type: 'trek:navigate', to: '/admin' } }));
expect(navigate).not.toHaveBeenCalled();
// message from our iframe -> handled
fromFrame(iframe, { type: 'trek:navigate', to: '/dashboard' });
expect(navigate).toHaveBeenCalledWith('/dashboard');
});
it('FE-PLUGINS-FRAME-003: blocks unsafe navigation targets and renders notifications as text', () => {
const { container } = render();
const iframe = container.querySelector('iframe')!;
fromFrame(iframe, { type: 'trek:navigate', to: '//evil.example' }); // protocol-relative
expect(navigate).not.toHaveBeenCalled();
fromFrame(iframe, { type: 'trek:notify', level: 'success', message: 'saved' });
expect(toast.success).toHaveBeenCalledWith('saved');
});
it('FE-PLUGINS-FRAME-004: trek:invoke calls the host proxy and replies to the frame', async () => {
const { container } = render();
const iframe = container.querySelector('iframe')!;
const posted: unknown[] = [];
// capture host->frame messages
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m);
fromFrame(iframe, { type: 'trek:invoke', requestId: 'r1', sub: '/status', method: 'GET' });
await waitFor(() => expect(invoke).toHaveBeenCalledWith('demo', '/status', { method: 'GET', body: undefined }));
await waitFor(() => expect(posted.some((m) => (m as { type?: string }).type === 'trek:response')).toBe(true));
});
});