diff --git a/dashboards/tracker-web/src/__tests__/webhooks-proxy.test.ts b/dashboards/tracker-web/src/__tests__/webhooks-proxy.test.ts new file mode 100644 index 00000000..d38ac97a --- /dev/null +++ b/dashboards/tracker-web/src/__tests__/webhooks-proxy.test.ts @@ -0,0 +1,78 @@ +/** + * Tests for /api/webhooks/[...path] — external integration proxy. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { NextRequest } from 'next/server'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +import { POST } from '@/app/api/webhooks/[...path]/route'; + +function mockNextRequest(body?: string, headers?: Record): NextRequest { + const headerMap = new Map(Object.entries(headers || {})); + return { + method: 'POST', + headers: { + get: (key: string) => headerMap.get(key.toLowerCase()) || headerMap.get(key) || null, + }, + nextUrl: { + searchParams: new URLSearchParams('source=gitea'), + }, + text: async () => body || '', + } as unknown as NextRequest; +} + +describe('webhook proxy', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.clearAllMocks(); + process.env.PLATFORM_API_URL = 'http://localhost:4003'; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('proxies external webhook payloads and signature headers', async () => { + mockFetch.mockResolvedValue({ + status: 202, + text: async () => JSON.stringify({ accepted: true }), + }); + const body = JSON.stringify({ action: 'closed', pull_request: { number: 12 } }); + const req = mockNextRequest(body, { + 'x-hub-signature-256': 'sha256=test', + 'x-gitea-event': 'pull_request', + 'x-product-id': 'tracker', + }); + + const res = await POST(req, { params: Promise.resolve({ path: ['gitea'] }) }); + + expect(res.status).toBe(202); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/api/webhooks/gitea?source=gitea'), + expect.objectContaining({ + method: 'POST', + body, + headers: expect.objectContaining({ + 'x-hub-signature-256': 'sha256=test', + 'x-gitea-event': 'pull_request', + 'x-product-id': 'tracker', + }), + }) + ); + }); + + it('returns 502 when webhook ingestion is unavailable', async () => { + mockFetch.mockRejectedValue(new Error('ECONNREFUSED')); + + const res = await POST(mockNextRequest('{}'), { + params: Promise.resolve({ path: ['github'] }), + }); + + expect(res.status).toBe(502); + await expect(res.json()).resolves.toEqual({ error: 'Webhook ingestion unavailable' }); + }); +}); diff --git a/dashboards/tracker-web/src/app/api/webhooks/[...path]/route.ts b/dashboards/tracker-web/src/app/api/webhooks/[...path]/route.ts new file mode 100644 index 00000000..1ef1a4d3 --- /dev/null +++ b/dashboards/tracker-web/src/app/api/webhooks/[...path]/route.ts @@ -0,0 +1,59 @@ +/** + * External webhook ingestion proxy. + * + * Keeps tracker-web's public webhook URLs stable while platform-service handles + * signature validation, event parsing, item/PR linking, and persistence. + */ + +import { NextRequest, NextResponse } from 'next/server'; + +const PLATFORM_API = process.env.PLATFORM_API_URL || 'http://localhost:4003'; +const FORWARDED_HEADERS = [ + 'authorization', + 'content-type', + 'x-product-id', + 'x-hub-signature', + 'x-hub-signature-256', + 'x-github-event', + 'x-gitea-event', + 'x-gitlab-event', + 'x-webhook-signature', + 'x-webhook-timestamp', +]; + +async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { + const { path } = await params; + const url = new URL(`/api/webhooks/${path.join('/')}`, PLATFORM_API); + + req.nextUrl.searchParams.forEach((value, key) => { + url.searchParams.set(key, value); + }); + + try { + const headers: Record = {}; + for (const key of FORWARDED_HEADERS) { + const value = req.headers.get(key); + if (value) headers[key] = value; + } + if (!headers['content-type']) headers['content-type'] = 'application/json'; + + const body = await req.text(); + const res = await fetch(url.toString(), { + method: req.method, + headers, + body, + }); + const data = await res.text(); + + return new NextResponse(data, { + status: res.status, + headers: { 'Content-Type': res.headers?.get('content-type') || 'application/json' }, + }); + } catch { + return NextResponse.json({ error: 'Webhook ingestion unavailable' }, { status: 502 }); + } +} + +export const POST = proxy; +export const PUT = proxy; +export const PATCH = proxy;