feat(tracker-web): expose webhook ingestion proxy
This commit is contained in:
parent
6e023f3bdc
commit
359d99b67f
78
dashboards/tracker-web/src/__tests__/webhooks-proxy.test.ts
Normal file
78
dashboards/tracker-web/src/__tests__/webhooks-proxy.test.ts
Normal file
@ -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<string, string>): 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' });
|
||||
});
|
||||
});
|
||||
@ -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<string, string> = {};
|
||||
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;
|
||||
Loading…
Reference in New Issue
Block a user