Compare commits

...

5 Commits

Author SHA1 Message Date
Juan Diego García
72dca7cacc chore(main): release 0.38.2 (#906) 2026-03-12 16:51:53 -05:00
Juan Diego García
4ae56b730a refactor(auth): consolidate PUBLIC_MODE and mutation guards into reusable helpers (#909)
* refactor(auth): consolidate PUBLIC_MODE and mutation guards into reusable helpers

* fix: fix websocket test override
2026-03-12 10:51:26 -05:00
Juan Diego García
cf6e867cf1 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
2026-03-11 10:48:49 -05:00
dependabot[bot]
183601a121 build(deps): bump pypdf in /server in the uv group across 1 directory (#908)
Bumps the uv group with 1 update in the /server directory: [pypdf](https://github.com/py-pdf/pypdf).


Updates `pypdf` from 6.7.5 to 6.8.0
- [Release notes](https://github.com/py-pdf/pypdf/releases)
- [Changelog](https://github.com/py-pdf/pypdf/blob/main/CHANGELOG.md)
- [Commits](https://github.com/py-pdf/pypdf/compare/6.7.5...6.8.0)

---
updated-dependencies:
- dependency-name: pypdf
  dependency-version: 6.8.0
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-11 10:29:43 -05:00
Sergey Mankovsky
b53c8da398 fix: add tests that check some of the issues are already fixed (#905)
* Add tests that check some of the issues are already fixed

* Fix test formatting
2026-03-10 11:58:53 -05:00
32 changed files with 990 additions and 93 deletions

View File

@@ -1,5 +1,13 @@
# Changelog
## [0.38.2](https://github.com/GreyhavenHQ/reflector/compare/v0.38.1...v0.38.2) (2026-03-12)
### Bug Fixes
* add auth guards to prevent anonymous access to write endpoints in non-public mode ([#907](https://github.com/GreyhavenHQ/reflector/issues/907)) ([cf6e867](https://github.com/GreyhavenHQ/reflector/commit/cf6e867cf12c42411e5a7412f6ec44eee8351665))
* add tests that check some of the issues are already fixed ([#905](https://github.com/GreyhavenHQ/reflector/issues/905)) ([b53c8da](https://github.com/GreyhavenHQ/reflector/commit/b53c8da3981c394bdab08504b45d25f62c35495a))
## [0.38.1](https://github.com/GreyhavenHQ/reflector/compare/v0.38.0...v0.38.1) (2026-03-06)

View File

@@ -12,6 +12,7 @@ AccessTokenInfo = auth_module.AccessTokenInfo
authenticated = auth_module.authenticated
current_user = auth_module.current_user
current_user_optional = auth_module.current_user_optional
current_user_optional_if_public_mode = auth_module.current_user_optional_if_public_mode
parse_ws_bearer_token = auth_module.parse_ws_bearer_token
current_user_ws_optional = auth_module.current_user_ws_optional
verify_raw_token = auth_module.verify_raw_token

View File

@@ -129,6 +129,17 @@ async def current_user_optional(
return await _authenticate_user(jwt_token, api_key, jwtauth)
async def current_user_optional_if_public_mode(
jwt_token: Annotated[Optional[str], Depends(oauth2_scheme)],
api_key: Annotated[Optional[str], Depends(api_key_header)],
jwtauth: JWTAuth = Depends(),
) -> Optional[UserInfo]:
user = await _authenticate_user(jwt_token, api_key, jwtauth)
if user is None and not settings.PUBLIC_MODE:
raise HTTPException(status_code=401, detail="Not authenticated")
return user
def parse_ws_bearer_token(
websocket: "WebSocket",
) -> tuple[Optional[str], Optional[str]]:

View File

@@ -21,6 +21,11 @@ def current_user_optional():
return None
def current_user_optional_if_public_mode():
# auth_none means no authentication at all — always public
return None
def parse_ws_bearer_token(websocket):
return None, None

View File

@@ -150,6 +150,16 @@ async def current_user_optional(
return await _authenticate_user(jwt_token, api_key)
async def current_user_optional_if_public_mode(
jwt_token: Annotated[Optional[str], Depends(oauth2_scheme)],
api_key: Annotated[Optional[str], Depends(api_key_header)],
) -> Optional[UserInfo]:
user = await _authenticate_user(jwt_token, api_key)
if user is None and not settings.PUBLIC_MODE:
raise HTTPException(status_code=401, detail="Not authenticated")
return user
# --- WebSocket auth (same pattern as auth_jwt.py) ---
def parse_ws_bearer_token(
websocket: "WebSocket",

View File

@@ -697,6 +697,18 @@ class TranscriptController:
return False
return user_id and transcript.user_id == user_id
@staticmethod
def check_can_mutate(transcript: Transcript, user_id: str | None) -> None:
"""
Raises HTTP 403 if the user cannot mutate the transcript.
Policy:
- Anonymous transcripts (user_id is None) are editable by anyone
- Owned transcripts can only be mutated by their owner
"""
if transcript.user_id is not None and transcript.user_id != user_id:
raise HTTPException(status_code=403, detail="Not authorized")
@asynccontextmanager
async def transaction(self):
"""

View File

@@ -89,14 +89,16 @@ 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_if_public_mode)
],
) -> 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
"""
meeting = await meetings_controller.get_by_id(meeting_id)
if not meeting:

View File

@@ -17,7 +17,6 @@ from reflector.db.rooms import rooms_controller
from reflector.redis_cache import RedisAsyncLock
from reflector.schemas.platform import Platform
from reflector.services.ics_sync import ics_sync_service
from reflector.settings import settings
from reflector.utils.url import add_query_param
from reflector.video_platforms.factory import create_platform_client
from reflector.worker.webhook import test_webhook
@@ -178,11 +177,10 @@ router = APIRouter()
@router.get("/rooms", response_model=Page[RoomDetails])
async def rooms_list(
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
],
) -> list[RoomDetails]:
if not user and not settings.PUBLIC_MODE:
raise HTTPException(status_code=401, detail="Not authenticated")
user_id = user["sub"] if user else None
paginated = await apaginate(

View File

@@ -263,16 +263,15 @@ class SearchResponse(BaseModel):
@router.get("/transcripts", response_model=Page[GetTranscriptMinimal])
async def transcripts_list(
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
],
source_kind: SourceKind | None = None,
room_id: str | None = None,
search_term: str | None = None,
change_seq_from: int | None = None,
sort_by: Literal["created_at", "change_seq"] | None = None,
):
if not user and not settings.PUBLIC_MODE:
raise HTTPException(status_code=401, detail="Not authenticated")
user_id = user["sub"] if user else None
# Default behavior preserved: sort_by=None → "-created_at"
@@ -307,13 +306,10 @@ async def transcripts_search(
from_datetime: SearchFromDatetimeParam = None,
to_datetime: SearchToDatetimeParam = None,
user: Annotated[
Optional[auth.UserInfo], Depends(auth.current_user_optional)
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
] = None,
):
"""Full-text search across transcript titles and content."""
if not user and not settings.PUBLIC_MODE:
raise HTTPException(status_code=401, detail="Not authenticated")
user_id = user["sub"] if user else None
if from_datetime and to_datetime and from_datetime > to_datetime:
@@ -346,7 +342,9 @@ async def transcripts_search(
@router.post("/transcripts", response_model=GetTranscriptWithParticipants)
async def transcripts_create(
info: CreateTranscript,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
],
):
user_id = user["sub"] if user else None
transcript = await transcripts_controller.add(

View File

@@ -62,8 +62,7 @@ async def transcript_add_participant(
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")
transcripts_controller.check_can_mutate(transcript, user_id)
# ensure the speaker is unique
if participant.speaker is not None and transcript.participants is not None:
@@ -109,8 +108,7 @@ async def transcript_update_participant(
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")
transcripts_controller.check_can_mutate(transcript, user_id)
# ensure the speaker is unique
for p in transcript.participants:
@@ -148,7 +146,6 @@ async def transcript_delete_participant(
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")
transcripts_controller.check_can_mutate(transcript, user_id)
await transcripts_controller.delete_participant(transcript, participant_id)
return DeletionStatus(status="ok")

View File

@@ -26,7 +26,9 @@ class ProcessStatus(BaseModel):
@router.post("/transcripts/{transcript_id}/process")
async def transcript_process(
transcript_id: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
],
) -> ProcessStatus:
user_id = user["sub"] if user else None
transcript = await transcripts_controller.get_by_id_for_http(

View File

@@ -41,8 +41,7 @@ async def transcript_assign_speaker(
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")
transcripts_controller.check_can_mutate(transcript, user_id)
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")
@@ -121,8 +120,7 @@ async def transcript_merge_speaker(
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")
transcripts_controller.check_can_mutate(transcript, user_id)
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")

View File

@@ -21,7 +21,9 @@ async def transcript_record_upload(
chunk_number: int,
total_chunks: int,
chunk: UploadFile,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
],
):
user_id = user["sub"] if user else None
transcript = await transcripts_controller.get_by_id_for_http(

View File

@@ -15,7 +15,9 @@ async def transcript_record_webrtc(
transcript_id: str,
params: RtcOffer,
request: Request,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
],
):
user_id = user["sub"] if user else None
transcript = await transcripts_controller.get_by_id_for_http(

View File

@@ -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,
)

View File

@@ -437,6 +437,8 @@ async def ws_manager_in_memory(monkeypatch):
try:
fastapi_app.dependency_overrides[auth.current_user_optional] = lambda: None
# current_user_optional_if_public_mode is NOT overridden here so the real
# implementation runs and enforces the PUBLIC_MODE check during tests.
except Exception:
pass
@@ -491,37 +493,39 @@ async def authenticated_client2():
@asynccontextmanager
async def authenticated_client_ctx():
from reflector.app import app
from reflector.auth import current_user, current_user_optional
from reflector.auth import (
current_user,
current_user_optional,
current_user_optional_if_public_mode,
)
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",
}
_user = lambda: {"sub": "randomuserid", "email": "test@mail.com"}
app.dependency_overrides[current_user] = _user
app.dependency_overrides[current_user_optional] = _user
app.dependency_overrides[current_user_optional_if_public_mode] = _user
yield
del app.dependency_overrides[current_user]
del app.dependency_overrides[current_user_optional]
del app.dependency_overrides[current_user_optional_if_public_mode]
@asynccontextmanager
async def authenticated_client2_ctx():
from reflector.app import app
from reflector.auth import current_user, current_user_optional
from reflector.auth import (
current_user,
current_user_optional,
current_user_optional_if_public_mode,
)
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",
}
_user = lambda: {"sub": "randomuserid2", "email": "test@mail.com"}
app.dependency_overrides[current_user] = _user
app.dependency_overrides[current_user_optional] = _user
app.dependency_overrides[current_user_optional_if_public_mode] = _user
yield
del app.dependency_overrides[current_user]
del app.dependency_overrides[current_user_optional]
del app.dependency_overrides[current_user_optional_if_public_mode]
@pytest.fixture(scope="session")
@@ -550,7 +554,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 +563,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

17
server/tests/test_app.py Normal file
View File

@@ -0,0 +1,17 @@
"""Tests for app-level endpoints (root, not under /v1)."""
import pytest
@pytest.mark.asyncio
async def test_health_endpoint_returns_healthy():
"""GET /health returns 200 and {"status": "healthy"} for probes and CI."""
from httpx import AsyncClient
from reflector.app import app
# Health is at app root, not under /v1
async with AsyncClient(app=app, base_url="http://test") as root_client:
response = await root_client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "healthy"}

View File

@@ -5,6 +5,7 @@ This test verifies the complete file processing pipeline without mocking much,
ensuring all processors are correctly invoked and the happy path works correctly.
"""
from contextlib import asynccontextmanager
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
@@ -651,3 +652,43 @@ async def test_pipeline_file_process_no_audio_file(
# This should fail when trying to open the file with av
with pytest.raises(Exception):
await pipeline.process(non_existent_path)
@pytest.mark.asyncio
async def test_on_title_does_not_overwrite_user_set_title():
"""When transcript already has a title, on_title does not call update."""
from reflector.db.transcripts import Transcript, TranscriptFinalTitle
from reflector.pipelines.main_file_pipeline import PipelineMainFile
transcript_id = str(uuid4())
transcript_with_title = Transcript(
id=transcript_id,
name="test",
source_kind="file",
title="User set title",
)
controller = "reflector.pipelines.main_live_pipeline.transcripts_controller"
with patch(f"{controller}.get_by_id", new_callable=AsyncMock) as mock_get:
with patch(f"{controller}.update", new_callable=AsyncMock) as mock_update:
with patch(
f"{controller}.append_event", new_callable=AsyncMock
) as mock_append:
with patch(f"{controller}.transaction") as mock_txn:
mock_get.return_value = transcript_with_title
mock_append.return_value = None
@asynccontextmanager
async def noop_txn():
yield
mock_txn.return_value = noop_txn()
pipeline = PipelineMainFile(transcript_id=transcript_id)
await pipeline.on_title(
TranscriptFinalTitle(title="Generated title")
)
mock_get.assert_called_once()
mock_update.assert_not_called()
mock_append.assert_called_once()

View File

@@ -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

View File

@@ -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,

View File

@@ -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"
@@ -110,6 +115,33 @@ async def test_transcript_get_update_title(authenticated_client, client):
assert response.json()["title"] == "test_title"
@pytest.mark.asyncio
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"]
transcript = await transcripts_controller.get_by_id(transcript_id)
assert transcript is not None
assert transcript.status == "idle"
event = await transcripts_controller.set_status(transcript_id, "processing")
assert event is not None
assert event.event == "STATUS"
assert event.data.get("value") == "processing"
updated = await transcripts_controller.get_by_id(transcript_id)
assert updated.status == "processing"
@pytest.mark.asyncio
async def test_transcripts_list_anonymous(client):
# XXX this test is a bit fragile, as it depends on the storage which
@@ -233,3 +265,43 @@ async def test_transcript_get_returns_null_room_name_when_no_room(
assert response.status_code == 200
assert response.json()["room_id"] is None
assert response.json()["room_name"] is None
@pytest.mark.asyncio
async def test_transcripts_list_filtered_by_room_id(authenticated_client, client):
"""GET /transcripts?room_id=X returns only transcripts for that room."""
# Use same user as authenticated_client (conftest uses "randomuserid")
user_id = "randomuserid"
room = await rooms_controller.add(
name="room-for-list-filter",
user_id=user_id,
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="",
)
in_room = await transcripts_controller.add(
name="in-room",
source_kind="file",
room_id=room.id,
user_id=user_id,
)
other = await transcripts_controller.add(
name="no-room",
source_kind="file",
room_id=None,
user_id=user_id,
)
response = await client.get("/transcripts", params={"room_id": room.id})
assert response.status_code == 200
items = response.json()["items"]
ids = [t["id"] for t in items]
assert in_room.id in ids
assert other.id not in ids

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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"}
)

View File

@@ -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

View File

@@ -141,33 +141,19 @@ async def test_user_ws_accepts_valid_token_and_receives_events(appserver_ws_user
await asyncio.sleep(0.2)
# Emit an event to the user's room via a standard HTTP action
# Use a real HTTP request to the server with the JWT token so that
# current_user_optional_if_public_mode is exercised without dependency overrides
from httpx import AsyncClient
from reflector.app import app
from reflector.auth import current_user, current_user_optional
# Override auth dependencies so HTTP request is performed as the same user
# Use the internal user.id (not the Authentik UID)
app.dependency_overrides[current_user] = lambda: {
"sub": user.id,
"email": "user-abc@example.com",
}
app.dependency_overrides[current_user_optional] = lambda: {
"sub": user.id,
"email": "user-abc@example.com",
}
# Use in-memory client (global singleton makes it share ws_manager)
async with AsyncClient(app=app, base_url=f"http://{host}:{port}/v1") as ac:
# Create a transcript as this user so that the server publishes TRANSCRIPT_CREATED to user room
resp = await ac.post("/transcripts", json={"name": "WS Test"})
async with AsyncClient(base_url=f"http://{host}:{port}/v1") as ac:
resp = await ac.post(
"/transcripts",
json={"name": "WS Test"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
# Receive the published event
msg = await ws.receive_json()
assert msg["event"] == "TRANSCRIPT_CREATED"
assert "id" in msg["data"]
# Clean overrides
del app.dependency_overrides[current_user]
del app.dependency_overrides[current_user_optional]

6
server/uv.lock generated
View File

@@ -2973,11 +2973,11 @@ wheels = [
[[package]]
name = "pypdf"
version = "6.7.5"
version = "6.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/52/37cc0aa9e9d1bf7729a737a0d83f8b3f851c8eb137373d9f71eafb0a3405/pypdf-6.7.5.tar.gz", hash = "sha256:40bb2e2e872078655f12b9b89e2f900888bb505e88a82150b64f9f34fa25651d", size = 5304278, upload-time = "2026-03-02T09:05:21.464Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b4/a3/e705b0805212b663a4c27b861c8a603dba0f8b4bb281f96f8e746576a50d/pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b", size = 5307831, upload-time = "2026-03-09T13:37:40.591Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/89/336673efd0a88956562658aba4f0bbef7cb92a6fbcbcaf94926dbc82b408/pypdf-6.7.5-py3-none-any.whl", hash = "sha256:07ba7f1d6e6d9aa2a17f5452e320a84718d4ce863367f7ede2fd72280349ab13", size = 331421, upload-time = "2026-03-02T09:05:19.722Z" },
{ url = "https://files.pythonhosted.org/packages/8c/ec/4ccf3bb86b1afe5d7176e1c8abcdbf22b53dd682ec2eda50e1caadcf6846/pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7", size = 332177, upload-time = "2026-03-09T13:37:38.774Z" },
]
[[package]]

View File

@@ -0,0 +1,26 @@
/**
* Reconnection policy for WebSocket.
* Ensures exponential backoff is applied and capped at 30s.
*/
import { getReconnectDelayMs, MAX_RETRIES } from "../webSocketReconnect";
describe("webSocketReconnect", () => {
describe("getReconnectDelayMs", () => {
it("returns exponential backoff: 1s, 2s, 4s, 8s, 16s, then cap 30s", () => {
expect(getReconnectDelayMs(0)).toBe(1000);
expect(getReconnectDelayMs(1)).toBe(2000);
expect(getReconnectDelayMs(2)).toBe(4000);
expect(getReconnectDelayMs(3)).toBe(8000);
expect(getReconnectDelayMs(4)).toBe(16000);
expect(getReconnectDelayMs(5)).toBe(30000); // 32s capped to 30s
expect(getReconnectDelayMs(6)).toBe(30000);
expect(getReconnectDelayMs(9)).toBe(30000);
});
it("never exceeds 30s for any retry index", () => {
for (let i = 0; i <= MAX_RETRIES; i++) {
expect(getReconnectDelayMs(i)).toBeLessThanOrEqual(30000);
}
});
});
});

View File

@@ -14,6 +14,7 @@ import {
} from "../../lib/apiHooks";
import { useAuth } from "../../lib/AuthProvider";
import { parseNonEmptyString } from "../../lib/utils";
import { getReconnectDelayMs, MAX_RETRIES } from "./webSocketReconnect";
type TranscriptWsEvent =
operations["v1_transcript_get_websocket_events"]["responses"][200]["content"]["application/json"];
@@ -338,7 +339,6 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
if (!transcriptId) return;
const tsId = parseNonEmptyString(transcriptId);
const MAX_RETRIES = 10;
const url = `${WEBSOCKET_URL}/v1/transcripts/${transcriptId}/events`;
let ws: WebSocket | null = null;
let retryCount = 0;
@@ -472,7 +472,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
if (normalCodes.includes(event.code)) return;
if (retryCount < MAX_RETRIES) {
const delay = Math.min(1000 * Math.pow(2, retryCount), 30000);
const delay = getReconnectDelayMs(retryCount);
console.log(
`WebSocket reconnecting in ${delay}ms (attempt ${retryCount + 1}/${MAX_RETRIES})`,
);

View File

@@ -0,0 +1,10 @@
/** Reconnection policy for WebSocket: exponential backoff, capped at 30s. */
export const MAX_RETRIES = 10;
/**
* Delay in ms before reconnecting. retryIndex is 0-based (0 = first retry).
* Returns 1000, 2000, 4000, ... up to 30000 max.
*/
export function getReconnectDelayMs(retryIndex: number): number {
return Math.min(1000 * Math.pow(2, retryIndex), 30000);
}

View File

@@ -10,7 +10,8 @@
"lint": "next lint",
"format": "prettier --write .",
"openapi": "openapi-typescript http://127.0.0.1:1250/openapi.json -o ./app/reflector-api.d.ts",
"test": "jest"
"test": "jest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@chakra-ui/react": "^3.33.0",