Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 89 additions & 23 deletions frontend/src/__tests__/api/privileged-session-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,19 +112,79 @@ describe('privileged frontend session forwarding', () => {
assertOpaqueSessionOnly(fetchMock.mock.calls[0][1]);
});

it('copies the upstream x-request-id onto review queue error responses', async () => {
const requestId = crypto.randomUUID();
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ detail: 'boom' }), {
status: 500,
headers: { 'content-type': 'application/json', 'x-request-id': requestId },
}));

const response = await getReviewItems(
browserRequest('https://app.regengine.test/api/review/items'),
);

expect(response.status).toBe(500);
expect(response.headers.get('x-request-id')).toBe(requestId);
expect(await response.json()).toEqual({ error: 'boom' });
});

it('copies the upstream x-request-id onto transformed review queue success responses', async () => {
const requestId = crypto.randomUUID();
const reviewId = crypto.randomUUID();
const docHash = crypto.randomUUID().replace(/-/g, '');
const confidenceScore = Math.random();
const createdAt = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const updatedAt = new Date(Date.now()).toISOString();
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
items: [{
review_id: reviewId,
doc_hash: docHash,
confidence_score: confidenceScore,
created_at: createdAt,
updated_at: updatedAt,
status: 'PENDING',
tenant_id: 'tenant-123',
text_preview: 'preview text',
extraction: { lot: 'L1' },
}],
}), {
status: 200,
headers: { 'content-type': 'application/json', 'x-request-id': requestId },
}));

const response = await getReviewItems(
browserRequest('https://app.regengine.test/api/review/items'),
);

expect(response.status).toBe(200);
expect(response.headers.get('x-request-id')).toBe(requestId);
expect(await response.json()).toEqual([{
id: reviewId,
doc_hash: docHash,
confidence_score: confidenceScore,
created_at: createdAt,
updated_at: updatedAt,
status: 'PENDING',
tenant_id: 'tenant-123',
source_text: 'preview text',
extracted_data: { lot: 'L1' },
}]);
});

it('sends no reviewer identity or retired credential on a decision', async () => {
const reviewId = crypto.randomUUID();
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
status: 'approved',
review_id: '6ba7b810-9dad-41d1-a0b4-00c04fd430c8',
review_id: reviewId,
updated: true,
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));

const response = await approveReviewItem(
browserRequest('https://app.regengine.test/api/review/6ba7b810-9dad-41d1-a0b4-00c04fd430c8/approve', 'POST'),
{ params: Promise.resolve({ id: '6ba7b810-9dad-41d1-a0b4-00c04fd430c8' }) },
browserRequest(`https://app.regengine.test/api/review/${reviewId}/approve`, 'POST'),
{ params: Promise.resolve({ id: reviewId }) },
);

expect(response.status).toBe(200);
Expand Down Expand Up @@ -199,23 +259,26 @@ describe('privileged frontend session forwarding', () => {
),
{ params: Promise.resolve({ path: ['flagged-extractions'] }) },
)],
['review reject', async () => rejectReviewItem(
browserRequest(
'https://app.regengine.test/api/review/6ba7b810-9dad-41d1-a0b4-00c04fd430c8/reject',
'POST',
{
extraHeaders: {
'x-vercel-forwarded-for': '2001:0db8:0:0:0:0:0:1',
'x-forwarded-for': '192.0.2.10',
'x-real-ip': '192.0.2.11',
'x-regengine-proxy-client-ip': '192.0.2.12',
'x-regengine-proxy-timestamp': '1',
'x-regengine-proxy-signature': 'v1=attacker',
['review reject', (() => {
const reviewId = crypto.randomUUID();
return async () => rejectReviewItem(
browserRequest(
`https://app.regengine.test/api/review/${reviewId}/reject`,
'POST',
{
extraHeaders: {
'x-vercel-forwarded-for': '2001:0db8:0:0:0:0:0:1',
'x-forwarded-for': '192.0.2.10',
'x-real-ip': '192.0.2.11',
'x-regengine-proxy-client-ip': '192.0.2.12',
'x-regengine-proxy-timestamp': '1',
'x-regengine-proxy-signature': 'v1=attacker',
},
},
},
),
{ params: Promise.resolve({ id: '6ba7b810-9dad-41d1-a0b4-00c04fd430c8' }) },
)],
),
{ params: Promise.resolve({ id: reviewId }) },
);
})()],
])('attests the platform client IP on the %s privileged proxy', async (_label, invoke) => {
vi.stubEnv('VERCEL', '1');
vi.stubEnv('VERCEL_ENV', 'production');
Expand Down Expand Up @@ -252,10 +315,13 @@ describe('privileged frontend session forwarding', () => {
browserRequest('https://app.regengine.test/api/review/flagged-extractions'),
{ params: Promise.resolve({ path: ['flagged-extractions'] }) },
)],
['review reject', async () => rejectReviewItem(
browserRequest('https://app.regengine.test/api/review/6ba7b810-9dad-41d1-a0b4-00c04fd430c8/reject', 'POST'),
{ params: Promise.resolve({ id: '6ba7b810-9dad-41d1-a0b4-00c04fd430c8' }) },
)],
['review reject', (() => {
const reviewId = crypto.randomUUID();
return async () => rejectReviewItem(
browserRequest(`https://app.regengine.test/api/review/${reviewId}/reject`, 'POST'),
{ params: Promise.resolve({ id: reviewId }) },
);
})()],
])('fails the %s privileged proxy closed when Vercel omits the platform IP', async (_label, invoke) => {
vi.stubEnv('VERCEL', '1');
vi.stubEnv('VERCEL_ENV', 'production');
Expand Down
165 changes: 165 additions & 0 deletions frontend/src/__tests__/api/proxy-sanitization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,171 @@ describe('proxy 5xx error sanitization', () => {
});
});

