Add admin review and confirmation of cross-service user IDs
Magent CI/CD / verify (push) Successful in 10m29s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 54s

This commit is contained in:
2026-09-08 15:41:45 +12:00
parent 2976145dd8
commit 12611a9819
14 changed files with 972 additions and 0 deletions
+32
View File
@@ -46,6 +46,38 @@ class JellystatClient(ApiClient):
raise JellystatError("Jellystat returned an unexpected library response")
return {"connected": True}
async def check_user_ids(self, user_ids: list[str]) -> dict:
"""Read metadata for known identities; never scan everyone's playback history."""
if not self.configured():
return {user_id: {"state": "not_configured"} for user_id in user_ids}
results = {user_id: {"state": "unavailable"} for user_id in user_ids}
semaphore = asyncio.Semaphore(6)
async with httpx.AsyncClient(timeout=8.0) as client:
async def check(user_id):
if not re.fullmatch(r"[a-f0-9]{32}", user_id):
return
async with semaphore:
try:
response = await client.post(f"{self.base_url}/api/getUserDetails",
headers={"x-api-token": self.api_key}, json={"userid": user_id})
if response.status_code == 404 or (response.status_code == 200 and not response.content.strip()):
results[user_id] = {"state": "missing"}
return
response.raise_for_status()
row = response.json()
if row is None:
results[user_id] = {"state": "missing"}
elif isinstance(row, dict) and same_user_id(row.get("Id"), user_id):
results[user_id] = {"state": "matched", "id": user_id, "name": str(row.get("Name") or "")[:200]}
except (httpx.HTTPError, ValueError):
pass
try:
async with asyncio.timeout(25):
await asyncio.gather(*(check(user_id) for user_id in user_ids))
except TimeoutError:
pass
return results
async def get_user_history(self, user_id: str, start: datetime, end: datetime) -> tuple[list, list]:
if not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", user_id):
raise JellystatError("Invalid linked Jellyfin identity")
+14
View File
@@ -193,6 +193,19 @@ def init_db() -> None:
PRIMARY KEY (source, local_user_id), UNIQUE (source, jellyfin_user_id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS user_identity_confirmations (
local_user_id INTEGER PRIMARY KEY,
jellyfin_server_id TEXT NOT NULL,
jellyfin_user_id TEXT NOT NULL,
jellyfin_source TEXT NOT NULL,
seerr_source TEXT NOT NULL,
seerr_user_id INTEGER NOT NULL,
confirmed_at TEXT NOT NULL,
confirmed_by TEXT NOT NULL,
UNIQUE (jellyfin_server_id, jellyfin_user_id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS request_repairs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1288,6 +1301,7 @@ def set_user_jellyseerr_id(username: str, jellyseerr_user_id: Optional[int]) ->
conn.execute(
"""
UPDATE users SET jellyseerr_user_id = ? WHERE username = ? COLLATE NOCASE
AND id NOT IN (SELECT local_user_id FROM user_identity_confirmations)
""",
(jellyseerr_user_id, username),
)
+2
View File
@@ -28,6 +28,7 @@ from .routers.events import router as events_router
from .routers.portal import router as portal_router
from .routers.operations import router as operations_router
from .routers.insights import router as insights_router
from .routers.identities import router as identities_router
from .services.jellyfin_sync import run_daily_jellyfin_sync
from .services.issue_resolution import run_issue_confirmation_loop
from .services.operation_progress import (
@@ -282,3 +283,4 @@ app.include_router(events_router)
app.include_router(portal_router)
app.include_router(operations_router)
app.include_router(insights_router)
app.include_router(identities_router)
+33
View File
@@ -0,0 +1,33 @@
from fastapi import APIRouter, Depends, Response
from pydantic import BaseModel, ConfigDict, Field, field_validator
from ..auth import require_admin
from ..services.identity_review import confirm_identities, review_identities
router = APIRouter(prefix="/admin/identities", tags=["admin"], dependencies=[Depends(require_admin)])
class Confirmation(BaseModel):
model_config = ConfigDict(extra="forbid")
revision: str = Field(pattern=r"^[a-f0-9]{64}$")
user_ids: list[int] = Field(min_length=1, max_length=3000)
@field_validator("user_ids")
@classmethod
def unique_positive_ids(cls, value):
if any(user_id <= 0 for user_id in value) or len(set(value)) != len(value):
raise ValueError("Choose unique positive user IDs")
return value
@router.get("")
async def review(response: Response):
response.headers["Cache-Control"] = "no-store"
report, _, _ = await review_identities()
return report
@router.post("/confirm")
async def confirm(payload: Confirmation, response: Response, admin: dict = Depends(require_admin)):
response.headers["Cache-Control"] = "no-store"
return await confirm_identities(payload.revision, payload.user_ids, admin)
+277
View File
@@ -0,0 +1,277 @@
"""Admin-reviewed account links. Live IDs are authoritative; names only suggest candidates."""
import asyncio
import hashlib
import json
import re
import sqlite3
from collections import defaultdict
from contextlib import closing
from datetime import datetime, timezone
from fastapi import HTTPException
from .. import db
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..clients.jellystat import JellystatClient
from ..runtime import get_runtime_settings
from .jellyfin_identity import source_key
MAX_USERS = 3000
CONFIG_KEYS = ("jellyfin_base_url", "jellyfin_api_key", "jellyseerr_base_url",
"jellyseerr_api_key", "jellystat_base_url", "jellystat_api_key")
def normalized_id(value):
value = str(value or "").lower().replace("-", "")
return value if re.fullmatch(r"[a-f0-9]{32}", value) else None
def name_key(value):
return str(value or "").strip().casefold()
def digest(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def config_digest(runtime):
return digest([getattr(runtime, key, None) for key in CONFIG_KEYS])
def snapshot(conn):
conn.row_factory = sqlite3.Row
return {
"users": [dict(row) for row in conn.execute(
"SELECT id, username, role, auth_provider, jellyseerr_user_id FROM users ORDER BY id")],
"links": [dict(row) for row in conn.execute(
"SELECT source, local_user_id, jellyfin_user_id FROM jellyfin_user_links ORDER BY source, local_user_id")],
"confirmations": [dict(row) for row in conn.execute(
"SELECT * FROM user_identity_confirmations ORDER BY local_user_id")],
# Detect settings changes between checking services and committing the reviewed links.
"config_revision": digest([tuple(row) for row in conn.execute(
"SELECT key, value FROM settings WHERE key IN (" + ",".join("?" for _ in CONFIG_KEYS) + ") ORDER BY key", CONFIG_KEYS)]),
}
def read_snapshot():
with closing(db._connect()) as conn:
return snapshot(conn)
async def jellyfin_directory(runtime):
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
return {"state": "not_configured", "users": []}
try:
users, server = await asyncio.gather(client.get_users(), client.get_system_info())
server_id = normalized_id(server.get("Id")) if isinstance(server, dict) else None
if not server_id or not isinstance(users, list) or len(users) > MAX_USERS:
raise ValueError()
clean = []
seen = set()
for user in users:
user_id = normalized_id(user.get("Id"))
if not user_id or user_id in seen or normalized_id(user.get("ServerId")) != server_id:
raise ValueError()
seen.add(user_id)
clean.append({"id": user_id, "name": str(user.get("Name") or "")[:200]})
return {"state": "available", "server_id": server_id, "users": sorted(clean, key=lambda row: row["id"])}
except Exception:
return {"state": "unavailable", "users": []}
async def seerr_directory(runtime):
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.base_url or not client.api_key:
return {"state": "not_configured", "users": []}
try:
users = []
seen = set()
expected_total = None
async with asyncio.timeout(20):
for skip in range(0, MAX_USERS, 100):
page = await client.get_users(take=100, skip=skip)
total = page["pageInfo"]["results"]
batch = page["results"]
if type(total) is not int or total < 0 or total > MAX_USERS or not isinstance(batch, list):
raise ValueError()
if expected_total is not None and total != expected_total:
raise ValueError()
expected_total = total
for user in batch:
user_id = user.get("id")
if type(user_id) is not int or user_id <= 0 or user_id in seen:
raise ValueError()
seen.add(user_id)
users.append({"id": user_id, "name": str(user.get("displayName") or user.get("jellyfinUsername") or "")[:200],
"jellyfin_id": normalized_id(user.get("jellyfinUserId"))})
if len(users) == total:
return {"state": "available", "users": sorted(users, key=lambda row: row["id"])}
if len(batch) != 100 or len(users) > total:
raise ValueError()
except Exception:
pass
return {"state": "unavailable", "users": []}
def build_report(local, jellyfin, seerr, jellystat, runtime):
jf_by_id = {row["id"]: row for row in jellyfin["users"]}
jf_by_name = defaultdict(list)
for row in jellyfin["users"]:
jf_by_name[name_key(row["name"])].append(row["id"])
seerr_by_id = {row["id"]: row for row in seerr["users"]}
seerr_by_jf = defaultdict(list)
for row in seerr["users"]:
if row["jellyfin_id"]:
seerr_by_jf[row["jellyfin_id"]].append(row)
current_source = source_key(runtime.jellyfin_base_url)
seerr_source = source_key(runtime.jellyseerr_base_url)
links = {row["local_user_id"]: normalized_id(row["jellyfin_user_id"]) for row in local["links"] if row["source"] == current_source}
confirmed = {row["local_user_id"]: row for row in local["confirmations"]}
local_by_name, local_by_seerr = defaultdict(list), defaultdict(list)
for user in local["users"]:
local_by_name[name_key(user["username"])].append(user["id"])
if user["jellyseerr_user_id"] is not None:
local_by_seerr[user["jellyseerr_user_id"]].append(user["id"])
rows = []
for user in local["users"]:
issues = []
saved = confirmed.get(user["id"])
linked = links.get(user["id"])
stored_seerr = seerr_by_id.get(user["jellyseerr_user_id"])
by_name = jf_by_name.get(name_key(user["username"]), [])
basis = "none"
candidate = None
if saved:
candidate = saved["jellyfin_user_id"]
basis = "confirmed_id"
if saved["jellyfin_server_id"] != jellyfin.get("server_id") or saved["seerr_source"] != seerr_source:
issues.append("The confirmed server or Seerr connection has changed.")
elif linked:
candidate, basis = linked, "stored_jellyfin_id"
elif stored_seerr and stored_seerr["jellyfin_id"]:
candidate, basis = stored_seerr["jellyfin_id"], "stored_seerr_id"
elif user["auth_provider"] == "jellyfin" and len(by_name) == 1:
candidate, basis = by_name[0], "suggested_username"
if len(local_by_name[name_key(user["username"])]) > 1:
issues.append("Multiple Magent rows share this username after case and whitespace normalization.")
if len(local_by_seerr.get(user["jellyseerr_user_id"], [])) > 1:
issues.append("Multiple Magent rows share the stored Seerr ID.")
if len(by_name) > 1:
issues.append("This name matches multiple distinct Jellyfin IDs.")
if candidate and by_name and candidate not in by_name:
issues.append("The stored ID and current Jellyfin username point to different accounts.")
if linked and candidate and linked != candidate:
issues.append("The stored Jellyfin link conflicts with the confirmed identity.")
jf = jf_by_id.get(candidate)
if candidate and not jf and jellyfin["state"] == "available":
issues.append("The linked Jellyfin ID is absent from the current server.")
expected_seerr = seerr_by_jf.get(candidate, [])
if len(expected_seerr) > 1:
issues.append("Multiple Seerr users reference the same Jellyfin ID.")
if user["jellyseerr_user_id"] is not None and seerr["state"] == "available" and (
len(expected_seerr) != 1 or expected_seerr[0]["id"] != user["jellyseerr_user_id"]
):
issues.append("The stored Seerr ID does not match Seerr's Jellyfin ID mapping.")
if saved and user["jellyseerr_user_id"] != saved["seerr_user_id"]:
issues.append("The stored Seerr ID has changed since confirmation.")
js = jellystat.get(candidate, {"state": "not_checked"})
rows.append({"user": user, "jellyfin": jf, "candidate_jellyfin_id": candidate,
"stored_jellyfin_id": linked, "seerr": expected_seerr, "jellystat": js,
"basis": basis, "issues": issues, "confirmed_at": saved["confirmed_at"] if saved else None,
"can_confirm": False, "state": "unlinked"})
candidates = defaultdict(list)
for row in rows:
if row["candidate_jellyfin_id"]:
candidates[row["candidate_jellyfin_id"]].append(row)
for row in rows:
candidate = row["candidate_jellyfin_id"]
if len(candidates.get(candidate, [])) > 1:
row["issues"].append("Multiple Magent accounts resolve to this Jellyfin ID.")
# Also protect IDs already reserved by a link/confirmation whose local user was deleted.
if any(link["local_user_id"] != row["user"]["id"] and link["source"] == current_source
and normalized_id(link["jellyfin_user_id"]) == candidate for link in local["links"]) or any(
item["local_user_id"] != row["user"]["id"] and item["jellyfin_server_id"] == jellyfin.get("server_id")
and item["jellyfin_user_id"] == candidate for item in local["confirmations"]):
row["issues"].append("This Jellyfin ID is already reserved by another Magent account.")
if row["issues"]:
row["state"] = "conflict"
elif jellyfin["state"] != "available" or seerr["state"] != "available" or (candidate and row["jellystat"]["state"] in {"unavailable", "not_configured"}):
row["state"] = "unavailable"
elif not row["jellyfin"] or not row["seerr"] or row["jellystat"]["state"] != "matched":
row["state"] = "unlinked"
elif row["confirmed_at"] and row["stored_jellyfin_id"] == candidate:
row["state"] = "confirmed"
else:
row["state"] = "ready"
row["can_confirm"] = True
upstream = [{"platform": "Seerr", "id": str(row["id"]), "name": row["name"], "jellyfin_id": row["jellyfin_id"],
"detail": "No current Jellyfin account has this ID."} for row in seerr["users"]
if row["jellyfin_id"] not in jf_by_id and jellyfin["state"] == "available"]
upstream += [{"platform": "Jellyfin", "id": row["id"], "name": row["name"], "jellyfin_id": row["id"],
"detail": "No Magent account resolves to this ID."} for row in jellyfin["users"] if row["id"] not in candidates]
services = {"jellyfin": jellyfin["state"], "seerr": seerr["state"],
"jellystat": "not_configured" if not runtime.jellystat_base_url or not runtime.jellystat_api_key else
"not_checked" if not jellystat else
"unavailable" if any(r["state"] == "unavailable" for r in jellystat.values()) else "available"}
report = {"server_id": jellyfin.get("server_id"), "services": services, "rows": rows, "upstream": upstream,
"counts": {"magent": len(rows), "jellyfin": len(jellyfin["users"]), "seerr": len(seerr["users"]),
"jellystat_checked": sum(r["state"] in {"matched", "missing"} for r in jellystat.values()),
**{state: sum(row["state"] == state for row in rows) for state in ("ready", "confirmed", "conflict", "unlinked", "unavailable")}}}
report["revision"] = digest([report, digest(local), config_digest(runtime)])
report["checked_at"] = datetime.now(timezone.utc).isoformat()
return report
async def review_identities():
runtime = await asyncio.to_thread(get_runtime_settings)
local, jf, seerr = await asyncio.gather(asyncio.to_thread(read_snapshot), jellyfin_directory(runtime), seerr_directory(runtime))
if len(local["users"]) > MAX_USERS:
raise HTTPException(422, "The identity check supports up to 3,000 Magent accounts.")
ids = {row["id"] for row in jf["users"]}
ids.update(row["jellyfin_id"] for row in seerr["users"] if row["jellyfin_id"])
ids.update(normalized_id(row["jellyfin_user_id"]) for row in local["links"])
ids.discard(None)
if len(ids) > MAX_USERS:
raise HTTPException(422, "There are too many upstream IDs for one identity check.")
stats_client = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
js = await stats_client.check_user_ids(sorted(ids)) if stats_client.configured() else {key: {"state": "not_configured"} for key in ids}
return build_report(local, jf, seerr, js, runtime), local, runtime
def save_confirmations(report, local, runtime, user_ids, admin):
rows = {row["user"]["id"]: row for row in report["rows"]}
if any(user_id not in rows or not rows[user_id]["can_confirm"] for user_id in user_ids):
raise HTTPException(409, "Some selected accounts cannot be confirmed. Run the check again and review the conflicts.")
now = datetime.now(timezone.utc).isoformat()
try:
with closing(db._connect()) as conn, conn:
conn.execute("BEGIN IMMEDIATE")
if digest(snapshot(conn)) != digest(local) or config_digest(get_runtime_settings()) != config_digest(runtime):
raise HTTPException(409, "Accounts or settings changed during confirmation. Run the check again.")
for user_id in user_ids:
row = rows[user_id]
jf_id = row["jellyfin"]["id"]
seerr_id = row["seerr"][0]["id"]
conn.execute("""INSERT INTO jellyfin_user_links (source, local_user_id, jellyfin_user_id) VALUES (?, ?, ?)
ON CONFLICT(source,local_user_id) DO UPDATE SET jellyfin_user_id=excluded.jellyfin_user_id""",
(source_key(runtime.jellyfin_base_url), user_id, jf_id))
conn.execute("UPDATE users SET jellyseerr_user_id=? WHERE id=?", (seerr_id, user_id))
conn.execute("""INSERT INTO user_identity_confirmations
(local_user_id,jellyfin_server_id,jellyfin_user_id,jellyfin_source,seerr_source,seerr_user_id,confirmed_at,confirmed_by)
VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(local_user_id) DO UPDATE SET
jellyfin_source=excluded.jellyfin_source,confirmed_at=excluded.confirmed_at,confirmed_by=excluded.confirmed_by""",
(user_id, report["server_id"], jf_id, source_key(runtime.jellyfin_base_url), source_key(runtime.jellyseerr_base_url),
seerr_id, now, admin["username"]))
except sqlite3.IntegrityError as exc:
raise HTTPException(409, "An identity is already linked to another account. Run the check again.") from exc
return {"confirmed": len(user_ids), "confirmed_at": now}
async def confirm_identities(revision, user_ids, admin):
report, local, runtime = await review_identities()
if report["revision"] != revision:
raise HTTPException(409, "The identity check has changed. Run it again before confirming accounts.")
return await asyncio.to_thread(save_confirmations, report, local, runtime, user_ids, admin)
@@ -28,6 +28,9 @@ def link_user(username: str, jellyfin_user_id: str, base_url: str | None) -> Non
if not user or not jellyfin_user_id or not base_url:
return
with closing(db._connect()) as conn, conn:
if conn.execute("SELECT 1 FROM user_identity_confirmations WHERE local_user_id = ?", (user["id"],)).fetchone():
# Reviewed identities are updated only through the admin confirmation workflow.
return
# A renamed or re-created account must not silently take over an existing identity.
conn.execute(
"INSERT OR IGNORE INTO jellyfin_user_links (source, local_user_id, jellyfin_user_id) VALUES (?, ?, ?)",
+268
View File
@@ -0,0 +1,268 @@
import json
import unittest
from contextlib import closing
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import httpx
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from backend.app import db
from backend.app.auth import get_current_user
from backend.app.clients.jellystat import JellystatClient
from backend.app.routers import identities
from backend.app.services import identity_review as review
from backend.app.services.jellyfin_identity import link_user, linked_user_id
from backend.tests.test_backend_quality import TempDatabaseMixin
JF = "a" * 32
OTHER = "b" * 32
SERVER = "c" * 32
ADMIN = {"username": "admin", "role": "admin"}
class IdentityReviewTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
def setUp(self):
super().setUp()
db.create_user("Georgia", "jellyfin-user", auth_provider="jellyfin")
self.user_id = db.get_user_by_username("Georgia")["id"]
self.runtime = SimpleNamespace(jellyfin_base_url="http://jellyfin", jellyfin_api_key="SECRET-JF",
jellyseerr_base_url="http://seerr", jellyseerr_api_key="SECRET-SEERR",
jellystat_base_url="http://jellystat", jellystat_api_key="SECRET-STATS")
runtime_patch = patch.object(review, "get_runtime_settings", return_value=self.runtime)
runtime_patch.start()
self.addCleanup(runtime_patch.stop)
self.jf = {"state": "available", "server_id": SERVER, "users": [{"id": JF, "name": "Georgia"}]}
self.seerr = {"state": "available", "users": [{"id": 20, "name": "An unrelated display name", "jellyfin_id": JF}]}
self.js = {JF: {"state": "matched", "id": JF, "name": "Georgia"}}
def build(self):
local = review.read_snapshot()
return review.build_report(local, self.jf, self.seerr, self.js, self.runtime), local
def row(self, report):
return next(row for row in report["rows"] if row["user"]["id"] == self.user_id)
async def test_georgia_preview_is_read_only_and_uses_seerr_jellyfin_id(self):
before = review.read_snapshot()
report, _ = self.build()
row = self.row(report)
self.assertEqual(row["basis"], "suggested_username")
self.assertEqual(row["state"], "ready")
self.assertEqual(row["seerr"][0]["id"], 20)
self.assertEqual(before, review.read_snapshot())
serialized = json.dumps(report)
for private in ["SECRET", "password_hash", "jellyfin_api_key", "email"]:
self.assertNotIn(private, serialized)
async def test_confirmation_persists_both_links_and_survives_legacy_sync(self):
report, local = self.build()
result = review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.assertEqual(result["confirmed"], 1)
self.assertEqual(linked_user_id("Georgia", self.runtime.jellyfin_base_url), JF)
self.assertEqual(db.get_user_by_username("Georgia")["jellyseerr_user_id"], 20)
saved = review.read_snapshot()["confirmations"][0]
self.assertEqual(saved["jellyfin_server_id"], SERVER)
self.assertEqual(saved["confirmed_by"], "admin")
db.set_user_jellyseerr_id("Georgia", 999)
link_user("Georgia", OTHER, "http://other-server")
self.assertEqual(db.get_user_by_username("Georgia")["jellyseerr_user_id"], 20)
self.assertIsNone(linked_user_id("Georgia", "http://other-server"))
refreshed, _ = self.build()
self.assertEqual(self.row(refreshed)["state"], "confirmed")
self.assertFalse(self.row(refreshed)["can_confirm"])
async def test_hidden_duplicate_seerr_and_jellyfin_rows_block_confirmation(self):
db.set_user_jellyseerr_id("Georgia", 20)
db.create_user("georgia@example.com", "jellyseerr-user", auth_provider="jellyseerr", jellyseerr_user_id=20)
report, local = self.build()
self.assertEqual(len(db.get_all_users()), 1)
self.assertEqual(len(report["rows"]), 2)
self.assertTrue(all(row["state"] == "conflict" for row in report["rows"]))
with self.assertRaises(HTTPException):
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.assertEqual(review.read_snapshot()["confirmations"], [])
async def test_whitespace_accounts_and_wrong_seerr_mapping_are_conflicts(self):
self.jf["users"].append({"id": OTHER, "name": "Georgia "})
self.seerr["users"].append({"id": 21, "name": "Georgia ", "jellyfin_id": OTHER})
db.set_user_jellyseerr_id("Georgia", 21)
report, _ = self.build()
self.assertEqual(self.row(report)["state"], "conflict")
self.assertFalse(self.row(report)["can_confirm"])
async def test_case_duplicates_in_magent_remain_visible_and_blocked(self):
db.create_user("georgia", "jellyfin-user", auth_provider="jellyfin")
report, _ = self.build()
self.assertEqual(report["counts"]["conflict"], 2)
self.assertEqual(report["counts"]["ready"], 0)
async def test_email_prefix_and_local_username_do_not_claim_an_identity(self):
db.create_user("Georgia@example.com", "jellyseerr-user", auth_provider="jellyseerr")
self.jf["users"].append({"id": OTHER, "name": "local"})
db.create_user("local", "password", auth_provider="local")
report, _ = self.build()
for row in report["rows"]:
if row["user"]["id"] != self.user_id:
self.assertIsNone(row["candidate_jellyfin_id"])
self.assertFalse(row["can_confirm"])
async def test_missing_or_unavailable_services_never_confirm(self):
for state in ["missing", "unavailable", "not_configured"]:
self.js[JF] = {"state": state}
report, _ = self.build()
self.assertFalse(self.row(report)["can_confirm"])
self.js = {JF: {"state": "matched", "id": JF}}
self.seerr = {"state": "unavailable", "users": []}
report, _ = self.build()
self.assertEqual(self.row(report)["state"], "unavailable")
async def test_duplicate_upstream_id_and_orphaned_reservations_are_blocked(self):
self.seerr["users"].append({"id": 21, "name": "Other", "jellyfin_id": JF})
report, _ = self.build()
self.assertEqual(self.row(report)["state"], "conflict")
self.seerr["users"].pop()
with closing(db._connect()) as conn, conn:
conn.execute("INSERT INTO jellyfin_user_links VALUES (?,?,?)", (review.source_key(self.runtime.jellyfin_base_url), 9999, JF))
report, _ = self.build()
self.assertEqual(self.row(report)["state"], "conflict")
async def test_wrong_stored_id_is_not_silently_replaced(self):
link_user("Georgia", OTHER, self.runtime.jellyfin_base_url)
report, _ = self.build()
self.assertEqual(self.row(report)["state"], "conflict")
self.assertEqual(self.row(report)["candidate_jellyfin_id"], OTHER)
async def test_account_changes_reject_whole_save(self):
report, local = self.build()
db.set_user_jellyseerr_id("Georgia", 99)
with self.assertRaises(HTTPException) as raised:
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.assertEqual(raised.exception.status_code, 409)
self.assertEqual(review.read_snapshot()["confirmations"], [])
self.assertIsNone(linked_user_id("Georgia", self.runtime.jellyfin_base_url))
async def test_settings_changes_reject_save(self):
report, local = self.build()
db.set_setting("jellyfin_base_url", "http://changed")
with self.assertRaises(HTTPException) as raised:
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.assertEqual(raised.exception.status_code, 409)
async def test_save_reads_real_runtime_settings_inside_transaction(self):
from backend.app.runtime import get_runtime_settings
for key in review.CONFIG_KEYS:
db.set_setting(key, getattr(self.runtime, key))
report, local = self.build()
with patch.object(review, "get_runtime_settings", get_runtime_settings):
result = review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.assertEqual(result["confirmed"], 1)
async def test_batch_rolls_back_all_links_if_a_later_write_fails(self):
db.create_user("Second", "jellyfin-user", auth_provider="jellyfin")
second_id = db.get_user_by_username("Second")["id"]
self.jf["users"].append({"id": OTHER, "name": "Second"})
self.seerr["users"].append({"id": 21, "name": "Second", "jellyfin_id": OTHER})
self.js[OTHER] = {"state": "matched", "id": OTHER}
with closing(db._connect()) as conn, conn:
conn.execute(f"""CREATE TRIGGER fail_second_confirmation BEFORE INSERT ON user_identity_confirmations
WHEN NEW.local_user_id={second_id} BEGIN SELECT RAISE(ABORT, 'fixture conflict'); END""")
report, local = self.build()
with self.assertRaises(HTTPException) as raised:
review.save_confirmations(report, local, self.runtime, [self.user_id, second_id], ADMIN)
self.assertEqual(raised.exception.status_code, 409)
after = review.read_snapshot()
self.assertEqual(after["confirmations"], [])
self.assertEqual(after["links"], [])
self.assertTrue(all(row["jellyseerr_user_id"] is None for row in after["users"]))
async def test_confirmation_rechecks_live_report_and_rejects_stale_revision(self):
report, local = self.build()
changed = {**report, "revision": "f" * 64}
with patch.object(review, "review_identities", new_callable=AsyncMock, return_value=(changed, local, self.runtime)), \
patch.object(review, "save_confirmations") as save:
with self.assertRaises(HTTPException) as raised:
await review.confirm_identities(report["revision"], [self.user_id], ADMIN)
self.assertEqual(raised.exception.status_code, 409)
save.assert_not_called()
async def test_different_server_cannot_reuse_confirmed_id(self):
report, local = self.build()
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.jf["server_id"] = OTHER
report, _ = self.build()
self.assertEqual(self.row(report)["state"], "conflict")
async def test_report_revision_ignores_time_but_detects_mapping_changes(self):
a, _ = self.build()
b, _ = self.build()
self.assertEqual(a["revision"], b["revision"])
self.seerr["users"][0]["id"] = 99
c, _ = self.build()
self.assertNotEqual(a["revision"], c["revision"])
async def test_seerr_directory_requires_complete_unique_pages(self):
users = [{"id": i, "jellyfinUserId": f"{i:032x}", "displayName": f"User {i}"} for i in range(1, 102)]
pages = [{"pageInfo": {"results": 101}, "results": users[:100]}, {"pageInfo": {"results": 101}, "results": users[100:]}]
with patch.object(review.JellyseerrClient, "get_users", new_callable=AsyncMock, side_effect=pages) as get:
result = await review.seerr_directory(self.runtime)
self.assertEqual(result["state"], "available")
self.assertEqual(len(result["users"]), 101)
self.assertEqual(get.await_args.kwargs["skip"], 100)
for broken in [[], users[:2], users[:1] * 100]:
with patch.object(review.JellyseerrClient, "get_users", new_callable=AsyncMock, return_value={"pageInfo": {"results": 101}, "results": broken}):
self.assertEqual((await review.seerr_directory(self.runtime))["state"], "unavailable")
async def test_jellyfin_server_and_directory_must_agree(self):
with patch.object(review.JellyfinClient, "get_system_info", new_callable=AsyncMock, return_value={"Id": SERVER}), \
patch.object(review.JellyfinClient, "get_users", new_callable=AsyncMock, return_value=[{"Id": JF, "Name": "Georgia", "ServerId": OTHER}]):
self.assertEqual((await review.jellyfin_directory(self.runtime))["state"], "unavailable")
class JellystatIdentityTests(unittest.IsolatedAsyncioTestCase):
async def test_unconfigured_client_never_calls_upstream(self):
with patch("backend.app.clients.jellystat.httpx.AsyncClient") as http:
result = await JellystatClient(None, None).check_user_ids([JF])
http.assert_not_called()
self.assertEqual(result[JF]["state"], "not_configured")
async def test_missing_wrong_and_failed_ids_are_distinguished_without_details(self):
ids = [f"{i:032x}" for i in range(1, 6)]
def handler(request):
self.assertEqual(request.headers["x-api-token"], "PRIVATE")
user_id = json.loads(request.content)["userid"]
if user_id == ids[0]: return httpx.Response(200, json={"Id": user_id, "Name": "Georgia", "PRIVATE": "hidden"})
if user_id == ids[1]: return httpx.Response(200, content=b"")
if user_id == ids[2]: return httpx.Response(200, json={"Id": OTHER})
if user_id == ids[3]: return httpx.Response(401, text="PRIVATE error")
return httpx.Response(503, text="PRIVATE error")
real = httpx.AsyncClient
with patch("backend.app.clients.jellystat.httpx.AsyncClient", side_effect=lambda **kwargs: real(transport=httpx.MockTransport(handler), **kwargs)):
data = await JellystatClient("http://jellystat", "PRIVATE").check_user_ids(ids)
self.assertEqual([data[key]["state"] for key in ids], ["matched", "missing", "unavailable", "unavailable", "unavailable"])
self.assertNotIn("PRIVATE", json.dumps(data))
class IdentityRouteTests(unittest.TestCase):
def client(self, role=None):
app = FastAPI()
app.include_router(identities.router)
if role:
app.dependency_overrides[get_current_user] = lambda: {"username": "viewer", "role": role}
return TestClient(app)
def test_admin_only_read_and_write(self):
for role, status in [(None, 401), ("user", 403)]:
client = self.client(role)
self.assertEqual(client.get("/admin/identities").status_code, status)
self.assertEqual(client.post("/admin/identities/confirm", json={"revision": "a" * 64, "user_ids": [1]}).status_code, status)
def test_no_store_and_no_browser_supplied_identity(self):
with patch.object(identities, "review_identities", new_callable=AsyncMock, return_value=({"rows": []}, {}, None)):
response = self.client("admin").get("/admin/identities")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["cache-control"], "no-store")
for body in [{"revision": "a" * 64, "user_ids": [1, 1]}, {"revision": "a" * 64, "user_ids": []},
{"revision": "a" * 64, "user_ids": [1], "jellyfin_id": OTHER}]:
self.assertEqual(self.client("admin").post("/admin/identities/confirm", json=body).status_code, 422)