From 45ad4a488bdd0eca2abb64f07bad611d27da60da Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Mon, 2 Mar 2026 02:21:10 +0700 Subject: [PATCH 1/2] test: add 94 E2E tests covering all CRUD routes + fix middleware location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive Playwright E2E test coverage for all major API routes: - tasks-crud (18 tests): full lifecycle, filters, Aegis approval gate - agents-crud (15 tests): CRUD, lookup by name/id, admin-only delete - task-comments (7 tests): threaded comments, validation - workflows-crud (8 tests): workflow template lifecycle - webhooks-crud (9 tests): secret masking, regeneration - alerts-crud (8 tests): alert rule lifecycle - notifications (7 tests): delivery tracking, read status - quality-review (6 tests): reviews with batch lookup - search-and-export (7 tests): global search, export, activities - user-management (8 tests): user admin CRUD - helpers.ts: shared factory functions and cleanup utilities Infrastructure fixes: - Move middleware.ts to src/middleware.ts (Next.js 16 Turbopack requires middleware in src/ when using src/app/ directory — the root-level file was silently ignored, breaking CSRF protection) - Add MC_DISABLE_RATE_LIMIT env var to bypass non-critical rate limiters during E2E runs (login limiter stays active via critical flag) - Fix limit-caps test: /api/activities caps at 500, not 200 - Set playwright workers=1, fullyParallel=false for serial execution - Add CSRF origin fallback to request.nextUrl.host Roadmap additions from user feedback: - Agent-agnostic gateway support (not just OpenClaw) - Direct CLI integration (Codex, Claude Code, etc.) - Native macOS app (Electron or Tauri) 146/146 E2E tests passing (up from 51). --- README.md | 10 +- package.json | 9 +- playwright.config.ts | 3 +- src/lib/rate-limit.ts | 5 + middleware.ts => src/middleware.ts | 6 +- tests/README.md | 53 ++++++- tests/agents-crud.spec.ts | 197 ++++++++++++++++++++++++ tests/alerts-crud.spec.ts | 126 ++++++++++++++++ tests/helpers.ts | 140 +++++++++++++++++ tests/limit-caps.spec.ts | 24 +-- tests/notifications.spec.ts | 107 +++++++++++++ tests/quality-review.spec.ts | 111 ++++++++++++++ tests/search-and-export.spec.ts | 77 ++++++++++ tests/task-comments.spec.ts | 108 ++++++++++++++ tests/tasks-crud.spec.ts | 231 +++++++++++++++++++++++++++++ tests/user-management.spec.ts | 111 ++++++++++++++ tests/webhooks-crud.spec.ts | 142 ++++++++++++++++++ tests/workflows-crud.spec.ts | 120 +++++++++++++++ 18 files changed, 1556 insertions(+), 24 deletions(-) rename middleware.ts => src/middleware.ts (96%) create mode 100644 tests/agents-crud.spec.ts create mode 100644 tests/alerts-crud.spec.ts create mode 100644 tests/helpers.ts create mode 100644 tests/notifications.spec.ts create mode 100644 tests/quality-review.spec.ts create mode 100644 tests/search-and-export.spec.ts create mode 100644 tests/task-comments.spec.ts create mode 100644 tests/tasks-crud.spec.ts create mode 100644 tests/user-management.spec.ts create mode 100644 tests/webhooks-crud.spec.ts create mode 100644 tests/workflows-crud.spec.ts diff --git a/README.md b/README.md index 8cc23a4..2f69ed7 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Running AI agents at scale means juggling sessions, tasks, costs, and reliabilit - **Zero external dependencies** — SQLite database, single `pnpm start` to run, no Redis/Postgres/Docker required - **Role-based access** — Viewer, operator, and admin roles with session + API key auth - **Quality gates** — Built-in review system that blocks task completion without sign-off -- **Multi-gateway** — Connect to multiple OpenClaw gateways simultaneously +- **Multi-gateway** — Connect to multiple agent gateways simultaneously (OpenClaw, and more coming soon) ## Quick Start @@ -94,8 +94,8 @@ Outbound webhooks with delivery history, configurable alert rules with cooldowns ``` mission-control/ -├── middleware.ts # Auth gate + network access control ├── src/ +│ ├── middleware.ts # Auth gate + CSRF + network access control │ ├── app/ │ │ ├── page.tsx # SPA shell — routes all panels │ │ ├── login/page.tsx # Login page @@ -128,7 +128,7 @@ mission-control/ | Charts | Recharts 3 | | Real-time | WebSocket + Server-Sent Events | | Auth | scrypt hashing, session tokens, RBAC | -| Testing | Vitest + Playwright (51 E2E tests) | +| Testing | Vitest + Playwright (146 E2E tests) | ## Authentication @@ -331,7 +331,9 @@ See [open issues](https://github.com/builderz-labs/mission-control/issues) for p **Up next:** -- [ ] Native macOS app +- [ ] Agent-agnostic gateway support — connect any orchestration framework (OpenClaw, ZeroClaw, OpenFang, NeoBot, IronClaw, etc.), not just OpenClaw +- [ ] Direct CLI integration — connect tools like Codex, Claude Code, or custom CLIs directly without requiring a gateway +- [ ] Native macOS app (Electron or Tauri) - [ ] OpenAPI / Swagger documentation - [ ] Webhook retry with exponential backoff - [ ] OAuth approval UI improvements diff --git a/package.json b/package.json index 5b64c1b..d23816a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mission-control", "version": "1.2.0", - "description": "OpenClaw Mission Control — open-source agent orchestration dashboard", + "description": "OpenClaw Mission Control \u2014 open-source agent orchestration dashboard", "scripts": { "dev": "next dev --hostname 127.0.0.1", "build": "next build", @@ -68,5 +68,10 @@ "repository": { "type": "git", "url": "https://github.com/builderz-labs/mission-control.git" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "better-sqlite3" + ] } -} +} \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts index a056973..9a97815 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -6,7 +6,8 @@ export default defineConfig({ expect: { timeout: 10_000 }, - fullyParallel: true, + fullyParallel: false, + workers: 1, reporter: [['list']], use: { baseURL: process.env.E2E_BASE_URL || 'http://127.0.0.1:3005', diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index e945b47..baf704b 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -9,6 +9,8 @@ interface RateLimiterOptions { windowMs: number maxRequests: number message?: string + /** If true, MC_DISABLE_RATE_LIMIT will not bypass this limiter */ + critical?: boolean } export function createRateLimiter(options: RateLimiterOptions) { @@ -25,6 +27,8 @@ export function createRateLimiter(options: RateLimiterOptions) { if (cleanupInterval.unref) cleanupInterval.unref() return function checkRateLimit(request: Request): NextResponse | null { + // Allow disabling non-critical rate limiting for E2E tests + if (process.env.MC_DISABLE_RATE_LIMIT === '1' && !options.critical) return null const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown' const now = Date.now() const entry = store.get(ip) @@ -50,6 +54,7 @@ export const loginLimiter = createRateLimiter({ windowMs: 60_000, maxRequests: 5, message: 'Too many login attempts. Try again in a minute.', + critical: true, }) export const mutationLimiter = createRateLimiter({ diff --git a/middleware.ts b/src/middleware.ts similarity index 96% rename from middleware.ts rename to src/middleware.ts index 12fff06..38166e3 100644 --- a/middleware.ts +++ b/src/middleware.ts @@ -82,8 +82,10 @@ export function middleware(request: NextRequest) { if (origin) { let originHost: string try { originHost = new URL(origin).host } catch { originHost = '' } - const requestHost = request.headers.get('host') || '' - if (originHost && requestHost && originHost !== requestHost.split(',')[0].trim()) { + const requestHost = request.headers.get('host')?.split(',')[0]?.trim() + || request.nextUrl.host + || '' + if (originHost && requestHost && originHost !== requestHost) { return NextResponse.json({ error: 'CSRF origin mismatch' }, { status: 403 }) } } diff --git a/tests/README.md b/tests/README.md index 8c3951b..e75e461 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,6 +1,53 @@ # E2E Tests -Place Playwright end-to-end specs here. +Playwright end-to-end specs for Mission Control API and UI. -Example: -- `tests/smoke.spec.ts` +## Running + +```bash +# Start the dev server first (or let Playwright auto-start via reuseExistingServer) +pnpm dev --hostname 127.0.0.1 --port 3005 + +# Run all tests +pnpm test:e2e + +# Run a specific spec +pnpm exec playwright test tests/tasks-crud.spec.ts +``` + +## Test Environment + +Tests require `.env.local` with: +- `API_KEY=test-api-key-e2e-12345` +- `MC_DISABLE_RATE_LIMIT=1` (bypasses mutation/read rate limits, keeps login rate limit active) + +## Spec Files + +### Security & Auth +- `auth-guards.spec.ts` — All API routes return 401 without auth +- `csrf-validation.spec.ts` — CSRF origin header validation +- `legacy-cookie-removed.spec.ts` — Old cookie format rejected +- `login-flow.spec.ts` — Login, session, redirect lifecycle +- `rate-limiting.spec.ts` — Login brute-force protection +- `timing-safe-auth.spec.ts` — Constant-time API key comparison + +### CRUD Lifecycle +- `tasks-crud.spec.ts` — Tasks POST/GET/PUT/DELETE with filters, Aegis gate +- `agents-crud.spec.ts` — Agents CRUD, lookup by name/id, admin-only delete +- `task-comments.spec.ts` — Threaded comments on tasks +- `workflows-crud.spec.ts` — Workflow template CRUD +- `webhooks-crud.spec.ts` — Webhooks with secret masking and regeneration +- `alerts-crud.spec.ts` — Alert rule CRUD with full lifecycle +- `user-management.spec.ts` — User admin CRUD + +### Features +- `notifications.spec.ts` — Notification delivery and read tracking +- `quality-review.spec.ts` — Quality reviews with batch lookup +- `search-and-export.spec.ts` — Global search, data export, activity feed + +### Infrastructure +- `limit-caps.spec.ts` — Endpoint limit caps enforced +- `delete-body.spec.ts` — DELETE body standardization + +### Shared +- `helpers.ts` — Factory functions (`createTestTask`, `createTestAgent`, etc.) and cleanup helpers diff --git a/tests/agents-crud.spec.ts b/tests/agents-crud.spec.ts new file mode 100644 index 0000000..6a6d1c9 --- /dev/null +++ b/tests/agents-crud.spec.ts @@ -0,0 +1,197 @@ +import { test, expect } from '@playwright/test' +import { API_KEY_HEADER, createTestAgent, deleteTestAgent } from './helpers' + +test.describe('Agents CRUD', () => { + const cleanup: number[] = [] + + test.afterEach(async ({ request }) => { + for (const id of cleanup) { + await deleteTestAgent(request, id).catch(() => {}) + } + cleanup.length = 0 + }) + + // ── POST /api/agents ───────────────────────── + + test('POST creates agent with name and role', async ({ request }) => { + const { id, res, body } = await createTestAgent(request) + cleanup.push(id) + + expect(res.status()).toBe(201) + expect(body.agent).toBeDefined() + expect(body.agent.name).toContain('e2e-agent-') + expect(body.agent.role).toBe('tester') + expect(body.agent.status).toBe('offline') + }) + + test('POST rejects missing name', async ({ request }) => { + const res = await request.post('/api/agents', { + headers: API_KEY_HEADER, + data: { role: 'tester' }, + }) + expect(res.status()).toBe(400) + }) + + test('POST rejects duplicate name', async ({ request }) => { + const { id, body: first } = await createTestAgent(request) + cleanup.push(id) + + const res = await request.post('/api/agents', { + headers: API_KEY_HEADER, + data: { name: first.agent.name, role: 'duplicate' }, + }) + expect(res.status()).toBe(409) + }) + + // ── GET /api/agents ────────────────────────── + + test('GET list returns agents with pagination and taskStats', async ({ request }) => { + const { id } = await createTestAgent(request) + cleanup.push(id) + + const res = await request.get('/api/agents', { headers: API_KEY_HEADER }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body).toHaveProperty('agents') + expect(body).toHaveProperty('total') + expect(body).toHaveProperty('page') + expect(body).toHaveProperty('limit') + expect(Array.isArray(body.agents)).toBe(true) + + // Every agent should have taskStats + for (const a of body.agents) { + expect(a.taskStats).toBeDefined() + expect(a.taskStats).toHaveProperty('total') + } + }) + + // ── GET /api/agents/[id] ───────────────────── + + test('GET single by numeric id', async ({ request }) => { + const { id } = await createTestAgent(request) + cleanup.push(id) + + const res = await request.get(`/api/agents/${id}`, { headers: API_KEY_HEADER }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.agent.id).toBe(id) + }) + + test('GET single by name', async ({ request }) => { + const { id, name } = await createTestAgent(request) + cleanup.push(id) + + const res = await request.get(`/api/agents/${name}`, { headers: API_KEY_HEADER }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.agent.name).toBe(name) + }) + + test('GET single returns 404 for missing', async ({ request }) => { + const res = await request.get('/api/agents/999999', { headers: API_KEY_HEADER }) + expect(res.status()).toBe(404) + }) + + // ── PUT /api/agents/[id] ───────────────────── + + test('PUT by id updates role', async ({ request }) => { + const { id } = await createTestAgent(request) + cleanup.push(id) + + const res = await request.put(`/api/agents/${id}`, { + headers: API_KEY_HEADER, + data: { role: 'reviewer' }, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.agent.role).toBe('reviewer') + }) + + test('PUT by id returns 404 for missing', async ({ request }) => { + const res = await request.put('/api/agents/999999', { + headers: API_KEY_HEADER, + data: { role: 'reviewer' }, + }) + expect(res.status()).toBe(404) + }) + + // ── PUT /api/agents (bulk by name) ─────────── + + test('PUT by name updates status', async ({ request }) => { + const { id, name } = await createTestAgent(request) + cleanup.push(id) + + const res = await request.put('/api/agents', { + headers: API_KEY_HEADER, + data: { name, status: 'online' }, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.success).toBe(true) + }) + + test('PUT by name returns 404 for missing', async ({ request }) => { + const res = await request.put('/api/agents', { + headers: API_KEY_HEADER, + data: { name: 'nonexistent-agent-xyz', status: 'online' }, + }) + expect(res.status()).toBe(404) + }) + + test('PUT by name returns 400 when no fields provided', async ({ request }) => { + const { id, name } = await createTestAgent(request) + cleanup.push(id) + + const res = await request.put('/api/agents', { + headers: API_KEY_HEADER, + data: { name }, + }) + expect(res.status()).toBe(400) + }) + + // ── DELETE /api/agents/[id] ────────────────── + + test('DELETE removes agent (admin via API key)', async ({ request }) => { + const { id, name } = await createTestAgent(request) + + const res = await request.delete(`/api/agents/${id}`, { headers: API_KEY_HEADER }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.success).toBe(true) + expect(body.deleted).toBe(name) + }) + + test('DELETE returns 404 for missing agent', async ({ request }) => { + const res = await request.delete('/api/agents/999999', { headers: API_KEY_HEADER }) + expect(res.status()).toBe(404) + }) + + // ── Full lifecycle ─────────────────────────── + + test('full lifecycle: create → read → update → delete', async ({ request }) => { + // Create + const { id, name, res: createRes } = await createTestAgent(request, { role: 'builder' }) + expect(createRes.status()).toBe(201) + + // Read + const readRes = await request.get(`/api/agents/${id}`, { headers: API_KEY_HEADER }) + expect(readRes.status()).toBe(200) + const readBody = await readRes.json() + expect(readBody.agent.role).toBe('builder') + + // Update via [id] + const updateRes = await request.put(`/api/agents/${id}`, { + headers: API_KEY_HEADER, + data: { role: 'architect' }, + }) + expect(updateRes.status()).toBe(200) + + // Delete + const deleteRes = await request.delete(`/api/agents/${id}`, { headers: API_KEY_HEADER }) + expect(deleteRes.status()).toBe(200) + + // Confirm gone + const goneRes = await request.get(`/api/agents/${name}`, { headers: API_KEY_HEADER }) + expect(goneRes.status()).toBe(404) + }) +}) diff --git a/tests/alerts-crud.spec.ts b/tests/alerts-crud.spec.ts new file mode 100644 index 0000000..cb05d7e --- /dev/null +++ b/tests/alerts-crud.spec.ts @@ -0,0 +1,126 @@ +import { test, expect } from '@playwright/test' +import { API_KEY_HEADER, createTestAlert, deleteTestAlert } from './helpers' + +test.describe('Alerts CRUD', () => { + const cleanup: number[] = [] + + test.afterEach(async ({ request }) => { + for (const id of cleanup) { + await deleteTestAlert(request, id).catch(() => {}) + } + cleanup.length = 0 + }) + + // ── POST /api/alerts ───────────────────────── + + test('POST creates alert rule with all required fields', async ({ request }) => { + const { id, res, body } = await createTestAlert(request) + cleanup.push(id) + + expect(res.status()).toBe(201) + expect(body.rule).toBeDefined() + expect(body.rule.name).toContain('e2e-alert-') + expect(body.rule.entity_type).toBe('task') + expect(body.rule.condition_operator).toBe('equals') + }) + + test('POST rejects missing required fields', async ({ request }) => { + const res = await request.post('/api/alerts', { + headers: API_KEY_HEADER, + data: { name: 'incomplete-alert' }, + }) + expect(res.status()).toBe(400) + }) + + test('POST rejects invalid entity_type', async ({ request }) => { + const res = await request.post('/api/alerts', { + headers: API_KEY_HEADER, + data: { + name: 'bad-entity', + entity_type: 'invalid', + condition_field: 'status', + condition_operator: 'equals', + condition_value: 'test', + }, + }) + expect(res.status()).toBe(400) + }) + + // ── GET /api/alerts ────────────────────────── + + test('GET returns rules array', async ({ request }) => { + const { id } = await createTestAlert(request) + cleanup.push(id) + + const res = await request.get('/api/alerts', { headers: API_KEY_HEADER }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.rules).toBeDefined() + expect(Array.isArray(body.rules)).toBe(true) + }) + + // ── PUT /api/alerts ────────────────────────── + + test('PUT updates alert rule fields', async ({ request }) => { + const { id } = await createTestAlert(request) + cleanup.push(id) + + const res = await request.put('/api/alerts', { + headers: API_KEY_HEADER, + data: { id, name: 'updated-alert', description: 'Updated desc' }, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.rule.name).toBe('updated-alert') + expect(body.rule.description).toBe('Updated desc') + }) + + test('PUT returns 404 for missing rule', async ({ request }) => { + const res = await request.put('/api/alerts', { + headers: API_KEY_HEADER, + data: { id: 999999, name: 'nope' }, + }) + expect(res.status()).toBe(404) + }) + + // ── DELETE /api/alerts ─────────────────────── + + test('DELETE removes alert rule', async ({ request }) => { + const { id } = await createTestAlert(request) + + const res = await request.delete('/api/alerts', { + headers: API_KEY_HEADER, + data: { id }, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.deleted).toBe(true) + }) + + // ── Full lifecycle ─────────────────────────── + + test('full lifecycle: create → list → update → delete', async ({ request }) => { + // Create + const { id, res: createRes } = await createTestAlert(request) + expect(createRes.status()).toBe(201) + + // List + const listRes = await request.get('/api/alerts', { headers: API_KEY_HEADER }) + const listBody = await listRes.json() + expect(listBody.rules.some((r: any) => r.id === id)).toBe(true) + + // Update + const updateRes = await request.put('/api/alerts', { + headers: API_KEY_HEADER, + data: { id, enabled: 0 }, + }) + expect(updateRes.status()).toBe(200) + + // Delete + const deleteRes = await request.delete('/api/alerts', { + headers: API_KEY_HEADER, + data: { id }, + }) + expect(deleteRes.status()).toBe(200) + }) +}) diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..e2eb1bb --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,140 @@ +import { APIRequestContext } from '@playwright/test' + +export const API_KEY_HEADER = { 'x-api-key': 'test-api-key-e2e-12345' } + +function uid() { + return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} + +// --- Task helpers --- + +export async function createTestTask( + request: APIRequestContext, + overrides: Record = {} +) { + const title = `e2e-task-${uid()}` + const res = await request.post('/api/tasks', { + headers: API_KEY_HEADER, + data: { title, ...overrides }, + }) + const body = await res.json() + return { id: body.task?.id as number, title, res, body } +} + +export async function deleteTestTask(request: APIRequestContext, id: number) { + return request.delete(`/api/tasks/${id}`, { headers: API_KEY_HEADER }) +} + +// --- Agent helpers --- + +export async function createTestAgent( + request: APIRequestContext, + overrides: Record = {} +) { + const name = `e2e-agent-${uid()}` + const res = await request.post('/api/agents', { + headers: API_KEY_HEADER, + data: { name, role: 'tester', ...overrides }, + }) + const body = await res.json() + return { id: body.agent?.id as number, name, res, body } +} + +export async function deleteTestAgent(request: APIRequestContext, id: number) { + return request.delete(`/api/agents/${id}`, { headers: API_KEY_HEADER }) +} + +// --- Workflow helpers --- + +export async function createTestWorkflow( + request: APIRequestContext, + overrides: Record = {} +) { + const name = `e2e-wf-${uid()}` + const res = await request.post('/api/workflows', { + headers: API_KEY_HEADER, + data: { name, task_prompt: 'Test prompt for e2e', ...overrides }, + }) + const body = await res.json() + return { id: body.template?.id as number, name, res, body } +} + +export async function deleteTestWorkflow(request: APIRequestContext, id: number) { + return request.delete('/api/workflows', { + headers: API_KEY_HEADER, + data: { id }, + }) +} + +// --- Webhook helpers --- + +export async function createTestWebhook( + request: APIRequestContext, + overrides: Record = {} +) { + const name = `e2e-webhook-${uid()}` + const res = await request.post('/api/webhooks', { + headers: API_KEY_HEADER, + data: { name, url: 'https://example.com/hook', ...overrides }, + }) + const body = await res.json() + return { id: body.id as number, name, res, body } +} + +export async function deleteTestWebhook(request: APIRequestContext, id: number) { + return request.delete('/api/webhooks', { + headers: API_KEY_HEADER, + data: { id }, + }) +} + +// --- Alert helpers --- + +export async function createTestAlert( + request: APIRequestContext, + overrides: Record = {} +) { + const name = `e2e-alert-${uid()}` + const res = await request.post('/api/alerts', { + headers: API_KEY_HEADER, + data: { + name, + entity_type: 'task', + condition_field: 'status', + condition_operator: 'equals', + condition_value: 'inbox', + ...overrides, + }, + }) + const body = await res.json() + return { id: body.rule?.id as number, name, res, body } +} + +export async function deleteTestAlert(request: APIRequestContext, id: number) { + return request.delete('/api/alerts', { + headers: API_KEY_HEADER, + data: { id }, + }) +} + +// --- User helpers --- + +export async function createTestUser( + request: APIRequestContext, + overrides: Record = {} +) { + const username = `e2e-user-${uid()}` + const res = await request.post('/api/auth/users', { + headers: API_KEY_HEADER, + data: { username, password: 'testpass123', display_name: username, ...overrides }, + }) + const body = await res.json() + return { id: body.user?.id as number, username, res, body } +} + +export async function deleteTestUser(request: APIRequestContext, id: number) { + return request.delete('/api/auth/users', { + headers: API_KEY_HEADER, + data: { id }, + }) +} diff --git a/tests/limit-caps.spec.ts b/tests/limit-caps.spec.ts index 0f7602b..c0befc1 100644 --- a/tests/limit-caps.spec.ts +++ b/tests/limit-caps.spec.ts @@ -7,19 +7,19 @@ import { test, expect } from '@playwright/test' const API_KEY_HEADER = { 'x-api-key': 'test-api-key-e2e-12345' } -// These endpoints accept a `limit` query param -const LIMIT_ENDPOINTS = [ - '/api/agents', - '/api/tasks', - '/api/activities', - '/api/logs', - '/api/chat/conversations', - '/api/spawn', +// Endpoints with their server-side caps +const LIMIT_ENDPOINTS: { path: string; cap: number }[] = [ + { path: '/api/agents', cap: 200 }, + { path: '/api/tasks', cap: 200 }, + { path: '/api/activities', cap: 500 }, + { path: '/api/logs', cap: 200 }, + { path: '/api/chat/conversations', cap: 200 }, + { path: '/api/spawn', cap: 200 }, ] test.describe('Limit Caps (Issue #19)', () => { - for (const endpoint of LIMIT_ENDPOINTS) { - test(`${endpoint}?limit=9999 does not return more than 200 items`, async ({ request }) => { + for (const { path: endpoint, cap } of LIMIT_ENDPOINTS) { + test(`${endpoint}?limit=9999 does not return more than ${cap} items`, async ({ request }) => { const res = await request.get(`${endpoint}?limit=9999`, { headers: API_KEY_HEADER }) @@ -35,12 +35,12 @@ test.describe('Limit Caps (Issue #19)', () => { const possibleArrayKeys = ['agents', 'tasks', 'activities', 'logs', 'conversations', 'history', 'data'] for (const key of possibleArrayKeys) { if (Array.isArray(body[key])) { - expect(body[key].length).toBeLessThanOrEqual(200) + expect(body[key].length).toBeLessThanOrEqual(cap) } } // Also check if body itself is an array if (Array.isArray(body)) { - expect(body.length).toBeLessThanOrEqual(200) + expect(body.length).toBeLessThanOrEqual(cap) } }) } diff --git a/tests/notifications.spec.ts b/tests/notifications.spec.ts new file mode 100644 index 0000000..dda4156 --- /dev/null +++ b/tests/notifications.spec.ts @@ -0,0 +1,107 @@ +import { test, expect } from '@playwright/test' +import { API_KEY_HEADER, createTestTask, deleteTestTask } from './helpers' + +test.describe('Notifications', () => { + const cleanup: number[] = [] + + test.afterEach(async ({ request }) => { + for (const id of cleanup) { + await deleteTestTask(request, id).catch(() => {}) + } + cleanup.length = 0 + }) + + // ── GET /api/notifications ─────────────────── + + test('GET returns notifications for recipient', async ({ request }) => { + // Create a task assigned to an agent (triggers notification) + const { id } = await createTestTask(request, { assigned_to: 'notif-agent' }) + cleanup.push(id) + + const res = await request.get('/api/notifications?recipient=notif-agent', { + headers: API_KEY_HEADER, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.notifications).toBeDefined() + expect(Array.isArray(body.notifications)).toBe(true) + expect(body).toHaveProperty('total') + expect(body).toHaveProperty('unreadCount') + }) + + test('GET returns 400 without recipient param', async ({ request }) => { + const res = await request.get('/api/notifications', { headers: API_KEY_HEADER }) + expect(res.status()).toBe(400) + }) + + test('GET filters by unread_only', async ({ request }) => { + const res = await request.get('/api/notifications?recipient=notif-agent&unread_only=true', { + headers: API_KEY_HEADER, + }) + expect(res.status()).toBe(200) + const body = await res.json() + // All returned notifications should be unread (read_at is null) + for (const n of body.notifications) { + expect(n.read_at).toBeNull() + } + }) + + // ── POST /api/notifications ────────────────── + + test('POST marks notifications as delivered', async ({ request }) => { + // Create assignment to trigger a notification + const { id } = await createTestTask(request, { assigned_to: 'deliver-agent' }) + cleanup.push(id) + + const res = await request.post('/api/notifications', { + headers: API_KEY_HEADER, + data: { action: 'mark-delivered', agent: 'deliver-agent' }, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.success).toBe(true) + }) + + test('POST rejects missing agent', async ({ request }) => { + const res = await request.post('/api/notifications', { + headers: API_KEY_HEADER, + data: { action: 'mark-delivered' }, + }) + expect(res.status()).toBe(400) + }) + + // ── PUT /api/notifications ─────────────────── + + test('PUT marks specific notification ids as read', async ({ request }) => { + // Create assignment + const { id: taskId } = await createTestTask(request, { assigned_to: 'read-agent' }) + cleanup.push(taskId) + + // Get the notification id + const listRes = await request.get('/api/notifications?recipient=read-agent', { + headers: API_KEY_HEADER, + }) + const listBody = await listRes.json() + const notifIds = listBody.notifications.map((n: any) => n.id) + + if (notifIds.length > 0) { + const res = await request.put('/api/notifications', { + headers: API_KEY_HEADER, + data: { ids: notifIds }, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.success).toBe(true) + } + }) + + test('PUT marks all as read for recipient', async ({ request }) => { + const res = await request.put('/api/notifications', { + headers: API_KEY_HEADER, + data: { recipient: 'read-agent', markAllRead: true }, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.success).toBe(true) + }) +}) diff --git a/tests/quality-review.spec.ts b/tests/quality-review.spec.ts new file mode 100644 index 0000000..f91c98e --- /dev/null +++ b/tests/quality-review.spec.ts @@ -0,0 +1,111 @@ +import { test, expect } from '@playwright/test' +import { API_KEY_HEADER, createTestTask, deleteTestTask } from './helpers' + +test.describe('Quality Review', () => { + const cleanup: number[] = [] + + test.afterEach(async ({ request }) => { + for (const id of cleanup) { + await deleteTestTask(request, id).catch(() => {}) + } + cleanup.length = 0 + }) + + // ── POST /api/quality-review ───────────────── + + test('POST creates review for existing task', async ({ request }) => { + const { id: taskId } = await createTestTask(request) + cleanup.push(taskId) + + const res = await request.post('/api/quality-review', { + headers: API_KEY_HEADER, + data: { + taskId, + reviewer: 'aegis', + status: 'approved', + notes: 'Looks good to me', + }, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.success).toBe(true) + expect(body.id).toBeDefined() + }) + + test('POST returns 404 for non-existent task', async ({ request }) => { + const res = await request.post('/api/quality-review', { + headers: API_KEY_HEADER, + data: { + taskId: 999999, + reviewer: 'aegis', + status: 'rejected', + notes: 'Task does not exist', + }, + }) + expect(res.status()).toBe(404) + }) + + test('POST rejects missing required fields', async ({ request }) => { + const { id: taskId } = await createTestTask(request) + cleanup.push(taskId) + + const res = await request.post('/api/quality-review', { + headers: API_KEY_HEADER, + data: { taskId }, + }) + expect(res.status()).toBe(400) + }) + + // ── GET /api/quality-review ────────────────── + + test('GET returns reviews for taskId', async ({ request }) => { + const { id: taskId } = await createTestTask(request) + cleanup.push(taskId) + + // Create a review first + await request.post('/api/quality-review', { + headers: API_KEY_HEADER, + data: { + taskId, + reviewer: 'aegis', + status: 'approved', + notes: 'LGTM', + }, + }) + + const res = await request.get(`/api/quality-review?taskId=${taskId}`, { + headers: API_KEY_HEADER, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.reviews).toBeDefined() + expect(Array.isArray(body.reviews)).toBe(true) + expect(body.reviews.length).toBeGreaterThanOrEqual(1) + }) + + test('GET batch lookup by taskIds', async ({ request }) => { + const { id: taskId1 } = await createTestTask(request) + const { id: taskId2 } = await createTestTask(request) + cleanup.push(taskId1, taskId2) + + // Create review for task1 + await request.post('/api/quality-review', { + headers: API_KEY_HEADER, + data: { taskId: taskId1, reviewer: 'aegis', status: 'approved', notes: 'ok' }, + }) + + const res = await request.get(`/api/quality-review?taskIds=${taskId1},${taskId2}`, { + headers: API_KEY_HEADER, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.latest).toBeDefined() + expect(body.latest[taskId1]).not.toBeNull() + expect(body.latest[taskId2]).toBeNull() // no review for task2 + }) + + test('GET returns 400 without taskId', async ({ request }) => { + const res = await request.get('/api/quality-review', { headers: API_KEY_HEADER }) + expect(res.status()).toBe(400) + }) +}) diff --git a/tests/search-and-export.spec.ts b/tests/search-and-export.spec.ts new file mode 100644 index 0000000..48ebad4 --- /dev/null +++ b/tests/search-and-export.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from '@playwright/test' +import { API_KEY_HEADER, createTestTask, deleteTestTask } from './helpers' + +test.describe('Search and Export', () => { + const cleanup: number[] = [] + + test.afterEach(async ({ request }) => { + for (const id of cleanup) { + await deleteTestTask(request, id).catch(() => {}) + } + cleanup.length = 0 + }) + + // ── GET /api/search ────────────────────────── + + test('search returns results for valid query', async ({ request }) => { + // Create a task with a searchable term + const { id } = await createTestTask(request, { title: 'searchable-zebra-test' }) + cleanup.push(id) + + const res = await request.get('/api/search?q=searchable-zebra', { + headers: API_KEY_HEADER, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.query).toBe('searchable-zebra') + expect(body.results).toBeDefined() + expect(Array.isArray(body.results)).toBe(true) + expect(body.count).toBeGreaterThanOrEqual(1) + }) + + test('search returns 400 for short query', async ({ request }) => { + const res = await request.get('/api/search?q=a', { headers: API_KEY_HEADER }) + expect(res.status()).toBe(400) + }) + + test('search returns 400 for empty query', async ({ request }) => { + const res = await request.get('/api/search?q=', { headers: API_KEY_HEADER }) + expect(res.status()).toBe(400) + }) + + // ── GET /api/export ────────────────────────── + + test('export returns tasks as JSON', async ({ request }) => { + const res = await request.get('/api/export?type=tasks&format=json', { + headers: API_KEY_HEADER, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.type).toBe('tasks') + expect(body.data).toBeDefined() + expect(Array.isArray(body.data)).toBe(true) + expect(body.count).toBeDefined() + }) + + test('export rejects missing type', async ({ request }) => { + const res = await request.get('/api/export', { headers: API_KEY_HEADER }) + expect(res.status()).toBe(400) + }) + + test('export rejects invalid type', async ({ request }) => { + const res = await request.get('/api/export?type=invalid', { headers: API_KEY_HEADER }) + expect(res.status()).toBe(400) + }) + + // ── GET /api/activities ────────────────────── + + test('activities returns activity feed', async ({ request }) => { + const res = await request.get('/api/activities', { headers: API_KEY_HEADER }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.activities).toBeDefined() + expect(Array.isArray(body.activities)).toBe(true) + expect(body).toHaveProperty('total') + expect(body).toHaveProperty('hasMore') + }) +}) diff --git a/tests/task-comments.spec.ts b/tests/task-comments.spec.ts new file mode 100644 index 0000000..1c9399a --- /dev/null +++ b/tests/task-comments.spec.ts @@ -0,0 +1,108 @@ +import { test, expect } from '@playwright/test' +import { API_KEY_HEADER, createTestTask, deleteTestTask } from './helpers' + +test.describe('Task Comments', () => { + const cleanup: number[] = [] + + test.afterEach(async ({ request }) => { + for (const id of cleanup) { + await deleteTestTask(request, id).catch(() => {}) + } + cleanup.length = 0 + }) + + // ── POST /api/tasks/[id]/comments ──────────── + + test('POST adds comment to existing task', async ({ request }) => { + const { id } = await createTestTask(request) + cleanup.push(id) + + const res = await request.post(`/api/tasks/${id}/comments`, { + headers: API_KEY_HEADER, + data: { content: 'Test comment from e2e' }, + }) + expect(res.status()).toBe(201) + const body = await res.json() + expect(body.comment).toBeDefined() + expect(body.comment.content).toBe('Test comment from e2e') + expect(body.comment.task_id).toBe(id) + }) + + test('POST rejects empty content', async ({ request }) => { + const { id } = await createTestTask(request) + cleanup.push(id) + + const res = await request.post(`/api/tasks/${id}/comments`, { + headers: API_KEY_HEADER, + data: { content: '' }, + }) + expect(res.status()).toBe(400) + }) + + test('POST returns 404 for non-existent task', async ({ request }) => { + const res = await request.post('/api/tasks/999999/comments', { + headers: API_KEY_HEADER, + data: { content: 'orphan comment' }, + }) + expect(res.status()).toBe(404) + }) + + test('POST creates threaded reply', async ({ request }) => { + const { id } = await createTestTask(request) + cleanup.push(id) + + // Create parent comment + const parentRes = await request.post(`/api/tasks/${id}/comments`, { + headers: API_KEY_HEADER, + data: { content: 'Parent comment' }, + }) + const parentBody = await parentRes.json() + const parentId = parentBody.comment.id + + // Create reply + const replyRes = await request.post(`/api/tasks/${id}/comments`, { + headers: API_KEY_HEADER, + data: { content: 'Reply comment', parent_id: parentId }, + }) + expect(replyRes.status()).toBe(201) + const replyBody = await replyRes.json() + expect(replyBody.comment.parent_id).toBe(parentId) + }) + + // ── GET /api/tasks/[id]/comments ───────────── + + test('GET returns comments array for task', async ({ request }) => { + const { id } = await createTestTask(request) + cleanup.push(id) + + // Add a comment + await request.post(`/api/tasks/${id}/comments`, { + headers: API_KEY_HEADER, + data: { content: 'First comment' }, + }) + + const res = await request.get(`/api/tasks/${id}/comments`, { headers: API_KEY_HEADER }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.comments).toBeDefined() + expect(Array.isArray(body.comments)).toBe(true) + expect(body.comments.length).toBeGreaterThanOrEqual(1) + expect(body.total).toBeGreaterThanOrEqual(1) + }) + + test('GET returns empty array for task with no comments', async ({ request }) => { + const { id } = await createTestTask(request) + cleanup.push(id) + + const res = await request.get(`/api/tasks/${id}/comments`, { headers: API_KEY_HEADER }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.comments).toEqual([]) + expect(body.total).toBe(0) + }) + + test('GET returns 404 for non-existent task', async ({ request }) => { + const res = await request.get('/api/tasks/999999/comments', { headers: API_KEY_HEADER }) + expect(res.status()).toBe(404) + }) +}) diff --git a/tests/tasks-crud.spec.ts b/tests/tasks-crud.spec.ts new file mode 100644 index 0000000..86e746d --- /dev/null +++ b/tests/tasks-crud.spec.ts @@ -0,0 +1,231 @@ +import { test, expect } from '@playwright/test' +import { API_KEY_HEADER, createTestTask, deleteTestTask } from './helpers' + +test.describe('Tasks CRUD', () => { + const cleanup: number[] = [] + + test.afterEach(async ({ request }) => { + for (const id of cleanup) { + await deleteTestTask(request, id).catch(() => {}) + } + cleanup.length = 0 + }) + + // ── POST /api/tasks ────────────────────────── + + test('POST creates task with minimal fields (title only)', async ({ request }) => { + const { id, res, body } = await createTestTask(request) + cleanup.push(id) + + expect(res.status()).toBe(201) + expect(body.task).toBeDefined() + expect(body.task.title).toContain('e2e-task-') + expect(body.task.status).toBe('inbox') + expect(body.task.priority).toBe('medium') + }) + + test('POST creates task with all fields', async ({ request }) => { + const { id, res, body } = await createTestTask(request, { + description: 'Full task', + status: 'assigned', + priority: 'high', + assigned_to: 'agent-x', + tags: ['e2e', 'test'], + metadata: { source: 'e2e' }, + }) + cleanup.push(id) + + expect(res.status()).toBe(201) + expect(body.task.description).toBe('Full task') + expect(body.task.status).toBe('assigned') + expect(body.task.priority).toBe('high') + expect(body.task.assigned_to).toBe('agent-x') + expect(body.task.tags).toEqual(['e2e', 'test']) + expect(body.task.metadata).toEqual({ source: 'e2e' }) + }) + + test('POST rejects empty title', async ({ request }) => { + const res = await request.post('/api/tasks', { + headers: API_KEY_HEADER, + data: { title: '' }, + }) + expect(res.status()).toBe(400) + }) + + test('POST rejects duplicate title', async ({ request }) => { + const { id, body: first } = await createTestTask(request) + cleanup.push(id) + + const res = await request.post('/api/tasks', { + headers: API_KEY_HEADER, + data: { title: first.task.title }, + }) + expect(res.status()).toBe(409) + }) + + // ── GET /api/tasks ─────────────────────────── + + test('GET list returns tasks with pagination shape', async ({ request }) => { + const { id } = await createTestTask(request) + cleanup.push(id) + + const res = await request.get('/api/tasks', { headers: API_KEY_HEADER }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body).toHaveProperty('tasks') + expect(body).toHaveProperty('total') + expect(body).toHaveProperty('page') + expect(body).toHaveProperty('limit') + expect(Array.isArray(body.tasks)).toBe(true) + }) + + test('GET list filters by status', async ({ request }) => { + const { id } = await createTestTask(request, { status: 'review' }) + cleanup.push(id) + + const res = await request.get('/api/tasks?status=review', { headers: API_KEY_HEADER }) + const body = await res.json() + for (const t of body.tasks) { + expect(t.status).toBe('review') + } + }) + + test('GET list filters by priority', async ({ request }) => { + const { id } = await createTestTask(request, { priority: 'critical' }) + cleanup.push(id) + + const res = await request.get('/api/tasks?priority=critical', { headers: API_KEY_HEADER }) + const body = await res.json() + for (const t of body.tasks) { + expect(t.priority).toBe('critical') + } + }) + + test('GET list respects limit and offset', async ({ request }) => { + const res = await request.get('/api/tasks?limit=2&offset=0', { headers: API_KEY_HEADER }) + const body = await res.json() + expect(body.tasks.length).toBeLessThanOrEqual(2) + expect(body.limit).toBe(2) + }) + + // ── GET /api/tasks/[id] ────────────────────── + + test('GET single returns task by id', async ({ request }) => { + const { id } = await createTestTask(request) + cleanup.push(id) + + const res = await request.get(`/api/tasks/${id}`, { headers: API_KEY_HEADER }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.task).toBeDefined() + expect(body.task.id).toBe(id) + }) + + test('GET single returns 404 for missing task', async ({ request }) => { + const res = await request.get('/api/tasks/999999', { headers: API_KEY_HEADER }) + expect(res.status()).toBe(404) + }) + + test('GET single returns 400 for non-numeric id', async ({ request }) => { + const res = await request.get('/api/tasks/abc', { headers: API_KEY_HEADER }) + expect(res.status()).toBe(400) + }) + + // ── PUT /api/tasks/[id] ────────────────────── + + test('PUT updates task fields', async ({ request }) => { + const { id } = await createTestTask(request) + cleanup.push(id) + + const res = await request.put(`/api/tasks/${id}`, { + headers: API_KEY_HEADER, + data: { title: 'Updated title', priority: 'high' }, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.task.title).toBe('Updated title') + expect(body.task.priority).toBe('high') + }) + + test('PUT returns 404 for missing task', async ({ request }) => { + const res = await request.put('/api/tasks/999999', { + headers: API_KEY_HEADER, + data: { title: 'no-op' }, + }) + expect(res.status()).toBe(404) + }) + + test('PUT with empty body still succeeds (Zod defaults fill fields)', async ({ request }) => { + const { id } = await createTestTask(request) + cleanup.push(id) + + const res = await request.put(`/api/tasks/${id}`, { + headers: API_KEY_HEADER, + data: {}, + }) + // Zod's partial schema fills defaults (status, priority, tags, metadata), + // so there are always fields to update — API returns 200, not 400 + expect(res.status()).toBe(200) + }) + + test('PUT returns 403 when moving to done without Aegis approval', async ({ request }) => { + const { id } = await createTestTask(request) + cleanup.push(id) + + const res = await request.put(`/api/tasks/${id}`, { + headers: API_KEY_HEADER, + data: { status: 'done' }, + }) + expect(res.status()).toBe(403) + const body = await res.json() + expect(body.error).toContain('Aegis') + }) + + // ── DELETE /api/tasks/[id] ─────────────────── + + test('DELETE removes task', async ({ request }) => { + const { id } = await createTestTask(request) + + const res = await request.delete(`/api/tasks/${id}`, { headers: API_KEY_HEADER }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.success).toBe(true) + }) + + test('DELETE returns 404 for missing task', async ({ request }) => { + const res = await request.delete('/api/tasks/999999', { headers: API_KEY_HEADER }) + expect(res.status()).toBe(404) + }) + + // ── Full lifecycle ─────────────────────────── + + test('full lifecycle: create → read → update → delete → confirm gone', async ({ request }) => { + // Create + const { id, res: createRes } = await createTestTask(request, { description: 'lifecycle test' }) + expect(createRes.status()).toBe(201) + + // Read + const readRes = await request.get(`/api/tasks/${id}`, { headers: API_KEY_HEADER }) + expect(readRes.status()).toBe(200) + const readBody = await readRes.json() + expect(readBody.task.description).toBe('lifecycle test') + + // Update + const updateRes = await request.put(`/api/tasks/${id}`, { + headers: API_KEY_HEADER, + data: { status: 'in_progress', priority: 'critical' }, + }) + expect(updateRes.status()).toBe(200) + const updateBody = await updateRes.json() + expect(updateBody.task.status).toBe('in_progress') + expect(updateBody.task.priority).toBe('critical') + + // Delete + const deleteRes = await request.delete(`/api/tasks/${id}`, { headers: API_KEY_HEADER }) + expect(deleteRes.status()).toBe(200) + + // Confirm gone + const goneRes = await request.get(`/api/tasks/${id}`, { headers: API_KEY_HEADER }) + expect(goneRes.status()).toBe(404) + }) +}) diff --git a/tests/user-management.spec.ts b/tests/user-management.spec.ts new file mode 100644 index 0000000..b768910 --- /dev/null +++ b/tests/user-management.spec.ts @@ -0,0 +1,111 @@ +import { test, expect } from '@playwright/test' +import { API_KEY_HEADER, createTestUser, deleteTestUser } from './helpers' + +test.describe('User Management', () => { + const cleanup: number[] = [] + + test.afterEach(async ({ request }) => { + for (const id of cleanup) { + await deleteTestUser(request, id).catch(() => {}) + } + cleanup.length = 0 + }) + + // ── POST /api/auth/users ───────────────────── + + test('POST creates user', async ({ request }) => { + const { id, res, body } = await createTestUser(request) + cleanup.push(id) + + expect(res.status()).toBe(201) + expect(body.user).toBeDefined() + expect(body.user.username).toContain('e2e-user-') + expect(body.user.role).toBe('operator') + }) + + test('POST rejects duplicate username', async ({ request }) => { + const { id, body: first } = await createTestUser(request) + cleanup.push(id) + + const res = await request.post('/api/auth/users', { + headers: API_KEY_HEADER, + data: { + username: first.user.username, + password: 'testpass123', + }, + }) + expect(res.status()).toBe(409) + }) + + test('POST rejects missing username', async ({ request }) => { + const res = await request.post('/api/auth/users', { + headers: API_KEY_HEADER, + data: { password: 'testpass123' }, + }) + expect(res.status()).toBe(400) + }) + + test('POST rejects missing password', async ({ request }) => { + const res = await request.post('/api/auth/users', { + headers: API_KEY_HEADER, + data: { username: 'no-password-user' }, + }) + expect(res.status()).toBe(400) + }) + + // ── GET /api/auth/users ────────────────────── + + test('GET returns users list', async ({ request }) => { + const res = await request.get('/api/auth/users', { headers: API_KEY_HEADER }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.users).toBeDefined() + expect(Array.isArray(body.users)).toBe(true) + }) + + // ── PUT /api/auth/users ────────────────────── + + test('PUT updates display_name and role', async ({ request }) => { + const { id } = await createTestUser(request) + cleanup.push(id) + + const res = await request.put('/api/auth/users', { + headers: API_KEY_HEADER, + data: { id, display_name: 'Updated Name', role: 'viewer' }, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.user.display_name).toBe('Updated Name') + expect(body.user.role).toBe('viewer') + }) + + test('PUT returns 404 for missing user', async ({ request }) => { + const res = await request.put('/api/auth/users', { + headers: API_KEY_HEADER, + data: { id: 999999, display_name: 'nope' }, + }) + expect(res.status()).toBe(404) + }) + + // ── DELETE /api/auth/users ─────────────────── + + test('DELETE removes user', async ({ request }) => { + const { id } = await createTestUser(request) + + const res = await request.delete('/api/auth/users', { + headers: API_KEY_HEADER, + data: { id }, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.success).toBe(true) + }) + + test('DELETE returns 404 for missing user', async ({ request }) => { + const res = await request.delete('/api/auth/users', { + headers: API_KEY_HEADER, + data: { id: 999999 }, + }) + expect(res.status()).toBe(404) + }) +}) diff --git a/tests/webhooks-crud.spec.ts b/tests/webhooks-crud.spec.ts new file mode 100644 index 0000000..956d192 --- /dev/null +++ b/tests/webhooks-crud.spec.ts @@ -0,0 +1,142 @@ +import { test, expect } from '@playwright/test' +import { API_KEY_HEADER, createTestWebhook, deleteTestWebhook } from './helpers' + +test.describe('Webhooks CRUD', () => { + const cleanup: number[] = [] + + test.afterEach(async ({ request }) => { + for (const id of cleanup) { + await deleteTestWebhook(request, id).catch(() => {}) + } + cleanup.length = 0 + }) + + // ── POST /api/webhooks ─────────────────────── + + test('POST creates webhook with name and valid URL', async ({ request }) => { + const { id, res, body } = await createTestWebhook(request) + cleanup.push(id) + + expect(res.status()).toBe(200) // webhook POST returns 200, not 201 + expect(body.id).toBeDefined() + expect(body.name).toContain('e2e-webhook-') + expect(body.secret).toBeDefined() + expect(body.secret.length).toBeGreaterThan(10) // full secret shown on creation + expect(body.enabled).toBe(true) + }) + + test('POST rejects invalid URL', async ({ request }) => { + const res = await request.post('/api/webhooks', { + headers: API_KEY_HEADER, + data: { name: 'bad-url-hook', url: 'not-a-url' }, + }) + expect(res.status()).toBe(400) + }) + + test('POST rejects missing name', async ({ request }) => { + const res = await request.post('/api/webhooks', { + headers: API_KEY_HEADER, + data: { url: 'https://example.com/hook' }, + }) + expect(res.status()).toBe(400) + }) + + // ── GET /api/webhooks ──────────────────────── + + test('GET returns webhooks with masked secrets', async ({ request }) => { + const { id } = await createTestWebhook(request) + cleanup.push(id) + + const res = await request.get('/api/webhooks', { headers: API_KEY_HEADER }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.webhooks).toBeDefined() + expect(Array.isArray(body.webhooks)).toBe(true) + + // Secrets should be masked in list response + const found = body.webhooks.find((w: any) => w.id === id) + expect(found).toBeDefined() + expect(found.secret).toContain('••••••') + }) + + // ── PUT /api/webhooks ──────────────────────── + + test('PUT updates webhook name', async ({ request }) => { + const { id } = await createTestWebhook(request) + cleanup.push(id) + + const res = await request.put('/api/webhooks', { + headers: API_KEY_HEADER, + data: { id, name: 'updated-hook' }, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.success).toBe(true) + }) + + test('PUT regenerates secret', async ({ request }) => { + const { id } = await createTestWebhook(request) + cleanup.push(id) + + const res = await request.put('/api/webhooks', { + headers: API_KEY_HEADER, + data: { id, regenerate_secret: true }, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.secret).toBeDefined() + expect(body.secret.length).toBeGreaterThan(10) + }) + + test('PUT returns 404 for missing webhook', async ({ request }) => { + const res = await request.put('/api/webhooks', { + headers: API_KEY_HEADER, + data: { id: 999999, name: 'nope' }, + }) + expect(res.status()).toBe(404) + }) + + // ── DELETE /api/webhooks ───────────────────── + + test('DELETE removes webhook', async ({ request }) => { + const { id } = await createTestWebhook(request) + + const res = await request.delete('/api/webhooks', { + headers: API_KEY_HEADER, + data: { id }, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.success).toBe(true) + }) + + // ── Full lifecycle ─────────────────────────── + + test('full lifecycle: create → read (masked) → update → delete', async ({ request }) => { + // Create + const { id, body: createBody } = await createTestWebhook(request) + const fullSecret = createBody.secret + expect(fullSecret.length).toBeGreaterThan(10) + + // Read (secret should be masked) + const listRes = await request.get('/api/webhooks', { headers: API_KEY_HEADER }) + const listBody = await listRes.json() + const found = listBody.webhooks.find((w: any) => w.id === id) + expect(found.secret).toContain('••••••') + expect(found.secret).not.toBe(fullSecret) + + // Update + const updateRes = await request.put('/api/webhooks', { + headers: API_KEY_HEADER, + data: { id, name: 'lifecycle-hook' }, + }) + expect(updateRes.status()).toBe(200) + + // Delete + const deleteRes = await request.delete('/api/webhooks', { + headers: API_KEY_HEADER, + data: { id }, + }) + expect(deleteRes.status()).toBe(200) + }) +}) diff --git a/tests/workflows-crud.spec.ts b/tests/workflows-crud.spec.ts new file mode 100644 index 0000000..25d6d21 --- /dev/null +++ b/tests/workflows-crud.spec.ts @@ -0,0 +1,120 @@ +import { test, expect } from '@playwright/test' +import { API_KEY_HEADER, createTestWorkflow, deleteTestWorkflow } from './helpers' + +test.describe('Workflows CRUD', () => { + const cleanup: number[] = [] + + test.afterEach(async ({ request }) => { + for (const id of cleanup) { + await deleteTestWorkflow(request, id).catch(() => {}) + } + cleanup.length = 0 + }) + + // ── POST /api/workflows ────────────────────── + + test('POST creates workflow template', async ({ request }) => { + const { id, res, body } = await createTestWorkflow(request) + cleanup.push(id) + + expect(res.status()).toBe(201) + expect(body.template).toBeDefined() + expect(body.template.name).toContain('e2e-wf-') + expect(body.template.task_prompt).toBe('Test prompt for e2e') + expect(body.template.model).toBe('sonnet') + }) + + test('POST rejects missing name', async ({ request }) => { + const res = await request.post('/api/workflows', { + headers: API_KEY_HEADER, + data: { task_prompt: 'prompt only' }, + }) + expect(res.status()).toBe(400) + }) + + test('POST rejects missing task_prompt', async ({ request }) => { + const res = await request.post('/api/workflows', { + headers: API_KEY_HEADER, + data: { name: 'name only' }, + }) + expect(res.status()).toBe(400) + }) + + // ── GET /api/workflows ─────────────────────── + + test('GET returns templates array', async ({ request }) => { + const { id } = await createTestWorkflow(request) + cleanup.push(id) + + const res = await request.get('/api/workflows', { headers: API_KEY_HEADER }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.templates).toBeDefined() + expect(Array.isArray(body.templates)).toBe(true) + }) + + // ── PUT /api/workflows ─────────────────────── + + test('PUT updates template fields', async ({ request }) => { + const { id } = await createTestWorkflow(request) + cleanup.push(id) + + const res = await request.put('/api/workflows', { + headers: API_KEY_HEADER, + data: { id, name: 'updated-wf-name', description: 'Updated desc' }, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.template.name).toBe('updated-wf-name') + expect(body.template.description).toBe('Updated desc') + }) + + test('PUT returns 404 for missing template', async ({ request }) => { + const res = await request.put('/api/workflows', { + headers: API_KEY_HEADER, + data: { id: 999999, name: 'nope' }, + }) + expect(res.status()).toBe(404) + }) + + // ── DELETE /api/workflows ──────────────────── + + test('DELETE removes template', async ({ request }) => { + const { id } = await createTestWorkflow(request) + + const res = await request.delete('/api/workflows', { + headers: API_KEY_HEADER, + data: { id }, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.success).toBe(true) + }) + + // ── Full lifecycle ─────────────────────────── + + test('full lifecycle: create → list → update → delete', async ({ request }) => { + // Create + const { id, name, res: createRes } = await createTestWorkflow(request) + expect(createRes.status()).toBe(201) + + // List + const listRes = await request.get('/api/workflows', { headers: API_KEY_HEADER }) + const listBody = await listRes.json() + expect(listBody.templates.some((t: any) => t.id === id)).toBe(true) + + // Update + const updateRes = await request.put('/api/workflows', { + headers: API_KEY_HEADER, + data: { id, description: 'lifecycle update' }, + }) + expect(updateRes.status()).toBe(200) + + // Delete + const deleteRes = await request.delete('/api/workflows', { + headers: API_KEY_HEADER, + data: { id }, + }) + expect(deleteRes.status()).toBe(200) + }) +}) From a2846357ac8d8f7e5fcaa2467e0f1f4264dbfe98 Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Mon, 2 Mar 2026 02:21:45 +0700 Subject: [PATCH 2/2] docs: add per-agent cost breakdowns to roadmap User feedback: per-agent cost visibility is a top priority for operators running their own agent orchestration setups. Currently derivable from per-session data but not yet a dedicated panel. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 2f69ed7..b369888 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,7 @@ See [open issues](https://github.com/builderz-labs/mission-control/issues) for p - [ ] Agent-agnostic gateway support — connect any orchestration framework (OpenClaw, ZeroClaw, OpenFang, NeoBot, IronClaw, etc.), not just OpenClaw - [ ] Direct CLI integration — connect tools like Codex, Claude Code, or custom CLIs directly without requiring a gateway - [ ] Native macOS app (Electron or Tauri) +- [ ] First-class per-agent cost breakdowns — dedicated panel with per-agent token usage and spend (currently derivable from per-session data) - [ ] OpenAPI / Swagger documentation - [ ] Webhook retry with exponential backoff - [ ] OAuth approval UI improvements