Files
wehub-resource-sync c48612c494
Docs Build / Build docs site (push) Waiting to run
Publish @openmaic packages / Build, validate & publish (push) Waiting to run
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

203 lines
6.5 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { NextRequest } from 'next/server';
const mocks = vi.hoisted(() => ({ validateUrlForSSRF: vi.fn() }));
vi.mock('@/lib/server/ssrf-guard', () => ({ validateUrlForSSRF: mocks.validateUrlForSSRF }));
vi.mock('@/lib/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
async function postProxy(body: Record<string, unknown>) {
const { POST } = await import('@/app/api/proxy-media/route');
const req = new Request('http://localhost/api/proxy-media', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return POST(req as unknown as NextRequest);
}
describe('POST /api/proxy-media', () => {
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
mocks.validateUrlForSSRF.mockReset();
});
it('1. direct 200: returns blob with forwarded content-type', async () => {
mocks.validateUrlForSSRF.mockResolvedValue(null);
const bodyBytes = new Uint8Array([1, 2, 3, 4]);
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(bodyBytes, {
status: 200,
headers: { 'Content-Type': 'image/png' },
}),
),
);
const res = await postProxy({ url: 'https://example.com/image.png' });
expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toBe('image/png');
const buf = await res.arrayBuffer();
expect(new Uint8Array(buf)).toEqual(bodyBytes);
});
it('2. single redirect followed: validates both initial and redirect target', async () => {
mocks.validateUrlForSSRF.mockResolvedValue(null);
const finalBytes = new Uint8Array([10, 20]);
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(null, {
status: 302,
headers: { location: 'https://cdn.example.com/final.png' },
}),
)
.mockResolvedValueOnce(
new Response(finalBytes, {
status: 200,
headers: { 'Content-Type': 'image/png' },
}),
);
vi.stubGlobal('fetch', fetchMock);
const res = await postProxy({ url: 'https://example.com/redirect' });
expect(res.status).toBe(200);
// validateUrlForSSRF called for both initial URL and redirect target
expect(mocks.validateUrlForSSRF).toHaveBeenCalledTimes(2);
expect(mocks.validateUrlForSSRF).toHaveBeenNthCalledWith(1, 'https://example.com/redirect');
expect(mocks.validateUrlForSSRF).toHaveBeenNthCalledWith(
2,
'https://cdn.example.com/final.png',
);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('3. redirect to private/blocked URL is rejected (SSRF guard)', async () => {
// Initial URL passes; redirect target is blocked
mocks.validateUrlForSSRF
.mockResolvedValueOnce(null) // initial URL: ok
.mockResolvedValueOnce('Local/private network URLs are not allowed'); // redirect target: blocked
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValueOnce(
new Response(null, {
status: 302,
headers: { location: 'http://192.168.1.1/secret' },
}),
),
);
const res = await postProxy({ url: 'https://example.com/harmless' });
const json = await res.json();
expect(res.status).toBe(403);
expect(json).toMatchObject({ errorCode: 'INVALID_URL' });
});
it('4. too many redirects returns 502 TOO_MANY_REDIRECTS', async () => {
mocks.validateUrlForSSRF.mockResolvedValue(null);
// Every fetch returns 302 → infinite loop hits MAX_REDIRECTS (5)
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: 'https://example.com/loop' },
}),
),
);
const res = await postProxy({ url: 'https://example.com/loop' });
const json = await res.json();
expect(res.status).toBe(502);
expect(json).toMatchObject({ errorCode: 'TOO_MANY_REDIRECTS' });
});
it('5. initial URL blocked: 403 returned, no fetch attempted', async () => {
mocks.validateUrlForSSRF.mockResolvedValue('Local/private network URLs are not allowed');
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const res = await postProxy({ url: 'http://169.254.169.254/metadata' });
const json = await res.json();
expect(res.status).toBe(403);
expect(json).toMatchObject({ errorCode: 'INVALID_URL' });
expect(fetchMock).not.toHaveBeenCalled();
});
it('6. redirect without Location header returns 502', async () => {
mocks.validateUrlForSSRF.mockResolvedValue(null);
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
// no location header
}),
),
);
const res = await postProxy({ url: 'https://example.com/bad-redirect' });
const json = await res.json();
expect(res.status).toBe(502);
expect(json).toMatchObject({ errorCode: 'UPSTREAM_ERROR' });
});
it('7. upstream 404 returns 404 UPSTREAM_ERROR (forwarded, not 502)', async () => {
mocks.validateUrlForSSRF.mockResolvedValue(null);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 404 })));
const res = await postProxy({ url: 'https://example.com/not-found' });
const json = await res.json();
expect(res.status).toBe(404);
expect(json).toMatchObject({ errorCode: 'UPSTREAM_ERROR' });
});
it('8. upstream 503 is collapsed to 502', async () => {
mocks.validateUrlForSSRF.mockResolvedValue(null);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 503 })));
const res = await postProxy({ url: 'https://example.com/server-error' });
const json = await res.json();
expect(res.status).toBe(502);
expect(json).toMatchObject({ errorCode: 'UPSTREAM_ERROR' });
});
it('9. upstream Content-Length exceeding cap returns 502', async () => {
mocks.validateUrlForSSRF.mockResolvedValue(null);
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(null, {
status: 200,
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': String(26 * 1024 * 1024), // 26 MiB > 25 MiB cap
},
}),
),
);
const res = await postProxy({ url: 'https://example.com/huge-asset' });
const json = await res.json();
expect(res.status).toBe(502);
expect(json).toMatchObject({ errorCode: 'UPSTREAM_ERROR' });
});
});