140 lines
4.3 KiB
TypeScript
140 lines
4.3 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import type { NextRequest } from 'next/server';
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
fetchPinnedPublicUrl: vi.fn(),
|
|
PublicUrlFetchError: class MockPublicUrlFetchError extends Error {
|
|
constructor(
|
|
readonly code: 'INVALID_URL' | 'BLOCKED_TARGET' | 'DNS_FAILURE' | 'UPSTREAM_FAILURE',
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
}
|
|
},
|
|
}));
|
|
|
|
vi.mock('@/lib/server/public-url-fetch', () => ({
|
|
fetchPinnedPublicUrl: mocks.fetchPinnedPublicUrl,
|
|
PublicUrlFetchError: mocks.PublicUrlFetchError,
|
|
}));
|
|
|
|
vi.mock('@/lib/logger', () => ({
|
|
createLogger: () => ({
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
async function postAzureVoices(body: Record<string, unknown>) {
|
|
const { POST } = await import('@/app/api/azure-voices/route');
|
|
return POST(
|
|
new Request('http://localhost/api/azure-voices', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
}) as unknown as NextRequest,
|
|
);
|
|
}
|
|
|
|
describe('POST /api/azure-voices', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
mocks.fetchPinnedPublicUrl.mockReset();
|
|
});
|
|
|
|
it('uses a pinned GET with the original Azure host and disposes after JSON is read', async () => {
|
|
const dispose = vi.fn().mockResolvedValue(undefined);
|
|
mocks.fetchPinnedPublicUrl.mockResolvedValue({
|
|
response: new Response(JSON.stringify([{ ShortName: 'zh-CN-XiaoxiaoNeural' }]), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}),
|
|
dispose,
|
|
});
|
|
|
|
const res = await postAzureVoices({
|
|
apiKey: 'azure-secret',
|
|
baseUrl: 'https://eastasia.tts.speech.microsoft.com',
|
|
});
|
|
const json = await res.json();
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(json).toMatchObject({
|
|
success: true,
|
|
voices: [{ ShortName: 'zh-CN-XiaoxiaoNeural' }],
|
|
});
|
|
expect(mocks.fetchPinnedPublicUrl).toHaveBeenCalledWith(
|
|
'https://eastasia.tts.speech.microsoft.com/cognitiveservices/voices/list',
|
|
expect.objectContaining({
|
|
headers: { 'Ocp-Apim-Subscription-Key': 'azure-secret' },
|
|
maxResponseBytes: 8 * 1024 * 1024,
|
|
}),
|
|
);
|
|
expect(dispose).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('fails closed on an upstream redirect without forwarding the subscription key', async () => {
|
|
const dispose = vi.fn().mockResolvedValue(undefined);
|
|
mocks.fetchPinnedPublicUrl.mockResolvedValue({
|
|
response: new Response(null, {
|
|
status: 302,
|
|
headers: { location: 'https://attacker.example/steal' },
|
|
}),
|
|
dispose,
|
|
});
|
|
|
|
const res = await postAzureVoices({
|
|
apiKey: 'azure-secret',
|
|
baseUrl: 'https://eastasia.tts.speech.microsoft.com',
|
|
});
|
|
const json = await res.json();
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(json).toMatchObject({ errorCode: 'REDIRECT_NOT_ALLOWED' });
|
|
expect(mocks.fetchPinnedPublicUrl).toHaveBeenCalledOnce();
|
|
expect(dispose).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('blocks metadata/private targets even when the low-level error contains the address', async () => {
|
|
mocks.fetchPinnedPublicUrl.mockRejectedValue(
|
|
new mocks.PublicUrlFetchError('BLOCKED_TARGET', '100.100.100.200 is blocked'),
|
|
);
|
|
|
|
const res = await postAzureVoices({
|
|
apiKey: 'azure-secret',
|
|
baseUrl: 'http://100.100.100.200',
|
|
});
|
|
const json = await res.json();
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(json).toEqual({
|
|
success: false,
|
|
errorCode: 'INVALID_URL',
|
|
error: 'Azure endpoint is not an allowed public URL',
|
|
});
|
|
expect(JSON.stringify(json)).not.toContain('100.100.100.200');
|
|
});
|
|
|
|
it('maps transport details to a generic 502', async () => {
|
|
mocks.fetchPinnedPublicUrl.mockRejectedValue(
|
|
new mocks.PublicUrlFetchError('UPSTREAM_FAILURE', 'ECONNREFUSED 10.0.0.8'),
|
|
);
|
|
|
|
const res = await postAzureVoices({
|
|
apiKey: 'azure-secret',
|
|
baseUrl: 'https://eastasia.tts.speech.microsoft.com',
|
|
});
|
|
const json = await res.json();
|
|
|
|
expect(res.status).toBe(502);
|
|
expect(json).toEqual({
|
|
success: false,
|
|
errorCode: 'UPSTREAM_ERROR',
|
|
error: 'Unable to connect to Azure Speech Services',
|
|
});
|
|
expect(JSON.stringify(json)).not.toContain('10.0.0.8');
|
|
});
|
|
});
|