From cf6e867cf12c42411e5a7412f6ec44eee8351665 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Diego=20Garc=C3=ADa?= Date: Wed, 11 Mar 2026 10:48:49 -0500 Subject: [PATCH] fix: add auth guards to prevent anonymous access to write endpoints in non-public mode (#907) * fix: add auth guards to prevent anonymous access to write endpoints in non-public mode * test: anon data accessible regardless of guards * fix: celery test --- server/reflector/views/meetings.py | 9 +- server/reflector/views/transcripts.py | 3 + server/reflector/views/transcripts_process.py | 4 + server/reflector/views/transcripts_upload.py | 4 + server/reflector/views/transcripts_webrtc.py | 4 + server/reflector/worker/process.py | 4 +- server/tests/conftest.py | 5 +- server/tests/test_security_permissions.py | 605 ++++++++++++++++++ server/tests/test_transcript_formats.py | 43 +- server/tests/test_transcripts.py | 16 +- .../tests/test_transcripts_audio_download.py | 5 +- server/tests/test_transcripts_process.py | 27 +- server/tests/test_transcripts_rtc_ws.py | 14 + server/tests/test_transcripts_translation.py | 17 +- server/tests/test_transcripts_upload.py | 6 + 15 files changed, 745 insertions(+), 21 deletions(-) diff --git a/server/reflector/views/meetings.py b/server/reflector/views/meetings.py index 44adf500..b5aef231 100644 --- a/server/reflector/views/meetings.py +++ b/server/reflector/views/meetings.py @@ -16,6 +16,7 @@ from reflector.db.meetings import ( ) from reflector.db.rooms import rooms_controller from reflector.logger import logger +from reflector.settings import settings from reflector.utils.string import NonEmptyString from reflector.video_platforms.factory import create_platform_client @@ -89,15 +90,17 @@ class StartRecordingRequest(BaseModel): @router.post("/meetings/{meeting_id}/recordings/start") async def start_recording( - meeting_id: NonEmptyString, body: StartRecordingRequest + meeting_id: NonEmptyString, + body: StartRecordingRequest, + user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], ) -> dict[str, Any]: """Start cloud or raw-tracks recording via Daily.co REST API. Both cloud and raw-tracks are started via REST API to bypass enable_recording limitation of allowing only 1 recording at a time. Uses different instanceIds for cloud vs raw-tracks (same won't work) - - Note: No authentication required - anonymous users supported. TODO this is a DOS vector """ + if not user and not settings.PUBLIC_MODE: + raise HTTPException(status_code=401, detail="Not authenticated") meeting = await meetings_controller.get_by_id(meeting_id) if not meeting: raise HTTPException(status_code=404, detail="Meeting not found") diff --git a/server/reflector/views/transcripts.py b/server/reflector/views/transcripts.py index 69bc9a80..ea75c303 100644 --- a/server/reflector/views/transcripts.py +++ b/server/reflector/views/transcripts.py @@ -348,6 +348,9 @@ async def transcripts_create( info: CreateTranscript, user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], ): + if not user and not settings.PUBLIC_MODE: + raise HTTPException(status_code=401, detail="Not authenticated") + user_id = user["sub"] if user else None transcript = await transcripts_controller.add( info.name, diff --git a/server/reflector/views/transcripts_process.py b/server/reflector/views/transcripts_process.py index 1e1f7201..af80df43 100644 --- a/server/reflector/views/transcripts_process.py +++ b/server/reflector/views/transcripts_process.py @@ -15,6 +15,7 @@ from reflector.services.transcript_process import ( prepare_transcript_processing, validate_transcript_for_processing, ) +from reflector.settings import settings router = APIRouter() @@ -28,6 +29,9 @@ async def transcript_process( transcript_id: str, user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], ) -> ProcessStatus: + if not user and not settings.PUBLIC_MODE: + raise HTTPException(status_code=401, detail="Not authenticated") + user_id = user["sub"] if user else None transcript = await transcripts_controller.get_by_id_for_http( transcript_id, user_id=user_id diff --git a/server/reflector/views/transcripts_upload.py b/server/reflector/views/transcripts_upload.py index 1e8fe2e6..165a266a 100644 --- a/server/reflector/views/transcripts_upload.py +++ b/server/reflector/views/transcripts_upload.py @@ -7,6 +7,7 @@ from pydantic import BaseModel import reflector.auth as auth from reflector.db.transcripts import SourceKind, transcripts_controller from reflector.pipelines.main_file_pipeline import task_pipeline_file_process +from reflector.settings import settings router = APIRouter() @@ -23,6 +24,9 @@ async def transcript_record_upload( chunk: UploadFile, user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], ): + if not user and not settings.PUBLIC_MODE: + raise HTTPException(status_code=401, detail="Not authenticated") + user_id = user["sub"] if user else None transcript = await transcripts_controller.get_by_id_for_http( transcript_id, user_id=user_id diff --git a/server/reflector/views/transcripts_webrtc.py b/server/reflector/views/transcripts_webrtc.py index bd731cac..184949ad 100644 --- a/server/reflector/views/transcripts_webrtc.py +++ b/server/reflector/views/transcripts_webrtc.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request import reflector.auth as auth from reflector.db.transcripts import transcripts_controller +from reflector.settings import settings from .rtc_offer import RtcOffer, rtc_offer_base @@ -17,6 +18,9 @@ async def transcript_record_webrtc( request: Request, user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], ): + if not user and not settings.PUBLIC_MODE: + raise HTTPException(status_code=401, detail="Not authenticated") + user_id = user["sub"] if user else None transcript = await transcripts_controller.get_by_id_for_http( transcript_id, user_id=user_id diff --git a/server/reflector/worker/process.py b/server/reflector/worker/process.py index ede72947..152175d0 100644 --- a/server/reflector/worker/process.py +++ b/server/reflector/worker/process.py @@ -132,7 +132,7 @@ async def process_recording(bucket_name: str, object_key: str): target_language="en", user_id=room.user_id, recording_id=recording.id, - share_mode="public", + share_mode="semi-private", meeting_id=meeting.id, room_id=room.id, ) @@ -343,7 +343,7 @@ async def _process_multitrack_recording_inner( target_language="en", user_id=room.user_id, recording_id=recording.id, - share_mode="public", + share_mode="semi-private", meeting_id=meeting.id, room_id=room.id, ) diff --git a/server/tests/conftest.py b/server/tests/conftest.py index 0dace1b9..e0d6fe90 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -550,7 +550,7 @@ def reset_hatchet_client(): @pytest.fixture -async def fake_transcript_with_topics(tmpdir, client): +async def fake_transcript_with_topics(tmpdir, client, monkeypatch): import shutil from pathlib import Path @@ -559,6 +559,9 @@ async def fake_transcript_with_topics(tmpdir, client): from reflector.settings import settings from reflector.views.transcripts import transcripts_controller + monkeypatch.setattr( + settings, "PUBLIC_MODE", True + ) # public mode: allow anonymous transcript creation for this test settings.DATA_DIR = Path(tmpdir) # create a transcript diff --git a/server/tests/test_security_permissions.py b/server/tests/test_security_permissions.py index ef871152..83d03223 100644 --- a/server/tests/test_security_permissions.py +++ b/server/tests/test_security_permissions.py @@ -5,6 +5,7 @@ import time from pathlib import Path import pytest +from conftest import authenticated_client_ctx from httpx_ws import aconnect_ws from uvicorn import Config, Server @@ -382,3 +383,607 @@ async def test_audio_mp3_requires_token_for_owned_transcript( ) resp2 = await client.get(f"/transcripts/{t.id}/audio/mp3", params={"token": token}) assert resp2.status_code == 200 + + +# ====================================================================== +# Auth guards: anonymous blocked when PUBLIC_MODE=False +# ====================================================================== + + +@pytest.mark.asyncio +async def test_anonymous_cannot_create_transcript_when_not_public(client, monkeypatch): + monkeypatch.setattr(settings, "PUBLIC_MODE", False) + + resp = await client.post("/transcripts", json={"name": "anon-test"}) + assert resp.status_code == 401, resp.text + + +@pytest.mark.asyncio +async def test_anonymous_cannot_process_transcript_when_not_public(client, monkeypatch): + monkeypatch.setattr(settings, "PUBLIC_MODE", False) + + t = await transcripts_controller.add( + name="process-test", + source_kind=SourceKind.LIVE, + user_id=None, + share_mode="public", + ) + + resp = await client.post(f"/transcripts/{t.id}/process") + assert resp.status_code == 401, resp.text + + +@pytest.mark.asyncio +async def test_anonymous_cannot_upload_when_not_public(client, monkeypatch): + monkeypatch.setattr(settings, "PUBLIC_MODE", False) + + t = await transcripts_controller.add( + name="upload-test", + source_kind=SourceKind.LIVE, + user_id=None, + share_mode="public", + ) + + # Minimal multipart upload + resp = await client.post( + f"/transcripts/{t.id}/record/upload", + params={"chunk_number": 0, "total_chunks": 1}, + files={"chunk": ("test.mp3", b"fake-audio", "audio/mpeg")}, + ) + assert resp.status_code == 401, resp.text + + +@pytest.mark.asyncio +async def test_anonymous_cannot_webrtc_record_when_not_public(client, monkeypatch): + monkeypatch.setattr(settings, "PUBLIC_MODE", False) + + t = await transcripts_controller.add( + name="webrtc-test", + source_kind=SourceKind.LIVE, + user_id=None, + share_mode="public", + ) + + resp = await client.post( + f"/transcripts/{t.id}/record/webrtc", + json={"sdp": "v=0\r\n", "type": "offer"}, + ) + assert resp.status_code == 401, resp.text + + +@pytest.mark.asyncio +async def test_anonymous_cannot_start_meeting_recording_when_not_public( + client, monkeypatch +): + monkeypatch.setattr(settings, "PUBLIC_MODE", False) + + room = await rooms_controller.add( + name="recording-auth-test", + user_id="owner-rec", + 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="", + ) + + meeting = await meetings_controller.create( + id="meeting-rec-test", + room_name="recording-auth-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, + ) + + resp = await client.post( + f"/meetings/{meeting.id}/recordings/start", + json={"type": "cloud", "instanceId": "00000000-0000-0000-0000-000000000001"}, + ) + assert resp.status_code == 401, resp.text + + +# ====================================================================== +# Public mode: anonymous IS allowed when PUBLIC_MODE=True +# ====================================================================== + + +@pytest.mark.asyncio +async def test_anonymous_can_create_transcript_when_public(client, monkeypatch): + monkeypatch.setattr(settings, "PUBLIC_MODE", True) + + resp = await client.post("/transcripts", json={"name": "anon-public-test"}) + assert resp.status_code == 200, resp.text + + +@pytest.mark.asyncio +async def test_anonymous_can_list_transcripts_when_public(client, monkeypatch): + monkeypatch.setattr(settings, "PUBLIC_MODE", True) + + resp = await client.get("/transcripts") + assert resp.status_code == 200, resp.text + + +@pytest.mark.asyncio +async def test_anonymous_can_read_public_transcript(client, monkeypatch): + monkeypatch.setattr(settings, "PUBLIC_MODE", True) + + t = await transcripts_controller.add( + name="readable-test", + source_kind=SourceKind.LIVE, + user_id=None, + share_mode="public", + ) + + resp = await client.get(f"/transcripts/{t.id}") + assert resp.status_code == 200, resp.text + + +@pytest.mark.asyncio +async def test_anonymous_can_upload_when_public(client, monkeypatch): + monkeypatch.setattr(settings, "PUBLIC_MODE", True) + + t = await transcripts_controller.add( + name="upload-public-test", + source_kind=SourceKind.LIVE, + user_id=None, + share_mode="public", + ) + + resp = await client.post( + f"/transcripts/{t.id}/record/upload", + params={"chunk_number": 0, "total_chunks": 2}, + files={"chunk": ("test.mp3", b"fake-audio", "audio/mpeg")}, + ) + # Chunk 0 of 2 won't trigger av.open validation, so should succeed with "ok" + # The key assertion: auth did NOT block us (no 401) + assert resp.status_code != 401, f"Should not get 401 in public mode: {resp.text}" + assert resp.status_code == 200, resp.text + + +@pytest.mark.asyncio +async def test_anonymous_can_start_meeting_recording_when_public(client, monkeypatch): + monkeypatch.setattr(settings, "PUBLIC_MODE", True) + + room = await rooms_controller.add( + name="recording-public-test", + user_id="owner-pub", + 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="", + ) + + meeting = await meetings_controller.create( + id="meeting-pub-test", + room_name="recording-public-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, + ) + + resp = await client.post( + f"/meetings/{meeting.id}/recordings/start", + json={"type": "cloud", "instanceId": "00000000-0000-0000-0000-000000000002"}, + ) + # Should not be 401 (may fail for other reasons like no Daily API, but auth passes) + assert resp.status_code != 401, f"Should not get 401 in public mode: {resp.text}" + + +# ====================================================================== +# Authenticated user vs private data (own transcripts) +# Authenticated owner should be able to create, read, and process +# their own private transcripts even when PUBLIC_MODE=False +# ====================================================================== + + +@pytest.mark.asyncio +async def test_authenticated_can_create_transcript_private_mode(client, monkeypatch): + """Authenticated user can create transcripts even when PUBLIC_MODE=False.""" + monkeypatch.setattr(settings, "PUBLIC_MODE", False) + + async with authenticated_client_ctx(): + resp = await client.post("/transcripts", json={"name": "auth-private-create"}) + assert resp.status_code == 200, resp.text + assert resp.json()["user_id"] == "randomuserid" + + +@pytest.mark.asyncio +async def test_authenticated_can_read_own_private_transcript(client, monkeypatch): + """Authenticated owner can read their own private transcript.""" + monkeypatch.setattr(settings, "PUBLIC_MODE", False) + + # Create transcript owned by "randomuserid" + t = await transcripts_controller.add( + name="auth-private-read", + source_kind=SourceKind.LIVE, + user_id="randomuserid", + share_mode="private", + ) + + async with authenticated_client_ctx(): + resp = await client.get(f"/transcripts/{t.id}") + assert resp.status_code == 200, resp.text + + +@pytest.mark.asyncio +async def test_authenticated_cannot_read_others_private_transcript(client, monkeypatch): + """Authenticated user cannot read another user's private transcript.""" + monkeypatch.setattr(settings, "PUBLIC_MODE", False) + + # Create transcript owned by someone else + t = await transcripts_controller.add( + name="other-private", + source_kind=SourceKind.LIVE, + user_id="other-owner", + share_mode="private", + ) + + async with authenticated_client_ctx(): + resp = await client.get(f"/transcripts/{t.id}") + assert resp.status_code == 403, resp.text + + +@pytest.mark.asyncio +async def test_authenticated_can_process_own_private_transcript(client, monkeypatch): + """Authenticated owner can trigger processing on their own private transcript.""" + monkeypatch.setattr(settings, "PUBLIC_MODE", False) + + t = await transcripts_controller.add( + name="auth-private-process", + source_kind=SourceKind.LIVE, + user_id="randomuserid", + share_mode="private", + ) + + async with authenticated_client_ctx(): + resp = await client.post(f"/transcripts/{t.id}/process") + # Should pass auth (may fail for other reasons like validation, but not 401/403) + assert resp.status_code not in (401, 403), resp.text + + +@pytest.mark.asyncio +async def test_authenticated_can_upload_to_own_private_transcript(client, monkeypatch): + """Authenticated owner can upload audio to their own private transcript.""" + monkeypatch.setattr(settings, "PUBLIC_MODE", False) + + t = await transcripts_controller.add( + name="auth-private-upload", + source_kind=SourceKind.LIVE, + user_id="randomuserid", + share_mode="private", + ) + + async with authenticated_client_ctx(): + resp = await client.post( + f"/transcripts/{t.id}/record/upload", + params={"chunk_number": 0, "total_chunks": 2}, + files={"chunk": ("test.mp3", b"fake-audio", "audio/mpeg")}, + ) + # Auth passes, chunk accepted (not final chunk so no av validation) + assert resp.status_code == 200, resp.text + + +@pytest.mark.asyncio +async def test_authenticated_can_webrtc_own_private_transcript(client, monkeypatch): + """Authenticated owner can start WebRTC recording on their own private transcript.""" + monkeypatch.setattr(settings, "PUBLIC_MODE", False) + + t = await transcripts_controller.add( + name="auth-private-webrtc", + source_kind=SourceKind.LIVE, + user_id="randomuserid", + share_mode="private", + ) + + async with authenticated_client_ctx(): + resp = await client.post( + f"/transcripts/{t.id}/record/webrtc", + json={"sdp": "v=0\r\n", "type": "offer"}, + ) + # Auth passes (may fail for other reasons like RTC setup, but not 401/403) + assert resp.status_code not in (401, 403), resp.text + + +# ====================================================================== +# Authenticated user vs semi-private data (other user's transcripts) +# Any authenticated user should be able to READ semi-private transcripts +# but NOT write to them (upload, process) since they don't own them +# ====================================================================== + + +@pytest.mark.asyncio +async def test_authenticated_can_read_others_semi_private_transcript( + client, monkeypatch +): + """Any authenticated user can read a semi-private transcript (link sharing).""" + monkeypatch.setattr(settings, "PUBLIC_MODE", False) + + # Create transcript owned by someone else with semi-private share mode + t = await transcripts_controller.add( + name="semi-private-readable", + source_kind=SourceKind.LIVE, + user_id="other-owner", + share_mode="semi-private", + ) + + async with authenticated_client_ctx(): + resp = await client.get(f"/transcripts/{t.id}") + assert resp.status_code == 200, resp.text + + +@pytest.mark.asyncio +async def test_anonymous_cannot_read_semi_private_transcript(client, monkeypatch): + """Anonymous user cannot read a semi-private transcript.""" + monkeypatch.setattr(settings, "PUBLIC_MODE", False) + + t = await transcripts_controller.add( + name="semi-private-blocked", + source_kind=SourceKind.LIVE, + user_id="some-owner", + share_mode="semi-private", + ) + + resp = await client.get(f"/transcripts/{t.id}") + assert resp.status_code == 403, resp.text + + +@pytest.mark.asyncio +async def test_authenticated_can_list_own_transcripts_private_mode(client, monkeypatch): + """Authenticated user can list their own transcripts when PUBLIC_MODE=False.""" + monkeypatch.setattr(settings, "PUBLIC_MODE", False) + + await transcripts_controller.add( + name="my-transcript", + source_kind=SourceKind.LIVE, + user_id="randomuserid", + share_mode="private", + ) + + async with authenticated_client_ctx(): + resp = await client.get("/transcripts") + assert resp.status_code == 200, resp.text + items = resp.json()["items"] + assert len(items) >= 1 + # All returned transcripts should belong to the user or be in shared rooms + for item in items: + assert item["user_id"] == "randomuserid" or item.get("room_id") is not None + + +@pytest.mark.asyncio +async def test_authenticated_cannot_list_others_private_transcripts( + client, monkeypatch +): + """Authenticated user should NOT see another user's private transcripts in the list.""" + monkeypatch.setattr(settings, "PUBLIC_MODE", False) + + await transcripts_controller.add( + name="hidden-from-others", + source_kind=SourceKind.LIVE, + user_id="secret-owner", + share_mode="private", + ) + + async with authenticated_client_ctx(): + resp = await client.get("/transcripts") + assert resp.status_code == 200, resp.text + items = resp.json()["items"] + # Should not contain transcripts owned by "secret-owner" + for item in items: + assert ( + item.get("user_id") != "secret-owner" + ), f"Leaked private transcript: {item['id']}" + + +# ====================================================================== +# Anonymous-created transcripts (user_id=None) +# These transcripts bypass share_mode checks entirely in get_by_id_for_http. +# They should always be accessible to everyone regardless of PUBLIC_MODE +# or share_mode setting, because there is no owner to restrict access. +# ====================================================================== + + +@pytest.mark.asyncio +async def test_anonymous_transcript_accessible_when_public_mode_true( + client, monkeypatch +): + """Anonymous transcript (user_id=None) is accessible even with default private share_mode + when PUBLIC_MODE=True.""" + monkeypatch.setattr(settings, "PUBLIC_MODE", True) + + t = await transcripts_controller.add( + name="anon-transcript-public-mode", + source_kind=SourceKind.LIVE, + user_id=None, + share_mode="private", # share_mode is irrelevant for user_id=None + ) + + resp = await client.get(f"/transcripts/{t.id}") + assert resp.status_code == 200, resp.text + + +@pytest.mark.asyncio +async def test_anonymous_transcript_accessible_when_public_mode_false( + client, monkeypatch +): + """Anonymous transcript (user_id=None) is accessible by authenticated users + even when PUBLIC_MODE=False. The transcript has no owner, so share_mode is bypassed.""" + monkeypatch.setattr(settings, "PUBLIC_MODE", False) + + t = await transcripts_controller.add( + name="anon-transcript-private-mode", + source_kind=SourceKind.LIVE, + user_id=None, + share_mode="private", + ) + + async with authenticated_client_ctx(): + resp = await client.get(f"/transcripts/{t.id}") + assert resp.status_code == 200, resp.text + + +@pytest.mark.asyncio +async def test_anonymous_transcript_accessible_regardless_of_share_mode( + client, monkeypatch +): + """Anonymous transcripts (user_id=None) are accessible regardless of share_mode value. + Tests all three share modes to confirm the user_id=None bypass works consistently.""" + monkeypatch.setattr(settings, "PUBLIC_MODE", True) + + for mode in ("private", "semi-private", "public"): + t = await transcripts_controller.add( + name=f"anon-share-{mode}", + source_kind=SourceKind.LIVE, + user_id=None, + share_mode=mode, + ) + + resp = await client.get(f"/transcripts/{t.id}") + assert resp.status_code == 200, f"Failed for share_mode={mode}: {resp.text}" + + +@pytest.mark.asyncio +async def test_anonymous_transcript_readable_by_different_authenticated_user( + client, monkeypatch +): + """An authenticated user can read anonymous transcripts (user_id=None) even with + private share_mode, because the no-owner bypass applies.""" + monkeypatch.setattr(settings, "PUBLIC_MODE", False) + + t = await transcripts_controller.add( + name="anon-read-by-auth-user", + source_kind=SourceKind.LIVE, + user_id=None, + share_mode="private", + ) + + async with authenticated_client_ctx(): + resp = await client.get(f"/transcripts/{t.id}") + assert resp.status_code == 200, resp.text + assert resp.json()["user_id"] is None + + +@pytest.mark.asyncio +async def test_anonymous_transcript_in_list_when_public_mode(client, monkeypatch): + """Anonymous transcripts appear in the transcript list when PUBLIC_MODE=True.""" + monkeypatch.setattr(settings, "PUBLIC_MODE", True) + + t = await transcripts_controller.add( + name="anon-in-list", + source_kind=SourceKind.LIVE, + user_id=None, + share_mode="private", + ) + + resp = await client.get("/transcripts") + assert resp.status_code == 200, resp.text + ids = [item["id"] for item in resp.json()["items"]] + assert t.id in ids, "Anonymous transcript should appear in the public list" + + +@pytest.mark.asyncio +async def test_anonymous_transcript_audio_accessible(client, monkeypatch, tmpdir): + """Anonymous transcript audio (mp3) is accessible without authentication + because user_id=None bypasses share_mode checks.""" + monkeypatch.setattr(settings, "PUBLIC_MODE", True) + monkeypatch.setattr(settings, "DATA_DIR", Path(tmpdir).as_posix()) + + t = await transcripts_controller.add( + name="anon-audio-access", + source_kind=SourceKind.LIVE, + user_id=None, + 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) + + resp = await client.get(f"/transcripts/{t.id}/audio/mp3") + assert ( + resp.status_code == 200 + ), f"Anonymous transcript audio should be accessible: {resp.text}" + + +@pytest.mark.asyncio +async def test_owned_transcript_not_accessible_by_anon_when_not_public( + client, monkeypatch +): + """Contrast test: owned transcript with private share_mode is NOT accessible + to anonymous users when PUBLIC_MODE=False. This confirms that the user_id=None + bypass only applies to anonymous transcripts, not to all transcripts.""" + monkeypatch.setattr(settings, "PUBLIC_MODE", False) + + t = await transcripts_controller.add( + name="owned-private-contrast", + source_kind=SourceKind.LIVE, + user_id="some-owner", + share_mode="private", + ) + + resp = await client.get(f"/transcripts/{t.id}") + assert ( + resp.status_code == 403 + ), f"Owned private transcript should be denied to anonymous: {resp.text}" + + +@pytest.mark.asyncio +async def test_authenticated_can_start_meeting_recording_private_mode( + client, monkeypatch +): + """Authenticated user can start recording in non-public mode.""" + monkeypatch.setattr(settings, "PUBLIC_MODE", False) + + room = await rooms_controller.add( + name="auth-recording-test", + user_id="randomuserid", + 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="", + ) + + meeting = await meetings_controller.create( + id="meeting-auth-rec", + room_name="auth-recording-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, + ) + + async with authenticated_client_ctx(): + resp = await client.post( + f"/meetings/{meeting.id}/recordings/start", + json={ + "type": "cloud", + "instanceId": "00000000-0000-0000-0000-000000000003", + }, + ) + # Auth passes (may fail for Daily API reasons, but not 401) + assert resp.status_code != 401, resp.text diff --git a/server/tests/test_transcript_formats.py b/server/tests/test_transcript_formats.py index ea44636d..cae9ec74 100644 --- a/server/tests/test_transcript_formats.py +++ b/server/tests/test_transcript_formats.py @@ -340,8 +340,13 @@ async def test_transcript_formats_with_overlapping_speakers_multitrack(): @pytest.mark.asyncio -async def test_api_transcript_format_text(client): +async def test_api_transcript_format_text(monkeypatch, client): """Test GET /transcripts/{id} with transcript_format=text.""" + from reflector.settings import settings + + monkeypatch.setattr( + settings, "PUBLIC_MODE", True + ) # public mode: allow anonymous transcript creation for this test response = await client.post("/transcripts", json={"name": "Test transcript"}) assert response.status_code == 200 tid = response.json()["id"] @@ -390,8 +395,13 @@ async def test_api_transcript_format_text(client): @pytest.mark.asyncio -async def test_api_transcript_format_text_timestamped(client): +async def test_api_transcript_format_text_timestamped(monkeypatch, client): """Test GET /transcripts/{id} with transcript_format=text-timestamped.""" + from reflector.settings import settings + + monkeypatch.setattr( + settings, "PUBLIC_MODE", True + ) # public mode: allow anonymous transcript creation for this test response = await client.post("/transcripts", json={"name": "Test transcript"}) assert response.status_code == 200 tid = response.json()["id"] @@ -441,8 +451,13 @@ async def test_api_transcript_format_text_timestamped(client): @pytest.mark.asyncio -async def test_api_transcript_format_webvtt_named(client): +async def test_api_transcript_format_webvtt_named(monkeypatch, client): """Test GET /transcripts/{id} with transcript_format=webvtt-named.""" + from reflector.settings import settings + + monkeypatch.setattr( + settings, "PUBLIC_MODE", True + ) # public mode: allow anonymous transcript creation for this test response = await client.post("/transcripts", json={"name": "Test transcript"}) assert response.status_code == 200 tid = response.json()["id"] @@ -491,8 +506,13 @@ async def test_api_transcript_format_webvtt_named(client): @pytest.mark.asyncio -async def test_api_transcript_format_json(client): +async def test_api_transcript_format_json(monkeypatch, client): """Test GET /transcripts/{id} with transcript_format=json.""" + from reflector.settings import settings + + monkeypatch.setattr( + settings, "PUBLIC_MODE", True + ) # public mode: allow anonymous transcript creation for this test response = await client.post("/transcripts", json={"name": "Test transcript"}) assert response.status_code == 200 tid = response.json()["id"] @@ -544,8 +564,13 @@ async def test_api_transcript_format_json(client): @pytest.mark.asyncio -async def test_api_transcript_format_default_is_text(client): +async def test_api_transcript_format_default_is_text(monkeypatch, client): """Test GET /transcripts/{id} defaults to text format.""" + from reflector.settings import settings + + monkeypatch.setattr( + settings, "PUBLIC_MODE", True + ) # public mode: allow anonymous transcript creation for this test response = await client.post("/transcripts", json={"name": "Test transcript"}) assert response.status_code == 200 tid = response.json()["id"] @@ -654,12 +679,18 @@ async def test_api_topics_endpoint_multitrack_segmentation(client): @pytest.mark.asyncio -async def test_api_topics_endpoint_non_multitrack_segmentation(client): +async def test_api_topics_endpoint_non_multitrack_segmentation(monkeypatch, client): """Test GET /transcripts/{id}/topics uses default segmentation for non-multitrack. Ensures backward compatibility - transcripts without multitrack recordings should continue using the default speaker-change-based segmentation. """ + from reflector.settings import settings + + monkeypatch.setattr( + settings, "PUBLIC_MODE", True + ) # public mode: allow anonymous transcript creation for this test + from reflector.db.transcripts import ( TranscriptParticipant, TranscriptTopic, diff --git a/server/tests/test_transcripts.py b/server/tests/test_transcripts.py index 6d04c06c..b8cb313c 100644 --- a/server/tests/test_transcripts.py +++ b/server/tests/test_transcripts.py @@ -5,7 +5,12 @@ from reflector.db.transcripts import transcripts_controller @pytest.mark.asyncio -async def test_transcript_create(client): +async def test_transcript_create(monkeypatch, client): + from reflector.settings import settings + + monkeypatch.setattr( + settings, "PUBLIC_MODE", True + ) # public mode: allow anonymous transcript creation for this test response = await client.post("/transcripts", json={"name": "test"}) assert response.status_code == 200 assert response.json()["name"] == "test" @@ -111,8 +116,15 @@ async def test_transcript_get_update_title(authenticated_client, client): @pytest.mark.asyncio -async def test_set_status_emits_status_event_and_updates_transcript(client): +async def test_set_status_emits_status_event_and_updates_transcript( + monkeypatch, client +): """set_status adds a STATUS event and updates the transcript status (broadcast for WebSocket).""" + from reflector.settings import settings + + monkeypatch.setattr( + settings, "PUBLIC_MODE", True + ) # public mode: allow anonymous transcript creation for this test response = await client.post("/transcripts", json={"name": "Status test"}) assert response.status_code == 200 transcript_id = response.json()["id"] diff --git a/server/tests/test_transcripts_audio_download.py b/server/tests/test_transcripts_audio_download.py index b7dcfca9..3990c553 100644 --- a/server/tests/test_transcripts_audio_download.py +++ b/server/tests/test_transcripts_audio_download.py @@ -5,10 +5,13 @@ import pytest @pytest.fixture -async def fake_transcript(tmpdir, client): +async def fake_transcript(tmpdir, client, monkeypatch): from reflector.settings import settings from reflector.views.transcripts import transcripts_controller + monkeypatch.setattr( + settings, "PUBLIC_MODE", True + ) # public mode: allow anonymous transcript creation for this test settings.DATA_DIR = Path(tmpdir) # create a transcript diff --git a/server/tests/test_transcripts_process.py b/server/tests/test_transcripts_process.py index 623015d3..7abea607 100644 --- a/server/tests/test_transcripts_process.py +++ b/server/tests/test_transcripts_process.py @@ -5,6 +5,8 @@ from unittest.mock import AsyncMock, patch import pytest from httpx import ASGITransport, AsyncClient +from reflector.settings import settings + @pytest.fixture async def app_lifespan(): @@ -36,7 +38,11 @@ async def test_transcript_process( dummy_file_diarization, dummy_storage, client, + monkeypatch, ): + # public mode: this test uses an anonymous client; allow anonymous transcript creation + monkeypatch.setattr(settings, "PUBLIC_MODE", True) + # create a transcript response = await client.post("/transcripts", json={"name": "test"}) assert response.status_code == 200 @@ -106,12 +112,17 @@ async def test_transcript_process( @pytest.mark.usefixtures("setup_database") @pytest.mark.asyncio -async def test_whereby_recording_uses_file_pipeline(client): +async def test_whereby_recording_uses_file_pipeline(monkeypatch, client): """Test that Whereby recordings (bucket_name but no track_keys) use file pipeline""" from datetime import datetime, timezone from reflector.db.recordings import Recording, recordings_controller from reflector.db.transcripts import transcripts_controller + from reflector.settings import settings + + monkeypatch.setattr( + settings, "PUBLIC_MODE", True + ) # public mode: allow anonymous transcript creation for this test # Create transcript with Whereby recording (has bucket_name, no track_keys) transcript = await transcripts_controller.add( @@ -157,13 +168,18 @@ async def test_whereby_recording_uses_file_pipeline(client): @pytest.mark.usefixtures("setup_database") @pytest.mark.asyncio -async def test_dailyco_recording_uses_multitrack_pipeline(client): +async def test_dailyco_recording_uses_multitrack_pipeline(monkeypatch, client): """Test that Daily.co recordings (bucket_name + track_keys) use multitrack pipeline""" from datetime import datetime, timezone from reflector.db.recordings import Recording, recordings_controller from reflector.db.rooms import rooms_controller from reflector.db.transcripts import transcripts_controller + from reflector.settings import settings + + monkeypatch.setattr( + settings, "PUBLIC_MODE", True + ) # public mode: allow anonymous transcript creation for this test room = await rooms_controller.add( name="test-room", @@ -235,13 +251,18 @@ async def test_dailyco_recording_uses_multitrack_pipeline(client): @pytest.mark.usefixtures("setup_database") @pytest.mark.asyncio -async def test_reprocess_error_transcript_passes_force(client): +async def test_reprocess_error_transcript_passes_force(monkeypatch, client): """When transcript status is 'error', reprocess passes force=True to start fresh workflow.""" from datetime import datetime, timezone from reflector.db.recordings import Recording, recordings_controller from reflector.db.rooms import rooms_controller from reflector.db.transcripts import transcripts_controller + from reflector.settings import settings + + monkeypatch.setattr( + settings, "PUBLIC_MODE", True + ) # public mode: allow anonymous transcript creation for this test room = await rooms_controller.add( name="test-room", diff --git a/server/tests/test_transcripts_rtc_ws.py b/server/tests/test_transcripts_rtc_ws.py index 8c015791..e966eb02 100644 --- a/server/tests/test_transcripts_rtc_ws.py +++ b/server/tests/test_transcripts_rtc_ws.py @@ -133,10 +133,17 @@ async def test_transcript_rtc_and_websocket( fake_mp3_upload, appserver, client, + monkeypatch, ): # goal: start the server, exchange RTC, receive websocket events # because of that, we need to start the server in a thread # to be able to connect with aiortc + from reflector.settings import settings + + monkeypatch.setattr( + settings, "PUBLIC_MODE", True + ) # public mode: allow anonymous transcript creation for this test + server, host, port = appserver # create a transcript @@ -298,11 +305,18 @@ async def test_transcript_rtc_and_websocket_and_fr( fake_mp3_upload, appserver, client, + monkeypatch, ): # goal: start the server, exchange RTC, receive websocket events # because of that, we need to start the server in a thread # to be able to connect with aiortc # with target french language + from reflector.settings import settings + + monkeypatch.setattr( + settings, "PUBLIC_MODE", True + ) # public mode: allow anonymous transcript creation for this test + server, host, port = appserver # create a transcript diff --git a/server/tests/test_transcripts_translation.py b/server/tests/test_transcripts_translation.py index 6819c653..69ad03cb 100644 --- a/server/tests/test_transcripts_translation.py +++ b/server/tests/test_transcripts_translation.py @@ -1,8 +1,13 @@ import pytest +from reflector.settings import settings + @pytest.mark.asyncio -async def test_transcript_create_default_translation(client): +async def test_transcript_create_default_translation(monkeypatch, client): + monkeypatch.setattr( + settings, "PUBLIC_MODE", True + ) # public mode: allow anonymous transcript creation for this test response = await client.post("/transcripts", json={"name": "test en"}) assert response.status_code == 200 assert response.json()["name"] == "test en" @@ -18,7 +23,10 @@ async def test_transcript_create_default_translation(client): @pytest.mark.asyncio -async def test_transcript_create_en_fr_translation(client): +async def test_transcript_create_en_fr_translation(monkeypatch, client): + monkeypatch.setattr( + settings, "PUBLIC_MODE", True + ) # public mode: allow anonymous transcript creation for this test response = await client.post( "/transcripts", json={"name": "test en/fr", "target_language": "fr"} ) @@ -36,7 +44,10 @@ async def test_transcript_create_en_fr_translation(client): @pytest.mark.asyncio -async def test_transcript_create_fr_en_translation(client): +async def test_transcript_create_fr_en_translation(monkeypatch, client): + monkeypatch.setattr( + settings, "PUBLIC_MODE", True + ) # public mode: allow anonymous transcript creation for this test response = await client.post( "/transcripts", json={"name": "test fr/en", "source_language": "fr"} ) diff --git a/server/tests/test_transcripts_upload.py b/server/tests/test_transcripts_upload.py index e9a90c7a..bedc7206 100644 --- a/server/tests/test_transcripts_upload.py +++ b/server/tests/test_transcripts_upload.py @@ -16,7 +16,13 @@ async def test_transcript_upload_file( dummy_file_diarization, dummy_storage, client, + monkeypatch, ): + from reflector.settings import settings + + monkeypatch.setattr( + settings, "PUBLIC_MODE", True + ) # public mode: allow anonymous transcript creation for this test # create a transcript response = await client.post("/transcripts", json={"name": "test"}) assert response.status_code == 200