import uuid from datetime import datetime import pytest from sqlalchemy.orm import sessionmaker from starlette.testclient import WebSocketDisconnect from tests.conftest import make_project from backend import portal_auth as pa from backend.auth_passwords import hash_password from backend.models import MonitoringLocation def _sound_location(db_session, project): loc = MonitoringLocation( id=str(uuid.uuid4()), project_id=project.id, name="Site", location_type="sound", created_at=datetime.utcnow(), sort_order=0) db_session.add(loc) db_session.commit() return loc def test_session_for_A_cannot_open_B_location(client, db_session): a = make_project(db_session, portal_enabled=True, portal_link_token="ta", portal_password_hash=hash_password("pw")) b = make_project(db_session) b_loc = _sound_location(db_session, b) # Establish an A session r = client.post("/portal/p/ta", data={"password": "pw"}, follow_redirects=False) assert r.status_code == 303 # Try to open B's location page → 404 (not 403), no leak r2 = client.get(f"/portal/location/{b_loc.id}") assert r2.status_code == 404 def test_session_can_open_its_own_location(client, db_session): # Positive case: proves the negative test's 404 is real scoping, not a blanket # "client owns nothing" failure — an A session CAN open A's own location. a = make_project(db_session, portal_enabled=True, portal_link_token="ta2", portal_password_hash=hash_password("pw")) a_loc = _sound_location(db_session, a) r = client.post("/portal/p/ta2", data={"password": "pw"}, follow_redirects=False) assert r.status_code == 303 r2 = client.get(f"/portal/location/{a_loc.id}") assert r2.status_code == 200 def test_ws_stream_rejects_unauthenticated(client, db_session): # The live-feed WebSocket must refuse a connection with no session cookie (1008). a = make_project(db_session, portal_enabled=True, portal_link_token="tw1", portal_password_hash=hash_password("pw")) a_loc = _sound_location(db_session, a) with pytest.raises(WebSocketDisconnect) as exc: with client.websocket_connect(f"/portal/api/location/{a_loc.id}/stream") as ws: ws.receive_text() assert exc.value.code == 1008 def test_ws_stream_rejects_cross_project(client, db_session, monkeypatch): # The WebSocket enforces the SAME per-project ownership as the HTTP routes: a # B-session opening A's stream is closed 1008 (ownership) before any device feed. # The handler uses SessionLocal() directly (not the get_db override), so point it # at the test DB engine so this genuinely exercises the ownership check (not a # vacuous "client not found"). import backend.routers.portal as portal_router monkeypatch.setattr(portal_router, "SessionLocal", sessionmaker(bind=db_session.get_bind())) a = make_project(db_session, portal_enabled=True, portal_link_token="tw2", portal_password_hash=hash_password("pw")) a_loc = _sound_location(db_session, a) make_project(db_session, portal_enabled=True, portal_link_token="tw3", portal_password_hash=hash_password("pw")) # Log in as project B, then aim the stream at project A's location. assert client.post("/portal/p/tw3", data={"password": "pw"}, follow_redirects=False).status_code == 303 with pytest.raises(WebSocketDisconnect) as exc: with client.websocket_connect(f"/portal/api/location/{a_loc.id}/stream") as ws: ws.receive_text() assert exc.value.code == 1008