From 279b01902f34db56ddd88bdb3645347b9497bc5a Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Wed, 13 May 2026 19:08:49 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20tautulli=20=E2=80=94=20hmac=20token=20co?= =?UTF-8?q?mpare,=20public=20pattern=20loader,=20startup=20cache,=20endpoi?= =?UTF-8?q?nt=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/ingest/base.py | 5 +++ app/rest.py | 12 +++++-- tests/test_ingest_tautulli.py | 61 +++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/app/ingest/base.py b/app/ingest/base.py index 9a9bcdc..a222c9b 100644 --- a/app/ingest/base.py +++ b/app/ingest/base.py @@ -42,6 +42,11 @@ def _compile(patterns: list[LogPattern]) -> list[tuple[LogPattern, re.Pattern]]: return [(p, re.compile(p.pattern, re.IGNORECASE)) for p in patterns] +def load_compiled_patterns(path: Path) -> list[tuple[LogPattern, object]]: + """Load and compile patterns from a YAML file. Public API over the private _compile.""" + return _compile(load_patterns(path)) + + def apply_patterns( text: str, compiled: list[tuple[LogPattern, re.Pattern]], diff --git a/app/rest.py b/app/rest.py index 60fcd69..a0c152e 100644 --- a/app/rest.py +++ b/app/rest.py @@ -8,6 +8,7 @@ from __future__ import annotations import asyncio import dataclasses +import hmac import json import os import sqlite3 @@ -24,7 +25,7 @@ from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from app.ingest.pipeline import ensure_schema -from app.ingest.base import _compile, load_patterns +from app.ingest.base import load_compiled_patterns, load_patterns from app.ingest.tautulli import parse_webhook as _parse_tautulli from app.services.incidents import ( build_bundle, @@ -68,11 +69,14 @@ PATTERN_DIR = Path(os.environ.get("TURNSTONE_PATTERNS", Path(__file__).parent.pa PATTERN_FILE = PATTERN_DIR / "default.yaml" _watcher = Watcher(DB_PATH, PATTERN_FILE) +_compiled_patterns: list = [] @asynccontextmanager async def _lifespan(app: FastAPI): + global _compiled_patterns ensure_schema(DB_PATH) + _compiled_patterns = load_compiled_patterns(PATTERN_FILE) watch_cfg_path = PATTERN_DIR / "watch.yaml" configs = load_watch_config(watch_cfg_path) if configs: @@ -360,7 +364,9 @@ def watch_status() -> dict: @router.post("/api/watch/reload") def watch_reload() -> dict: """Stop all watch sources and restart with current watch.yaml.""" + global _compiled_patterns _watcher.stop() + _compiled_patterns = load_compiled_patterns(PATTERN_FILE) watch_cfg_path = PATTERN_DIR / "watch.yaml" configs = load_watch_config(watch_cfg_path) if configs: @@ -496,13 +502,13 @@ def ingest_tautulli( token = prefs.get("tautulli_token", "") if token: header_token = request.headers.get("X-Tautulli-Token", "") - if header_token != token: + if not hmac.compare_digest(header_token, token): raise HTTPException(status_code=403, detail="Invalid Tautulli token") if "action" not in payload: raise HTTPException(status_code=400, detail="Missing required field: action") - compiled = _compile(load_patterns(PATTERN_FILE)) + compiled = _compiled_patterns entry = _parse_tautulli(payload, compiled) conn = sqlite3.connect(str(DB_PATH)) diff --git a/tests/test_ingest_tautulli.py b/tests/test_ingest_tautulli.py index 0892e2c..a3820f8 100644 --- a/tests/test_ingest_tautulli.py +++ b/tests/test_ingest_tautulli.py @@ -2,6 +2,7 @@ from __future__ import annotations import pytest +from unittest.mock import patch from app.ingest.tautulli import is_tautulli_payload, parse_webhook @@ -242,3 +243,63 @@ class TestFixedFields: def test_entry_id_is_nonempty_string(self): entry = parse_webhook(_ERROR_PAYLOAD, []) assert isinstance(entry.entry_id, str) and len(entry.entry_id) > 0 + + +# --------------------------------------------------------------------------- +# Endpoint tests — auth, validation, happy path +# --------------------------------------------------------------------------- + +class TestEndpoint: + @pytest.fixture + def client(self, tmp_path): + from fastapi.testclient import TestClient + from app.ingest.pipeline import ensure_schema + import app.rest as rest_module + + db = tmp_path / "test.db" + ensure_schema(db) + + with patch.object(rest_module, "DB_PATH", db), \ + patch.object(rest_module, "PREFS_PATH", tmp_path / "prefs.json"), \ + patch.object(rest_module, "_compiled_patterns", []): + with TestClient(rest_module.app, raise_server_exceptions=True) as c: + yield c + + def test_missing_action_returns_400(self, client): + resp = client.post( + "/turnstone/api/ingest/tautulli", + json={"session_key": "x"}, + ) + assert resp.status_code == 400 + + def test_wrong_token_returns_403(self, tmp_path): + from fastapi.testclient import TestClient + from app.ingest.pipeline import ensure_schema + import app.rest as rest_module + + db = tmp_path / "test.db" + ensure_schema(db) + prefs_path = tmp_path / "prefs.json" + import json as _json + prefs_path.write_text(_json.dumps({"tautulli_token": "secret"})) + + with patch.object(rest_module, "DB_PATH", db), \ + patch.object(rest_module, "PREFS_PATH", prefs_path), \ + patch.object(rest_module, "_compiled_patterns", []): + with TestClient(rest_module.app, raise_server_exceptions=True) as c: + resp = c.post( + "/turnstone/api/ingest/tautulli", + json=_ERROR_PAYLOAD, + headers={"X-Tautulli-Token": "wrong"}, + ) + assert resp.status_code == 403 + + def test_valid_payload_returns_200(self, client): + resp = client.post( + "/turnstone/api/ingest/tautulli", + json=_ERROR_PAYLOAD, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["stored"] == 1 + assert "entry_id" in data