Files
TREK/server/tests/unit/plugins/plugin-frame.test.ts
T
mauriceboe 037eb8a209 feat(plugins): sandboxed page/widget frames + trekBridge (M3)
Plugins can now render UI. Page plugins appear as a nav entry and open a
full-page sandboxed iframe; the frame talks to TREK only over the postMessage
bridge.

- Server serves plugin client assets at /plugin-frame/:id/* with a strict path
  guard and a locked-down, per-plugin CSP (default-src none; connect-src limited
  to declared outbound hosts; sandbox WITHOUT allow-same-origin -> opaque origin,
  so the frame can't read the session cookie or the parent DOM). Global CSP
  frameSrc relaxed from 'none' to 'self' for exactly these frames.
- GET /api/plugins feed lists active plugins for the client.
- Client: pluginStore + PluginFrame (the trekBridge host) authenticates every
  inbound message by SENDER WINDOW IDENTITY (event.source), not by a claimed id
  or origin; pushes context (theme/locale/tripId/userId), validates navigation,
  renders notifications as text, resizes widgets, and proxies trek:invoke to the
  plugin's own routes host-side (session cookie stays with the host).
- Page route /plugins/:id + Navbar nav injection for page plugins.

Dashboard widget slot and the trek:request core-data bridge are deferred to a
later slice.
2026-07-03 17:12:56 +02:00

87 lines
3.4 KiB
TypeScript

/**
* The sandboxed plugin frame server (#plugins, M3): serves a plugin's client/
* assets with a locked-down per-frame CSP and a strict path guard, only when the
* plugin is active and the runtime is enabled.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const { pluginsEnabledMock } = vi.hoisted(() => ({ pluginsEnabledMock: vi.fn(() => true) }));
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled: pluginsEnabledMock }));
import { PluginFrameController } from '../../../src/nest/plugins/plugin-frame.controller';
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
let codeRoot: string;
beforeAll(() => {
codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-frame-'));
process.env.TREK_PLUGINS_DIR = codeRoot;
const dir = path.join(codeRoot, 'widget', 'client');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'index.html'), '<!doctype html><body>hi</body>');
});
afterAll(() => {
delete process.env.TREK_PLUGINS_DIR;
fs.rmSync(codeRoot, { recursive: true, force: true });
});
function fakeRes() {
const res = {
statusCode: 0,
headers: {} as Record<string, string>,
sent: undefined as unknown,
filePath: undefined as string | undefined,
status(c: number) { res.statusCode = c; return res; },
setHeader(k: string, v: string) { res.headers[k] = v; },
send(b: unknown) { res.sent = b; return res; },
sendFile(p: string) { res.filePath = p; res.statusCode ||= 200; },
};
return res;
}
const req = (p: string) => ({ params: { path: p } }) as never;
function runtime(active = true, hosts: string[] = []): PluginRuntimeService {
return { isActive: vi.fn(() => active), outboundHostsOf: vi.fn(() => hosts) } as unknown as PluginRuntimeService;
}
describe('PluginFrameController', () => {
it('serves index.html with the opaque-frame CSP + sandbox', () => {
const res = fakeRes();
new PluginFrameController(runtime(true, ['api.weather.com'])).serve('widget', req(''), res as never);
expect(res.filePath).toContain(path.join('widget', 'client', 'index.html'));
const csp = res.headers['Content-Security-Policy'];
expect(csp).toContain("default-src 'none'");
expect(csp).toContain('sandbox allow-scripts allow-forms allow-popups');
expect(csp).not.toContain('allow-same-origin');
expect(csp).toContain('connect-src \'self\' https://api.weather.com');
expect(res.headers['X-Content-Type-Options']).toBe('nosniff');
});
it('404 when the plugin is inactive', () => {
const res = fakeRes();
new PluginFrameController(runtime(false)).serve('widget', req(''), res as never);
expect(res.statusCode).toBe(404);
});
it('404 when the runtime is disabled', () => {
pluginsEnabledMock.mockReturnValueOnce(false);
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req(''), res as never);
expect(res.statusCode).toBe(404);
});
it('403 on a path-traversal attempt', () => {
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req('../../../etc/passwd'), res as never);
expect(res.statusCode).toBe(403);
});
it('404 for a missing file', () => {
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req('missing.js'), res as never);
expect(res.statusCode).toBe(404);
});
});