mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
192 lines
7.4 KiB
Python
192 lines
7.4 KiB
Python
"""系统日志查看与下载接口的权限和打包行为测试。"""
|
|
|
|
import asyncio
|
|
import io
|
|
import threading
|
|
import zipfile
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from starlette.responses import Response
|
|
|
|
from app.api.endpoints import system as system_endpoint
|
|
from app.application.configuration import (
|
|
get_configured_system_config,
|
|
get_runtime_settings,
|
|
)
|
|
from app.runtime.config import settings
|
|
from app.startup.composition.system import compose_system_service
|
|
|
|
|
|
def test_logging_routes_use_superuser_dependency():
|
|
"""日志查看和下载路由都必须绑定管理员依赖,避免普通登录用户读取敏感日志。"""
|
|
routes = {route.path: route for route in system_endpoint.router.routes}
|
|
|
|
logging_dependencies = {dependency.call for dependency in routes["/logging"].dependant.dependencies}
|
|
download_dependencies = {dependency.call for dependency in routes["/logging/download/{name}"].dependant.dependencies}
|
|
|
|
assert system_endpoint._verify_log_resource_superuser in logging_dependencies
|
|
assert system_endpoint._verify_log_resource_superuser in download_dependencies
|
|
|
|
|
|
def test_log_resource_dependency_rejects_normal_user():
|
|
"""日志资源依赖必须拒绝非管理员 resource token。"""
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
system_endpoint._verify_log_resource_superuser(
|
|
SimpleNamespace(super_user=False),
|
|
)
|
|
|
|
assert exc_info.value.status_code == 403
|
|
|
|
|
|
@pytest.fixture(name="isolated_log_path")
|
|
def fixture_isolated_log_path(monkeypatch, tmp_path: Path) -> Path:
|
|
"""将日志目录隔离到临时目录,避免测试读取或打包真实运行日志。"""
|
|
config_path = tmp_path / "config"
|
|
log_path = config_path / "logs"
|
|
log_path.mkdir(parents=True)
|
|
monkeypatch.setattr(settings, "CONFIG_DIR", str(config_path))
|
|
return log_path
|
|
|
|
|
|
@pytest.fixture(name="system_runtime")
|
|
def fixture_system_runtime(isolated_log_path):
|
|
"""构造使用真实文件日志 Adapter 的最小 HostRuntime 投影。"""
|
|
|
|
@asynccontextmanager
|
|
async def rule_group_mutation():
|
|
"""提供本组日志测试不会进入的规则组事务替身。"""
|
|
yield SimpleNamespace()
|
|
|
|
configured_settings = get_runtime_settings()
|
|
settings_port = SimpleNamespace(
|
|
get=lambda key, default=None: (
|
|
isolated_log_path
|
|
if key == "LOG_PATH"
|
|
else configured_settings.get(key, default)
|
|
)
|
|
)
|
|
service = compose_system_service(
|
|
settings=settings_port,
|
|
system_config=get_configured_system_config(),
|
|
rule_group_mutation=rule_group_mutation,
|
|
)
|
|
return SimpleNamespace(system=service)
|
|
|
|
|
|
def test_logging_requires_superuser_dependency(
|
|
monkeypatch, isolated_log_path, system_runtime
|
|
):
|
|
"""实时日志查看接口必须通过管理员依赖,普通资源令牌不能直接读取日志。"""
|
|
(isolated_log_path / "moviepilot.log").write_text("hello\n", encoding="utf-8")
|
|
response = asyncio.run(
|
|
system_endpoint.get_logging(
|
|
request=SimpleNamespace(is_disconnected=lambda: False),
|
|
length=-1,
|
|
logfile="moviepilot.log",
|
|
_=SimpleNamespace(id=1, name="admin", is_superuser=True),
|
|
runtime=system_runtime,
|
|
)
|
|
)
|
|
|
|
assert isinstance(response, Response)
|
|
|
|
|
|
def test_download_moviepilot_logs_packages_latest_ten_log_files(
|
|
isolated_log_path, system_runtime
|
|
):
|
|
"""传入 moviepilot 时下载主程序滚动日志,最多打包 10 个文件。"""
|
|
for index in range(12):
|
|
(isolated_log_path / f"moviepilot.log.{index}").write_text(f"old-{index}", encoding="utf-8")
|
|
(isolated_log_path / "moviepilot.log").write_text("current", encoding="utf-8")
|
|
(isolated_log_path / "moviepilot.txt").write_text("ignored", encoding="utf-8")
|
|
(isolated_log_path / "plugins").mkdir()
|
|
(isolated_log_path / "plugins" / "demo.log").write_text("plugin", encoding="utf-8")
|
|
|
|
response = asyncio.run(
|
|
system_endpoint.download_logging(
|
|
name="moviepilot", _=SimpleNamespace(), runtime=system_runtime
|
|
)
|
|
)
|
|
body = asyncio.run(_read_streaming_body(response))
|
|
|
|
with zipfile.ZipFile(io.BytesIO(body)) as archive:
|
|
names = archive.namelist()
|
|
|
|
moviepilot_zip_root = response.headers["Content-Disposition"].split('filename="', 1)[1].removesuffix('.zip"')
|
|
assert response.media_type == "application/zip"
|
|
assert 'filename="moviepilot-logs-' in response.headers["Content-Disposition"]
|
|
assert "moviepilot-moviepilot-logs" not in response.headers["Content-Disposition"]
|
|
assert len(names) == 10
|
|
assert f"{moviepilot_zip_root}/moviepilot.log" in names
|
|
assert "moviepilot.log" not in names
|
|
assert "plugins/demo.log" not in names
|
|
assert "moviepilot.txt" not in names
|
|
|
|
|
|
def test_download_plugin_logs_packages_plugin_files_only(
|
|
isolated_log_path, system_runtime
|
|
):
|
|
"""传入插件 ID 时只下载该插件滚动日志,最多打包 10 个文件。"""
|
|
plugin_dir = isolated_log_path / "plugins"
|
|
plugin_dir.mkdir()
|
|
for index in range(11):
|
|
(plugin_dir / f"demoplugin.log.{index}").write_text(f"plugin-{index}", encoding="utf-8")
|
|
(plugin_dir / "demoplugin.log").write_text("current", encoding="utf-8")
|
|
(plugin_dir / "other.log").write_text("other", encoding="utf-8")
|
|
(isolated_log_path / "moviepilot.log").write_text("main", encoding="utf-8")
|
|
|
|
response = asyncio.run(
|
|
system_endpoint.download_logging(
|
|
name="DemoPlugin", _=SimpleNamespace(), runtime=system_runtime
|
|
)
|
|
)
|
|
body = asyncio.run(_read_streaming_body(response))
|
|
|
|
with zipfile.ZipFile(io.BytesIO(body)) as archive:
|
|
names = archive.namelist()
|
|
|
|
plugin_zip_root = response.headers["Content-Disposition"].split('filename="', 1)[1].removesuffix('.zip"')
|
|
assert len(names) == 10
|
|
assert f"{plugin_zip_root}/demoplugin.log" in names
|
|
assert "demoplugin.log" not in names
|
|
assert "plugins/demoplugin.log" not in names
|
|
assert "plugins/other.log" not in names
|
|
assert "moviepilot.log" not in names
|
|
|
|
|
|
def test_download_log_zip_generation_runs_outside_event_loop_thread(
|
|
monkeypatch, isolated_log_path, system_runtime
|
|
):
|
|
"""日志压缩 I/O 必须离开事件循环线程执行,避免大日志下载阻塞其他请求。"""
|
|
(isolated_log_path / "moviepilot.log").write_text("current", encoding="utf-8")
|
|
event_loop_thread = threading.current_thread().name
|
|
write_threads = []
|
|
original_write = zipfile.ZipFile.writestr
|
|
|
|
def capture_write_thread(self, zinfo_or_arcname, data, compress_type=None, compresslevel=None):
|
|
"""记录实际 zip 写入线程,并保持原始 ZipFile.write 行为。"""
|
|
write_threads.append(threading.current_thread().name)
|
|
return original_write(self, zinfo_or_arcname, data, compress_type, compresslevel)
|
|
|
|
monkeypatch.setattr(zipfile.ZipFile, "writestr", capture_write_thread)
|
|
|
|
response = asyncio.run(
|
|
system_endpoint.download_logging(
|
|
name="moviepilot", _=SimpleNamespace(), runtime=system_runtime
|
|
)
|
|
)
|
|
body = asyncio.run(_read_streaming_body(response))
|
|
|
|
assert body
|
|
assert write_threads
|
|
assert all(thread_name != event_loop_thread for thread_name in write_threads)
|
|
|
|
|
|
async def _read_streaming_body(response) -> bytes:
|
|
"""读取 StreamingResponse 内容,便于断言 zip 文件条目。"""
|
|
return b"".join([chunk async for chunk in response.body_iterator])
|