tracksolid_timescale_grafan.../tests/integration/test_webhook_endpoints.py
david kiania b11294009b
Some checks are pending
Static Analysis / static (push) Waiting to run
Tests / test (push) Waiting to run
Static Analysis / static (pull_request) Waiting to run
Tests / test (pull_request) Waiting to run
fix(security,ingest): 260702 audit — secure the stack, correct poller counters
Security:
- .dockerignore + Dockerfile: stop baking .env / the 346MB OSM pbf into image
  layers; install pinned from uv.lock (reproducible builds) (SEC-04/05).
- docker-compose: DB port binds ${DB_BIND_ADDR:-127.0.0.1} — loopback-only by
  default; remote tooling moves to an SSH tunnel (SEC-01).
- webhook_receiver: CRITICAL startup warning + WEBHOOK_REQUIRE_TOKEN=1 fail-closed
  when JIMI_WEBHOOK_TOKEN is empty (SEC-02 / FIX-W01).

Correctness:
- FIX-M22/E07: capture cur.rowcount BEFORE RELEASE SAVEPOINT in poll_alarms/
  poll_trips/poll_parking — the RELEASE reported -1, producing "Alarms: -4 new
  events inserted" logs and negative ingestion_log.rows_inserted.
- FIX-W02: parse application/json push bodies (were silently dropped).
- FIX-W03: move webhook DB work off the event loop via asyncio.to_thread.
- FIX-M23: poll_trips phased so no txn/connection is held across Tracksolid +
  Nominatim (1 req/s) network calls.
- FIX-M24: sync_devices disables devices absent from every target (guarded).
- FIX-W04: reject device-clock-garbage alarm_time (2019 timestamps observed).
- get_token(): don't relabel already-aware timestamptz expiries (BUG-P9).

Observability/lifecycle:
- migration 21: v_ingest_health restricted to active pipeline endpoints so
  one-shot tools stop wedging /health/ingest at 'stale' (dry-run verified).
- FIX-M25: daily purge_audit_logs() trims ingestion_log (90d) + refresh_log (180d).
- remove orphaned duplicate migrations/10_driver_clock_views.sql; ruff lint config.

+5 webhook tests (82 pass). Report/plan/work-log in docs/reports/260702_*.
Local only; not deployed. CLAUDE.md fix-history edits left uncommitted (that file
also carries unrelated in-progress edits).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 09:51:02 +03:00

251 lines
11 KiB
Python

