diff --git a/server/pyproject.toml b/server/pyproject.toml index f63947c8..ffa28d15 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -112,6 +112,7 @@ source = ["reflector"] [tool.pytest_env] ENVIRONMENT = "pytest" DATABASE_URL = "postgresql://test_user:test_password@localhost:15432/reflector_test" +AUTH_BACKEND = "jwt" [tool.pytest.ini_options] addopts = "-ra -q --disable-pytest-warnings --cov --cov-report html -v" diff --git a/server/reflector/db/transcripts.py b/server/reflector/db/transcripts.py index 47148995..b82e4fe1 100644 --- a/server/reflector/db/transcripts.py +++ b/server/reflector/db/transcripts.py @@ -647,6 +647,19 @@ class TranscriptController: query = transcripts.delete().where(transcripts.c.recording_id == recording_id) await get_database().execute(query) + @staticmethod + def user_can_mutate(transcript: Transcript, user_id: str | None) -> bool: + """ + Returns True if the given user is allowed to modify the transcript. + + Policy: + - Anonymous transcripts (user_id is None) cannot be modified via API + - Only the owner (matching user_id) can modify their transcript + """ + if transcript.user_id is None: + return False + return user_id and transcript.user_id == user_id + @asynccontextmanager async def transaction(self): """ diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py index b849ae3d..70e3f9e4 100644 --- a/server/reflector/views/rooms.py +++ b/server/reflector/views/rooms.py @@ -199,6 +199,8 @@ async def rooms_get( room = await rooms_controller.get_by_id_for_http(room_id, user_id=user_id) if not room: raise HTTPException(status_code=404, detail="Room not found") + if not room.is_shared and (user_id is None or room.user_id != user_id): + raise HTTPException(status_code=403, detail="Room access denied") return room @@ -229,9 +231,9 @@ async def rooms_get_by_name( @router.post("/rooms", response_model=Room) async def rooms_create( room: CreateRoom, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Annotated[auth.UserInfo, Depends(auth.current_user)], ): - user_id = user["sub"] if user else None + user_id = user["sub"] return await rooms_controller.add( name=room.name, @@ -256,12 +258,14 @@ async def rooms_create( async def rooms_update( room_id: str, info: UpdateRoom, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Annotated[auth.UserInfo, Depends(auth.current_user)], ): - user_id = user["sub"] if user else None + user_id = user["sub"] room = await rooms_controller.get_by_id_for_http(room_id, user_id=user_id) if not room: raise HTTPException(status_code=404, detail="Room not found") + if room.user_id != user_id: + raise HTTPException(status_code=403, detail="Not authorized") values = info.dict(exclude_unset=True) await rooms_controller.update(room, values) return room @@ -270,12 +274,14 @@ async def rooms_update( @router.delete("/rooms/{room_id}", response_model=DeletionStatus) async def rooms_delete( room_id: str, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Annotated[auth.UserInfo, Depends(auth.current_user)], ): - user_id = user["sub"] if user else None - room = await rooms_controller.get_by_id(room_id, user_id=user_id) + user_id = user["sub"] + room = await rooms_controller.get_by_id(room_id) if not room: raise HTTPException(status_code=404, detail="Room not found") + if room.user_id != user_id: + raise HTTPException(status_code=403, detail="Not authorized") await rooms_controller.remove_by_id(room.id, user_id=user_id) return DeletionStatus(status="ok") @@ -339,16 +345,16 @@ async def rooms_create_meeting( @router.post("/rooms/{room_id}/webhook/test", response_model=WebhookTestResult) async def rooms_test_webhook( room_id: str, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Annotated[auth.UserInfo, Depends(auth.current_user)], ): """Test webhook configuration by sending a sample payload.""" - user_id = user["sub"] if user else None + user_id = user["sub"] room = await rooms_controller.get_by_id(room_id) if not room: raise HTTPException(status_code=404, detail="Room not found") - if user_id and room.user_id != user_id: + if room.user_id != user_id: raise HTTPException( status_code=403, detail="Not authorized to test this room's webhook" ) diff --git a/server/reflector/views/transcripts.py b/server/reflector/views/transcripts.py index ed2445ae..839c6cdb 100644 --- a/server/reflector/views/transcripts.py +++ b/server/reflector/views/transcripts.py @@ -9,8 +9,6 @@ from pydantic import BaseModel, Field, constr, field_serializer import reflector.auth as auth from reflector.db import get_database -from reflector.db.meetings import meetings_controller -from reflector.db.rooms import rooms_controller from reflector.db.search import ( DEFAULT_SEARCH_LIMIT, SearchLimit, @@ -344,12 +342,14 @@ async def transcript_get( async def transcript_update( transcript_id: str, info: UpdateTranscript, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Annotated[auth.UserInfo, Depends(auth.current_user)], ): - user_id = user["sub"] if user else None + user_id = user["sub"] transcript = await transcripts_controller.get_by_id_for_http( transcript_id, user_id=user_id ) + if not transcripts_controller.user_can_mutate(transcript, user_id): + raise HTTPException(status_code=403, detail="Not authorized") values = info.dict(exclude_unset=True) updated_transcript = await transcripts_controller.update(transcript, values) return updated_transcript @@ -358,18 +358,14 @@ async def transcript_update( @router.delete("/transcripts/{transcript_id}", response_model=DeletionStatus) async def transcript_delete( transcript_id: str, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Annotated[auth.UserInfo, Depends(auth.current_user)], ): - user_id = user["sub"] if user else None + user_id = user["sub"] transcript = await transcripts_controller.get_by_id(transcript_id) if not transcript: raise HTTPException(status_code=404, detail="Transcript not found") - - if transcript.meeting_id: - meeting = await meetings_controller.get_by_id(transcript.meeting_id) - room = await rooms_controller.get_by_id(meeting.room_id) - if room.is_shared: - user_id = None + if not transcripts_controller.user_can_mutate(transcript, user_id): + raise HTTPException(status_code=403, detail="Not authorized") await transcripts_controller.remove_by_id(transcript.id, user_id=user_id) return DeletionStatus(status="ok") @@ -443,15 +439,16 @@ async def transcript_post_to_zulip( stream: str, topic: str, include_topics: bool, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Annotated[auth.UserInfo, Depends(auth.current_user)], ): - user_id = user["sub"] if user else None + user_id = user["sub"] transcript = await transcripts_controller.get_by_id_for_http( transcript_id, user_id=user_id ) if not transcript: raise HTTPException(status_code=404, detail="Transcript not found") - + if not transcripts_controller.user_can_mutate(transcript, user_id): + raise HTTPException(status_code=403, detail="Not authorized") content = get_zulip_message(transcript, include_topics) message_updated = False diff --git a/server/reflector/views/transcripts_participants.py b/server/reflector/views/transcripts_participants.py index 6b407c69..eb314eff 100644 --- a/server/reflector/views/transcripts_participants.py +++ b/server/reflector/views/transcripts_participants.py @@ -56,12 +56,14 @@ async def transcript_get_participants( async def transcript_add_participant( transcript_id: str, participant: CreateParticipant, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Annotated[auth.UserInfo, Depends(auth.current_user)], ) -> Participant: - user_id = user["sub"] if user else None + user_id = user["sub"] transcript = await transcripts_controller.get_by_id_for_http( transcript_id, user_id=user_id ) + if transcript.user_id is not None and transcript.user_id != user_id: + raise HTTPException(status_code=403, detail="Not authorized") # ensure the speaker is unique if participant.speaker is not None and transcript.participants is not None: @@ -101,12 +103,14 @@ async def transcript_update_participant( transcript_id: str, participant_id: str, participant: UpdateParticipant, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Annotated[auth.UserInfo, Depends(auth.current_user)], ) -> Participant: - user_id = user["sub"] if user else None + user_id = user["sub"] transcript = await transcripts_controller.get_by_id_for_http( transcript_id, user_id=user_id ) + if transcript.user_id is not None and transcript.user_id != user_id: + raise HTTPException(status_code=403, detail="Not authorized") # ensure the speaker is unique for p in transcript.participants: @@ -138,11 +142,13 @@ async def transcript_update_participant( async def transcript_delete_participant( transcript_id: str, participant_id: str, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Annotated[auth.UserInfo, Depends(auth.current_user)], ) -> DeletionStatus: - user_id = user["sub"] if user else None + user_id = user["sub"] transcript = await transcripts_controller.get_by_id_for_http( transcript_id, user_id=user_id ) + if transcript.user_id is not None and transcript.user_id != user_id: + raise HTTPException(status_code=403, detail="Not authorized") await transcripts_controller.delete_participant(transcript, participant_id) return DeletionStatus(status="ok") diff --git a/server/reflector/views/transcripts_speaker.py b/server/reflector/views/transcripts_speaker.py index e027bd44..787e554a 100644 --- a/server/reflector/views/transcripts_speaker.py +++ b/server/reflector/views/transcripts_speaker.py @@ -35,12 +35,14 @@ class SpeakerMerge(BaseModel): async def transcript_assign_speaker( transcript_id: str, assignment: SpeakerAssignment, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Annotated[auth.UserInfo, Depends(auth.current_user)], ) -> SpeakerAssignmentStatus: - user_id = user["sub"] if user else None + user_id = user["sub"] transcript = await transcripts_controller.get_by_id_for_http( transcript_id, user_id=user_id ) + if transcript.user_id is not None and transcript.user_id != user_id: + raise HTTPException(status_code=403, detail="Not authorized") if not transcript: raise HTTPException(status_code=404, detail="Transcript not found") @@ -113,12 +115,14 @@ async def transcript_assign_speaker( async def transcript_merge_speaker( transcript_id: str, merge: SpeakerMerge, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Annotated[auth.UserInfo, Depends(auth.current_user)], ) -> SpeakerAssignmentStatus: - user_id = user["sub"] if user else None + user_id = user["sub"] transcript = await transcripts_controller.get_by_id_for_http( transcript_id, user_id=user_id ) + if transcript.user_id is not None and transcript.user_id != user_id: + raise HTTPException(status_code=403, detail="Not authorized") if not transcript: raise HTTPException(status_code=404, detail="Transcript not found") diff --git a/server/reflector/views/transcripts_websocket.py b/server/reflector/views/transcripts_websocket.py index c78e418c..ccb7d7ff 100644 --- a/server/reflector/views/transcripts_websocket.py +++ b/server/reflector/views/transcripts_websocket.py @@ -4,8 +4,11 @@ Transcripts websocket API """ -from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect + +import reflector.auth as auth from reflector.db.transcripts import transcripts_controller from reflector.ws_manager import get_ws_manager @@ -21,10 +24,12 @@ async def transcript_get_websocket_events(transcript_id: str): async def transcript_events_websocket( transcript_id: str, websocket: WebSocket, - # user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Optional[auth.UserInfo] = Depends(auth.current_user_optional), ): - # user_id = user["sub"] if user else None - transcript = await transcripts_controller.get_by_id(transcript_id) + user_id = user["sub"] if user else None + transcript = await transcripts_controller.get_by_id_for_http( + transcript_id, user_id=user_id + ) if not transcript: raise HTTPException(status_code=404, detail="Transcript not found") diff --git a/server/tests/conftest.py b/server/tests/conftest.py index 22fe4193..8271d1ad 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -1,4 +1,5 @@ import os +from contextlib import asynccontextmanager from tempfile import NamedTemporaryFile from unittest.mock import patch @@ -337,6 +338,162 @@ async def client(): yield ac +@pytest.fixture(autouse=True) +async def ws_manager_in_memory(monkeypatch): + """Replace Redis-based WS manager with an in-memory implementation for tests.""" + import asyncio + import json + + from reflector.ws_manager import WebsocketManager + + class _InMemorySubscriber: + def __init__(self, queue: asyncio.Queue): + self.queue = queue + + async def get_message(self, ignore_subscribe_messages: bool = True): + try: + return await asyncio.wait_for(self.queue.get(), timeout=0.05) + except Exception: + return None + + class InMemoryPubSubManager: + def __init__(self): + self.queues: dict[str, asyncio.Queue] = {} + self.connected = False + + async def connect(self) -> None: + self.connected = True + + async def disconnect(self) -> None: + self.connected = False + + async def send_json(self, room_id: str, message: dict) -> None: + if room_id not in self.queues: + self.queues[room_id] = asyncio.Queue() + payload = json.dumps(message).encode("utf-8") + await self.queues[room_id].put( + {"channel": room_id.encode("utf-8"), "data": payload} + ) + + async def subscribe(self, room_id: str): + if room_id not in self.queues: + self.queues[room_id] = asyncio.Queue() + return _InMemorySubscriber(self.queues[room_id]) + + async def unsubscribe(self, room_id: str) -> None: + # keep queue for potential later resubscribe within same test + pass + + pubsub = InMemoryPubSubManager() + ws_manager = WebsocketManager(pubsub_client=pubsub) + + def _get_ws_manager(): + return ws_manager + + # Patch all places that imported get_ws_manager at import time + monkeypatch.setattr("reflector.ws_manager.get_ws_manager", _get_ws_manager) + monkeypatch.setattr( + "reflector.pipelines.main_live_pipeline.get_ws_manager", _get_ws_manager + ) + monkeypatch.setattr( + "reflector.views.transcripts_websocket.get_ws_manager", _get_ws_manager + ) + + # Websocket auth: avoid OAuth2 on websocket dependencies; allow anonymous + import reflector.auth as auth + + # Ensure FastAPI uses our override for routes that captured the original callable + from reflector.app import app as fastapi_app + + try: + fastapi_app.dependency_overrides[auth.current_user_optional] = lambda: None + except Exception: + pass + + # Stub Redis cache used by profanity filter to avoid external Redis + from reflector import redis_cache as rc + + class _FakeRedis: + def __init__(self): + self._data = {} + + def get(self, key): + value = self._data.get(key) + if value is None: + return None + if isinstance(value, bytes): + return value + return str(value).encode("utf-8") + + def setex(self, key, duration, value): + # ignore duration for tests + if isinstance(value, bytes): + self._data[key] = value + else: + self._data[key] = str(value).encode("utf-8") + + fake_redises: dict[int, _FakeRedis] = {} + + def _get_redis_client(db=0): + if db not in fake_redises: + fake_redises[db] = _FakeRedis() + return fake_redises[db] + + monkeypatch.setattr(rc, "get_redis_client", _get_redis_client) + + yield + + +@pytest.fixture +@pytest.mark.asyncio +async def authenticated_client(): + async with authenticated_client_ctx(): + yield + + +@pytest.fixture +@pytest.mark.asyncio +async def authenticated_client2(): + async with authenticated_client2_ctx(): + yield + + +@asynccontextmanager +async def authenticated_client_ctx(): + from reflector.app import app + from reflector.auth import current_user, current_user_optional + + app.dependency_overrides[current_user] = lambda: { + "sub": "randomuserid", + "email": "test@mail.com", + } + app.dependency_overrides[current_user_optional] = lambda: { + "sub": "randomuserid", + "email": "test@mail.com", + } + yield + del app.dependency_overrides[current_user] + del app.dependency_overrides[current_user_optional] + + +@asynccontextmanager +async def authenticated_client2_ctx(): + from reflector.app import app + from reflector.auth import current_user, current_user_optional + + app.dependency_overrides[current_user] = lambda: { + "sub": "randomuserid2", + "email": "test@mail.com", + } + app.dependency_overrides[current_user_optional] = lambda: { + "sub": "randomuserid2", + "email": "test@mail.com", + } + yield + del app.dependency_overrides[current_user] + del app.dependency_overrides[current_user_optional] + + @pytest.fixture(scope="session") def fake_mp3_upload(): with patch( diff --git a/server/tests/test_room_ics_api.py b/server/tests/test_room_ics_api.py index 27a784d7..8e7cf76f 100644 --- a/server/tests/test_room_ics_api.py +++ b/server/tests/test_room_ics_api.py @@ -11,14 +11,21 @@ from reflector.db.rooms import rooms_controller @pytest.fixture async def authenticated_client(client): from reflector.app import app - from reflector.auth import current_user_optional + from reflector.auth import current_user, current_user_optional + app.dependency_overrides[current_user] = lambda: { + "sub": "test-user", + "email": "test@example.com", + } app.dependency_overrides[current_user_optional] = lambda: { "sub": "test-user", "email": "test@example.com", } - yield client - del app.dependency_overrides[current_user_optional] + try: + yield client + finally: + del app.dependency_overrides[current_user] + del app.dependency_overrides[current_user_optional] @pytest.mark.asyncio diff --git a/server/tests/test_security_permissions.py b/server/tests/test_security_permissions.py new file mode 100644 index 00000000..ef871152 --- /dev/null +++ b/server/tests/test_security_permissions.py @@ -0,0 +1,384 @@ +import asyncio +import shutil +import threading +import time +from pathlib import Path + +import pytest +from httpx_ws import aconnect_ws +from uvicorn import Config, Server + +from reflector import zulip as zulip_module +from reflector.app import app +from reflector.db import get_database +from reflector.db.meetings import meetings_controller +from reflector.db.rooms import Room, rooms_controller +from reflector.db.transcripts import ( + SourceKind, + TranscriptTopic, + transcripts_controller, +) +from reflector.processors.types import Word +from reflector.settings import settings +from reflector.views.transcripts import create_access_token + + +@pytest.mark.asyncio +async def test_anonymous_cannot_delete_transcript_in_shared_room(client): + # Create a shared room with a fake owner id so meeting has a room_id + room = await rooms_controller.add( + name="shared-room-test", + user_id="owner-1", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=True, + webhook_url="", + webhook_secret="", + ) + + # Create a meeting for that room (so transcript.meeting_id links to the shared room) + meeting = await meetings_controller.create( + id="meeting-sec-test", + room_name="room-sec-test", + room_url="room-url", + host_room_url="host-url", + start_date=Room.model_fields["created_at"].default_factory(), + end_date=Room.model_fields["created_at"].default_factory(), + room=room, + ) + + # Create a transcript owned by someone else and link it to meeting + t = await transcripts_controller.add( + name="to-delete", + source_kind=SourceKind.LIVE, + user_id="owner-2", + meeting_id=meeting.id, + room_id=room.id, + share_mode="private", + ) + + # Anonymous DELETE should be rejected + del_resp = await client.delete(f"/transcripts/{t.id}") + assert del_resp.status_code == 401, del_resp.text + + +@pytest.mark.asyncio +async def test_anonymous_cannot_mutate_participants_on_public_transcript(client): + # Create a public transcript with no owner + t = await transcripts_controller.add( + name="public-transcript", + source_kind=SourceKind.LIVE, + user_id=None, + share_mode="public", + ) + + # Anonymous POST participant must be rejected + resp = await client.post( + f"/transcripts/{t.id}/participants", + json={"name": "AnonUser", "speaker": 0}, + ) + assert resp.status_code == 401, resp.text + + +@pytest.mark.asyncio +async def test_anonymous_cannot_update_and_delete_room(client): + # Create room as owner id "owner-3" via controller + room = await rooms_controller.add( + name="room-anon-update-delete", + user_id="owner-3", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + webhook_url="", + webhook_secret="", + ) + + # Anonymous PATCH via API (no auth) + resp = await client.patch( + f"/rooms/{room.id}", + json={ + "name": "room-anon-updated", + "zulip_auto_post": False, + "zulip_stream": "", + "zulip_topic": "", + "is_locked": False, + "room_mode": "normal", + "recording_type": "cloud", + "recording_trigger": "automatic-2nd-participant", + "is_shared": False, + "webhook_url": "", + "webhook_secret": "", + }, + ) + # Expect authentication required + assert resp.status_code == 401, resp.text + + # Anonymous DELETE via API + del_resp = await client.delete(f"/rooms/{room.id}") + assert del_resp.status_code == 401, del_resp.text + + +@pytest.mark.asyncio +async def test_anonymous_cannot_post_transcript_to_zulip(client, monkeypatch): + # Create a public transcript with some content + t = await transcripts_controller.add( + name="zulip-public", + source_kind=SourceKind.LIVE, + user_id=None, + share_mode="public", + ) + + # Mock send/update calls + def _fake_send_message_to_zulip(stream, topic, content): + return {"id": 12345} + + async def _fake_update_message(message_id, stream, topic, content): + return {"result": "success"} + + monkeypatch.setattr( + zulip_module, "send_message_to_zulip", _fake_send_message_to_zulip + ) + monkeypatch.setattr(zulip_module, "update_zulip_message", _fake_update_message) + + # Anonymous POST to Zulip endpoint + resp = await client.post( + f"/transcripts/{t.id}/zulip", + params={"stream": "general", "topic": "Updates", "include_topics": False}, + ) + assert resp.status_code == 401, resp.text + + +@pytest.mark.asyncio +async def test_anonymous_cannot_assign_speaker_on_public_transcript(client): + # Create public transcript + t = await transcripts_controller.add( + name="public-assign", + source_kind=SourceKind.LIVE, + user_id=None, + share_mode="public", + ) + + # Add a topic with words to be reassigned + topic = TranscriptTopic( + title="T1", + summary="S1", + timestamp=0.0, + transcript="Hello", + words=[Word(start=0.0, end=1.0, text="Hello", speaker=0)], + ) + transcript = await transcripts_controller.get_by_id(t.id) + await transcripts_controller.upsert_topic(transcript, topic) + + # Anonymous assign speaker over time range covering the word + resp = await client.patch( + f"/transcripts/{t.id}/speaker/assign", + json={ + "speaker": 1, + "timestamp_from": 0.0, + "timestamp_to": 1.0, + }, + ) + assert resp.status_code == 401, resp.text + + +# Minimal server fixture for websocket tests +@pytest.fixture +def appserver_ws_simple(setup_database): + host = "127.0.0.1" + port = 1256 + server_started = threading.Event() + server_exception = None + server_instance = None + + def run_server(): + nonlocal server_exception, server_instance + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + config = Config(app=app, host=host, port=port, loop=loop) + server_instance = Server(config) + + async def start_server(): + database = get_database() + await database.connect() + try: + await server_instance.serve() + finally: + await database.disconnect() + + server_started.set() + loop.run_until_complete(start_server()) + except Exception as e: + server_exception = e + server_started.set() + finally: + loop.close() + + server_thread = threading.Thread(target=run_server, daemon=True) + server_thread.start() + + server_started.wait(timeout=30) + if server_exception: + raise server_exception + + time.sleep(0.5) + + yield host, port + + if server_instance: + server_instance.should_exit = True + server_thread.join(timeout=30) + + +@pytest.mark.asyncio +async def test_websocket_denies_anonymous_on_private_transcript(appserver_ws_simple): + host, port = appserver_ws_simple + + # Create a private transcript owned by someone + t = await transcripts_controller.add( + name="private-ws", + source_kind=SourceKind.LIVE, + user_id="owner-x", + share_mode="private", + ) + + base_url = f"http://{host}:{port}/v1" + # Anonymous connect should be denied + with pytest.raises(Exception): + async with aconnect_ws(f"{base_url}/transcripts/{t.id}/events") as ws: + await ws.close() + + +@pytest.mark.asyncio +async def test_anonymous_cannot_update_public_transcript(client): + t = await transcripts_controller.add( + name="update-me", + source_kind=SourceKind.LIVE, + user_id=None, + share_mode="public", + ) + + resp = await client.patch( + f"/transcripts/{t.id}", + json={"title": "New Title From Anonymous"}, + ) + assert resp.status_code == 401, resp.text + + +@pytest.mark.asyncio +async def test_anonymous_cannot_get_nonshared_room_by_id(client): + room = await rooms_controller.add( + name="private-room-exposed", + user_id="owner-z", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + webhook_url="", + webhook_secret="", + ) + + resp = await client.get(f"/rooms/{room.id}") + assert resp.status_code == 403, resp.text + + +@pytest.mark.asyncio +async def test_anonymous_cannot_call_rooms_webhook_test(client): + room = await rooms_controller.add( + name="room-webhook-test", + user_id="owner-y", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + webhook_url="http://localhost.invalid/webhook", + webhook_secret="secret", + ) + + # Anonymous caller + resp = await client.post(f"/rooms/{room.id}/webhook/test") + assert resp.status_code == 401, resp.text + + +@pytest.mark.asyncio +async def test_anonymous_cannot_create_room(client): + payload = { + "name": "room-create-auth-required", + "zulip_auto_post": False, + "zulip_stream": "", + "zulip_topic": "", + "is_locked": False, + "room_mode": "normal", + "recording_type": "cloud", + "recording_trigger": "automatic-2nd-participant", + "is_shared": False, + "webhook_url": "", + "webhook_secret": "", + } + resp = await client.post("/rooms", json=payload) + assert resp.status_code == 401, resp.text + + +@pytest.mark.asyncio +async def test_list_search_401_when_public_mode_false(client, monkeypatch): + monkeypatch.setattr(settings, "PUBLIC_MODE", False) + + resp = await client.get("/transcripts") + assert resp.status_code == 401 + + resp = await client.get("/transcripts/search", params={"q": "hello"}) + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_audio_mp3_requires_token_for_owned_transcript( + client, tmpdir, monkeypatch +): + # Use temp data dir + monkeypatch.setattr(settings, "DATA_DIR", Path(tmpdir).as_posix()) + + # Create owner transcript and attach a local mp3 + t = await transcripts_controller.add( + name="owned-audio", + source_kind=SourceKind.LIVE, + user_id="owner-a", + share_mode="private", + ) + + tr = await transcripts_controller.get_by_id(t.id) + await transcripts_controller.update(tr, {"status": "ended"}) + + # copy fixture audio to transcript path + audio_path = Path(__file__).parent / "records" / "test_mathieu_hello.mp3" + tr.audio_mp3_filename.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(audio_path, tr.audio_mp3_filename) + + # Anonymous GET without token should be 403 or 404 depending on access; we call mp3 + resp = await client.get(f"/transcripts/{t.id}/audio/mp3") + assert resp.status_code == 403 + + # With token should succeed + token = create_access_token( + {"sub": tr.user_id}, expires_delta=__import__("datetime").timedelta(minutes=15) + ) + resp2 = await client.get(f"/transcripts/{t.id}/audio/mp3", params={"token": token}) + assert resp2.status_code == 200 diff --git a/server/tests/test_transcripts.py b/server/tests/test_transcripts.py index 8ce0bd36..2c6acc77 100644 --- a/server/tests/test_transcripts.py +++ b/server/tests/test_transcripts.py @@ -1,5 +1,3 @@ -from contextlib import asynccontextmanager - import pytest @@ -19,7 +17,7 @@ async def test_transcript_create(client): @pytest.mark.asyncio -async def test_transcript_get_update_name(client): +async def test_transcript_get_update_name(authenticated_client, client): response = await client.post("/transcripts", json={"name": "test"}) assert response.status_code == 200 assert response.json()["name"] == "test" @@ -40,7 +38,7 @@ async def test_transcript_get_update_name(client): @pytest.mark.asyncio -async def test_transcript_get_update_locked(client): +async def test_transcript_get_update_locked(authenticated_client, client): response = await client.post("/transcripts", json={"name": "test"}) assert response.status_code == 200 assert response.json()["locked"] is False @@ -61,7 +59,7 @@ async def test_transcript_get_update_locked(client): @pytest.mark.asyncio -async def test_transcript_get_update_summary(client): +async def test_transcript_get_update_summary(authenticated_client, client): response = await client.post("/transcripts", json={"name": "test"}) assert response.status_code == 200 assert response.json()["long_summary"] is None @@ -89,7 +87,7 @@ async def test_transcript_get_update_summary(client): @pytest.mark.asyncio -async def test_transcript_get_update_title(client): +async def test_transcript_get_update_title(authenticated_client, client): response = await client.post("/transcripts", json={"name": "test"}) assert response.status_code == 200 assert response.json()["title"] is None @@ -127,56 +125,6 @@ async def test_transcripts_list_anonymous(client): settings.PUBLIC_MODE = False -@asynccontextmanager -async def authenticated_client_ctx(): - from reflector.app import app - from reflector.auth import current_user, current_user_optional - - app.dependency_overrides[current_user] = lambda: { - "sub": "randomuserid", - "email": "test@mail.com", - } - app.dependency_overrides[current_user_optional] = lambda: { - "sub": "randomuserid", - "email": "test@mail.com", - } - yield - del app.dependency_overrides[current_user] - del app.dependency_overrides[current_user_optional] - - -@asynccontextmanager -async def authenticated_client2_ctx(): - from reflector.app import app - from reflector.auth import current_user, current_user_optional - - app.dependency_overrides[current_user] = lambda: { - "sub": "randomuserid2", - "email": "test@mail.com", - } - app.dependency_overrides[current_user_optional] = lambda: { - "sub": "randomuserid2", - "email": "test@mail.com", - } - yield - del app.dependency_overrides[current_user] - del app.dependency_overrides[current_user_optional] - - -@pytest.fixture -@pytest.mark.asyncio -async def authenticated_client(): - async with authenticated_client_ctx(): - yield - - -@pytest.fixture -@pytest.mark.asyncio -async def authenticated_client2(): - async with authenticated_client2_ctx(): - yield - - @pytest.mark.asyncio async def test_transcripts_list_authenticated(authenticated_client, client): # XXX this test is a bit fragile, as it depends on the storage which @@ -199,7 +147,7 @@ async def test_transcripts_list_authenticated(authenticated_client, client): @pytest.mark.asyncio -async def test_transcript_delete(client): +async def test_transcript_delete(authenticated_client, client): response = await client.post("/transcripts", json={"name": "testdel1"}) assert response.status_code == 200 assert response.json()["name"] == "testdel1" @@ -214,7 +162,7 @@ async def test_transcript_delete(client): @pytest.mark.asyncio -async def test_transcript_mark_reviewed(client): +async def test_transcript_mark_reviewed(authenticated_client, client): response = await client.post("/transcripts", json={"name": "test"}) assert response.status_code == 200 assert response.json()["name"] == "test" diff --git a/server/tests/test_transcripts_audio_download.py b/server/tests/test_transcripts_audio_download.py index e40d0ade..b7dcfca9 100644 --- a/server/tests/test_transcripts_audio_download.py +++ b/server/tests/test_transcripts_audio_download.py @@ -111,7 +111,9 @@ async def test_transcript_audio_download_range_with_seek( @pytest.mark.asyncio -async def test_transcript_delete_with_audio(fake_transcript, client): +async def test_transcript_delete_with_audio( + authenticated_client, fake_transcript, client +): response = await client.delete(f"/transcripts/{fake_transcript.id}") assert response.status_code == 200 assert response.json()["status"] == "ok" diff --git a/server/tests/test_transcripts_participants.py b/server/tests/test_transcripts_participants.py index 076f750e..24ec6a90 100644 --- a/server/tests/test_transcripts_participants.py +++ b/server/tests/test_transcripts_participants.py @@ -2,7 +2,7 @@ import pytest @pytest.mark.asyncio -async def test_transcript_participants(client): +async def test_transcript_participants(authenticated_client, client): response = await client.post("/transcripts", json={"name": "test"}) assert response.status_code == 200 assert response.json()["participants"] == [] @@ -39,7 +39,7 @@ async def test_transcript_participants(client): @pytest.mark.asyncio -async def test_transcript_participants_same_speaker(client): +async def test_transcript_participants_same_speaker(authenticated_client, client): response = await client.post("/transcripts", json={"name": "test"}) assert response.status_code == 200 assert response.json()["participants"] == [] @@ -62,7 +62,7 @@ async def test_transcript_participants_same_speaker(client): @pytest.mark.asyncio -async def test_transcript_participants_update_name(client): +async def test_transcript_participants_update_name(authenticated_client, client): response = await client.post("/transcripts", json={"name": "test"}) assert response.status_code == 200 assert response.json()["participants"] == [] @@ -100,7 +100,7 @@ async def test_transcript_participants_update_name(client): @pytest.mark.asyncio -async def test_transcript_participants_update_speaker(client): +async def test_transcript_participants_update_speaker(authenticated_client, client): response = await client.post("/transcripts", json={"name": "test"}) assert response.status_code == 200 assert response.json()["participants"] == [] diff --git a/server/tests/test_transcripts_speaker.py b/server/tests/test_transcripts_speaker.py index d18c5072..e85eb1c7 100644 --- a/server/tests/test_transcripts_speaker.py +++ b/server/tests/test_transcripts_speaker.py @@ -2,7 +2,9 @@ import pytest @pytest.mark.asyncio -async def test_transcript_reassign_speaker(fake_transcript_with_topics, client): +async def test_transcript_reassign_speaker( + authenticated_client, fake_transcript_with_topics, client +): transcript_id = fake_transcript_with_topics.id # check the transcript exists @@ -114,7 +116,9 @@ async def test_transcript_reassign_speaker(fake_transcript_with_topics, client): @pytest.mark.asyncio -async def test_transcript_merge_speaker(fake_transcript_with_topics, client): +async def test_transcript_merge_speaker( + authenticated_client, fake_transcript_with_topics, client +): transcript_id = fake_transcript_with_topics.id # check the transcript exists @@ -181,7 +185,7 @@ async def test_transcript_merge_speaker(fake_transcript_with_topics, client): @pytest.mark.asyncio async def test_transcript_reassign_with_participant( - fake_transcript_with_topics, client + authenticated_client, fake_transcript_with_topics, client ): transcript_id = fake_transcript_with_topics.id @@ -347,7 +351,9 @@ async def test_transcript_reassign_with_participant( @pytest.mark.asyncio -async def test_transcript_reassign_edge_cases(fake_transcript_with_topics, client): +async def test_transcript_reassign_edge_cases( + authenticated_client, fake_transcript_with_topics, client +): transcript_id = fake_transcript_with_topics.id # check the transcript exists diff --git a/www/app/(app)/transcripts/useWebSockets.ts b/www/app/(app)/transcripts/useWebSockets.ts index 09426061..ed44577e 100644 --- a/www/app/(app)/transcripts/useWebSockets.ts +++ b/www/app/(app)/transcripts/useWebSockets.ts @@ -62,7 +62,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { useEffect(() => { document.onkeyup = (e) => { - if (e.key === "a" && process.env.NEXT_PUBLIC_ENV === "development") { + if (e.key === "a" && process.env.NODE_ENV === "development") { const segments: GetTranscriptSegmentTopic[] = [ { speaker: 1, @@ -201,7 +201,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { setFinalSummary({ summary: "This is the final summary" }); } - if (e.key === "z" && process.env.NEXT_PUBLIC_ENV === "development") { + if (e.key === "z" && process.env.NODE_ENV === "development") { setTranscriptTextLive( "This text is in English, and it is a pretty long sentence to test the limits", );