"""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"