Sicherheits-Härtung und Bugfixes
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_TMP = tempfile.mkdtemp(prefix="stream-recorder-tests-")
|
||||
os.environ["DATA_DIR"] = os.path.join(_TMP, "data")
|
||||
os.environ["STATIC_DIR"] = os.path.join(_TMP, "no-static")
|
||||
os.environ["RECORDING_ROOTS"] = os.path.join(_TMP, "recordings")
|
||||
os.environ.pop("ENABLE_API_DOCS", None)
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
import app as app_module # noqa: E402
|
||||
from config import hash_password # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
from models import get_session, Job, Station, Recording, LogEntry # noqa: E402
|
||||
|
||||
TEST_PASSWORD = "geheim-12345"
|
||||
# Schneller Hash für Tests
|
||||
TEST_HASH = hash_password(TEST_PASSWORD, iterations=1000)
|
||||
|
||||
|
||||
def basic(user, password):
|
||||
return "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_mod():
|
||||
return app_module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app_mod):
|
||||
cfg = app_mod.config
|
||||
with cfg.lock:
|
||||
cfg.data["auth"] = {"enabled": False, "username": "admin", "password_hash": TEST_HASH}
|
||||
cfg.data["ntfy"] = {"url": "https://ntfy.example/topic", "token": "tk_secret", "events": "error"}
|
||||
cfg.data["plik"] = {"url": "https://plik.example", "api_key": "plik_secret", "ttl": "30d"}
|
||||
cfg.data["recording"]["download_path"] = os.environ["RECORDING_ROOTS"]
|
||||
app_mod.clear_auth_cache()
|
||||
app_mod.reset_auth_failures()
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
for model in (Job, Station, Recording, LogEntry):
|
||||
session.query(model).delete()
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
return TestClient(app_mod.app)
|
||||
@@ -0,0 +1,282 @@
|
||||
import datetime
|
||||
import os
|
||||
|
||||
from conftest import TEST_PASSWORD, basic
|
||||
from models import get_session, Job, Recording, LogEntry, utcnow
|
||||
|
||||
|
||||
def _job(**kw):
|
||||
data = {"name": "Testjob", "stream_url": "https://example.com/stream.mp3"}
|
||||
data.update(kw)
|
||||
return data
|
||||
|
||||
|
||||
# --- Config ---
|
||||
|
||||
def test_config_get_never_leaks_secrets(client):
|
||||
r = client.get("/api/config")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
text = r.text
|
||||
for secret in ("tk_secret", "plik_secret", "pbkdf2", TEST_PASSWORD):
|
||||
assert secret not in text
|
||||
assert body["auth"]["password_set"] is True
|
||||
assert body["ntfy"]["token_set"] is True
|
||||
assert body["plik"]["api_key_set"] is True
|
||||
assert "password" not in body["auth"] and "password_hash" not in body["auth"]
|
||||
assert "token" not in body["ntfy"] and "api_key" not in body["plik"]
|
||||
assert set(body) == {"recording", "server", "auth", "ntfy", "plik", "schedule", "logging"}
|
||||
|
||||
|
||||
def test_config_put_forbidden_download_path(client):
|
||||
for path in ("/etc", "relative/path", os.environ["RECORDING_ROOTS"] + "/../escape"):
|
||||
r = client.put("/api/config", json={"data": {"recording": {"download_path": path}}})
|
||||
assert r.status_code == 400, path
|
||||
|
||||
|
||||
def test_config_put_allowed_download_path(client, app_mod):
|
||||
sub = os.path.join(os.environ["RECORDING_ROOTS"], "sub")
|
||||
r = client.put("/api/config", json={"data": {"recording": {"download_path": sub}}})
|
||||
assert r.status_code == 200, r.text
|
||||
assert app_mod.config.get("recording", "download_path") == os.path.realpath(sub)
|
||||
|
||||
|
||||
def test_config_put_rejects_server_and_unknown(client):
|
||||
assert client.put("/api/config", json={"data": {"server": {"port": 1}}}).status_code == 422
|
||||
assert client.put("/api/config", json={"data": {"auth": {"foo": 1}}}).status_code == 422
|
||||
assert client.put("/api/config", json={"data": {"recording": {"max_retries": 5000}}}).status_code == 422
|
||||
assert client.put("/api/config", json={"data": {"logging": {"level": "TRACE"}}}).status_code == 422
|
||||
|
||||
|
||||
def test_config_put_semantic_validation(client):
|
||||
assert client.put("/api/config", json={"data": {"ntfy": {"events": "start,foo"}}}).status_code == 400
|
||||
assert client.put("/api/config", json={"data": {"ntfy": {"url": "file:///x"}}}).status_code == 400
|
||||
assert client.put("/api/config", json={"data": {"plik": {"ttl": "30x"}}}).status_code == 400
|
||||
assert client.put("/api/config", json={"data": {"plik": {"ttl": "12h"}}}).status_code == 200
|
||||
|
||||
|
||||
def test_config_secrets_semantics(client, app_mod):
|
||||
cfg = app_mod.config
|
||||
# fehlt -> unverändert
|
||||
assert client.put("/api/config", json={"data": {"ntfy": {"url": "https://n.example/t"}}}).status_code == 200
|
||||
assert cfg.get("ntfy", "token") == "tk_secret"
|
||||
# leer -> löschen
|
||||
assert client.put("/api/config", json={"data": {"ntfy": {"token": ""}, "plik": {"api_key": ""}}}).status_code == 200
|
||||
assert cfg.get("ntfy", "token") == "" and cfg.get("plik", "api_key") == ""
|
||||
# Passwort leer -> 400, zu kurz -> 400
|
||||
assert client.put("/api/config", json={"data": {"auth": {"password": ""}}}).status_code == 400
|
||||
assert client.put("/api/config", json={"data": {"auth": {"password": "kurz"}}}).status_code == 400
|
||||
old_hash = cfg.get("auth", "password_hash")
|
||||
assert client.put("/api/config", json={"data": {"auth": {"password": "neues-passwort"}}}).status_code == 200
|
||||
assert cfg.get("auth", "password_hash") != old_hash
|
||||
assert "neues-passwort" not in open(cfg.config_path, encoding="utf-8").read()
|
||||
|
||||
|
||||
def test_enable_auth_without_password(client, app_mod):
|
||||
app_mod.config.data["auth"]["password_hash"] = ""
|
||||
r = client.put("/api/config", json={"data": {"auth": {"enabled": True}}})
|
||||
assert r.status_code == 400
|
||||
r = client.put("/api/config", json={"data": {"auth": {"enabled": True, "password": "langes-passwort"}}})
|
||||
assert r.status_code == 200
|
||||
assert client.get("/api/config").status_code == 401
|
||||
|
||||
|
||||
# --- Auth ---
|
||||
|
||||
def test_auth_middleware(client, app_mod):
|
||||
app_mod.config.data["auth"]["enabled"] = True
|
||||
assert client.get("/api/health").status_code == 200
|
||||
assert client.get("/api/health/x").status_code == 401
|
||||
r = client.get("/api/jobs")
|
||||
assert r.status_code == 401
|
||||
assert "Basic" in r.headers["www-authenticate"]
|
||||
assert client.get("/api/jobs", headers={"Authorization": basic("admin", TEST_PASSWORD)}).status_code == 200
|
||||
# zweiter Aufruf über Cache
|
||||
assert client.get("/api/jobs", headers={"Authorization": basic("admin", TEST_PASSWORD)}).status_code == 200
|
||||
assert client.get("/api/jobs", headers={"Authorization": "Basic !!!"}).status_code == 401
|
||||
assert client.get("/api/jobs", headers={"Authorization": basic("root", TEST_PASSWORD)}).status_code == 401
|
||||
|
||||
|
||||
def test_auth_bruteforce_429(client, app_mod):
|
||||
app_mod.config.data["auth"]["enabled"] = True
|
||||
for _ in range(10):
|
||||
assert client.get("/api/jobs", headers={"Authorization": basic("admin", "falsch")}).status_code == 401
|
||||
assert client.get("/api/jobs", headers={"Authorization": basic("admin", "falsch")}).status_code == 429
|
||||
assert client.get("/api/jobs", headers={"Authorization": basic("admin", TEST_PASSWORD)}).status_code == 429
|
||||
assert client.get("/api/health").status_code == 200
|
||||
|
||||
|
||||
# --- CSRF / Header ---
|
||||
|
||||
def test_csrf(client):
|
||||
body = _job()
|
||||
assert client.post("/api/jobs", json=body, headers={"Sec-Fetch-Site": "cross-site"}).status_code == 403
|
||||
assert client.post("/api/jobs", json=body, headers={"Origin": "https://evil.example"}).status_code == 403
|
||||
assert client.post("/api/jobs", json=body, headers={"Origin": "null"}).status_code == 403
|
||||
r = client.post("/api/jobs", json=body, headers={"Origin": "http://testserver", "Sec-Fetch-Site": "same-origin"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert client.post("/api/jobs", json=body).status_code == 200
|
||||
# GET bleibt unberührt
|
||||
assert client.get("/api/jobs", headers={"Sec-Fetch-Site": "cross-site"}).status_code == 200
|
||||
|
||||
|
||||
def test_security_headers_and_docs(client):
|
||||
r = client.get("/api/health")
|
||||
assert r.headers["x-content-type-options"] == "nosniff"
|
||||
assert r.headers["x-frame-options"] == "DENY"
|
||||
assert r.headers["referrer-policy"] == "no-referrer"
|
||||
assert "frame-ancestors 'none'" in r.headers["content-security-policy"]
|
||||
for path in ("/docs", "/redoc", "/openapi.json"):
|
||||
assert client.get(path).status_code == 404
|
||||
|
||||
|
||||
# --- Jobs ---
|
||||
|
||||
def test_job_validation(client):
|
||||
assert client.post("/api/jobs", json=_job(stream_url="file:///etc/passwd")).status_code == 400
|
||||
assert client.post("/api/jobs", json=_job(stream_url="concat:a|b")).status_code == 400
|
||||
r = client.post("/api/jobs", json=_job(extra_ffmpeg_args="-c:a copy /app/app.py"))
|
||||
assert r.status_code == 400 and "/app/app.py" in r.json()["detail"]
|
||||
assert client.post("/api/jobs", json=_job(schedule_enabled=True)).status_code == 400
|
||||
assert client.post("/api/jobs", json=_job(schedule_start="24:00")).status_code == 400
|
||||
assert client.post("/api/jobs", json=_job(station_id=9999)).status_code == 400
|
||||
assert client.post("/api/jobs", json=_job(stream_type="evil")).status_code == 422
|
||||
assert client.post("/api/jobs", json=_job(name=" ")).status_code == 422
|
||||
assert client.post("/api/jobs", json=_job(segment_duration=5)).status_code == 422
|
||||
assert client.post("/api/jobs", json=_job(pre_buffer=500)).status_code == 422
|
||||
r = client.post("/api/jobs", json=_job(segment_duration=0, schedule_enabled=True, schedule_start="10:00",
|
||||
schedule_days="mo-fr", extra_ffmpeg_args="-c:a libmp3lame -b:a 128k"))
|
||||
assert r.status_code == 200, r.text
|
||||
job = client.get(f"/api/jobs/{r.json()['id']}").json()
|
||||
assert job["segment_duration"] is None
|
||||
|
||||
|
||||
def test_timestamps_are_utc_aware(client):
|
||||
job_id = client.post("/api/jobs", json=_job()).json()["id"]
|
||||
station_id = client.post("/api/stations", json={"name": "S", "stream_url": "https://e.example/s"}).json()["id"]
|
||||
session = get_session()
|
||||
try:
|
||||
session.add(Recording(job_id=None, job_name="x", stream_url="https://e.example", status="completed",
|
||||
stopped_at=utcnow()))
|
||||
session.add(LogEntry(level="INFO", message="m"))
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
stamps = [
|
||||
client.get(f"/api/jobs/{job_id}").json()["created_at"],
|
||||
client.get("/api/jobs").json()[0]["created_at"],
|
||||
client.get(f"/api/stations/{station_id}").json()["created_at"],
|
||||
client.get("/api/recordings").json()[0]["started_at"],
|
||||
client.get("/api/recordings").json()[0]["stopped_at"],
|
||||
client.get("/api/logs").json()[0]["timestamp"],
|
||||
]
|
||||
for s in stamps:
|
||||
assert s.endswith("+00:00"), s
|
||||
assert abs(datetime.datetime.fromisoformat(s) - datetime.datetime.now(datetime.timezone.utc)).total_seconds() < 120
|
||||
|
||||
|
||||
def test_delete_station_unlinks_jobs(client):
|
||||
sid = client.post("/api/stations", json={"name": "S", "stream_url": "https://e.example/s"}).json()["id"]
|
||||
jid = client.post("/api/jobs", json=_job(station_id=sid)).json()["id"]
|
||||
assert client.delete(f"/api/stations/{sid}").status_code == 200
|
||||
assert client.get(f"/api/jobs/{jid}").json()["station_id"] is None
|
||||
|
||||
|
||||
def _add_recording(**kw):
|
||||
session = get_session()
|
||||
try:
|
||||
rec = Recording(job_name="x", stream_url="https://e.example", **kw)
|
||||
session.add(rec)
|
||||
session.commit()
|
||||
return rec.id
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_delete_and_start_job_while_recording(client, app_mod, monkeypatch):
|
||||
jid = client.post("/api/jobs", json=_job()).json()["id"]
|
||||
_add_recording(job_id=jid, status="running")
|
||||
assert client.delete(f"/api/jobs/{jid}").status_code == 400
|
||||
called = []
|
||||
monkeypatch.setattr(app_mod.recorder, "start_recording", lambda **kw: called.append(kw) or {})
|
||||
assert client.post(f"/api/jobs/{jid}/record").status_code == 409
|
||||
assert not called
|
||||
|
||||
|
||||
def test_start_job_legacy_bad_data(client, app_mod, monkeypatch):
|
||||
session = get_session()
|
||||
try:
|
||||
job = Job(name="alt", stream_url="https://e.example/s", schedule_start="10:00", schedule_stop="xx")
|
||||
session.add(job)
|
||||
session.commit()
|
||||
jid = job.id
|
||||
finally:
|
||||
session.close()
|
||||
monkeypatch.setattr(app_mod.recorder, "start_recording", lambda **kw: {"recording_id": 1})
|
||||
assert client.post(f"/api/jobs/{jid}/record").status_code == 400
|
||||
|
||||
|
||||
def test_start_job_passes_duration(client, app_mod, monkeypatch):
|
||||
jid = client.post("/api/jobs", json=_job(schedule_start="10:00", schedule_stop="11:30",
|
||||
pre_buffer=5)).json()["id"]
|
||||
calls = []
|
||||
monkeypatch.setattr(app_mod.recorder, "start_recording", lambda **kw: calls.append(kw) or {"recording_id": 1})
|
||||
assert client.post(f"/api/jobs/{jid}/record").status_code == 200
|
||||
assert calls[0]["max_duration"] == 5400
|
||||
|
||||
|
||||
def test_quick_record_validation(client, app_mod, monkeypatch):
|
||||
monkeypatch.setattr(app_mod.recorder, "start_recording", lambda **kw: {"recording_id": 1})
|
||||
assert client.post("/api/quick-record", json={"url": "file:///etc/passwd"}).status_code == 400
|
||||
assert client.post("/api/quick-record", json={"url": "https://e.example/s", "max_duration": 0}).status_code == 422
|
||||
assert client.post("/api/quick-record", json={"url": "https://e.example/s"}).status_code == 200
|
||||
|
||||
|
||||
# --- Recordings ---
|
||||
|
||||
def test_extend(client):
|
||||
unlimited = _add_recording(status="running")
|
||||
r = client.post(f"/api/recordings/{unlimited}/extend?minutes=10")
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"] == "Aufnahme ist bereits unbegrenzt"
|
||||
|
||||
stop = utcnow() + datetime.timedelta(minutes=5)
|
||||
limited = _add_recording(status="running", scheduled_stop=stop)
|
||||
assert client.post(f"/api/recordings/{limited}/extend?minutes=10").status_code == 200
|
||||
rec = client.get(f"/api/recordings/{limited}").json()
|
||||
new_stop = datetime.datetime.fromisoformat(rec["scheduled_stop"]).replace(tzinfo=None)
|
||||
assert abs((new_stop - stop).total_seconds() - 600) < 1
|
||||
|
||||
assert client.post(f"/api/recordings/{limited}/extend?minutes=0").status_code == 422
|
||||
assert client.post(f"/api/recordings/{limited}/extend?minutes=1441").status_code == 422
|
||||
assert client.post("/api/recordings/99999/extend?minutes=5").status_code == 404
|
||||
|
||||
|
||||
def test_stop_endpoint(client):
|
||||
assert client.post("/api/recordings/99999/stop").status_code == 404
|
||||
done = _add_recording(status="completed")
|
||||
assert client.post(f"/api/recordings/{done}/stop").status_code == 400
|
||||
|
||||
|
||||
def test_query_limits(client):
|
||||
assert client.get("/api/logs?limit=0").status_code == 422
|
||||
assert client.get("/api/logs?limit=1001").status_code == 422
|
||||
assert client.get("/api/logs?level=bogus").status_code == 400
|
||||
assert client.get("/api/logs?level=warn").status_code == 200
|
||||
assert client.get("/api/recordings?limit=501").status_code == 422
|
||||
|
||||
|
||||
def test_cleanup_stale(client, app_mod):
|
||||
rid = _add_recording(status="running", pid=999999, output_file="/nonexistent/x.mp3")
|
||||
rid2 = _add_recording(status="starting")
|
||||
app_mod.recorder.cleanup_stale()
|
||||
session = get_session()
|
||||
try:
|
||||
for i in (rid, rid2):
|
||||
rec = session.get(Recording, i)
|
||||
assert rec.status == "error"
|
||||
assert rec.error_message == "Abgebrochen durch Neustart"
|
||||
assert rec.stopped_at is not None
|
||||
finally:
|
||||
session.close()
|
||||
@@ -0,0 +1,92 @@
|
||||
import datetime
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
import yaml
|
||||
|
||||
import config as config_module
|
||||
from config import Config, verify_password
|
||||
from models import get_session, Job
|
||||
from scheduler import StreamScheduler
|
||||
|
||||
|
||||
def _add_job(**kw):
|
||||
session = get_session()
|
||||
try:
|
||||
job = Job(name=kw.pop("name", "j"), stream_url="https://e.example/s", schedule_enabled=True, **kw)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
return job.id
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_bad_legacy_job_does_not_break_tick(client):
|
||||
_add_job(name="kaputt1", schedule_start="00:00", schedule_stop="xx")
|
||||
_add_job(name="kaputt2", schedule_start="25:99", schedule_days="foo-bar")
|
||||
_add_job(name="kaputt3", schedule_start="00:00", schedule_once=True, schedule_date="kein-datum")
|
||||
good = _add_job(name="gut", schedule_start="00:00", schedule_stop="", schedule_days="*")
|
||||
|
||||
rec = mock.MagicMock()
|
||||
sched = StreamScheduler(rec)
|
||||
with mock.patch("scheduler.logger") as log:
|
||||
sched._tick()
|
||||
sched._tick()
|
||||
|
||||
started = [c.kwargs["job_id"] for c in rec.start_recording.call_args_list]
|
||||
assert started == [good]
|
||||
# Fehler nur einmal pro Job geloggt, nicht bei jedem Tick
|
||||
assert log.error.call_count == 1
|
||||
|
||||
|
||||
def test_started_keys_pruned(client):
|
||||
sched = StreamScheduler(mock.MagicMock())
|
||||
old = (datetime.date.today() - datetime.timedelta(days=5)).isoformat()
|
||||
today = datetime.date.today().isoformat()
|
||||
sched._started_keys = {f"1|False|{old}|10:00", f"2|False|{today}|10:00", "kaputt"}
|
||||
sched._prune_started_keys(datetime.datetime.now())
|
||||
assert sched._started_keys == {f"2|False|{today}|10:00"}
|
||||
|
||||
|
||||
def test_first_start_generates_password(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config_module, "PBKDF2_ITERATIONS", 1000)
|
||||
path = tmp_path / "config.yml"
|
||||
cfg = Config(str(path))
|
||||
assert cfg.generated_password
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
assert data["auth"]["enabled"] is True
|
||||
assert "password" not in data["auth"]
|
||||
assert verify_password(cfg.generated_password, data["auth"]["password_hash"])
|
||||
assert cfg.generated_password not in path.read_text(encoding="utf-8")
|
||||
if os.name == "posix":
|
||||
assert (os.stat(path).st_mode & 0o777) == 0o600
|
||||
|
||||
# Zweiter Start: kein neues Passwort
|
||||
assert Config(str(path)).generated_password is None
|
||||
|
||||
|
||||
def test_plaintext_password_migration(tmp_path):
|
||||
path = tmp_path / "config.yml"
|
||||
path.write_text("auth:\n enabled: false\n username: admin\n password: altes-passwort\n", encoding="utf-8")
|
||||
cfg = Config(str(path))
|
||||
assert cfg.generated_password is None
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
assert "password" not in data["auth"]
|
||||
assert data["auth"]["enabled"] is False
|
||||
assert verify_password("altes-passwort", data["auth"]["password_hash"])
|
||||
|
||||
|
||||
def test_legacy_default_password_counts_as_unset(tmp_path):
|
||||
path = tmp_path / "config.yml"
|
||||
path.write_text("auth:\n enabled: false\n username: admin\n password: stream-recorder\n", encoding="utf-8")
|
||||
Config(str(path))
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
assert data["auth"]["password_hash"] == ""
|
||||
|
||||
|
||||
def test_legacy_default_password_kept_when_auth_enabled(tmp_path):
|
||||
path = tmp_path / "config.yml"
|
||||
path.write_text("auth:\n enabled: true\n username: admin\n password: stream-recorder\n", encoding="utf-8")
|
||||
cfg = Config(str(path))
|
||||
assert cfg.default_password_active is True
|
||||
assert verify_password("stream-recorder", cfg.get("auth", "password_hash"))
|
||||
@@ -0,0 +1,118 @@
|
||||
import pytest
|
||||
|
||||
from recorder import RecordingManager, validate_ffmpeg_args, validate_stream_url, PROTOCOL_WHITELIST
|
||||
from scheduler import compute_duration, validate_schedule
|
||||
|
||||
|
||||
@pytest.mark.parametrize("args", [
|
||||
"",
|
||||
"-c:a libmp3lame -b:a 128k",
|
||||
"-metadata title=Foo/Bar",
|
||||
"-metadata 'title=Mit Leerzeichen'",
|
||||
"-vn -ar 44100 -ac 2",
|
||||
"-af volume=2",
|
||||
])
|
||||
def test_ffmpeg_args_allowed(args):
|
||||
validate_ffmpeg_args(args)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("args", [
|
||||
"/app/app.py",
|
||||
"-i x",
|
||||
"-af amovie=/etc/passwd",
|
||||
"-filter:a AMOVIE=x",
|
||||
"-af sendcmd=f=x",
|
||||
"-c:a",
|
||||
"-c:a -b:a",
|
||||
"-b:a 128k out.mp3",
|
||||
"-y",
|
||||
"-f segment -segment_list /tmp/x",
|
||||
"-metadata ''",
|
||||
"'unclosed",
|
||||
"-vn " * 200,
|
||||
])
|
||||
def test_ffmpeg_args_rejected(args):
|
||||
with pytest.raises(ValueError):
|
||||
validate_ffmpeg_args(args)
|
||||
|
||||
|
||||
def test_ffmpeg_args_error_names_token():
|
||||
with pytest.raises(ValueError, match="/app/app.py"):
|
||||
validate_ffmpeg_args("-vn /app/app.py")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"https://example.com/stream.mp3",
|
||||
"http://radio.example:8000/live",
|
||||
"rtmp://live.example/app/key",
|
||||
"rtsps://cam.example/stream",
|
||||
])
|
||||
def test_url_allowed(url):
|
||||
assert validate_stream_url(url) == url
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"file:///etc/passwd",
|
||||
"concat:/etc/passwd|/etc/shadow",
|
||||
"subfile:,,start,0,end,0,,:/etc/passwd",
|
||||
"https://",
|
||||
"http://exa mple.com/",
|
||||
"https://example.com/\nfoo",
|
||||
"ftp://example.com/x",
|
||||
"",
|
||||
"https://example.com/" + "a" * 1000,
|
||||
])
|
||||
def test_url_rejected(url):
|
||||
with pytest.raises(ValueError):
|
||||
validate_stream_url(url)
|
||||
|
||||
|
||||
def test_ffmpeg_cmd_has_protocol_whitelist_before_input():
|
||||
mgr = RecordingManager(config=None)
|
||||
cmd = mgr._build_ffmpeg_cmd("https://example.com/a.m3u8", "hls", "/x/out.mp4",
|
||||
"-metadata title=X", False, None)
|
||||
assert cmd.index("-protocol_whitelist") < cmd.index("-i")
|
||||
assert cmd[cmd.index("-protocol_whitelist") + 1] == PROTOCOL_WHITELIST
|
||||
assert "file" not in PROTOCOL_WHITELIST.split(",")
|
||||
assert cmd[-1] == "/x/out.mp4"
|
||||
assert cmd[-3:-1] == ["-metadata", "title=X"]
|
||||
|
||||
|
||||
def test_ffmpeg_cmd_revalidates():
|
||||
mgr = RecordingManager(config=None)
|
||||
with pytest.raises(ValueError):
|
||||
mgr._build_ffmpeg_cmd("https://example.com/a", "http", "/x/out.mp4", "/app/app.py", False, None)
|
||||
with pytest.raises(ValueError):
|
||||
mgr._build_ffmpeg_cmd("file:///etc/passwd", "http", "/x/out.mp4", "", False, None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("job,msg", [
|
||||
({"schedule_start": "25:00"}, "HH:MM"),
|
||||
({"schedule_start": "10:00", "schedule_stop": "9:5"}, "HH:MM"),
|
||||
({"schedule_date": "2026-02-30"}, "JJJJ"),
|
||||
({"schedule_days": "mo-xx"}, "Wochentage"),
|
||||
({"schedule_enabled": True}, "Startzeit"),
|
||||
({"schedule_once": True, "schedule_start": "10:00"}, "Datum"),
|
||||
({"schedule_date": "2026-05-02", "schedule_end_date": "2026-05-01"}, "Enddatum"),
|
||||
])
|
||||
def test_schedule_invalid(job, msg):
|
||||
with pytest.raises(ValueError, match=msg):
|
||||
validate_schedule(job)
|
||||
|
||||
|
||||
def test_schedule_valid():
|
||||
validate_schedule({"schedule_enabled": True, "schedule_start": "23:30", "schedule_stop": "01:00",
|
||||
"schedule_days": "Mo-Fr,SA"})
|
||||
validate_schedule({"schedule_once": True, "schedule_date": "2026-05-01",
|
||||
"schedule_end_date": "2026-05-03", "schedule_start": "10:00"})
|
||||
|
||||
|
||||
def test_compute_duration():
|
||||
assert compute_duration({"max_duration": 120}, apply_buffers=True) == 120
|
||||
assert compute_duration({"schedule_start": "10:00", "schedule_stop": "11:00"}, False) == 3600
|
||||
assert compute_duration({"schedule_start": "23:00", "schedule_stop": "01:00"}, False) == 7200
|
||||
assert compute_duration({"schedule_start": "10:00", "schedule_stop": "11:00",
|
||||
"pre_buffer": 5, "post_buffer": 5}, True) == 4200
|
||||
assert compute_duration({"schedule_start": "10:00", "schedule_stop": ""}, True) is None
|
||||
with pytest.raises(ValueError):
|
||||
compute_duration({"schedule_start": "10:00", "schedule_stop": "xx"}, False)
|
||||
Reference in New Issue
Block a user