feat: send email in share transcript and add email sending in room (#924)

* fix: add source language for file pipeline

* feat: send email in share transcript and add email sending in room

* fix: hide audio and video streaming for unauthenticated users

* fix: security order
This commit is contained in:
Juan Diego García
2026-03-24 17:17:52 -05:00
committed by GitHub
parent 74b9b97453
commit e2ba502697
28 changed files with 861 additions and 174 deletions

View File

@@ -137,6 +137,7 @@ async def mock_storage():
operation: str = "get_object",
expires_in: int = 3600,
bucket=None,
extra_params=None,
):
return f"http://test-storage/{path}"

View File

@@ -373,9 +373,9 @@ async def test_audio_mp3_requires_token_for_owned_transcript(
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
# Anonymous GET without token should be 401 (auth required)
resp = await client.get(f"/transcripts/{t.id}/audio/mp3")
assert resp.status_code == 403
assert resp.status_code == 401
# With token should succeed
token = create_access_token(
@@ -898,7 +898,7 @@ async def test_anonymous_transcript_in_list_when_public_mode(client, monkeypatch
@pytest.mark.asyncio
async def test_anonymous_transcript_audio_accessible(client, monkeypatch, tmpdir):
"""Anonymous transcript audio (mp3) is accessible without authentication
because user_id=None bypasses share_mode checks."""
because user_id=None bypasses the auth requirement (pipeline access)."""
monkeypatch.setattr(settings, "PUBLIC_MODE", True)
monkeypatch.setattr(settings, "DATA_DIR", Path(tmpdir).as_posix())
@@ -920,7 +920,7 @@ async def test_anonymous_transcript_audio_accessible(client, monkeypatch, tmpdir
resp = await client.get(f"/transcripts/{t.id}/audio/mp3")
assert (
resp.status_code == 200
), f"Anonymous transcript audio should be accessible: {resp.text}"
), f"Anonymous transcript audio should be accessible for pipeline: {resp.text}"
@pytest.mark.asyncio

View File

@@ -40,7 +40,7 @@ async def fake_transcript(tmpdir, client, monkeypatch):
],
)
async def test_transcript_audio_download(
fake_transcript, url_suffix, content_type, client
authenticated_client, fake_transcript, url_suffix, content_type, client
):
response = await client.get(f"/transcripts/{fake_transcript.id}/audio{url_suffix}")
assert response.status_code == 200
@@ -61,7 +61,7 @@ async def test_transcript_audio_download(
],
)
async def test_transcript_audio_download_head(
fake_transcript, url_suffix, content_type, client
authenticated_client, fake_transcript, url_suffix, content_type, client
):
response = await client.head(f"/transcripts/{fake_transcript.id}/audio{url_suffix}")
assert response.status_code == 200
@@ -82,7 +82,7 @@ async def test_transcript_audio_download_head(
],
)
async def test_transcript_audio_download_range(
fake_transcript, url_suffix, content_type, client
authenticated_client, fake_transcript, url_suffix, content_type, client
):
response = await client.get(
f"/transcripts/{fake_transcript.id}/audio{url_suffix}",
@@ -102,7 +102,7 @@ async def test_transcript_audio_download_range(
],
)
async def test_transcript_audio_download_range_with_seek(
fake_transcript, url_suffix, content_type, client
authenticated_client, fake_transcript, url_suffix, content_type, client
):
response = await client.get(
f"/transcripts/{fake_transcript.id}/audio{url_suffix}",

View File

@@ -98,10 +98,10 @@ async def private_transcript(tmpdir):
@pytest.mark.asyncio
async def test_audio_mp3_private_no_auth_returns_403(private_transcript, client):
"""Without auth, accessing a private transcript's audio returns 403."""
async def test_audio_mp3_private_no_auth_returns_401(private_transcript, client):
"""Without auth, accessing a private transcript's audio returns 401."""
response = await client.get(f"/transcripts/{private_transcript.id}/audio/mp3")
assert response.status_code == 403
assert response.status_code == 401
@pytest.mark.asyncio
@@ -125,8 +125,8 @@ async def test_audio_mp3_with_bearer_header(private_transcript, client):
@pytest.mark.asyncio
async def test_audio_mp3_public_transcript_no_auth_ok(tmpdir, client):
"""Public transcripts are accessible without any auth."""
async def test_audio_mp3_public_transcript_no_auth_returns_401(tmpdir, client):
"""Public transcripts require authentication for audio access."""
from reflector.db.transcripts import SourceKind, transcripts_controller
from reflector.settings import settings
@@ -146,8 +146,7 @@ async def test_audio_mp3_public_transcript_no_auth_ok(tmpdir, client):
shutil.copy(mp3_source, audio_filename)
response = await client.get(f"/transcripts/{transcript.id}/audio/mp3")
assert response.status_code == 200
assert response.headers["content-type"] == "audio/mpeg"
assert response.status_code == 401
# ---------------------------------------------------------------------------
@@ -299,11 +298,9 @@ async def test_local_audio_link_token_works_with_authentik_backend(
"""_generate_local_audio_link creates an HS256 token via create_access_token.
When the Authentik (RS256) auth backend is active, verify_raw_token uses
JWTAuth which expects RS256 + public key. The HS256 token created by
_generate_local_audio_link will fail verification, returning 401.
This test documents the bug: the internal audio URL generated for the
diarization pipeline is unusable under the JWT auth backend.
JWTAuth which expects RS256 + public key. The HS256 token fails RS256
verification, but the audio endpoint's HS256 fallback (jwt.decode with
SECRET_KEY) correctly handles it, so the request succeeds with 200.
"""
from urllib.parse import parse_qs, urlparse
@@ -322,6 +319,55 @@ async def test_local_audio_link_token_works_with_authentik_backend(
f"/transcripts/{private_transcript.id}/audio/mp3?token={token}"
)
# BUG: this should be 200 (the token was created by our own server),
# but the Authentik backend rejects it because it's HS256, not RS256.
# The HS256 fallback in the audio endpoint handles this correctly.
assert response.status_code == 200
# ---------------------------------------------------------------------------
# Waveform endpoint auth tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_waveform_requires_authentication(client):
"""Waveform endpoint returns 401 for unauthenticated requests."""
response = await client.get("/transcripts/any-id/audio/waveform")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_audio_mp3_authenticated_user_accesses_anonymous_transcript(
tmpdir, client
):
"""Authenticated user can access audio for an anonymous (user_id=None) transcript."""
from reflector.app import app
from reflector.auth import current_user, current_user_optional
from reflector.db.transcripts import SourceKind, transcripts_controller
from reflector.settings import settings
settings.DATA_DIR = Path(tmpdir)
transcript = await transcripts_controller.add(
"Anonymous audio test",
source_kind=SourceKind.FILE,
user_id=None,
share_mode="private",
)
await transcripts_controller.update(transcript, {"status": "ended"})
audio_filename = transcript.audio_mp3_filename
mp3_source = Path(__file__).parent / "records" / "test_mathieu_hello.mp3"
audio_filename.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(mp3_source, audio_filename)
_user = lambda: {"sub": "some-authenticated-user", "email": "user@example.com"}
app.dependency_overrides[current_user] = _user
app.dependency_overrides[current_user_optional] = _user
try:
response = await client.get(f"/transcripts/{transcript.id}/audio/mp3")
finally:
del app.dependency_overrides[current_user]
del app.dependency_overrides[current_user_optional]
assert response.status_code == 200
assert response.headers["content-type"] == "audio/mpeg"

View File

@@ -103,3 +103,162 @@ async def test_transcript_get_includes_video_fields(authenticated_client, client
data = response.json()
assert data["has_cloud_video"] is False
assert data["cloud_video_duration"] is None
@pytest.mark.asyncio
async def test_video_url_requires_authentication(client):
"""Test that video URL endpoint returns 401 for unauthenticated requests."""
response = await client.get("/transcripts/any-id/video/url")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_video_url_presigned_params(authenticated_client, client):
"""Test that presigned URL is generated with short expiry and inline disposition."""
from reflector.db import get_database
from reflector.db.meetings import meetings
meeting_id = "test-meeting-presigned-params"
await get_database().execute(
meetings.insert().values(
id=meeting_id,
room_name="Presigned Params Meeting",
room_url="https://example.com",
host_room_url="https://example.com/host",
start_date=datetime.now(timezone.utc),
end_date=datetime.now(timezone.utc) + timedelta(hours=1),
room_id=None,
daily_composed_video_s3_key="recordings/video.mp4",
daily_composed_video_duration=60,
)
)
transcript = await transcripts_controller.add(
name="presigned-params",
source_kind=SourceKind.ROOM,
meeting_id=meeting_id,
user_id="randomuserid",
)
with patch("reflector.views.transcripts_video.get_source_storage") as mock_storage:
mock_instance = AsyncMock()
mock_instance.get_file_url = AsyncMock(
return_value="https://s3.example.com/presigned-url"
)
mock_storage.return_value = mock_instance
await client.get(f"/transcripts/{transcript.id}/video/url")
mock_instance.get_file_url.assert_called_once_with(
"recordings/video.mp4",
operation="get_object",
expires_in=900,
extra_params={
"ResponseContentDisposition": "inline",
"ResponseContentType": "video/mp4",
},
)
async def _create_meeting_with_video(meeting_id):
"""Helper to create a meeting with cloud video."""
from reflector.db import get_database
from reflector.db.meetings import meetings
await get_database().execute(
meetings.insert().values(
id=meeting_id,
room_name="Video Meeting",
room_url="https://example.com",
host_room_url="https://example.com/host",
start_date=datetime.now(timezone.utc),
end_date=datetime.now(timezone.utc) + timedelta(hours=1),
room_id=None,
daily_composed_video_s3_key="recordings/video.mp4",
daily_composed_video_duration=60,
)
)
@pytest.mark.asyncio
async def test_video_url_private_transcript_denies_non_owner(
authenticated_client, client
):
"""Authenticated non-owner cannot access video for a private transcript."""
meeting_id = "test-meeting-private-deny"
await _create_meeting_with_video(meeting_id)
transcript = await transcripts_controller.add(
name="private-video",
source_kind=SourceKind.ROOM,
meeting_id=meeting_id,
user_id="other-owner",
share_mode="private",
)
with patch("reflector.views.transcripts_video.get_source_storage") as mock_storage:
mock_instance = AsyncMock()
mock_instance.get_file_url = AsyncMock(
return_value="https://s3.example.com/url"
)
mock_storage.return_value = mock_instance
response = await client.get(f"/transcripts/{transcript.id}/video/url")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_video_url_public_transcript_allows_authenticated_non_owner(
authenticated_client, client
):
"""Authenticated non-owner can access video for a public transcript."""
meeting_id = "test-meeting-public-allow"
await _create_meeting_with_video(meeting_id)
transcript = await transcripts_controller.add(
name="public-video",
source_kind=SourceKind.ROOM,
meeting_id=meeting_id,
user_id="other-owner",
share_mode="public",
)
with patch("reflector.views.transcripts_video.get_source_storage") as mock_storage:
mock_instance = AsyncMock()
mock_instance.get_file_url = AsyncMock(
return_value="https://s3.example.com/url"
)
mock_storage.return_value = mock_instance
response = await client.get(f"/transcripts/{transcript.id}/video/url")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_video_url_semi_private_allows_authenticated_non_owner(
authenticated_client, client
):
"""Authenticated non-owner can access video for a semi-private transcript."""
meeting_id = "test-meeting-semi-private-allow"
await _create_meeting_with_video(meeting_id)
transcript = await transcripts_controller.add(
name="semi-private-video",
source_kind=SourceKind.ROOM,
meeting_id=meeting_id,
user_id="other-owner",
share_mode="semi-private",
)
with patch("reflector.views.transcripts_video.get_source_storage") as mock_storage:
mock_instance = AsyncMock()
mock_instance.get_file_url = AsyncMock(
return_value="https://s3.example.com/url"
)
mock_storage.return_value = mock_instance
response = await client.get(f"/transcripts/{transcript.id}/video/url")
assert response.status_code == 200