Files
photoanalyzer/tests/integration/test_upload_preflight.py

514 lines
18 KiB
Python

"""Upload preflight: credentials, scope, readiness, and tokens (US05-01).
Every external boundary is faked but never mocked away: the uploader is a real
executable on disk invoked through ``subprocess``, and the Immich server is a real
localhost HTTP server answering ``/api/server/ping``. Preflight itself must stay
read-only — the library snapshot is asserted unchanged.
"""
import json
import stat
import threading
import uuid
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
import pytest
from fastapi.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,
RenameOperation,
RenamePlan,
SafetyReview,
)
from photo_pipeline.services.hashing import sha256_file
from photo_pipeline.services.uploads import UploadError, UploadService
pytestmark = pytest.mark.phase_e # part of the Phase E acceptance gate (US05-06)
NOW = datetime(2026, 1, 1, tzinfo=timezone.utc)
# A sentinel credential: every assertion below proves it never leaves configuration.
SENTINEL_KEY = "immich-sentinel-9f3a2b"
UPLOADER_VERSION = "immich-go 0.21.0"
# ── fake external boundary ───────────────────────────────────────────────────
class _PingHandler(BaseHTTPRequestHandler):
payload = b'{"res":"pong"}'
status = 200
def do_GET(self): # noqa: N802 (BaseHTTPRequestHandler API)
self.send_response(type(self).status)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(type(self).payload)
def log_message(self, *args):
pass # keep the test output clean
@pytest.fixture
def immich_server():
"""A real HTTP server that answers like Immich. Yields its base URL."""
handler = type("Handler", (_PingHandler,), {})
server = HTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
yield f"http://127.0.0.1:{server.server_port}", handler
server.shutdown()
server.server_close()
def _fake_uploader(tmp_path):
"""A real executable standing in for immich-go."""
path = tmp_path / "immich-go"
path.write_text(f"#!/bin/sh\necho '{UPLOADER_VERSION}'\n")
path.chmod(path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
return path
# ── environment ──────────────────────────────────────────────────────────────
def _env(tmp_path, server_url, *, credential=SENTINEL_KEY, uploader=None):
(tmp_path / "data").mkdir(exist_ok=True)
lib = tmp_path / "lib"
lib.mkdir(exist_ok=True)
env = {
"PHOTO_PIPELINE_DATA_DIR": str(tmp_path / "data"),
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib),
"PHOTO_PIPELINE_IMMICH_SERVER_URL": server_url,
"PHOTO_PIPELINE_IMMICH_GO_BINARY": str(
uploader if uploader is not None else _fake_uploader(tmp_path)
),
}
if credential:
env["PHOTO_PIPELINE_IMMICH_API_KEY"] = credential
config = Config.from_env(env)
run_migrations(config.database_url)
return config, create_session_factory(create_db_engine(config.database_url)), lib
def _album(
sf,
lib,
album="rome",
names=("a.jpg", "b.jpg"),
*,
decision="sfw",
exif_verified=True,
analyzed=True,
):
"""A real album folder with registered, stage-complete assets."""
folder = lib / album
folder.mkdir(parents=True, exist_ok=True)
ids = []
with sf() as session:
for name in names:
path = folder / name
path.write_bytes(name.encode() * 16)
asset_id = str(uuid.uuid4())
ids.append(asset_id)
session.add(
Asset(
id=asset_id,
original_path=str(path),
current_path=str(path),
discovered_at=NOW,
hash_version=1,
byte_size=path.stat().st_size,
current_sha256=sha256_file(path),
)
)
if decision is not None:
session.add(
SafetyReview(
id=str(uuid.uuid4()),
asset_id=asset_id,
decision=decision,
exif_verified_at=NOW if exif_verified else None,
)
)
if analyzed:
session.add(
AnalysisResult(
asset_id=asset_id,
status="analyzed",
exif_written_at=NOW,
)
)
session.commit()
return folder, ids
def _snapshot(lib):
return {
str(p.relative_to(lib)): (p.read_bytes() if p.is_file() else None)
for p in sorted(lib.rglob("*"))
}
def _service(sf, config):
return UploadService(sf, config=config)
def _codes(report):
return {issue["code"] for issue in report["blockers"]} | {
issue["code"] for album in report["albums"] for issue in album["blockers"]
} | {
issue["code"]
for album in report["albums"]
for asset in album["assets"]
for issue in asset["blockers"]
}
# ── happy path ───────────────────────────────────────────────────────────────
def test_ready_preflight_reports_scope_command_and_hashes(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
folder, ids = _album(sf, lib)
before = _snapshot(lib)
report = _service(sf, config).preflight()
assert report["state"] == "ready" and report["blockers"] == []
assert report["server"]["reachable"] is True
assert report["uploader"]["installed"] is True
assert report["uploader"]["version"] == UPLOADER_VERSION
assert report["credentials"]["api_key_configured"] is True
assert report["totals"] == {
"albums": 1,
"ready_albums": 1,
"assets": 2,
"eligible": 2,
"blocked": 0,
}
album = report["albums"][0]
assert album["album"] == "rome" and album["folder"] == str(folder)
assert album["album_name"] == "rome" # folder-as-album
assert album["partial"] is False and album["state"] == "ready"
assert sorted(a["asset_id"] for a in album["assets"]) == sorted(ids)
# Hashes are of the bytes on disk right now, not a remembered value.
for asset in album["assets"]:
assert asset["current_sha256"] == sha256_file(asset["current_path"])
assert report["token"].startswith("v1:")
assert _snapshot(lib) == before, "preflight must not touch the library"
def test_command_preview_shows_folder_and_album_without_the_key(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
folder, _ = _album(sf, lib)
preview = _service(sf, config).preflight()["albums"][0]["command_preview"]
assert f"--api-key={'***'}" in preview
assert "--album-name=rome" in preview
assert str(folder) in preview
assert SENTINEL_KEY not in " ".join(preview)
def test_scoping_to_one_album_excludes_the_others(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
_album(sf, lib, "rome")
_album(sf, lib, "paris", names=("c.jpg",))
report = _service(sf, config).preflight(["paris"])
assert [album["album"] for album in report["albums"]] == ["paris"]
assert report["totals"]["assets"] == 1
def test_unknown_album_in_scope_is_refused(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
_album(sf, lib, "rome")
with pytest.raises(UploadError):
_service(sf, config).preflight(["atlantis"])
def test_empty_scope_is_a_blocker(tmp_path, immich_server):
url, _ = immich_server
config, sf, _ = _env(tmp_path, url)
report = _service(sf, config).preflight()
assert report["state"] == "blocked" and "empty_scope" in _codes(report)
# ── credentials and environment ──────────────────────────────────────────────
def test_missing_api_key_blocks_without_touching_the_server(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url, credential=None)
_album(sf, lib)
report = _service(sf, config).preflight()
assert report["state"] == "blocked"
assert "credentials_missing" in _codes(report)
assert report["credentials"]["api_key_configured"] is False
def test_unreachable_server_blocks(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url.replace(url.rsplit(":", 1)[-1], "1"))
_album(sf, lib)
report = _service(sf, config).preflight()
assert "server_unreachable" in _codes(report)
assert report["server"]["reachable"] is False and report["server"]["detail"]
def test_server_that_is_not_immich_blocks(tmp_path, immich_server):
url, handler = immich_server
handler.payload = b'{"error":"unauthorized"}'
handler.status = 401
config, sf, lib = _env(tmp_path, url)
_album(sf, lib)
assert "server_unreachable" in _codes(_service(sf, config).preflight())
def test_missing_uploader_blocks(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url, uploader=tmp_path / "does-not-exist")
_album(sf, lib)
report = _service(sf, config).preflight()
assert "immich_go_missing" in _codes(report)
assert report["uploader"]["installed"] is False and report["uploader"]["version"] is None
def test_half_applied_rename_blocks_upload(tmp_path, immich_server):
"""Album paths must be final before upload: an operation that may have already
touched the disk blocks every other mutation until it is recovered (US04-04)."""
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
folder, _ = _album(sf, lib)
with sf() as session:
plan_id = str(uuid.uuid4())
session.add(RenamePlan(id=plan_id, state="applying", operation_count=1))
session.flush()
session.add(
RenameOperation(
id=str(uuid.uuid4()),
plan_id=plan_id,
sequence=0,
operation="move_folder",
source_path=str(folder),
destination_path=str(lib / "2019 Rome"),
journal_state="moving",
)
)
session.commit()
report = _service(sf, config).preflight()
assert report["state"] == "blocked" and "rename_pending" in _codes(report)
def test_no_secret_appears_anywhere_in_the_report(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
_album(sf, lib)
report = _service(sf, config).preflight()
assert SENTINEL_KEY not in json.dumps(report, default=str)
# ── stage readiness ──────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"kwargs,code",
[
({"decision": None}, "safety_undecided"),
({"decision": "deferred"}, "safety_deferred"),
({"exif_verified": False}, "safety_exif_unverified"),
({"analyzed": False}, "analysis_incomplete"),
],
)
def test_blocked_stage_blocks_upload(tmp_path, immich_server, kwargs, code):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
_album(sf, lib, **kwargs)
report = _service(sf, config).preflight()
assert report["state"] == "blocked"
assert code in _codes(report)
assert report["albums"][0]["eligible_count"] == 0
def test_reviewed_nsfw_asset_is_upload_eligible_without_analysis(tmp_path, immich_server):
"""NSFW never reaches the analyser, but a verified nsfw keyword makes it
uploadable (concept §8 eligibility table)."""
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
_album(sf, lib, decision="nsfw", analyzed=False)
report = _service(sf, config).preflight()
assert report["state"] == "ready"
assert report["albums"][0]["eligible_count"] == 2
def test_changed_bytes_block_the_album(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
folder, _ = _album(sf, lib)
(folder / "a.jpg").write_bytes(b"edited after the checkpoint")
report = _service(sf, config).preflight()
assert "bytes_changed" in _codes(report)
assert report["albums"][0]["blocked_count"] == 1
def test_missing_file_blocks_the_album(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
folder, _ = _album(sf, lib)
(folder / "a.jpg").unlink()
assert "file_missing" in _codes(_service(sf, config).preflight())
# ── partial scope ────────────────────────────────────────────────────────────
def test_partial_album_is_blocked_until_explicitly_approved(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
folder, _ = _album(sf, lib)
(folder / "a.jpg").write_bytes(b"changed")
blocked = _service(sf, config).preflight()
approved = _service(sf, config).preflight(allow_partial=True)
assert blocked["state"] == "blocked"
assert "partial_scope" in {issue["code"] for issue in blocked["albums"][0]["blockers"]}
assert approved["state"] == "ready"
assert approved["albums"][0]["partial"] is True
assert approved["albums"][0]["eligible_count"] == 1
# The approval is part of the token, so it can never be replayed as a full run.
assert approved["token"] != blocked["token"]
def test_partial_approval_cannot_rescue_an_album_with_nothing_ready(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
_album(sf, lib, decision=None)
report = _service(sf, config).preflight(allow_partial=True)
assert report["state"] == "blocked"
assert "empty_scope" in {issue["code"] for issue in report["albums"][0]["blockers"]}
# ── token ────────────────────────────────────────────────────────────────────
def test_token_is_stable_while_nothing_relevant_changes(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
_album(sf, lib)
service = _service(sf, config)
first = service.preflight()["token"]
assert service.preflight()["token"] == first
assert service.verify_token(first) is True
@pytest.mark.parametrize("scope", [None, ["rome"]])
def test_edited_bytes_make_the_token_stale(tmp_path, immich_server, scope):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
folder, _ = _album(sf, lib)
service = _service(sf, config)
token = service.preflight(scope)["token"]
(folder / "b.jpg").write_bytes(b"edited outside the app")
assert service.verify_token(token, scope) is False
def test_changed_decision_makes_the_token_stale(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
_, ids = _album(sf, lib)
service = _service(sf, config)
token = service.preflight()["token"]
with sf() as session:
session.add(
SafetyReview(
id=str(uuid.uuid4()),
asset_id=ids[0],
decision="deferred",
created_at=datetime(2099, 1, 1, tzinfo=timezone.utc), # the latest review wins
)
)
session.commit()
assert service.verify_token(token) is False
def test_token_from_a_different_scope_is_rejected(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
_album(sf, lib, "rome")
_album(sf, lib, "paris", names=("c.jpg",))
service = _service(sf, config)
assert service.verify_token(service.preflight(["rome"])["token"], ["paris"]) is False
assert service.verify_token("v1:not-a-real-token") is False
assert service.verify_token("") is False
# ── API surface ──────────────────────────────────────────────────────────────
def test_api_preflight_returns_the_report_without_secrets(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
_album(sf, lib)
with TestClient(create_app(config)) as client:
response = client.post("/api/v1/upload-preflight", json={})
assert response.status_code == 200
body = response.json()
assert body["state"] == "ready" and body["token"].startswith("v1:")
assert SENTINEL_KEY not in response.text
def test_api_rejects_an_unknown_album(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
_album(sf, lib)
with TestClient(create_app(config)) as client:
response = client.post("/api/v1/upload-preflight", json={"albums": ["atlantis"]})
assert response.status_code == 422
assert response.json()["error"]["code"] == "unknown_album"