Files
MoviePilot/tests/test_config_secrets.py

305 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
验证部署 secret 的读取、来源顺序、坏文件隔离与配置写回保护。
"""
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Any
import pytest
from app.runtime.config import Settings, _get_secrets_dir
@pytest.fixture
def secret_config(tmp_path, monkeypatch):
"""
为每个用例隔离环境、环境文件和 secret 目录。
:yields: 配置构造函数、secret 目录、环境文件路径和收集的安全告警
"""
for name in Settings.model_fields:
monkeypatch.delenv(name, raising=False)
directory = tmp_path / "secrets"
directory.mkdir()
env_file = tmp_path / "app.env"
env_file.write_text("# isolated\n", encoding="utf-8")
messages = []
monkeypatch.setattr("app.runtime.config.get_env_path", lambda: env_file)
monkeypatch.setattr("app.runtime.config.logger.warning", messages.append)
def create(**kwargs: Any) -> Settings:
"""
在临时目录内构造真实配置实例。
"""
options = dict(_secrets_dir=directory, _env_file=env_file, CONFIG_DIR=str(tmp_path))
options.update(kwargs)
return Settings(**options)
yield create, directory, env_file, messages
def test_secrets_priority_and_no_environment_export(secret_config, monkeypatch):
"""
构造参数、进程环境、dotenv、secret 与默认值按原有顺序生效。
"""
create, directory, env_file, _messages = secret_config
(directory / "LOG_MAX_FILE_SIZE").write_text("7\n", encoding="utf-8")
assert create().LOG_MAX_FILE_SIZE == 7
env_file.write_text("LOG_MAX_FILE_SIZE='8'\n", encoding="utf-8")
assert create().LOG_MAX_FILE_SIZE == 8
monkeypatch.setenv("LOG_MAX_FILE_SIZE", "9")
assert create().LOG_MAX_FILE_SIZE == 9
assert create(LOG_MAX_FILE_SIZE=10).LOG_MAX_FILE_SIZE == 10
monkeypatch.delenv("LOG_MAX_FILE_SIZE")
env_file.write_text("# isolated\n", encoding="utf-8")
(directory / "LOG_MAX_FILE_SIZE").unlink()
assert create().LOG_MAX_FILE_SIZE == 5
token = "file-token-0123456789"
(directory / "API_TOKEN").write_text(token + "\n", encoding="utf-8")
before = env_file.read_bytes()
assert create().API_TOKEN == token
assert "API_TOKEN" not in os.environ
assert env_file.read_bytes() == before
@pytest.mark.parametrize("name, raw, expected", [
("DEBUG", "true", True),
("LOG_MAX_FILE_SIZE", "9", 9),
("ALLOWED_HOSTS", '["https://example.test"]', ["https://example.test"]),
("DB_CONNECT_ARGS", '{"connect_timeout":10}', {"connect_timeout": 10}),
("GITHUB_TOKEN", "\ufefffile-token-0123456789\r\n", "file-token-0123456789"),
("GITHUB_TOKEN", "first\nsecond\n", "first\nsecond"),
("GITHUB_TOKEN", "'quoted-value'", "'quoted-value'"),
])
def test_secrets_formats(secret_config, name, raw, expected):
"""
标量与 JSON 复合值按字段解析,BOM 和末尾换行不进入生效值。
"""
create, directory, env_file, _messages = secret_config
(directory / name).write_text(raw, encoding="utf-8")
before = env_file.read_bytes()
assert getattr(create(), name) == expected
assert env_file.read_bytes() == before
@pytest.mark.parametrize("name, raw", [
("API_TOKEN", b"short-token"),
("API_TOKEN", b"\xff\xfe"),
("DEBUG", b"notabool-sensitive"),
("LOG_MAX_FILE_SIZE", b"notanumber-sensitive"),
("LOG_LEVEL", b""),
("ALLOWED_HOSTS", b"not-json-sensitive"),
("ALLOWED_HOSTS", b"{}"),
("DB_CONNECT_ARGS", b"[]"),
("DB_CONNECT_ARGS", b"not-json-sensitive"),
("API_WORKERS", b"0"),
])
def test_invalid_secret_is_skipped_without_write_or_leak(secret_config, name, raw):
"""
不可读编码、空值、类型和约束错误只影响单项,不写环境文件或泄露原文。
"""
create, directory, env_file, messages = secret_config
baseline = create()
(directory / name).write_bytes(raw)
(directory / "GITHUB_TOKEN").write_text("valid-other-file", encoding="utf-8")
before = env_file.read_bytes()
config = create()
assert getattr(config, name) == getattr(baseline, name)
assert config.GITHUB_TOKEN == "valid-other-file"
assert config.update_setting(name, "replacement")[0] is False
assert env_file.read_bytes() == before
assert messages and name in messages[-1]
assert "sensitive" not in " ".join(messages)
assert "short-token" not in " ".join(messages)
def test_secret_is_readonly_even_when_shadowed(secret_config):
"""
已存在 secret 的字段不能被界面改写,历史 dotenv 遮蔽需手工移除。
"""
create, directory, env_file, _messages = secret_config
(directory / "DEBUG").write_text("true", encoding="utf-8")
config = create()
assert config.update_setting("DEBUG", True) == (None, "")
success, message = config.update_setting("DEBUG", False)
assert success is False and "secret" in message
assert config.DEBUG is True
env_file.write_text("DEBUG='False'\n", encoding="utf-8")
config = create()
assert config.DEBUG is False
assert config.update_setting("DEBUG", True)[0] is False
env_file.write_text("# removed override\n", encoding="utf-8")
assert create().DEBUG is True
def test_shadowed_secret_prevents_automatic_correction_write(secret_config):
"""
即使高优先级文件需要自动修正,也不能覆盖外部管理的 secret 项。
"""
create, directory, env_file, _messages = secret_config
(directory / "DEBUG").write_text("true", encoding="utf-8")
env_file.write_text("DEBUG='bad'\n", encoding="utf-8")
before = env_file.read_bytes()
assert create().DEBUG is False
assert env_file.read_bytes() == before
def test_secrets_rotation_and_instance_isolation(secret_config):
"""
文件轮换仅新实例读取;禁用 secrets 的实例不会继承其他实例的写回锁。
"""
create, directory, _env_file, _messages = secret_config
path = directory / "GITHUB_TOKEN"
path.write_text("before", encoding="utf-8")
first = create()
path.write_text("after", encoding="utf-8")
assert first.GITHUB_TOKEN == "before"
assert create().GITHUB_TOKEN == "after"
plain = create(_secrets_dir=())
assert plain.update_setting("GITHUB_TOKEN", "local")[0] is True
@pytest.mark.parametrize("kind", ["missing", "file", "unreadable", "entry_directory"])
def test_unavailable_secret_paths(secret_config, monkeypatch, kind):
"""
缺失目录不告警,错误目录或无权限文件按项告警并保留默认值。
"""
create, directory, _env_file, messages = secret_config
if kind == "missing":
directory.rmdir()
elif kind == "file":
directory.rmdir()
directory.write_text("not-a-directory", encoding="utf-8")
elif kind == "entry_directory":
(directory / "API_TOKEN").mkdir()
else:
(directory / "API_TOKEN").write_text("unreadable-token", encoding="utf-8")
original = Path.read_text
def read(path, *args, **kwargs):
"""
仅对目标 secret 模拟运行用户无读取权限。
"""
if path == directory / "API_TOKEN":
raise PermissionError
return original(path, *args, **kwargs)
monkeypatch.setattr(Path, "read_text", read)
assert create().API_TOKEN is None
assert bool(messages) is (kind != "missing")
def test_kubernetes_symlink_and_exact_case(secret_config):
"""
K8s 的投影符号链接可读,小写文件名不能冒充大写配置名。
"""
create, directory, env_file, _messages = secret_config
(directory / "api_token").write_text("lowercase-token-012345", encoding="utf-8")
assert create().API_TOKEN is None
(directory / "api_token").unlink()
data = directory / "..data"
data.mkdir()
(data / "API_TOKEN").write_text("projected-token-012345", encoding="utf-8")
(directory / "API_TOKEN").symlink_to("..data/API_TOKEN")
before = env_file.read_bytes()
assert create().API_TOKEN == "projected-token-012345"
assert env_file.read_bytes() == before
@pytest.mark.parametrize("value", ["", "false", "off", "0", "FALSE"])
def test_secrets_directory_can_be_disabled(monkeypatch, value):
"""
环境变量可显式关闭 secrets,专用变量优先于通用变量。
"""
monkeypatch.setenv("SECRETS_DIR", "/other/secrets")
monkeypatch.setenv("MOVIEPILOT_SECRETS_DIR", value)
assert _get_secrets_dir() is None
def test_secrets_directory_default_and_override(monkeypatch):
"""
默认路径与两个进程环境变量均有明确优先级。
"""
monkeypatch.delenv("MOVIEPILOT_SECRETS_DIR", raising=False)
monkeypatch.delenv("SECRETS_DIR", raising=False)
assert _get_secrets_dir() == "/run/secrets"
monkeypatch.setenv("SECRETS_DIR", "/custom/secrets")
assert _get_secrets_dir() == "/custom/secrets"
monkeypatch.setenv("MOVIEPILOT_SECRETS_DIR", "/preferred/secrets")
assert _get_secrets_dir() == "/preferred/secrets"
def test_secrets_directory_is_indexed_once(secret_config, monkeypatch):
"""
无关文件增多时仍只扫描一次目录,而非逐字段重复扫描。
"""
create, directory, _env_file, _messages = secret_config
for index in range(100):
(directory / f"unrelated-{index}").touch()
(directory / "DEBUG").write_text("true", encoding="utf-8")
original = Path.iterdir
scanned = []
def scan(path):
"""
记录目标目录扫描次数。
"""
if path == directory:
scanned.append(path)
return original(path)
monkeypatch.setattr(Path, "iterdir", scan)
assert create().DEBUG is True
assert scanned == [directory]
def test_unreadable_secret_directory_is_skipped(secret_config, monkeypatch):
"""
整个目录无权限时保留配置默认值且明确告警。
"""
create, directory, _env_file, messages = secret_config
original = Path.iterdir
def scan(path):
"""
模拟运行用户无法列出 secret 目录。
"""
if path == directory:
raise PermissionError
return original(path)
monkeypatch.setattr(Path, "iterdir", scan)
assert create().API_TOKEN is None
assert messages == ["secrets 目录不可用,请检查目录类型与读取权限"]
@pytest.mark.parametrize("selector", ["MOVIEPILOT_SECRETS_DIR", "SECRETS_DIR", "disabled"])
def test_module_singleton_loads_secret_without_constructor_override(secret_config, selector):
"""
真实导入单例会自动读取进程指定目录,关闭开关则不读取文件。
"""
_create, directory, env_file, _messages = secret_config
(directory / "API_TOKEN").write_text("module-token-0123456789", encoding="utf-8")
environment = dict(os.environ)
environment.pop("MOVIEPILOT_SECRETS_DIR", None)
environment.pop("SECRETS_DIR", None)
environment["CONFIG_DIR"] = str(env_file.parent)
environment["SECRETS_DIR" if selector == "disabled" else selector] = str(directory)
if selector == "disabled":
environment["MOVIEPILOT_SECRETS_DIR"] = "false"
before = env_file.read_bytes()
result = subprocess.run(
[sys.executable, "-c", (
"import json, os; from app.runtime.config import settings; "
"print(json.dumps([settings.API_TOKEN, 'API_TOKEN' in os.environ]))"
)],
env=environment, check=True, capture_output=True, text=True, timeout=30,
)
expected = None if selector == "disabled" else "module-token-0123456789"
assert json.loads(result.stdout.strip().splitlines()[-1]) == [expected, False]
assert env_file.read_bytes() == before