fix: security review (#656)

* Add security review doc

* Add tests to reproduce security issues

* Fix security issues

* Fix tests

* Set auth auth backend for tests

* Fix ics api tests

* Fix transcript mutate check

* Update frontent env var names

* Remove permissions doc
This commit is contained in:
2025-09-29 23:07:49 +02:00
committed by GitHub
parent 969bd84fcc
commit 5d98754305
15 changed files with 647 additions and 111 deletions

View File

@@ -112,6 +112,7 @@ source = ["reflector"]
[tool.pytest_env] [tool.pytest_env]
ENVIRONMENT = "pytest" ENVIRONMENT = "pytest"
DATABASE_URL = "postgresql://test_user:test_password@localhost:15432/reflector_test" DATABASE_URL = "postgresql://test_user:test_password@localhost:15432/reflector_test"
AUTH_BACKEND = "jwt"
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "-ra -q --disable-pytest-warnings --cov --cov-report html -v" addopts = "-ra -q --disable-pytest-warnings --cov --cov-report html -v"

View File

@@ -647,6 +647,19 @@ class TranscriptController:
query = transcripts.delete().where(transcripts.c.recording_id == recording_id) query = transcripts.delete().where(transcripts.c.recording_id == recording_id)
await get_database().execute(query) await get_database().execute(query)
@staticmethod
def user_can_mutate(transcript: Transcript, user_id: str | None) -> bool:
"""
Returns True if the given user is allowed to modify the transcript.
Policy:
- Anonymous transcripts (user_id is None) cannot be modified via API
- Only the owner (matching user_id) can modify their transcript
"""
if transcript.user_id is None:
return False
return user_id and transcript.user_id == user_id
@asynccontextmanager @asynccontextmanager
async def transaction(self): async def transaction(self):
""" """

View File

@@ -199,6 +199,8 @@ async def rooms_get(
room = await rooms_controller.get_by_id_for_http(room_id, user_id=user_id) room = await rooms_controller.get_by_id_for_http(room_id, user_id=user_id)
if not room: if not room:
raise HTTPException(status_code=404, detail="Room not found") raise HTTPException(status_code=404, detail="Room not found")
if not room.is_shared and (user_id is None or room.user_id != user_id):
raise HTTPException(status_code=403, detail="Room access denied")
return room return room
@@ -229,9 +231,9 @@ async def rooms_get_by_name(
@router.post("/rooms", response_model=Room) @router.post("/rooms", response_model=Room)
async def rooms_create( async def rooms_create(
room: CreateRoom, room: CreateRoom,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[auth.UserInfo, Depends(auth.current_user)],
): ):
user_id = user["sub"] if user else None user_id = user["sub"]
return await rooms_controller.add( return await rooms_controller.add(
name=room.name, name=room.name,
@@ -256,12 +258,14 @@ async def rooms_create(
async def rooms_update( async def rooms_update(
room_id: str, room_id: str,
info: UpdateRoom, info: UpdateRoom,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[auth.UserInfo, Depends(auth.current_user)],
): ):
user_id = user["sub"] if user else None user_id = user["sub"]
room = await rooms_controller.get_by_id_for_http(room_id, user_id=user_id) room = await rooms_controller.get_by_id_for_http(room_id, user_id=user_id)
if not room: if not room:
raise HTTPException(status_code=404, detail="Room not found") raise HTTPException(status_code=404, detail="Room not found")
if room.user_id != user_id:
raise HTTPException(status_code=403, detail="Not authorized")
values = info.dict(exclude_unset=True) values = info.dict(exclude_unset=True)
await rooms_controller.update(room, values) await rooms_controller.update(room, values)
return room return room
@@ -270,12 +274,14 @@ async def rooms_update(
@router.delete("/rooms/{room_id}", response_model=DeletionStatus) @router.delete("/rooms/{room_id}", response_model=DeletionStatus)
async def rooms_delete( async def rooms_delete(
room_id: str, room_id: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[auth.UserInfo, Depends(auth.current_user)],
): ):
user_id = user["sub"] if user else None user_id = user["sub"]
room = await rooms_controller.get_by_id(room_id, user_id=user_id) room = await rooms_controller.get_by_id(room_id)
if not room: if not room:
raise HTTPException(status_code=404, detail="Room not found") raise HTTPException(status_code=404, detail="Room not found")
if room.user_id != user_id:
raise HTTPException(status_code=403, detail="Not authorized")
await rooms_controller.remove_by_id(room.id, user_id=user_id) await rooms_controller.remove_by_id(room.id, user_id=user_id)
return DeletionStatus(status="ok") return DeletionStatus(status="ok")
@@ -339,16 +345,16 @@ async def rooms_create_meeting(
@router.post("/rooms/{room_id}/webhook/test", response_model=WebhookTestResult) @router.post("/rooms/{room_id}/webhook/test", response_model=WebhookTestResult)
async def rooms_test_webhook( async def rooms_test_webhook(
room_id: str, room_id: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[auth.UserInfo, Depends(auth.current_user)],
): ):
"""Test webhook configuration by sending a sample payload.""" """Test webhook configuration by sending a sample payload."""
user_id = user["sub"] if user else None user_id = user["sub"]
room = await rooms_controller.get_by_id(room_id) room = await rooms_controller.get_by_id(room_id)
if not room: if not room:
raise HTTPException(status_code=404, detail="Room not found") raise HTTPException(status_code=404, detail="Room not found")
if user_id and room.user_id != user_id: if room.user_id != user_id:
raise HTTPException( raise HTTPException(
status_code=403, detail="Not authorized to test this room's webhook" status_code=403, detail="Not authorized to test this room's webhook"
) )

View File

@@ -9,8 +9,6 @@ from pydantic import BaseModel, Field, constr, field_serializer
import reflector.auth as auth import reflector.auth as auth
from reflector.db import get_database from reflector.db import get_database
from reflector.db.meetings import meetings_controller
from reflector.db.rooms import rooms_controller
from reflector.db.search import ( from reflector.db.search import (
DEFAULT_SEARCH_LIMIT, DEFAULT_SEARCH_LIMIT,
SearchLimit, SearchLimit,
@@ -344,12 +342,14 @@ async def transcript_get(
async def transcript_update( async def transcript_update(
transcript_id: str, transcript_id: str,
info: UpdateTranscript, info: UpdateTranscript,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[auth.UserInfo, Depends(auth.current_user)],
): ):
user_id = user["sub"] if user else None user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http( transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id transcript_id, user_id=user_id
) )
if not transcripts_controller.user_can_mutate(transcript, user_id):
raise HTTPException(status_code=403, detail="Not authorized")
values = info.dict(exclude_unset=True) values = info.dict(exclude_unset=True)
updated_transcript = await transcripts_controller.update(transcript, values) updated_transcript = await transcripts_controller.update(transcript, values)
return updated_transcript return updated_transcript
@@ -358,18 +358,14 @@ async def transcript_update(
@router.delete("/transcripts/{transcript_id}", response_model=DeletionStatus) @router.delete("/transcripts/{transcript_id}", response_model=DeletionStatus)
async def transcript_delete( async def transcript_delete(
transcript_id: str, transcript_id: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[auth.UserInfo, Depends(auth.current_user)],
): ):
user_id = user["sub"] if user else None user_id = user["sub"]
transcript = await transcripts_controller.get_by_id(transcript_id) transcript = await transcripts_controller.get_by_id(transcript_id)
if not transcript: if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found") raise HTTPException(status_code=404, detail="Transcript not found")
if not transcripts_controller.user_can_mutate(transcript, user_id):
if transcript.meeting_id: raise HTTPException(status_code=403, detail="Not authorized")
meeting = await meetings_controller.get_by_id(transcript.meeting_id)
room = await rooms_controller.get_by_id(meeting.room_id)
if room.is_shared:
user_id = None
await transcripts_controller.remove_by_id(transcript.id, user_id=user_id) await transcripts_controller.remove_by_id(transcript.id, user_id=user_id)
return DeletionStatus(status="ok") return DeletionStatus(status="ok")
@@ -443,15 +439,16 @@ async def transcript_post_to_zulip(
stream: str, stream: str,
topic: str, topic: str,
include_topics: bool, include_topics: bool,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[auth.UserInfo, Depends(auth.current_user)],
): ):
user_id = user["sub"] if user else None user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http( transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id transcript_id, user_id=user_id
) )
if not transcript: if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found") raise HTTPException(status_code=404, detail="Transcript not found")
if not transcripts_controller.user_can_mutate(transcript, user_id):
raise HTTPException(status_code=403, detail="Not authorized")
content = get_zulip_message(transcript, include_topics) content = get_zulip_message(transcript, include_topics)
message_updated = False message_updated = False

View File

@@ -56,12 +56,14 @@ async def transcript_get_participants(
async def transcript_add_participant( async def transcript_add_participant(
transcript_id: str, transcript_id: str,
participant: CreateParticipant, participant: CreateParticipant,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[auth.UserInfo, Depends(auth.current_user)],
) -> Participant: ) -> Participant:
user_id = user["sub"] if user else None user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http( transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id transcript_id, user_id=user_id
) )
if transcript.user_id is not None and transcript.user_id != user_id:
raise HTTPException(status_code=403, detail="Not authorized")
# ensure the speaker is unique # ensure the speaker is unique
if participant.speaker is not None and transcript.participants is not None: if participant.speaker is not None and transcript.participants is not None:
@@ -101,12 +103,14 @@ async def transcript_update_participant(
transcript_id: str, transcript_id: str,
participant_id: str, participant_id: str,
participant: UpdateParticipant, participant: UpdateParticipant,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[auth.UserInfo, Depends(auth.current_user)],
) -> Participant: ) -> Participant:
user_id = user["sub"] if user else None user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http( transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id transcript_id, user_id=user_id
) )
if transcript.user_id is not None and transcript.user_id != user_id:
raise HTTPException(status_code=403, detail="Not authorized")
# ensure the speaker is unique # ensure the speaker is unique
for p in transcript.participants: for p in transcript.participants:
@@ -138,11 +142,13 @@ async def transcript_update_participant(
async def transcript_delete_participant( async def transcript_delete_participant(
transcript_id: str, transcript_id: str,
participant_id: str, participant_id: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[auth.UserInfo, Depends(auth.current_user)],
) -> DeletionStatus: ) -> DeletionStatus:
user_id = user["sub"] if user else None user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http( transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id transcript_id, user_id=user_id
) )
if transcript.user_id is not None and transcript.user_id != user_id:
raise HTTPException(status_code=403, detail="Not authorized")
await transcripts_controller.delete_participant(transcript, participant_id) await transcripts_controller.delete_participant(transcript, participant_id)
return DeletionStatus(status="ok") return DeletionStatus(status="ok")

View File

@@ -35,12 +35,14 @@ class SpeakerMerge(BaseModel):
async def transcript_assign_speaker( async def transcript_assign_speaker(
transcript_id: str, transcript_id: str,
assignment: SpeakerAssignment, assignment: SpeakerAssignment,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[auth.UserInfo, Depends(auth.current_user)],
) -> SpeakerAssignmentStatus: ) -> SpeakerAssignmentStatus:
user_id = user["sub"] if user else None user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http( transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id transcript_id, user_id=user_id
) )
if transcript.user_id is not None and transcript.user_id != user_id:
raise HTTPException(status_code=403, detail="Not authorized")
if not transcript: if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found") raise HTTPException(status_code=404, detail="Transcript not found")
@@ -113,12 +115,14 @@ async def transcript_assign_speaker(
async def transcript_merge_speaker( async def transcript_merge_speaker(
transcript_id: str, transcript_id: str,
merge: SpeakerMerge, merge: SpeakerMerge,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[auth.UserInfo, Depends(auth.current_user)],
) -> SpeakerAssignmentStatus: ) -> SpeakerAssignmentStatus:
user_id = user["sub"] if user else None user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http( transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id transcript_id, user_id=user_id
) )
if transcript.user_id is not None and transcript.user_id != user_id:
raise HTTPException(status_code=403, detail="Not authorized")
if not transcript: if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found") raise HTTPException(status_code=404, detail="Transcript not found")

View File

@@ -4,8 +4,11 @@ Transcripts websocket API
""" """
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
import reflector.auth as auth
from reflector.db.transcripts import transcripts_controller from reflector.db.transcripts import transcripts_controller
from reflector.ws_manager import get_ws_manager from reflector.ws_manager import get_ws_manager
@@ -21,10 +24,12 @@ async def transcript_get_websocket_events(transcript_id: str):
async def transcript_events_websocket( async def transcript_events_websocket(
transcript_id: str, transcript_id: str,
websocket: WebSocket, websocket: WebSocket,
# user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Optional[auth.UserInfo] = Depends(auth.current_user_optional),
): ):
# user_id = user["sub"] if user else None user_id = user["sub"] if user else None
transcript = await transcripts_controller.get_by_id(transcript_id) transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if not transcript: if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found") raise HTTPException(status_code=404, detail="Transcript not found")

View File

@@ -1,4 +1,5 @@
import os import os
from contextlib import asynccontextmanager
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from unittest.mock import patch from unittest.mock import patch
@@ -337,6 +338,162 @@ async def client():
yield ac yield ac
@pytest.fixture(autouse=True)
async def ws_manager_in_memory(monkeypatch):
"""Replace Redis-based WS manager with an in-memory implementation for tests."""
import asyncio
import json
from reflector.ws_manager import WebsocketManager
class _InMemorySubscriber:
def __init__(self, queue: asyncio.Queue):
self.queue = queue
async def get_message(self, ignore_subscribe_messages: bool = True):
try:
return await asyncio.wait_for(self.queue.get(), timeout=0.05)
except Exception:
return None
class InMemoryPubSubManager:
def __init__(self):
self.queues: dict[str, asyncio.Queue] = {}
self.connected = False
async def connect(self) -> None:
self.connected = True
async def disconnect(self) -> None:
self.connected = False
async def send_json(self, room_id: str, message: dict) -> None:
if room_id not in self.queues:
self.queues[room_id] = asyncio.Queue()
payload = json.dumps(message).encode("utf-8")
await self.queues[room_id].put(
{"channel": room_id.encode("utf-8"), "data": payload}
)
async def subscribe(self, room_id: str):
if room_id not in self.queues:
self.queues[room_id] = asyncio.Queue()
return _InMemorySubscriber(self.queues[room_id])
async def unsubscribe(self, room_id: str) -> None:
# keep queue for potential later resubscribe within same test
pass
pubsub = InMemoryPubSubManager()
ws_manager = WebsocketManager(pubsub_client=pubsub)
def _get_ws_manager():
return ws_manager
# Patch all places that imported get_ws_manager at import time
monkeypatch.setattr("reflector.ws_manager.get_ws_manager", _get_ws_manager)
monkeypatch.setattr(
"reflector.pipelines.main_live_pipeline.get_ws_manager", _get_ws_manager
)
monkeypatch.setattr(
"reflector.views.transcripts_websocket.get_ws_manager", _get_ws_manager
)
# Websocket auth: avoid OAuth2 on websocket dependencies; allow anonymous
import reflector.auth as auth
# Ensure FastAPI uses our override for routes that captured the original callable
from reflector.app import app as fastapi_app
try:
fastapi_app.dependency_overrides[auth.current_user_optional] = lambda: None
except Exception:
pass
# Stub Redis cache used by profanity filter to avoid external Redis
from reflector import redis_cache as rc
class _FakeRedis:
def __init__(self):
self._data = {}
def get(self, key):
value = self._data.get(key)
if value is None:
return None
if isinstance(value, bytes):
return value
return str(value).encode("utf-8")
def setex(self, key, duration, value):
# ignore duration for tests
if isinstance(value, bytes):
self._data[key] = value
else:
self._data[key] = str(value).encode("utf-8")
fake_redises: dict[int, _FakeRedis] = {}
def _get_redis_client(db=0):
if db not in fake_redises:
fake_redises[db] = _FakeRedis()
return fake_redises[db]
monkeypatch.setattr(rc, "get_redis_client", _get_redis_client)
yield
@pytest.fixture
@pytest.mark.asyncio
async def authenticated_client():
async with authenticated_client_ctx():
yield
@pytest.fixture
@pytest.mark.asyncio
async def authenticated_client2():
async with authenticated_client2_ctx():
yield
@asynccontextmanager
async def authenticated_client_ctx():
from reflector.app import app
from reflector.auth import current_user, current_user_optional
app.dependency_overrides[current_user] = lambda: {
"sub": "randomuserid",
"email": "test@mail.com",
}
app.dependency_overrides[current_user_optional] = lambda: {
"sub": "randomuserid",
"email": "test@mail.com",
}
yield
del app.dependency_overrides[current_user]
del app.dependency_overrides[current_user_optional]
@asynccontextmanager
async def authenticated_client2_ctx():
from reflector.app import app
from reflector.auth import current_user, current_user_optional
app.dependency_overrides[current_user] = lambda: {
"sub": "randomuserid2",
"email": "test@mail.com",
}
app.dependency_overrides[current_user_optional] = lambda: {
"sub": "randomuserid2",
"email": "test@mail.com",
}
yield
del app.dependency_overrides[current_user]
del app.dependency_overrides[current_user_optional]
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def fake_mp3_upload(): def fake_mp3_upload():
with patch( with patch(

View File

@@ -11,14 +11,21 @@ from reflector.db.rooms import rooms_controller
@pytest.fixture @pytest.fixture
async def authenticated_client(client): async def authenticated_client(client):
from reflector.app import app from reflector.app import app
from reflector.auth import current_user_optional from reflector.auth import current_user, current_user_optional
app.dependency_overrides[current_user] = lambda: {
"sub": "test-user",
"email": "test@example.com",
}
app.dependency_overrides[current_user_optional] = lambda: { app.dependency_overrides[current_user_optional] = lambda: {
"sub": "test-user", "sub": "test-user",
"email": "test@example.com", "email": "test@example.com",
} }
yield client try:
del app.dependency_overrides[current_user_optional] yield client
finally:
del app.dependency_overrides[current_user]
del app.dependency_overrides[current_user_optional]
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -0,0 +1,384 @@
import asyncio
import shutil
import threading
import time
from pathlib import Path
import pytest
from httpx_ws import aconnect_ws
from uvicorn import Config, Server
from reflector import zulip as zulip_module
from reflector.app import app
from reflector.db import get_database
from reflector.db.meetings import meetings_controller
from reflector.db.rooms import Room, rooms_controller
from reflector.db.transcripts import (
SourceKind,
TranscriptTopic,
transcripts_controller,
)
from reflector.processors.types import Word
from reflector.settings import settings
from reflector.views.transcripts import create_access_token
@pytest.mark.asyncio
async def test_anonymous_cannot_delete_transcript_in_shared_room(client):
# Create a shared room with a fake owner id so meeting has a room_id
room = await rooms_controller.add(
name="shared-room-test",
user_id="owner-1",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=True,
webhook_url="",
webhook_secret="",
)
# Create a meeting for that room (so transcript.meeting_id links to the shared room)
meeting = await meetings_controller.create(
id="meeting-sec-test",
room_name="room-sec-test",
room_url="room-url",
host_room_url="host-url",
start_date=Room.model_fields["created_at"].default_factory(),
end_date=Room.model_fields["created_at"].default_factory(),
room=room,
)
# Create a transcript owned by someone else and link it to meeting
t = await transcripts_controller.add(
name="to-delete",
source_kind=SourceKind.LIVE,
user_id="owner-2",
meeting_id=meeting.id,
room_id=room.id,
share_mode="private",
)
# Anonymous DELETE should be rejected
del_resp = await client.delete(f"/transcripts/{t.id}")
assert del_resp.status_code == 401, del_resp.text
@pytest.mark.asyncio
async def test_anonymous_cannot_mutate_participants_on_public_transcript(client):
# Create a public transcript with no owner
t = await transcripts_controller.add(
name="public-transcript",
source_kind=SourceKind.LIVE,
user_id=None,
share_mode="public",
)
# Anonymous POST participant must be rejected
resp = await client.post(
f"/transcripts/{t.id}/participants",
json={"name": "AnonUser", "speaker": 0},
)
assert resp.status_code == 401, resp.text
@pytest.mark.asyncio
async def test_anonymous_cannot_update_and_delete_room(client):
# Create room as owner id "owner-3" via controller
room = await rooms_controller.add(
name="room-anon-update-delete",
user_id="owner-3",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
webhook_url="",
webhook_secret="",
)
# Anonymous PATCH via API (no auth)
resp = await client.patch(
f"/rooms/{room.id}",
json={
"name": "room-anon-updated",
"zulip_auto_post": False,
"zulip_stream": "",
"zulip_topic": "",
"is_locked": False,
"room_mode": "normal",
"recording_type": "cloud",
"recording_trigger": "automatic-2nd-participant",
"is_shared": False,
"webhook_url": "",
"webhook_secret": "",
},
)
# Expect authentication required
assert resp.status_code == 401, resp.text
# Anonymous DELETE via API
del_resp = await client.delete(f"/rooms/{room.id}")
assert del_resp.status_code == 401, del_resp.text
@pytest.mark.asyncio
async def test_anonymous_cannot_post_transcript_to_zulip(client, monkeypatch):
# Create a public transcript with some content
t = await transcripts_controller.add(
name="zulip-public",
source_kind=SourceKind.LIVE,
user_id=None,
share_mode="public",
)
# Mock send/update calls
def _fake_send_message_to_zulip(stream, topic, content):
return {"id": 12345}
async def _fake_update_message(message_id, stream, topic, content):
return {"result": "success"}
monkeypatch.setattr(
zulip_module, "send_message_to_zulip", _fake_send_message_to_zulip
)
monkeypatch.setattr(zulip_module, "update_zulip_message", _fake_update_message)
# Anonymous POST to Zulip endpoint
resp = await client.post(
f"/transcripts/{t.id}/zulip",
params={"stream": "general", "topic": "Updates", "include_topics": False},
)
assert resp.status_code == 401, resp.text
@pytest.mark.asyncio
async def test_anonymous_cannot_assign_speaker_on_public_transcript(client):
# Create public transcript
t = await transcripts_controller.add(
name="public-assign",
source_kind=SourceKind.LIVE,
user_id=None,
share_mode="public",
)
# Add a topic with words to be reassigned
topic = TranscriptTopic(
title="T1",
summary="S1",
timestamp=0.0,
transcript="Hello",
words=[Word(start=0.0, end=1.0, text="Hello", speaker=0)],
)
transcript = await transcripts_controller.get_by_id(t.id)
await transcripts_controller.upsert_topic(transcript, topic)
# Anonymous assign speaker over time range covering the word
resp = await client.patch(
f"/transcripts/{t.id}/speaker/assign",
json={
"speaker": 1,
"timestamp_from": 0.0,
"timestamp_to": 1.0,
},
)
assert resp.status_code == 401, resp.text
# Minimal server fixture for websocket tests
@pytest.fixture
def appserver_ws_simple(setup_database):
host = "127.0.0.1"
port = 1256
server_started = threading.Event()
server_exception = None
server_instance = None
def run_server():
nonlocal server_exception, server_instance
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
config = Config(app=app, host=host, port=port, loop=loop)
server_instance = Server(config)
async def start_server():
database = get_database()
await database.connect()
try:
await server_instance.serve()
finally:
await database.disconnect()
server_started.set()
loop.run_until_complete(start_server())
except Exception as e:
server_exception = e
server_started.set()
finally:
loop.close()
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
server_started.wait(timeout=30)
if server_exception:
raise server_exception
time.sleep(0.5)
yield host, port
if server_instance:
server_instance.should_exit = True
server_thread.join(timeout=30)
@pytest.mark.asyncio
async def test_websocket_denies_anonymous_on_private_transcript(appserver_ws_simple):
host, port = appserver_ws_simple
# Create a private transcript owned by someone
t = await transcripts_controller.add(
name="private-ws",
source_kind=SourceKind.LIVE,
user_id="owner-x",
share_mode="private",
)
base_url = f"http://{host}:{port}/v1"
# Anonymous connect should be denied
with pytest.raises(Exception):
async with aconnect_ws(f"{base_url}/transcripts/{t.id}/events") as ws:
await ws.close()
@pytest.mark.asyncio
async def test_anonymous_cannot_update_public_transcript(client):
t = await transcripts_controller.add(
name="update-me",
source_kind=SourceKind.LIVE,
user_id=None,
share_mode="public",
)
resp = await client.patch(
f"/transcripts/{t.id}",
json={"title": "New Title From Anonymous"},
)
assert resp.status_code == 401, resp.text
@pytest.mark.asyncio
async def test_anonymous_cannot_get_nonshared_room_by_id(client):
room = await rooms_controller.add(
name="private-room-exposed",
user_id="owner-z",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
webhook_url="",
webhook_secret="",
)
resp = await client.get(f"/rooms/{room.id}")
assert resp.status_code == 403, resp.text
@pytest.mark.asyncio
async def test_anonymous_cannot_call_rooms_webhook_test(client):
room = await rooms_controller.add(
name="room-webhook-test",
user_id="owner-y",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
webhook_url="http://localhost.invalid/webhook",
webhook_secret="secret",
)
# Anonymous caller
resp = await client.post(f"/rooms/{room.id}/webhook/test")
assert resp.status_code == 401, resp.text
@pytest.mark.asyncio
async def test_anonymous_cannot_create_room(client):
payload = {
"name": "room-create-auth-required",
"zulip_auto_post": False,
"zulip_stream": "",
"zulip_topic": "",
"is_locked": False,
"room_mode": "normal",
"recording_type": "cloud",
"recording_trigger": "automatic-2nd-participant",
"is_shared": False,
"webhook_url": "",
"webhook_secret": "",
}
resp = await client.post("/rooms", json=payload)
assert resp.status_code == 401, resp.text
@pytest.mark.asyncio
async def test_list_search_401_when_public_mode_false(client, monkeypatch):
monkeypatch.setattr(settings, "PUBLIC_MODE", False)
resp = await client.get("/transcripts")
assert resp.status_code == 401
resp = await client.get("/transcripts/search", params={"q": "hello"})
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_audio_mp3_requires_token_for_owned_transcript(
client, tmpdir, monkeypatch
):
# Use temp data dir
monkeypatch.setattr(settings, "DATA_DIR", Path(tmpdir).as_posix())
# Create owner transcript and attach a local mp3
t = await transcripts_controller.add(
name="owned-audio",
source_kind=SourceKind.LIVE,
user_id="owner-a",
share_mode="private",
)
tr = await transcripts_controller.get_by_id(t.id)
await transcripts_controller.update(tr, {"status": "ended"})
# copy fixture audio to transcript path
audio_path = Path(__file__).parent / "records" / "test_mathieu_hello.mp3"
tr.audio_mp3_filename.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(audio_path, tr.audio_mp3_filename)
# Anonymous GET without token should be 403 or 404 depending on access; we call mp3
resp = await client.get(f"/transcripts/{t.id}/audio/mp3")
assert resp.status_code == 403
# With token should succeed
token = create_access_token(
{"sub": tr.user_id}, expires_delta=__import__("datetime").timedelta(minutes=15)
)
resp2 = await client.get(f"/transcripts/{t.id}/audio/mp3", params={"token": token})
assert resp2.status_code == 200

View File

@@ -1,5 +1,3 @@
from contextlib import asynccontextmanager
import pytest import pytest
@@ -19,7 +17,7 @@ async def test_transcript_create(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transcript_get_update_name(client): async def test_transcript_get_update_name(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"}) response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["name"] == "test" assert response.json()["name"] == "test"
@@ -40,7 +38,7 @@ async def test_transcript_get_update_name(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transcript_get_update_locked(client): async def test_transcript_get_update_locked(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"}) response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["locked"] is False assert response.json()["locked"] is False
@@ -61,7 +59,7 @@ async def test_transcript_get_update_locked(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transcript_get_update_summary(client): async def test_transcript_get_update_summary(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"}) response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["long_summary"] is None assert response.json()["long_summary"] is None
@@ -89,7 +87,7 @@ async def test_transcript_get_update_summary(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transcript_get_update_title(client): async def test_transcript_get_update_title(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"}) response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["title"] is None assert response.json()["title"] is None
@@ -127,56 +125,6 @@ async def test_transcripts_list_anonymous(client):
settings.PUBLIC_MODE = False settings.PUBLIC_MODE = False
@asynccontextmanager
async def authenticated_client_ctx():
from reflector.app import app
from reflector.auth import current_user, current_user_optional
app.dependency_overrides[current_user] = lambda: {
"sub": "randomuserid",
"email": "test@mail.com",
}
app.dependency_overrides[current_user_optional] = lambda: {
"sub": "randomuserid",
"email": "test@mail.com",
}
yield
del app.dependency_overrides[current_user]
del app.dependency_overrides[current_user_optional]
@asynccontextmanager
async def authenticated_client2_ctx():
from reflector.app import app
from reflector.auth import current_user, current_user_optional
app.dependency_overrides[current_user] = lambda: {
"sub": "randomuserid2",
"email": "test@mail.com",
}
app.dependency_overrides[current_user_optional] = lambda: {
"sub": "randomuserid2",
"email": "test@mail.com",
}
yield
del app.dependency_overrides[current_user]
del app.dependency_overrides[current_user_optional]
@pytest.fixture
@pytest.mark.asyncio
async def authenticated_client():
async with authenticated_client_ctx():
yield
@pytest.fixture
@pytest.mark.asyncio
async def authenticated_client2():
async with authenticated_client2_ctx():
yield
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transcripts_list_authenticated(authenticated_client, client): async def test_transcripts_list_authenticated(authenticated_client, client):
# XXX this test is a bit fragile, as it depends on the storage which # XXX this test is a bit fragile, as it depends on the storage which
@@ -199,7 +147,7 @@ async def test_transcripts_list_authenticated(authenticated_client, client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transcript_delete(client): async def test_transcript_delete(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "testdel1"}) response = await client.post("/transcripts", json={"name": "testdel1"})
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["name"] == "testdel1" assert response.json()["name"] == "testdel1"
@@ -214,7 +162,7 @@ async def test_transcript_delete(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transcript_mark_reviewed(client): async def test_transcript_mark_reviewed(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"}) response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["name"] == "test" assert response.json()["name"] == "test"

View File

@@ -111,7 +111,9 @@ async def test_transcript_audio_download_range_with_seek(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transcript_delete_with_audio(fake_transcript, client): async def test_transcript_delete_with_audio(
authenticated_client, fake_transcript, client
):
response = await client.delete(f"/transcripts/{fake_transcript.id}") response = await client.delete(f"/transcripts/{fake_transcript.id}")
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["status"] == "ok" assert response.json()["status"] == "ok"

View File

@@ -2,7 +2,7 @@ import pytest
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transcript_participants(client): async def test_transcript_participants(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"}) response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["participants"] == [] assert response.json()["participants"] == []
@@ -39,7 +39,7 @@ async def test_transcript_participants(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transcript_participants_same_speaker(client): async def test_transcript_participants_same_speaker(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"}) response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["participants"] == [] assert response.json()["participants"] == []
@@ -62,7 +62,7 @@ async def test_transcript_participants_same_speaker(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transcript_participants_update_name(client): async def test_transcript_participants_update_name(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"}) response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["participants"] == [] assert response.json()["participants"] == []
@@ -100,7 +100,7 @@ async def test_transcript_participants_update_name(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transcript_participants_update_speaker(client): async def test_transcript_participants_update_speaker(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"}) response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["participants"] == [] assert response.json()["participants"] == []

View File

@@ -2,7 +2,9 @@ import pytest
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transcript_reassign_speaker(fake_transcript_with_topics, client): async def test_transcript_reassign_speaker(
authenticated_client, fake_transcript_with_topics, client
):
transcript_id = fake_transcript_with_topics.id transcript_id = fake_transcript_with_topics.id
# check the transcript exists # check the transcript exists
@@ -114,7 +116,9 @@ async def test_transcript_reassign_speaker(fake_transcript_with_topics, client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transcript_merge_speaker(fake_transcript_with_topics, client): async def test_transcript_merge_speaker(
authenticated_client, fake_transcript_with_topics, client
):
transcript_id = fake_transcript_with_topics.id transcript_id = fake_transcript_with_topics.id
# check the transcript exists # check the transcript exists
@@ -181,7 +185,7 @@ async def test_transcript_merge_speaker(fake_transcript_with_topics, client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transcript_reassign_with_participant( async def test_transcript_reassign_with_participant(
fake_transcript_with_topics, client authenticated_client, fake_transcript_with_topics, client
): ):
transcript_id = fake_transcript_with_topics.id transcript_id = fake_transcript_with_topics.id
@@ -347,7 +351,9 @@ async def test_transcript_reassign_with_participant(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transcript_reassign_edge_cases(fake_transcript_with_topics, client): async def test_transcript_reassign_edge_cases(
authenticated_client, fake_transcript_with_topics, client
):
transcript_id = fake_transcript_with_topics.id transcript_id = fake_transcript_with_topics.id
# check the transcript exists # check the transcript exists

View File

@@ -62,7 +62,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
useEffect(() => { useEffect(() => {
document.onkeyup = (e) => { document.onkeyup = (e) => {
if (e.key === "a" && process.env.NEXT_PUBLIC_ENV === "development") { if (e.key === "a" && process.env.NODE_ENV === "development") {
const segments: GetTranscriptSegmentTopic[] = [ const segments: GetTranscriptSegmentTopic[] = [
{ {
speaker: 1, speaker: 1,
@@ -201,7 +201,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
setFinalSummary({ summary: "This is the final summary" }); setFinalSummary({ summary: "This is the final summary" });
} }
if (e.key === "z" && process.env.NEXT_PUBLIC_ENV === "development") { if (e.key === "z" && process.env.NODE_ENV === "development") {
setTranscriptTextLive( setTranscriptTextLive(
"This text is in English, and it is a pretty long sentence to test the limits", "This text is in English, and it is a pretty long sentence to test the limits",
); );