diff --git a/api/routes.py b/api/routes.py index 520c91e15..d460e086f 100644 --- a/api/routes.py +++ b/api/routes.py @@ -10318,7 +10318,10 @@ def _handle_tts(handler, parsed): allowed = { "zh-CN-XiaoxiaoNeural", "zh-CN-XiaoyiNeural", "zh-CN-YunxiNeural", "zh-CN-YunjianNeural", "zh-CN-YunyangNeural", - "en-US-AriaNeural", "en-US-GuyNeural" + "en-US-AriaNeural", "en-US-GuyNeural", + "fr-CA-AntoineNeural", "fr-CA-JeanNeural", + "fr-CA-SylvieNeural", "fr-CA-ThierryNeural", + "fr-FR-DeniseNeural", "fr-FR-EloiseNeural", "fr-FR-HenriNeural", } if voice not in allowed: from api.helpers import bad as _bad diff --git a/tests/test_french_voices_tts_allowlist.py b/tests/test_french_voices_tts_allowlist.py new file mode 100644 index 000000000..2adc259b4 --- /dev/null +++ b/tests/test_french_voices_tts_allowlist.py @@ -0,0 +1,108 @@ +"""Coverage for French voices in the Edge TTS allowlist. + +Mirrors the in-process / mocked-edge_tts pattern from +test_issue2931_edge_tts_endpoint.py to assert each French voice in the +allowlist reaches synthesis (HTTP 200) and that an unlisted French locale +(fr-BE) is still rejected at the allowlist (HTTP 400). +""" +import io +import json +import sys +from types import SimpleNamespace + +import pytest + +import api.routes as routes + + +class _FakeHandler: + def __init__(self, body: bytes, command: str = "POST", headers=None, client="1.2.3.4"): + self.command = command + self.rfile = io.BytesIO(body) + self.wfile = io.BytesIO() + self.headers = headers or {} + self.headers.setdefault("Content-Length", str(len(body))) + self.client_address = (client, 12345) + self.status = None + self.sent_headers = {} + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.sent_headers[key] = value + + def end_headers(self): + pass + + def payload(self): + try: + return json.loads(self.wfile.getvalue().decode("utf-8")) + except Exception: + return None + + +def _post(body_dict, **kw): + body = json.dumps(body_dict).encode() + return _FakeHandler(body, **kw) + + +def _reset_limiter(): + if hasattr(routes._handle_tts, "_tts_limiter"): + del routes._handle_tts._tts_limiter + + +@pytest.fixture(autouse=True) +def _fresh_tts_limiter(monkeypatch): + import api.auth as _auth + monkeypatch.setattr(_auth, "is_auth_enabled", lambda: False) + monkeypatch.setattr(routes, "is_auth_enabled", lambda: False, raising=False) + monkeypatch.delenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", raising=False) + _reset_limiter() + yield + _reset_limiter() + + +FRENCH_VOICES = [ + "fr-CA-AntoineNeural", + "fr-CA-JeanNeural", + "fr-CA-SylvieNeural", + "fr-CA-ThierryNeural", + "fr-FR-DeniseNeural", + "fr-FR-EloiseNeural", + "fr-FR-HenriNeural", +] + + +@pytest.mark.parametrize("voice", FRENCH_VOICES) +def test_french_voice_in_allowlist_reaches_synthesis(monkeypatch, voice): + captured = {} + + class FakeCommunicate: + def __init__(self, text, voice, **kwargs): + captured["text"] = text + captured["voice"] = voice + + def stream_sync(self): + yield {"type": "audio", "data": b"abc"} + + monkeypatch.setitem(sys.modules, "edge_tts", SimpleNamespace(Communicate=FakeCommunicate)) + + # Unique client per voice so the 2s per-client rate limiter does not throttle + # successive parametrized runs in this module. + client = f"10.99.0.{FRENCH_VOICES.index(voice) + 1}" + h = _post({"text": "Bonjour", "voice": voice}, client=client) + routes._handle_tts(h, None) + + assert h.status == 200, h.payload() + assert captured["voice"] == voice + + +def test_unlisted_french_locale_still_rejected(): + # fr-BE is a real Edge voice locale but is intentionally not in the + # allowlist; it must still be rejected at the voice check before any + # synthesis is attempted. + h = _post({"text": "Bonjour", "voice": "fr-BE-CharlineNeural"}, client="10.99.1.1") + routes._handle_tts(h, None) + assert h.status == 400 + assert "invalid voice" in (h.payload() or {}).get("error", "")