fix: tautulli — hmac token compare, public pattern loader, startup cache, endpoint tests
This commit is contained in:
parent
72800332c9
commit
950a854b58
3 changed files with 75 additions and 3 deletions
|
|
@ -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]],
|
||||||
|
|
|
||||||
12
app/rest.py
12
app/rest.py
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue