Spaces:
Runtime error
Runtime error
Nyk commited on
Commit ·
e1e5820
1
Parent(s): 32248ce
test: add 52 Playwright E2E tests covering all critical fixes
Browse files8 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 +7 -0
- .gitignore +4 -0
- playwright.config.ts +8 -2
- src/lib/migrations.ts +27 -8
- tests/auth-guards.spec.ts +45 -0
- tests/csrf-validation.spec.ts +52 -0
- tests/delete-body.spec.ts +82 -0
- tests/legacy-cookie-removed.spec.ts +32 -0
- tests/limit-caps.spec.ts +54 -0
- tests/login-flow.spec.ts +59 -0
- tests/rate-limiting.spec.ts +34 -0
- tests/timing-safe-auth.spec.ts +34 -0
.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:
|
| 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 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
})
|