build: harden controller v2 runtime
This commit is contained in:
@@ -115,6 +115,30 @@ class ConfigurationTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ConfigError, "attempt, continue, or reject"):
|
||||
load_config(path, environ={})
|
||||
|
||||
def test_non_finite_timeouts_are_rejected(self):
|
||||
for value in (".nan", ".inf", "-.inf"):
|
||||
with self.subTest(value=value), tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "config.yaml"
|
||||
path.write_text(
|
||||
f"router:\n confirmation_timeout_seconds: {value}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaisesRegex(ConfigError, "must be finite"):
|
||||
load_config(path, environ={})
|
||||
|
||||
def test_runtime_paths_must_be_absolute(self):
|
||||
cases = (
|
||||
("storage:\n media_base: relative/media\n", "storage.media_base"),
|
||||
("storage:\n data_base: relative/data\n", "storage.data_base"),
|
||||
("downloads:\n cookie_path: relative/cookies.txt\n", "downloads.cookie_path"),
|
||||
)
|
||||
for contents, field in cases:
|
||||
with self.subTest(field=field), tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "config.yaml"
|
||||
path.write_text(contents, encoding="utf-8")
|
||||
with self.assertRaisesRegex(ConfigError, f"{field} must be an absolute path"):
|
||||
load_config(path, environ={})
|
||||
|
||||
|
||||
class ImportLifecycleTests(unittest.TestCase):
|
||||
def test_controller_core_import_does_not_connect_or_create_runtime_paths(self):
|
||||
|
||||
+75
-4
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -24,14 +25,34 @@ class ImmediateExecutor:
|
||||
|
||||
|
||||
class FakeYtDlp:
|
||||
def __init__(self, media_base: Path, *, fail_download=False, no_media=False):
|
||||
def __init__(
|
||||
self,
|
||||
media_base: Path,
|
||||
*,
|
||||
fail_download=False,
|
||||
no_media=False,
|
||||
rewrite_cookie=False,
|
||||
):
|
||||
self.media_base = media_base
|
||||
self.fail_download = fail_download
|
||||
self.no_media = no_media
|
||||
self.rewrite_cookie = rewrite_cookie
|
||||
self.calls = []
|
||||
self.cookie_observations = []
|
||||
|
||||
def __call__(self, arguments, **kwargs):
|
||||
self.calls.append((list(arguments), dict(kwargs)))
|
||||
if "--cookies" in arguments:
|
||||
cookie_path = Path(arguments[arguments.index("--cookies") + 1])
|
||||
self.cookie_observations.append(
|
||||
(
|
||||
cookie_path,
|
||||
cookie_path.read_text(encoding="utf-8"),
|
||||
stat.S_IMODE(cookie_path.stat().st_mode),
|
||||
)
|
||||
)
|
||||
if self.rewrite_cookie:
|
||||
cookie_path.write_text("updated by yt-dlp", encoding="utf-8")
|
||||
if "--flat-playlist" in arguments:
|
||||
return subprocess.CompletedProcess(
|
||||
arguments,
|
||||
@@ -92,7 +113,8 @@ class DownloadManagerTests(unittest.TestCase):
|
||||
|
||||
def test_success_uses_configured_cookie_and_atomically_builds_playlist(self):
|
||||
events = []
|
||||
runner = FakeYtDlp(self.media)
|
||||
self.cookie.chmod(0o444)
|
||||
runner = FakeYtDlp(self.media, rewrite_cookie=True)
|
||||
manager = self.manager(runner, events)
|
||||
result = manager.enqueue("https://example.test/playlist", "Alice", "Service")
|
||||
self.assertEqual(result.status, "reserved")
|
||||
@@ -109,20 +131,41 @@ class DownloadManagerTests(unittest.TestCase):
|
||||
self.assertEqual(list(playlist.parent.glob(".playlist.*.tmp")), [])
|
||||
for arguments, _ in runner.calls:
|
||||
self.assertIn("--ignore-config", arguments)
|
||||
self.assertIn("--no-js-runtimes", arguments)
|
||||
self.assertEqual(arguments[arguments.index("--js-runtimes") + 1], "node")
|
||||
self.assertEqual(
|
||||
arguments[arguments.index("--cache-dir") + 1],
|
||||
"/tmp/yt-dlp-cache",
|
||||
)
|
||||
self.assertIn("--cookies", arguments)
|
||||
self.assertIn(str(self.cookie), arguments)
|
||||
self.assertNotIn(str(self.cookie), arguments)
|
||||
self.assertEqual(len(runner.cookie_observations), 2)
|
||||
working_paths = [item[0] for item in runner.cookie_observations]
|
||||
self.assertEqual(len(set(working_paths)), 2)
|
||||
self.assertTrue(
|
||||
all(item[1] == "cookie data" for item in runner.cookie_observations)
|
||||
)
|
||||
self.assertTrue(
|
||||
all(item[2] == 0o600 for item in runner.cookie_observations)
|
||||
)
|
||||
self.assertTrue(all(not path.exists() for path in working_paths))
|
||||
self.assertEqual(self.cookie.read_text(encoding="utf-8"), "cookie data")
|
||||
download_arguments = runner.calls[-1][0]
|
||||
self.assertIn("bv*[height<=720]+ba/b[height<=720]/b", download_arguments)
|
||||
|
||||
def test_worker_failure_is_contained_and_persisted(self):
|
||||
events = []
|
||||
manager = self.manager(FakeYtDlp(self.media, fail_download=True), events)
|
||||
runner = FakeYtDlp(self.media, fail_download=True)
|
||||
manager = self.manager(runner, events)
|
||||
result = manager.enqueue("https://example.test/failure", None, None)
|
||||
self.assertEqual(result.status, "reserved")
|
||||
slot = self.store.get_slot(1)
|
||||
self.assertEqual(slot.media_state, "error")
|
||||
self.assertIn("exit code 7", slot.last_error)
|
||||
self.assertEqual(events[-1].state, "error")
|
||||
self.assertTrue(
|
||||
all(not observation[0].exists() for observation in runner.cookie_observations)
|
||||
)
|
||||
|
||||
def test_missing_required_cookie_fails_job_without_escaping(self):
|
||||
self.cookie.unlink()
|
||||
@@ -196,6 +239,34 @@ class DownloadManagerTests(unittest.TestCase):
|
||||
self.assertFalse(slot.exists())
|
||||
self.assertEqual(self.store.get_slot(1).media_state, "empty")
|
||||
|
||||
def test_interrupted_deletion_recovery_defers_a_new_playback_guard(self):
|
||||
reservation = self.store.reserve_download(
|
||||
"https://example.test/protected-recovery",
|
||||
None,
|
||||
None,
|
||||
job_id="protected-recovery",
|
||||
)
|
||||
self.store.fail_download(
|
||||
"protected-recovery",
|
||||
1,
|
||||
reservation.generation,
|
||||
"failed",
|
||||
)
|
||||
self.store.begin_delete(1, reason="manual")
|
||||
slot = self.media / "slot1"
|
||||
slot.mkdir(parents=True)
|
||||
(slot / "still-playing.mp4").write_bytes(b"playing")
|
||||
self.store.protect_slot(1, "player_active", guard_id="fresh-player")
|
||||
manager = self.manager(FakeYtDlp(self.media))
|
||||
|
||||
self.assertEqual(manager.recover_pending_deletions(), [])
|
||||
self.assertTrue(slot.exists())
|
||||
self.assertEqual(self.store.get_slot(1).media_state, "deleting")
|
||||
|
||||
self.store.clear_playback_guards(reason="fresh terminal idle evidence")
|
||||
self.assertEqual(manager.recover_pending_deletions(), [1])
|
||||
self.assertFalse(slot.exists())
|
||||
|
||||
def test_shutdown_terminates_tracked_process_without_waiting_for_job_timeout(self):
|
||||
manager = DownloadManager(self.config, self.store)
|
||||
errors = []
|
||||
|
||||
@@ -32,6 +32,7 @@ class FakeClient:
|
||||
self.acks = []
|
||||
self.suppress_exceptions = False
|
||||
self.connect_error = None
|
||||
self.connect_result = None
|
||||
self.loop_result = mqtt.MQTT_ERR_SUCCESS
|
||||
self.publish_acknowledged = None
|
||||
self.publish_hook = None
|
||||
@@ -52,7 +53,7 @@ class FakeClient:
|
||||
self.calls.append(("connect_async", args, kwargs))
|
||||
if self.connect_error is not None:
|
||||
raise self.connect_error
|
||||
return mqtt.MQTT_ERR_SUCCESS
|
||||
return self.connect_result
|
||||
|
||||
def connect(self, *args, **kwargs):
|
||||
self.calls.append(("connect", args, kwargs))
|
||||
@@ -123,6 +124,14 @@ class MqttAdapterTests(unittest.TestCase):
|
||||
self.assertEqual(connect[1], ("broker", 1884))
|
||||
self.assertEqual(connect[2], {"keepalive": 30})
|
||||
|
||||
def test_paho_two_connect_async_none_starts_network_loop(self):
|
||||
self.client.connect_result = None
|
||||
self.adapter.start()
|
||||
|
||||
self.assertTrue(self.adapter.started)
|
||||
self.assertIn("loop_start", [call[0] for call in self.client.calls])
|
||||
self.assertIsNone(self.adapter.last_error)
|
||||
|
||||
def test_initial_setup_oserror_does_not_escape_startup(self):
|
||||
self.client.connect_error = OSError("network unreachable")
|
||||
self.adapter.start()
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import ast
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _read(name: str) -> str:
|
||||
return (ROOT / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _docker_logical_lines(text: str) -> list[str]:
|
||||
logical: list[str] = []
|
||||
pending = ""
|
||||
for raw_line in text.splitlines():
|
||||
stripped = raw_line.strip()
|
||||
if not stripped or (not pending and stripped.startswith("#")):
|
||||
continue
|
||||
pending = f"{pending} {stripped}".strip()
|
||||
if pending.endswith("\\"):
|
||||
pending = pending[:-1].rstrip()
|
||||
continue
|
||||
logical.append(pending)
|
||||
pending = ""
|
||||
if pending:
|
||||
logical.append(pending)
|
||||
return logical
|
||||
|
||||
|
||||
def _environment_map(value: Any) -> dict[str, str | None]:
|
||||
if isinstance(value, dict):
|
||||
return {str(key): None if item is None else str(item) for key, item in value.items()}
|
||||
result: dict[str, str | None] = {}
|
||||
for entry in value or []:
|
||||
key, separator, item = str(entry).partition("=")
|
||||
result[key] = item if separator else None
|
||||
return result
|
||||
|
||||
|
||||
def _volume_mounts(value: Any) -> list[tuple[str | None, str, bool]]:
|
||||
mounts: list[tuple[str | None, str, bool]] = []
|
||||
for entry in value or []:
|
||||
if isinstance(entry, dict):
|
||||
target = str(entry.get("target") or "")
|
||||
if target:
|
||||
mounts.append(
|
||||
(
|
||||
None if entry.get("source") is None else str(entry["source"]),
|
||||
target,
|
||||
bool(entry.get("read_only", False)),
|
||||
)
|
||||
)
|
||||
continue
|
||||
# Greedy source matching tolerates Compose substitutions containing
|
||||
# `:-` or `:?` while keeping the absolute container target distinct.
|
||||
match = re.match(r"^(.*):(/[^:]+)(?::([^:]+))?$", str(entry))
|
||||
if match:
|
||||
mode = match.group(3) or ""
|
||||
mounts.append((match.group(1), match.group(2), "ro" in mode.split(",")))
|
||||
return mounts
|
||||
|
||||
|
||||
def _secret_targets(value: Any) -> set[str]:
|
||||
targets: set[str] = set()
|
||||
for entry in value or []:
|
||||
if isinstance(entry, dict):
|
||||
source = str(entry.get("source") or "")
|
||||
target = str(entry.get("target") or source)
|
||||
else:
|
||||
source = str(entry)
|
||||
target = source
|
||||
if not target:
|
||||
continue
|
||||
targets.add(target if target.startswith("/") else f"/run/secrets/{target}")
|
||||
return targets
|
||||
|
||||
|
||||
class RequirementPinTests(unittest.TestCase):
|
||||
def test_runtime_requirements_are_exactly_pinned(self):
|
||||
path = ROOT / "requirements.txt"
|
||||
self.assertTrue(path.is_file(), "requirements.txt is missing")
|
||||
requirements: dict[str, str] = {}
|
||||
pattern = re.compile(
|
||||
r"^([A-Za-z0-9_.-]+)(?:\[[A-Za-z0-9_,.-]+\])?==([^;\s]+)(?:\s*;.*)?$"
|
||||
)
|
||||
for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
line = raw_line.split("#", 1)[0].strip()
|
||||
if not line:
|
||||
continue
|
||||
match = pattern.fullmatch(line)
|
||||
self.assertIsNotNone(
|
||||
match,
|
||||
f"requirements.txt:{line_number} is not an exact == pin: {line!r}",
|
||||
)
|
||||
assert match is not None
|
||||
normalized = re.sub(r"[-_.]+", "-", match.group(1)).lower()
|
||||
requirements[normalized] = match.group(2)
|
||||
|
||||
for project in ("flask", "gunicorn", "paho-mqtt", "pyyaml", "yt-dlp"):
|
||||
self.assertIn(project, requirements, f"missing runtime dependency {project}")
|
||||
self.assertNotIn("*", requirements[project])
|
||||
|
||||
|
||||
class DockerfileTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.path = ROOT / "Dockerfile"
|
||||
self.assertTrue(self.path.is_file(), "Dockerfile is missing")
|
||||
self.text = self.path.read_text(encoding="utf-8")
|
||||
self.lines = _docker_logical_lines(self.text)
|
||||
|
||||
def test_image_copies_the_v2_runtime(self):
|
||||
copies = "\n".join(
|
||||
line for line in self.lines if line.upper().startswith("COPY ")
|
||||
)
|
||||
for required in (
|
||||
"controller",
|
||||
"config.yaml",
|
||||
"app.py",
|
||||
"wsgi.py",
|
||||
"gunicorn.conf.py",
|
||||
):
|
||||
self.assertIn(required, copies, f"Dockerfile does not copy {required}")
|
||||
|
||||
def test_image_runs_non_root_on_the_controller_port(self):
|
||||
users = [line.split(None, 1)[1] for line in self.lines if line.upper().startswith("USER ")]
|
||||
self.assertTrue(users, "Dockerfile must set a non-root USER")
|
||||
self.assertNotIn(users[-1].strip().lower(), {"root", "0", "0:0"})
|
||||
self.assertRegex(self.text, r"(?im)^\s*EXPOSE\s+7531(?:/tcp)?\s*$")
|
||||
|
||||
def test_image_has_an_http_healthcheck_and_runs_gunicorn(self):
|
||||
self.assertRegex(self.text, r"(?im)^\s*HEALTHCHECK\b")
|
||||
self.assertIn("/healthz", self.text)
|
||||
launch = "\n".join(
|
||||
line
|
||||
for line in self.lines
|
||||
if line.upper().startswith(("CMD ", "ENTRYPOINT "))
|
||||
).lower()
|
||||
self.assertIn("gunicorn", launch)
|
||||
|
||||
def test_image_has_no_unbounded_yt_dlp_upgrade_or_hidden_global_config(self):
|
||||
self.assertNotRegex(
|
||||
self.text,
|
||||
r"(?i)(?:--upgrade|-U)\s+[\"']?(?:yt-dlp|yt_dlp)",
|
||||
)
|
||||
self.assertNotIn("/etc/yt-dlp.conf", self.text)
|
||||
|
||||
def test_image_verifies_a_supported_node_runtime_for_yt_dlp(self):
|
||||
self.assertRegex(
|
||||
self.text,
|
||||
r"(?im)^FROM\s+node:22\.\d+\.\d+-bookworm-slim\s+AS\s+",
|
||||
)
|
||||
self.assertIn("NodeJsRuntime", self.text)
|
||||
self.assertIn("info.supported", self.text)
|
||||
|
||||
|
||||
class GunicornConfigTests(unittest.TestCase):
|
||||
def test_gunicorn_uses_exactly_one_worker_without_preload(self):
|
||||
path = ROOT / "gunicorn.conf.py"
|
||||
self.assertTrue(path.is_file(), "gunicorn.conf.py is missing")
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
assignments: dict[str, Any] = {}
|
||||
for node in tree.body:
|
||||
if not isinstance(node, (ast.Assign, ast.AnnAssign)):
|
||||
continue
|
||||
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
||||
try:
|
||||
value = ast.literal_eval(node.value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
for target in targets:
|
||||
if isinstance(target, ast.Name):
|
||||
assignments[target.id] = value
|
||||
|
||||
self.assertEqual(assignments.get("workers"), 1)
|
||||
self.assertIs(assignments.get("preload_app"), False)
|
||||
self.assertIs(assignments.get("control_socket_disable"), True)
|
||||
self.assertEqual(assignments.get("worker_tmp_dir"), "/tmp")
|
||||
|
||||
|
||||
class ComposeRuntimeTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.path = ROOT / "docker-compose.yml"
|
||||
self.assertTrue(self.path.is_file(), "docker-compose.yml is missing")
|
||||
self.text = self.path.read_text(encoding="utf-8")
|
||||
document = yaml.safe_load(self.text)
|
||||
self.assertIsInstance(document, dict)
|
||||
self.document = document
|
||||
self.service = document.get("services", {}).get("controller")
|
||||
self.assertIsInstance(self.service, dict, "Compose controller service is missing")
|
||||
|
||||
def test_compose_requires_a_real_flask_secret_and_publishes_port_7531(self):
|
||||
environment = _environment_map(self.service.get("environment"))
|
||||
secret = environment.get("FLASK_SECRET_KEY")
|
||||
self.assertIsNotNone(secret, "FLASK_SECRET_KEY environment setting is missing")
|
||||
assert secret is not None
|
||||
self.assertTrue(secret.startswith("${FLASK_SECRET_KEY"))
|
||||
self.assertIn("?", secret, "FLASK_SECRET_KEY must use required Compose substitution")
|
||||
self.assertNotIn("change-me", secret.lower())
|
||||
self.assertNotIn("dev-secret", secret.lower())
|
||||
|
||||
published_targets: set[str] = set()
|
||||
for entry in self.service.get("ports") or []:
|
||||
if isinstance(entry, dict):
|
||||
published_targets.add(str(entry.get("target")))
|
||||
else:
|
||||
match = re.search(r":(\d+)(?:/(?:tcp|udp))?$", str(entry))
|
||||
if match:
|
||||
published_targets.add(match.group(1))
|
||||
self.assertIn("7531", published_targets)
|
||||
|
||||
def test_compose_uses_dedicated_data_media_and_cookie_mounts(self):
|
||||
mounts = _volume_mounts(self.service.get("volumes"))
|
||||
by_target = {target: (source, read_only) for source, target, read_only in mounts}
|
||||
self.assertIn("/controller-data", by_target)
|
||||
self.assertIn("/mnt/Media/YTPlayer", by_target)
|
||||
self.assertNotEqual(by_target["/controller-data"][0], "/home/media/Scripts/Video-Player")
|
||||
|
||||
cookie_path = "/run/secrets/yt-dlp-cookies.txt"
|
||||
mounted_cookie = cookie_path in by_target and by_target[cookie_path][1]
|
||||
mounted_secret_directory = (
|
||||
"/run/secrets" in by_target and by_target["/run/secrets"][1]
|
||||
)
|
||||
secret_cookie = cookie_path in _secret_targets(self.service.get("secrets"))
|
||||
self.assertTrue(
|
||||
mounted_cookie or mounted_secret_directory or secret_cookie,
|
||||
f"cookies must be read-only at exactly {cookie_path}",
|
||||
)
|
||||
self.assertNotIn("/cookies_ro.txt", self.text)
|
||||
self.assertNotRegex(
|
||||
self.text,
|
||||
r"/home/media/Scripts/Video-Player(?=[:\s\"'])",
|
||||
)
|
||||
|
||||
def test_compose_enables_container_hardening_and_bounded_operations(self):
|
||||
self.assertIs(self.service.get("read_only"), True)
|
||||
self.assertIs(self.service.get("init"), True)
|
||||
|
||||
cap_drop = self.service.get("cap_drop") or []
|
||||
if isinstance(cap_drop, str):
|
||||
cap_drop = [cap_drop]
|
||||
self.assertIn("ALL", {str(value).upper() for value in cap_drop})
|
||||
|
||||
security_options = self.service.get("security_opt") or []
|
||||
if isinstance(security_options, str):
|
||||
security_options = [security_options]
|
||||
self.assertTrue(
|
||||
any("no-new-privileges" in str(value).lower() for value in security_options)
|
||||
)
|
||||
|
||||
tmpfs = self.service.get("tmpfs") or []
|
||||
if isinstance(tmpfs, str):
|
||||
tmpfs = [tmpfs]
|
||||
self.assertTrue(any(str(value).split(":", 1)[0] == "/tmp" for value in tmpfs))
|
||||
self.assertTrue(any("noexec" in str(value) for value in tmpfs))
|
||||
|
||||
healthcheck = self.service.get("healthcheck")
|
||||
self.assertIsInstance(healthcheck, dict)
|
||||
self.assertIn("/healthz", str(healthcheck.get("test")))
|
||||
|
||||
logging_config = self.service.get("logging")
|
||||
self.assertIsInstance(logging_config, dict)
|
||||
options = logging_config.get("options") or {}
|
||||
self.assertTrue(options.get("max-size"), "logging max-size is missing")
|
||||
self.assertTrue(options.get("max-file"), "logging max-file is missing")
|
||||
grace = str(self.service.get("stop_grace_period") or "").rstrip("s")
|
||||
self.assertGreaterEqual(float(grace), 30.0)
|
||||
|
||||
def test_compose_preserves_the_external_tabernet_network(self):
|
||||
service_networks = self.service.get("networks") or []
|
||||
if isinstance(service_networks, dict):
|
||||
service_networks = service_networks.keys()
|
||||
self.assertIn("tabernet", service_networks)
|
||||
tabernet = self.document.get("networks", {}).get("tabernet")
|
||||
self.assertIsInstance(tabernet, dict)
|
||||
self.assertIs(tabernet.get("external"), True)
|
||||
|
||||
|
||||
class DockerIgnoreTests(unittest.TestCase):
|
||||
def test_dockerignore_excludes_secrets_and_python_caches(self):
|
||||
path = ROOT / ".dockerignore"
|
||||
self.assertTrue(path.is_file(), ".dockerignore is missing")
|
||||
patterns = {
|
||||
line.strip().lower()
|
||||
for line in path.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip() and not line.lstrip().startswith("#")
|
||||
}
|
||||
self.assertTrue(any("__pycache__" in pattern for pattern in patterns))
|
||||
self.assertTrue(
|
||||
any("pyc" in pattern or "py[cod]" in pattern for pattern in patterns)
|
||||
)
|
||||
self.assertTrue(any(pattern in {".env", ".env*", "*.env"} for pattern in patterns))
|
||||
self.assertTrue(any("secret" in pattern for pattern in patterns))
|
||||
self.assertTrue(any("cookie" in pattern for pattern in patterns))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+57
-2
@@ -14,8 +14,9 @@ from controller.config import (
|
||||
StorageConfig,
|
||||
)
|
||||
from controller.downloads import canonical_playlist_path
|
||||
from controller.models import SlotProtectedError
|
||||
from controller.mqtt_adapter import MqttEvent, PublishReceipt
|
||||
from controller.service import ControllerService
|
||||
from controller.service import ControllerService, ControllerUnavailableError
|
||||
from controller.store import ControllerStore
|
||||
|
||||
|
||||
@@ -82,12 +83,20 @@ class FakeMqttAdapter:
|
||||
class FakeDownloadManager:
|
||||
def __init__(self):
|
||||
self.recovered = False
|
||||
self.recovery_calls = 0
|
||||
self.expiry_calls = 0
|
||||
self.stopped = False
|
||||
|
||||
def recover_pending_deletions(self):
|
||||
self.recovered = True
|
||||
|
||||
def schedule_pending_deletion_recovery(self):
|
||||
self.recovered = True
|
||||
self.recovery_calls += 1
|
||||
return True
|
||||
|
||||
def expire_due_slots(self):
|
||||
self.expiry_calls += 1
|
||||
return []
|
||||
|
||||
def shutdown(self, *, wait):
|
||||
@@ -144,7 +153,7 @@ class ControllerServiceTests(unittest.TestCase):
|
||||
wall_clock=self.wall,
|
||||
)
|
||||
self.service.start()
|
||||
self.assertTrue(self.downloads.recovered)
|
||||
self.assertFalse(self.downloads.recovered)
|
||||
return self.service
|
||||
|
||||
def ready_slot(self, slot=1):
|
||||
@@ -414,6 +423,52 @@ class ControllerServiceTests(unittest.TestCase):
|
||||
self.assertTrue(health["coordinator_alive"])
|
||||
self.assertEqual(health["status"], "degraded")
|
||||
self.assertIn("mqtt_disconnected", health["degraded_reasons"])
|
||||
self.assertIn("awaiting_player_evidence", health["degraded_reasons"])
|
||||
|
||||
def test_cleanup_and_downloads_wait_for_post_start_player_evidence(self):
|
||||
self.start_service()
|
||||
self.service._next_expiry = self.monotonic()
|
||||
time.sleep(0.2)
|
||||
|
||||
self.assertEqual(self.downloads.expiry_calls, 0)
|
||||
self.assertFalse(self.downloads.recovered)
|
||||
with self.assertRaisesRegex(
|
||||
ControllerUnavailableError,
|
||||
"deferred until fresh Player evidence",
|
||||
):
|
||||
self.service.enqueue_download(
|
||||
"https://example.test/deferred",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
||||
self.send_message(
|
||||
self.config.topics.player_state,
|
||||
"idle",
|
||||
retain=True,
|
||||
)
|
||||
self.wait_for(
|
||||
lambda: self.downloads.recovered,
|
||||
"retained Player idle did not release deferred recovery",
|
||||
)
|
||||
self.wait_for(
|
||||
lambda: self.downloads.expiry_calls > 0,
|
||||
"Player idle did not release deferred expiry",
|
||||
)
|
||||
self.assertNotIn(
|
||||
"awaiting_player_evidence",
|
||||
self.service.health()["degraded_reasons"],
|
||||
)
|
||||
|
||||
def test_manual_delete_requires_explicit_override_while_player_is_unknown(self):
|
||||
self.start_service()
|
||||
self.ready_slot(1)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
SlotProtectedError,
|
||||
"confirm_player_unknown=true",
|
||||
):
|
||||
self.service.submit_control({"action": "delete_slot", "slot": 1})
|
||||
|
||||
def test_administrator_can_clear_exact_uncertain_playback_review(self):
|
||||
self.start_service()
|
||||
|
||||
Reference in New Issue
Block a user