US05-01: Validate Credentials and Upload Readiness
This commit is contained in:
511
tests/integration/test_upload_preflight.py
Normal file
511
tests/integration/test_upload_preflight.py
Normal file
@@ -0,0 +1,511 @@
|
||||
"""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"
|
||||
@@ -103,6 +103,9 @@
|
||||
],
|
||||
"US04-06": [
|
||||
"tests/e2e/test_phase_d_pipeline.py"
|
||||
],
|
||||
"US05-01": [
|
||||
"tests/integration/test_upload_preflight.py"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user