512 lines
18 KiB
Python
512 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
|
|
|
|
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"
|