describe('proxy x-request-id passthrough', () => {
it('passes the upstream x-request-id through on stream proxy success', async () => {
const requestId = crypto.randomUUID();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json', 'x-request-id': requestId },
})));
const handlers = createStreamProxy({
serviceName: 'ingestion',
resolveTargetBases: () => ['http://internal.service.local'],
buildHeaders: () => new Headers(),
methods: ['GET'],
});

const response = await handlers.GET!(
makeRouteRequest(),
{ params: Promise.resolve({ path: ['jobs'] }) },
);

expect(response.status).toBe(200);
expect(response.headers.get('x-request-id')).toBe(requestId);
});

it('preserves the upstream x-request-id on rebuilt stream proxy 5xx responses', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const requestId = crypto.randomUUID();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('boom', {
status: 500,
headers: { 'content-type': 'text/plain', 'x-request-id': requestId },
})));
const handlers = createStreamProxy({
serviceName: 'admin',
resolveTargetBases: () => ['http://internal.service.local'],
buildHeaders: () => new Headers(),
methods: ['GET'],
});

const response = await handlers.GET!(
makeRouteRequest(),
{ params: Promise.resolve({ path: ['users'] }) },
);

expect(response.status).toBe(500);
expect(response.headers.get('x-request-id')).toBe(requestId);
expect(errorSpy).toHaveBeenCalled();
});

it('preserves the upstream x-request-id on rebuilt JSON proxy 5xx responses', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const requestId = crypto.randomUUID();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
detail: 'boom',
}), {
status: 503,
headers: { 'content-type': 'application/json', 'x-request-id': requestId },
})));
const handlers = createJsonProxy({
serviceName: 'compliance',
buildTargetUrl: () => 'http://internal.service.local/compliance/check',
buildHeaders: () => new Headers(),
methods: ['GET'],
});

const response = await handlers.GET!(
makeRouteRequest(),
{ params: Promise.resolve({ path: ['check'] }) },
);

expect(response.status).toBe(503);
expect(response.headers.get('x-request-id')).toBe(requestId);
const body = await responseJson(response);
expect(body).toEqual({ error: 'Service temporarily unavailable' });
expect(errorSpy).toHaveBeenCalled();
});

it.each([400, 422])('preserves the upstream x-request-id on rebuilt JSON proxy %i responses', async (status) => {
const requestId = crypto.randomUUID();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
detail: 'invalid payload',
}), {
status,
headers: { 'content-type': 'application/json', 'x-request-id': requestId },
})));
const handlers = createJsonProxy({
serviceName: 'compliance',
buildTargetUrl: () => 'http://internal.service.local/compliance/check',
buildHeaders: () => new Headers(),
methods: ['POST'],
});

const response = await handlers.POST!(
makeRouteRequest({ jsonBody: { bad: true } }),
{ params: Promise.resolve({ path: ['check'] }) },
);

expect(response.status).toBe(status);
expect(response.headers.get('x-request-id')).toBe(requestId);
const body = await responseJson(response);
expect(body).toEqual({ error: 'invalid payload' });
});

