Локальный FastAPI-сервис поверх GigaAM v3 и sherpa-onnx: приём аудио, очередь задач, разделение по говорящим, постобработка терминов. Доставка на Windows - ZIP со встроенным Python, без установки чего-либо. Обновление кода при запуске тянется из релизов Gitea: меняется только папка app, десятки килобайт вместо всего пакета. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
129 lines
5.2 KiB
Python
129 lines
5.2 KiB
Python
"""Тесты автообновления.
|
|
|
|
Обновление трогает работающий сервис, поэтому проверяется главное:
|
|
не ставить старое поверх нового, не ставить битый архив и уметь откатиться.
|
|
"""
|
|
import hashlib
|
|
import io
|
|
import zipfile
|
|
|
|
import pytest
|
|
|
|
from app.updater import (
|
|
UpdateError,
|
|
install_update,
|
|
is_newer,
|
|
parse_version,
|
|
verify_sha256,
|
|
)
|
|
|
|
|
|
def make_zip(files: dict[str, str]) -> bytes:
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w") as z:
|
|
for name, text in files.items():
|
|
z.writestr(name, text)
|
|
return buf.getvalue()
|
|
|
|
|
|
class TestVersions:
|
|
def test_parses_plain_version(self):
|
|
assert parse_version("1.2.3") == (1, 2, 3)
|
|
|
|
def test_parses_with_v_prefix(self):
|
|
assert parse_version("v1.2.3") == (1, 2, 3)
|
|
|
|
def test_missing_parts_are_zero(self):
|
|
assert parse_version("2") == (2, 0, 0)
|
|
|
|
def test_garbage_is_lowest(self):
|
|
assert parse_version("не версия") == (0, 0, 0)
|
|
|
|
@pytest.mark.parametrize("remote,local", [("1.0.1", "1.0.0"), ("1.1.0", "1.0.9"),
|
|
("2.0.0", "1.99.99")])
|
|
def test_newer_versions_detected(self, remote, local):
|
|
assert is_newer(remote, local) is True
|
|
|
|
@pytest.mark.parametrize("remote,local", [("1.0.0", "1.0.0"), ("0.9.0", "1.0.0")])
|
|
def test_same_or_older_not_installed(self, remote, local):
|
|
"""Откат по времени - тоже ошибка: старое не должно затирать новое."""
|
|
assert is_newer(remote, local) is False
|
|
|
|
|
|
class TestChecksum:
|
|
def test_matching_checksum_passes(self):
|
|
data = b"payload"
|
|
verify_sha256(data, hashlib.sha256(data).hexdigest())
|
|
|
|
def test_wrong_checksum_raises(self):
|
|
with pytest.raises(UpdateError, match="контрольная сумма"):
|
|
verify_sha256(b"payload", "0" * 64)
|
|
|
|
def test_checksum_is_case_insensitive(self):
|
|
data = b"payload"
|
|
verify_sha256(data, hashlib.sha256(data).hexdigest().upper())
|
|
|
|
|
|
class TestInstall:
|
|
def test_replaces_app_directory(self, tmp_path):
|
|
base = tmp_path
|
|
(base / "app").mkdir()
|
|
(base / "app" / "main.py").write_text("старый", encoding="utf-8")
|
|
|
|
archive = make_zip({"app/main.py": "новый", "app/version.py": '__version__ = "9.9.9"'})
|
|
install_update(archive, base, verify=lambda _: True)
|
|
|
|
assert (base / "app" / "main.py").read_text(encoding="utf-8") == "новый"
|
|
|
|
def test_keeps_user_files(self, tmp_path):
|
|
"""Конфиг и словарь замен принадлежат пользователю, их трогать нельзя."""
|
|
base = tmp_path
|
|
(base / "app").mkdir()
|
|
(base / "config.toml").write_text("мои настройки", encoding="utf-8")
|
|
(base / "replacements.txt").write_text("мои замены", encoding="utf-8")
|
|
|
|
archive = make_zip({"app/main.py": "новый",
|
|
"config.toml": "чужие настройки",
|
|
"replacements.txt": "чужие замены"})
|
|
install_update(archive, base, verify=lambda _: True)
|
|
|
|
assert (base / "config.toml").read_text(encoding="utf-8") == "мои настройки"
|
|
assert (base / "replacements.txt").read_text(encoding="utf-8") == "мои замены"
|
|
|
|
def test_rolls_back_when_new_code_is_broken(self, tmp_path):
|
|
"""Если новая версия не запускается, возвращаем предыдущую."""
|
|
base = tmp_path
|
|
(base / "app").mkdir()
|
|
(base / "app" / "main.py").write_text("рабочий код", encoding="utf-8")
|
|
|
|
archive = make_zip({"app/main.py": "сломанный код"})
|
|
with pytest.raises(UpdateError, match="проверк"):
|
|
install_update(archive, base, verify=lambda _: False)
|
|
|
|
assert (base / "app" / "main.py").read_text(encoding="utf-8") == "рабочий код"
|
|
|
|
def test_rejects_archive_without_app(self, tmp_path):
|
|
base = tmp_path
|
|
(base / "app").mkdir()
|
|
archive = make_zip({"readme.txt": "мусор"})
|
|
with pytest.raises(UpdateError, match="нет папки app"):
|
|
install_update(archive, base, verify=lambda _: True)
|
|
|
|
def test_rejects_path_traversal(self, tmp_path):
|
|
"""Архив не должен уметь писать за пределы папки сервиса."""
|
|
base = tmp_path
|
|
(base / "app").mkdir()
|
|
archive = make_zip({"app/../../evil.py": "вредонос"})
|
|
with pytest.raises(UpdateError, match="путь"):
|
|
install_update(archive, base, verify=lambda _: True)
|
|
|
|
def test_rejects_absolute_paths(self, tmp_path):
|
|
base = tmp_path
|
|
(base / "app").mkdir()
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w") as z:
|
|
z.writestr("/etc/passwd", "вредонос")
|
|
z.writestr("app/main.py", "код")
|
|
with pytest.raises(UpdateError, match="путь"):
|
|
install_update(buf.getvalue(), base, verify=lambda _: True)
|