mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 07:29: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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user