it('passes the upstream Retry-After through on stream proxy 429 responses', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
detail: 'rate limited',
}), {
status: 429,
headers: { 'content-type': 'application/json', 'retry-after': '45' },
})));
const handlers = createStreamProxy({
serviceName: 'ingestion',
resolveTargetBases: () => ['http://internal.service.local'],
buildHeaders: () => new Headers(),
methods: ['GET'],
});

const response = await handlers.GET!(
makeRouteRequest(),
{ params: Promise.resolve({ path: ['api/v1/records'] }) },
);

expect(response.status).toBe(429);
expect(response.headers.get('retry-after')).toBe('45');
});

it('preserves Retry-After and x-request-id on rebuilt JSON proxy 429 responses', async () => {
const requestId = crypto.randomUUID();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
detail: 'rate limited',
}), {
status: 429,
headers: {
'content-type': 'application/json',
'retry-after': '30',
'x-request-id': requestId,
},
})));
const handlers = createJsonProxy({
serviceName: 'compliance',
buildTargetUrl: () => 'http://internal.service.local/compliance/check',
buildHeaders: () => new Headers(),
methods: ['GET'],
});

const response = await handlers.GET!(
makeRouteRequest(),
{ params: Promise.resolve({ path: ['check'] }) },
);

expect(response.status).toBe(429);
expect(response.headers.get('retry-after')).toBe('30');
expect(response.headers.get('x-request-id')).toBe(requestId);
});

it('sets x-request-id on proxyError responses only when a request ID is provided', () => {
const requestId = crypto.randomUUID();

const withId = proxyError('admin request failed', 503, { requestId });
expect(withId.status).toBe(503);
expect(withId.headers.get('x-request-id')).toBe(requestId);

const withoutId = proxyError('admin request failed', 503);
expect(withoutId.headers.get('x-request-id')).toBeNull();
});
});

describe('requireProxyAuth', () => {
it('does not treat REGENGINE_API_KEY as a caller credential', () => {
configureFirstPartyServiceCredential();
Expand Down
36 changes: 35 additions & 1 deletion frontend/src/__tests__/lib/api-error.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { apiError, getErrorStatus, isAuthError } from '@/lib/api-error';
import { apiError, getErrorRequestId, getErrorStatus, isAuthError, requestIdFromResponse, withRequestId } from '@/lib/api-error';

describe('api-error helpers', () => {
it('builds errors with machine-readable status in both fields', () => {
Expand All @@ -17,6 +17,40 @@ describe('api-error helpers', () => {
expect(getErrorStatus({ status: '401' })).toBeNull();
});

it('attaches a request ID to the direct and nested error shape', () => {
const requestId = crypto.randomUUID();
const err = apiError(500, 'Internal Server Error', requestId);
expect(err.requestId).toBe(requestId);
expect(err.response?.requestId).toBe(requestId);
expect(getErrorRequestId(err)).toBe(requestId);
expect(getErrorRequestId(apiError(500, ''))).toBeUndefined();
});

it('reads the request ID from the failed response header', () => {
const requestId = crypto.randomUUID();
const plainRequestId = crypto.randomUUID();
const res = new Response('boom', { status: 500, headers: { 'x-request-id': requestId } });
expect(requestIdFromResponse(res)).toBe(requestId);
expect(requestIdFromResponse(new Response('boom', { status: 500 }))).toBeUndefined();
expect(requestIdFromResponse(null)).toBeUndefined();
expect(requestIdFromResponse({ ok: false, status: 500 })).toBeUndefined();
expect(requestIdFromResponse({ headers: { 'x-request-id': plainRequestId } })).toBe(plainRequestId);

const err = withRequestId(new Error('nope'), res);
expect(err.message).toBe('nope');
expect(getErrorRequestId(err)).toBe(requestId);
});

it('reads the request ID from API client header and plain shapes', () => {
const headerRequestId = crypto.randomUUID();
const directRequestId = crypto.randomUUID();
expect(getErrorRequestId({ response: { status: 500, headers: { 'x-request-id': headerRequestId } } })).toBe(headerRequestId);
expect(getErrorRequestId({ requestId: directRequestId })).toBe(directRequestId);
expect(getErrorRequestId(new Error('plain'))).toBeUndefined();
expect(getErrorRequestId(null)).toBeUndefined();
expect(getErrorRequestId({ requestId: 42 })).toBeUndefined();
});

it('flags only 401 and 403 as auth errors', () => {
expect(isAuthError(apiError(401, ''))).toBe(true);
expect(isAuthError(apiError(403, ''))).toBe(true);
Expand Down
Loading
Loading