mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Point 1 of the v1.0 lock-in: commit to a stable HTTP API (the cleanest, fullest form).
Core (centralized):
- Every JSON /api response now uses ONE envelope via a Fastify preSerialization hook (src/web/server.ts): success -> { success:true, data:<payload> }; error -> { success:false, error, errorCode } with a conventional HTTP status. Non-JSON routes (file-raw, tail-file SSE, download, screenshots, /q redirect, WS) are skipped.
- Error-code -> HTTP status is a single source of truth (httpStatusForErrorCode in src/types/api.ts): 400/401/404/409/422/429/500. Expanded ApiErrorCode (added UNAUTHORIZED, CONFLICT, RATE_LIMITED). Errors are no longer HTTP 200.
- Versioned alias: /api/v1/* rewrites to /api/* (rewriteApiV1Url), so external clients pin to a stable surface while the bundled UI keeps using /api/*.
- Handlers stripped of manual 'success:true' (50 across 14 route files) so they return bare payloads the hook wraps uniformly; fixed the mux DELETE {success:<bool>} envelope collision (-> {killed}).
Frontend (48 call sites across 10 files):
- _apiJson() auto-unwraps { success:true, data } -> data (null on error), so most bare-shape readers are transparent. Raw-fetch sites relocate payload reads under .data; success/res.ok/error checks unchanged.
Docs: new docs/api-reference.md (envelope, status table, error codes, /api/v1, SSE); versioning-policy.md flipped — the HTTP/SSE API is now part of the stable, SemVer-covered surface.
Verification: full unit/route suite green (2680 passed) incl. ~166 updated assertions across 24 test files; typecheck/lint/format/frontend-syntax clean; a headless-chromium smoke loaded the migrated UI and drove the panels with 0 console/page errors; /api/status and /api/v1/status confirmed returning the uniform envelope live.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
80 lines
3.4 KiB
TypeScript
80 lines
3.4 KiB
TypeScript
/**
|
|
* POST /api/system/span-displays (multi-monitor launcher) + resolveSpanUrl.
|
|
*
|
|
* The route shells out to scripts/span-codeman.sh, so we mock child_process.spawn
|
|
* to avoid actually opening a browser (and to assert the sanitized URL passed to
|
|
* it). process.platform is overridden per-case so the macOS-only guard is tested
|
|
* deterministically regardless of where the suite runs.
|
|
*
|
|
* Port: N/A (app.inject).
|
|
*/
|
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
|
|
const spawnMock = vi.hoisted(() => vi.fn(() => ({ on: vi.fn(), unref: vi.fn() })));
|
|
vi.mock('node:child_process', async (orig) => {
|
|
const actual = await orig<typeof import('node:child_process')>();
|
|
return { ...actual, spawn: spawnMock };
|
|
});
|
|
|
|
import { createRouteTestHarness } from './_route-test-utils.js';
|
|
import { registerSystemRoutes, resolveSpanUrl } from '../../src/web/routes/system-routes.js';
|
|
|
|
const REAL_PLATFORM = process.platform;
|
|
function setPlatform(p: NodeJS.Platform) {
|
|
Object.defineProperty(process, 'platform', { value: p, configurable: true });
|
|
}
|
|
afterEach(() => {
|
|
setPlatform(REAL_PLATFORM);
|
|
spawnMock.mockClear();
|
|
});
|
|
|
|
describe('resolveSpanUrl', () => {
|
|
it('takes a digits-only port from the Host header, pinned to localhost', () => {
|
|
expect(resolveSpanUrl('localhost:5000')).toBe('http://localhost:5000');
|
|
// Hostname is discarded — always localhost (same machine).
|
|
expect(resolveSpanUrl('attacker.example.com:3000')).toBe('http://localhost:3000');
|
|
});
|
|
|
|
it('falls back to the default port for missing / non-numeric ports', () => {
|
|
expect(resolveSpanUrl(undefined)).toBe('http://localhost:3000');
|
|
expect(resolveSpanUrl('localhost')).toBe('http://localhost:3000');
|
|
expect(resolveSpanUrl('localhost:99;rm -rf /')).toBe('http://localhost:3000');
|
|
expect(resolveSpanUrl('localhost:80abc')).toBe('http://localhost:3000');
|
|
expect(resolveSpanUrl('x', '5000')).toBe('http://localhost:5000');
|
|
});
|
|
});
|
|
|
|
describe('POST /api/system/span-displays', () => {
|
|
it('returns 400 (macOS-only) on non-darwin and never spawns', async () => {
|
|
setPlatform('linux');
|
|
const { app } = await createRouteTestHarness(registerSystemRoutes);
|
|
const res = await app.inject({ method: 'POST', url: '/api/system/span-displays' });
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.json().success).toBe(false);
|
|
expect(res.json().error).toMatch(/macOS/i);
|
|
expect(spawnMock).not.toHaveBeenCalled();
|
|
await app.close();
|
|
});
|
|
|
|
it('spawns the launcher with the sanitized localhost URL on darwin', async () => {
|
|
setPlatform('darwin');
|
|
const { app } = await createRouteTestHarness(registerSystemRoutes);
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/system/span-displays',
|
|
headers: { host: 'localhost:5000' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
// Handler returns a bare { url } on success; the uniform envelope wraps it to
|
|
// { success:true, data:{ url } } in production. At the route-handler layer the
|
|
// harness sees the bare return, so we assert on body.url directly.
|
|
expect(res.json()).toMatchObject({ url: 'http://localhost:5000' });
|
|
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
const [cmd, args] = spawnMock.mock.calls[0] as [string, string[]];
|
|
expect(cmd).toBe('bash');
|
|
expect(args[0]).toMatch(/span-codeman\.sh$/);
|
|
expect(args[1]).toBe('http://localhost:5000');
|
|
await app.close();
|
|
});
|
|
});
|