mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
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:
@@ -112,6 +112,7 @@ source = ["reflector"]
|
||||
[tool.pytest_env]
|
||||
ENVIRONMENT = "pytest"
|
||||
DATABASE_URL = "postgresql://test_user:test_password@localhost:15432/reflector_test"
|
||||
AUTH_BACKEND = "jwt"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-ra -q --disable-pytest-warnings --cov --cov-report html -v"
|
||||
|
||||
@@ -647,6 +647,19 @@ class TranscriptController:
|
||||
query = transcripts.delete().where(transcripts.c.recording_id == recording_id)
|
||||
await get_database().execute(query)
|
||||
|
||||
@staticmethod
|
||||
def user_can_mutate(transcript: Transcript, user_id: str | None) -> bool:
|
||||
"""
|
||||
Returns True if the given user is allowed to modify the transcript.
|
||||
|
||||
Policy:
|
||||
- Anonymous transcripts (user_id is None) cannot be modified via API
|
||||
- Only the owner (matching user_id) can modify their transcript
|
||||
"""
|
||||
if transcript.user_id is None:
|
||||
return False
|
||||
return user_id and transcript.user_id == user_id
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self):
|
||||
"""
|
||||
|
||||
@@ -199,6 +199,8 @@ async def rooms_get(
|
||||
room = await rooms_controller.get_by_id_for_http(room_id, user_id=user_id)
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
if not room.is_shared and (user_id is None or room.user_id != user_id):
|
||||
raise HTTPException(status_code=403, detail="Room access denied")
|
||||
return room
|
||||
|
||||
|
||||
@@ -229,9 +231,9 @@ async def rooms_get_by_name(
|
||||
@router.post("/rooms", response_model=Room)
|
||||
async def rooms_create(
|
||||
room: CreateRoom,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
|
||||
):
|
||||
user_id = user["sub"] if user else None
|
||||
user_id = user["sub"]
|
||||
|
||||
return await rooms_controller.add(
|
||||
name=room.name,
|
||||
@@ -256,12 +258,14 @@ async def rooms_create(
|
||||
async def rooms_update(
|
||||
room_id: str,
|
||||
info: UpdateRoom,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
|
||||
):
|
||||
user_id = user["sub"] if user else None
|
||||
user_id = user["sub"]
|
||||
room = await rooms_controller.get_by_id_for_http(room_id, user_id=user_id)
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
if room.user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
values = info.dict(exclude_unset=True)
|
||||
await rooms_controller.update(room, values)
|
||||
return room
|
||||
@@ -270,12 +274,14 @@ async def rooms_update(
|
||||
@router.delete("/rooms/{room_id}", response_model=DeletionStatus)
|
||||
async def rooms_delete(
|
||||
room_id: str,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
|
||||
):
|
||||
user_id = user["sub"] if user else None
|
||||
room = await rooms_controller.get_by_id(room_id, user_id=user_id)
|
||||
user_id = user["sub"]
|
||||
room = await rooms_controller.get_by_id(room_id)
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
if room.user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
await rooms_controller.remove_by_id(room.id, user_id=user_id)
|
||||
return DeletionStatus(status="ok")
|
||||
|
||||
@@ -339,16 +345,16 @@ async def rooms_create_meeting(
|
||||
@router.post("/rooms/{room_id}/webhook/test", response_model=WebhookTestResult)
|
||||
async def rooms_test_webhook(
|
||||
room_id: str,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
|
||||
):
|
||||
"""Test webhook configuration by sending a sample payload."""
|
||||
user_id = user["sub"] if user else None
|
||||
user_id = user["sub"]
|
||||
|
||||
room = await rooms_controller.get_by_id(room_id)
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
|
||||
if user_id and room.user_id != user_id:
|
||||
if room.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Not authorized to test this room's webhook"
|
||||
)
|
||||
|
||||
@@ -9,8 +9,6 @@ from pydantic import BaseModel, Field, constr, field_serializer
|
||||
|
||||
import reflector.auth as auth
|
||||
from reflector.db import get_database
|
||||
from reflector.db.meetings import meetings_controller
|
||||
from reflector.db.rooms import rooms_controller
|
||||
from reflector.db.search import (
|
||||
DEFAULT_SEARCH_LIMIT,
|
||||
SearchLimit,
|
||||
@@ -344,12 +342,14 @@ async def transcript_get(
|
||||
async def transcript_update(
|
||||
transcript_id: str,
|
||||
info: UpdateTranscript,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
|
||||
):
|
||||
user_id = user["sub"] if user else None
|
||||
user_id = user["sub"]
|
||||
transcript = await transcripts_controller.get_by_id_for_http(
|
||||
transcript_id, user_id=user_id
|
||||
)
|
||||
if not transcripts_controller.user_can_mutate(transcript, user_id):
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
values = info.dict(exclude_unset=True)
|
||||
updated_transcript = await transcripts_controller.update(transcript, values)
|
||||
return updated_transcript
|
||||
@@ -358,18 +358,14 @@ async def transcript_update(
|
||||
@router.delete("/transcripts/{transcript_id}", response_model=DeletionStatus)
|
||||
async def transcript_delete(
|
||||
transcript_id: str,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
|
||||
):
|
||||
user_id = user["sub"] if user else None
|
||||
user_id = user["sub"]
|
||||
transcript = await transcripts_controller.get_by_id(transcript_id)
|
||||
if not transcript:
|
||||
raise HTTPException(status_code=404, detail="Transcript not found")
|
||||
|
||||
if transcript.meeting_id:
|
||||
meeting = await meetings_controller.get_by_id(transcript.meeting_id)
|
||||
room = await rooms_controller.get_by_id(meeting.room_id)
|
||||
if room.is_shared:
|
||||
user_id = None
|
||||
if not transcripts_controller.user_can_mutate(transcript, user_id):
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
await transcripts_controller.remove_by_id(transcript.id, user_id=user_id)
|
||||
return DeletionStatus(status="ok")
|
||||
@@ -443,15 +439,16 @@ async def transcript_post_to_zulip(
|
||||
stream: str,
|
||||
topic: str,
|
||||
include_topics: bool,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
|
||||
):
|
||||
user_id = user["sub"] if user else None
|
||||
user_id = user["sub"]
|
||||
transcript = await transcripts_controller.get_by_id_for_http(
|
||||
transcript_id, user_id=user_id
|
||||
)
|
||||
if not transcript:
|
||||
raise HTTPException(status_code=404, detail="Transcript not found")
|
||||
|
||||
if not transcripts_controller.user_can_mutate(transcript, user_id):
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
content = get_zulip_message(transcript, include_topics)
|
||||
|
||||
message_updated = False
|
||||
|
||||
@@ -56,12 +56,14 @@ async def transcript_get_participants(
|
||||
async def transcript_add_participant(
|
||||
transcript_id: str,
|
||||
participant: CreateParticipant,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
|
||||
) -> Participant:
|
||||
user_id = user["sub"] if user else None
|
||||
user_id = user["sub"]
|
||||
transcript = await transcripts_controller.get_by_id_for_http(
|
||||
transcript_id, user_id=user_id
|
||||
)
|
||||
if transcript.user_id is not None and transcript.user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
# ensure the speaker is unique
|
||||
if participant.speaker is not None and transcript.participants is not None:
|
||||
@@ -101,12 +103,14 @@ async def transcript_update_participant(
|
||||
transcript_id: str,
|
||||
participant_id: str,
|
||||
participant: UpdateParticipant,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
|
||||
) -> Participant:
|
||||
user_id = user["sub"] if user else None
|
||||
user_id = user["sub"]
|
||||
transcript = await transcripts_controller.get_by_id_for_http(
|
||||
transcript_id, user_id=user_id
|
||||
)
|
||||
if transcript.user_id is not None and transcript.user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
# ensure the speaker is unique
|
||||
for p in transcript.participants:
|
||||
@@ -138,11 +142,13 @@ async def transcript_update_participant(
|
||||
async def transcript_delete_participant(
|
||||
transcript_id: str,
|
||||
participant_id: str,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
|
||||
) -> DeletionStatus:
|
||||
user_id = user["sub"] if user else None
|
||||
user_id = user["sub"]
|
||||
transcript = await transcripts_controller.get_by_id_for_http(
|
||||
transcript_id, user_id=user_id
|
||||
)
|
||||
if transcript.user_id is not None and transcript.user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
await transcripts_controller.delete_participant(transcript, participant_id)
|
||||
return DeletionStatus(status="ok")
|
||||
|
||||
@@ -35,12 +35,14 @@ class SpeakerMerge(BaseModel):
|
||||
async def transcript_assign_speaker(
|
||||
transcript_id: str,
|
||||
assignment: SpeakerAssignment,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
|
||||
) -> SpeakerAssignmentStatus:
|
||||
user_id = user["sub"] if user else None
|
||||
user_id = user["sub"]
|
||||
transcript = await transcripts_controller.get_by_id_for_http(
|
||||
transcript_id, user_id=user_id
|
||||
)
|
||||
if transcript.user_id is not None and transcript.user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
if not transcript:
|
||||
raise HTTPException(status_code=404, detail="Transcript not found")
|
||||
@@ -113,12 +115,14 @@ async def transcript_assign_speaker(
|
||||
async def transcript_merge_speaker(
|
||||
transcript_id: str,
|
||||
merge: SpeakerMerge,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
|
||||
) -> SpeakerAssignmentStatus:
|
||||
user_id = user["sub"] if user else None
|
||||
user_id = user["sub"]
|
||||
transcript = await transcripts_controller.get_by_id_for_http(
|
||||
transcript_id, user_id=user_id
|
||||
)
|
||||
if transcript.user_id is not None and transcript.user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
if not transcript:
|
||||
raise HTTPException(status_code=404, detail="Transcript not found")
|
||||
|
||||
@@ -4,8 +4,11 @@ Transcripts websocket API
|
||||
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
||||
|
||||
import reflector.auth as auth
|
||||
from reflector.db.transcripts import transcripts_controller
|
||||
from reflector.ws_manager import get_ws_manager
|
||||
|
||||
@@ -21,10 +24,12 @@ async def transcript_get_websocket_events(transcript_id: str):
|
||||
async def transcript_events_websocket(
|
||||
transcript_id: str,
|
||||
websocket: WebSocket,
|
||||
# user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
user: Optional[auth.UserInfo] = Depends(auth.current_user_optional),
|
||||
):
|
||||
# user_id = user["sub"] if user else None
|
||||
transcript = await transcripts_controller.get_by_id(transcript_id)
|
||||
user_id = user["sub"] if user else None
|
||||
transcript = await transcripts_controller.get_by_id_for_http(
|
||||
transcript_id, user_id=user_id
|
||||
)
|
||||
if not transcript:
|
||||
raise HTTPException(status_code=404, detail="Transcript not found")
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from tempfile import NamedTemporaryFile
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -337,6 +338,162 @@ async def client():
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def ws_manager_in_memory(monkeypatch):
|
||||
"""Replace Redis-based WS manager with an in-memory implementation for tests."""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from reflector.ws_manager import WebsocketManager
|
||||
|
||||
class _InMemorySubscriber:
|
||||
def __init__(self, queue: asyncio.Queue):
|
||||
self.queue = queue
|
||||
|
||||
async def get_message(self, ignore_subscribe_messages: bool = True):
|
||||
try:
|
||||
return await asyncio.wait_for(self.queue.get(), timeout=0.05)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
class InMemoryPubSubManager:
|
||||
def __init__(self):
|
||||
self.queues: dict[str, asyncio.Queue] = {}
|
||||
self.connected = False
|
||||
|
||||
async def connect(self) -> None:
|
||||
self.connected = True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self.connected = False
|
||||
|
||||
async def send_json(self, room_id: str, message: dict) -> None:
|
||||
if room_id not in self.queues:
|
||||
self.queues[room_id] = asyncio.Queue()
|
||||
payload = json.dumps(message).encode("utf-8")
|
||||
await self.queues[room_id].put(
|
||||
{"channel": room_id.encode("utf-8"), "data": payload}
|
||||
)
|
||||
|
||||
async def subscribe(self, room_id: str):
|
||||
if room_id not in self.queues:
|
||||
self.queues[room_id] = asyncio.Queue()
|
||||
return _InMemorySubscriber(self.queues[room_id])
|
||||
|
||||
async def unsubscribe(self, room_id: str) -> None:
|
||||
# keep queue for potential later resubscribe within same test
|
||||
pass
|
||||
|
||||
pubsub = InMemoryPubSubManager()
|
||||
ws_manager = WebsocketManager(pubsub_client=pubsub)
|
||||
|
||||
def _get_ws_manager():
|
||||
return ws_manager
|
||||
|
||||
# Patch all places that imported get_ws_manager at import time
|
||||
monkeypatch.setattr("reflector.ws_manager.get_ws_manager", _get_ws_manager)
|
||||
monkeypatch.setattr(
|
||||
"reflector.pipelines.main_live_pipeline.get_ws_manager", _get_ws_manager
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"reflector.views.transcripts_websocket.get_ws_manager", _get_ws_manager
|
||||
)
|
||||
|
||||
# Websocket auth: avoid OAuth2 on websocket dependencies; allow anonymous
|
||||
import reflector.auth as auth
|
||||
|
||||
# Ensure FastAPI uses our override for routes that captured the original callable
|
||||
from reflector.app import app as fastapi_app
|
||||
|
||||
try:
|
||||
fastapi_app.dependency_overrides[auth.current_user_optional] = lambda: None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Stub Redis cache used by profanity filter to avoid external Redis
|
||||
from reflector import redis_cache as rc
|
||||
|
||||
class _FakeRedis:
|
||||
def __init__(self):
|
||||
self._data = {}
|
||||
|
||||
def get(self, key):
|
||||
value = self._data.get(key)
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bytes):
|
||||
return value
|
||||
return str(value).encode("utf-8")
|
||||
|
||||
def setex(self, key, duration, value):
|
||||
# ignore duration for tests
|
||||
if isinstance(value, bytes):
|
||||
self._data[key] = value
|
||||
else:
|
||||
self._data[key] = str(value).encode("utf-8")
|
||||
|
||||
fake_redises: dict[int, _FakeRedis] = {}
|
||||
|
||||
def _get_redis_client(db=0):
|
||||
if db not in fake_redises:
|
||||
fake_redises[db] = _FakeRedis()
|
||||
return fake_redises[db]
|
||||
|
||||
monkeypatch.setattr(rc, "get_redis_client", _get_redis_client)
|
||||
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.mark.asyncio
|
||||
async def authenticated_client():
|
||||
async with authenticated_client_ctx():
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.mark.asyncio
|
||||
async def authenticated_client2():
|
||||
async with authenticated_client2_ctx():
|
||||
yield
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def authenticated_client_ctx():
|
||||
from reflector.app import app
|
||||
from reflector.auth import current_user, current_user_optional
|
||||
|
||||
app.dependency_overrides[current_user] = lambda: {
|
||||
"sub": "randomuserid",
|
||||
"email": "test@mail.com",
|
||||
}
|
||||
app.dependency_overrides[current_user_optional] = lambda: {
|
||||
"sub": "randomuserid",
|
||||
"email": "test@mail.com",
|
||||
}
|
||||
yield
|
||||
del app.dependency_overrides[current_user]
|
||||
del app.dependency_overrides[current_user_optional]
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def authenticated_client2_ctx():
|
||||
from reflector.app import app
|
||||
from reflector.auth import current_user, current_user_optional
|
||||
|
||||
app.dependency_overrides[current_user] = lambda: {
|
||||
"sub": "randomuserid2",
|
||||
"email": "test@mail.com",
|
||||
}
|
||||
app.dependency_overrides[current_user_optional] = lambda: {
|
||||
"sub": "randomuserid2",
|
||||
"email": "test@mail.com",
|
||||
}
|
||||
yield
|
||||
del app.dependency_overrides[current_user]
|
||||
del app.dependency_overrides[current_user_optional]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def fake_mp3_upload():
|
||||
with patch(
|
||||
|
||||
@@ -11,14 +11,21 @@ from reflector.db.rooms import rooms_controller
|
||||
@pytest.fixture
|
||||
async def authenticated_client(client):
|
||||
from reflector.app import app
|
||||
from reflector.auth import current_user_optional
|
||||
from reflector.auth import current_user, current_user_optional
|
||||
|
||||
app.dependency_overrides[current_user] = lambda: {
|
||||
"sub": "test-user",
|
||||
"email": "test@example.com",
|
||||
}
|
||||
app.dependency_overrides[current_user_optional] = lambda: {
|
||||
"sub": "test-user",
|
||||
"email": "test@example.com",
|
||||
}
|
||||
yield client
|
||||
del app.dependency_overrides[current_user_optional]
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
del app.dependency_overrides[current_user]
|
||||
del app.dependency_overrides[current_user_optional]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
384
server/tests/test_security_permissions.py
Normal file
384
server/tests/test_security_permissions.py
Normal 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
|
||||
@@ -1,5 +1,3 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -19,7 +17,7 @@ async def test_transcript_create(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_get_update_name(client):
|
||||
async def test_transcript_get_update_name(authenticated_client, client):
|
||||
response = await client.post("/transcripts", json={"name": "test"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "test"
|
||||
@@ -40,7 +38,7 @@ async def test_transcript_get_update_name(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_get_update_locked(client):
|
||||
async def test_transcript_get_update_locked(authenticated_client, client):
|
||||
response = await client.post("/transcripts", json={"name": "test"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["locked"] is False
|
||||
@@ -61,7 +59,7 @@ async def test_transcript_get_update_locked(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_get_update_summary(client):
|
||||
async def test_transcript_get_update_summary(authenticated_client, client):
|
||||
response = await client.post("/transcripts", json={"name": "test"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["long_summary"] is None
|
||||
@@ -89,7 +87,7 @@ async def test_transcript_get_update_summary(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_get_update_title(client):
|
||||
async def test_transcript_get_update_title(authenticated_client, client):
|
||||
response = await client.post("/transcripts", json={"name": "test"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["title"] is None
|
||||
@@ -127,56 +125,6 @@ async def test_transcripts_list_anonymous(client):
|
||||
settings.PUBLIC_MODE = False
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def authenticated_client_ctx():
|
||||
from reflector.app import app
|
||||
from reflector.auth import current_user, current_user_optional
|
||||
|
||||
app.dependency_overrides[current_user] = lambda: {
|
||||
"sub": "randomuserid",
|
||||
"email": "test@mail.com",
|
||||
}
|
||||
app.dependency_overrides[current_user_optional] = lambda: {
|
||||
"sub": "randomuserid",
|
||||
"email": "test@mail.com",
|
||||
}
|
||||
yield
|
||||
del app.dependency_overrides[current_user]
|
||||
del app.dependency_overrides[current_user_optional]
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def authenticated_client2_ctx():
|
||||
from reflector.app import app
|
||||
from reflector.auth import current_user, current_user_optional
|
||||
|
||||
app.dependency_overrides[current_user] = lambda: {
|
||||
"sub": "randomuserid2",
|
||||
"email": "test@mail.com",
|
||||
}
|
||||
app.dependency_overrides[current_user_optional] = lambda: {
|
||||
"sub": "randomuserid2",
|
||||
"email": "test@mail.com",
|
||||
}
|
||||
yield
|
||||
del app.dependency_overrides[current_user]
|
||||
del app.dependency_overrides[current_user_optional]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.mark.asyncio
|
||||
async def authenticated_client():
|
||||
async with authenticated_client_ctx():
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.mark.asyncio
|
||||
async def authenticated_client2():
|
||||
async with authenticated_client2_ctx():
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcripts_list_authenticated(authenticated_client, client):
|
||||
# XXX this test is a bit fragile, as it depends on the storage which
|
||||
@@ -199,7 +147,7 @@ async def test_transcripts_list_authenticated(authenticated_client, client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_delete(client):
|
||||
async def test_transcript_delete(authenticated_client, client):
|
||||
response = await client.post("/transcripts", json={"name": "testdel1"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "testdel1"
|
||||
@@ -214,7 +162,7 @@ async def test_transcript_delete(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_mark_reviewed(client):
|
||||
async def test_transcript_mark_reviewed(authenticated_client, client):
|
||||
response = await client.post("/transcripts", json={"name": "test"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "test"
|
||||
|
||||
@@ -111,7 +111,9 @@ async def test_transcript_audio_download_range_with_seek(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_delete_with_audio(fake_transcript, client):
|
||||
async def test_transcript_delete_with_audio(
|
||||
authenticated_client, fake_transcript, client
|
||||
):
|
||||
response = await client.delete(f"/transcripts/{fake_transcript.id}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
|
||||
@@ -2,7 +2,7 @@ import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_participants(client):
|
||||
async def test_transcript_participants(authenticated_client, client):
|
||||
response = await client.post("/transcripts", json={"name": "test"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["participants"] == []
|
||||
@@ -39,7 +39,7 @@ async def test_transcript_participants(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_participants_same_speaker(client):
|
||||
async def test_transcript_participants_same_speaker(authenticated_client, client):
|
||||
response = await client.post("/transcripts", json={"name": "test"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["participants"] == []
|
||||
@@ -62,7 +62,7 @@ async def test_transcript_participants_same_speaker(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_participants_update_name(client):
|
||||
async def test_transcript_participants_update_name(authenticated_client, client):
|
||||
response = await client.post("/transcripts", json={"name": "test"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["participants"] == []
|
||||
@@ -100,7 +100,7 @@ async def test_transcript_participants_update_name(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_participants_update_speaker(client):
|
||||
async def test_transcript_participants_update_speaker(authenticated_client, client):
|
||||
response = await client.post("/transcripts", json={"name": "test"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["participants"] == []
|
||||
|
||||
@@ -2,7 +2,9 @@ import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_reassign_speaker(fake_transcript_with_topics, client):
|
||||
async def test_transcript_reassign_speaker(
|
||||
authenticated_client, fake_transcript_with_topics, client
|
||||
):
|
||||
transcript_id = fake_transcript_with_topics.id
|
||||
|
||||
# check the transcript exists
|
||||
@@ -114,7 +116,9 @@ async def test_transcript_reassign_speaker(fake_transcript_with_topics, client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_merge_speaker(fake_transcript_with_topics, client):
|
||||
async def test_transcript_merge_speaker(
|
||||
authenticated_client, fake_transcript_with_topics, client
|
||||
):
|
||||
transcript_id = fake_transcript_with_topics.id
|
||||
|
||||
# check the transcript exists
|
||||
@@ -181,7 +185,7 @@ async def test_transcript_merge_speaker(fake_transcript_with_topics, client):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_reassign_with_participant(
|
||||
fake_transcript_with_topics, client
|
||||
authenticated_client, fake_transcript_with_topics, client
|
||||
):
|
||||
transcript_id = fake_transcript_with_topics.id
|
||||
|
||||
@@ -347,7 +351,9 @@ async def test_transcript_reassign_with_participant(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_reassign_edge_cases(fake_transcript_with_topics, client):
|
||||
async def test_transcript_reassign_edge_cases(
|
||||
authenticated_client, fake_transcript_with_topics, client
|
||||
):
|
||||
transcript_id = fake_transcript_with_topics.id
|
||||
|
||||
# check the transcript exists
|
||||
|
||||
@@ -62,7 +62,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
|
||||
|
||||
useEffect(() => {
|
||||
document.onkeyup = (e) => {
|
||||
if (e.key === "a" && process.env.NEXT_PUBLIC_ENV === "development") {
|
||||
if (e.key === "a" && process.env.NODE_ENV === "development") {
|
||||
const segments: GetTranscriptSegmentTopic[] = [
|
||||
{
|
||||
speaker: 1,
|
||||
@@ -201,7 +201,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
|
||||
|
||||
setFinalSummary({ summary: "This is the final summary" });
|
||||
}
|
||||
if (e.key === "z" && process.env.NEXT_PUBLIC_ENV === "development") {
|
||||
if (e.key === "z" && process.env.NODE_ENV === "development") {
|
||||
setTranscriptTextLive(
|
||||
"This text is in English, and it is a pretty long sentence to test the limits",
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user