fix: tautulli — hmac token compare, public pattern loader, startup cache, endpoint tests

This commit is contained in:
pyr0ball 2026-05-13 19:08:49 -07:00
parent 581e0314b4
commit 279b01902f
3 changed files with 75 additions and 3 deletions

View file

@ -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] 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( def apply_patterns(
text: str, text: str,
compiled: list[tuple[LogPattern, re.Pattern]], compiled: list[tuple[LogPattern, re.Pattern]],

View file

@ -8,6 +8,7 @@ from __future__ import annotations
import asyncio import asyncio
import dataclasses import dataclasses
import hmac
import json import json
import os import os
import sqlite3 import sqlite3
@ -24,7 +25,7 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
from app.ingest.pipeline import ensure_schema 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.ingest.tautulli import parse_webhook as _parse_tautulli
from app.services.incidents import ( from app.services.incidents import (
build_bundle, build_bundle,
@ -68,11 +69,14 @@ PATTERN_DIR = Path(os.environ.get("TURNSTONE_PATTERNS", Path(__file__).parent.pa
PATTERN_FILE = PATTERN_DIR / "default.yaml" PATTERN_FILE = PATTERN_DIR / "default.yaml"
_watcher = Watcher(DB_PATH, PATTERN_FILE) _watcher = Watcher(DB_PATH, PATTERN_FILE)
_compiled_patterns: list = []
@asynccontextmanager @asynccontextmanager
async def _lifespan(app: FastAPI): async def _lifespan(app: FastAPI):
global _compiled_patterns
ensure_schema(DB_PATH) ensure_schema(DB_PATH)
_compiled_patterns = load_compiled_patterns(PATTERN_FILE)
watch_cfg_path = PATTERN_DIR / "watch.yaml" watch_cfg_path = PATTERN_DIR / "watch.yaml"
configs = load_watch_config(watch_cfg_path) configs = load_watch_config(watch_cfg_path)
if configs: if configs:
@ -360,7 +364,9 @@ def watch_status() -> dict:
@router.post("/api/watch/reload") @router.post("/api/watch/reload")
def watch_reload() -> dict: def watch_reload() -> dict:
"""Stop all watch sources and restart with current watch.yaml.""" """Stop all watch sources and restart with current watch.yaml."""
global _compiled_patterns
_watcher.stop() _watcher.stop()
_compiled_patterns = load_compiled_patterns(PATTERN_FILE)
watch_cfg_path = PATTERN_DIR / "watch.yaml" watch_cfg_path = PATTERN_DIR / "watch.yaml"
configs = load_watch_config(watch_cfg_path) configs = load_watch_config(watch_cfg_path)
if configs: if configs:
@ -496,13 +502,13 @@ def ingest_tautulli(
token = prefs.get("tautulli_token", "") token = prefs.get("tautulli_token", "")
if token: if token:
header_token = request.headers.get("X-Tautulli-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") raise HTTPException(status_code=403, detail="Invalid Tautulli token")
if "action" not in payload: if "action" not in payload:
raise HTTPException(status_code=400, detail="Missing required field: action") raise HTTPException(status_code=400, detail="Missing required field: action")
compiled = _compile(load_patterns(PATTERN_FILE)) compiled = _compiled_patterns
entry = _parse_tautulli(payload, compiled) entry = _parse_tautulli(payload, compiled)
conn = sqlite3.connect(str(DB_PATH)) conn = sqlite3.connect(str(DB_PATH))

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from unittest.mock import patch
from app.ingest.tautulli import is_tautulli_payload, parse_webhook from app.ingest.tautulli import is_tautulli_payload, parse_webhook
@ -242,3 +243,63 @@ class TestFixedFields:
def test_entry_id_is_nonempty_string(self): def test_entry_id_is_nonempty_string(self):
entry = parse_webhook(_ERROR_PAYLOAD, []) entry = parse_webhook(_ERROR_PAYLOAD, [])
assert isinstance(entry.entry_id, str) and len(entry.entry_id) > 0 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