mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
merge master into claude-response-viewer-normalization
Only CLAUDE.md conflicted: master restructured it into the short-rule + docs/architecture-invariants.md pointer layout while this PR was open. The response-viewer detail now lives in architecture-invariants, so the Claude turn-grouping and restored-placeholder rebind notes moved there. Changeset rewritten to record the measured effect on real transcripts.
This commit is contained in:
@@ -79,6 +79,61 @@ describe('terminal link-provider regexes (shipped source)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('urlPattern keeps query strings whole (a single & is part of the URL)', () => {
|
||||
// Excluding `&` truncated every real query string: a WordPress edit link
|
||||
// resolved to `?post=1479` and opened the wrong page, and Claude Code's OAuth
|
||||
// login URL (many `&` params) was not clickable at all.
|
||||
const url = shippedPattern('urlPattern');
|
||||
const strip = (u: string) => u.replace(/[.,;:!?)&]+$/, '');
|
||||
const cases: Array<[string, string]> = [
|
||||
[
|
||||
'updated in place: https://bio-hacking.blog/wp-admin/post.php?post=1479&action=edit',
|
||||
'https://bio-hacking.blog/wp-admin/post.php?post=1479&action=edit',
|
||||
],
|
||||
[
|
||||
'open https://claude.ai/oauth/authorize?code=true&client_id=abc&scope=user%3Ainference&state=xyz',
|
||||
'https://claude.ai/oauth/authorize?code=true&client_id=abc&scope=user%3Ainference&state=xyz',
|
||||
],
|
||||
['see https://x.com/a?b=1&c=2&d=3 ok', 'https://x.com/a?b=1&c=2&d=3'],
|
||||
// A lone trailing & is punctuation, not part of the target.
|
||||
['trailing https://x.com/a?b=1& next', 'https://x.com/a?b=1'],
|
||||
];
|
||||
for (const [line, want] of cases) {
|
||||
url.lastIndex = 0;
|
||||
const m = url.exec(line);
|
||||
expect(m, line).not.toBeNull();
|
||||
expect(strip(m![0]), line).toBe(want);
|
||||
}
|
||||
});
|
||||
|
||||
it('urlPattern still stops at the shell && operator', () => {
|
||||
// `&&` never appears inside a URL, so it must remain a boundary or a link
|
||||
// would swallow the next command.
|
||||
const url = shippedPattern('urlPattern');
|
||||
for (const line of ['curl https://x.com/api && echo done', 'curl https://x.com/api&&echo done']) {
|
||||
url.lastIndex = 0;
|
||||
expect(url.exec(line)![0], line).toBe('https://x.com/api');
|
||||
}
|
||||
});
|
||||
|
||||
it('extPattern links pasted image/PDF attachment paths', () => {
|
||||
// `.claude-images/paste-*.png` is what Codeman writes for a pasted screenshot;
|
||||
// without image extensions the path rendered as plain, unclickable text.
|
||||
const ext = shippedPattern('extPattern');
|
||||
const cases = [
|
||||
'/home/arkon/default/claudeman/.claude-images/paste-1785164958410-d11eb7d0.png',
|
||||
'/tmp/shot.jpeg',
|
||||
'/opt/app/report.pdf',
|
||||
'/home/a/diagram.svg',
|
||||
];
|
||||
for (const path of cases) {
|
||||
ext.lastIndex = 0;
|
||||
const m = ext.exec(`see ${path} here`);
|
||||
expect(m, path).not.toBeNull();
|
||||
expect(m![1], path).toBe(path);
|
||||
}
|
||||
});
|
||||
|
||||
it('cmdPattern arg group cannot match empty tokens (the exponential trigger)', () => {
|
||||
// structural guard: the dangerous construct is an empty-matchable token
|
||||
// inside a repeated group — `[^\s\/]*\s+` repeated. Check the pattern
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* @fileoverview Fast VM/static regressions for the shared filesystem picker and
|
||||
* extended mobile keyboard actions. No browser or real server required.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const keyboardSource = readFileSync(resolve('src/web/public/keyboard-accessory.js'), 'utf8');
|
||||
const terminalSource = readFileSync(resolve('src/web/public/terminal-ui.js'), 'utf8');
|
||||
const sessionSource = readFileSync(resolve('src/web/public/session-ui.js'), 'utf8');
|
||||
const indexSource = readFileSync(resolve('src/web/public/index.html'), 'utf8');
|
||||
|
||||
function loadTerminalMixin() {
|
||||
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, (...args: unknown[]) => unknown> };
|
||||
const cjkClear = vi.fn();
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
requestAnimationFrame: vi.fn(),
|
||||
CodemanApp: FakeCodemanApp,
|
||||
CjkInput: { clear: cjkClear },
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
document: { addEventListener: vi.fn() },
|
||||
});
|
||||
vm.runInContext(terminalSource, context, { filename: 'terminal-ui.js' });
|
||||
return { mixin: FakeCodemanApp.prototype, cjkClear };
|
||||
}
|
||||
|
||||
const terminalHarness = loadTerminalMixin();
|
||||
|
||||
function loadKeyboardModule() {
|
||||
const app = {
|
||||
activeSessionId: 'session-1',
|
||||
sessions: new Map([['session-1', { workingDir: '/mnt/d/AI' }]]),
|
||||
terminal: { focus: vi.fn() },
|
||||
clearTerminalInput: vi.fn(),
|
||||
insertTerminalText: vi.fn(),
|
||||
sendInput: vi.fn(),
|
||||
};
|
||||
const context = vm.createContext({
|
||||
app,
|
||||
MobileDetection: { isTouchDevice: () => false },
|
||||
URLSearchParams,
|
||||
fetch: vi.fn(),
|
||||
document: {},
|
||||
setTimeout: (fn: () => void) => {
|
||||
fn();
|
||||
return 1;
|
||||
},
|
||||
clearTimeout: vi.fn(),
|
||||
});
|
||||
vm.runInContext(
|
||||
`${keyboardSource}\nglobalThis.__bar = KeyboardAccessoryBar; globalThis.__picker = PathPicker;`,
|
||||
context
|
||||
);
|
||||
return {
|
||||
app,
|
||||
bar: (context as unknown as { __bar: { handleAction(action: string): void } }).__bar,
|
||||
picker: (context as unknown as { __picker: { open: ReturnType<typeof vi.fn> } }).__picker,
|
||||
};
|
||||
}
|
||||
|
||||
describe('mobile filesystem picker actions', () => {
|
||||
it('keeps clear-input separate from the destructive /clear command', () => {
|
||||
const { app, bar } = loadKeyboardModule();
|
||||
bar.handleAction('clear-input');
|
||||
|
||||
expect(app.clearTerminalInput).toHaveBeenCalledOnce();
|
||||
expect(app.sendInput).not.toHaveBeenCalled();
|
||||
expect(keyboardSource).toContain('data-action="clear-input"');
|
||||
expect(keyboardSource).toContain('data-action="clear" title="/clear"');
|
||||
});
|
||||
|
||||
it('opens at the active working directory and inserts the selected path without Enter', () => {
|
||||
const { app, bar, picker } = loadKeyboardModule();
|
||||
picker.open = vi.fn();
|
||||
|
||||
bar.handleAction('pick-path');
|
||||
|
||||
expect(picker.open).toHaveBeenCalledOnce();
|
||||
const options = picker.open.mock.calls[0][0];
|
||||
expect(options).toMatchObject({
|
||||
sessionId: 'session-1',
|
||||
initialPath: '/mnt/d/AI',
|
||||
directoriesOnly: false,
|
||||
});
|
||||
options.onSelect('/mnt/d/AI/project/file.ts');
|
||||
expect(app.insertTerminalText).toHaveBeenCalledWith('/mnt/d/AI/project/file.ts');
|
||||
expect(app.sendInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wires Link Existing to the shared folder-only picker', () => {
|
||||
expect(indexSource).toContain('onclick="app.openLinkCasePathPicker()"');
|
||||
expect(indexSource).toContain('id="linkCasePath"');
|
||||
expect(sessionSource).toContain('openLinkCasePathPicker()');
|
||||
expect(sessionSource).toContain('directoriesOnly: true');
|
||||
});
|
||||
|
||||
it('keeps Choose separate from safe inline file preview', () => {
|
||||
expect(keyboardSource).toContain('openPreview(entry)');
|
||||
expect(keyboardSource).toContain('/api/filesystem/preview?');
|
||||
expect(keyboardSource).toContain("entry.previewKind === 'image'");
|
||||
expect(keyboardSource).toContain("entry.previewKind === 'text'");
|
||||
expect(keyboardSource).toContain("choose.textContent = 'Choose'");
|
||||
expect(keyboardSource).toContain('pre.textContent = content');
|
||||
});
|
||||
|
||||
it('inserts a selected path into the editable local-echo prompt without sending it', () => {
|
||||
const appendText = vi.fn();
|
||||
const sendInput = vi.fn();
|
||||
const focus = vi.fn();
|
||||
const app = {
|
||||
activeSessionId: 'session-1',
|
||||
_localEchoEnabled: true,
|
||||
_localEchoOverlay: { appendText },
|
||||
terminal: { focus },
|
||||
sendInput,
|
||||
};
|
||||
|
||||
terminalHarness.mixin.insertTerminalText.call(app, '/mnt/d/AI/project');
|
||||
|
||||
expect(appendText).toHaveBeenCalledWith('/mnt/d/AI/project');
|
||||
expect(sendInput).not.toHaveBeenCalled();
|
||||
expect(focus).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('clears pending and already-flushed prompt text without invoking /clear', () => {
|
||||
const clear = vi.fn();
|
||||
const suppressBufferDetection = vi.fn();
|
||||
const sendInput = vi.fn(() => Promise.resolve());
|
||||
const showToast = vi.fn();
|
||||
const focus = vi.fn();
|
||||
const app = {
|
||||
activeSessionId: 'session-1',
|
||||
_inputFlushTimeout: null,
|
||||
_pendingInput: 'pending text',
|
||||
_localEchoEnabled: true,
|
||||
_localEchoOverlay: {
|
||||
getFlushed: () => ({ count: 4, text: 'sent' }),
|
||||
clear,
|
||||
suppressBufferDetection,
|
||||
},
|
||||
_flushedOffsets: new Map([['session-1', 4]]),
|
||||
_flushedTexts: new Map([['session-1', 'sent']]),
|
||||
sendInput,
|
||||
showToast,
|
||||
terminal: { focus },
|
||||
};
|
||||
|
||||
terminalHarness.mixin.clearTerminalInput.call(app);
|
||||
|
||||
expect(app._pendingInput).toBe('');
|
||||
expect(clear).toHaveBeenCalledOnce();
|
||||
expect(suppressBufferDetection).toHaveBeenCalledOnce();
|
||||
expect(sendInput).toHaveBeenCalledWith('\x7f'.repeat(4));
|
||||
expect(sendInput).not.toHaveBeenCalledWith('/clear');
|
||||
expect(app._flushedOffsets.size).toBe(0);
|
||||
expect(app._flushedTexts.size).toBe(0);
|
||||
expect(showToast).toHaveBeenCalledWith('Input cleared', 'success');
|
||||
expect(focus).toHaveBeenCalledOnce();
|
||||
expect(terminalHarness.cjkClear).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses Ctrl+U to clear the TUI-owned prompt when local echo is disabled', () => {
|
||||
const sendInput = vi.fn(() => Promise.resolve());
|
||||
const app = {
|
||||
activeSessionId: 'session-1',
|
||||
_inputFlushTimeout: null,
|
||||
_pendingInput: '',
|
||||
_localEchoEnabled: false,
|
||||
_localEchoOverlay: null,
|
||||
sendInput,
|
||||
showToast: vi.fn(),
|
||||
terminal: { focus: vi.fn() },
|
||||
};
|
||||
|
||||
terminalHarness.mixin.clearTerminalInput.call(app);
|
||||
|
||||
expect(sendInput).toHaveBeenCalledWith('\x15');
|
||||
});
|
||||
});
|
||||
+31
-9
@@ -250,6 +250,9 @@ describe('QR Token Manager (unit)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** Must match the alphabet in `generateShortCode` (tunnel-manager.ts). */
|
||||
const BASE62_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
describe('Short code distribution (bias check)', () => {
|
||||
it('should produce roughly uniform character distribution', () => {
|
||||
// Generate 6000 codes (36000 chars) and check distribution
|
||||
@@ -265,17 +268,36 @@ describe('Short code distribution (bias check)', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Expected count per char: 36000 / 62 ≈ 580.6
|
||||
const expected = 36000 / 62;
|
||||
let maxDeviation = 0;
|
||||
for (const [, count] of charCounts) {
|
||||
const deviation = Math.abs(count - expected) / expected;
|
||||
maxDeviation = Math.max(maxDeviation, deviation);
|
||||
// Chi-square goodness-of-fit against a uniform base62 alphabet.
|
||||
//
|
||||
// This deliberately does NOT assert on the max per-character deviation.
|
||||
// That statistic is the maximum of 62 correlated near-normal cells, so its
|
||||
// tail is fat: with n=36000 the per-cell relative SD is ~4.1%, which puts a
|
||||
// 15% bound at |z| ~ 3.65 and, taken as a max over 62 cells, fails on a
|
||||
// perfectly uniform generator about 1.6% of the time. Measured over 3000
|
||||
// simulated runs: 48 spurious failures. That is the flake.
|
||||
//
|
||||
// Chi-square is the right tool for "is this multinomial uniform", and its
|
||||
// threshold is derivable rather than eyeballed. df = 62 - 1 = 61, so under
|
||||
// the null E[X²] = 61 and SD = sqrt(2*61) ~ 11.05; the Wilson-Hilferty
|
||||
// approximation puts the p = 1e-6 critical value at ~129. Rounding to 130
|
||||
// gives a false-positive rate around one run in a million.
|
||||
//
|
||||
// Power is unaffected. Dropping rejection sampling reintroduces modulo bias
|
||||
// (256 % 62 = 8, so the first 8 characters draw 5 chances per 256 instead
|
||||
// of 4, ~25% overrepresented), which scores X² ~ 237. Simulated: 3000 clean
|
||||
// runs peaked at 104, while 200 biased runs bottomed out at 174.5, so the
|
||||
// threshold sits in a wide empty gap between the two.
|
||||
const alphabetSize = 62;
|
||||
const expected = 36000 / alphabetSize;
|
||||
let chiSquare = 0;
|
||||
for (let i = 0; i < alphabetSize; i++) {
|
||||
const count = charCounts.get(BASE62_ALPHABET[i]) ?? 0;
|
||||
chiSquare += (count - expected) ** 2 / expected;
|
||||
}
|
||||
|
||||
// With rejection sampling, deviation should be < 15% (generous)
|
||||
// Without rejection sampling (modulo bias), first 6 chars would be ~25% overrepresented
|
||||
expect(maxDeviation).toBeLessThan(0.15);
|
||||
expect(charCounts.size).toBe(alphabetSize);
|
||||
expect(chiSquare).toBeLessThan(130);
|
||||
|
||||
tm.stopTokenRotation();
|
||||
});
|
||||
|
||||
@@ -20,18 +20,28 @@ export interface RouteTestHarness {
|
||||
* @param registerFn - The route registration function (e.g., registerSessionRoutes).
|
||||
* Uses `any` for ctx parameter because route functions expect typed port intersections
|
||||
* that MockRouteContext satisfies structurally but not nominally.
|
||||
* @param ctxOptions - Optional overrides for the mock context
|
||||
* @param ctxOptions - Optional overrides for the mock context. `authUser` stands
|
||||
* in for what the auth middleware would attach in multi-user mode; without it
|
||||
* `getAuthUser()` falls back to a synthetic admin, which passes every
|
||||
* ownership check and would make a scoping test pass vacuously.
|
||||
*/
|
||||
export async function createRouteTestHarness(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
registerFn: (app: FastifyInstance, ctx: any) => void,
|
||||
ctxOptions?: { sessionId?: string }
|
||||
ctxOptions?: { sessionId?: string; authUser?: { username: string; role: 'admin' | 'user' } }
|
||||
): Promise<RouteTestHarness> {
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
// Register cookie plugin — some routes access req.cookies
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
if (ctxOptions?.authUser) {
|
||||
const authUser = ctxOptions.authUser;
|
||||
app.addHook('onRequest', async (req) => {
|
||||
(req as unknown as { authUser: typeof authUser }).authUser = authUser;
|
||||
});
|
||||
}
|
||||
|
||||
const ctx = createMockRouteContext(ctxOptions);
|
||||
|
||||
registerFn(app, ctx);
|
||||
|
||||
@@ -8,13 +8,14 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||
import { ApiErrorCode } from '../../src/types.js';
|
||||
|
||||
// Mock fs/promises for file operations
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
readdir: vi.fn(async () => []),
|
||||
readFile: vi.fn(async () => 'file content'),
|
||||
stat: vi.fn(async () => ({ size: 100, isFile: () => true })),
|
||||
stat: vi.fn(async () => ({ size: 100, isFile: () => true, isDirectory: () => true })),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -55,13 +56,301 @@ describe('file-routes', () => {
|
||||
// Default: realpathSync returns the path unchanged
|
||||
mockedRealpathSync.mockImplementation((p: string) => p as never);
|
||||
// Default stat
|
||||
mockedStat.mockResolvedValue({ size: 100, isFile: () => true } as never);
|
||||
mockedStat.mockResolvedValue({ size: 100, isFile: () => true, isDirectory: () => true } as never);
|
||||
mockedReadFile.mockImplementation(async (path) =>
|
||||
String(path).endsWith('settings.json') ? ('{}' as never) : ('file content' as never)
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
// ========== GET /api/filesystem/browse ==========
|
||||
|
||||
describe('GET /api/filesystem/browse', () => {
|
||||
it('lists the active session folder lazily with directories first', async () => {
|
||||
mockedReaddir.mockResolvedValueOnce([
|
||||
{
|
||||
name: 'notes.txt',
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
isSymbolicLink: () => false,
|
||||
},
|
||||
{
|
||||
name: 'src',
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
},
|
||||
] as never);
|
||||
|
||||
const path = harness.ctx._session.workingDir;
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.path).toBe(path);
|
||||
expect(body.data.roots[0]).toEqual({ label: 'Current Folder', path });
|
||||
expect(
|
||||
body.data.entries.map((entry: { name: string; type: string; previewKind?: string }) => [
|
||||
entry.name,
|
||||
entry.type,
|
||||
entry.previewKind,
|
||||
])
|
||||
).toEqual([
|
||||
['src', 'directory', undefined],
|
||||
['notes.txt', 'file', 'text'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects paths outside the configured roots', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/browse?path=${encodeURIComponent('/tmp/not-an-allowed-root')}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('does not expose hidden entries or symlinks that escape the allowed roots', async () => {
|
||||
const root = harness.ctx._session.workingDir;
|
||||
mockedReaddir.mockResolvedValueOnce([
|
||||
{
|
||||
name: '.secret',
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
isSymbolicLink: () => false,
|
||||
},
|
||||
{
|
||||
name: 'outside-link',
|
||||
isDirectory: () => false,
|
||||
isFile: () => false,
|
||||
isSymbolicLink: () => true,
|
||||
},
|
||||
] as never);
|
||||
mockedRealpathSync.mockImplementation((path: string) =>
|
||||
path === `${root}/outside-link` ? ('/etc/shadow' as never) : (path as never)
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(root)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body).data.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown session scope', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/filesystem/browse?sessionId=missing-session',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.NOT_FOUND });
|
||||
});
|
||||
|
||||
it('rejects direct navigation into a hidden descendant', async () => {
|
||||
const hidden = `${harness.ctx._session.workingDir}/.git`;
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(hidden)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Multi-user scoping for the filesystem picker ==========
|
||||
//
|
||||
// The picker is a SECOND file-serving surface and does not inherit the
|
||||
// attachment guard's ownership scoping, so both of its endpoints have to do
|
||||
// it themselves. Two distinct holes are covered here:
|
||||
// 1. `sessionId` was used without an owner check, so any user could pin
|
||||
// another user's workingDir as a browse root.
|
||||
// 2. `Home` and `CASES_DIR` were unconditional roots, and per-user spaces
|
||||
// live INSIDE homedir(), so Home alone exposed every other user's files.
|
||||
describe('filesystem picker multi-user scoping', () => {
|
||||
const SPACES = '/tmp/codeman-test-user-spaces';
|
||||
let prevMultiUser: string | undefined;
|
||||
let prevSpaces: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
prevMultiUser = process.env.CODEMAN_MULTIUSER;
|
||||
prevSpaces = process.env.CODEMAN_USER_SPACES_DIR;
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
process.env.CODEMAN_USER_SPACES_DIR = SPACES;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (prevMultiUser === undefined) delete process.env.CODEMAN_MULTIUSER;
|
||||
else process.env.CODEMAN_MULTIUSER = prevMultiUser;
|
||||
if (prevSpaces === undefined) delete process.env.CODEMAN_USER_SPACES_DIR;
|
||||
else process.env.CODEMAN_USER_SPACES_DIR = prevSpaces;
|
||||
});
|
||||
|
||||
const harnessAs = (role: 'admin' | 'user', username: string) =>
|
||||
createRouteTestHarness(registerFileRoutes, { authUser: { username, role } });
|
||||
|
||||
it('404s a browse scoped to another user session instead of adopting its folder', async () => {
|
||||
const scoped = await harnessAs('user', 'bob');
|
||||
scoped.ctx._session.owner = 'alice';
|
||||
try {
|
||||
const res = await scoped.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/browse?sessionId=${scoped.ctx._sessionId}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.NOT_FOUND });
|
||||
// The decisive part: alice's folder must not have leaked in as a root.
|
||||
expect(res.body).not.toContain(scoped.ctx._session.workingDir);
|
||||
} finally {
|
||||
await scoped.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('404s a preview scoped to another user session', async () => {
|
||||
const scoped = await harnessAs('user', 'bob');
|
||||
scoped.ctx._session.owner = 'alice';
|
||||
try {
|
||||
const res = await scoped.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/preview?sessionId=${scoped.ctx._sessionId}&path=${encodeURIComponent(
|
||||
`${scoped.ctx._session.workingDir}/notes.md`
|
||||
)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
} finally {
|
||||
await scoped.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('confines a regular user to their own space, never Home or the shared cases dir', async () => {
|
||||
const scoped = await harnessAs('user', 'bob');
|
||||
try {
|
||||
mockedReaddir.mockResolvedValueOnce([] as never);
|
||||
const res = await scoped.app.inject({ method: 'GET', url: '/api/filesystem/browse' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.roots).toEqual([{ label: 'My Space', path: `${SPACES}/bob` }]);
|
||||
expect(body.data.path).toBe(`${SPACES}/bob`);
|
||||
} finally {
|
||||
await scoped.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to browse another user's space by absolute path", async () => {
|
||||
const scoped = await harnessAs('user', 'bob');
|
||||
try {
|
||||
const res = await scoped.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/browse?path=${encodeURIComponent(`${SPACES}/alice/cases`)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
} finally {
|
||||
await scoped.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the host-wide roots for a multi-user admin', async () => {
|
||||
const scoped = await harnessAs('admin', 'root');
|
||||
try {
|
||||
mockedReaddir.mockResolvedValueOnce([] as never);
|
||||
const res = await scoped.app.inject({ method: 'GET', url: '/api/filesystem/browse' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const labels = JSON.parse(res.body).data.roots.map((root: { label: string }) => root.label);
|
||||
expect(labels).toContain('Home');
|
||||
expect(labels).not.toContain('My Space');
|
||||
} finally {
|
||||
await scoped.app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/filesystem/preview ==========
|
||||
|
||||
describe('GET /api/filesystem/preview', () => {
|
||||
it('serves Markdown as inert plain text inside the active session root', async () => {
|
||||
const path = `${harness.ctx._session.workingDir}/notes.md`;
|
||||
mockedReadFile.mockImplementation(async (candidate) =>
|
||||
candidate === path ? ('# Safe heading\n<script>alert(1)</script>' as never) : ('{}' as never)
|
||||
);
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/plain');
|
||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||
expect(res.body).toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
it('rejects unsupported file types', async () => {
|
||||
const path = `${harness.ctx._session.workingDir}/archive.exe`;
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('rejects hidden files even when requested directly', async () => {
|
||||
const path = `${harness.ctx._session.workingDir}/.env`;
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects a preview symlink whose real path escapes every allowed root', async () => {
|
||||
const path = `${harness.ctx._session.workingDir}/outside.png`;
|
||||
mockedRealpathSync.mockImplementation((candidate: string) =>
|
||||
candidate === path ? ('/etc/shadow' as never) : (candidate as never)
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('caps text previews at 2MB', async () => {
|
||||
const path = `${harness.ctx._session.workingDir}/large.txt`;
|
||||
mockedStat.mockImplementation(async (candidate) =>
|
||||
candidate === path
|
||||
? ({ size: 2 * 1024 * 1024 + 1, isFile: () => true, isDirectory: () => false } as never)
|
||||
: ({ size: 100, isFile: () => true, isDirectory: () => true } as never)
|
||||
);
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(413);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/sessions/:id/files ==========
|
||||
|
||||
describe('GET /api/sessions/:id/files', () => {
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* CRUD + capability behaviour for /api/webviews.
|
||||
*
|
||||
* Uses app.inject() (no port) against a temp CODEMAN_DATA_DIR, so nothing touches
|
||||
* the developer's real ~/.codeman/webviews.json.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { registerWebviewRoutes } from '../../src/web/routes/webview-routes.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { webviewCapabilities } from '../../src/webview-capabilities.js';
|
||||
import { capabilityFromProxyPath } from '../../src/web/webview-proxy.js';
|
||||
|
||||
let app: FastifyInstance;
|
||||
let tmpDir: string;
|
||||
let savedDataDir: string | undefined;
|
||||
const broadcasts: Array<{ event: string; data: unknown }> = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-webviews-'));
|
||||
savedDataDir = process.env.CODEMAN_DATA_DIR;
|
||||
process.env.CODEMAN_DATA_DIR = tmpDir;
|
||||
broadcasts.length = 0;
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
// The proxy route declares a wsHandler, so the plugin must be present.
|
||||
await app.register(fastifyWebsocket);
|
||||
registerWebviewRoutes(app, {
|
||||
broadcast: (event: string, data: unknown) => broadcasts.push({ event, data }),
|
||||
} as never);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
if (savedDataDir === undefined) delete process.env.CODEMAN_DATA_DIR;
|
||||
else process.env.CODEMAN_DATA_DIR = savedDataDir;
|
||||
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
const create = (payload: Record<string, unknown>) => app.inject({ method: 'POST', url: '/api/webviews', payload });
|
||||
|
||||
describe('GET /api/webviews', () => {
|
||||
it('starts empty and reports the frame budget the client must honour', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/webviews' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.webviews).toEqual([]);
|
||||
expect(typeof body.data.maxLiveFrames).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/webviews', () => {
|
||||
it('creates a dashboard that defaults to proxied and sandboxed', async () => {
|
||||
const res = await create({ name: 'Grafana', url: 'http://127.0.0.1:4000/' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const w = res.json().data;
|
||||
// Proxy + untrusted are the safe defaults and must not drift.
|
||||
expect(w.embedMode).toBe('proxy');
|
||||
expect(w.trusted).toBe(false);
|
||||
expect(w.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it('broadcasts the change so other devices re-fetch', async () => {
|
||||
await create({ name: 'G', url: 'http://127.0.0.1:4000/' });
|
||||
expect(broadcasts.map((b) => b.event)).toContain('webview:changed');
|
||||
});
|
||||
|
||||
it('persists across a fresh read of the store', async () => {
|
||||
await create({ name: 'G', url: 'http://127.0.0.1:4000/' });
|
||||
const list = (await app.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews;
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].name).toBe('G');
|
||||
});
|
||||
|
||||
it('rejects URLs that are not plain http(s)', async () => {
|
||||
for (const url of ['javascript:alert(1)', 'file:///etc/passwd', 'data:text/html,x']) {
|
||||
const res = await create({ name: 'bad', url });
|
||||
expect(res.statusCode, url).toBe(400);
|
||||
expect(res.json().errorCode).toBe('INVALID_INPUT');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects URLs carrying embedded credentials', async () => {
|
||||
const res = await create({ name: 'bad', url: 'http://user:pass@host:4000/' });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('requires a name', async () => {
|
||||
expect((await create({ url: 'http://127.0.0.1:4000/' })).statusCode).toBe(400);
|
||||
expect((await create({ name: ' ', url: 'http://127.0.0.1:4000/' })).statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/webviews/:id', () => {
|
||||
it('updates fields and revokes the outstanding capability', async () => {
|
||||
const id = (await create({ name: 'G', url: 'http://127.0.0.1:4000/' })).json().data.id;
|
||||
const opened = await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` });
|
||||
const cap = capabilityFromProxyPath(opened.json().data.embedUrl)!;
|
||||
expect(webviewCapabilities.resolve(cap)).toBeDefined();
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/webviews/${id}`,
|
||||
payload: { url: 'http://127.0.0.1:4001/' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data.url).toBe('http://127.0.0.1:4001/');
|
||||
// A token minted against the OLD url must not survive the repoint.
|
||||
expect(webviewCapabilities.resolve(cap)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('404s an unknown id', async () => {
|
||||
const res = await app.inject({ method: 'PATCH', url: '/api/webviews/nope', payload: { name: 'x' } });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('still validates the URL on update', async () => {
|
||||
const id = (await create({ name: 'G', url: 'http://127.0.0.1:4000/' })).json().data.id;
|
||||
const res = await app.inject({ method: 'PATCH', url: `/api/webviews/${id}`, payload: { url: 'file:///etc' } });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/webviews/:id', () => {
|
||||
it('removes it and revokes its capability', async () => {
|
||||
const id = (await create({ name: 'G', url: 'http://127.0.0.1:4000/' })).json().data.id;
|
||||
const opened = await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` });
|
||||
const cap = capabilityFromProxyPath(opened.json().data.embedUrl)!;
|
||||
|
||||
expect((await app.inject({ method: 'DELETE', url: `/api/webviews/${id}` })).statusCode).toBe(200);
|
||||
expect((await app.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews).toEqual([]);
|
||||
expect(webviewCapabilities.resolve(cap)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('404s an unknown id', async () => {
|
||||
expect((await app.inject({ method: 'DELETE', url: '/api/webviews/nope' })).statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/webviews/:id/open', () => {
|
||||
it('mints a same-origin embed path for a proxied dashboard', async () => {
|
||||
const id = (await create({ name: 'G', url: 'http://127.0.0.1:4000/' })).json().data.id;
|
||||
const data = (await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` })).json().data;
|
||||
expect(data.embedUrl).toMatch(/^\/webview\/[A-Za-z0-9_-]{16,}\/$/);
|
||||
expect(capabilityFromProxyPath(data.embedUrl)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns no embed path in direct mode, where the iframe uses the real URL', async () => {
|
||||
const id = (await create({ name: 'G', url: 'https://ok.example/', embedMode: 'direct' })).json().data.id;
|
||||
const data = (await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` })).json().data;
|
||||
expect(data.embedUrl).toBeUndefined();
|
||||
expect(data.webview.url).toBe('https://ok.example/');
|
||||
});
|
||||
|
||||
it('reuses the capability across repeated opens instead of leaking one per click', async () => {
|
||||
const id = (await create({ name: 'G', url: 'http://127.0.0.1:4000/' })).json().data.id;
|
||||
const first = (await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` })).json().data.embedUrl;
|
||||
const second = (await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` })).json().data.embedUrl;
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it('records lastOpenedAt', async () => {
|
||||
const id = (await create({ name: 'G', url: 'http://127.0.0.1:4000/' })).json().data.id;
|
||||
await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` });
|
||||
const list = (await app.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews;
|
||||
expect(typeof list[0].lastOpenedAt).toBe('number');
|
||||
});
|
||||
|
||||
it('404s an unknown id', async () => {
|
||||
expect((await app.inject({ method: 'POST', url: '/api/webviews/nope/open' })).statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('proxy route', () => {
|
||||
it('refuses an unknown or expired capability', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: `/webview/${'Z'.repeat(32)}/` });
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('redirects the prefix without a trailing slash, so relative URLs resolve inside it', async () => {
|
||||
const cap = 'Y'.repeat(32);
|
||||
const res = await app.inject({ method: 'GET', url: `/webview/${cap}` });
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toBe(`/webview/${cap}/`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/webviews/probe', () => {
|
||||
it('reports an unreachable target as a normal answer, not a 500', async () => {
|
||||
// Port 1 is reserved and refuses instantly.
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/webviews/probe',
|
||||
payload: { url: 'http://127.0.0.1:1/' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const probe = res.json().data;
|
||||
expect(probe.reachable).toBe(false);
|
||||
expect(probe.recommendedMode).toBe('proxy');
|
||||
});
|
||||
|
||||
it('rejects an invalid URL up front', async () => {
|
||||
const res = await app.inject({ method: 'POST', url: '/api/webviews/probe', payload: { url: 'file:///etc' } });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* @fileoverview Static and VM regressions for Codeman UI/xterm skin parity.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const indexSource = readFileSync(resolve('src/web/public/index.html'), 'utf8');
|
||||
const stylesSource = readFileSync(resolve('src/web/public/styles.css'), 'utf8');
|
||||
const mobileStylesSource = readFileSync(resolve('src/web/public/mobile.css'), 'utf8');
|
||||
const terminalSource = readFileSync(resolve('src/web/public/terminal-ui.js'), 'utf8');
|
||||
|
||||
const LIGHT_SKINS = ['paper-gray', 'solarized-light', 'catppuccin-latte', 'rose-pine-dawn'] as const;
|
||||
|
||||
function hexRgb(hex: string): [number, number, number] {
|
||||
const normalized = hex.replace('#', '');
|
||||
if (!/^[0-9a-f]{6}$/i.test(normalized)) throw new Error(`Expected six-digit hex color, got ${hex}`);
|
||||
return [0, 2, 4].map((offset) => Number.parseInt(normalized.slice(offset, offset + 2), 16)) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
}
|
||||
|
||||
function luminance(hex: string): number {
|
||||
const channels = hexRgb(hex).map((channel) => {
|
||||
const value = channel / 255;
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
|
||||
}
|
||||
|
||||
function contrastRatio(first: string, second: string): number {
|
||||
const [lighter, darker] = [luminance(first), luminance(second)].sort((a, b) => b - a);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
function loadTerminalThemes() {
|
||||
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, (...args: unknown[]) => unknown> };
|
||||
const document = { documentElement: { dataset: { skin: 'paper-gray' } } };
|
||||
const window: Record<string, unknown> = {};
|
||||
vm.runInNewContext(terminalSource, { CodemanApp: FakeCodemanApp, document, window }, { filename: 'terminal-ui.js' });
|
||||
return {
|
||||
mixin: FakeCodemanApp.prototype,
|
||||
themes: window.CODEMAN_XTERM_THEMES as Record<string, Record<string, string>>,
|
||||
isLight: window.codemanCurrentSkinIsLight as (skin?: string) => boolean,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Codeman light skins', () => {
|
||||
const terminal = loadTerminalThemes();
|
||||
|
||||
it('keeps the picker, pre-paint allowlist, CSS, and xterm palette in sync', () => {
|
||||
for (const skin of LIGHT_SKINS) {
|
||||
expect(indexSource).toContain(`value="${skin}"`);
|
||||
expect(indexSource).toContain(`'${skin}'`);
|
||||
expect(stylesSource).toContain(`html[data-skin="${skin}"]`);
|
||||
expect(stylesSource).toMatch(new RegExp(`html\\[data-skin="${skin}"\\] \\{[\\s\\S]*?color-scheme: light;`));
|
||||
expect(terminal.themes[skin]).toBeDefined();
|
||||
expect(terminal.isLight(skin)).toBe(true);
|
||||
}
|
||||
expect(terminal.isLight('daylight-blue')).toBe(false);
|
||||
});
|
||||
|
||||
it('provides readable dark-on-light terminal foregrounds', () => {
|
||||
for (const skin of LIGHT_SKINS) {
|
||||
const theme = terminal.themes[skin];
|
||||
expect(contrastRatio(theme.background, theme.foreground), skin).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
});
|
||||
|
||||
it('switches live terminals between light and dark contrast policies', () => {
|
||||
const main = { options: {} as Record<string, unknown>, rows: 24, refresh: vi.fn() };
|
||||
const teammate = { options: {} as Record<string, unknown>, rows: 12, refresh: vi.fn() };
|
||||
const refreshFont = vi.fn();
|
||||
const app = {
|
||||
terminal: main,
|
||||
teammateTerminals: new Map([['agent-1', { terminal: teammate }]]),
|
||||
_localEchoOverlay: { refreshFont },
|
||||
};
|
||||
|
||||
terminal.mixin.applyTerminalSkin.call(app, 'paper-gray');
|
||||
expect(main.options.minimumContrastRatio).toBe(4.5);
|
||||
expect(teammate.options.minimumContrastRatio).toBe(4.5);
|
||||
expect((main.options.theme as Record<string, string>).background).toBe('#f6f8fa');
|
||||
expect(refreshFont).toHaveBeenCalledTimes(1);
|
||||
|
||||
terminal.mixin.applyTerminalSkin.call(app, 'daylight-blue');
|
||||
expect(main.options.minimumContrastRatio).toBe(1);
|
||||
expect(teammate.options.minimumContrastRatio).toBe(1);
|
||||
expect((main.options.theme as Record<string, string>).background).toBe('#161b23');
|
||||
expect(refreshFont).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('themes stateful input and response surfaces instead of pinning dark colors', () => {
|
||||
expect(stylesSource).toContain('background: var(--bg-input);\n color: var(--text);');
|
||||
expect(stylesSource).toMatch(/#cjkInput \{[\s\S]*?background: var\(--bg-input\);[\s\S]*?color: var\(--text\);/);
|
||||
expect(stylesSource).toMatch(/\.response-viewer \{[\s\S]*?background: var\(--floating-bg\);/);
|
||||
expect(stylesSource).toMatch(/\.response-viewer-body pre \{[\s\S]*?background: var\(--bg-dark\);/);
|
||||
expect(stylesSource).toMatch(/\.response-viewer-body pre code \{[\s\S]*?color: var\(--text\);/);
|
||||
expect(stylesSource).toMatch(/\.file-preview-body \{[\s\S]*?background: var\(--bg-dark\);/);
|
||||
});
|
||||
|
||||
it('uses skin variables for the pre-paint skeleton and native controls', () => {
|
||||
expect(indexSource).toContain('background:var(--term-bg,#161b23)');
|
||||
expect(indexSource).toContain('background:var(--glass-bg,rgba(31,38,48,0.85))');
|
||||
expect(stylesSource).toContain('color-scheme: light;');
|
||||
expect(stylesSource).toContain('background: var(--floating-bg);');
|
||||
expect(mobileStylesSource).toContain(':is(.header, .toolbar, .keyboard-accessory-bar)');
|
||||
expect(mobileStylesSource).toContain(':is(.case-settings-popover-mobile, .mobile-case-picker-sheet)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* The web-tab proxy is exempt from Codeman's cookie auth and its cross-site Origin
|
||||
* guard, because a sandboxed dashboard iframe is opaque-origin: it sends no session
|
||||
* cookie and its writes arrive with `Origin: null`. The capability in the path is
|
||||
* the credential instead.
|
||||
*
|
||||
* That exemption is the security-sensitive part of this feature, so these tests pin
|
||||
* its EDGES: it must apply to a live capability and to nothing else. A regression
|
||||
* here would be an unauthenticated hole into an agent-spawning API.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { registerAuthMiddleware, registerHostGuard, registerSecurityHeaders } from '../src/web/middleware/auth.js';
|
||||
import { webviewCapabilities } from '../src/webview-capabilities.js';
|
||||
import type { HostPolicy } from '../src/web/network-auth-policy.js';
|
||||
|
||||
const POLICY: HostPolicy = { allowedHosts: [], allowLan: true };
|
||||
const PASSWORD = 'test-password';
|
||||
|
||||
let app: FastifyInstance;
|
||||
let capability: string;
|
||||
let savedPassword: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
savedPassword = process.env.CODEMAN_PASSWORD;
|
||||
// The middleware reads this at registration time; auth is inert without it.
|
||||
process.env.CODEMAN_PASSWORD = PASSWORD;
|
||||
|
||||
capability = webviewCapabilities.mint('webview-under-test', undefined);
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
// Same order as server.ts (host guard → auth → security headers), so hook
|
||||
// interactions are exercised for real. The OPTIONS short-circuit lives in
|
||||
// registerSecurityHeaders and is part of what these tests pin.
|
||||
registerHostGuard(app, () => POLICY);
|
||||
registerAuthMiddleware(app, false);
|
||||
registerSecurityHeaders(app, false);
|
||||
|
||||
// Stand-ins for the real surfaces, so a reachable route means auth let it through.
|
||||
app.all('/webview/:cap/*', async () => ({ proxied: true }));
|
||||
app.all('/api/sessions', async () => ({ sensitive: true }));
|
||||
app.get('/', async () => 'app shell');
|
||||
app.get('/static/app.js', async () => 'asset');
|
||||
app.get('/webviewfoo/bar', async () => 'lookalike');
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
webviewCapabilities.revokeWebview('webview-under-test');
|
||||
if (savedPassword === undefined) delete process.env.CODEMAN_PASSWORD;
|
||||
else process.env.CODEMAN_PASSWORD = savedPassword;
|
||||
});
|
||||
|
||||
describe('the exemption applies to a live capability', () => {
|
||||
it('lets an unauthenticated GET through on the proxy path', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: `/webview/${capability}/static/app.js` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('lets a write through despite Origin: null, which a sandboxed iframe always sends', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/webview/${capability}/login`,
|
||||
headers: { origin: 'null' },
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('lets a CORS preflight reach the proxy instead of the global 204 short-circuit', async () => {
|
||||
// registerSecurityHeaders answers every OPTIONS with a bare 204, which carries
|
||||
// no Access-Control-Allow-Origin for the `null` origin a sandboxed frame sends.
|
||||
// The proxy must get the chance to answer with real CORS headers, or every
|
||||
// dashboard fetch fails its preflight.
|
||||
const res = await app.inject({
|
||||
method: 'OPTIONS',
|
||||
url: `/webview/${capability}/api/stats`,
|
||||
headers: { origin: 'null', 'access-control-request-method': 'GET' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200); // reached the stand-in route, not the 204 hook
|
||||
});
|
||||
|
||||
it('still short-circuits OPTIONS everywhere else', async () => {
|
||||
// Authenticated, because the auth hook runs before the security-headers hook
|
||||
// and would otherwise 401 first. With credentials the 204 short-circuit is
|
||||
// reached, proving it is intact for every non-webview path.
|
||||
const res = await app.inject({
|
||||
method: 'OPTIONS',
|
||||
url: '/api/sessions',
|
||||
headers: {
|
||||
origin: 'null',
|
||||
'access-control-request-method': 'GET',
|
||||
authorization: 'Basic ' + Buffer.from(`admin:${PASSWORD}`).toString('base64'),
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(204);
|
||||
expect(res.headers['access-control-allow-origin']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('serves a root-absolute asset when the Referer identifies the dashboard', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/static/app.js',
|
||||
headers: { referer: `http://localhost/webview/${capability}/panel` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the exemption does NOT widen anywhere else', () => {
|
||||
it('rejects an unauthenticated request with no capability at all', async () => {
|
||||
expect((await app.inject({ method: 'GET', url: '/static/app.js' })).statusCode).toBe(401);
|
||||
expect((await app.inject({ method: 'GET', url: '/' })).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects a well-formed but UNKNOWN capability', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: `/webview/${'Z'.repeat(32)}/x` });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects a revoked capability immediately', async () => {
|
||||
webviewCapabilities.revokeWebview('webview-under-test');
|
||||
const res = await app.inject({ method: 'GET', url: `/webview/${capability}/x` });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('does not match a lookalike prefix', async () => {
|
||||
expect((await app.inject({ method: 'GET', url: '/webviewfoo/bar' })).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('NEVER exempts the Codeman API, even with a valid capability in the Referer', async () => {
|
||||
// This is the hole the Referer form would open if it were not path-fenced.
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/sessions',
|
||||
headers: { referer: `http://localhost/webview/${capability}/panel` },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('does not let the Referer form carry a WRITE', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/static/app.js',
|
||||
headers: { referer: `http://localhost/webview/${capability}/panel`, origin: 'null' },
|
||||
payload: {},
|
||||
});
|
||||
// Blocked as cross-site by the Origin guard, or as unauthenticated. Either is fine;
|
||||
// what matters is that it is not 200.
|
||||
expect(res.statusCode).not.toBe(200);
|
||||
});
|
||||
|
||||
it('still blocks a genuinely cross-site write to the API', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
headers: { origin: 'https://evil.example' },
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authenticated access is unaffected', () => {
|
||||
const basic = 'Basic ' + Buffer.from(`admin:${PASSWORD}`).toString('base64');
|
||||
|
||||
it('normal Basic auth still reaches the app', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/', headers: { authorization: basic } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('a wrong password is still rejected', async () => {
|
||||
const wrong = 'Basic ' + Buffer.from('admin:nope').toString('base64');
|
||||
expect((await app.inject({ method: 'GET', url: '/', headers: { authorization: wrong } })).statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,493 @@
|
||||
/**
|
||||
* Pure helpers behind the web-tab reverse proxy (src/web/webview-proxy.ts).
|
||||
*
|
||||
* These cover the rewrites that make an un-embeddable dashboard embeddable, and
|
||||
* the containment checks that keep the proxy from becoming an open relay.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildDownstreamResponseHeaders,
|
||||
buildProxyCorsHeaders,
|
||||
buildUpstreamRequestHeaders,
|
||||
capabilityFromProxyPath,
|
||||
capabilityFromReferer,
|
||||
extractFrameAncestors,
|
||||
filterCookieHeader,
|
||||
isFramableCrossOrigin,
|
||||
isHtmlContentType,
|
||||
isValidWebviewUrl,
|
||||
parseWebviewUrl,
|
||||
proxyPrefixFor,
|
||||
resolveUpstreamUrl,
|
||||
rewriteHtml,
|
||||
rewriteLocation,
|
||||
rewriteSetCookie,
|
||||
runtimeUrlShim,
|
||||
stripFrameAncestors,
|
||||
upstreamWebSocketUrl,
|
||||
} from '../src/web/webview-proxy.js';
|
||||
|
||||
const CAP = 'A'.repeat(32);
|
||||
const PREFIX = `/webview/${CAP}/`;
|
||||
|
||||
describe('parseWebviewUrl', () => {
|
||||
it('accepts plain http and https', () => {
|
||||
expect(parseWebviewUrl('http://127.0.0.1:4000/')?.origin).toBe('http://127.0.0.1:4000');
|
||||
expect(parseWebviewUrl('https://dash.example.com/grafana')?.origin).toBe('https://dash.example.com');
|
||||
});
|
||||
|
||||
it('rejects non-http schemes', () => {
|
||||
for (const url of ['javascript:alert(1)', 'file:///etc/passwd', 'data:text/html,x', 'ftp://host/x']) {
|
||||
expect(parseWebviewUrl(url), url).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects embedded credentials, which would be forwarded and logged', () => {
|
||||
expect(parseWebviewUrl('http://user:pass@host:4000/')).toBeNull();
|
||||
expect(parseWebviewUrl('http://user@host:4000/')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects garbage and empty input', () => {
|
||||
expect(parseWebviewUrl('')).toBeNull();
|
||||
expect(parseWebviewUrl('not a url')).toBeNull();
|
||||
expect(isValidWebviewUrl('http://ok.example')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveUpstreamUrl', () => {
|
||||
const saved = 'http://127.0.0.1:4000/grafana/d/abc?theme=dark';
|
||||
|
||||
it('serves the saved path+query for the landing page', () => {
|
||||
expect(resolveUpstreamUrl(saved, '', '')?.href).toBe('http://127.0.0.1:4000/grafana/d/abc?theme=dark');
|
||||
});
|
||||
|
||||
it('is ORIGIN-scoped, not path-scoped, so root-absolute assets resolve', () => {
|
||||
// The saved /grafana/d/abc path must NOT be prepended, or /public/x.js 404s.
|
||||
expect(resolveUpstreamUrl(saved, 'public/build/app.js', '')?.href).toBe(
|
||||
'http://127.0.0.1:4000/public/build/app.js'
|
||||
);
|
||||
});
|
||||
|
||||
it('carries the query string through', () => {
|
||||
expect(resolveUpstreamUrl(saved, 'api/data', '?from=now-6h')?.href).toBe(
|
||||
'http://127.0.0.1:4000/api/data?from=now-6h'
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses to leave the upstream origin', () => {
|
||||
// Protocol-relative would jump host; traversal would climb out.
|
||||
expect(resolveUpstreamUrl(saved, '/evil.com/x', '')?.origin).toBe('http://127.0.0.1:4000');
|
||||
expect(resolveUpstreamUrl(saved, '//evil.com/x', '')).toBeNull();
|
||||
const climbed = resolveUpstreamUrl(saved, '../../../../etc/passwd', '');
|
||||
expect(climbed?.origin).toBe('http://127.0.0.1:4000');
|
||||
});
|
||||
|
||||
it('returns null for an unusable saved url', () => {
|
||||
expect(resolveUpstreamUrl('javascript:alert(1)', 'x', '')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('capability extraction', () => {
|
||||
it('reads the capability out of a proxy path', () => {
|
||||
expect(capabilityFromProxyPath(`${PREFIX}static/app.js`)).toBe(CAP);
|
||||
expect(capabilityFromProxyPath(PREFIX)).toBe(CAP);
|
||||
expect(capabilityFromProxyPath(`/webview/${CAP}`)).toBe(CAP);
|
||||
});
|
||||
|
||||
it('does not match a lookalike prefix', () => {
|
||||
expect(capabilityFromProxyPath('/webviewfoo/bar')).toBeNull();
|
||||
expect(capabilityFromProxyPath('/api/webviews')).toBeNull();
|
||||
expect(capabilityFromProxyPath('/')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects capabilities of implausible shape', () => {
|
||||
expect(capabilityFromProxyPath('/webview/short/x')).toBeNull();
|
||||
expect(capabilityFromProxyPath('/webview/has spaces here and more/x')).toBeNull();
|
||||
expect(capabilityFromProxyPath('/webview/../../etc/x')).toBeNull();
|
||||
});
|
||||
|
||||
it('reads it from a Referer for the root-absolute asset fallback', () => {
|
||||
expect(capabilityFromReferer(`https://box.ts.net${PREFIX}page`)).toBe(CAP);
|
||||
expect(capabilityFromReferer('https://box.ts.net/')).toBeNull();
|
||||
expect(capabilityFromReferer('not a url')).toBeNull();
|
||||
expect(capabilityFromReferer(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSP handling', () => {
|
||||
it('strips frame-ancestors and keeps every other directive', () => {
|
||||
const csp = "default-src 'self'; frame-ancestors 'none'; script-src 'unsafe-inline'";
|
||||
expect(stripFrameAncestors(csp)).toBe("default-src 'self'; script-src 'unsafe-inline'");
|
||||
});
|
||||
|
||||
it('leaves a policy without frame-ancestors alone', () => {
|
||||
expect(stripFrameAncestors("default-src 'self'")).toBe("default-src 'self'");
|
||||
});
|
||||
|
||||
it('does not confuse a similarly-named directive', () => {
|
||||
expect(stripFrameAncestors("frame-src 'self'; frame-ancestors 'none'")).toBe("frame-src 'self'");
|
||||
});
|
||||
|
||||
it('extracts the directive value for the probe', () => {
|
||||
expect(extractFrameAncestors("default-src 'self'; frame-ancestors https://a.com")).toBe('https://a.com');
|
||||
expect(extractFrameAncestors("default-src 'self'")).toBeUndefined();
|
||||
expect(extractFrameAncestors(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFramableCrossOrigin', () => {
|
||||
it('honours X-Frame-Options', () => {
|
||||
expect(isFramableCrossOrigin('DENY', undefined)).toBe(false);
|
||||
expect(isFramableCrossOrigin('sameorigin', undefined)).toBe(false);
|
||||
expect(isFramableCrossOrigin(undefined, undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats frame-ancestors 'none' and 'self' as not cross-origin framable", () => {
|
||||
expect(isFramableCrossOrigin(undefined, "frame-ancestors 'none'")).toBe(false);
|
||||
expect(isFramableCrossOrigin(undefined, "frame-ancestors 'self'")).toBe(false);
|
||||
});
|
||||
|
||||
it('allows a wildcard or explicit host', () => {
|
||||
expect(isFramableCrossOrigin(undefined, 'frame-ancestors *')).toBe(true);
|
||||
expect(isFramableCrossOrigin(undefined, 'frame-ancestors https://codeman.example')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rewriteLocation', () => {
|
||||
const requestUrl = new URL('http://127.0.0.1:4000/login');
|
||||
|
||||
it('maps a root-absolute redirect into the proxy prefix', () => {
|
||||
expect(rewriteLocation('/dashboard?x=1', requestUrl, CAP)).toBe(`${PREFIX}dashboard?x=1`);
|
||||
});
|
||||
|
||||
it('maps a same-origin absolute redirect', () => {
|
||||
expect(rewriteLocation('http://127.0.0.1:4000/home', requestUrl, CAP)).toBe(`${PREFIX}home`);
|
||||
});
|
||||
|
||||
it('leaves a CROSS-origin redirect alone rather than relaying it', () => {
|
||||
// Relaying would make this an open proxy for any host the upstream names.
|
||||
expect(rewriteLocation('https://evil.example/x', requestUrl, CAP)).toBe('https://evil.example/x');
|
||||
});
|
||||
|
||||
it('preserves the hash', () => {
|
||||
expect(rewriteLocation('/panel#row2', requestUrl, CAP)).toBe(`${PREFIX}panel#row2`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rewriteSetCookie', () => {
|
||||
it('rebases Path onto the proxy prefix and drops Domain', () => {
|
||||
const out = rewriteSetCookie('sid=abc; Path=/; Domain=dash.local; HttpOnly', CAP, true);
|
||||
expect(out).toContain('sid=abc');
|
||||
expect(out).toContain(`Path=${PREFIX}`);
|
||||
expect(out).not.toContain('Domain');
|
||||
expect(out).toContain('HttpOnly');
|
||||
});
|
||||
|
||||
it('adds a scoped Path when the upstream sent none', () => {
|
||||
expect(rewriteSetCookie('sid=abc; HttpOnly', CAP, true)).toContain(`Path=${PREFIX}`);
|
||||
});
|
||||
|
||||
it('drops Secure when Codeman itself is serving plain HTTP', () => {
|
||||
// A Secure cookie over http is silently discarded by the browser.
|
||||
expect(rewriteSetCookie('sid=abc; Path=/; Secure', CAP, false)).not.toMatch(/secure/i);
|
||||
expect(rewriteSetCookie('sid=abc; Path=/; Secure', CAP, true)).toMatch(/Secure/);
|
||||
});
|
||||
|
||||
it('keeps a nested upstream path under the prefix', () => {
|
||||
expect(rewriteSetCookie('sid=abc; Path=/admin', CAP, true)).toContain(`Path=${PREFIX}admin`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterCookieHeader', () => {
|
||||
it("removes Codeman's own session cookie and keeps the dashboard's", () => {
|
||||
expect(filterCookieHeader('codeman_session=SECRET; dash=1; other=2', ['codeman_session'])).toBe('dash=1; other=2');
|
||||
});
|
||||
|
||||
it('returns undefined when nothing survives', () => {
|
||||
expect(filterCookieHeader('codeman_session=SECRET', ['codeman_session'])).toBeUndefined();
|
||||
expect(filterCookieHeader(undefined, ['codeman_session'])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildUpstreamRequestHeaders', () => {
|
||||
const upstream = new URL('http://127.0.0.1:4000/panel');
|
||||
|
||||
it('NEVER forwards Codeman credentials to the dashboard', () => {
|
||||
const headers = buildUpstreamRequestHeaders(
|
||||
{ authorization: 'Basic CODEMANCREDS', cookie: 'codeman_session=SECRET; dash=1', accept: '*/*' },
|
||||
upstream,
|
||||
{ forwardCookies: false, sessionCookieName: 'codeman_session' }
|
||||
);
|
||||
expect(headers.authorization).toBeUndefined();
|
||||
expect(headers.cookie).toBeUndefined();
|
||||
expect(headers.accept).toBe('*/*');
|
||||
});
|
||||
|
||||
it('forwards the dashboard cookies but strips the session cookie in trusted mode', () => {
|
||||
const headers = buildUpstreamRequestHeaders({ cookie: 'codeman_session=SECRET; dash=1' }, upstream, {
|
||||
forwardCookies: true,
|
||||
sessionCookieName: 'codeman_session',
|
||||
});
|
||||
expect(headers.cookie).toBe('dash=1');
|
||||
expect(headers.authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it('presents Origin/Referer as if the browser talked to the dashboard directly', () => {
|
||||
const headers = buildUpstreamRequestHeaders({ origin: 'https://codeman.local' }, upstream, {
|
||||
forwardCookies: false,
|
||||
sessionCookieName: 'codeman_session',
|
||||
});
|
||||
expect(headers.origin).toBe('http://127.0.0.1:4000');
|
||||
expect(headers.referer).toBe('http://127.0.0.1:4000/panel');
|
||||
});
|
||||
|
||||
it('drops hop-by-hop and recomputed headers', () => {
|
||||
const headers = buildUpstreamRequestHeaders(
|
||||
{ host: 'codeman.local', connection: 'keep-alive', 'transfer-encoding': 'chunked', 'content-length': '5' },
|
||||
upstream,
|
||||
{ forwardCookies: false, sessionCookieName: 'codeman_session' }
|
||||
);
|
||||
expect(headers.host).toBeUndefined();
|
||||
expect(headers.connection).toBeUndefined();
|
||||
expect(headers['transfer-encoding']).toBeUndefined();
|
||||
expect(headers['content-length']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDownstreamResponseHeaders', () => {
|
||||
const requestUrl = new URL('http://127.0.0.1:4000/panel');
|
||||
const build = (entries: Array<[string, string]>, cookies: string[] = []) =>
|
||||
buildDownstreamResponseHeaders(entries, cookies, CAP, requestUrl, true);
|
||||
|
||||
it('strips the framing refusal, which is the whole point of the proxy', () => {
|
||||
const { headers } = build([
|
||||
['x-frame-options', 'DENY'],
|
||||
['content-type', 'text/html'],
|
||||
]);
|
||||
expect(headers['x-frame-options']).toBeUndefined();
|
||||
expect(headers['content-type']).toBe('text/html');
|
||||
});
|
||||
|
||||
it('drops content-encoding/length because undici already decoded the body', () => {
|
||||
// Forwarding these makes the browser try to gunzip plaintext.
|
||||
const { headers } = build([
|
||||
['content-encoding', 'gzip'],
|
||||
['content-length', '1234'],
|
||||
]);
|
||||
expect(headers['content-encoding']).toBeUndefined();
|
||||
expect(headers['content-length']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the upstream CSP minus frame-ancestors, and null when there was none', () => {
|
||||
expect(build([['content-security-policy', "default-src 'self'; frame-ancestors 'none'"]]).csp).toBe(
|
||||
"default-src 'self'"
|
||||
);
|
||||
expect(build([['content-type', 'text/css']]).csp).toBeNull();
|
||||
});
|
||||
|
||||
it('rewrites Location and Set-Cookie', () => {
|
||||
const { headers, setCookie } = build([['location', '/next']], ['sid=1; Path=/']);
|
||||
expect(headers.location).toBe(`${PREFIX}next`);
|
||||
expect(setCookie).toHaveLength(1);
|
||||
expect(setCookie[0]).toContain(`Path=${PREFIX}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rewriteHtml', () => {
|
||||
it('injects <base> immediately after <head>', () => {
|
||||
const out = rewriteHtml('<html><head><title>x</title></head><body></body></html>', CAP);
|
||||
expect(out).toContain(`<head><base href="${PREFIX}">`);
|
||||
});
|
||||
|
||||
it('falls back to <html>, then to the very start, for malformed documents', () => {
|
||||
expect(rewriteHtml('<html><body>hi</body></html>', CAP)).toContain(`<html><base href="${PREFIX}">`);
|
||||
const bare = rewriteHtml('just text', CAP);
|
||||
expect(bare.startsWith(`<base href="${PREFIX}">`)).toBe(true);
|
||||
expect(bare.endsWith('just text')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not add a second <base> when the page already has one', () => {
|
||||
const out = rewriteHtml('<html><head><base href="/x/"></head></html>', CAP);
|
||||
expect(out.match(/<base/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('still injects the runtime shim when the page ships its own <base>', () => {
|
||||
// The shim is the only layer that catches runtime-built URLs, so an early
|
||||
// return on an existing <base> would silently break those pages.
|
||||
const out = rewriteHtml('<html><head><base href="/x/"></head></html>', CAP);
|
||||
expect(out).toContain('<script>');
|
||||
expect(out).toContain(PREFIX);
|
||||
});
|
||||
|
||||
it('injects the shim into every rewritten document', () => {
|
||||
expect(rewriteHtml('<html><head></head></html>', CAP)).toContain('<script>');
|
||||
expect(rewriteHtml('just text', CAP)).toContain('<script>');
|
||||
});
|
||||
|
||||
it('rebases root-absolute src/href/action, which <base> cannot fix', () => {
|
||||
const out = rewriteHtml(
|
||||
`<head></head><body><script src="/static/app.js"></script><link href='/s.css'><form action="/login"></form></body>`,
|
||||
CAP
|
||||
);
|
||||
expect(out).toContain(`src="${PREFIX}static/app.js"`);
|
||||
expect(out).toContain(`href='${PREFIX}s.css'`);
|
||||
expect(out).toContain(`action="${PREFIX}login"`);
|
||||
});
|
||||
|
||||
it('leaves protocol-relative and absolute URLs alone', () => {
|
||||
const out = rewriteHtml('<head></head><script src="//cdn.example/x.js"></script><img src="https://a/b.png">', CAP);
|
||||
expect(out).toContain('src="//cdn.example/x.js"');
|
||||
expect(out).toContain('src="https://a/b.png"');
|
||||
});
|
||||
|
||||
it('is stable across repeated calls (no shared regex lastIndex)', () => {
|
||||
const html = '<head></head><script src="/a.js"></script>';
|
||||
expect(rewriteHtml(html, CAP)).toBe(rewriteHtml(html, CAP));
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildProxyCorsHeaders', () => {
|
||||
it('echoes the opaque origin a sandboxed frame sends', () => {
|
||||
// Without this the browser rejects every dashboard fetch with an opaque
|
||||
// net::ERR_FAILED, while the page itself renders fine.
|
||||
const h = buildProxyCorsHeaders('null');
|
||||
expect(h['access-control-allow-origin']).toBe('null');
|
||||
expect(h.vary).toBe('Origin');
|
||||
});
|
||||
|
||||
it('omits allow-credentials for a null origin, which browsers reject together', () => {
|
||||
expect(buildProxyCorsHeaders('null')['access-control-allow-credentials']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows credentials for a real origin (trusted mode)', () => {
|
||||
const h = buildProxyCorsHeaders('https://codeman.local');
|
||||
expect(h['access-control-allow-origin']).toBe('https://codeman.local');
|
||||
expect(h['access-control-allow-credentials']).toBe('true');
|
||||
});
|
||||
|
||||
it('echoes requested headers on a preflight', () => {
|
||||
expect(buildProxyCorsHeaders('null', 'content-type, x-token')['access-control-allow-headers']).toBe(
|
||||
'content-type, x-token'
|
||||
);
|
||||
expect(buildProxyCorsHeaders('null')['access-control-allow-headers']).toBe('*');
|
||||
});
|
||||
|
||||
it('emits nothing when the request carries no Origin', () => {
|
||||
expect(buildProxyCorsHeaders(undefined)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtimeUrlShim', () => {
|
||||
const shim = runtimeUrlShim(PREFIX);
|
||||
const body = shim.replace(/^<script>/, '').replace(/<\/script>$/, '');
|
||||
|
||||
it('emits a parseable script', () => {
|
||||
expect(shim.startsWith('<script>')).toBe(true);
|
||||
expect(shim.endsWith('</script>')).toBe(true);
|
||||
expect(() => new Function(body)).not.toThrow();
|
||||
});
|
||||
|
||||
it('contains no bare </script> that would close the tag early', () => {
|
||||
expect(/<\/script>/i.test(body)).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Execute the shim against a fake window and return the patched globals, so the
|
||||
* rewrite logic is tested for real rather than by reading the source.
|
||||
*/
|
||||
function runShim(host = 'codeman.local') {
|
||||
const calls: string[] = [];
|
||||
const win: Record<string, unknown> = {
|
||||
fetch: (input: unknown) => {
|
||||
calls.push(String(typeof input === 'object' && input ? (input as { url: string }).url : input));
|
||||
return Promise.resolve();
|
||||
},
|
||||
XMLHttpRequest: function () {} as unknown as { prototype: Record<string, unknown> },
|
||||
WebSocket: class {
|
||||
url: string;
|
||||
constructor(u: string) {
|
||||
this.url = u;
|
||||
calls.push(u);
|
||||
}
|
||||
},
|
||||
EventSource: class {
|
||||
url: string;
|
||||
constructor(u: string) {
|
||||
this.url = u;
|
||||
calls.push(u);
|
||||
}
|
||||
},
|
||||
};
|
||||
(win.XMLHttpRequest as { prototype: Record<string, unknown> }).prototype = {
|
||||
open(_m: string, u: string) {
|
||||
calls.push(u);
|
||||
},
|
||||
};
|
||||
const location = { href: `https://${host}${PREFIX}page`, host };
|
||||
new Function('window', 'location', 'URL', 'Request', `with (window) { ${body} }`)(win, location, URL, undefined);
|
||||
return { win, calls };
|
||||
}
|
||||
|
||||
it('rewrites a ROOT-ABSOLUTE fetch, the case <base> cannot reach', () => {
|
||||
const { win, calls } = runShim();
|
||||
(win.fetch as (u: string) => void)('/api/carousel/job?id=1');
|
||||
expect(calls[0]).toBe(`${PREFIX}api/carousel/job?id=1`);
|
||||
});
|
||||
|
||||
it('leaves relative URLs alone (<base> already handles them)', () => {
|
||||
const { win, calls } = runShim();
|
||||
(win.fetch as (u: string) => void)('api/data');
|
||||
expect(calls[0]).toBe('api/data');
|
||||
});
|
||||
|
||||
it('does not double-prefix an already-proxied URL', () => {
|
||||
const { win, calls } = runShim();
|
||||
(win.fetch as (u: string) => void)(`${PREFIX}api/data`);
|
||||
expect(calls[0]).toBe(`${PREFIX}api/data`);
|
||||
});
|
||||
|
||||
it('leaves cross-origin URLs alone', () => {
|
||||
const { win, calls } = runShim();
|
||||
(win.fetch as (u: string) => void)('https://cdn.example/lib.js');
|
||||
expect(calls[0]).toBe('https://cdn.example/lib.js');
|
||||
});
|
||||
|
||||
it('rewrites a same-origin ABSOLUTE URL built from location', () => {
|
||||
const { win, calls } = runShim();
|
||||
(win.fetch as (u: string) => void)('https://codeman.local/api/data');
|
||||
expect(calls[0]).toBe(`https://codeman.local${PREFIX}api/data`);
|
||||
});
|
||||
|
||||
it('patches XMLHttpRequest.open', () => {
|
||||
const { win, calls } = runShim();
|
||||
const xhr = win.XMLHttpRequest as { prototype: { open: (m: string, u: string) => void } };
|
||||
xhr.prototype.open.call({}, 'GET', '/api/data');
|
||||
expect(calls[0]).toBe(`${PREFIX}api/data`);
|
||||
});
|
||||
|
||||
it('patches WebSocket and EventSource', () => {
|
||||
const { win, calls } = runShim();
|
||||
new (win.WebSocket as new (u: string) => unknown)('/live');
|
||||
new (win.EventSource as new (u: string) => unknown)('/events');
|
||||
expect(calls).toEqual([`${PREFIX}live`, `${PREFIX}events`]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('misc helpers', () => {
|
||||
it('identifies HTML content types, parameters included', () => {
|
||||
expect(isHtmlContentType('text/html; charset=utf-8')).toBe(true);
|
||||
expect(isHtmlContentType('application/xhtml+xml')).toBe(true);
|
||||
expect(isHtmlContentType('application/json')).toBe(false);
|
||||
expect(isHtmlContentType(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('maps http(s) to ws(s) for the socket leg', () => {
|
||||
expect(upstreamWebSocketUrl(new URL('http://h:4000/live'))).toBe('ws://h:4000/live');
|
||||
expect(upstreamWebSocketUrl(new URL('https://h/live'))).toBe('wss://h/live');
|
||||
});
|
||||
|
||||
it('builds the iframe prefix', () => {
|
||||
expect(proxyPrefixFor(CAP)).toBe(PREFIX);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user