Nyk commited on
Commit
e1e5820
·
1 Parent(s): 32248ce

test: add 52 Playwright E2E tests covering all critical fixes

Browse files

8 test suites verifying:
- Auth guards on 19 GET endpoints (Issue #4)
- Timing-safe API key comparison (Issue #5)
- Legacy cookie auth removal (Issue #7)
- Login rate limiting (Issue #8)
- CSRF Origin header validation (Issue #20)
- DELETE body standardization (Issue #18)
- Query limit caps at 200 (Issue #19)
- Login flow and session lifecycle

Also fixes migration 013 crash on fresh DB when gateways table
doesn't exist (created lazily by gateways API, not in migrations).

.env.test ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ AUTH_USER=testadmin
2
+ AUTH_PASS=testpass123
3
+ API_KEY=test-api-key-e2e-12345
4
+ AUTH_SECRET=test-legacy-secret
5
+ MC_ALLOW_ANY_HOST=1
6
+ MC_COOKIE_SECURE=
7
+ MC_COOKIE_SAMESITE=lax
.gitignore CHANGED
@@ -29,6 +29,10 @@ next-env.d.ts
29
  .data/
30
  aegis/
31
 
 
 
 
 
32
  # Claude Code context files
33
  CLAUDE.md
34
  **/CLAUDE.md
 
29
  .data/
30
  aegis/
31
 
32
+ # Playwright
33
+ test-results/
34
+ playwright-report/
35
+
36
  # Claude Code context files
37
  CLAUDE.md
38
  **/CLAUDE.md
playwright.config.ts CHANGED
@@ -9,10 +9,16 @@ export default defineConfig({
9
  fullyParallel: true,
10
  reporter: [['list']],
11
  use: {
12
- baseURL: process.env.E2E_BASE_URL || 'http://127.0.0.1:3000',
13
  trace: 'retain-on-failure'
14
  },
15
  projects: [
16
  { name: 'chromium', use: { ...devices['Desktop Chrome'] } }
17
- ]
 
 
 
 
 
 
18
  })
 
9
  fullyParallel: true,
10
  reporter: [['list']],
11
  use: {
12
+ baseURL: process.env.E2E_BASE_URL || 'http://127.0.0.1:3005',
13
  trace: 'retain-on-failure'
14
  },
15
  projects: [
16
  { name: 'chromium', use: { ...devices['Desktop Chrome'] } }
17
+ ],
18
+ webServer: {
19
+ command: 'pnpm start',
20
+ url: 'http://127.0.0.1:3005',
21
+ reuseExistingServer: true,
22
+ timeout: 30_000,
23
+ }
24
  })
src/lib/migrations.ts CHANGED
@@ -338,6 +338,12 @@ const migrations: Migration[] = [
338
  {
339
  id: '013_tenant_owner_gateway',
340
  up: (db) => {
 
 
 
 
 
 
341
  const columns = db.prepare(`PRAGMA table_info(tenants)`).all() as Array<{ name: string }>
342
  const hasOwnerGateway = columns.some((c) => c.name === 'owner_gateway')
343
  if (!hasOwnerGateway) {
@@ -348,14 +354,27 @@ const migrations: Migration[] = [
348
  String(process.env.MC_DEFAULT_OWNER_GATEWAY || process.env.MC_DEFAULT_GATEWAY_NAME || 'primary').trim() ||
349
  'primary'
350
 
351
- db.prepare(`
352
- UPDATE tenants
353
- SET owner_gateway = COALESCE(
354
- (SELECT name FROM gateways ORDER BY is_primary DESC, id ASC LIMIT 1),
355
- ?
356
- )
357
- WHERE owner_gateway IS NULL OR trim(owner_gateway) = ''
358
- `).run(defaultGatewayName)
 
 
 
 
 
 
 
 
 
 
 
 
 
359
 
360
  db.exec(`CREATE INDEX IF NOT EXISTS idx_tenants_owner_gateway ON tenants(owner_gateway)`)
361
  }
 
338
  {
339
  id: '013_tenant_owner_gateway',
340
  up: (db) => {
341
+ // Check if tenants table exists (may not on fresh installs without super-admin)
342
+ const hasTenants = (db.prepare(
343
+ `SELECT name FROM sqlite_master WHERE type='table' AND name='tenants'`
344
+ ).get() as any)
345
+ if (!hasTenants) return
346
+
347
  const columns = db.prepare(`PRAGMA table_info(tenants)`).all() as Array<{ name: string }>
348
  const hasOwnerGateway = columns.some((c) => c.name === 'owner_gateway')
349
  if (!hasOwnerGateway) {
 
354
  String(process.env.MC_DEFAULT_OWNER_GATEWAY || process.env.MC_DEFAULT_GATEWAY_NAME || 'primary').trim() ||
355
  'primary'
356
 
357
+ // Check if gateways table exists (created lazily by gateways API, not in migrations)
358
+ const hasGateways = (db.prepare(
359
+ `SELECT name FROM sqlite_master WHERE type='table' AND name='gateways'`
360
+ ).get() as any)
361
+
362
+ if (hasGateways) {
363
+ db.prepare(`
364
+ UPDATE tenants
365
+ SET owner_gateway = COALESCE(
366
+ (SELECT name FROM gateways ORDER BY is_primary DESC, id ASC LIMIT 1),
367
+ ?
368
+ )
369
+ WHERE owner_gateway IS NULL OR trim(owner_gateway) = ''
370
+ `).run(defaultGatewayName)
371
+ } else {
372
+ db.prepare(`
373
+ UPDATE tenants
374
+ SET owner_gateway = ?
375
+ WHERE owner_gateway IS NULL OR trim(owner_gateway) = ''
376
+ `).run(defaultGatewayName)
377
+ }
378
 
379
  db.exec(`CREATE INDEX IF NOT EXISTS idx_tenants_owner_gateway ON tenants(owner_gateway)`)
380
  }
tests/auth-guards.spec.ts ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test, expect } from '@playwright/test'
2
+
3
+ /**
4
+ * E2E tests for Issue #4 — Auth guards on GET endpoints
5
+ * Verifies that unauthenticated requests to API endpoints are rejected.
6
+ */
7
+
8
+ const PROTECTED_GET_ENDPOINTS = [
9
+ '/api/agents',
10
+ '/api/tasks',
11
+ '/api/activities',
12
+ '/api/notifications?recipient=test',
13
+ '/api/status',
14
+ '/api/logs',
15
+ '/api/chat/conversations',
16
+ '/api/chat/messages',
17
+ '/api/standup',
18
+ '/api/spawn',
19
+ '/api/pipelines',
20
+ '/api/pipelines/run',
21
+ '/api/webhooks',
22
+ '/api/webhooks/deliveries',
23
+ '/api/workflows',
24
+ '/api/settings',
25
+ '/api/tokens',
26
+ '/api/search?q=test',
27
+ '/api/audit',
28
+ ]
29
+
30
+ test.describe('Auth Guards (Issue #4)', () => {
31
+ for (const endpoint of PROTECTED_GET_ENDPOINTS) {
32
+ test(`GET ${endpoint} returns 401 without auth`, async ({ request }) => {
33
+ const res = await request.get(endpoint)
34
+ expect(res.status()).toBe(401)
35
+ })
36
+ }
37
+
38
+ test('GET endpoint returns 200 with valid API key', async ({ request }) => {
39
+ const res = await request.get('/api/agents', {
40
+ headers: { 'x-api-key': 'test-api-key-e2e-12345' }
41
+ })
42
+ // Should be 200 (or possibly 500 if no gateway configured, but NOT 401)
43
+ expect(res.status()).not.toBe(401)
44
+ })
45
+ })
tests/csrf-validation.spec.ts ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test, expect } from '@playwright/test'
2
+
3
+ /**
4
+ * E2E tests for Issue #20 — CSRF Origin header validation
5
+ * Verifies that mutating requests with mismatched Origin are rejected.
6
+ */
7
+
8
+ test.describe('CSRF Origin Validation (Issue #20)', () => {
9
+ test('POST with mismatched Origin is rejected', async ({ request }) => {
10
+ const res = await request.post('/api/auth/login', {
11
+ data: { username: 'test', password: 'test' },
12
+ headers: {
13
+ 'origin': 'https://evil.example.com',
14
+ 'host': '127.0.0.1:3005'
15
+ }
16
+ })
17
+ expect(res.status()).toBe(403)
18
+ const body = await res.json()
19
+ expect(body.error).toContain('CSRF')
20
+ })
21
+
22
+ test('POST with matching Origin is allowed', async ({ request }) => {
23
+ const res = await request.post('/api/auth/login', {
24
+ data: { username: 'testadmin', password: 'testpass123' },
25
+ headers: {
26
+ 'origin': 'http://127.0.0.1:3005',
27
+ 'host': '127.0.0.1:3005'
28
+ }
29
+ })
30
+ // Should not be 403 CSRF — may be 200 (success) or other status
31
+ expect(res.status()).not.toBe(403)
32
+ })
33
+
34
+ test('POST without Origin header is allowed (non-browser client)', async ({ request }) => {
35
+ const res = await request.post('/api/auth/login', {
36
+ data: { username: 'testadmin', password: 'testpass123' },
37
+ })
38
+ // No Origin = non-browser client, should be allowed through CSRF check
39
+ expect(res.status()).not.toBe(403)
40
+ })
41
+
42
+ test('GET requests are not subject to CSRF check', async ({ request }) => {
43
+ const res = await request.get('/api/agents', {
44
+ headers: {
45
+ 'origin': 'https://evil.example.com',
46
+ 'x-api-key': 'test-api-key-e2e-12345'
47
+ }
48
+ })
49
+ // GET is exempt from CSRF — should not be 403
50
+ expect(res.status()).not.toBe(403)
51
+ })
52
+ })
tests/delete-body.spec.ts ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test, expect } from '@playwright/test'
2
+
3
+ /**
4
+ * E2E tests for Issue #18 — DELETE handlers use request body
5
+ * Verifies that DELETE endpoints require JSON body instead of query params.
6
+ */
7
+
8
+ const API_KEY_HEADER = { 'x-api-key': 'test-api-key-e2e-12345' }
9
+
10
+ test.describe('DELETE Body Standardization (Issue #18)', () => {
11
+ test('DELETE /api/pipelines rejects without body', async ({ request }) => {
12
+ const res = await request.delete('/api/pipelines', {
13
+ headers: API_KEY_HEADER
14
+ })
15
+ const body = await res.json()
16
+ expect(body.error).toContain('body required')
17
+ expect(res.status()).toBe(400)
18
+ })
19
+
20
+ test('DELETE /api/pipelines accepts body with id', async ({ request }) => {
21
+ const res = await request.delete('/api/pipelines', {
22
+ headers: API_KEY_HEADER,
23
+ data: { id: '99999' }
24
+ })
25
+ // Should not be 400 "body required" — the body was provided
26
+ expect(res.status()).not.toBe(400)
27
+ })
28
+
29
+ test('DELETE /api/webhooks rejects without body', async ({ request }) => {
30
+ const res = await request.delete('/api/webhooks', {
31
+ headers: API_KEY_HEADER
32
+ })
33
+ const body = await res.json()
34
+ expect(body.error).toContain('body required')
35
+ expect(res.status()).toBe(400)
36
+ })
37
+
38
+ test('DELETE /api/settings rejects without body', async ({ request }) => {
39
+ const res = await request.delete('/api/settings', {
40
+ headers: API_KEY_HEADER
41
+ })
42
+ const body = await res.json()
43
+ expect(body.error).toContain('body required')
44
+ expect(res.status()).toBe(400)
45
+ })
46
+
47
+ test('DELETE /api/workflows rejects without body', async ({ request }) => {
48
+ const res = await request.delete('/api/workflows', {
49
+ headers: API_KEY_HEADER
50
+ })
51
+ const body = await res.json()
52
+ expect(body.error).toContain('body required')
53
+ expect(res.status()).toBe(400)
54
+ })
55
+
56
+ test('DELETE /api/backup rejects without body', async ({ request }) => {
57
+ const res = await request.delete('/api/backup', {
58
+ headers: API_KEY_HEADER
59
+ })
60
+ const body = await res.json()
61
+ expect(body.error).toContain('body required')
62
+ expect(res.status()).toBe(400)
63
+ })
64
+
65
+ test('DELETE /api/auth/users rejects without body', async ({ request }) => {
66
+ const res = await request.delete('/api/auth/users', {
67
+ headers: API_KEY_HEADER
68
+ })
69
+ const body = await res.json()
70
+ expect(body.error).toContain('body required')
71
+ })
72
+
73
+ test('old query param style no longer works for DELETE', async ({ request }) => {
74
+ // The old pattern: DELETE /api/pipelines?id=1
75
+ const res = await request.delete('/api/pipelines?id=1', {
76
+ headers: API_KEY_HEADER
77
+ })
78
+ // Without a JSON body, this should fail with "body required"
79
+ const body = await res.json()
80
+ expect(body.error).toContain('body required')
81
+ })
82
+ })
tests/legacy-cookie-removed.spec.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test, expect } from '@playwright/test'
2
+
3
+ /**
4
+ * E2E tests for Issue #7 — Legacy cookie auth removal
5
+ * Verifies that the old mission-control-auth cookie no longer authenticates.
6
+ */
7
+
8
+ test.describe('Legacy Cookie Auth Removed (Issue #7)', () => {
9
+ test('legacy cookie does not authenticate API requests', async ({ request }) => {
10
+ const res = await request.get('/api/agents', {
11
+ headers: {
12
+ 'cookie': 'mission-control-auth=test-legacy-secret'
13
+ }
14
+ })
15
+ expect(res.status()).toBe(401)
16
+ })
17
+
18
+ test('legacy cookie does not authenticate page requests', async ({ page }) => {
19
+ // Set the legacy cookie
20
+ await page.context().addCookies([{
21
+ name: 'mission-control-auth',
22
+ value: 'test-legacy-secret',
23
+ domain: '127.0.0.1',
24
+ path: '/',
25
+ }])
26
+
27
+ // Try to access the main page — should redirect to login
28
+ const response = await page.goto('/')
29
+ const url = page.url()
30
+ expect(url).toContain('/login')
31
+ })
32
+ })
tests/limit-caps.spec.ts ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test, expect } from '@playwright/test'
2
+
3
+ /**
4
+ * E2E tests for Issue #19 — Unbounded limit caps
5
+ * Verifies that endpoints cap limit to 200 even if client requests more.
6
+ */
7
+
8
+ const API_KEY_HEADER = { 'x-api-key': 'test-api-key-e2e-12345' }
9
+
10
+ // These endpoints accept a `limit` query param
11
+ const LIMIT_ENDPOINTS = [
12
+ '/api/agents',
13
+ '/api/tasks',
14
+ '/api/activities',
15
+ '/api/logs',
16
+ '/api/chat/conversations',
17
+ '/api/spawn',
18
+ ]
19
+
20
+ test.describe('Limit Caps (Issue #19)', () => {
21
+ for (const endpoint of LIMIT_ENDPOINTS) {
22
+ test(`${endpoint}?limit=9999 does not return more than 200 items`, async ({ request }) => {
23
+ const res = await request.get(`${endpoint}?limit=9999`, {
24
+ headers: API_KEY_HEADER
25
+ })
26
+ // Should succeed (not error out)
27
+ expect(res.status()).not.toBe(500)
28
+
29
+ // The response should be valid JSON
30
+ const body = await res.json()
31
+ expect(body).toBeDefined()
32
+
33
+ // If the response has an array at the top level or nested, check its length
34
+ // Different endpoints return arrays under different keys
35
+ const possibleArrayKeys = ['agents', 'tasks', 'activities', 'logs', 'conversations', 'history', 'data']
36
+ for (const key of possibleArrayKeys) {
37
+ if (Array.isArray(body[key])) {
38
+ expect(body[key].length).toBeLessThanOrEqual(200)
39
+ }
40
+ }
41
+ // Also check if body itself is an array
42
+ if (Array.isArray(body)) {
43
+ expect(body.length).toBeLessThanOrEqual(200)
44
+ }
45
+ })
46
+ }
47
+
48
+ test('search endpoint has its own cap of 100', async ({ request }) => {
49
+ const res = await request.get('/api/search?q=test&limit=9999', {
50
+ headers: API_KEY_HEADER
51
+ })
52
+ expect(res.status()).not.toBe(500)
53
+ })
54
+ })
tests/login-flow.spec.ts ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test, expect } from '@playwright/test'
2
+
3
+ /**
4
+ * E2E smoke test — Login flow and session auth
5
+ * Verifies the basic login/session/logout lifecycle works end-to-end.
6
+ */
7
+
8
+ test.describe('Login Flow', () => {
9
+ test('login page loads', async ({ page }) => {
10
+ await page.goto('/login')
11
+ await expect(page).toHaveURL(/\/login/)
12
+ })
13
+
14
+ test('unauthenticated access redirects to login', async ({ page }) => {
15
+ await page.goto('/')
16
+ await expect(page).toHaveURL(/\/login/)
17
+ })
18
+
19
+ test('login API returns session cookie on success', async ({ request }) => {
20
+ const res = await request.post('/api/auth/login', {
21
+ data: { username: 'testadmin', password: 'testpass123' }
22
+ })
23
+ expect(res.status()).toBe(200)
24
+
25
+ const cookies = res.headers()['set-cookie']
26
+ expect(cookies).toBeDefined()
27
+ expect(cookies).toContain('mc-session')
28
+ })
29
+
30
+ test('login API rejects wrong password', async ({ request }) => {
31
+ const res = await request.post('/api/auth/login', {
32
+ data: { username: 'testadmin', password: 'wrongpassword' },
33
+ headers: { 'x-forwarded-for': '10.77.77.77' }
34
+ })
35
+ expect(res.status()).toBe(401)
36
+ })
37
+
38
+ test('session cookie grants API access', async ({ request }) => {
39
+ // Login to get a session
40
+ const loginRes = await request.post('/api/auth/login', {
41
+ data: { username: 'testadmin', password: 'testpass123' }
42
+ })
43
+ expect(loginRes.status()).toBe(200)
44
+
45
+ // Extract session cookie from Set-Cookie header
46
+ const setCookie = loginRes.headers()['set-cookie'] || ''
47
+ const match = setCookie.match(/mc-session=([^;]+)/)
48
+ expect(match).toBeTruthy()
49
+ const sessionToken = match![1]
50
+
51
+ // Use the session cookie to access /api/auth/me
52
+ const meRes = await request.get('/api/auth/me', {
53
+ headers: { 'cookie': `mc-session=${sessionToken}` }
54
+ })
55
+ expect(meRes.status()).toBe(200)
56
+ const body = await meRes.json()
57
+ expect(body.user?.username).toBe('testadmin')
58
+ })
59
+ })
tests/rate-limiting.spec.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test, expect } from '@playwright/test'
2
+
3
+ /**
4
+ * E2E tests for Issue #8 — Login rate limiting
5
+ * Verifies that login endpoint rate-limits after 5 failed attempts.
6
+ */
7
+
8
+ test.describe('Login Rate Limiting (Issue #8)', () => {
9
+ test('blocks login after 5 rapid failed attempts', async ({ request }) => {
10
+ const results: number[] = []
11
+
12
+ // Send 7 rapid login attempts with wrong password
13
+ for (let i = 0; i < 7; i++) {
14
+ const res = await request.post('/api/auth/login', {
15
+ data: { username: 'testadmin', password: 'wrongpassword' },
16
+ headers: { 'x-forwarded-for': '10.99.99.99' }
17
+ })
18
+ results.push(res.status())
19
+ }
20
+
21
+ // First 5 should be 401 (wrong password), then 429 (rate limited)
22
+ const rateLimited = results.filter(s => s === 429)
23
+ expect(rateLimited.length).toBeGreaterThanOrEqual(1)
24
+ })
25
+
26
+ test('successful login is not blocked for fresh IP', async ({ request }) => {
27
+ const res = await request.post('/api/auth/login', {
28
+ data: { username: 'testadmin', password: 'testpass123' },
29
+ headers: { 'x-forwarded-for': '10.88.88.88' }
30
+ })
31
+ // Should succeed (200) or at least not be rate limited
32
+ expect(res.status()).not.toBe(429)
33
+ })
34
+ })
tests/timing-safe-auth.spec.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test, expect } from '@playwright/test'
2
+
3
+ /**
4
+ * E2E tests for Issue #5 — Timing-safe API key comparison
5
+ * Verifies that API key auth works correctly after safeCompare migration.
6
+ */
7
+
8
+ test.describe('Timing-Safe Auth (Issue #5)', () => {
9
+ test('valid API key authenticates successfully', async ({ request }) => {
10
+ const res = await request.get('/api/status', {
11
+ headers: { 'x-api-key': 'test-api-key-e2e-12345' }
12
+ })
13
+ expect(res.status()).not.toBe(401)
14
+ })
15
+
16
+ test('wrong API key is rejected', async ({ request }) => {
17
+ const res = await request.get('/api/status', {
18
+ headers: { 'x-api-key': 'wrong-key' }
19
+ })
20
+ expect(res.status()).toBe(401)
21
+ })
22
+
23
+ test('empty API key is rejected', async ({ request }) => {
24
+ const res = await request.get('/api/status', {
25
+ headers: { 'x-api-key': '' }
26
+ })
27
+ expect(res.status()).toBe(401)
28
+ })
29
+
30
+ test('no auth header is rejected', async ({ request }) => {
31
+ const res = await request.get('/api/status')
32
+ expect(res.status()).toBe(401)
33
+ })
34
+ })