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)