7d71f126a2
The voice_library regex's purpose is to prevent path traversal and filter structurally-malformed IDs, not to enforce the HF catalog's lowercase-language convention. Asserting that EN_US-amy-medium is rejected was a category error — uppercase variants pass the regex but would 404 at install time against HF, which is a harmless dead end, not a security gap. Comment in the test now explains the scope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
157 lines
5.4 KiB
Python
157 lines
5.4 KiB
Python
"""Unit tests for the voice library catalog shaper + ID validation.
|
|
|
|
DB- and network-touching paths (fetch_catalog, install_voice) need a
|
|
real environment, so we test only the pure transformations here:
|
|
|
|
- Voice ID regex accepts valid piper IDs and rejects path-traversal attempts.
|
|
- shape_catalog_for_ui projects an HF-shaped catalog dict into the UI list.
|
|
- _resolve_file_urls picks the right .onnx and .onnx.json paths.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from fabledassistant.services import voice_library as lib
|
|
|
|
|
|
def test_voice_id_accepts_real_piper_ids():
|
|
for vid in [
|
|
"en_US-amy-medium",
|
|
"en_US-ryan-medium",
|
|
"en_GB-alan-low",
|
|
"fr_FR-siwis-medium",
|
|
"de_DE-thorsten-high",
|
|
"es_AR-daniela-high",
|
|
"zh_CN-huayan-medium",
|
|
"ar_JO-kareem-low",
|
|
"ca_ES-upc_ona-x_low",
|
|
]:
|
|
assert lib._is_valid_voice_id(vid), f"should accept {vid!r}"
|
|
|
|
|
|
def test_voice_id_rejects_path_traversal_and_garbage():
|
|
# Regex purpose is path-traversal + structurally-malformed prevention,
|
|
# NOT enforcement of the HF catalog's lowercase-language convention.
|
|
# Uppercase variants like `EN_US-amy-medium` pass the regex but would
|
|
# 404 at install time against HF — harmless, no need to over-tighten.
|
|
for vid in [
|
|
"../../etc/passwd",
|
|
"en_US-amy-medium/../something",
|
|
"../en_US-amy-medium",
|
|
"",
|
|
"en_US-amy", # missing quality
|
|
"amy-medium", # missing language
|
|
"en_US--medium", # empty dataset
|
|
"en_US-amy-large", # invalid quality
|
|
".onnx",
|
|
"en_US-amy-medium.onnx", # has extension; we want the bare id
|
|
]:
|
|
assert not lib._is_valid_voice_id(vid), f"should reject {vid!r}"
|
|
|
|
|
|
def test_shape_catalog_projects_expected_fields(monkeypatch, tmp_path):
|
|
"""Catalog dict → list of UI cards, with install state from disk scan."""
|
|
# Redirect the install-scan paths to a tmp dir; mark en_US-amy-medium
|
|
# as installed under /data so we can verify the field flips.
|
|
user_dir = tmp_path / "data_voices"
|
|
bundled_dir = tmp_path / "opt_voices"
|
|
user_dir.mkdir()
|
|
bundled_dir.mkdir()
|
|
(user_dir / "en_US-amy-medium.onnx").write_text("model")
|
|
(user_dir / "en_US-amy-medium.onnx.json").write_text("{}")
|
|
|
|
monkeypatch.setattr(lib, "_USER_VOICES_DIR", user_dir)
|
|
monkeypatch.setattr(lib, "_BUNDLED_VOICES_DIR", bundled_dir)
|
|
|
|
catalog = {
|
|
"en_US-amy-medium": {
|
|
"name": "amy",
|
|
"language": {
|
|
"code": "en_US",
|
|
"name_native": "English",
|
|
"country_english": "United States",
|
|
},
|
|
"quality": "medium",
|
|
"num_speakers": 1,
|
|
"files": {
|
|
"en/en_US/amy/medium/en_US-amy-medium.onnx": {
|
|
"size_bytes": 63_500_000,
|
|
},
|
|
"en/en_US/amy/medium/en_US-amy-medium.onnx.json": {
|
|
"size_bytes": 4_300,
|
|
},
|
|
},
|
|
},
|
|
"fr_FR-siwis-medium": {
|
|
"name": "siwis",
|
|
"language": {
|
|
"code": "fr_FR",
|
|
"name_native": "Français",
|
|
"country_english": "France",
|
|
},
|
|
"quality": "medium",
|
|
"num_speakers": 1,
|
|
"files": {
|
|
"fr/fr_FR/siwis/medium/fr_FR-siwis-medium.onnx": {
|
|
"size_bytes": 60_000_000,
|
|
},
|
|
"fr/fr_FR/siwis/medium/fr_FR-siwis-medium.onnx.json": {
|
|
"size_bytes": 3_900,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
out = lib.shape_catalog_for_ui(catalog)
|
|
assert len(out) == 2
|
|
by_id = {v["id"]: v for v in out}
|
|
|
|
amy = by_id["en_US-amy-medium"]
|
|
assert amy["name"] == "amy"
|
|
assert amy["language_code"] == "en_US"
|
|
assert amy["quality"] == "medium"
|
|
assert amy["size_bytes"] == 63_500_000 + 4_300
|
|
assert amy["installed"] is True
|
|
assert amy["installed_source"] == "user"
|
|
|
|
siwis = by_id["fr_FR-siwis-medium"]
|
|
assert siwis["language_code"] == "fr_FR"
|
|
assert siwis["installed"] is False
|
|
assert siwis["installed_source"] is None
|
|
|
|
# Sorted by language_code, then name — en_US before fr_FR.
|
|
assert out[0]["id"] == "en_US-amy-medium"
|
|
assert out[1]["id"] == "fr_FR-siwis-medium"
|
|
|
|
|
|
def test_resolve_file_urls_picks_onnx_and_sidecar():
|
|
catalog = {
|
|
"en_US-amy-medium": {
|
|
"files": {
|
|
"en/en_US/amy/medium/en_US-amy-medium.onnx": {"size_bytes": 1},
|
|
"en/en_US/amy/medium/en_US-amy-medium.onnx.json": {"size_bytes": 2},
|
|
},
|
|
}
|
|
}
|
|
urls = lib._resolve_file_urls(catalog, "en_US-amy-medium")
|
|
assert urls["onnx"].endswith("en_US-amy-medium.onnx")
|
|
assert urls["onnx.json"].endswith("en_US-amy-medium.onnx.json")
|
|
assert urls["onnx"].startswith("https://huggingface.co/rhasspy/piper-voices/resolve/main/")
|
|
|
|
|
|
def test_resolve_file_urls_raises_when_voice_missing():
|
|
with pytest.raises(KeyError):
|
|
lib._resolve_file_urls({}, "en_US-amy-medium")
|
|
|
|
|
|
def test_resolve_file_urls_raises_when_files_incomplete():
|
|
catalog = {
|
|
"en_US-amy-medium": {
|
|
"files": {
|
|
# Only the .onnx, missing the sidecar — that's malformed.
|
|
"en/en_US/amy/medium/en_US-amy-medium.onnx": {"size_bytes": 1},
|
|
}
|
|
}
|
|
}
|
|
with pytest.raises(ValueError, match="missing"):
|
|
lib._resolve_file_urls(catalog, "en_US-amy-medium")
|