diff --git a/server/src/routes/docs.test.ts b/server/src/routes/docs.test.ts new file mode 100644 index 0000000..15c296f --- /dev/null +++ b/server/src/routes/docs.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest' +import { Hono } from 'hono' +import { docsRouter } from './docs.js' + +const app = new Hono() +app.route('/api/docs', docsRouter) + +describe('docs webhook tester', () => { + it('rejects invalid challenge type', async () => { + const res = await app.request('/api/docs/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: 'https://example.com/webhook', type: 'invalid_type_xss' }), + }) + expect(res.status).toBe(400) + const json = await res.json() as { error: string } + expect(json.error).toContain('Invalid') + }) + + it('accepts valid challenge type', async () => { + // This will fail to actually reach the webhook but should pass validation + const res = await app.request('/api/docs/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: 'https://example.com/webhook', type: 'speed_blitz' }), + }) + // Should either succeed or fail at the fetch step — not at validation + // Status could be 200 (webhook responded) or 500 (fetch failed) + expect(res.status).not.toBe(400) + }) + + it('rejects missing URL', async () => { + const res = await app.request('/api/docs/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'speed_blitz' }), + }) + expect(res.status).toBe(400) + }) +}) diff --git a/server/src/routes/docs.ts b/server/src/routes/docs.ts index 75e9807..90b3a1f 100644 --- a/server/src/routes/docs.ts +++ b/server/src/routes/docs.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono' +import { z } from 'zod' import { getAllChallengeTypes } from '../engine/challenges.js' - export const docsRouter = new Hono() docsRouter.get('/webhook', (c) => { @@ -166,23 +166,28 @@ docsRouter.get('/webhook', (c) => { // POST /test — interactive webhook tester (no auth required) docsRouter.post('/test', async (c) => { const body = await c.req.json() - const { url, type } = body as { url?: string; type?: string } - - if (!url || typeof url !== 'string') { - return c.json({ error: 'Missing "url" field' }, 400) + const validTypes = getAllChallengeTypes() + const testSchema = z.object({ + url: z.string().url(), + type: z.enum(validTypes as [string, ...string[]]).optional(), + }) + const parsed = testSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: parsed.error.issues[0]?.message || 'Invalid request' }, 400) } + const { url, type } = parsed.data - // Basic URL validation — must be http(s), no private IPs - let parsed: URL + // Validate URL protocol and host + let parsedUrl: URL try { - parsed = new URL(url) + parsedUrl = new URL(url) } catch { return c.json({ error: 'Invalid URL' }, 400) } - if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') { return c.json({ error: 'URL must use http:// or https://' }, 400) } - const host = parsed.hostname + const host = parsedUrl.hostname if (host === 'localhost' || host === '127.0.0.1' || host.startsWith('192.168.') || host.startsWith('10.') || host.endsWith('.local')) { return c.json({ error: 'Cannot test private/local URLs' }, 400) } @@ -197,7 +202,7 @@ docsRouter.post('/test', async (c) => { } const selectedType = (type && challengeTypes[type]) ? type : 'speed_blitz' - const ct = challengeTypes[selectedType] + const ct = challengeTypes[selectedType] || challengeTypes['speed_blitz'] const payload = { fight_id: 'test_000000',