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) { 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'); }); });