Files
openclaw/extensions/volcengine/tts.test.ts
zengLingbiao c459a1876b fix(volcengine): reject malformed TTS audio (#113109)
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-25 08:15:14 -07:00

394 lines
11 KiB
TypeScript

// Volcengine tests cover tts plugin behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildVolcengineSpeechProvider } from "./speech-provider.js";
import { volcengineTTS } from "./tts.js";
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
fetchWithSsrFGuardMock: vi.fn(),
}));
const PROVIDER_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
}));
function requireFirstGuardedFetchCall(): unknown {
const [call] = fetchWithSsrFGuardMock.mock.calls;
if (!call) {
throw new Error("expected Volcengine guarded fetch call");
}
return call[0];
}
function makeProviderConfig(overrides?: Record<string, unknown>) {
return {
apiKey: "test-api-key",
voice: "en_female_anna_mars_bigtts",
...overrides,
};
}
function makeLegacyProviderConfig(overrides?: Record<string, unknown>) {
return {
appId: "test-app-id",
token: "test-token",
voice: "zh_female_xiaohe_uranus_bigtts",
cluster: "volcano_tts",
...overrides,
};
}
const TTS_ENV_KEYS = [
"BYTEPLUS_SEED_SPEECH_API_KEY",
"VOLCENGINE_TTS_API_KEY",
"VOLCENGINE_TTS_APPID",
"VOLCENGINE_TTS_TOKEN",
] as const;
function makeOversizedStreamResponse(): Response {
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(PROVIDER_RESPONSE_MAX_BYTES));
controller.enqueue(new Uint8Array(1));
controller.close();
},
}),
);
}
describe("Volcengine speech provider", () => {
const provider = buildVolcengineSpeechProvider();
beforeEach(() => {
fetchWithSsrFGuardMock.mockReset();
for (const key of TTS_ENV_KEYS) {
vi.stubEnv(key, undefined);
}
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("has correct id, label, and aliases", () => {
expect(provider.id).toBe("volcengine");
expect(provider.label).toBe("Volcengine");
expect(provider.aliases).toContain("bytedance");
expect(provider.aliases).toContain("doubao");
});
it("reports configured when an API key is present in providerConfig", () => {
expect(provider.isConfigured({ providerConfig: makeProviderConfig(), timeoutMs: 30000 })).toBe(
true,
);
});
it("reports configured for legacy appId and token in providerConfig", () => {
expect(
provider.isConfigured({ providerConfig: makeLegacyProviderConfig(), timeoutMs: 30000 }),
).toBe(true);
});
it("reports not configured when credentials are missing", () => {
expect(provider.isConfigured({ providerConfig: {}, timeoutMs: 30000 })).toBe(false);
});
it("falls back to env vars for credentials", () => {
vi.stubEnv("BYTEPLUS_SEED_SPEECH_API_KEY", "env-api-key");
expect(provider.isConfigured({ providerConfig: {}, timeoutMs: 30000 })).toBe(true);
});
it("rejects blank Seed and legacy credentials before requests", async () => {
for (const key of TTS_ENV_KEYS) {
vi.stubEnv(key, " ");
}
const providerConfig = { apiKey: " ", appId: " ", token: " " };
expect(provider.isConfigured({ providerConfig, timeoutMs: 30_000 })).toBe(false);
await expect(
provider.synthesize({
text: "hello",
cfg: {},
providerConfig,
target: "audio-file",
timeoutMs: 1_000,
}),
).rejects.toThrow("Volcengine TTS credentials missing");
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
});
it("lists voices with locale and gender", async () => {
const listVoices = provider.listVoices;
if (!listVoices) {
throw new Error("Expected Volcengine provider listVoices");
}
const voices = await listVoices({});
expect(voices.length).toBeGreaterThan(0);
expect(voices[0]).toEqual({
id: "en_female_anna_mars_bigtts",
name: "anna",
locale: "en-US",
gender: "female",
});
});
it("rejects non-decimal speedRatio directive values", () => {
expect(
provider.parseDirectiveToken?.({
key: "speed",
value: "0x1",
policy: {
enabled: true,
allowText: true,
allowProvider: true,
allowVoice: true,
allowModelId: true,
allowVoiceSettings: true,
allowNormalization: true,
allowSeed: true,
},
}),
).toEqual({
handled: true,
warnings: ['invalid Volcengine speedRatio "0x1"'],
});
});
it("sends the documented Seed Speech API key payload and returns voice-note Opus metadata", async () => {
const release = vi.fn();
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(
JSON.stringify({
code: 0,
data: Buffer.from("voice-audio").toString("base64"),
}),
),
release,
});
const result = await provider.synthesize({
text: "hello",
cfg: {},
providerConfig: makeProviderConfig({ emotion: "happy", speedRatio: 1.2 }),
target: "voice-note",
providerOverrides: { voice: "zh_male_aojiao_mars_bigtts", speedRatio: 0.9 },
timeoutMs: 1234,
});
expect(result.audioBuffer.toString()).toBe("voice-audio");
expect(result.outputFormat).toBe("opus");
expect(result.fileExtension).toBe(".opus");
expect(result.voiceCompatible).toBe(true);
const call = requireFirstGuardedFetchCall();
expect(call).toEqual({
url: "https://voice.ap-southeast-1.bytepluses.com/api/v3/tts/unidirectional",
init: {
method: "POST",
headers: {
"Content-Type": "application/json",
Connection: "keep-alive",
"X-Api-Key": "test-api-key",
"X-Api-Resource-Id": "seed-tts-1.0",
"X-Api-App-Key": "aGjiRDfUWi",
},
body: JSON.stringify({
user: { uid: "openclaw" },
req_params: {
text: "hello",
speaker: "zh_male_aojiao_mars_bigtts",
audio_params: {
format: "ogg_opus",
sample_rate: 24000,
},
speed_ratio: 0.9,
emotion: "happy",
},
}),
},
timeoutMs: 1234,
policy: { hostnameAllowlist: ["voice.ap-southeast-1.bytepluses.com"] },
auditContext: "volcengine.tts",
});
expect(release).toHaveBeenCalledTimes(1);
});
it("drops malformed speed ratios before synthesis", async () => {
const release = vi.fn();
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(
JSON.stringify({
code: 0,
data: Buffer.from("voice-audio").toString("base64"),
}),
),
release,
});
await provider.synthesize({
text: "hello",
cfg: {},
providerConfig: makeProviderConfig({ speedRatio: 4 }),
target: "audio-file",
providerOverrides: { speedRatio: -1 },
timeoutMs: 1234,
});
const call = requireFirstGuardedFetchCall() as { init: { body: string } };
const body = JSON.parse(call.init.body) as {
req_params?: { speed_ratio?: number };
};
expect(body.req_params).not.toHaveProperty("speed_ratio");
});
});
describe("volcengineTTS", () => {
beforeEach(() => {
fetchWithSsrFGuardMock.mockReset();
});
it("joins streamed Seed Speech audio frames", async () => {
const release = vi.fn();
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(
[
JSON.stringify({ code: 0, message: "" }),
JSON.stringify({ code: 0, data: Buffer.from("audio-1").toString("base64") }),
JSON.stringify({ code: 0, data: Buffer.from("audio-2").toString("base64") }),
JSON.stringify({ code: 20000000, message: "ok", data: null }),
].join("\n"),
),
release,
});
const audio = await volcengineTTS({
text: "hello",
apiKey: "secret-api-key",
voice: "zh_female_xiaohe_uranus_bigtts",
encoding: "mp3",
timeoutMs: 1000,
});
expect(audio.toString()).toBe("audio-1audio-2");
expect(release).toHaveBeenCalledTimes(1);
});
it.each([
{
name: "Seed Speech",
response: { code: 0, data: "%%%not-base64!!" },
params: { text: "hello", apiKey: "secret-api-key", timeoutMs: 1000 },
error: "BytePlus Seed Speech TTS returned malformed base64 audio data",
},
{
name: "legacy",
response: { code: 3000, data: "%%%not-base64!!" },
params: { text: "hello", appId: "app-id", token: "secret-token", timeoutMs: 1000 },
error: "Volcengine TTS returned malformed base64 audio data",
},
])("rejects malformed base64 in $name responses", async ({ response, params, error }) => {
const release = vi.fn();
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(JSON.stringify(response)),
release,
});
await expect(volcengineTTS(params)).rejects.toThrow(error);
expect(release).toHaveBeenCalledTimes(1);
});
it("reports Seed Speech provider errors without exposing credentials", async () => {
const release = vi.fn();
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(
JSON.stringify({ header: { code: 45000000, message: "speaker permission denied" } }),
{ status: 403 },
),
release,
});
let error: unknown;
try {
await volcengineTTS({
text: "hello",
apiKey: "secret-api-key",
timeoutMs: 1000,
});
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe(
"BytePlus Seed Speech TTS error 45000000: speaker permission denied",
);
expect((error as Error).message).not.toContain("secret-api-key");
expect(release).toHaveBeenCalledTimes(1);
});
it("bounds Seed Speech success response reads", async () => {
const release = vi.fn();
fetchWithSsrFGuardMock.mockResolvedValue({
response: makeOversizedStreamResponse(),
release,
});
await expect(
volcengineTTS({
text: "hello",
apiKey: "secret-api-key",
timeoutMs: 1000,
}),
).rejects.toThrow("BytePlus Seed Speech TTS response exceeds 16777216 bytes");
expect(release).toHaveBeenCalledTimes(1);
});
it("reports provider errors without exposing credentials", async () => {
const release = vi.fn();
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(JSON.stringify({ code: 3001, message: "load grant failed" }), {
status: 401,
}),
release,
});
let error: unknown;
try {
await volcengineTTS({
text: "hello",
appId: "app-id",
token: "secret-token",
timeoutMs: 1000,
});
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe("Volcengine TTS error 3001: load grant failed");
expect((error as Error).message).not.toContain("secret-token");
expect(release).toHaveBeenCalledTimes(1);
});
it("bounds legacy Volcengine success response reads", async () => {
const release = vi.fn();
fetchWithSsrFGuardMock.mockResolvedValue({
response: makeOversizedStreamResponse(),
release,
});
await expect(
volcengineTTS({
text: "hello",
appId: "app-id",
token: "secret-token",
timeoutMs: 1000,
}),
).rejects.toThrow("Volcengine TTS response exceeds 16777216 bytes");
expect(release).toHaveBeenCalledTimes(1);
});
});