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()