Files
OmniRoute/tests/unit/edgetts-provider.test.ts
Diego Rodrigues de Sa e Souza 6695cbbf7a feat: add EdgeTTS audio-tts provider (#6668) (#7605)
* feat(sse): add EdgeTTS audio-tts provider (#6668)

Registers Microsoft Edge "Read Aloud" as a new no-API-key AUDIO_SPEECH_PROVIDERS
entry — the first WebSocket-transport TTS provider in the registry. Reverse-
engineered/unofficial endpoint, same class of integration already accepted for
other "-web" style providers (chatgpt-web.ts, copilot-web.ts).

- open-sse/executors/edgeTts.ts: pure Sec-MS-GEC token construction (SHA-256
  over a public trusted-client-token + rounded Windows file-time ticks, ported
  from rany2/edge-tts drm.py), WS message framing (speech.config/ssml),
  binary-chunk demuxing, SSML building/escaping, and the WS synth call itself
  (injectable WebSocket ctor for tests, lazy `import("ws")` in production so
  it never enters esbuild's top-level CJS bundle graph). Per-client-IP
  sliding-window throttle (SlidingWindowLimiter) since there's no per-user key
  — one abusive deployment could otherwise get the shared trusted token
  rate-limited for everyone.
- open-sse/utils/publicCreds.ts: embeds the trusted-client-token via
  resolvePublicCred() (Hard Rule #11) — it's a constant hardcoded in every
  Edge build and every open-source edge-tts port, not a per-user secret.
- Extracted open-sse/utils/audioResponse.ts (shared response helpers) and
  open-sse/executors/awsPollyTts.ts (AWS Polly handler) out of
  open-sse/handlers/audioSpeech.ts to stay under its frozen file-size ratchet
  baseline while making room for the new branch — no behavior change to
  either extracted piece.
- src/app/api/v1/audio/speech/route.ts: thread the caller's IP through to the
  handler for the new throttle.

Tests: tests/unit/edgetts-provider.test.ts (23 cases) — Sec-MS-GEC determinism
and cross-check against a hand-derived reference vector, message framing,
binary demux, SSML escaping/injection-safety, registry lookup, publicCreds
shape, and the error path via an injected fake WebSocket (upstream failure ->
sanitized 502, no stack/path leak; Hard Rule #12), plus the per-IP rate limit.
No live upstream is required or used — the reverse-engineered protocol can't
be validated against real credentials, but every pure/testable seam is
covered per the TDD path in the bug/feature validation gate.

* test(mutation): register edgetts-provider.test.ts in stryker tap.testFiles (#6668)

The new provider's unit test covers a mutated module, so the strict
mutation-test-coverage gate requires it in stryker.conf.json's
tap.testFiles. Single-line addition (kept the file's existing formatting).
2026-07-17 11:32:25 -03:00

295 lines
13 KiB
TypeScript

// EdgeTTS (Microsoft Edge "Read Aloud") audio-tts provider (#6668).
//
// EdgeTTS is a reverse-engineered WebSocket endpoint with no API key, so
// there is no live upstream we can validate against in CI (Hard Rule #18
// TDD path). This suite covers everything that is a pure function: the
// Sec-MS-GEC token/HMAC construction, WS message framing, binary-chunk
// demuxing, SSML building/escaping, registry lookup, and the error path
// (mocked WebSocket failure -> sanitized error response, no stack leak).
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import {
computeSecMsGec,
buildConnectionId,
buildSpeechConfigMessage,
buildSsmlMessage,
buildSsml,
buildEdgeTtsWsUrl,
escapeSsmlText,
normalizeEdgeVoice,
isTurnEndMessage,
demuxAudioChunk,
synthesizeEdgeTts,
handleEdgeTtsSpeech,
type MinimalWebSocket,
} from "../../open-sse/executors/edgeTts.ts";
import { getSpeechProvider, parseSpeechModel } from "../../open-sse/config/audioRegistry.ts";
import { resolvePublicCred } from "../../open-sse/utils/publicCreds.ts";
// ─── Sec-MS-GEC token (HMAC-ish SHA-256 construction) ──────────────────────
test("computeSecMsGec is deterministic for the same 5-minute window", () => {
const base = Date.UTC(2026, 6, 17, 12, 0, 0); // 2026-07-17T12:00:00Z
const a = computeSecMsGec(base);
const b = computeSecMsGec(base + 60_000); // +1 minute, same 5-minute bucket
assert.equal(a, b, "same rounded-down 5-minute window must hash identically");
assert.match(a, /^[0-9A-F]{64}$/, "output must be a 64-char uppercase hex SHA-256 digest");
});
test("computeSecMsGec changes across a 5-minute window boundary", () => {
const base = Date.UTC(2026, 6, 17, 12, 0, 0);
const before = computeSecMsGec(base - 1); // just before the 5-min bucket
const after = computeSecMsGec(base);
assert.notEqual(before, after);
});
test("computeSecMsGec matches the reference rany2/edge-tts algorithm shape", () => {
// Cross-check against a hand-computed reference vector for a fixed instant,
// using the same constants/algorithm documented in drm.py:
// ticks = floor((nowMs/1000 + 11644473600) - (... % 300)) * 1e7
// sha256(`${ticks}${TRUSTED_CLIENT_TOKEN}`).hexdigest().upper()
const nowMs = Date.UTC(2026, 0, 1, 0, 0, 0);
const winEpoch = 11644473600;
let ticks = nowMs / 1000 + winEpoch;
ticks -= ticks % 300;
ticks *= 1e7;
const token = resolvePublicCred("edgetts_token");
const expected = createHash("sha256")
.update(`${Math.floor(ticks)}${token}`, "ascii")
.digest("hex")
.toUpperCase();
assert.equal(computeSecMsGec(nowMs), expected);
});
// ─── publicCreds shape assertion (Hard Rule #11) ───────────────────────────
test("edgetts_token is embedded via resolvePublicCred, not a string literal", () => {
const token = resolvePublicCred("edgetts_token");
assert.equal(typeof token, "string");
assert.ok(token.length > 0, "embedded default must decode to a non-empty token");
// The well-known public trusted-client-token format used by every Edge
// build and every open-source edge-tts port: 32 uppercase hex chars.
assert.match(token, /^[0-9A-F]{32}$/);
});
test("resolvePublicCred('edgetts_token') is stable across repeated calls", () => {
// No envName is passed for this key (there's no legacy .env var to migrate
// from — it's a brand-new provider), so it must always resolve to the same
// embedded default rather than reading from process.env.
assert.equal(resolvePublicCred("edgetts_token"), resolvePublicCred("edgetts_token"));
});
// ─── Connection id / message framing ───────────────────────────────────────
test("buildConnectionId returns a 32-char lowercase hex id with no dashes", () => {
const id = buildConnectionId();
assert.match(id, /^[0-9a-f]{32}$/);
});
test("buildConnectionId is unique per call", () => {
const ids = new Set(Array.from({ length: 20 }, () => buildConnectionId()));
assert.equal(ids.size, 20);
});
test("buildSpeechConfigMessage frames a valid speech.config WS text message", () => {
const msg = buildSpeechConfigMessage("Tue, 01 Jan 2026 00:00:00 GMT");
assert.match(msg, /^X-Timestamp:Tue, 01 Jan 2026 00:00:00 GMT\r\n/);
assert.match(msg, /Content-Type:application\/json; charset=utf-8\r\n/);
assert.match(msg, /Path:speech\.config\r\n\r\n/);
const jsonPart = msg.slice(msg.indexOf("\r\n\r\n") + 4);
const parsed = JSON.parse(jsonPart);
assert.equal(
parsed.context.synthesis.audio.outputFormat,
"audio-24khz-48kbitrate-mono-mp3"
);
});
test("buildSsmlMessage frames a valid ssml WS text message carrying the SSML body", () => {
const ssml = buildSsml({ text: "hello" });
const msg = buildSsmlMessage("req-123", ssml, "Tue, 01 Jan 2026 00:00:00 GMT");
assert.match(msg, /^X-RequestId:req-123\r\n/);
assert.match(msg, /Content-Type:application\/ssml\+xml\r\n/);
assert.match(msg, /Path:ssml\r\n\r\n/);
assert.ok(msg.endsWith(ssml), "message must end with the exact SSML payload");
});
test("buildEdgeTtsWsUrl includes TrustedClientToken, Sec-MS-GEC, and ConnectionId", () => {
const url = new URL(buildEdgeTtsWsUrl(Date.UTC(2026, 6, 17)));
assert.equal(url.protocol, "wss:");
assert.equal(url.hostname, "speech.platform.bing.com");
assert.ok(url.searchParams.get("TrustedClientToken"));
assert.match(url.searchParams.get("Sec-MS-GEC") || "", /^[0-9A-F]{64}$/);
assert.match(url.searchParams.get("ConnectionId") || "", /^[0-9a-f]{32}$/);
});
// ─── SSML building / escaping (untrusted-input safety) ─────────────────────
test("escapeSsmlText escapes all five XML special characters", () => {
assert.equal(
escapeSsmlText(`<tag> & "quoted" 'single'`),
"&lt;tag&gt; &amp; &quot;quoted&quot; &apos;single&apos;"
);
});
test("buildSsml embeds escaped text and rejects SSML injection via prosody attrs", () => {
const ssml = buildSsml({
text: "</voice><voice name='evil'>pwned",
rate: "'; </prosody><script>alert(1)</script>",
});
assert.ok(!ssml.includes("<script>"), "malicious prosody rate must be clamped, not embedded");
assert.ok(ssml.includes("&lt;/voice&gt;"), "malicious text must be XML-escaped");
assert.ok(ssml.includes("rate='default'"), "invalid rate falls back to default");
});
test("normalizeEdgeVoice accepts well-formed voice names and rejects everything else", () => {
assert.equal(normalizeEdgeVoice("en-US-AriaNeural"), "en-US-AriaNeural");
assert.equal(normalizeEdgeVoice("pt-BR-FranciscaNeural"), "pt-BR-FranciscaNeural");
assert.equal(normalizeEdgeVoice("not a voice; DROP TABLE"), "en-US-AriaNeural");
assert.equal(normalizeEdgeVoice(undefined), "en-US-AriaNeural");
});
// ─── Binary audio chunk demux (pure, no live socket needed) ────────────────
test("demuxAudioChunk splits a binary frame into headers + raw audio bytes", () => {
const headers = "Path:audio\r\nContent-Type:audio/mpeg\r\n";
const headerBuf = Buffer.from(headers, "ascii");
const audioBuf = Buffer.from([1, 2, 3, 4, 5]);
const lenBuf = Buffer.alloc(2);
lenBuf.writeUInt16BE(headerBuf.length, 0);
const frame = Buffer.concat([lenBuf, headerBuf, audioBuf]);
const result = demuxAudioChunk(frame);
assert.ok(result);
assert.equal(result!.headers, headers);
assert.deepEqual(Array.from(result!.audio), [1, 2, 3, 4, 5]);
});
test("demuxAudioChunk returns null for a truncated/malformed frame", () => {
assert.equal(demuxAudioChunk(Buffer.from([0])), null);
const lenBuf = Buffer.alloc(2);
lenBuf.writeUInt16BE(100, 0); // claims 100 header bytes but frame is short
assert.equal(demuxAudioChunk(Buffer.concat([lenBuf, Buffer.from("short")])), null);
});
test("isTurnEndMessage recognizes the turn.end marker and nothing else", () => {
assert.equal(isTurnEndMessage("X-RequestId:abc\r\nPath:turn.end\r\n\r\n"), true);
assert.equal(isTurnEndMessage("Path:turn.start\r\n\r\n"), false);
assert.equal(isTurnEndMessage(""), false);
});
// ─── Registry wiring ────────────────────────────────────────────────────────
test("edgetts is registered in AUDIO_SPEECH_PROVIDERS with no-key WS transport", () => {
const provider = getSpeechProvider("edgetts");
assert.ok(provider);
assert.equal(provider!.authType, "none");
assert.equal(provider!.format, "edgetts");
assert.match(provider!.baseUrl, /^wss:\/\//);
assert.ok(provider!.models.length > 0);
});
test("parseSpeechModel resolves 'edgetts/<voice>' to the edgetts provider", () => {
const { provider, model } = parseSpeechModel("edgetts/en-US-AriaNeural");
assert.equal(provider, "edgetts");
assert.equal(model, "en-US-AriaNeural");
});
// ─── synthesizeEdgeTts / handleEdgeTtsSpeech with an injected fake WebSocket ─
class FakeEmitterSocket implements MinimalWebSocket {
private listeners: Record<string, ((...args: unknown[]) => void)[]> = {};
sent: string[] = [];
closed = false;
on(event: string, listener: (...args: unknown[]) => void) {
(this.listeners[event] ??= []).push(listener);
}
send(data: string) {
this.sent.push(data);
}
close() {
this.closed = true;
}
emit(event: string, ...args: unknown[]) {
for (const l of this.listeners[event] || []) l(...args);
}
}
test("synthesizeEdgeTts resolves with concatenated audio on turn.end", async () => {
let socket: FakeEmitterSocket;
const Ctor = function (this: unknown, _url: string) {
socket = new FakeEmitterSocket();
// Simulate the server's protocol asynchronously after `open` is sent.
queueMicrotask(() => {
socket.emit("open");
const headers = "Path:audio\r\nContent-Type:audio/mpeg\r\n";
const headerBuf = Buffer.from(headers, "ascii");
const lenBuf = Buffer.alloc(2);
lenBuf.writeUInt16BE(headerBuf.length, 0);
const frame = Buffer.concat([lenBuf, headerBuf, Buffer.from("audiobytes")]);
socket.emit("message", frame, true);
socket.emit("message", "Path:turn.end\r\n\r\n", false);
});
return socket;
} as unknown as new (url: string) => MinimalWebSocket;
const result = await synthesizeEdgeTts({ text: "hello" }, Ctor);
assert.equal(result.audio.toString(), "audiobytes");
assert.equal(result.contentType, "audio/mpeg");
});
test("synthesizeEdgeTts rejects when the socket errors", async () => {
const Ctor = function (this: unknown, _url: string) {
const socket = new FakeEmitterSocket();
queueMicrotask(() => socket.emit("error", new Error("upstream refused connection")));
return socket;
} as unknown as new (url: string) => MinimalWebSocket;
await assert.rejects(
() => synthesizeEdgeTts({ text: "hello" }, Ctor),
/upstream refused connection/
);
});
test("handleEdgeTtsSpeech returns 400 without touching the network on empty input", async () => {
const response = await handleEdgeTtsSpeech({ input: "" });
assert.equal(response.status, 400);
const bodyJson = await response.json();
assert.equal(bodyJson.error.message, "input is required");
});
test("handleEdgeTtsSpeech returns a sanitized 502 on upstream WS failure (Hard Rule #12)", async () => {
const Ctor = function (this: unknown, _url: string) {
const socket = new FakeEmitterSocket();
// Simulate a raw upstream failure that could contain a stack trace or an
// absolute filesystem path — the handler must never leak it verbatim.
const err = new Error("connect ECONNREFUSED 127.0.0.1:443");
(err as Error).stack = `Error: connect ECONNREFUSED\n at /home/user/secret/app.js:42:10`;
queueMicrotask(() => socket.emit("error", err));
return socket;
} as unknown as new (url: string) => MinimalWebSocket;
const response = await handleEdgeTtsSpeech({ input: "hello" }, null, Ctor);
assert.equal(response.status, 502);
const bodyJson = await response.json();
assert.ok(bodyJson.error.message.includes("ECONNREFUSED"));
assert.ok(!bodyJson.error.message.includes("/home/user/secret"), "must not leak a filesystem path");
assert.ok(!bodyJson.error.message.includes("at /"), "must not leak a stack trace frame");
});
test("handleEdgeTtsSpeech enforces the per-IP sliding-window rate limit", async () => {
const ip = `203.0.113.${Math.floor(Math.random() * 250) + 1}`;
// Drain the window with the input-validation fast-path (still exercises
// tryAcquire before validation) using an always-invalid body to avoid a
// real network call, then assert the last call is 429, not 400.
let last: Response | undefined;
for (let i = 0; i < 21; i++) {
last = await handleEdgeTtsSpeech({ input: "" }, ip);
}
assert.equal(last!.status, 429);
const bodyJson = await last!.json();
assert.match(bodyJson.error.message, /rate limit/i);
});