chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
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"
|
||||
Reference in New Issue
Block a user