mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2026-03-21 22:56:47 +00:00
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
This commit is contained in:
committed by
GitHub
parent
183601a121
commit
cf6e867cf1
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user