Files
openmaic/OpenMAIC/tests/api/azure-voices.test.ts
2026-08-16 14:58:47 +08:00

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