mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
The opt-in multi-user feature's only enforcement is web-layer scoping (all sessions share one OS account). An adversarial review found 8 critical + 7 high cross-user holes that defeated it, plus mediums; all fixed here. Single-user (flag-off) behavior stays byte-identical apart from documented consistency deltas. Ownership / confinement: - DELETE /api/sessions (bulk) + /:id now owner-scope / findSessionOrFail - quick-start, cron (create+fire), scheduled runs confine workingDir to the owner's space; case link/docker-link/docker-import confine the host path - resolveCasePath no longer resolves linked cases for non-admins; foreign remote/docker cases are skipped (fall through to the caller's own local case) - history, subagents/workflows, mux-sessions, orchestrator, cron run-history, away-digest, and remote/docker host reads are owner- or admin-scoped Permission policy (section 6.3): - non-granted users are downgraded at every spawn site incl. legacy /api/scheduled, PlanOrchestrator one-shots, remote launch, and the cron-fire gemini/codex bypass switches; resolveClaudeModeForUsername now fails closed Auth / store: - verify-first login throttle (a correct password is never locked out), /ws terminal subject to the change-password lockbox, cookie fast-path re-validates identity live, role/grant changes revoke sessions, admin delete runs the last-admin guard before any teardown - users.json: distinguish missing (ENOENT) from corrupt/unreadable so a bad read can't overwrite all accounts; unique per-process temp write path Event streams: - debounced session:updated + batched task:updated, clipboard, and push notifications route by owner (fail closed); getLightState hides machine-wide globalStats from non-admins Tests: two suites updated to assert the fixed (secure) behavior. tsc, eslint, and test:ci all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
411 lines
13 KiB
TypeScript
411 lines
13 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
|
import { WebServer } from '../src/web/server.js';
|
|
import { existsSync, rmSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { homedir } from 'node:os';
|
|
|
|
const TEST_PORT = 3110;
|
|
const CASES_DIR = join(homedir(), 'codeman-cases');
|
|
|
|
describe('Edge Cases and Error Handling', () => {
|
|
let server: WebServer;
|
|
let baseUrl: string;
|
|
const createdCases: string[] = [];
|
|
|
|
beforeAll(async () => {
|
|
server = new WebServer(TEST_PORT, false, true);
|
|
await server.start();
|
|
baseUrl = `http://localhost:${TEST_PORT}`;
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Clean up cases created during this test
|
|
while (createdCases.length > 0) {
|
|
const caseName = createdCases.pop()!;
|
|
const casePath = join(CASES_DIR, caseName);
|
|
if (existsSync(casePath)) {
|
|
rmSync(casePath, { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server.stop();
|
|
}, 30000);
|
|
|
|
describe('Session Edge Cases', () => {
|
|
it('should handle deleting non-existent session gracefully', async () => {
|
|
const response = await fetch(`${baseUrl}/api/sessions/non-existent-id-12345`, {
|
|
method: 'DELETE',
|
|
});
|
|
const data = await response.json();
|
|
|
|
expect(data.success).toBe(false);
|
|
expect(data.error).toContain('not found');
|
|
});
|
|
|
|
it('should handle getting non-existent session gracefully', async () => {
|
|
const response = await fetch(`${baseUrl}/api/sessions/non-existent-id-12345`);
|
|
const data = await response.json();
|
|
|
|
expect(data.error).toContain('not found');
|
|
});
|
|
|
|
it('should handle running prompt on non-existent session', async () => {
|
|
const response = await fetch(`${baseUrl}/api/sessions/non-existent/run`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ prompt: 'test' }),
|
|
});
|
|
const data = await response.json();
|
|
|
|
expect(data.error).toContain('not found');
|
|
});
|
|
|
|
it('should handle input to non-existent session', async () => {
|
|
const response = await fetch(`${baseUrl}/api/sessions/non-existent/input`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ input: 'test' }),
|
|
});
|
|
const data = await response.json();
|
|
|
|
expect(data.error).toContain('not found');
|
|
});
|
|
|
|
it('should handle resize on non-existent session', async () => {
|
|
const response = await fetch(`${baseUrl}/api/sessions/non-existent/resize`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ cols: 80, rows: 24 }),
|
|
});
|
|
const data = await response.json();
|
|
|
|
expect(data.error).toContain('not found');
|
|
});
|
|
|
|
it('should handle interactive mode on non-existent session', async () => {
|
|
const response = await fetch(`${baseUrl}/api/sessions/non-existent/interactive`, {
|
|
method: 'POST',
|
|
});
|
|
const data = await response.json();
|
|
|
|
expect(data.error).toContain('not found');
|
|
});
|
|
|
|
it('should handle terminal buffer request on non-existent session', async () => {
|
|
const response = await fetch(`${baseUrl}/api/sessions/non-existent/terminal`);
|
|
const data = await response.json();
|
|
|
|
expect(data.error).toContain('not found');
|
|
});
|
|
|
|
it('should handle output request on non-existent session', async () => {
|
|
const response = await fetch(`${baseUrl}/api/sessions/non-existent/output`);
|
|
const data = await response.json();
|
|
|
|
expect(data.error).toContain('not found');
|
|
});
|
|
});
|
|
|
|
describe('Case Edge Cases', () => {
|
|
it('should reject empty case name', async () => {
|
|
const response = await fetch(`${baseUrl}/api/cases`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: '' }),
|
|
});
|
|
const data = await response.json();
|
|
|
|
expect(data.success).toBe(false);
|
|
expect(data.error).toContain('Invalid case name');
|
|
});
|
|
|
|
it('should reject case name with only special characters', async () => {
|
|
const response = await fetch(`${baseUrl}/api/cases`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: '!@#$%^' }),
|
|
});
|
|
const data = await response.json();
|
|
|
|
expect(data.success).toBe(false);
|
|
expect(data.error).toContain('Invalid case name');
|
|
});
|
|
|
|
it('should reject case name with path traversal attempt', async () => {
|
|
const response = await fetch(`${baseUrl}/api/cases`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: '../escape' }),
|
|
});
|
|
const data = await response.json();
|
|
|
|
expect(data.success).toBe(false);
|
|
expect(data.error).toContain('Invalid case name');
|
|
});
|
|
|
|
it('should handle getting non-existent case', async () => {
|
|
const response = await fetch(`${baseUrl}/api/cases/this-case-does-not-exist-12345`);
|
|
const data = await response.json();
|
|
|
|
expect(data.error).toBe('Case not found');
|
|
});
|
|
});
|
|
|
|
describe('Quick Start Edge Cases', () => {
|
|
it('should reject quick start with empty case name', async () => {
|
|
const response = await fetch(`${baseUrl}/api/quick-start`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ caseName: '' }),
|
|
});
|
|
const data = await response.json();
|
|
|
|
expect(data.success).toBe(false);
|
|
expect(data.error).toContain('Invalid case name');
|
|
});
|
|
|
|
it('should reject quick start with special characters', async () => {
|
|
const response = await fetch(`${baseUrl}/api/quick-start`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ caseName: 'test/../../etc/passwd' }),
|
|
});
|
|
const data = await response.json();
|
|
|
|
expect(data.success).toBe(false);
|
|
expect(data.error).toContain('Invalid case name');
|
|
});
|
|
|
|
it('should handle very long case names gracefully', async () => {
|
|
// Test with a very long but valid case name
|
|
const longName = 'a'.repeat(100);
|
|
const response = await fetch(`${baseUrl}/api/quick-start`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ caseName: longName }),
|
|
});
|
|
const data = await response.json();
|
|
|
|
// Should succeed with valid characters, even if long
|
|
if (data.success) {
|
|
createdCases.push(data.data.caseName);
|
|
}
|
|
// Either succeeds or fails gracefully
|
|
expect(data).toHaveProperty('success');
|
|
});
|
|
});
|
|
|
|
describe('Respawn Controller Edge Cases', () => {
|
|
it('should handle getting respawn status for non-existent session', async () => {
|
|
const response = await fetch(`${baseUrl}/api/sessions/non-existent/respawn`);
|
|
const data = await response.json();
|
|
|
|
expect(data.data.enabled).toBe(false);
|
|
expect(data.data.status).toBeNull();
|
|
});
|
|
|
|
it('should handle starting respawn on non-existent session', async () => {
|
|
const response = await fetch(`${baseUrl}/api/sessions/non-existent/respawn/start`, {
|
|
method: 'POST',
|
|
});
|
|
const data = await response.json();
|
|
|
|
expect(data.error).toContain('not found');
|
|
});
|
|
|
|
it('should handle stopping non-existent respawn controller', async () => {
|
|
const response = await fetch(`${baseUrl}/api/sessions/non-existent/respawn/stop`, {
|
|
method: 'POST',
|
|
});
|
|
const data = await response.json();
|
|
|
|
// respawn/stop now owner-gates via findSessionOrFail first (multi-user #18), so a
|
|
// non-existent session id 404s as "Session ... not found" (same not-found semantics,
|
|
// matching the sibling start/config/enable handlers).
|
|
expect(data.error).toContain('not found');
|
|
});
|
|
|
|
it('should handle updating config on non-existent session', async () => {
|
|
const response = await fetch(`${baseUrl}/api/sessions/non-existent/respawn/config`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ idleTimeoutMs: 5000 }),
|
|
});
|
|
const data = await response.json();
|
|
|
|
expect(data.error).toContain('not found');
|
|
});
|
|
});
|
|
|
|
describe('Scheduled Run Edge Cases', () => {
|
|
it('should handle stopping non-existent scheduled run', async () => {
|
|
const response = await fetch(`${baseUrl}/api/scheduled/non-existent`, {
|
|
method: 'DELETE',
|
|
});
|
|
const data = await response.json();
|
|
|
|
expect(data.success).toBe(false);
|
|
expect(data.error).toBe('Scheduled run not found');
|
|
});
|
|
|
|
it('should handle getting non-existent scheduled run', async () => {
|
|
const response = await fetch(`${baseUrl}/api/scheduled/non-existent`);
|
|
const data = await response.json();
|
|
|
|
expect(data.error).toBe('Scheduled run not found');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Concurrent Session Handling', () => {
|
|
let server: WebServer;
|
|
let baseUrl: string;
|
|
|
|
beforeAll(async () => {
|
|
server = new WebServer(TEST_PORT + 1, false, true);
|
|
await server.start();
|
|
baseUrl = `http://localhost:${TEST_PORT + 1}`;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server.stop();
|
|
}, 30000);
|
|
|
|
it('should handle multiple sessions simultaneously', async () => {
|
|
// Create multiple sessions concurrently
|
|
const createPromises = Array(5)
|
|
.fill(null)
|
|
.map(() =>
|
|
fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workingDir: '/tmp' }),
|
|
}).then((r) => r.json())
|
|
);
|
|
|
|
const results = await Promise.all(createPromises);
|
|
|
|
// All should succeed
|
|
for (const result of results) {
|
|
expect(result.success).toBe(true);
|
|
expect(result.data.session.id).toBeDefined();
|
|
}
|
|
|
|
// Verify sessions are listed
|
|
const listRes = await fetch(`${baseUrl}/api/sessions`);
|
|
const sessions = await listRes.json();
|
|
expect(sessions.data.length).toBeGreaterThanOrEqual(5);
|
|
|
|
// Clean up - delete all created sessions
|
|
for (const result of results) {
|
|
await fetch(`${baseUrl}/api/sessions/${result.data.session.id}`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
});
|
|
|
|
it('should handle rapid session creation and deletion', async () => {
|
|
const iterations = 10;
|
|
|
|
for (let i = 0; i < iterations; i++) {
|
|
// Create
|
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workingDir: '/tmp' }),
|
|
});
|
|
const createData = await createRes.json();
|
|
expect(createData.success).toBe(true);
|
|
|
|
// Delete immediately
|
|
const deleteRes = await fetch(`${baseUrl}/api/sessions/${createData.data.session.id}`, {
|
|
method: 'DELETE',
|
|
});
|
|
const deleteData = await deleteRes.json();
|
|
expect(deleteData.success).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('should handle multiple quick starts concurrently', async () => {
|
|
const caseNames = ['concurrent-test-1', 'concurrent-test-2', 'concurrent-test-3'];
|
|
const createdCases: string[] = [];
|
|
|
|
const quickStartPromises = caseNames.map((name) =>
|
|
fetch(`${baseUrl}/api/quick-start`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ caseName: `${name}-${Date.now()}` }),
|
|
}).then((r) => r.json())
|
|
);
|
|
|
|
const results = await Promise.all(quickStartPromises);
|
|
|
|
for (const result of results) {
|
|
expect(result.success).toBe(true);
|
|
if (result.data.caseName) {
|
|
createdCases.push(result.data.caseName);
|
|
}
|
|
}
|
|
|
|
// Cleanup
|
|
const { rmSync, existsSync } = await import('node:fs');
|
|
const { join } = await import('node:path');
|
|
const { homedir } = await import('node:os');
|
|
for (const name of createdCases) {
|
|
const path = join(homedir(), 'codeman-cases', name);
|
|
if (existsSync(path)) {
|
|
rmSync(path, { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('API Request Validation', () => {
|
|
let server: WebServer;
|
|
let baseUrl: string;
|
|
|
|
beforeAll(async () => {
|
|
server = new WebServer(TEST_PORT + 2, false, true);
|
|
await server.start();
|
|
baseUrl = `http://localhost:${TEST_PORT + 2}`;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server.stop();
|
|
}, 30000);
|
|
|
|
it('should handle malformed JSON in request body', async () => {
|
|
const response = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: 'not valid json',
|
|
});
|
|
|
|
// Should return an error status
|
|
expect(response.status).toBeGreaterThanOrEqual(400);
|
|
});
|
|
|
|
it('should handle empty request body when one is expected', async () => {
|
|
const response = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: '',
|
|
});
|
|
|
|
// Should either succeed with defaults or return appropriate error
|
|
// Fastify handles this gracefully
|
|
expect([200, 400, 500]).toContain(response.status);
|
|
});
|
|
|
|
it('should handle requests with missing content-type', async () => {
|
|
const response = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
});
|
|
|
|
// Should work or fail gracefully
|
|
expect(response.status).toBeDefined();
|
|
});
|
|
});
|