137 lines
4.9 KiB
Python
137 lines
4.9 KiB
Python
"""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())]
|