Compare commits
1 Commits
us/US07-06
...
us/US05-01
| Author | SHA1 | Date | |
|---|---|---|---|
| c7ddb253a9 |
@@ -25,6 +25,7 @@ from photo_pipeline.api.routes import (
|
|||||||
renames,
|
renames,
|
||||||
safety,
|
safety,
|
||||||
thumbnails,
|
thumbnails,
|
||||||
|
uploads,
|
||||||
workflow,
|
workflow,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ def create_app(config: Config | None = None) -> FastAPI:
|
|||||||
app.include_router(library.router, prefix="/api/v1")
|
app.include_router(library.router, prefix="/api/v1")
|
||||||
app.include_router(albums.router, prefix="/api/v1")
|
app.include_router(albums.router, prefix="/api/v1")
|
||||||
app.include_router(renames.router, prefix="/api/v1")
|
app.include_router(renames.router, prefix="/api/v1")
|
||||||
|
app.include_router(uploads.router, prefix="/api/v1")
|
||||||
# Static single-page app (hash-routed). Mounted last so /api/v1 wins.
|
# Static single-page app (hash-routed). Mounted last so /api/v1 wins.
|
||||||
if FRONTEND_DIR.is_dir():
|
if FRONTEND_DIR.is_dir():
|
||||||
app.mount("/app", StaticFiles(directory=FRONTEND_DIR, html=True), name="app")
|
app.mount("/app", StaticFiles(directory=FRONTEND_DIR, html=True), name="app")
|
||||||
|
|||||||
39
photo_pipeline/api/routes/uploads.py
Normal file
39
photo_pipeline/api/routes/uploads.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Upload preflight API (US05-01).
|
||||||
|
|
||||||
|
Preflight is a command, not a resource read: it contacts the Immich server, hashes
|
||||||
|
the current bytes, and issues a token. It uploads nothing — starting a batch is
|
||||||
|
US05-02, so there is deliberately no start endpoint here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from photo_pipeline.services.uploads import UploadError, UploadService
|
||||||
|
|
||||||
|
router = APIRouter(tags=["uploads"])
|
||||||
|
|
||||||
|
|
||||||
|
class PreflightRequest(BaseModel):
|
||||||
|
# ``None`` means every album; an explicit list scopes the check.
|
||||||
|
albums: list[str] | None = None
|
||||||
|
# Approving a partial upload is an explicit act, never a default.
|
||||||
|
allow_partial: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _service(request: Request) -> UploadService:
|
||||||
|
return UploadService(request.app.state.session_factory, config=request.app.state.config)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload-preflight")
|
||||||
|
def preflight(request: Request, body: PreflightRequest | None = None):
|
||||||
|
body = body or PreflightRequest()
|
||||||
|
try:
|
||||||
|
return _service(request).preflight(body.albums, allow_partial=body.allow_partial)
|
||||||
|
except UploadError as error:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=422,
|
||||||
|
content={"error": {"code": "unknown_album", "message": str(error)}},
|
||||||
|
)
|
||||||
@@ -37,6 +37,8 @@ class Config(BaseModel):
|
|||||||
|
|
||||||
vision_api_key: SecretStr | None = None
|
vision_api_key: SecretStr | None = None
|
||||||
immich_api_key: SecretStr | None = None
|
immich_api_key: SecretStr | None = None
|
||||||
|
immich_server_url: str = ""
|
||||||
|
immich_go_binary: str = "immich-go"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def database_path(self) -> Path:
|
def database_path(self) -> Path:
|
||||||
|
|||||||
107
photo_pipeline/integrations/immich_go.py
Normal file
107
photo_pipeline/integrations/immich_go.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""immich-go adapter: binary discovery, version, and redacted command preview.
|
||||||
|
|
||||||
|
Upload is the only stage that needs credentials, so this module is also the single
|
||||||
|
place that knows an API key exists. It never returns, logs, or renders the secret:
|
||||||
|
:func:`build_command` produces the real argument list for the uploader, and
|
||||||
|
:func:`redact` produces the copy that is safe for the API, the browser, and the
|
||||||
|
activity log. Both come from the same builder so the preview can never drift from
|
||||||
|
the command that would actually run.
|
||||||
|
|
||||||
|
Server reachability uses ``/api/server/ping`` through stdlib ``urllib`` — the app
|
||||||
|
has no HTTP client dependency and this is one request.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REDACTED = "***"
|
||||||
|
PING_PATH = "/api/server/ping"
|
||||||
|
PING_TIMEOUT_SECONDS = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def find_binary(binary: str = "immich-go") -> str | None:
|
||||||
|
"""Absolute path of the uploader, or ``None`` when it is not installed."""
|
||||||
|
return shutil.which(binary)
|
||||||
|
|
||||||
|
|
||||||
|
def version(binary: str = "immich-go") -> str | None:
|
||||||
|
"""Reported uploader version, or ``None`` when it is missing or unusable.
|
||||||
|
|
||||||
|
The version is persisted with every batch (concept §8) because immich-go's
|
||||||
|
flags and report text change between releases.
|
||||||
|
"""
|
||||||
|
path = find_binary(binary)
|
||||||
|
if path is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
result = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=30)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return None
|
||||||
|
output = (result.stdout or result.stderr or "").strip()
|
||||||
|
return output.splitlines()[0].strip() if output else None
|
||||||
|
|
||||||
|
|
||||||
|
def ping(server_url: str, *, timeout: float = PING_TIMEOUT_SECONDS) -> tuple[bool, str | None]:
|
||||||
|
"""``(reachable, detail)`` for the configured Immich server.
|
||||||
|
|
||||||
|
A reachable Immich answers ``{"res": "pong"}``. Anything else — wrong host, no
|
||||||
|
Immich, HTTP error — is a blocker with a short human detail. The detail never
|
||||||
|
carries the URL's credentials because the key travels in a header, not the URL.
|
||||||
|
"""
|
||||||
|
if not server_url:
|
||||||
|
return False, "no server URL configured"
|
||||||
|
url = server_url.rstrip("/") + PING_PATH
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=timeout) as response: # noqa: S310
|
||||||
|
payload = json.loads(response.read().decode("utf-8") or "{}")
|
||||||
|
except (urllib.error.URLError, OSError, ValueError, TimeoutError) as error:
|
||||||
|
return False, f"{type(error).__name__}: {error}"
|
||||||
|
if payload.get("res") == "pong":
|
||||||
|
return True, None
|
||||||
|
return False, "server did not answer with pong"
|
||||||
|
|
||||||
|
|
||||||
|
def build_command(
|
||||||
|
*,
|
||||||
|
binary: str,
|
||||||
|
server_url: str,
|
||||||
|
api_key: str,
|
||||||
|
album_name: str,
|
||||||
|
folder: Path | str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""The exact upload invocation for one folder-as-album batch."""
|
||||||
|
return [
|
||||||
|
binary,
|
||||||
|
"upload",
|
||||||
|
"from-folder",
|
||||||
|
f"--server={server_url}",
|
||||||
|
f"--api-key={api_key}",
|
||||||
|
f"--album-name={album_name}",
|
||||||
|
str(folder),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def redact(command: list[str]) -> list[str]:
|
||||||
|
"""The same command with every secret-bearing argument masked."""
|
||||||
|
return [f"--api-key={REDACTED}" if arg.startswith("--api-key=") else arg for arg in command]
|
||||||
|
|
||||||
|
|
||||||
|
def preview_command(
|
||||||
|
*, binary: str, server_url: str, album_name: str, folder: Path | str
|
||||||
|
) -> list[str]:
|
||||||
|
"""Redacted preview built without ever handling the real key."""
|
||||||
|
return redact(
|
||||||
|
build_command(
|
||||||
|
binary=binary,
|
||||||
|
server_url=server_url,
|
||||||
|
api_key=REDACTED,
|
||||||
|
album_name=album_name,
|
||||||
|
folder=folder,
|
||||||
|
)
|
||||||
|
)
|
||||||
306
photo_pipeline/services/uploads.py
Normal file
306
photo_pipeline/services/uploads.py
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
"""UploadService — preflight for Immich upload (US05-01).
|
||||||
|
|
||||||
|
Upload is the first stage that sends the library somewhere the app cannot take it
|
||||||
|
back from, so nothing here uploads: preflight only *proves* a scope is safe and
|
||||||
|
issues a token that a later start command must present (US05-02).
|
||||||
|
|
||||||
|
What it proves (concept §8 "preflight"):
|
||||||
|
|
||||||
|
- credentials are configured and the Immich server answers, without ever putting
|
||||||
|
the API key in a response, a preview, or a log line;
|
||||||
|
- ``immich-go`` is installed and its version is recorded;
|
||||||
|
- no rename is half-applied — the journal must not block library mutation;
|
||||||
|
- every asset in scope is canonical, present, and decided ``sfw``/``nsfw`` with a
|
||||||
|
verified safety EXIF checkpoint;
|
||||||
|
- every SFW asset also has a completed analysis EXIF checkpoint;
|
||||||
|
- the bytes on disk right now still hash to what inventory recorded, so the album
|
||||||
|
preview describes the exact bytes that would be uploaded.
|
||||||
|
|
||||||
|
Blocker codes are structured, never prose the UI has to parse:
|
||||||
|
|
||||||
|
``no_library_root``, ``credentials_missing``, ``server_unreachable``,
|
||||||
|
``immich_go_missing``, ``rename_pending``, ``unknown_album``, ``empty_scope``,
|
||||||
|
``file_missing``, ``bytes_changed``, ``safety_undecided``, ``safety_deferred``,
|
||||||
|
``safety_exif_unverified``, ``analysis_incomplete``, ``partial_scope``.
|
||||||
|
|
||||||
|
**Partial scope is a blocker, not a default.** An album with any blocked asset is
|
||||||
|
refused unless the caller passes the explicit ``allow_partial`` policy, which is
|
||||||
|
itself part of the token — a token issued for a partial upload can never be
|
||||||
|
replayed as a full one.
|
||||||
|
|
||||||
|
The token is derived, not stored: it is a digest of the whole report (minus its
|
||||||
|
timestamp), so any change that matters — a new decision, edited bytes, a different
|
||||||
|
server, a resolved blocker, a different policy — produces a different token and the
|
||||||
|
old one is stale by construction. No table, no invalidation bookkeeping.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from photo_pipeline.config import Config
|
||||||
|
from photo_pipeline.integrations import immich_go
|
||||||
|
from photo_pipeline.models import AnalysisResult, Asset, SafetyReview
|
||||||
|
from photo_pipeline.services.albums import album_label
|
||||||
|
from photo_pipeline.services.hashing import sha256_file
|
||||||
|
from photo_pipeline.services.rename_journal import RenameJournal
|
||||||
|
|
||||||
|
PREFLIGHT_VERSION = 1
|
||||||
|
TOKEN_PREFIX = f"v{PREFLIGHT_VERSION}"
|
||||||
|
SFW = "sfw"
|
||||||
|
NSFW = "nsfw"
|
||||||
|
ANALYZED = "analyzed"
|
||||||
|
|
||||||
|
|
||||||
|
class UploadError(RuntimeError):
|
||||||
|
"""Invalid preflight request (unknown album in the requested scope)."""
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _issue(code: str, message: str) -> dict:
|
||||||
|
return {"code": code, "message": message}
|
||||||
|
|
||||||
|
|
||||||
|
class UploadService:
|
||||||
|
def __init__(self, session_factory: sessionmaker, *, config: Config) -> None:
|
||||||
|
self._session_factory = session_factory
|
||||||
|
self._config = config
|
||||||
|
self._roots = tuple(Path(root) for root in config.library_roots)
|
||||||
|
|
||||||
|
# ── preflight ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def preflight(self, albums: list[str] | None = None, *, allow_partial: bool = False) -> dict:
|
||||||
|
"""Validate an upload scope and issue its token. Read-only, no upload."""
|
||||||
|
report = {
|
||||||
|
"schema_version": PREFLIGHT_VERSION,
|
||||||
|
"policy": {"allow_partial": allow_partial},
|
||||||
|
"blockers": [],
|
||||||
|
"credentials": self._credentials(),
|
||||||
|
"server": self._server(),
|
||||||
|
"uploader": self._uploader(),
|
||||||
|
}
|
||||||
|
report["blockers"] += self._environment_blockers(report)
|
||||||
|
report["albums"] = self._albums(albums, allow_partial=allow_partial)
|
||||||
|
report["totals"] = _totals(report["albums"])
|
||||||
|
if not report["albums"]:
|
||||||
|
report["blockers"].append(
|
||||||
|
_issue("empty_scope", "no canonical, active assets are in the selected scope")
|
||||||
|
)
|
||||||
|
report["state"] = (
|
||||||
|
"ready"
|
||||||
|
if not report["blockers"] and all(a["state"] == "ready" for a in report["albums"])
|
||||||
|
else "blocked"
|
||||||
|
)
|
||||||
|
report["token"] = _token(report)
|
||||||
|
report["generated_at"] = _now().isoformat()
|
||||||
|
return report
|
||||||
|
|
||||||
|
def verify_token(
|
||||||
|
self, token: str, albums: list[str] | None = None, *, allow_partial: bool = False
|
||||||
|
) -> bool:
|
||||||
|
"""True when ``token`` still describes the current state of that scope.
|
||||||
|
|
||||||
|
Recomputed rather than looked up, so an externally edited file or a changed
|
||||||
|
decision invalidates it even though nothing wrote to the database.
|
||||||
|
"""
|
||||||
|
return bool(token) and token == self.preflight(albums, allow_partial=allow_partial)["token"]
|
||||||
|
|
||||||
|
# ── environment ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _credentials(self) -> dict:
|
||||||
|
"""Presence only — the key itself never leaves configuration."""
|
||||||
|
return {
|
||||||
|
"server_url": self._config.immich_server_url,
|
||||||
|
"api_key_configured": self._config.immich_api_key is not None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _server(self) -> dict:
|
||||||
|
reachable, detail = immich_go.ping(self._config.immich_server_url)
|
||||||
|
return {"reachable": reachable, "detail": detail}
|
||||||
|
|
||||||
|
def _uploader(self) -> dict:
|
||||||
|
binary = self._config.immich_go_binary
|
||||||
|
return {
|
||||||
|
"binary": binary,
|
||||||
|
"installed": immich_go.find_binary(binary) is not None,
|
||||||
|
"version": immich_go.version(binary),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _environment_blockers(self, report: dict) -> list[dict]:
|
||||||
|
blockers: list[dict] = []
|
||||||
|
if not self._roots:
|
||||||
|
blockers.append(_issue("no_library_root", "no library root is configured"))
|
||||||
|
if not report["credentials"]["api_key_configured"] or not self._config.immich_server_url:
|
||||||
|
blockers.append(
|
||||||
|
_issue("credentials_missing", "an Immich server URL and API key are required")
|
||||||
|
)
|
||||||
|
elif not report["server"]["reachable"]:
|
||||||
|
blockers.append(
|
||||||
|
_issue(
|
||||||
|
"server_unreachable", f"Immich did not respond: {report['server']['detail']}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not report["uploader"]["installed"]:
|
||||||
|
blockers.append(
|
||||||
|
_issue("immich_go_missing", f"{self._config.immich_go_binary} is not installed")
|
||||||
|
)
|
||||||
|
if RenameJournal(self._session_factory).blocks_mutation():
|
||||||
|
blockers.append(
|
||||||
|
_issue("rename_pending", "an unresolved rename must be recovered before upload")
|
||||||
|
)
|
||||||
|
return blockers
|
||||||
|
|
||||||
|
# ── scope ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _albums(self, requested: list[str] | None, *, allow_partial: bool) -> list[dict]:
|
||||||
|
by_album = self._scope()
|
||||||
|
if requested is not None:
|
||||||
|
unknown = sorted(set(requested) - set(by_album))
|
||||||
|
if unknown:
|
||||||
|
raise UploadError(f"unknown album(s): {', '.join(unknown)}")
|
||||||
|
by_album = {name: by_album[name] for name in sorted(set(requested))}
|
||||||
|
return [
|
||||||
|
self._album(name, rows, allow_partial=allow_partial)
|
||||||
|
for name, rows in sorted(by_album.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
def _scope(self) -> dict[str, list[dict]]:
|
||||||
|
"""Canonical, active assets grouped by album, each with its stage evidence."""
|
||||||
|
with self._session_factory() as session:
|
||||||
|
assets = list(
|
||||||
|
session.scalars(
|
||||||
|
select(Asset).where(
|
||||||
|
Asset.canonical_asset_id.is_(None),
|
||||||
|
Asset.availability_state == "active",
|
||||||
|
Asset.current_path.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
reviews: dict[str, SafetyReview] = {}
|
||||||
|
for review in session.scalars(select(SafetyReview).order_by(SafetyReview.created_at)):
|
||||||
|
reviews[review.asset_id] = review # latest row per asset wins
|
||||||
|
analyses = {
|
||||||
|
result.asset_id: result for result in session.scalars(select(AnalysisResult))
|
||||||
|
}
|
||||||
|
|
||||||
|
by_album: dict[str, list[dict]] = {}
|
||||||
|
for asset in assets:
|
||||||
|
by_album.setdefault(album_label(asset.current_path, self._roots), []).append(
|
||||||
|
{
|
||||||
|
"asset_id": asset.id,
|
||||||
|
"path": asset.current_path,
|
||||||
|
"expected_sha256": asset.current_sha256,
|
||||||
|
"review": reviews.get(asset.id),
|
||||||
|
"analysis": analyses.get(asset.id),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return by_album
|
||||||
|
|
||||||
|
def _album(self, name: str, rows: list[dict], *, allow_partial: bool) -> dict:
|
||||||
|
folder = Path(rows[0]["path"]).parent
|
||||||
|
items = sorted((self._item(row) for row in rows), key=lambda item: item["current_path"])
|
||||||
|
blocked = [item for item in items if item["blockers"]]
|
||||||
|
eligible = [item for item in items if not item["blockers"]]
|
||||||
|
blockers: list[dict] = []
|
||||||
|
if blocked and not allow_partial:
|
||||||
|
blockers.append(
|
||||||
|
_issue(
|
||||||
|
"partial_scope",
|
||||||
|
f"{len(blocked)} of {len(items)} asset(s) are not upload-ready; resolve them "
|
||||||
|
"or approve a partial upload explicitly",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not eligible:
|
||||||
|
blockers.append(_issue("empty_scope", "no upload-ready asset remains in this album"))
|
||||||
|
# Folder-as-album: Immich names the album after the leaf folder, so the
|
||||||
|
# preview shows exactly what the server will create.
|
||||||
|
album_name = folder.name
|
||||||
|
return {
|
||||||
|
"album": name,
|
||||||
|
"folder": str(folder),
|
||||||
|
"album_name": album_name,
|
||||||
|
"asset_count": len(items),
|
||||||
|
"eligible_count": len(eligible),
|
||||||
|
"blocked_count": len(blocked),
|
||||||
|
"partial": bool(blocked),
|
||||||
|
"state": "blocked" if blockers else "ready",
|
||||||
|
"blockers": blockers,
|
||||||
|
"assets": items,
|
||||||
|
"command_preview": immich_go.preview_command(
|
||||||
|
binary=self._config.immich_go_binary,
|
||||||
|
server_url=self._config.immich_server_url,
|
||||||
|
album_name=album_name,
|
||||||
|
folder=folder,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _item(self, row: dict) -> dict:
|
||||||
|
"""One asset's readiness, including its hash *as it is on disk right now*."""
|
||||||
|
path = Path(row["path"])
|
||||||
|
review: SafetyReview | None = row["review"]
|
||||||
|
analysis: AnalysisResult | None = row["analysis"]
|
||||||
|
decision = review.decision if review else None
|
||||||
|
blockers: list[dict] = []
|
||||||
|
current_sha256 = None
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
blockers.append(_issue("file_missing", f"{path} is missing"))
|
||||||
|
else:
|
||||||
|
# ponytail: full re-hash every preflight. Gate on (size, mtime_ns) first
|
||||||
|
# if a large library makes this slow — the hash stays authoritative.
|
||||||
|
current_sha256 = sha256_file(path)
|
||||||
|
if row["expected_sha256"] and current_sha256 != row["expected_sha256"]:
|
||||||
|
blockers.append(
|
||||||
|
_issue("bytes_changed", f"{path} changed since its last verified checkpoint")
|
||||||
|
)
|
||||||
|
|
||||||
|
if decision not in (SFW, NSFW):
|
||||||
|
code = "safety_deferred" if decision == "deferred" else "safety_undecided"
|
||||||
|
blockers.append(_issue(code, "a confirmed sfw/nsfw safety decision is required"))
|
||||||
|
elif review.exif_verified_at is None:
|
||||||
|
blockers.append(
|
||||||
|
_issue("safety_exif_unverified", "the safety EXIF checkpoint is not verified")
|
||||||
|
)
|
||||||
|
elif decision == SFW and not (
|
||||||
|
analysis and analysis.status == ANALYZED and analysis.exif_written_at is not None
|
||||||
|
):
|
||||||
|
blockers.append(
|
||||||
|
_issue("analysis_incomplete", "SFW assets need a verified analysis EXIF checkpoint")
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"asset_id": row["asset_id"],
|
||||||
|
"current_path": str(path),
|
||||||
|
"safety_decision": decision,
|
||||||
|
"current_sha256": current_sha256,
|
||||||
|
"blockers": blockers,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _totals(albums: list[dict]) -> dict:
|
||||||
|
return {
|
||||||
|
"albums": len(albums),
|
||||||
|
"ready_albums": sum(1 for album in albums if album["state"] == "ready"),
|
||||||
|
"assets": sum(album["asset_count"] for album in albums),
|
||||||
|
"eligible": sum(album["eligible_count"] for album in albums),
|
||||||
|
"blocked": sum(album["blocked_count"] for album in albums),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _token(report: dict) -> str:
|
||||||
|
"""Digest of everything the report asserts. Volatile fields are excluded so the
|
||||||
|
same state always yields the same token; every relevant change breaks it."""
|
||||||
|
payload = {key: value for key, value in report.items() if key not in ("generated_at", "token")}
|
||||||
|
digest = hashlib.sha256(
|
||||||
|
json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
return f"{TOKEN_PREFIX}:{digest}"
|
||||||
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": [
|
"US04-06": [
|
||||||
"tests/e2e/test_phase_d_pipeline.py"
|
"tests/e2e/test_phase_d_pipeline.py"
|
||||||
|
],
|
||||||
|
"US05-01": [
|
||||||
|
"tests/integration/test_upload_preflight.py"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user