183 lines
6.7 KiB
Python
183 lines
6.7 KiB
Python
"""
|
|
Tests that internal exception text is never leaked to API clients in
|
|
api/routers/sources.py and api/podcast_service.py.
|
|
|
|
These previously interpolated the raw exception (`detail=f"...: {str(e)}"`)
|
|
into the client-facing error response - inconsistent with the safer pattern
|
|
already used elsewhere in the same files (e.g. the download handlers),
|
|
which log the raw exception server-side but return a fixed generic
|
|
message. Every occurrence already had a matching logger.error() call, so
|
|
this was a client-facing message change only, not a logging change.
|
|
"""
|
|
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
SECRET = "connection to db-primary-7f3a.internal:5432 refused: password authentication failed"
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
from api.main import app
|
|
|
|
return TestClient(app)
|
|
|
|
|
|
class TestSourcesRouterDoesNotLeakExceptionText:
|
|
def test_list_sources_failure_returns_generic_message(self, client):
|
|
with patch(
|
|
"api.routers.sources.repo_query",
|
|
new=AsyncMock(side_effect=RuntimeError(SECRET)),
|
|
):
|
|
response = client.get("/api/sources")
|
|
|
|
assert response.status_code == 500
|
|
assert SECRET not in response.text
|
|
assert response.json()["detail"] == "Error fetching sources"
|
|
|
|
def test_delete_source_failure_returns_generic_message(self, client):
|
|
mock_source = AsyncMock()
|
|
mock_source.delete = AsyncMock(side_effect=RuntimeError(SECRET))
|
|
|
|
with patch(
|
|
"api.routers.sources.Source.get", new=AsyncMock(return_value=mock_source)
|
|
):
|
|
response = client.delete("/api/sources/source:abc123")
|
|
|
|
assert response.status_code == 500
|
|
assert SECRET not in response.text
|
|
assert response.json()["detail"] == "Error deleting source"
|
|
|
|
def test_get_source_insights_failure_returns_generic_message(self, client):
|
|
mock_source = AsyncMock()
|
|
mock_source.get_insights = AsyncMock(side_effect=RuntimeError(SECRET))
|
|
|
|
with patch(
|
|
"api.routers.sources.Source.get", new=AsyncMock(return_value=mock_source)
|
|
):
|
|
response = client.get("/api/sources/source:abc123/insights")
|
|
|
|
assert response.status_code == 500
|
|
assert SECRET not in response.text
|
|
assert response.json()["detail"] == "Error fetching insights"
|
|
|
|
def test_update_source_failure_returns_generic_message(self, client):
|
|
mock_source = AsyncMock()
|
|
mock_source.save = AsyncMock(side_effect=RuntimeError(SECRET))
|
|
mock_source.asset = None
|
|
|
|
with patch(
|
|
"api.routers.sources.Source.get", new=AsyncMock(return_value=mock_source)
|
|
):
|
|
response = client.put(
|
|
"/api/sources/source:abc123", json={"title": "New Title"}
|
|
)
|
|
|
|
assert response.status_code == 500
|
|
assert SECRET not in response.text
|
|
assert response.json()["detail"] == "Error updating source"
|
|
|
|
|
|
class TestInvalidInputErrorsStillReturnTheirOwnSafeMessage:
|
|
"""InvalidInputError is a distinct, deliberate pattern (app-authored,
|
|
user-facing validation text) - not the raw-exception-leak pattern this
|
|
fix targets, and must be untouched."""
|
|
|
|
def test_update_source_invalid_input_still_returns_its_message(self, client):
|
|
from open_notebook.exceptions import InvalidInputError
|
|
|
|
mock_source = AsyncMock()
|
|
mock_source.save = AsyncMock(
|
|
side_effect=InvalidInputError("Title cannot be empty")
|
|
)
|
|
mock_source.asset = None
|
|
|
|
with patch(
|
|
"api.routers.sources.Source.get", new=AsyncMock(return_value=mock_source)
|
|
):
|
|
response = client.put(
|
|
"/api/sources/source:abc123", json={"title": "x"}
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"] == "Title cannot be empty"
|
|
|
|
|
|
class TestPodcastServiceDoesNotLeakExceptionText:
|
|
@pytest.mark.asyncio
|
|
async def test_get_job_status_failure_returns_generic_message(self):
|
|
from fastapi import HTTPException
|
|
|
|
from api.podcast_service import PodcastService
|
|
|
|
with patch(
|
|
"api.podcast_service.get_command_status",
|
|
new=AsyncMock(side_effect=RuntimeError(SECRET)),
|
|
):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await PodcastService.get_job_status("command:abc123")
|
|
|
|
assert exc_info.value.status_code == 500
|
|
assert SECRET not in exc_info.value.detail
|
|
assert exc_info.value.detail == "Failed to get job status"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_episodes_failure_returns_generic_message(self):
|
|
from fastapi import HTTPException
|
|
|
|
from api.podcast_service import PodcastService
|
|
from open_notebook.podcasts.models import PodcastEpisode
|
|
|
|
with patch.object(
|
|
PodcastEpisode,
|
|
"get_all",
|
|
new=AsyncMock(side_effect=RuntimeError(SECRET)),
|
|
):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await PodcastService.list_episodes()
|
|
|
|
assert exc_info.value.status_code == 500
|
|
assert SECRET not in exc_info.value.detail
|
|
assert exc_info.value.detail == "Failed to list episodes"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_episode_failure_returns_generic_not_found(self):
|
|
from fastapi import HTTPException
|
|
|
|
from api.podcast_service import PodcastService
|
|
from open_notebook.podcasts.models import PodcastEpisode
|
|
|
|
with patch.object(
|
|
PodcastEpisode, "get", new=AsyncMock(side_effect=RuntimeError(SECRET))
|
|
):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await PodcastService.get_episode("episode:missing")
|
|
|
|
assert exc_info.value.status_code == 404
|
|
assert SECRET not in exc_info.value.detail
|
|
assert exc_info.value.detail == "Episode not found"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_generation_job_failure_returns_generic_message(self):
|
|
from fastapi import HTTPException
|
|
|
|
from api.podcast_service import PodcastService
|
|
|
|
with patch(
|
|
"api.podcast_service.EpisodeProfile.get_by_name",
|
|
new=AsyncMock(side_effect=RuntimeError(SECRET)),
|
|
):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await PodcastService.submit_generation_job(
|
|
episode_profile_name="default",
|
|
speaker_profile_name="default",
|
|
episode_name="Test Episode",
|
|
content="some content",
|
|
)
|
|
|
|
assert exc_info.value.status_code == 500
|
|
assert SECRET not in exc_info.value.detail
|
|
assert exc_info.value.detail == "Failed to submit podcast generation job"
|