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]
|
[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"
|
||||||
|
|||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -11,13 +11,20 @@ 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",
|
||||||
}
|
}
|
||||||
|
try:
|
||||||
yield client
|
yield client
|
||||||
|
finally:
|
||||||
|
del app.dependency_overrides[current_user]
|
||||||
del app.dependency_overrides[current_user_optional]
|
del app.dependency_overrides[current_user_optional]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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
|
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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"] == []
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user