Files
lfnovo--open-notebook/tests/test_error_message_sanitization.py
2026-07-13 12:10:23 +08:00

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"