mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
refactor(plugins): async DB-touching methods; transactions through UnitOfWork
This commit is contained in:
@@ -70,15 +70,15 @@ const req = (p: string, host?: string) => ({ params: { path: p }, get: (h: strin
|
||||
function runtime(active = true, hosts: string[] = [], operatorHosts: string[] = []): PluginRuntimeService {
|
||||
return {
|
||||
isActive: vi.fn(() => active),
|
||||
outboundHostsOf: vi.fn(() => hosts),
|
||||
operatorEgressHosts: vi.fn(() => operatorHosts),
|
||||
outboundHostsOf: vi.fn(async () => hosts),
|
||||
operatorEgressHosts: vi.fn(async () => operatorHosts),
|
||||
} as unknown as PluginRuntimeService;
|
||||
}
|
||||
|
||||
describe('PluginFrameController', () => {
|
||||
it('serves index.html with the opaque-frame CSP + sandbox', () => {
|
||||
it('serves index.html with the opaque-frame CSP + sandbox', async () => {
|
||||
const res = fakeRes();
|
||||
new PluginFrameController(runtime(true, ['api.weather.com'])).serve('widget', req(''), res as never);
|
||||
await 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'");
|
||||
@@ -91,53 +91,53 @@ describe('PluginFrameController', () => {
|
||||
|
||||
// Under the Nest ExpressAdapter, res.sendFile(absolutePath) resolves against the
|
||||
// rewritten req.url and 404s spuriously — files must go out root-relative.
|
||||
it('serves files with an explicit root, not an absolute path', () => {
|
||||
it('serves files with an explicit root, not an absolute path', async () => {
|
||||
const res = fakeRes();
|
||||
new PluginFrameController(runtime(true)).serve('widget', req(''), res as never);
|
||||
await new PluginFrameController(runtime(true)).serve('widget', req(''), res as never);
|
||||
expect(res.fileRoot).toBe(path.join(codeRoot, 'widget', 'client'));
|
||||
});
|
||||
|
||||
// The frame document runs at an opaque origin, so its own <script src> loads are
|
||||
// cross-origin fetches; helmet's CORP: same-origin makes the browser drop them
|
||||
// and a multi-file client dies on boot.
|
||||
it('marks responses cross-origin loadable for the opaque frame', () => {
|
||||
it('marks responses cross-origin loadable for the opaque frame', async () => {
|
||||
const res = fakeRes();
|
||||
new PluginFrameController(runtime(true)).serve('widget', req(''), res as never);
|
||||
await new PluginFrameController(runtime(true)).serve('widget', req(''), res as never);
|
||||
expect(res.headers['Cross-Origin-Resource-Policy']).toBe('cross-origin');
|
||||
});
|
||||
|
||||
// An operatorEgress plugin's hosts are supplied by the ADMIN after install, so they are not
|
||||
// in the manifest grants. activate() unions them into the child's egress guard; the frame
|
||||
// must match, or a plugin with a UI can call the host from its server but not its iframe.
|
||||
it('includes admin-supplied operatorEgress hosts in connect-src', () => {
|
||||
it('includes admin-supplied operatorEgress hosts in connect-src', async () => {
|
||||
const res = fakeRes();
|
||||
new PluginFrameController(runtime(true, ['api.weather.com'], ['gotify.home.lan'])).serve('widget', req(''), res as never);
|
||||
await new PluginFrameController(runtime(true, ['api.weather.com'], ['gotify.home.lan'])).serve('widget', req(''), res as never);
|
||||
const csp = res.headers['Content-Security-Policy'];
|
||||
expect(csp).toContain('https://api.weather.com');
|
||||
expect(csp).toContain('https://gotify.home.lan');
|
||||
});
|
||||
|
||||
it('does not duplicate a host granted in the manifest AND added by the admin', () => {
|
||||
it('does not duplicate a host granted in the manifest AND added by the admin', async () => {
|
||||
const res = fakeRes();
|
||||
new PluginFrameController(runtime(true, ['ntfy.sh'], ['ntfy.sh'])).serve('widget', req(''), res as never);
|
||||
await new PluginFrameController(runtime(true, ['ntfy.sh'], ['ntfy.sh'])).serve('widget', req(''), res as never);
|
||||
const csp = res.headers['Content-Security-Policy'];
|
||||
expect(csp.match(/https:\/\/ntfy\.sh/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refuses to interpolate a malformed operator host into the CSP', () => {
|
||||
it('refuses to interpolate a malformed operator host into the CSP', async () => {
|
||||
const res = fakeRes();
|
||||
// The admin writer validates hosts, but connect-src is the last line of defence: a token
|
||||
// with a space or a bare * would widen the whole policy.
|
||||
new PluginFrameController(runtime(true, [], ['evil.com https://*', '*'])).serve('widget', req(''), res as never);
|
||||
await new PluginFrameController(runtime(true, [], ['evil.com https://*', '*'])).serve('widget', req(''), res as never);
|
||||
const csp = res.headers['Content-Security-Policy'];
|
||||
expect(csp).toContain("connect-src 'self'");
|
||||
expect(csp).not.toContain('evil.com');
|
||||
expect(csp).not.toContain('*');
|
||||
});
|
||||
|
||||
it('allows the plugin its own static assets when the Host header is clean', () => {
|
||||
it('allows the plugin its own static assets when the Host header is clean', async () => {
|
||||
const res = fakeRes();
|
||||
new PluginFrameController(runtime(true)).serve('widget', req('', 'trek.example.com:8443'), res as never);
|
||||
await new PluginFrameController(runtime(true)).serve('widget', req('', 'trek.example.com:8443'), res as never);
|
||||
const csp = res.headers['Content-Security-Policy'];
|
||||
// Scheme-less on purpose: matches the frame's own http AND https documents.
|
||||
expect(csp).toContain("script-src 'self' 'unsafe-inline' trek.example.com:8443/plugin-frame/widget/");
|
||||
@@ -145,54 +145,54 @@ describe('PluginFrameController', () => {
|
||||
expect(csp).toContain("connect-src 'self' trek.example.com:8443/plugin-frame/widget/");
|
||||
});
|
||||
|
||||
it('drops the own-assets source on a malformed Host header instead of widening the policy', () => {
|
||||
it('drops the own-assets source on a malformed Host header instead of widening the policy', async () => {
|
||||
const res = fakeRes();
|
||||
new PluginFrameController(runtime(true)).serve('widget', req('', 'evil.com *'), res as never);
|
||||
await new PluginFrameController(runtime(true)).serve('widget', req('', 'evil.com *'), res as never);
|
||||
const csp = res.headers['Content-Security-Policy'];
|
||||
expect(csp).toContain("script-src 'self' 'unsafe-inline';");
|
||||
expect(csp).not.toContain('evil.com');
|
||||
});
|
||||
|
||||
it('404 when the plugin is inactive', () => {
|
||||
it('404 when the plugin is inactive', async () => {
|
||||
const res = fakeRes();
|
||||
new PluginFrameController(runtime(false)).serve('widget', req(''), res as never);
|
||||
await new PluginFrameController(runtime(false)).serve('widget', req(''), res as never);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('404 when the runtime is disabled', () => {
|
||||
it('404 when the runtime is disabled', async () => {
|
||||
pluginsEnabledMock.mockReturnValueOnce(false);
|
||||
const res = fakeRes();
|
||||
new PluginFrameController(runtime(true)).serve('widget', req(''), res as never);
|
||||
await new PluginFrameController(runtime(true)).serve('widget', req(''), res as never);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('403 on a path-traversal attempt', () => {
|
||||
it('403 on a path-traversal attempt', async () => {
|
||||
const res = fakeRes();
|
||||
new PluginFrameController(runtime(true)).serve('widget', req('../../../etc/passwd'), res as never);
|
||||
await new PluginFrameController(runtime(true)).serve('widget', req('../../../etc/passwd'), res as never);
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('404 for a missing file', () => {
|
||||
it('404 for a missing file', async () => {
|
||||
const res = fakeRes();
|
||||
new PluginFrameController(runtime(true)).serve('widget', req('missing.js'), res as never);
|
||||
await new PluginFrameController(runtime(true)).serve('widget', req('missing.js'), res as never);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
// The lexical guard above cannot see through a link, and statSync follows one.
|
||||
it('403 on a symlink inside client/ that points out of the plugin', () => {
|
||||
it('403 on a symlink inside client/ that points out of the plugin', async () => {
|
||||
if (!symlinksAvailable) return;
|
||||
const res = fakeRes();
|
||||
new PluginFrameController(runtime(true)).serve('widget', req('esc/leak.js'), res as never);
|
||||
await new PluginFrameController(runtime(true)).serve('widget', req('esc/leak.js'), res as never);
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.filePath).toBeUndefined();
|
||||
});
|
||||
|
||||
// Dev-link mode: the plugin directory itself is a link, so the containment
|
||||
// check has to resolve BOTH sides or local development stops working.
|
||||
it('still serves a dev-linked plugin whose whole tree is a symlink', () => {
|
||||
it('still serves a dev-linked plugin whose whole tree is a symlink', async () => {
|
||||
if (!symlinksAvailable) return;
|
||||
const res = fakeRes();
|
||||
new PluginFrameController(runtime(true)).serve('linked', req(''), res as never);
|
||||
await new PluginFrameController(runtime(true)).serve('linked', req(''), res as never);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.filePath).toContain('index.html');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user