Add reviewed resolution for missing user identity links
This commit is contained in:
@@ -2,7 +2,7 @@ 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
|
||||
from ..services.identity_review import confirm_identities, review_identities, resolve_identity
|
||||
|
||||
router = APIRouter(prefix="/admin/identities", tags=["admin"], dependencies=[Depends(require_admin)])
|
||||
|
||||
@@ -31,3 +31,25 @@ async def review(response: Response):
|
||||
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)
|
||||
|
||||
|
||||
class Resolution(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
user_id: int = Field(gt=0, strict=True)
|
||||
jellyfin_user_id: str = Field(pattern=r"^[a-f0-9]{32}$")
|
||||
|
||||
|
||||
class ResolutionConfirmation(Resolution):
|
||||
revision: str = Field(pattern=r"^[a-f0-9]{64}$")
|
||||
|
||||
|
||||
@router.post("/resolve/check")
|
||||
async def check_resolution(payload: Resolution, response: Response):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return await resolve_identity(payload.user_id, payload.jellyfin_user_id)
|
||||
|
||||
|
||||
@router.post("/resolve/confirm")
|
||||
async def confirm_resolution(payload: ResolutionConfirmation, response: Response, admin: dict = Depends(require_admin)):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return await resolve_identity(payload.user_id, payload.jellyfin_user_id, payload.revision, admin)
|
||||
|
||||
@@ -116,7 +116,10 @@ async def seerr_directory(runtime):
|
||||
return {"state": "unavailable", "users": []}
|
||||
|
||||
|
||||
def build_report(local, jellyfin, seerr, jellystat, runtime):
|
||||
def build_report(local, jellyfin, seerr, jellystat, runtime, selections=None):
|
||||
selections = selections or {}
|
||||
if any(user_id not in {user['id'] for user in local['users']} for user_id in selections):
|
||||
raise HTTPException(404, "This Magent account no longer exists. Run the check again.")
|
||||
jf_by_id = {row["id"]: row for row in jellyfin["users"]}
|
||||
jf_by_name = defaultdict(list)
|
||||
for row in jellyfin["users"]:
|
||||
@@ -155,6 +158,11 @@ def build_report(local, jellyfin, seerr, jellystat, runtime):
|
||||
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 user["id"] in selections:
|
||||
chosen = selections[user["id"]]
|
||||
if saved and chosen != saved["jellyfin_user_id"]:
|
||||
issues.append("A confirmed identity cannot be replaced through missing-link resolution.")
|
||||
candidate, basis = chosen, "admin_selected"
|
||||
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:
|
||||
@@ -217,6 +225,7 @@ def build_report(local, jellyfin, seerr, jellystat, runtime):
|
||||
"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,
|
||||
"jellyfin_users": jellyfin["users"],
|
||||
"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")}}}
|
||||
@@ -225,7 +234,7 @@ def build_report(local, jellyfin, seerr, jellystat, runtime):
|
||||
return report
|
||||
|
||||
|
||||
async def review_identities():
|
||||
async def review_identities(selections=None):
|
||||
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:
|
||||
@@ -238,7 +247,7 @@ async def review_identities():
|
||||
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
|
||||
return build_report(local, jf, seerr, js, runtime, selections), local, runtime
|
||||
|
||||
|
||||
def save_confirmations(report, local, runtime, user_ids, admin):
|
||||
@@ -275,3 +284,13 @@ async def confirm_identities(revision, user_ids, admin):
|
||||
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)
|
||||
|
||||
|
||||
async def resolve_identity(user_id, jellyfin_user_id, revision=None, admin=None):
|
||||
report, local, runtime = await review_identities({user_id: jellyfin_user_id})
|
||||
if revision is not None:
|
||||
if report["revision"] != revision:
|
||||
raise HTTPException(409, "Accounts or service mappings changed. Check the selected account again before saving.")
|
||||
return await asyncio.to_thread(save_confirmations, report, local, runtime, [user_id], admin)
|
||||
return {"revision": report["revision"], "server_id": report["server_id"],
|
||||
"row": next(row for row in report["rows"] if row["user"]["id"] == user_id)}
|
||||
|
||||
@@ -44,6 +44,61 @@ class IdentityReviewTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
def row(self, report):
|
||||
return next(row for row in report["rows"] if row["user"]["id"] == self.user_id)
|
||||
|
||||
async def test_manual_selection_resolves_different_username_without_guessing(self):
|
||||
self.jf['users'][0]['name'] = 'Different Jellyfin name'
|
||||
before = review.read_snapshot()
|
||||
report, _ = self.build()
|
||||
self.assertEqual(self.row(report)['state'], 'unlinked')
|
||||
report = review.build_report(before, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF})
|
||||
self.assertTrue(self.row(report)['can_confirm'])
|
||||
self.assertEqual(review.read_snapshot(), before)
|
||||
review.save_confirmations(report, before, self.runtime, [self.user_id], ADMIN)
|
||||
self.assertEqual(linked_user_id('Georgia', self.runtime.jellyfin_base_url), JF)
|
||||
self.assertEqual(self.row(self.build()[0])['state'], 'confirmed')
|
||||
|
||||
async def test_manual_selection_cannot_replace_stored_or_confirmed_identity(self):
|
||||
self.jf['users'].append({'id': OTHER, 'name': 'Other'})
|
||||
self.seerr['users'].append({'id': 21, 'name': 'Other', 'jellyfin_id': OTHER})
|
||||
self.js[OTHER] = {'state': 'matched', 'id': OTHER}
|
||||
report, local = self.build()
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
|
||||
local = review.read_snapshot()
|
||||
report = review.build_report(local, self.jf, self.seerr, self.js, self.runtime, {self.user_id: OTHER})
|
||||
self.assertFalse(self.row(report)['can_confirm'])
|
||||
with self.assertRaises(HTTPException):
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
|
||||
self.assertEqual(review.read_snapshot(), local)
|
||||
|
||||
async def test_manual_selection_checks_missing_ids_and_duplicate_owners(self):
|
||||
self.jf['users'][0]['name'] = 'Different'
|
||||
for state in ['missing', 'unavailable', 'not_configured']:
|
||||
self.js[JF] = {'state': state}
|
||||
report = review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF})
|
||||
self.assertFalse(self.row(report)['can_confirm'])
|
||||
self.js[JF] = {'state': 'matched', 'id': JF}
|
||||
db.create_user('Owner', 'password', auth_provider='local', jellyseerr_user_id=20)
|
||||
report = review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF})
|
||||
self.assertEqual(self.row(report)['state'], 'conflict')
|
||||
report = review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {self.user_id: OTHER})
|
||||
self.assertFalse(self.row(report)['can_confirm'])
|
||||
with self.assertRaises(HTTPException) as error:
|
||||
review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {999: JF})
|
||||
self.assertEqual(error.exception.status_code, 404)
|
||||
|
||||
async def test_resolution_rechecks_live_services_and_rejects_changed_selection(self):
|
||||
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
|
||||
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
|
||||
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js):
|
||||
before = review.read_snapshot()
|
||||
preview = await review.resolve_identity(self.user_id, JF)
|
||||
self.assertEqual(review.read_snapshot(), before)
|
||||
with self.assertRaises(HTTPException) as error:
|
||||
await review.resolve_identity(self.user_id, OTHER, preview['revision'], ADMIN)
|
||||
self.assertEqual(error.exception.status_code, 409)
|
||||
self.assertEqual(review.read_snapshot(), before)
|
||||
result = await review.resolve_identity(self.user_id, JF, preview['revision'], ADMIN)
|
||||
self.assertEqual(result['confirmed'], 1)
|
||||
|
||||
async def test_georgia_preview_is_read_only_and_uses_seerr_jellyfin_id(self):
|
||||
before = review.read_snapshot()
|
||||
report, _ = self.build()
|
||||
@@ -258,6 +313,18 @@ class IdentityRouteTests(unittest.TestCase):
|
||||
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_resolution_requires_admin_and_strict_ids(self):
|
||||
for endpoint in ['check', 'confirm']:
|
||||
body = {'user_id': 1, 'jellyfin_user_id': JF}
|
||||
if endpoint == 'confirm': body['revision'] = 'a' * 64
|
||||
for role, status in [(None, 401), ('user', 403)]:
|
||||
self.assertEqual(self.client(role).post('/admin/identities/resolve/' + endpoint, json=body).status_code, status)
|
||||
for invalid in [{'user_id': True}, {'jellyfin_user_id': 'invalid'}, {'seerr_user_id': 22}]:
|
||||
self.assertEqual(self.client('admin').post('/admin/identities/resolve/' + endpoint, json={**body, **invalid}).status_code, 422)
|
||||
with patch.object(identities, 'resolve_identity', new_callable=AsyncMock, return_value={'row': {}}):
|
||||
result = self.client('admin').post('/admin/identities/resolve/check', json={'user_id': 1, 'jellyfin_user_id': JF})
|
||||
self.assertEqual(result.headers['cache-control'], 'no-store')
|
||||
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user