mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
321 lines
16 KiB
TypeScript
321 lines
16 KiB
TypeScript
// FE-PLUGINS-FRAME-001 to 012
|
|
import { render, cleanup, waitFor, fireEvent, screen, act } from '@testing-library/react';
|
|
import PluginFrame from './PluginFrame';
|
|
import { usePluginStore } from '../../store/pluginStore';
|
|
|
|
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 }));
|
|
const wsListeners = new Set<(ev: Record<string, unknown>) => void>();
|
|
|
|
vi.mock('react-router-dom', () => ({ useNavigate: () => navigate }));
|
|
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
|
|
vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en', t: (k: string) => k }) }));
|
|
vi.mock('../../store/authStore', () => ({ useAuthStore: (sel: (s: unknown) => unknown) => sel({ user: { id: 7, username: 'ada', avatar_url: null, role: 'admin' } }) }));
|
|
vi.mock('../../store/settingsStore', () => ({ useSettingsStore: (sel: (s: unknown) => unknown) => sel({ settings: { default_currency: 'EUR', time_format: '24h', distance_unit: 'metric', temperature_unit: 'celsius', blur_booking_codes: true } }) }));
|
|
vi.mock('../../api/client', () => ({ pluginsApi: { invoke: (id: string, sub: string, init?: unknown) => invoke(id, sub, init) } }));
|
|
vi.mock('../../api/websocket', () => ({
|
|
addListener: (fn: (ev: Record<string, unknown>) => void) => wsListeners.add(fn),
|
|
removeListener: (fn: (ev: Record<string, unknown>) => void) => wsListeners.delete(fn),
|
|
}));
|
|
|
|
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();
|
|
wsListeners.clear();
|
|
});
|
|
|
|
describe('PluginFrame', () => {
|
|
it('FE-PLUGINS-FRAME-001: renders an opaque sandboxed iframe (no allow-same-origin)', () => {
|
|
const { container } = render(<PluginFrame pluginId="demo" />);
|
|
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(<PluginFrame pluginId="demo" />);
|
|
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(<PluginFrame pluginId="demo" />);
|
|
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(<PluginFrame pluginId="demo" />);
|
|
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));
|
|
});
|
|
|
|
it('FE-PLUGINS-FRAME-005: context carries theme tokens, formats and non-secret display identity', () => {
|
|
const { container } = render(<PluginFrame pluginId="demo" />);
|
|
const iframe = container.querySelector('iframe')!;
|
|
const posted: Array<Record<string, unknown>> = [];
|
|
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record<string, unknown>);
|
|
|
|
fromFrame(iframe, { type: 'trek:context:request' });
|
|
|
|
const ctx = posted.find((m) => m.type === 'trek:context') as Record<string, unknown> | undefined;
|
|
expect(ctx).toBeTruthy();
|
|
expect(ctx!.tokens).toBeTruthy(); // resolved design tokens (empty {} in jsdom, but present)
|
|
expect(ctx!.formats).toMatchObject({
|
|
currency: 'EUR',
|
|
timeFormat: '24h',
|
|
distanceUnit: 'metric',
|
|
temperatureUnit: 'celsius',
|
|
blurBookingCodes: true,
|
|
});
|
|
// Display identity is present but carries NO secret (no email, role only as a boolean).
|
|
expect(ctx!.user).toMatchObject({ name: 'ada', isAdmin: true });
|
|
expect(JSON.stringify(ctx)).not.toContain('@'); // no email leaked
|
|
});
|
|
|
|
it('FE-PLUGINS-FRAME-008: a day-detail host passes the open day (and place stays null)', () => {
|
|
const { container } = render(<PluginFrame pluginId="demo" tripId="1" dayId="12" />);
|
|
const iframe = container.querySelector('iframe')!;
|
|
const posted: Array<Record<string, unknown>> = [];
|
|
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record<string, unknown>);
|
|
|
|
fromFrame(iframe, { type: 'trek:context:request' });
|
|
|
|
const ctx = posted.find((m) => m.type === 'trek:context') as Record<string, unknown> | undefined;
|
|
expect(ctx).toBeTruthy();
|
|
expect(ctx!.tripId).toBe('1');
|
|
expect(ctx!.dayId).toBe('12');
|
|
expect(ctx!.placeId).toBeNull();
|
|
});
|
|
|
|
it('FE-PLUGINS-FRAME-014: a reservation-detail host passes the open reservation (and day/place stay null)', () => {
|
|
const { container } = render(<PluginFrame pluginId="demo" tripId="1" reservationId="88" />);
|
|
const iframe = container.querySelector('iframe')!;
|
|
const posted: Array<Record<string, unknown>> = [];
|
|
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record<string, unknown>);
|
|
|
|
fromFrame(iframe, { type: 'trek:context:request' });
|
|
|
|
const ctx = posted.find((m) => m.type === 'trek:context') as Record<string, unknown> | undefined;
|
|
expect(ctx).toBeTruthy();
|
|
expect(ctx!.tripId).toBe('1');
|
|
expect(ctx!.reservationId).toBe('88');
|
|
expect(ctx!.dayId).toBeNull();
|
|
expect(ctx!.placeId).toBeNull();
|
|
});
|
|
|
|
it('FE-PLUGINS-FRAME-006: context mirrors the host appearance state (scheme/density/flags)', () => {
|
|
const { container } = render(<PluginFrame pluginId="demo" />);
|
|
const iframe = container.querySelector('iframe')!;
|
|
const posted: Array<Record<string, unknown>> = [];
|
|
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record<string, unknown>);
|
|
|
|
fromFrame(iframe, { type: 'trek:context:request' });
|
|
|
|
const ctx = posted.find((m) => m.type === 'trek:context') as Record<string, unknown> | undefined;
|
|
expect(ctx).toBeTruthy();
|
|
// A plugin can honour the same accent/density/accessibility choices as the host.
|
|
expect(ctx!.appearance).toMatchObject({
|
|
scheme: 'default',
|
|
density: 'comfortable',
|
|
noTransparency: false,
|
|
reducedMotion: false,
|
|
});
|
|
});
|
|
|
|
it('FE-PLUGINS-FRAME-007: fill mode pins the frame to 100% height and ignores trek:resize', () => {
|
|
const { container } = render(<PluginFrame pluginId="demo" fill />);
|
|
const iframe = container.querySelector('iframe')!;
|
|
act(() => { fromFrame(iframe, { type: 'trek:resize', height: 480 }); });
|
|
expect(iframe.style.height).toBe('100%');
|
|
});
|
|
|
|
it('FE-PLUGINS-FRAME-008: without fill, trek:resize drives the frame height (widget self-sizing)', () => {
|
|
const { container } = render(<PluginFrame pluginId="demo" />);
|
|
const iframe = container.querySelector('iframe')!;
|
|
act(() => { fromFrame(iframe, { type: 'trek:resize', height: 480 }); });
|
|
expect(iframe.style.height).toBe('480px');
|
|
});
|
|
|
|
it('FE-PLUGINS-FRAME-009: trek:confirm renders the native dialog and answers over the bridge', async () => {
|
|
const { container } = render(<PluginFrame pluginId="demo" />);
|
|
const iframe = container.querySelector('iframe')!;
|
|
const posted: Array<Record<string, unknown>> = [];
|
|
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record<string, unknown>);
|
|
|
|
fromFrame(iframe, { type: 'trek:confirm', requestId: 'c1', message: 'Delete everything?', confirmLabel: 'Yes, wipe it' });
|
|
const confirmBtn = await screen.findByText('Yes, wipe it');
|
|
fireEvent.click(confirmBtn);
|
|
|
|
const result = posted.find((m) => m.type === 'trek:confirm:result') as Record<string, unknown> | undefined;
|
|
expect(result).toMatchObject({ requestId: 'c1', confirmed: true });
|
|
});
|
|
|
|
it('FE-PLUGINS-FRAME-010: trek:openExternal opens only real web URLs in a noopener tab', () => {
|
|
const open = vi.spyOn(window, 'open').mockReturnValue(null);
|
|
const { container } = render(<PluginFrame pluginId="demo" />);
|
|
const iframe = container.querySelector('iframe')!;
|
|
|
|
fromFrame(iframe, { type: 'trek:openExternal', url: 'javascript:alert(1)' });
|
|
fromFrame(iframe, { type: 'trek:openExternal', url: 'not a url' });
|
|
expect(open).not.toHaveBeenCalled();
|
|
|
|
fromFrame(iframe, { type: 'trek:openExternal', url: 'https://example.com/docs' });
|
|
expect(open).toHaveBeenCalledWith('https://example.com/docs', '_blank', 'noopener,noreferrer');
|
|
open.mockRestore();
|
|
});
|
|
|
|
it('FE-PLUGINS-FRAME-011: forwards core-event names for the trip in view, payload-free', () => {
|
|
const { container } = render(<PluginFrame pluginId="demo" tripId="42" />);
|
|
const iframe = container.querySelector('iframe')!;
|
|
const posted: Array<Record<string, unknown>> = [];
|
|
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record<string, unknown>);
|
|
// The bridge only forwards to the original document (first load).
|
|
fireEvent.load(iframe);
|
|
|
|
expect(wsListeners.size).toBe(1);
|
|
const emit = [...wsListeners][0];
|
|
emit({ type: 'place_created', tripId: 99, place: { secret: true } }); // other trip -> dropped
|
|
emit({ type: 'place_created', tripId: 42, place: { secret: true } });
|
|
|
|
const events = posted.filter((m) => m.type === 'trek:event');
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0]).toMatchObject({ event: 'place_created', tripId: '42' });
|
|
expect(JSON.stringify(events[0])).not.toContain('secret'); // names only, never payloads
|
|
});
|
|
|
|
it('FE-PLUGINS-FRAME-012: trek:notify clamps a plugin-supplied duration', () => {
|
|
const { container } = render(<PluginFrame pluginId="demo" />);
|
|
const iframe = container.querySelector('iframe')!;
|
|
fromFrame(iframe, { type: 'trek:notify', level: 'info', message: 'hi', duration: 999999 });
|
|
expect(toast.info).toHaveBeenCalledWith('hi', 15000);
|
|
// NaN must not slip through the clamp as a sticky toast.
|
|
fromFrame(iframe, { type: 'trek:notify', level: 'info', message: 'ho', duration: NaN });
|
|
expect(toast.info).toHaveBeenLastCalledWith('ho');
|
|
});
|
|
|
|
it('FE-PLUGINS-FRAME-013: swapping pluginId in place restarts the bridge for the new plugin', () => {
|
|
const { container, rerender } = render(<PluginFrame pluginId="alpha" />);
|
|
fireEvent.load(container.querySelector('iframe')!);
|
|
|
|
rerender(<PluginFrame pluginId="beta" />);
|
|
const next = container.querySelector('iframe')!; // keyed by pluginId -> fresh element
|
|
expect(next.getAttribute('src')).toBe('/plugin-frame/beta/index.html');
|
|
act(() => { fireEvent.load(next); });
|
|
|
|
// Without the per-plugin reset this would be refused as a "navigated" frame.
|
|
fromFrame(next, { type: 'trek:navigate', to: '/dashboard' });
|
|
expect(navigate).toHaveBeenCalledWith('/dashboard');
|
|
});
|
|
|
|
describe('geolocation bridge (geolocation:read)', () => {
|
|
const geo = {
|
|
getCurrentPosition: vi.fn(),
|
|
watchPosition: vi.fn((..._args: unknown[]) => 7),
|
|
clearWatch: vi.fn(),
|
|
};
|
|
const grant = (granted: boolean) => {
|
|
usePluginStore.setState({
|
|
plugins: granted ? [{ id: 'demo', name: 'Demo', type: 'widget', icon: null, geolocation: true }] : [],
|
|
});
|
|
};
|
|
beforeEach(() => {
|
|
Object.defineProperty(navigator, 'geolocation', { value: geo, configurable: true });
|
|
geo.getCurrentPosition.mockClear();
|
|
geo.watchPosition.mockClear();
|
|
geo.clearWatch.mockClear();
|
|
});
|
|
afterEach(() => { grant(false); });
|
|
|
|
function mount(granted: boolean) {
|
|
grant(granted);
|
|
const { container, unmount } = render(<PluginFrame pluginId="demo" />);
|
|
const iframe = container.querySelector('iframe')!;
|
|
const posted: Array<Record<string, unknown>> = [];
|
|
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) =>
|
|
posted.push(m as Record<string, unknown>);
|
|
return { iframe, posted, unmount };
|
|
}
|
|
|
|
it('FE-PLUGINS-FRAME-015: refuses an ungranted plugin without touching the browser API', () => {
|
|
const { iframe, posted } = mount(false);
|
|
fromFrame(iframe, { type: 'trek:geolocation', requestId: 'g1' });
|
|
expect(posted.find((m) => m.type === 'trek:geolocation:result')).toMatchObject({ requestId: 'g1', error: 'forbidden' });
|
|
expect(geo.getCurrentPosition).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-PLUGINS-FRAME-016: a granted get posts plain position data into the frame', () => {
|
|
geo.getCurrentPosition.mockImplementation((ok: (p: unknown) => void) =>
|
|
ok({ coords: { latitude: 52.5, longitude: 13.4, accuracy: 9, heading: null, speed: null }, timestamp: 1234 }));
|
|
const { iframe, posted } = mount(true);
|
|
fromFrame(iframe, { type: 'trek:geolocation', requestId: 'g2' });
|
|
expect(posted.find((m) => m.type === 'trek:geolocation:result')).toMatchObject({
|
|
requestId: 'g2',
|
|
position: { lat: 52.5, lng: 13.4, accuracy: 9, timestamp: 1234 },
|
|
});
|
|
});
|
|
|
|
it('FE-PLUGINS-FRAME-017: watch streams updates and the GPS watch dies with the frame', () => {
|
|
let tick: ((p: unknown) => void) | null = null;
|
|
geo.watchPosition.mockImplementation((ok: (p: unknown) => void) => { tick = ok; return 7; });
|
|
const { iframe, posted, unmount } = mount(true);
|
|
|
|
fromFrame(iframe, { type: 'trek:geolocation', requestId: 'g3', action: 'watch' });
|
|
expect(posted.find((m) => m.type === 'trek:geolocation:result')).toMatchObject({ requestId: 'g3', watching: true });
|
|
act(() => tick!({ coords: { latitude: 1, longitude: 2, accuracy: 5, heading: null, speed: null }, timestamp: 1 }));
|
|
expect(posted.find((m) => m.type === 'trek:geolocation:update')).toMatchObject({ position: { lat: 1, lng: 2 } });
|
|
|
|
// Unmounting must never leave a live GPS watch behind.
|
|
unmount();
|
|
expect(geo.clearWatch).toHaveBeenCalledWith(7);
|
|
});
|
|
|
|
it('FE-PLUGINS-FRAME-018: clear stops the watch on request', () => {
|
|
const { iframe, posted } = mount(true);
|
|
fromFrame(iframe, { type: 'trek:geolocation', requestId: 'g4', action: 'watch' });
|
|
fromFrame(iframe, { type: 'trek:geolocation', requestId: 'g5', action: 'clear' });
|
|
expect(geo.clearWatch).toHaveBeenCalledWith(7);
|
|
expect(posted.find((m) => m.type === 'trek:geolocation:result' && m.requestId === 'g5')).toMatchObject({ cleared: true });
|
|
});
|
|
|
|
it('FE-PLUGINS-FRAME-019: a running watch stops streaming the moment the grant is revoked', () => {
|
|
let tick: ((p: unknown) => void) | null = null;
|
|
geo.watchPosition.mockImplementation((ok: (p: unknown) => void) => { tick = ok; return 7; });
|
|
const { iframe, posted } = mount(true);
|
|
fromFrame(iframe, { type: 'trek:geolocation', requestId: 'g6', action: 'watch' });
|
|
|
|
// Admin revokes geolocation:read while the frame stays mounted (re-render
|
|
// updates the ref the watch callback reads).
|
|
act(() => grant(false));
|
|
const before = posted.length;
|
|
act(() => tick!({ coords: { latitude: 3, longitude: 4, accuracy: 5, heading: null, speed: null }, timestamp: 2 }));
|
|
// No further position leaks, and the OS watch is released.
|
|
expect(posted.length).toBe(before);
|
|
expect(geo.clearWatch).toHaveBeenCalledWith(7);
|
|
});
|
|
});
|
|
});
|