Unify user management and add reviewed identity repairs
Magent CI/CD / verify (push) Canceled after 9m3s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s

This commit is contained in:
2026-09-10 16:06:30 +12:00
parent 9856c7fb90
commit 6e473fd0a7
11 changed files with 501 additions and 191 deletions
+135
View File
@@ -99,6 +99,139 @@ class IdentityReviewTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
result = await review.resolve_identity(self.user_id, JF, preview['revision'], ADMIN)
self.assertEqual(result['confirmed'], 1)
async def test_repair_replaces_wrong_local_link_and_records_before_after(self):
link_user('Georgia', OTHER, self.runtime.jellyfin_base_url)
db.set_user_jellyseerr_id('Georgia', 999)
before = review.read_snapshot()
report = review.build_report(before, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True)
self.assertTrue(self.row(report)['can_confirm'])
self.assertEqual(review.read_snapshot(), before)
review.save_confirmations(report, before, self.runtime, [self.user_id], ADMIN, repair=True)
self.assertEqual(linked_user_id('Georgia', self.runtime.jellyfin_base_url), JF)
self.assertEqual(db.get_user_by_username('Georgia')['jellyseerr_user_id'], 20)
with closing(db._connect()) as conn:
audit = conn.execute('SELECT before_json,after_json,repaired_by FROM user_identity_repairs').fetchone()
self.assertEqual(json.loads(audit[0])['seerr_user_id'], 999)
self.assertEqual(json.loads(audit[1])['jellyfin_user_id'], JF)
self.assertEqual(audit[2], 'admin')
async def test_repair_preserves_duplicate_ownership_and_server_guards(self):
db.create_user('Owner', 'password', auth_provider='local', jellyseerr_user_id=20)
local = review.read_snapshot()
report = review.build_report(local, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True)
self.assertFalse(self.row(report)['can_confirm'])
with self.assertRaises(HTTPException):
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN, repair=True)
with closing(db._connect()) as conn, conn:
conn.execute('DELETE FROM users WHERE username=?', ('Owner',))
report, local = self.build()
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.jf['server_id'] = OTHER
report = review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True)
self.assertFalse(self.row(report)['can_confirm'])
async def test_repair_does_not_invent_missing_seerr_identity(self):
self.seerr['users'][0]['jellyfin_id'] = OTHER
local = review.read_snapshot()
report = review.build_report(local, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True)
self.assertEqual(self.row(report)['state'], 'unlinked')
with self.assertRaises(HTTPException):
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN, repair=True)
self.assertEqual(local, review.read_snapshot())
async def test_repair_audit_failure_rolls_back_links(self):
link_user('Georgia', OTHER, self.runtime.jellyfin_base_url)
with closing(db._connect()) as conn, conn:
conn.execute("CREATE TRIGGER fail_identity_audit BEFORE INSERT ON user_identity_repairs BEGIN SELECT RAISE(ABORT, 'fixture'); END")
local = review.read_snapshot()
report = review.build_report(local, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True)
with self.assertRaises(HTTPException):
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN, repair=True)
self.assertEqual(local, review.read_snapshot())
async def test_repair_rechecks_revision_and_updates_confirmed_ids(self):
report, local = self.build()
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.jf['users'][0]['id'] = OTHER
self.seerr['users'][0]['jellyfin_id'] = OTHER
self.js = {OTHER: {'state': 'matched', 'id': OTHER}}
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):
preview = await review.repair_identity(self.user_id, OTHER)
with self.assertRaises(HTTPException):
await review.repair_identity(self.user_id, OTHER, 'f' * 64, ADMIN)
await review.repair_identity(self.user_id, OTHER, preview['revision'], ADMIN)
self.assertEqual(review.read_snapshot()['confirmations'][0]['jellyfin_user_id'], OTHER)
async def test_single_account_import_is_explicit_and_rechecked_before_local_save(self):
self.seerr['users'] = []
async def imported(*args, **kwargs):
self.seerr['users'] = [{'id': 25, 'name': 'Georgia', 'jellyfin_id': JF}]
return []
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), \
patch.object(review.JellyseerrClient, 'post', new_callable=AsyncMock, side_effect=imported) as post:
before = review.read_snapshot()
blocked = await review.repair_identity(self.user_id, JF)
self.assertFalse(blocked['row']['can_confirm'])
preview = await review.repair_identity(self.user_id, JF, create_seerr=True)
self.assertEqual(preview['action'], 'import_seerr')
self.assertTrue(preview['row']['can_confirm'])
post.assert_not_called()
self.assertEqual(review.read_snapshot(), before)
with self.assertRaises(HTTPException):
await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, False)
post.assert_not_called()
await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, True)
post.assert_awaited_once_with('/api/v1/user/import-from-jellyfin', payload={'jellyfinUserIds': [JF]})
self.assertEqual(db.get_user_by_username('Georgia')['jellyseerr_user_id'], 25)
async def test_failed_import_never_writes_local_links_or_retries(self):
self.seerr['users'] = []
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), \
patch.object(review.JellyseerrClient, 'post', new_callable=AsyncMock, side_effect=httpx.ReadTimeout('fixture')) as post:
before = review.read_snapshot()
preview = await review.repair_identity(self.user_id, JF, create_seerr=True)
with self.assertRaises(HTTPException) as error:
await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, True)
self.assertEqual(error.exception.status_code, 502)
self.assertEqual(post.await_count, 1)
self.assertEqual(review.read_snapshot(), before)
async def test_import_preserves_upstream_account_when_local_save_is_blocked(self):
self.seerr['users'] = []
async def imported(*args, **kwargs):
self.seerr['users'] = [{'id': 25, 'name': 'Georgia', 'jellyfin_id': JF}]
db.set_user_jellyseerr_id('Georgia', 999)
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), \
patch.object(review.JellyseerrClient, 'post', new_callable=AsyncMock, side_effect=imported), \
patch.object(review.JellyseerrClient, 'delete_user', new_callable=AsyncMock) as delete:
preview = await review.repair_identity(self.user_id, JF, create_seerr=True)
with self.assertRaises(HTTPException) as error:
await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, True)
self.assertIn('Seerr import completed', error.exception.detail)
self.assertEqual(db.get_user_by_username('Georgia')['jellyseerr_user_id'], 999)
self.assertEqual(review.read_snapshot()['confirmations'], [])
delete.assert_not_called()
async def test_import_blocks_existing_name_with_different_jellyfin_id(self):
self.seerr['users'] = [{'id': 25, 'name': 'Georgia', 'jellyfin_id': OTHER}]
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), \
patch.object(review.JellyseerrClient, 'post', new_callable=AsyncMock) as post:
preview = await review.repair_identity(self.user_id, JF, create_seerr=True)
self.assertFalse(preview['row']['can_confirm'])
with self.assertRaises(HTTPException):
await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, True)
post.assert_not_called()
async def test_georgia_preview_is_read_only_and_uses_seerr_jellyfin_id(self):
before = review.read_snapshot()
report, _ = self.build()
@@ -319,8 +452,10 @@ class IdentityRouteTests(unittest.TestCase):
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)
self.assertEqual(self.client(role).post('/admin/identities/repair/' + 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)
self.assertEqual(self.client('admin').post('/admin/identities/repair/' + 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')