mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 06:29:42 +02:00
Merge pull request #381 from mtiller/feat/reverse-proxy-base-url
feat(web): support a reverse-proxy base URL
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for the pure reverse-proxy base-path helpers
|
||||
* (src/config/base-path.ts). These back the server ingress strip (rewriteUrl),
|
||||
* the egress Location rewrite (onSend), and the frontend route builder, so their
|
||||
* correctness is what makes a sub-path mount work end to end.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
normalizeBasePath,
|
||||
isValidBasePath,
|
||||
assertValidBasePath,
|
||||
joinBasePath,
|
||||
stripBasePath,
|
||||
} from '../src/config/base-path.js';
|
||||
|
||||
describe('normalizeBasePath', () => {
|
||||
it('treats root / and empty as no prefix', () => {
|
||||
expect(normalizeBasePath('/')).toBe('');
|
||||
expect(normalizeBasePath('')).toBe('');
|
||||
expect(normalizeBasePath(undefined)).toBe('');
|
||||
expect(normalizeBasePath(null)).toBe('');
|
||||
expect(normalizeBasePath(' ')).toBe('');
|
||||
});
|
||||
|
||||
it('adds a leading slash and drops trailing slashes', () => {
|
||||
expect(normalizeBasePath('codeman')).toBe('/codeman');
|
||||
expect(normalizeBasePath('/codeman')).toBe('/codeman');
|
||||
expect(normalizeBasePath('/codeman/')).toBe('/codeman');
|
||||
expect(normalizeBasePath('codeman///')).toBe('/codeman');
|
||||
});
|
||||
|
||||
it('collapses duplicate slashes and keeps nested segments', () => {
|
||||
expect(normalizeBasePath('//a//b//')).toBe('/a/b');
|
||||
expect(normalizeBasePath('/tools/codeman')).toBe('/tools/codeman');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidBasePath / assertValidBasePath', () => {
|
||||
it('accepts root and well-formed segments', () => {
|
||||
expect(isValidBasePath('')).toBe(true);
|
||||
expect(isValidBasePath('/codeman')).toBe(true);
|
||||
expect(isValidBasePath('/tools/codeman-2')).toBe(true);
|
||||
expect(isValidBasePath('/a_b.c~d')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects segments with unsafe characters', () => {
|
||||
expect(isValidBasePath('/a b')).toBe(false);
|
||||
expect(isValidBasePath('/a?b')).toBe(false);
|
||||
expect(isValidBasePath('/a#b')).toBe(false);
|
||||
expect(isValidBasePath('/a%2f')).toBe(false);
|
||||
});
|
||||
|
||||
it('assertValidBasePath normalizes valid input and throws on bad', () => {
|
||||
expect(assertValidBasePath('/codeman/')).toBe('/codeman');
|
||||
expect(assertValidBasePath('/')).toBe('');
|
||||
expect(() => assertValidBasePath('/a b')).toThrow(/Invalid --base-url/);
|
||||
expect(() => assertValidBasePath('?x')).toThrow(/Invalid --base-url/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('joinBasePath (frontend/egress route builder)', () => {
|
||||
it('is a no-op at root', () => {
|
||||
expect(joinBasePath('', '/api/x')).toBe('/api/x');
|
||||
expect(joinBasePath('', '/')).toBe('/');
|
||||
});
|
||||
|
||||
it('prefixes root-absolute app paths', () => {
|
||||
expect(joinBasePath('/codeman', '/api/x')).toBe('/codeman/api/x');
|
||||
expect(joinBasePath('/codeman', '/')).toBe('/codeman/');
|
||||
expect(joinBasePath('/codeman', '/ws/sessions/1/terminal')).toBe('/codeman/ws/sessions/1/terminal');
|
||||
});
|
||||
|
||||
it('leaves absolute, protocol-relative, and relative URLs alone', () => {
|
||||
expect(joinBasePath('/codeman', 'https://x/y')).toBe('https://x/y');
|
||||
expect(joinBasePath('/codeman', 'ws://x/y')).toBe('ws://x/y');
|
||||
expect(joinBasePath('/codeman', '//host/y')).toBe('//host/y');
|
||||
expect(joinBasePath('/codeman', 'app.js')).toBe('app.js');
|
||||
expect(joinBasePath('/codeman', '#frag')).toBe('#frag');
|
||||
expect(joinBasePath('/codeman', 'data:image/png;base64,AAAA')).toBe('data:image/png;base64,AAAA');
|
||||
});
|
||||
|
||||
it('is idempotent — never double-prefixes', () => {
|
||||
expect(joinBasePath('/codeman', '/codeman/api/x')).toBe('/codeman/api/x');
|
||||
expect(joinBasePath('/codeman', '/codeman')).toBe('/codeman');
|
||||
expect(joinBasePath('/codeman', '/codeman?y=1')).toBe('/codeman?y=1');
|
||||
});
|
||||
|
||||
it('does not treat a same-named sibling path as already-prefixed', () => {
|
||||
// /codeman-docs must NOT be mistaken for the /codeman mount.
|
||||
expect(joinBasePath('/codeman', '/codeman-docs/x')).toBe('/codeman/codeman-docs/x');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripBasePath (server ingress)', () => {
|
||||
it('is a no-op at root', () => {
|
||||
expect(stripBasePath('', '/api/x')).toBe('/api/x');
|
||||
});
|
||||
|
||||
it('strips the prefix from proxied requests', () => {
|
||||
expect(stripBasePath('/codeman', '/codeman/api/x')).toBe('/api/x');
|
||||
expect(stripBasePath('/codeman', '/codeman')).toBe('/');
|
||||
expect(stripBasePath('/codeman', '/codeman/')).toBe('/');
|
||||
expect(stripBasePath('/codeman', '/codeman?y=1')).toBe('/?y=1');
|
||||
});
|
||||
|
||||
it('leaves un-prefixed requests unchanged (direct-to-port: hooks, health, docker bridge)', () => {
|
||||
expect(stripBasePath('/codeman', '/api/x')).toBe('/api/x');
|
||||
expect(stripBasePath('/codeman', '/api/hook-event')).toBe('/api/hook-event');
|
||||
// A same-named sibling is not the mount.
|
||||
expect(stripBasePath('/codeman', '/codeman-docs/x')).toBe('/codeman-docs/x');
|
||||
});
|
||||
|
||||
it('round-trips with joinBasePath', () => {
|
||||
const base = '/tools/codeman';
|
||||
for (const p of ['/', '/api/x', '/ws/y', '/session/abc']) {
|
||||
expect(stripBasePath(base, joinBasePath(base, p))).toBe(p);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,16 @@ describe('buildWebArgs', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('forwards --base-url so a detached/service relaunch keeps the mount prefix', () => {
|
||||
const args = buildWebArgs({ host: '127.0.0.1', port: 3000, https: false, basePath: '/codeman' });
|
||||
expect(args).toContain('--base-url');
|
||||
expect(args[args.indexOf('--base-url') + 1]).toBe('/codeman');
|
||||
});
|
||||
|
||||
it('omits --base-url at root (empty basePath)', () => {
|
||||
expect(buildWebArgs({ host: '127.0.0.1', port: 3000, https: false, basePath: '' })).not.toContain('--base-url');
|
||||
});
|
||||
|
||||
it('never re-emits the daemon flags themselves (the child must not re-fork)', () => {
|
||||
const args = buildWebArgs({ host: '127.0.0.1', port: 3000, https: false });
|
||||
expect(args).not.toContain('--daemon');
|
||||
|
||||
@@ -161,6 +161,8 @@ function loadPanel(options: { sessionId?: string | null; showHidden?: boolean }
|
||||
CodemanApp,
|
||||
console,
|
||||
escapeHtml,
|
||||
// Reverse-proxy route builder from constants.js (not loaded here); identity at root.
|
||||
CodemanBase: { base: '', url: (p: string) => p },
|
||||
localStorage: { getItem: () => null, setItem: vi.fn() },
|
||||
document: {
|
||||
getElementById: (id: string) => elements[id] ?? null,
|
||||
@@ -222,6 +224,8 @@ function loadRealSelectSessionHarness(options: { terminalFailure?: boolean } = {
|
||||
},
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
WebSocket: { OPEN: 1 },
|
||||
// Reverse-proxy route builder from constants.js (not loaded here); identity at root.
|
||||
CodemanBase: { base: '', url: (p: string) => p },
|
||||
MobileDetection: { isTouchDevice: () => false },
|
||||
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
document: {
|
||||
|
||||
@@ -38,6 +38,8 @@ function loadApp() {
|
||||
console: { ...console, warn: vi.fn(), error: vi.fn() },
|
||||
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
escapeHtml: (s: string) => String(s),
|
||||
// Reverse-proxy route builder from constants.js (not loaded here); identity at root.
|
||||
CodemanBase: { base: '', url: (p: string) => p },
|
||||
document: { getElementById: () => null, addEventListener: vi.fn() },
|
||||
window: windowStub,
|
||||
setTimeout,
|
||||
|
||||
@@ -64,6 +64,8 @@ function loadApp(media: FakeMedia[]) {
|
||||
console: { ...console, warn: vi.fn() },
|
||||
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
escapeHtml: (s: string) => String(s),
|
||||
// Reverse-proxy route builder from constants.js (not loaded here); identity at root.
|
||||
CodemanBase: { base: '', url: (p: string) => p },
|
||||
document: { getElementById: () => null, addEventListener: vi.fn() },
|
||||
window: { addEventListener: vi.fn() },
|
||||
setTimeout,
|
||||
|
||||
@@ -228,3 +228,42 @@ describe('WebServer.renderIndexHtml', () => {
|
||||
expect(html).not.toContain('gesture-codeman.js');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebServer.renderIndexHtml reverse-proxy base path', () => {
|
||||
const BASE_TEMPLATE = ['<head>', '<base href="/">', '<title>Codeman</title>', '</head>', '<body></body>'].join('\n');
|
||||
|
||||
function makeBaseServer(basePath: string) {
|
||||
// constructor: (port, https, testMode, host, titleHostname, allowUnauth, basePath)
|
||||
const server = new WebServer(0, false, true, '127.0.0.1', undefined, false, basePath);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(server as any).indexHtmlTemplate = BASE_TEMPLATE;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(server as any).readSettings = vi.fn(async () => ({}));
|
||||
return server;
|
||||
}
|
||||
|
||||
it('is inert at root — base tag unchanged and no base global injected', async () => {
|
||||
const server = makeBaseServer('');
|
||||
const html = await render(server);
|
||||
expect(html).toContain('<base href="/">');
|
||||
// At root the frontend reads a MISSING __CODEMAN_BASE__ as root, so nothing is
|
||||
// injected and the historical output is byte-identical.
|
||||
expect(html).not.toContain('__CODEMAN_BASE__');
|
||||
});
|
||||
|
||||
it('points the base tag and the base global at a sub-path mount', async () => {
|
||||
const server = makeBaseServer('/codeman');
|
||||
const html = await render(server);
|
||||
expect(html).toContain('<base href="/codeman/">');
|
||||
expect(html).toContain('window.__CODEMAN_BASE__="/codeman"');
|
||||
// The global rides right after <base>, before any (deferred) script.
|
||||
expect(html.indexOf('window.__CODEMAN_BASE__')).toBeLessThan(html.indexOf('</head>'));
|
||||
});
|
||||
|
||||
it('normalizes a raw operator prefix passed to the constructor', async () => {
|
||||
const server = makeBaseServer('codeman/');
|
||||
const html = await render(server);
|
||||
expect(html).toContain('<base href="/codeman/">');
|
||||
expect(html).toContain('window.__CODEMAN_BASE__="/codeman"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -684,3 +684,39 @@ describe('referrer policy on proxied responses', () => {
|
||||
expect(headers['referrer-policy']).toBe('same-origin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reverse-proxy base path', () => {
|
||||
const BASE = '/codeman';
|
||||
const BASED_PREFIX = `${BASE}/webview/${CAP}/`;
|
||||
|
||||
it('rides the mount into the iframe prefix', () => {
|
||||
expect(proxyPrefixFor(CAP, BASE)).toBe(BASED_PREFIX);
|
||||
expect(proxyPrefixFor(CAP, '')).toBe(PREFIX); // root unchanged
|
||||
});
|
||||
|
||||
it('rewrites HTML (base tag, root-absolute attrs, shim) under the mount', () => {
|
||||
const out = rewriteHtml('<html><head></head><body><img src="/logo.png"></body></html>', CAP, BASE);
|
||||
expect(out).toContain(`<base href="${BASED_PREFIX}">`);
|
||||
expect(out).toContain(`src="${BASED_PREFIX}logo.png"`);
|
||||
// The runtime shim's rewrite target is the base-prefixed path.
|
||||
expect(out).toContain(JSON.stringify(BASED_PREFIX));
|
||||
});
|
||||
|
||||
it('rebases Set-Cookie Path onto the mounted prefix so the browser sends it back', () => {
|
||||
expect(rewriteSetCookie('sid=abc; Path=/', CAP, true, BASE)).toContain(`Path=${BASED_PREFIX}`);
|
||||
expect(rewriteSetCookie('sid=abc; HttpOnly', CAP, true, BASE)).toContain(`Path=${BASED_PREFIX}`);
|
||||
});
|
||||
|
||||
it('rewrites a same-origin Location into the mounted prefix', () => {
|
||||
const requestUrl = new URL('http://127.0.0.1:4000/app');
|
||||
expect(rewriteLocation('/dashboard?x=1', requestUrl, CAP, BASE)).toBe(`${BASED_PREFIX}dashboard?x=1`);
|
||||
});
|
||||
|
||||
it('extracts the capability from a browser Referer that carries the mount prefix', () => {
|
||||
expect(capabilityFromReferer(`https://box.ts.net${BASED_PREFIX}page`, BASE)).toBe(CAP);
|
||||
// A same-named sibling path must not be mistaken for the mount.
|
||||
expect(capabilityFromReferer(`https://box.ts.net/codeman-docs/webview/${CAP}/page`, BASE)).toBeNull();
|
||||
// Without the base arg the prefixed Referer no longer matches (documents why the arg exists).
|
||||
expect(capabilityFromReferer(`https://box.ts.net${BASED_PREFIX}page`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user