"""US07-02: what an unhandled failure says, and where the library ends. Two boundaries that only show up below the HTTP surface: * Exception text is where internals leak — absolute paths, SQL, and occasionally a credential passed to the call that blew up. The first tests drive the real application with a route that raises such an exception (no production route does) and assert the client sees only a code. * The path the database recorded is not the path the filesystem will open a moment later. Analysis is the one stage whose bytes leave this machine, so it resolves the source against the library roots immediately before the provider call. """ from __future__ import annotations import logging import uuid from datetime import datetime, timezone import pytest from PIL import Image from starlette.testclient import TestClient from photo_pipeline.api.app import create_app from photo_pipeline.config import Config from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations from photo_pipeline.models import AnalysisResult, Asset from photo_pipeline.services.analysis import AnalysisService from photo_pipeline.services.safety import SafetyService BOOM = "sqlite:///Users/someone/Pictures/private.db failed with key sk-secret-123" @pytest.fixture def client(tmp_path): app = create_app(Config(data_dir=tmp_path / "data")) @app.get("/api/v1/boom") def boom(): raise RuntimeError(BOOM) with TestClient(app, raise_server_exceptions=False) as test_client: yield test_client def test_an_unhandled_error_returns_a_bare_envelope(client, caplog): with caplog.at_level(logging.ERROR): response = client.get("/api/v1/boom") assert response.status_code == 500 assert response.json() == {"error": {"code": "internal_error", "message": "internal error"}} assert BOOM not in response.text and "Traceback" not in response.text # The operator still gets the whole story, on the server side. assert BOOM in caplog.text def test_a_refusal_response_still_carries_the_default_headers(client): """A 500 escaping the middleware's response path would also escape its headers.""" response = client.get("/api/v1/boom") assert response.headers["x-content-type-options"] == "nosniff" assert response.headers["x-frame-options"] == "DENY" # ── the library boundary, revalidated at the moment of use ─────────────────── class RecordingProvider: def __init__(self): self.calls = [] def analyze(self, path, *, album_hint): self.calls.append(path) return {"description": "a photo", "tags": []} def _library(tmp_path): lib = tmp_path / "lib" lib.mkdir() outside = tmp_path / "outside" outside.mkdir() for path in (lib / "inside.jpg", outside / "private.jpg"): Image.new("RGB", (8, 8), "blue").save(path) (tmp_path / "data").mkdir() config = Config.from_env({"PHOTO_PIPELINE_DATA_DIR": str(tmp_path / "data")}) run_migrations(config.database_url) return lib, outside, create_session_factory(create_db_engine(config.database_url)) def _sfw_asset(sf, path): asset = Asset( id=str(uuid.uuid4()), original_path=str(path), current_path=str(path), discovered_at=datetime.now(timezone.utc), hash_version=1, ) with sf() as session: session.add(asset) session.commit() SafetyService(sf).decide(asset.id, "sfw", write_exif=False) return asset.id def test_analysis_will_not_send_a_file_that_left_the_library(tmp_path): """A link swapped under an asset after the scan points at something the user never put in the library. Those bytes must not reach the vision provider — it is the one place in the pipeline where content leaves this machine.""" lib, outside, sf = _library(tmp_path) inside = lib / "inside.jpg" asset_id = _sfw_asset(sf, inside) inside.unlink() inside.symlink_to(outside / "private.jpg") provider = RecordingProvider() result = AnalysisService(sf, provider=provider, library_roots=(lib,)).run([asset_id]) assert provider.calls == [], "the provider must never have been constructed a request" assert result == {"analyzed": 0, "skipped": 0, "errors": 1} with sf() as session: row = session.get(AnalysisResult, asset_id) # The failure is visible and names no path. assert row.status == "error" assert "outside the configured library roots" in row.error_message assert str(outside) not in row.error_message def test_analysis_still_reads_a_file_that_stayed_inside(tmp_path): """The guard must resolve real paths, not refuse everything.""" lib, _, sf = _library(tmp_path) asset_id = _sfw_asset(sf, lib / "inside.jpg") provider = RecordingProvider() result = AnalysisService(sf, provider=provider, library_roots=(lib,)).run([asset_id]) assert result["analyzed"] == 1 assert provider.calls == [str((lib / "inside.jpg").resolve())]