feat: device pairing, telemetry ingestion, live dashboard WS (Workstream G)

Implements the backend half of the ESP32-P4 sensor node spec's pairing,
ingestion, and live-broadcast contract:

- New Device model (backend/app/models/device.py): id, user_id FK, name,
  token_hash (unique+indexed), created_at, last_seen_at. Reuses
  generate_session_token()/hash_token() from auth_session.py verbatim for
  the one-time raw pairing token / stored hash.
- POST /api/device, GET /api/device (session-cookie authenticated REST
  pairing endpoints) and POST /api/device/telemetry (device bearer-token
  authenticated ingestion, per-device rate limited, 16KB body cap, 64
  reading cap, strict shape validation — never a 500 on garbage input) in
  backend/app/routes/device.py.
- /ws/device-feed live dashboard WS (qm_session cookie authenticated),
  fanning out ingested readings to the owning user's connected dashboard
  sockets via an in-process dict[user_id, connections] registry, each with
  its own send-queue + single sender task (mirrors app.ws's
  SeanceState/_sender convention).
- last_seen_at updates on every successful ingestion.
- _process_reading(device, reading) left as an explicit no-op handoff point
  for Workstream K's summon-pipeline integration.

Backend suite: 102 passed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-07-24 01:12:21 +00:00
parent cf817e5241
commit c88fbc843a
6 changed files with 780 additions and 0 deletions

View File