"""Integration tests for FastAPI webhook endpoints."""
import sys
import os
import json
import pytest
from unittest.mock import MagicMock, patch, call
from contextlib import contextmanager
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
os.environ.setdefault("TRACKSOLID_APP_KEY", "test_key")
os.environ.setdefault("TRACKSOLID_APP_SECRET", "test_secret")
os.environ.setdefault("TRACKSOLID_USER_ID", "test_user")
os.environ.setdefault("TRACKSOLID_PWD_MD5", "test_md5")
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost:5432/test")
os.environ.setdefault("JIMI_WEBHOOK_TOKEN", "")
# Fixtures carry fixed 2024 timestamps; widen the FIX-W04 sanity window so
# they stay valid regardless of the wall clock.
os.environ.setdefault("WEBHOOK_EVENT_MAX_AGE_DAYS", "100000")
from fastapi.testclient import TestClient
import webhook_receiver_rev
from tests.fixtures.api_responses import (
WEBHOOK_ALARM_PAYLOAD,
WEBHOOK_ALARM_NULL_TYPE,
WEBHOOK_ALARM_NO_POSITION,
WEBHOOK_ALARM_ZERO_ISLAND,
WEBHOOK_TRIP_BCD_PAYLOAD,
WEBHOOK_TRIP_ISO_PAYLOAD,
WEBHOOK_OBD_PAYLOAD,
)
def make_mock_conn():
"""Create a mock DB connection with cursor support."""
mock_cur = MagicMock()
mock_conn = MagicMock()
mock_conn.cursor.return_value.__enter__ = lambda s: mock_cur
mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
return mock_conn, mock_cur
@contextmanager
def mock_get_conn_ctx(mock_conn):
yield mock_conn
@pytest.fixture
def client():
return TestClient(webhook_receiver_rev.app, raise_server_exceptions=True)
@pytest.fixture
def mock_db():
mock_conn, mock_cur = make_mock_conn()
with patch("webhook_receiver_rev.get_conn") as mock_get_conn:
mock_get_conn.return_value = mock_get_conn_ctx(mock_conn)
yield mock_conn, mock_cur
class TestHealth:
def test_health_returns_ok(self, client):
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
class TestPushAlarm:
def test_valid_alarm_accepted(self, client, mock_db):
mock_conn, mock_cur = mock_db
data_list = json.dumps([WEBHOOK_ALARM_PAYLOAD])
response = client.post("/pushalarm", data={"token": "", "data_list": data_list})
assert response.status_code == 200
assert response.json()["code"] == 0
def test_null_alarm_type_skipped(self, client, mock_db):
"""BUG-02 guard: NULL alarm_type must be rejected, not inserted."""
mock_conn, mock_cur = mock_db
data_list = json.dumps([WEBHOOK_ALARM_NULL_TYPE])
response = client.post("/pushalarm", data={"token": "", "data_list": data_list})
assert response.status_code == 200
# Verify no data INSERT was executed. log_ingestion always writes one
# row to tracksolid.ingestion_log — exclude it from the assertion.
data_inserts = [
c for c in mock_cur.execute.call_args_list
if "INSERT" in str(c) and "ingestion_log" not in str(c)
]
assert len(data_inserts) == 0, "NULL alarm_type must not be inserted"
def test_empty_data_list_ok(self, client):
response = client.post("/pushalarm", data={"token": "", "data_list": ""})
assert response.status_code == 200
def test_batch_with_bad_item_processes_rest(self, client, mock_db):
"""BUG-04: One bad item must not abort the entire batch."""
mock_conn, mock_cur = mock_db
# One valid, one missing alarm_type (will be skipped, not crash)
items = [WEBHOOK_ALARM_PAYLOAD, WEBHOOK_ALARM_NULL_TYPE]
data_list = json.dumps(items)
response = client.post("/pushalarm", data={"token": "", "data_list": data_list})
assert response.status_code == 200
assert response.json()["code"] == 0
def test_alarm_cross_feeds_live_position(self, client, mock_db):
"""FIX-M21: a valid alarm must additionally upsert live_positions."""
mock_conn, mock_cur = mock_db
data_list = json.dumps([WEBHOOK_ALARM_PAYLOAD])
response = client.post("/pushalarm", data={"token": "", "data_list": data_list})
assert response.status_code == 200
# Exactly one INSERT INTO live_positions should have fired.
lp_inserts = [
c for c in mock_cur.execute.call_args_list
if "tracksolid.live_positions" in str(c) and "INSERT" in str(c)
]
assert len(lp_inserts) == 1, "Cross-feed must upsert live_positions exactly once"
def test_alarm_without_lat_lng_skips_cross_feed(self, client, mock_db):
"""FIX-M21: an alarm without lat/lng must NOT touch live_positions
(but must still insert the alarm row)."""
mock_conn, mock_cur = mock_db
data_list = json.dumps([WEBHOOK_ALARM_NO_POSITION])
response = client.post("/pushalarm", data={"token": "", "data_list": data_list})
assert response.status_code == 200
lp_inserts = [
c for c in mock_cur.execute.call_args_list
if "tracksolid.live_positions" in str(c) and "INSERT" in str(c)
]
assert len(lp_inserts) == 0, "No live_positions write without a valid fix"
alarm_inserts = [
c for c in mock_cur.execute.call_args_list
if "tracksolid.alarms" in str(c) and "INSERT" in str(c)
]
assert len(alarm_inserts) == 1, "Alarm row must still write"
def test_alarm_with_zero_island_skips_cross_feed(self, client, mock_db):
"""FIX-M21: a (0, 0) fix must NOT propagate to live_positions —
is_valid_fix in the shared helper guards Zero Island."""
mock_conn, mock_cur = mock_db
data_list = json.dumps([WEBHOOK_ALARM_ZERO_ISLAND])
response = client.post("/pushalarm", data={"token": "", "data_list": data_list})
assert response.status_code == 200
lp_inserts = [
c for c in mock_cur.execute.call_args_list
if "tracksolid.live_positions" in str(c) and "INSERT" in str(c)
]
assert len(lp_inserts) == 0, "Zero-Island fix must not reach live_positions"
class TestPushTripReport:
def test_bcd_timestamp_parsed(self, client, mock_db):
"""BUG-03: BCD timestamp 220415103000 must be parsed correctly."""
mock_conn, mock_cur = mock_db
data_list = json.dumps([WEBHOOK_TRIP_BCD_PAYLOAD])
response = client.post("/pushtripreport", data={"token": "", "data_list": data_list})
assert response.status_code == 200
assert response.json()["code"] == 0
# Verify an INSERT was attempted
insert_calls = [c for c in mock_cur.execute.call_args_list
if "INSERT" in str(c)]
assert len(insert_calls) > 0, "Trip with BCD timestamp must trigger INSERT"
def test_iso_timestamp_accepted(self, client, mock_db):
mock_conn, mock_cur = mock_db
data_list = json.dumps([WEBHOOK_TRIP_ISO_PAYLOAD])
response = client.post("/pushtripreport", data={"token": "", "data_list": data_list})
assert response.status_code == 200
def test_missing_imei_skipped(self, client, mock_db):
mock_conn, mock_cur = mock_db
bad_trip = {"beginTime": "2024-04-12 07:00:00", "miles": 10.0}
data_list = json.dumps([bad_trip])
response = client.post("/pushtripreport", data={"token": "", "data_list": data_list})
assert response.status_code == 200
class TestPushObd:
def test_valid_obd_accepted(self, client, mock_db):
mock_conn, mock_cur = mock_db
data_list = json.dumps([WEBHOOK_OBD_PAYLOAD])
response = client.post("/pushobd", data={"token": "", "data_list": data_list})
assert response.status_code == 200
assert response.json()["code"] == 0
class TestJsonPushFormat:
"""FIX-W02: the documented JSON push format must be parsed, not silently dropped."""
def test_json_data_list_accepted(self, client, mock_db):
mock_conn, mock_cur = mock_db
response = client.post(
"/pushalarm",
json={"token": "", "data_list": [WEBHOOK_ALARM_PAYLOAD]},
)
assert response.status_code == 200
assert response.json()["code"] == 0
data_inserts = [
c for c in mock_cur.execute.call_args_list
if "tracksolid.alarms" in str(c) and "INSERT" in str(c)
]
assert len(data_inserts) == 1, "JSON-body push must insert the alarm"
def test_json_single_object_data_accepted(self, client, mock_db):
mock_conn, mock_cur = mock_db
response = client.post(
"/pushalarm",
json={"token": "", "data": WEBHOOK_ALARM_PAYLOAD},
)
assert response.status_code == 200
data_inserts = [
c for c in mock_cur.execute.call_args_list
if "tracksolid.alarms" in str(c) and "INSERT" in str(c)
]
assert len(data_inserts) == 1
def test_malformed_json_returns_empty_success(self, client):
response = client.post(
"/pushalarm", content=b"{not json",
headers={"content-type": "application/json"},
)
assert response.status_code == 200
class TestEventTimestampGuard:
"""FIX-W04: device-clock garbage (e.g. 2019 alarm_time) must be rejected."""
def test_ancient_alarm_time_rejected(self, client, mock_db):
mock_conn, mock_cur = mock_db
stale = dict(WEBHOOK_ALARM_PAYLOAD, gateTime="2019-01-01 00:02:17")
# Narrow the sanity window (module-level env default is widened for fixtures).
from datetime import timedelta
with patch.object(webhook_receiver_rev, "_EVENT_MAX_AGE", timedelta(days=30)):
response = client.post(
"/pushalarm", data={"token": "", "data_list": json.dumps([stale])}
)
assert response.status_code == 200
data_inserts = [
c for c in mock_cur.execute.call_args_list
if "tracksolid.alarms" in str(c) and "INSERT" in str(c)
]
assert len(data_inserts) == 0, "2019 alarm_time must not be inserted"
def test_sane_ts_helper(self):
from datetime import datetime, timezone, timedelta
now = datetime.now(timezone.utc)
recent = (now - timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S")
with patch.object(webhook_receiver_rev, "_EVENT_MAX_AGE", timedelta(days=30)):
assert webhook_receiver_rev._is_sane_event_ts(recent) is True
assert webhook_receiver_rev._is_sane_event_ts("2019-01-01 00:02:17") is False
assert webhook_receiver_rev._is_sane_event_ts(None) is False
future = (now + timedelta(days=10)).strftime("%Y-%m-%d %H:%M:%S")
assert webhook_receiver_rev._is_sane_event_ts(future) is False