mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 07:29:42 +02:00
Merge remote-tracking branch 'origin/master' into worktree-grok-mode
# Conflicts: # src/web/public/app.js
This commit is contained in:
@@ -461,6 +461,32 @@ describe('ralph-routes', () => {
|
||||
// ========== POST /api/ralph-loop/start ==========
|
||||
|
||||
describe('POST /api/ralph-loop/start', () => {
|
||||
it('awaits layout insertion and stops lifecycle work when registration rejects', async () => {
|
||||
let rejectRegistration!: (error: Error) => void;
|
||||
harness.ctx.addSession.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
rejectRegistration = reject;
|
||||
})
|
||||
);
|
||||
|
||||
const pending = harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/ralph-loop/start',
|
||||
payload: { taskDescription: 'test task', completionPhrase: 'DONE', caseName: 'registration-order' },
|
||||
});
|
||||
await vi.waitFor(() => expect(harness.ctx.addSession).toHaveBeenCalledTimes(1));
|
||||
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
|
||||
expect(harness.ctx.setupSessionListeners).not.toHaveBeenCalled();
|
||||
|
||||
rejectRegistration(new Error('layout capacity exceeded'));
|
||||
const response = await pending;
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
|
||||
expect(harness.ctx.setupSessionListeners).not.toHaveBeenCalled();
|
||||
expect(harness.ctx.broadcast).not.toHaveBeenCalledWith('session:created', expect.anything());
|
||||
});
|
||||
|
||||
it('rejects invalid request body', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* @fileoverview Tests for PUT /api/session-order (global tab-order sync, COD-131).
|
||||
* @fileoverview Tests for the synchronized legacy PUT /api/session-order endpoint.
|
||||
*
|
||||
* Uses app.inject() — no real HTTP ports needed.
|
||||
* Asserts the uniform envelope contract:
|
||||
* SUCCESS -> 2xx, { success: true, data: { order } }
|
||||
* ERROR -> 4xx/5xx, { success: false, error, errorCode }
|
||||
* and that the order is persisted to the (mock) StateStore + broadcast over SSE.
|
||||
* Legacy callers are routed through the authenticated owner-scoped tab-layout service.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
@@ -13,6 +13,7 @@ import fastifyCookie from '@fastify/cookie';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||
import { TabLayoutValidationError } from '../../src/tab-layout.js';
|
||||
|
||||
// registerSessionRoutes pulls in session.js which can shell out; stub the bits
|
||||
// that would touch the OS at import/registration time. None are needed by the
|
||||
@@ -29,11 +30,22 @@ interface LocalHarness {
|
||||
ctx: MockRouteContext;
|
||||
}
|
||||
|
||||
async function buildHarness(): Promise<LocalHarness> {
|
||||
async function buildHarness(authUser = { username: 'alice', role: 'user' as const }): Promise<LocalHarness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
app.addHook('onRequest', async (req) => {
|
||||
(req as unknown as { authUser: typeof authUser }).authUser = authUser;
|
||||
});
|
||||
|
||||
const ctx = createMockRouteContext();
|
||||
Object.assign(ctx.tabLayouts, {
|
||||
putLegacyOrder: vi.fn(async (_actor: unknown, order: string[]) => ({
|
||||
order: [...order],
|
||||
changedOwnerOrders: {},
|
||||
globalOrder: [...order],
|
||||
globalChanged: true,
|
||||
})),
|
||||
});
|
||||
registerSessionRoutes(app, ctx as unknown as Parameters<typeof registerSessionRoutes>[1]);
|
||||
|
||||
// Mirror production's uniform-envelope preSerialization hook (server.ts).
|
||||
@@ -60,35 +72,22 @@ describe('PUT /api/session-order', () => {
|
||||
let harness: LocalHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.stubEnv('CODEMAN_MULTIUSER', '1');
|
||||
harness = await buildHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('persists the order and returns it in the envelope', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/session-order',
|
||||
payload: { order: ['a', 'b', 'c'] },
|
||||
it('routes a regular legacy PUT through the authenticated owner layout service', async () => {
|
||||
vi.mocked(harness.ctx.tabLayouts.putLegacyOrder).mockResolvedValueOnce({
|
||||
order: ['a', 'b'],
|
||||
changedOwnerOrders: { alice: ['a', 'b'] },
|
||||
globalOrder: ['a', 'b'],
|
||||
globalChanged: true,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body).toEqual({ success: true, data: { order: ['a', 'b', 'c'] } });
|
||||
// Persisted to the store.
|
||||
expect(harness.ctx.store.setSessionOrder).toHaveBeenCalledWith(['a', 'b', 'c']);
|
||||
expect(harness.ctx.store.getSessionOrder()).toEqual(['a', 'b', 'c']);
|
||||
// Broadcast over SSE.
|
||||
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:orderChanged', { order: ['a', 'b', 'c'] });
|
||||
});
|
||||
|
||||
it('preserves a server-only id (unknown to the pushing device) at the end', async () => {
|
||||
// Seed the store with an order containing a server-only id "z".
|
||||
harness.ctx.store.setSessionOrder(['a', 'z', 'b']);
|
||||
(harness.ctx.broadcast as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/session-order',
|
||||
@@ -96,25 +95,24 @@ describe('PUT /api/session-order', () => {
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
// Incoming order wins, server-only "z" falls to the end.
|
||||
expect(body).toEqual({ success: true, data: { order: ['b', 'a', 'z'] } });
|
||||
expect(harness.ctx.store.getSessionOrder()).toEqual(['b', 'a', 'z']);
|
||||
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:orderChanged', { order: ['b', 'a', 'z'] });
|
||||
expect(harness.ctx.tabLayouts.putLegacyOrder).toHaveBeenCalledWith({ owner: 'alice', isAdmin: false }, ['b', 'a']);
|
||||
expect(res.json().data.order).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('normalizes junk input (dedup + drop empties) before persisting', async () => {
|
||||
it('uses the machine-wide admin bridge for an admin caller', async () => {
|
||||
await harness.app.close();
|
||||
harness = await buildHarness({ username: 'root', role: 'admin' });
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/session-order',
|
||||
payload: { order: ['a', 'a', '', 'b'] },
|
||||
payload: { order: ['b', 'a'] },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ success: true, data: { order: ['a', 'b'] } });
|
||||
expect(harness.ctx.tabLayouts.putLegacyOrder).toHaveBeenCalledWith({ owner: 'root', isAdmin: true }, ['b', 'a']);
|
||||
});
|
||||
|
||||
it('rejects a non-array order with a 4xx envelope', async () => {
|
||||
it('rejects malformed bodies before invoking the service', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/session-order',
|
||||
@@ -124,6 +122,23 @@ describe('PUT /api/session-order', () => {
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(harness.ctx.store.setSessionOrder).not.toHaveBeenCalled();
|
||||
expect(harness.ctx.tabLayouts.putLegacyOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps owner-boundary validation failures to INVALID_INPUT', async () => {
|
||||
vi.mocked(harness.ctx.tabLayouts.putLegacyOrder).mockRejectedValueOnce(
|
||||
new TabLayoutValidationError('session is not owned by layout owner: foreign')
|
||||
);
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/session-order',
|
||||
payload: { order: ['foreign'] },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json()).toMatchObject({
|
||||
success: false,
|
||||
errorCode: ApiErrorCode.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -332,11 +332,16 @@ describe('system-routes', () => {
|
||||
|
||||
describe('POST /api/cleanup-state', () => {
|
||||
it('cleans up stale session state', async () => {
|
||||
const runStaleSessionCleanup = vi.fn(
|
||||
async (_activeIds: Set<string>, action: (ids: ReadonlySet<string>) => unknown) => action(new Set())
|
||||
);
|
||||
harness.ctx.tabLayouts.runStaleSessionCleanup = runStaleSessionCleanup;
|
||||
const res = await harness.app.inject({ method: 'POST', url: '/api/cleanup-state' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.cleanedSessions).toBe(0);
|
||||
expect(harness.ctx.store.cleanupStaleSessions).toHaveBeenCalled();
|
||||
expect(harness.ctx.store.cleanupSessionsByIds).toHaveBeenCalledWith(new Set());
|
||||
expect(runStaleSessionCleanup).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @fileoverview Owner-scoped tab-layout HTTP concurrency and validation contract.
|
||||
*/
|
||||
import Fastify from 'fastify';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { registerTabLayoutRoutes } from '../../src/web/routes/tab-layout-routes.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import type { TabLayout } from '../../src/tab-layout.js';
|
||||
|
||||
const layout = (version = 3): TabLayout => ({
|
||||
version,
|
||||
groups: [],
|
||||
ungrouped: [{ kind: 'session', id: 'mine' }],
|
||||
updatedAt: '2026-08-16T00:00:00.000Z',
|
||||
});
|
||||
|
||||
async function harness(username?: string, role: 'admin' | 'user' = 'user') {
|
||||
const app = Fastify({ logger: false });
|
||||
if (username) {
|
||||
app.addHook('onRequest', async (req) => {
|
||||
(req as unknown as { authUser: { username: string; role: 'admin' | 'user' } }).authUser = { username, role };
|
||||
});
|
||||
}
|
||||
const service = {
|
||||
get: vi.fn(async () => layout()),
|
||||
put: vi.fn(async (_owner: string, desired: unknown, baseVersion: number) => ({
|
||||
status: 'updated' as const,
|
||||
layout: { ...(desired as TabLayout), version: baseVersion + 1 },
|
||||
})),
|
||||
};
|
||||
registerTabLayoutRoutes(app, { tabLayouts: service } as never);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return { app, service };
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
describe('tab layout routes', () => {
|
||||
it('maps single-user requests to @single and never accepts an owner override', async () => {
|
||||
vi.stubEnv('CODEMAN_MULTIUSER', '0');
|
||||
const { app, service } = await harness();
|
||||
const response = await app.inject({ method: 'GET', url: '/api/tab-layout?owner=foreign' });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(service.get).toHaveBeenCalledWith('@single');
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('uses the authenticated username in multi-user mode, including for admins', async () => {
|
||||
vi.stubEnv('CODEMAN_MULTIUSER', '1');
|
||||
const { app, service } = await harness('admin-a', 'admin');
|
||||
await app.inject({ method: 'GET', url: '/api/tab-layout?owner=someone-else' });
|
||||
expect(service.get).toHaveBeenCalledWith('admin-a');
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it.each([2, 4])('returns 409 with the authoritative prepared layout when baseVersion=%s', async (baseVersion) => {
|
||||
vi.stubEnv('CODEMAN_MULTIUSER', '1');
|
||||
const { app, service } = await harness('alice');
|
||||
service.put.mockResolvedValueOnce({ status: 'conflict', layout: layout(3) });
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/tab-layout',
|
||||
payload: { baseVersion, layout: layout(baseVersion) },
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().success).toBe(false);
|
||||
expect(response.json().errorCode).toBe('CONFLICT');
|
||||
expect(response.json().data.layout).toEqual(layout(3));
|
||||
expect(service.put).toHaveBeenCalledWith('alice', layout(baseVersion), baseVersion);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects malformed writes before invoking the service', async () => {
|
||||
const { app, service } = await harness();
|
||||
const response = await app.inject({ method: 'PUT', url: '/api/tab-layout', payload: { baseVersion: -1 } });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(service.put).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects extra write keys before invoking the service', async () => {
|
||||
const { app, service } = await harness();
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/tab-layout',
|
||||
payload: { baseVersion: 3, layout: layout(), owner: 'foreign' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().errorCode).toBe('INVALID_INPUT');
|
||||
expect(service.put).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('has an explicit conservative body limit', async () => {
|
||||
const { app } = await harness();
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/tab-layout',
|
||||
payload: { baseVersion: 3, layout: layout(), padding: 'x'.repeat(140 * 1024) },
|
||||
});
|
||||
expect(response.statusCode).toBe(413);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
* the developer's real ~/.codeman/webviews.json.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
@@ -16,17 +16,23 @@ 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';
|
||||
import { TabLayoutService } from '../../src/tab-layout-service.js';
|
||||
import type { TabLayout } from '../../src/tab-layout.js';
|
||||
|
||||
let app: FastifyInstance;
|
||||
let tmpDir: string;
|
||||
let savedDataDir: string | undefined;
|
||||
const broadcasts: Array<{ event: string; data: unknown }> = [];
|
||||
const webviewCreated = vi.fn(async () => {});
|
||||
const webviewDeleted = vi.fn(async () => {});
|
||||
|
||||
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;
|
||||
webviewCreated.mockClear();
|
||||
webviewDeleted.mockClear();
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
@@ -34,6 +40,7 @@ beforeEach(async () => {
|
||||
await app.register(fastifyWebsocket);
|
||||
registerWebviewRoutes(app, {
|
||||
broadcast: (event: string, data: unknown) => broadcasts.push({ event, data }),
|
||||
tabLayouts: { webviewCreated, webviewDeleted },
|
||||
} as never);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
@@ -90,6 +97,58 @@ describe('POST /api/webviews', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('rolls back webview persistence and emits nothing when layout capacity rejects insertion', async () => {
|
||||
const refs = Array.from({ length: 512 }, (_, index) => ({ kind: 'session' as const, id: `s-${index}` }));
|
||||
const original: TabLayout = {
|
||||
version: 9,
|
||||
groups: [],
|
||||
ungrouped: refs,
|
||||
updatedAt: '2026-08-16T00:00:00.000Z',
|
||||
};
|
||||
let stored = original;
|
||||
const live = new Map(refs.map((ref, index) => [ref.id, { id: ref.id, createdAt: index }]));
|
||||
const atomicBroadcast = vi.fn();
|
||||
const service = new TabLayoutService({
|
||||
store: {
|
||||
getTabLayout: () => stored,
|
||||
setTabLayout: (_owner, layout) => {
|
||||
stored = layout;
|
||||
},
|
||||
getSessions: () => ({}),
|
||||
getSessionOrder: () => [],
|
||||
} as never,
|
||||
sessions: live,
|
||||
readWebviews: async () =>
|
||||
(await import('../../src/webview-store.js')).readWebviews(tmpDir) as Promise<
|
||||
Array<{ id: string; owner?: string }>
|
||||
>,
|
||||
broadcast: atomicBroadcast,
|
||||
broadcastSessionOrder: vi.fn(),
|
||||
});
|
||||
const atomicApp = Fastify({ logger: false });
|
||||
await atomicApp.register(fastifyCookie);
|
||||
await atomicApp.register(fastifyWebsocket);
|
||||
registerWebviewRoutes(atomicApp, {
|
||||
broadcast: atomicBroadcast,
|
||||
tabLayouts: service,
|
||||
} as never);
|
||||
installRouteErrorHandler(atomicApp);
|
||||
await atomicApp.ready();
|
||||
|
||||
const response = await atomicApp.inject({
|
||||
method: 'POST',
|
||||
url: '/api/webviews',
|
||||
payload: { name: 'overflow', url: 'https://example.test/' },
|
||||
});
|
||||
const list = (await atomicApp.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews;
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(list).toEqual([]);
|
||||
expect(stored).toEqual(original);
|
||||
expect(atomicBroadcast).not.toHaveBeenCalled();
|
||||
await atomicApp.close();
|
||||
});
|
||||
|
||||
it('rejects URLs carrying embedded credentials', async () => {
|
||||
const res = await create({ name: 'bad', url: 'http://user:pass@host:4000/' });
|
||||
expect(res.statusCode).toBe(400);
|
||||
@@ -142,6 +201,19 @@ describe('DELETE /api/webviews/:id', () => {
|
||||
expect(webviewCapabilities.resolve(cap)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the exact saved record and emits nothing when layout deletion fails', async () => {
|
||||
const created = (await create({ name: 'Keep me', url: 'https://keep.example/' })).json().data;
|
||||
broadcasts.length = 0;
|
||||
webviewDeleted.mockRejectedValueOnce(new Error('tab layout restoration failed'));
|
||||
|
||||
const response = await app.inject({ method: 'DELETE', url: `/api/webviews/${created.id}` });
|
||||
const list = (await app.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews;
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(list).toEqual([created]);
|
||||
expect(broadcasts).toEqual([]);
|
||||
});
|
||||
|
||||
it('404s an unknown id', async () => {
|
||||
expect((await app.inject({ method: 'DELETE', url: '/api/webviews/nope' })).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user