@@ -49,9 +49,11 @@ def sync_client(monkeypatch):
swapped: TestClient runs the lifespan on its own portal loop, and the
pooled production engine would carry connections across loops."""
import app.main as main_module
import app.routes.device as device_module
import app.ws as ws_module
monkeypatch.setattr(ws_module, "session_maker", TestSessionLocal)
monkeypatch.setattr(device_module, "session_maker", TestSessionLocal)
monkeypatch.setattr(main_module, "engine", test_engine)
with TestClient(app, base_url="https://testserver") as tc:
yield tc

View File

@@ -0,0 +1,426 @@
import hashlib
import pytest
from sqlalchemy import select
import app.routes.device as device_module
from app.models.device import Device
from app.rate_limit import RateLimiter
# ---------------------------------------------------------------------------
# Async-client helpers (REST-only tests)
# ---------------------------------------------------------------------------
async def _register_and_login(client, username="devowner"):
await client.post("/auth/register", json={"username": username, "password": "spookyspooky"})
await client.post("/auth/login", json={"username": username, "password": "spookyspooky"})
async def _pair_device(client, name="Sensor Node 1") -> dict:
response = await client.post("/api/device", json={"name": name})
assert response.status_code == 201
return response.json()
def _valid_reading(sensor_type="temperature", value=21.4, unit="c"):
return {"sensor_type": sensor_type, "value": value, "unit": unit, "metadata": {}}
# ---------------------------------------------------------------------------
# Pairing REST endpoints
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_device_requires_session_cookie(client):
response = await client.post("/api/device", json={"name": "Sensor Node"})
assert response.status_code == 401
@pytest.mark.asyncio
async def test_create_device_returns_raw_token_once(client, db_session):
await _register_and_login(client, "pairer1")
body = await _pair_device(client, "Attic Node")
assert body["name"] == "Attic Node"
assert "token" in body and len(body["token"]) > 20
assert "id" in body and "created_at" in body
assert "token_hash" not in body
# The stored hash is the sha256 of the raw token — same convention as
# AuthSession.token_hash / hash_token().
device = await db_session.scalar(select(Device).where(Device.id == body["id"]))
assert device.token_hash == hashlib.sha256(body["token"].encode()).hexdigest()
@pytest.mark.asyncio
async def test_list_devices_never_exposes_token_or_hash(client):
await _register_and_login(client, "pairer2")
await _pair_device(client, "Basement Node")
response = await client.get("/api/device")
assert response.status_code == 200
devices = response.json()["devices"]
assert len(devices) == 1
assert devices[0]["name"] == "Basement Node"
assert devices[0]["last_seen_at"] is None
assert "token" not in devices[0]
assert "token_hash" not in devices[0]
@pytest.mark.asyncio
async def test_list_devices_requires_session_cookie(client):
response = await client.get("/api/device")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_list_devices_scoped_to_owner(client):
await _register_and_login(client, "pairer3a")
await _pair_device(client, "Owner A Node")
await client.post("/auth/logout")
await _register_and_login(client, "pairer3b")
response = await client.get("/api/device")
assert response.json()["devices"] == []
# ---------------------------------------------------------------------------
# Telemetry ingestion — auth
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_telemetry_rejects_missing_bearer_token(client):
response = await client.post(
"/api/device/telemetry", json={"readings": [_valid_reading()]}
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_telemetry_rejects_malformed_authorization_header(client):
response = await client.post(
"/api/device/telemetry",
json={"readings": [_valid_reading()]},
headers={"Authorization": "Token not-a-bearer-scheme"},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_telemetry_rejects_unknown_token(client):
response = await client.post(
"/api/device/telemetry",
json={"readings": [_valid_reading()]},
headers={"Authorization": "Bearer totally-made-up-token"},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_telemetry_accepts_valid_bearer_token(client):
await _register_and_login(client, "ingest1")
device = await _pair_device(client, "Living Room Node")
response = await client.post(
"/api/device/telemetry",
json={"readings": [_valid_reading()]},
headers={"Authorization": f"Bearer {device['token']}"},
)
assert response.status_code == 202
assert response.json()["count"] == 1
# ---------------------------------------------------------------------------
# Telemetry ingestion — payload validation (never a 500)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_telemetry_rejects_oversized_body(client):
await _register_and_login(client, "ingest2")
device = await _pair_device(client, "Garage Node")
huge_reading = _valid_reading(sensor_type="temperature")
huge_reading["metadata"] = {"blob": "x" * 20_000}
response = await client.post(
"/api/device/telemetry",
json={"readings": [huge_reading]},
headers={"Authorization": f"Bearer {device['token']}"},
)
assert response.status_code == 413
@pytest.mark.asyncio
async def test_telemetry_rejects_oversized_readings_array(client):
await _register_and_login(client, "ingest3")
device = await _pair_device(client, "Hallway Node")
readings = [_valid_reading(sensor_type=f"sensor{i}") for i in range(65)]
response = await client.post(
"/api/device/telemetry",
json={"readings": readings},
headers={"Authorization": f"Bearer {device['token']}"},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_telemetry_rejects_garbage_sensor_type_type(client):
await _register_and_login(client, "ingest4")
device = await _pair_device(client, "Cellar Node")
bad = _valid_reading()
bad["sensor_type"] = 12345 # must be a string
response = await client.post(
"/api/device/telemetry",
json={"readings": [bad]},
headers={"Authorization": f"Bearer {device['token']}"},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_telemetry_rejects_garbage_value_type(client):
await _register_and_login(client, "ingest5")
device = await _pair_device(client, "Attic Node 2")
bad = _valid_reading()
bad["value"] = "not-a-number"
response = await client.post(
"/api/device/telemetry",
json={"readings": [bad]},
headers={"Authorization": f"Bearer {device['token']}"},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_telemetry_rejects_garbage_unit_type(client):
await _register_and_login(client, "ingest6")
device = await _pair_device(client, "Loft Node")
bad = _valid_reading()
bad["unit"] = {"nested": "dict"}
response = await client.post(
"/api/device/telemetry",
json={"readings": [bad]},
headers={"Authorization": f"Bearer {device['token']}"},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_telemetry_rejects_non_dict_metadata(client):
await _register_and_login(client, "ingest7")
device = await _pair_device(client, "Porch Node")
bad = _valid_reading()
bad["metadata"] = ["not", "a", "dict"]
response = await client.post(
"/api/device/telemetry",
json={"readings": [bad]},
headers={"Authorization": f"Bearer {device['token']}"},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_telemetry_rejects_malformed_json_body(client):
await _register_and_login(client, "ingest8")
device = await _pair_device(client, "Yard Node")
response = await client.post(
"/api/device/telemetry",
content=b"{not valid json",
headers={
"Authorization": f"Bearer {device['token']}",
"Content-Type": "application/json",
},
)
assert response.status_code == 422
# ---------------------------------------------------------------------------
# last_seen_at bookkeeping
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_telemetry_updates_last_seen_at(client):
await _register_and_login(client, "seenat1")
device = await _pair_device(client, "Cave Node")
before = await client.get("/api/device")
assert before.json()["devices"][0]["last_seen_at"] is None
await client.post(
"/api/device/telemetry",
json={"readings": [_valid_reading()]},
headers={"Authorization": f"Bearer {device['token']}"},
)
after = await client.get("/api/device")
assert after.json()["devices"][0]["last_seen_at"] is not None
# ---------------------------------------------------------------------------
# Rate limiting
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_telemetry_rate_limited_per_device(client, monkeypatch):
monkeypatch.setattr(
device_module, "telemetry_limiter", RateLimiter(max_requests=1, window_seconds=60)
)
await _register_and_login(client, "ratelimited1")
device = await _pair_device(client, "Rate Limited Node")
headers = {"Authorization": f"Bearer {device['token']}"}
first = await client.post(
"/api/device/telemetry", json={"readings": [_valid_reading()]}, headers=headers
)
assert first.status_code == 202
second = await client.post(
"/api/device/telemetry", json={"readings": [_valid_reading()]}, headers=headers
)
assert second.status_code == 429
@pytest.mark.asyncio
async def test_telemetry_rate_limit_is_per_device_not_global(client, monkeypatch):
monkeypatch.setattr(
device_module, "telemetry_limiter", RateLimiter(max_requests=1, window_seconds=60)
)
await _register_and_login(client, "ratelimited2")
device_a = await _pair_device(client, "Node A")
device_b = await _pair_device(client, "Node B")
resp_a = await client.post(
"/api/device/telemetry",
json={"readings": [_valid_reading()]},
headers={"Authorization": f"Bearer {device_a['token']}"},
)
assert resp_a.status_code == 202
# Device B has spent nothing yet — its own bucket is untouched.
resp_b = await client.post(
"/api/device/telemetry",
json={"readings": [_valid_reading()]},
headers={"Authorization": f"Bearer {device_b['token']}"},
)
assert resp_b.status_code == 202
# ---------------------------------------------------------------------------
# Live dashboard WS — pub/sub fan-out (needs a synchronous client that can
# hold a WS connection open alongside plain HTTP calls, same as ws.py's
# tests use `sync_client` for).
# ---------------------------------------------------------------------------
def _sync_register_and_login(sync_client, username="dashuser"):
sync_client.post("/auth/register", json={"username": username, "password": "spookyspooky"})
sync_client.post("/auth/login", json={"username": username, "password": "spookyspooky"})
return sync_client.cookies.get("qm_session")
def _sync_pair_device(sync_client, name="Sync Node") -> dict:
response = sync_client.post("/api/device", json={"name": name})
assert response.status_code == 201
return response.json()
def _ws_feed_connect(sync_client, token):
# Same rationale as app.ws's tests: TestClient upgrades over ws://, so
# the jar withholds the Secure qm_session cookie — pass it explicitly.
return sync_client.websocket_connect(
"/ws/device-feed", headers={"cookie": f"qm_session={token}"}
)
def test_device_feed_requires_session_cookie(sync_client):
with pytest.raises(Exception):
with sync_client.websocket_connect("/ws/device-feed"):
pass
def test_device_feed_sends_device_list_on_connect(sync_client):
token = _sync_register_and_login(sync_client, "dashuser1")
device = _sync_pair_device(sync_client, "Feed Node")
with _ws_feed_connect(sync_client, token) as ws:
frame = ws.receive_json()
assert frame["type"] == "devices"
assert len(frame["devices"]) == 1
assert frame["devices"][0]["id"] == device["id"]
assert frame["devices"][0]["name"] == "Feed Node"
assert "token" not in frame["devices"][0]
def test_reading_posted_while_dashboard_connected_arrives_on_socket(sync_client):
token = _sync_register_and_login(sync_client, "dashuser2")
device = _sync_pair_device(sync_client, "Live Node")
with _ws_feed_connect(sync_client, token) as ws:
ws.receive_json() # initial "devices" frame
response = sync_client.post(
"/api/device/telemetry",
json={"readings": [_valid_reading(sensor_type="presence", value=1, unit="bool")]},
headers={"Authorization": f"Bearer {device['token']}"},
)
assert response.status_code == 202
reading_frame = ws.receive_json()
assert reading_frame["type"] == "reading"
assert reading_frame["device_id"] == device["id"]
assert reading_frame["sensor_type"] == "presence"
assert reading_frame["value"] == 1
assert reading_frame["unit"] == "bool"
assert reading_frame["metadata"] == {}
assert "at" in reading_frame
def test_reading_posted_for_device_with_no_dashboard_owner_does_not_error(sync_client):
_sync_register_and_login(sync_client, "dashuser3")
device = _sync_pair_device(sync_client, "Lonely Node")
# No /ws/device-feed connection is open for this user at all.
response = sync_client.post(
"/api/device/telemetry",
json={"readings": [_valid_reading()]},
headers={"Authorization": f"Bearer {device['token']}"},
)
assert response.status_code == 202
def test_device_feed_only_broadcasts_to_the_owning_user(sync_client):
token_a = _sync_register_and_login(sync_client, "dashuser4a")
_sync_pair_device(sync_client, "User A Node")
_sync_register_and_login(sync_client, "dashuser4b")
device_b = _sync_pair_device(sync_client, "User B Node")
with _ws_feed_connect(sync_client, token_a) as ws_a:
ws_a.receive_json() # devices frame for user A (empty-ish/own list)
sync_client.post(
"/api/device/telemetry",
json={"readings": [_valid_reading()]},
headers={"Authorization": f"Bearer {device_b['token']}"},
)
# User A's socket must not receive user B's device reading.
# WebSocketTestSession.receive_json() has no timeout param, so poll
# its underlying queue directly with a short timeout instead of
# blocking forever waiting for a frame that must never arrive.
import queue
with pytest.raises(queue.Empty):
ws_a._send_queue.get(timeout=0.3)