Unify user management and add reviewed identity repairs
This commit is contained in:
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user