Add reviewed duplicate account consolidation and prevent duplicate imports
This commit is contained in:
@@ -187,6 +187,9 @@ def _has_secure_bootstrap_admin_credentials() -> bool:
|
||||
|
||||
def init_db() -> None:
|
||||
with _connect() as conn:
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS user_duplicate_repairs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, kept_user_id INTEGER NOT NULL,
|
||||
archive_json TEXT NOT NULL, repaired_by TEXT NOT NULL, repaired_at TEXT NOT NULL)""")
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS user_feature_permissions (
|
||||
user_id INTEGER NOT NULL, feature TEXT NOT NULL, enabled INTEGER NOT NULL,
|
||||
PRIMARY KEY(user_id, feature))""")
|
||||
@@ -1007,10 +1010,15 @@ def create_user(
|
||||
expires_at: Optional[str] = None,
|
||||
invited_by_code: Optional[str] = None,
|
||||
) -> None:
|
||||
username = str(username).strip()
|
||||
created_at = datetime.now(timezone.utc).isoformat()
|
||||
password_hash = hash_password(password)
|
||||
normalized_email = _normalize_stored_email(email)
|
||||
with _connect() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
if any(str(row[0]).strip().casefold() == username.casefold()
|
||||
for row in conn.execute("SELECT username FROM users")):
|
||||
raise sqlite3.IntegrityError("A normalized username already exists")
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO users (
|
||||
@@ -1061,10 +1069,15 @@ def create_user_if_missing(
|
||||
expires_at: Optional[str] = None,
|
||||
invited_by_code: Optional[str] = None,
|
||||
) -> bool:
|
||||
username = str(username).strip()
|
||||
created_at = datetime.now(timezone.utc).isoformat()
|
||||
password_hash = hash_password(password)
|
||||
normalized_email = _normalize_stored_email(email)
|
||||
with _connect() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
if any(str(row[0]).strip().casefold() == username.casefold()
|
||||
for row in conn.execute("SELECT username FROM users")):
|
||||
return False
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO users (
|
||||
@@ -1126,6 +1139,7 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
|
||||
jellyfin_password_hash, last_jellyfin_auth_at
|
||||
FROM users
|
||||
WHERE username = ? COLLATE NOCASE
|
||||
ORDER BY id
|
||||
""",
|
||||
(username,),
|
||||
).fetchone()
|
||||
|
||||
@@ -3,6 +3,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from ..auth import require_admin
|
||||
from ..services.identity_review import confirm_identities, review_identities, resolve_identity, repair_identity
|
||||
from ..services.duplicate_accounts import repair_duplicates
|
||||
|
||||
router = APIRouter(prefix="/admin/identities", tags=["admin"], dependencies=[Depends(require_admin)])
|
||||
|
||||
@@ -73,3 +74,26 @@ async def check_repair(payload: RepairResolution, response: Response):
|
||||
async def confirm_repair(payload: RepairConfirmation, response: Response, admin: dict = Depends(require_admin)):
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
return await repair_identity(payload.user_id, payload.jellyfin_user_id, payload.revision, admin, payload.create_seerr)
|
||||
|
||||
|
||||
class DuplicateCheck(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
user_id: int = Field(gt=0, strict=True)
|
||||
keep_id: int | None = Field(default=None, gt=0, strict=True)
|
||||
|
||||
|
||||
class DuplicateConfirmation(DuplicateCheck):
|
||||
keep_id: int = Field(gt=0, strict=True)
|
||||
revision: str = Field(pattern=r'^[a-f0-9]{64}$')
|
||||
|
||||
|
||||
@router.post('/duplicates/check')
|
||||
async def check_duplicates(payload: DuplicateCheck, response: Response):
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
return await repair_duplicates(payload.user_id, payload.keep_id)
|
||||
|
||||
|
||||
@router.post('/duplicates/confirm')
|
||||
async def confirm_duplicates(payload: DuplicateConfirmation, response: Response, admin: dict = Depends(require_admin)):
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
return await repair_duplicates(payload.user_id, payload.keep_id, payload.revision, admin)
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Reviewed consolidation of same-name Jellyfin accounts, entirely within Magent."""
|
||||
import asyncio
|
||||
import json
|
||||
from contextlib import closing
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException
|
||||
from .. import db
|
||||
from ..feature_access import FEATURES
|
||||
from . import identity_review as review
|
||||
from .jellyfin_identity import source_key
|
||||
|
||||
NAME_REFERENCES = {
|
||||
'signup_invites': ('created_by',),
|
||||
'portal_items': ('created_by_username', 'assignee_username'),
|
||||
'portal_comments': ('author_username',),
|
||||
'portal_item_activity': ('actor_username',),
|
||||
'platform_issues': ('reporter_username',),
|
||||
'platform_issue_events': ('author_username',),
|
||||
'requests_cache': ('requested_by', 'requested_by_norm'),
|
||||
}
|
||||
|
||||
|
||||
def account_state(conn, ids):
|
||||
conn.row_factory = db.sqlite3.Row
|
||||
placeholders = ','.join('?' for _ in ids)
|
||||
return {table: [dict(row) for row in conn.execute(
|
||||
f'SELECT * FROM {table} WHERE {column} IN ({placeholders}) ORDER BY {column}', ids)]
|
||||
for table, column in [('users', 'id'), ('user_feature_permissions', 'user_id'),
|
||||
('email_recap_subscriptions', 'user_id'), ('newsletter_subscriptions', 'user_id')]}
|
||||
|
||||
|
||||
def build_preview(report, local, runtime, state, user_id, keep_id=None):
|
||||
target = next((row for row in report['rows'] if row['user']['id'] == user_id), None)
|
||||
if not target:
|
||||
raise HTTPException(404, 'This Magent account no longer exists. Run the check again.')
|
||||
group = [row for row in report['rows'] if review.name_key(row['user']['username']) == review.name_key(target['user']['username'])]
|
||||
ids = {row['user']['id'] for row in group}
|
||||
if len(ids) < 2:
|
||||
raise HTTPException(409, 'No same-name duplicate group remains. Run the account check again.')
|
||||
jf_id = target['candidate_jellyfin_id']
|
||||
source = source_key(runtime.jellyfin_base_url)
|
||||
owned = {link['local_user_id'] for link in local['links'] if link['source'] == source and review.normalized_id(link['jellyfin_user_id']) == jf_id}
|
||||
recommended = min(ids, key=lambda identity: (identity not in owned, identity))
|
||||
keep_id = keep_id or recommended
|
||||
if keep_id not in ids:
|
||||
raise HTTPException(400, 'Choose an account from this duplicate group to keep.')
|
||||
problems = []
|
||||
if any(report['services'].get(service) != 'available' for service in ('jellyfin', 'seerr', 'jellystat')):
|
||||
problems.append('Restore all three media-service connections before consolidating accounts.')
|
||||
if not target['jellyfin'] or target['jellystat']['state'] != 'matched' or len(target['seerr']) != 1:
|
||||
problems.append('One Jellyfin identity and one Seerr account must be verified against Jellystat.')
|
||||
if target['jellyfin'] and review.name_key(target['jellyfin']['name']) != review.name_key(target['user']['username']):
|
||||
problems.append('The current Jellyfin name does not match this duplicate group.')
|
||||
if len([account for account in report['jellyfin_users'] if review.name_key(account['name']) == review.name_key(target['user']['username'])]) != 1:
|
||||
problems.append('The name must identify exactly one current Jellyfin account.')
|
||||
seerr_id = target['seerr'][0]['id'] if len(target['seerr']) == 1 else None
|
||||
for row in group:
|
||||
if row['user']['role'] != 'user' or row['user']['auth_provider'] != 'jellyfin':
|
||||
problems.append('Only non-admin Jellyfin sign-in accounts can use duplicate consolidation.')
|
||||
if not jf_id or row['candidate_jellyfin_id'] != jf_id or row['user']['jellyseerr_user_id'] not in (None, seerr_id):
|
||||
problems.append('These rows do not all resolve to the same Jellyfin and Seerr identity.')
|
||||
for link in local['links']:
|
||||
if link['local_user_id'] in ids:
|
||||
if link['source'] != source or review.normalized_id(link['jellyfin_user_id']) != jf_id:
|
||||
problems.append('A duplicate has a different saved Jellyfin identity or server.')
|
||||
elif link['source'] == source and review.normalized_id(link['jellyfin_user_id']) == jf_id:
|
||||
problems.append('Another account or orphaned reservation owns this Jellyfin identity.')
|
||||
for item in local['confirmations']:
|
||||
if item['local_user_id'] in ids:
|
||||
if (item['jellyfin_server_id'] != report['server_id'] or item['jellyfin_user_id'] != jf_id
|
||||
or item['jellyfin_source'] != source or item['seerr_source'] != source_key(runtime.jellyseerr_base_url)
|
||||
or item['seerr_user_id'] != seerr_id):
|
||||
problems.append('A saved confirmation points to a different identity or server.')
|
||||
elif item['jellyfin_server_id'] == report['server_id'] and item['jellyfin_user_id'] == jf_id:
|
||||
problems.append('Another confirmation owns this identity.')
|
||||
if any(row['user']['id'] not in ids and (row['candidate_jellyfin_id'] == jf_id or
|
||||
(seerr_id is not None and row['user']['jellyseerr_user_id'] == seerr_id)) for row in report['rows']):
|
||||
problems.append('An account outside this same-name group also claims the identity.')
|
||||
accounts = [account for account in state['users'] if account['id'] in ids]
|
||||
kept = next(account for account in accounts if account['id'] == keep_id)
|
||||
overrides = {(entry['user_id'], entry['feature']): bool(entry['enabled']) for entry in state['user_feature_permissions']}
|
||||
features = {key: all(bool(account['invite_management_enabled']) if key == 'invites' else
|
||||
overrides.get((account['id'], key), True) for account in accounts) for key in FEATURES}
|
||||
expiries = [account['expires_at'] for account in accounts if account['expires_at']]
|
||||
try:
|
||||
expiry = min(expiries, key=lambda value: db._parse_datetime_value(value).timestamp()) if expiries else None
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
expiry = kept['expires_at']
|
||||
problems.append('An expiry date is invalid. Correct it before repairing duplicates.')
|
||||
proposed = {'id': keep_id, 'username': target['jellyfin']['name'] if target['jellyfin'] else kept['username'],
|
||||
'email': kept['email'], 'profile_id': kept['profile_id'], 'expires_at': expiry,
|
||||
'is_blocked': any(account['is_blocked'] for account in accounts),
|
||||
'auto_search_enabled': all(account['auto_search_enabled'] for account in accounts),
|
||||
'features': features, 'jellyfin_user_id': jf_id, 'seerr_user_id': seerr_id}
|
||||
public = [{key: account.get(key) for key in ('id', 'username', 'email', 'profile_id', 'last_login_at', 'created_at')}
|
||||
for account in accounts]
|
||||
return {'accounts': public, 'keep_id': keep_id, 'recommended_id': recommended, 'proposed': proposed,
|
||||
'issues': sorted(set(problems)), 'can_confirm': not problems,
|
||||
'revision': review.digest([report['revision'], state, keep_id, proposed])}
|
||||
|
||||
|
||||
async def prepare(user_id, keep_id=None):
|
||||
report, local, runtime = await review.review_identities()
|
||||
target = next((row for row in local['users'] if row['id'] == user_id), None)
|
||||
if not target:
|
||||
raise HTTPException(404, 'Account not found.')
|
||||
ids = sorted(row['id'] for row in local['users'] if review.name_key(row['username']) == review.name_key(target['username']))
|
||||
with closing(db._connect()) as conn:
|
||||
conn.execute('BEGIN')
|
||||
if review.digest(review.snapshot(conn)) != review.digest(local):
|
||||
raise HTTPException(409, 'Accounts changed during the check. Preview again.')
|
||||
state = account_state(conn, ids)
|
||||
return build_preview(report, local, runtime, state, user_id, keep_id), report, local, runtime, state
|
||||
|
||||
|
||||
def consolidate(preview, report, local, runtime, state, admin):
|
||||
if not preview['can_confirm']:
|
||||
raise HTTPException(409, 'This duplicate group cannot be consolidated. Review the listed conflicts.')
|
||||
ids = sorted(account['id'] for account in state['users'])
|
||||
keep = preview['keep_id']
|
||||
removed = [identity for identity in ids if identity != keep]
|
||||
values = preview['proposed']
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with closing(db._connect()) as conn, conn:
|
||||
conn.execute('BEGIN IMMEDIATE')
|
||||
if (review.digest(review.snapshot(conn)) != review.digest(local)
|
||||
or review.digest(account_state(conn, ids)) != review.digest(state)
|
||||
or review.config_digest(review.get_runtime_settings()) != review.config_digest(runtime)):
|
||||
raise HTTPException(409, 'Accounts, permissions or subscriptions changed. Preview again before saving.')
|
||||
for table in ('email_recap_deliveries', 'newsletter_deliveries'):
|
||||
if conn.execute(f"SELECT 1 FROM {table} WHERE user_id IN ({','.join('?' for _ in ids)}) AND state='sending'", ids).fetchone():
|
||||
raise HTTPException(409, 'An account email is currently being sent. Wait for delivery to finish, then preview again.')
|
||||
archive = {**state, 'links': [entry for entry in local['links'] if entry['local_user_id'] in ids],
|
||||
'confirmations': [entry for entry in local['confirmations'] if entry['local_user_id'] in ids],
|
||||
'proposed': values}
|
||||
conn.execute('INSERT INTO user_duplicate_repairs(kept_user_id,archive_json,repaired_by,repaired_at) VALUES(?,?,?,?)',
|
||||
(keep, json.dumps(archive, sort_keys=True), admin['username'], now))
|
||||
names = {account['username'] for account in state['users']}
|
||||
tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
for table, columns in NAME_REFERENCES.items():
|
||||
if table not in tables:
|
||||
continue
|
||||
for column in columns:
|
||||
old_values = {review.name_key(name) for name in names} if column == 'requested_by_norm' else names
|
||||
for name in old_values:
|
||||
new_value = review.name_key(values['username']) if column == 'requested_by_norm' else values['username']
|
||||
conn.execute(f'UPDATE {table} SET {column}=? WHERE {column}=? COLLATE BINARY', (new_value, name))
|
||||
activity = [dict(row) for row in conn.execute('SELECT * FROM user_activity') if review.name_key(row['username']) == review.name_key(values['username'])]
|
||||
for entry in activity:
|
||||
conn.execute('DELETE FROM user_activity WHERE id=?', (entry['id'],))
|
||||
for entry in activity:
|
||||
conn.execute('''INSERT INTO user_activity(username,ip,user_agent,first_seen_at,last_seen_at,hit_count)
|
||||
VALUES(?,?,?,?,?,?) ON CONFLICT(username,ip,user_agent) DO UPDATE SET
|
||||
first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at),
|
||||
hit_count=hit_count+excluded.hit_count''', (values['username'], entry['ip'], entry['user_agent'], entry['first_seen_at'], entry['last_seen_at'], entry['hit_count']))
|
||||
for name in names:
|
||||
conn.execute('DELETE FROM password_reset_tokens WHERE username=? COLLATE NOCASE', (name,))
|
||||
for identity in removed:
|
||||
# Duplicate subscriptions are not inherited. Preserve delivery history and cancel outstanding work.
|
||||
for table in ('email_recap_deliveries', 'newsletter_deliveries'):
|
||||
conn.execute(f"UPDATE {table} SET state='cancelled',detail='Duplicate account consolidated.' WHERE user_id=? AND state IN ('queued','retry','preparing')", (identity,))
|
||||
conn.execute(f'UPDATE {table} SET user_id=? WHERE user_id=?', (keep, identity))
|
||||
conn.execute('DELETE FROM jellyfin_user_links WHERE local_user_id=?', (identity,))
|
||||
conn.execute('DELETE FROM user_identity_confirmations WHERE local_user_id=?', (identity,))
|
||||
conn.execute('DELETE FROM users WHERE id=?', (identity,))
|
||||
last_login = max((account['last_login_at'] for account in state['users'] if account['last_login_at']), default=None)
|
||||
conn.execute('''UPDATE users SET username=?,jellyseerr_user_id=?,is_blocked=?,auto_search_enabled=?,
|
||||
invite_management_enabled=?,expires_at=?,last_login_at=? WHERE id=?''',
|
||||
(values['username'], values['seerr_user_id'], values['is_blocked'], values['auto_search_enabled'],
|
||||
values['features']['invites'], values['expires_at'], last_login, keep))
|
||||
for feature, enabled in values['features'].items():
|
||||
if feature != 'invites':
|
||||
conn.execute('''INSERT INTO user_feature_permissions VALUES(?,?,?)
|
||||
ON CONFLICT(user_id,feature) DO UPDATE SET enabled=excluded.enabled''', (keep, feature, int(enabled)))
|
||||
conn.execute('''INSERT INTO jellyfin_user_links VALUES(?,?,?) ON CONFLICT(source,local_user_id)
|
||||
DO UPDATE SET jellyfin_user_id=excluded.jellyfin_user_id''', (source_key(runtime.jellyfin_base_url), keep, values['jellyfin_user_id']))
|
||||
conn.execute('DELETE FROM user_identity_confirmations WHERE local_user_id=?', (keep,))
|
||||
conn.execute('''INSERT INTO user_identity_confirmations VALUES(?,?,?,?,?,?,?,?)''',
|
||||
(keep, report['server_id'], values['jellyfin_user_id'], source_key(runtime.jellyfin_base_url),
|
||||
source_key(runtime.jellyseerr_base_url), values['seerr_user_id'], now, admin['username']))
|
||||
return {'kept_user_id': keep, 'consolidated': len(removed), 'repaired_at': now}
|
||||
|
||||
|
||||
async def repair_duplicates(user_id, keep_id=None, revision=None, admin=None):
|
||||
preview, report, local, runtime, state = await prepare(user_id, keep_id)
|
||||
if revision is None:
|
||||
return preview
|
||||
if revision != preview['revision']:
|
||||
raise HTTPException(409, 'The duplicate-account preview changed. Preview again before saving.')
|
||||
return await asyncio.to_thread(consolidate, preview, report, local, runtime, state, admin)
|
||||
@@ -0,0 +1,168 @@
|
||||
import json
|
||||
import sqlite3
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from types import SimpleNamespace
|
||||
|
||||
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.feature_access import permissions, update_permissions
|
||||
from backend.app.routers import identities
|
||||
from backend.app.services import duplicate_accounts as duplicates, identity_review as review
|
||||
from backend.app.services.jellyfin_identity import link_user
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
JF, SERVER = 'a' * 32, 'b' * 32
|
||||
|
||||
|
||||
class DuplicateAccountTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
db.create_user('Viewer', 'Password-123456!', auth_provider='jellyfin', jellyseerr_user_id=42)
|
||||
self.keep = db.get_user_by_username('Viewer')['id']
|
||||
with db._connect() as conn:
|
||||
self.extra = conn.execute("""INSERT INTO users(username,password_hash,role,auth_provider,
|
||||
jellyseerr_user_id,created_at) VALUES('viewer ','old-hash','user','jellyfin',42,'2026-01-01')""").lastrowid
|
||||
self.runtime = SimpleNamespace(jellyfin_base_url='http://jf', jellyfin_api_key='test',
|
||||
jellyseerr_base_url='http://seerr', jellyseerr_api_key='test', jellystat_base_url='http://stats', jellystat_api_key='test')
|
||||
link_user('Viewer', JF, 'http://jf')
|
||||
self.jf = {'state': 'available', 'server_id': SERVER, 'users': [{'id': JF, 'name': 'Viewer'}]}
|
||||
self.seerr = {'state': 'available', 'users': [{'id': 42, 'name': 'Viewer', 'jellyfin_id': JF}]}
|
||||
for name, value in [('get_runtime_settings', self.runtime), ('jellyfin_directory', self.jf), ('seerr_directory', self.seerr)]:
|
||||
mocked = patch.object(review, name, return_value=value)
|
||||
mocked.start(); self.addCleanup(mocked.stop)
|
||||
mocked = patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock,
|
||||
return_value={JF: {'state': 'matched', 'id': JF}})
|
||||
mocked.start(); self.addCleanup(mocked.stop)
|
||||
|
||||
async def test_consolidation_preserves_history_and_restrictive_access(self):
|
||||
with db._connect() as conn:
|
||||
conn.execute('UPDATE users SET auto_search_enabled=0,expires_at=? WHERE id=?', ('2026-01-01T00:00:00+00:00', self.extra))
|
||||
conn.execute('INSERT INTO user_feature_permissions VALUES(?,?,?)', (self.extra, 'issues', 0))
|
||||
db.upsert_user_activity('Viewer', '127.0.0.1', 'test')
|
||||
db.upsert_user_activity('viewer ', '127.0.0.1', 'test')
|
||||
item = db.create_portal_item(kind='issue', title='Issue', description='History', created_by_username='viewer ', created_by_id=42)
|
||||
before = review.read_snapshot()
|
||||
preview = await duplicates.repair_duplicates(self.extra)
|
||||
self.assertEqual(review.read_snapshot(), before, 'Preview must not mutate accounts')
|
||||
self.assertTrue(preview['can_confirm'], preview['issues'])
|
||||
self.assertEqual(preview['keep_id'], self.keep)
|
||||
self.assertNotIn('old-hash', json.dumps(preview))
|
||||
result = await duplicates.repair_duplicates(self.extra, self.keep, preview['revision'], {'username': 'admin'})
|
||||
self.assertEqual(result['consolidated'], 1)
|
||||
self.assertIsNone(db.get_user_by_id(self.extra))
|
||||
user = db.get_user_by_username('Viewer')
|
||||
self.assertEqual(user['id'], self.keep)
|
||||
self.assertFalse(user['auto_search_enabled'])
|
||||
self.assertFalse(permissions(user)['issues'])
|
||||
self.assertTrue(user['is_expired'])
|
||||
self.assertEqual(db.get_portal_item(item['id'])['created_by_username'], 'Viewer')
|
||||
self.assertEqual(db.get_portal_item(item['id'])['created_by_id'], 42, 'IDs here belong to Seerr')
|
||||
with db._connect() as conn:
|
||||
self.assertEqual(conn.execute('SELECT SUM(hit_count) FROM user_activity').fetchone()[0], 2)
|
||||
archive = json.loads(conn.execute('SELECT archive_json FROM user_duplicate_repairs').fetchone()[0])
|
||||
self.assertEqual(len(archive['users']), 2)
|
||||
self.assertEqual(conn.execute('SELECT local_user_id FROM jellyfin_user_links').fetchone()[0], self.keep)
|
||||
report, _, _ = await review.review_identities()
|
||||
self.assertEqual(next(row for row in report['rows'] if row['user']['id'] == self.keep)['state'], 'confirmed')
|
||||
self.assertFalse(db.create_user_if_missing('VIEWER ', 'unused', auth_provider='jellyfin'))
|
||||
|
||||
async def test_choose_other_row_retains_its_settings_and_moves_link(self):
|
||||
with db._connect() as conn:
|
||||
conn.execute('UPDATE users SET email=? WHERE id=?', ('chosen@example.test', self.extra))
|
||||
preview = await duplicates.repair_duplicates(self.keep, self.extra)
|
||||
self.assertEqual(preview['proposed']['email'], 'chosen@example.test')
|
||||
await duplicates.repair_duplicates(self.keep, self.extra, preview['revision'], {'username': 'admin'})
|
||||
self.assertEqual(db.get_user_by_username('Viewer')['id'], self.extra)
|
||||
self.assertEqual(db.get_user_by_id(self.extra)['username'], 'Viewer')
|
||||
|
||||
async def test_changed_permission_or_identity_rejects_stale_preview(self):
|
||||
preview, report, local, runtime, state = await duplicates.prepare(self.keep)
|
||||
update_permissions({'stats': False}, 'Viewer')
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
duplicates.consolidate(preview, report, local, runtime, state, {'username': 'admin'})
|
||||
self.assertEqual(caught.exception.status_code, 409)
|
||||
self.assertIsNotNone(db.get_user_by_id(self.extra))
|
||||
self.seerr['users'][0]['jellyfin_id'] = 'c' * 32
|
||||
with self.assertRaises(HTTPException):
|
||||
await duplicates.repair_duplicates(self.keep, self.keep, preview['revision'], {'username': 'admin'})
|
||||
|
||||
async def test_conflicting_identities_admins_and_other_owners_are_blocked(self):
|
||||
with db._connect() as conn:
|
||||
conn.execute("UPDATE users SET role='admin' WHERE id=?", (self.extra,))
|
||||
self.assertFalse((await duplicates.repair_duplicates(self.keep))['can_confirm'])
|
||||
with db._connect() as conn:
|
||||
conn.execute("UPDATE users SET role='user',jellyseerr_user_id=99 WHERE id=?", (self.extra,))
|
||||
self.assertFalse((await duplicates.repair_duplicates(self.keep))['can_confirm'])
|
||||
with db._connect() as conn:
|
||||
conn.execute('UPDATE users SET jellyseerr_user_id=42 WHERE id=?', (self.extra,))
|
||||
db.create_user('Other', 'Password-123456!', auth_provider='jellyfin', jellyseerr_user_id=42)
|
||||
self.assertFalse((await duplicates.repair_duplicates(self.keep))['can_confirm'])
|
||||
|
||||
async def test_transaction_rolls_back_archive_and_history_on_failure(self):
|
||||
preview, report, local, runtime, state = await duplicates.prepare(self.keep)
|
||||
with db._connect() as conn:
|
||||
conn.execute("CREATE TRIGGER prevent_test_delete BEFORE DELETE ON users BEGIN SELECT RAISE(ABORT,'fixture failure'); END")
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
duplicates.consolidate(preview, report, local, runtime, state, {'username': 'admin'})
|
||||
self.assertIsNotNone(db.get_user_by_id(self.extra))
|
||||
with db._connect() as conn:
|
||||
self.assertEqual(conn.execute('SELECT COUNT(*) FROM user_duplicate_repairs').fetchone()[0], 0)
|
||||
|
||||
async def test_creation_rejects_case_and_whitespace_variants(self):
|
||||
for name in ('viewer', 'VIEWER', ' Viewer '):
|
||||
self.assertFalse(db.create_user_if_missing(name, 'unused'))
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
db.create_user(name, 'unused')
|
||||
|
||||
async def test_unresolved_whitespace_accounts_keep_distinct_lookup(self):
|
||||
self.assertEqual(db.get_user_by_username('Viewer')['id'], self.keep)
|
||||
self.assertEqual(db.get_user_by_username('viewer ')['id'], self.extra)
|
||||
self.assertIsNone(db.get_user_by_username(' Viewer '), 'Do not guess between unresolved identities')
|
||||
|
||||
async def test_concurrent_imports_create_only_one_normalized_account(self):
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
results = list(pool.map(lambda name: db.create_user_if_missing(name, 'Password-123456!'), ['New viewer', 'NEW VIEWER ']))
|
||||
self.assertEqual(sorted(results), [False, True])
|
||||
|
||||
def seed_delivery(self, state='queued'):
|
||||
with db._connect() as conn:
|
||||
for prefix in ('email_recap', 'newsletter'):
|
||||
for identity in (self.keep, self.extra):
|
||||
conn.execute(f'''INSERT INTO {prefix}_subscriptions(user_id,state,email,identity_source,identity_id,
|
||||
version,requested_at,unsubscribe_token) VALUES(?,?,?,?,?,?,?,?)''',
|
||||
(identity, 'enabled', 'viewer@example.test', review.source_key('http://jf'), JF, str(identity), 1, prefix + str(identity)))
|
||||
period = {'month': '2026-08'} if prefix == 'email_recap' else {'edition_id': 'edition', 'edition_revision': 1}
|
||||
values = {'id': prefix, 'dedupe_key': prefix, 'user_id': self.extra, **period, 'kind': 'test',
|
||||
'email': 'viewer@example.test', 'subscription_version': str(self.extra), 'public_url': 'https://example.test',
|
||||
'state': state, 'created_at': 1, 'updated_at': 1, 'next_attempt_at': 1}
|
||||
conn.execute(f"INSERT INTO {prefix}_deliveries({','.join(values)}) VALUES({','.join('?' for _ in values)})", tuple(values.values()))
|
||||
|
||||
async def test_email_history_retained_pending_cancelled_and_consent_not_inherited(self):
|
||||
self.seed_delivery()
|
||||
preview = await duplicates.repair_duplicates(self.extra)
|
||||
await duplicates.repair_duplicates(self.extra, self.keep, preview['revision'], {'username': 'admin'})
|
||||
with db._connect() as conn:
|
||||
for prefix in ('email_recap', 'newsletter'):
|
||||
delivery = conn.execute(f'SELECT user_id,state FROM {prefix}_deliveries').fetchone()
|
||||
self.assertEqual(delivery, (self.keep, 'cancelled'))
|
||||
subs = conn.execute(f'SELECT user_id,state FROM {prefix}_subscriptions').fetchall()
|
||||
self.assertEqual(subs, [(self.keep, 'enabled')])
|
||||
|
||||
async def test_sending_email_blocks_repair_without_removing_accounts(self):
|
||||
self.seed_delivery('sending')
|
||||
preview = await duplicates.repair_duplicates(self.extra)
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
await duplicates.repair_duplicates(self.extra, self.keep, preview['revision'], {'username': 'admin'})
|
||||
self.assertEqual(caught.exception.status_code, 409)
|
||||
self.assertIsNotNone(db.get_user_by_id(self.extra))
|
||||
|
||||
async def test_duplicate_endpoints_are_admin_only(self):
|
||||
app = FastAPI(); app.include_router(identities.router)
|
||||
app.dependency_overrides[get_current_user] = lambda: {'username': 'viewer', 'role': 'user'}
|
||||
with TestClient(app) as client:
|
||||
for path in ('check', 'confirm'):
|
||||
self.assertEqual(client.post('/admin/identities/duplicates/' + path, json={'user_id': self.keep}).status_code, 403)
|
||||
@@ -281,7 +281,9 @@ class IdentityReviewTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
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")
|
||||
# Legacy duplicate predates the normalized-name creation guard.
|
||||
with db._connect() as conn:
|
||||
conn.execute("INSERT INTO users(username,password_hash,role,auth_provider,created_at) VALUES('georgia','unused','user','jellyfin','2026-01-01')")
|
||||
report, _ = self.build()
|
||||
self.assertEqual(report["counts"]["conflict"], 2)
|
||||
self.assertEqual(report["counts"]["ready"], 0)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Duplicate account repair
|
||||
|
||||
Open **Configuration → User management → Account links & repairs**, run **Check all user IDs**, then choose **Repair duplicate accounts** on a same-name conflict. An individual user's management overlay also links to this view with their username prefilled.
|
||||
|
||||
The preview recommends the Magent row that already owns the Jellyfin link, or the oldest row if none does. Administrators can select a different row from the group. Confirmation requires an explicit acknowledgement that the rows belong to the same person.
|
||||
|
||||
Eligibility requires a single current Jellyfin account for the normalized name, one Seerr account mapped to that Jellyfin ID, and the same ID verified in Jellystat. Every member must be a non-admin Jellyfin sign-in account resolving to that identity. Different stored IDs, other servers, orphaned reservations, ownership outside the group, and unavailable services block repair. Similar names alone are insufficient.
|
||||
|
||||
The transaction:
|
||||
|
||||
- Archives the account records, settings, subscriptions and identity links in `user_duplicate_repairs`, recording the administrator and timestamp. This internal archive includes credential fields and is never returned through the preview API.
|
||||
- Keeps the selected Magent ID, email and profile, and uses the current verified Jellyfin username.
|
||||
- Preserves the most restrictive feature permissions, automatic-search setting, invitation access, any block and the earliest expiry.
|
||||
- Consolidates username references for requests, issues, comments, invitations and login activity. Seerr request IDs and Seerr author IDs remain unchanged.
|
||||
- Retains email delivery history, cancels outstanding deliveries from retired rows, and does not inherit their subscriptions. The retained account's own subscriptions remain subject to the normal identity and access checks. Sending emails block repair until they finish.
|
||||
- Invalidates existing password-reset links, removes the extra active Magent rows, and confirms the retained account's verified service links. Affected users may need to sign in again.
|
||||
|
||||
Jellyfin, Seerr and Jellystat accounts, media and upstream history are not modified. There is no automatic bulk merge or self-service undo. The archive supports administrative investigation; unrelated or renamed identities require separate review.
|
||||
|
||||
Both preview and confirmation recheck live service mappings. A transaction rechecks local identity state, permissions, subscriptions and connection settings before writing. Stale previews fail with HTTP 409. Account creation/import checks normalized usernames under a SQLite write lock to prevent concurrent case/whitespace duplicates from recurring.
|
||||
|
||||
Validation: temporary-database tests cover history, permissions, consent, rollback, concurrent creation, stale previews and ownership conflicts. `scripts/review_duplicate_accounts_ui.cjs` checks desktop/mobile UI and confirmation using intercepted API fixtures only.
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { authFetch, getApiBase } from '../../lib/auth'
|
||||
import { FEATURES, type FeatureAccess } from '../../lib/features'
|
||||
import type { Row } from './IdentityReviewPanel'
|
||||
|
||||
type Account = { id: number; username: string; email: string | null; profile_id: number | null; last_login_at: string | null }
|
||||
type Preview = {
|
||||
accounts: Account[]; keep_id: number; recommended_id: number; revision: string; can_confirm: boolean; issues: string[]
|
||||
proposed: Account & { jellyfin_user_id: string; seerr_user_id: number; features: FeatureAccess; expires_at: string | null; is_blocked: boolean; auto_search_enabled: boolean }
|
||||
}
|
||||
|
||||
export default function DuplicateAccountRepair({ row, onClose, onSaved }: { row: Row; onClose: () => void; onSaved: () => void }) {
|
||||
const dialog = useRef<HTMLDialogElement>(null)
|
||||
const controller = useRef<AbortController | null>(null)
|
||||
const [preview, setPreview] = useState<Preview | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [acknowledged, setAcknowledged] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const submit = async (confirm = false, keepId?: number) => {
|
||||
const abort = new AbortController()
|
||||
controller.current?.abort(); controller.current = abort
|
||||
setError(''); setAcknowledged(false)
|
||||
if (confirm) setSaving(true)
|
||||
else setBusy(true)
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/admin/identities/duplicates/${confirm ? 'confirm' : 'check'}`, {
|
||||
method: 'POST', signal: abort.signal, headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: row.user.id, ...(keepId ? { keep_id: keepId } : {}), ...(confirm ? { keep_id: preview?.keep_id, revision: preview?.revision } : {}) }),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (!response.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'Could not review these accounts.')
|
||||
if (!abort.signal.aborted) { if (confirm) onSaved(); else setPreview(data) }
|
||||
} catch (err) { if (!abort.signal.aborted) { setError(err instanceof Error ? err.message : 'Repair failed. Preview again.'); setPreview(null) } }
|
||||
finally { if (!abort.signal.aborted) { setBusy(false); setSaving(false) } }
|
||||
}
|
||||
useEffect(() => {
|
||||
const previous = document.activeElement as HTMLElement | null
|
||||
const overflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'; dialog.current?.showModal()
|
||||
void submit()
|
||||
return () => { controller.current?.abort(); document.body.style.overflow = overflow; previous?.focus() }
|
||||
}, [])
|
||||
return <dialog ref={dialog} className="identity-resolve-dialog" aria-labelledby="duplicates-title" onCancel={(event) => { event.preventDefault(); if (!saving) onClose() }}>
|
||||
<div className="identity-resolve-content">
|
||||
<header><h2 id="duplicates-title">Repair duplicate accounts</h2><button type="button" className="ghost-button" disabled={saving} onClick={onClose}>Close</button></header>
|
||||
<p>Review the Magent accounts for <strong>{row.user.username}</strong>. This repair keeps one account linked to the verified Jellyfin identity.</p>
|
||||
{busy && <p role="status">Checking live service IDs and duplicate ownership...</p>}
|
||||
{error && <p className="error-banner" role="alert">{error}</p>}
|
||||
{!preview && !busy && <button type="button" disabled={saving} onClick={() => void submit()}>Check again</button>}
|
||||
{preview && <section className="identity-confirm-panel" aria-label="Duplicate repair preview">
|
||||
<label>Magent account to keep<select disabled={busy || saving} value={preview.keep_id} onChange={(event) => void submit(false, Number(event.target.value))}>
|
||||
{preview.accounts.map((account) => <option key={account.id} value={account.id}>{account.username} — Magent {account.id}{account.id === preview.recommended_id ? ' (recommended)' : ''}</option>)}
|
||||
</select></label>
|
||||
<p>The recommended row already owns the Jellyfin link, or is the oldest row when neither owns it.</p>
|
||||
<div className="identity-mapping">{preview.accounts.map((account) => <div key={account.id}><strong>Magent {account.id}{account.id === preview.keep_id ? ' · Keep' : ' · Consolidate'}</strong><p>{account.username}</p><p>{account.email || 'No email'} · Profile {account.profile_id ?? 'None'}</p><p>Last login: {account.last_login_at ? new Date(account.last_login_at).toLocaleString() : 'Never'}</p></div>)}</div>
|
||||
<h3>Resulting account</h3>
|
||||
<p><strong>{preview.proposed.username}</strong> · Magent {preview.keep_id} · Seerr {preview.proposed.seerr_user_id ?? 'Not verified'}</p>
|
||||
<p>Jellyfin / Jellystat: <code>{preview.proposed.jellyfin_user_id ?? 'Not verified'}</code></p>
|
||||
<p>Email: {preview.proposed.email || 'None'} · Profile: {preview.proposed.profile_id ?? 'None'}</p>
|
||||
<p>Access: {preview.proposed.is_blocked ? 'Blocked' : 'Not blocked'} · Expiry: {preview.proposed.expires_at ? new Date(preview.proposed.expires_at).toLocaleString() : 'None'} · Automatic search: {preview.proposed.auto_search_enabled ? 'Enabled' : 'Disabled'}</p>
|
||||
<ul>{FEATURES.map((feature) => <li key={feature.key}>{feature.label}: {preview.proposed.features[feature.key] ? 'Enabled' : 'Disabled'}</li>)}</ul>
|
||||
<p>Request, issue, invitation and login activity history is retained. The selected account keeps its email and profile. Any block, earlier expiry or disabled permission on either row is preserved.</p>
|
||||
<p>Extra Magent rows are removed from the active directory after their details are archived. Their outstanding emails are cancelled and their email subscriptions are not inherited. The kept account retains its own subscriptions where still eligible. Password reset links must be requested again.</p>
|
||||
<p>Jellyfin, Seerr and Jellystat accounts and media are unchanged. This action does not merge different Jellyfin identities or delete upstream users.</p>
|
||||
{preview.issues.length > 0 && <ul className="identity-issues">{preview.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>}
|
||||
<label className="identity-import-option"><span><input type="checkbox" checked={acknowledged} disabled={busy || saving || !preview.can_confirm} onChange={(event) => setAcknowledged(event.target.checked)} /> I confirm these rows belong to the same person and have reviewed the account to keep.</span></label>
|
||||
<button type="button" disabled={!preview.can_confirm || !acknowledged || busy || saving} onClick={() => void submit(true)}>{saving ? 'Rechecking and repairing...' : 'Confirm duplicate repair'}</button>
|
||||
</section>}
|
||||
</div>
|
||||
</dialog>
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, getApiBase } from '../../lib/auth'
|
||||
import './identities.css'
|
||||
import DuplicateAccountRepair from './DuplicateAccountRepair'
|
||||
import ResolveIdentityLink from './ResolveIdentityLink'
|
||||
|
||||
type Identity = { id: string; name: string }
|
||||
@@ -44,12 +45,14 @@ export default function IdentityReviewPanel() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [duplicates, setDuplicates] = useState<Row | null>(null)
|
||||
const [resolving, setResolving] = useState<Row | null>(null)
|
||||
const [reviewing, setReviewing] = useState(false)
|
||||
const controller = useRef<AbortController | null>(null)
|
||||
const reviewPanel = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(new URLSearchParams(window.location.search).get('user') ?? '')
|
||||
const abort = new AbortController()
|
||||
void authFetch(`${getApiBase()}/auth/me`, { signal: abort.signal }).then(async (response) => {
|
||||
if (response.status === 401) { router.replace('/login'); return }
|
||||
@@ -150,7 +153,7 @@ export default function IdentityReviewPanel() {
|
||||
<div><dt>Jellystat user ID</dt><dd><code>{row.jellystat.id ?? 'Not verified'}</code><span>{statsLabels[row.jellystat.state] ?? row.jellystat.state}</span></dd></div>
|
||||
</dl>
|
||||
{row.issues.length > 0 && <ul className="identity-issues">{row.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>}
|
||||
{(row.state === 'unlinked' || row.state === 'conflict') && <div className="identity-resolution-entry"><p className="identity-meta">Compare the correct Jellyfin identity with the stored links and review the smallest safe repair.</p><button type="button" className="ghost-button" disabled={saving || report.services.jellyfin !== 'available'} onClick={() => setResolving(row)}>Review repair</button></div>}
|
||||
{(row.state === 'unlinked' || row.state === 'conflict') && <div className="identity-resolution-entry"><p className="identity-meta">Compare the correct Jellyfin identity with the stored links and review the smallest safe repair.</p><button type="button" className="ghost-button" disabled={saving || report.services.jellyfin !== 'available'} onClick={() => setResolving(row)}>Review repair</button>{row.issues.some((issue) => issue.includes("share this username")) && <button type="button" className="ghost-button" disabled={saving} onClick={() => setDuplicates(row)}>Repair duplicate accounts</button>}</div>}
|
||||
{row.state === 'unavailable' && <p className="identity-meta">A required service could not be checked. Check its connection and run this again.</p>}
|
||||
{row.confirmed_at && <p className="identity-meta">Last confirmed {new Date(row.confirmed_at).toLocaleString()}</p>}
|
||||
</article>)}
|
||||
@@ -158,6 +161,7 @@ export default function IdentityReviewPanel() {
|
||||
{report.upstream.length > 0 && <details className="identity-upstream"><summary>{report.upstream.length} upstream accounts need review</summary><ul>{report.upstream.map((entry) => <li key={`${entry.platform}-${entry.id}`}><strong>{entry.platform}: {entry.name}</strong> · ID <code>{entry.id}</code>{entry.jellyfin_id && <span> · Jellyfin <code>{entry.jellyfin_id}</code></span>}<p>{entry.detail}</p></li>)}</ul></details>}
|
||||
</>}
|
||||
</>}
|
||||
{duplicates && <DuplicateAccountRepair row={duplicates} onClose={() => setDuplicates(null)} onSaved={() => { setDuplicates(null); void runCheck().then(() => setNotice('Duplicate accounts repaired. History retained and links rechecked.')) }} />}
|
||||
{resolving && report && <ResolveIdentityLink row={resolving} accounts={report.jellyfin_users} onClose={() => setResolving(null)} onSaved={() => {
|
||||
setResolving(null); setReport(null); setSelected([]); setReviewing(false)
|
||||
setNotice('Account links repaired and saved. Run another check to see the updated mappings.')
|
||||
|
||||
@@ -587,6 +587,7 @@ export default function UserDetailPage() {
|
||||
<header className="user-management-heading"><div><h2 id="manage-this-user-title">Manage {user.username}</h2><p>Feature access, account settings and account restrictions.</p></div><button type="button" className="ghost-button" onClick={() => setManageOpen(false)}>Close</button></header>
|
||||
{error && <p className="error-banner" role="alert">{error}</p>}
|
||||
{actionStatus && <p className="status-banner" role="status">{actionStatus}</p>}
|
||||
<p><a className="ghost-button" href={`/users?view=identities&user=${encodeURIComponent(user.username)}`}>Review service links & duplicate accounts</a></p>
|
||||
{manageOpen && <FeatureControls key={user.role} username={user.username} onSaved={() => void loadUser()} />}
|
||||
<div className="user-management-grid">
|
||||
<div className="admin-panel user-detail-panel">
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_PACKAGE || 'playwright');
|
||||
const base = process.env.REVIEW_BASE || 'http://localhost:3114';
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const context = await browser.newContext();
|
||||
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }]);
|
||||
const rows = [36, 1880].map(id => ({ user: { id, username: 'DeaTH_TaXi', role: 'user', auth_provider: 'jellyfin', jellyseerr_user_id: 13 },
|
||||
jellyfin: { id: 'a'.repeat(32), name: 'DeaTH_TaXi' }, candidate_jellyfin_id: 'a'.repeat(32), seerr: [{ id: 13, name: 'DeaTH_TaXi' }],
|
||||
jellystat: { state: 'matched', id: 'a'.repeat(32) }, issues: ['Multiple Magent rows share this username after case and whitespace normalization.'], state: 'conflict' }));
|
||||
let blocked = false, confirmed = false, checks = 0; const writes = [];
|
||||
await context.route('**/api/**', async route => {
|
||||
const request = route.request(), path = new URL(request.url()).pathname;
|
||||
if (path === '/api/auth/me') return route.fulfill({ json: { username: 'Admin', role: 'admin' } });
|
||||
if (path === '/api/admin/identities') { checks++; return route.fulfill({ json: { rows: confirmed ? [] : rows, counts: { magent: 2, conflict: 2 }, services: { jellyfin: 'available', seerr: 'available', jellystat: 'available' }, jellyfin_users: [{ id: 'a'.repeat(32), name: 'DeaTH_TaXi' }], upstream: [] } }); }
|
||||
if (path.endsWith('/duplicates/check')) {
|
||||
const keep = request.postDataJSON().keep_id || 36;
|
||||
return route.fulfill({ json: { accounts: rows.map(row => ({ ...row.user, email: 'viewer@example.test', profile_id: null, last_login_at: null })), keep_id: keep, recommended_id: 36, revision: String(keep), can_confirm: !blocked, issues: blocked ? ['Another account owns this identity.'] : [],
|
||||
proposed: { id: keep, username: 'DeaTH_TaXi', email: 'viewer@example.test', jellyfin_user_id: 'a'.repeat(32), seerr_user_id: 13, features: { stats: true, requests: true, new_requests: true, issues: false, invites: false }, auto_search_enabled: false } } });
|
||||
}
|
||||
if (path.endsWith('/duplicates/confirm')) { writes.push(request.postDataJSON()); confirmed = true; return route.fulfill({ json: { consolidated: 1, kept_user_id: 1880 } }); }
|
||||
return route.fulfill({ json: {} });
|
||||
});
|
||||
const page = await context.newPage(), errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
for (const width of [1440, 390]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
await page.goto(base + '/users?view=identities&user=DeaTH_TaXi');
|
||||
await page.getByRole('button', { name: 'Check all user IDs', exact: true }).click();
|
||||
const trigger = page.getByRole('button', { name: 'Repair duplicate accounts', exact: true }).first();
|
||||
await trigger.click(); const dialog = page.getByRole('dialog');
|
||||
await dialog.getByLabel('Magent account to keep').waitFor();
|
||||
assert.equal(await dialog.getByLabel('Magent account to keep').inputValue(), '36');
|
||||
assert(await dialog.getByRole('button', { name: 'Confirm duplicate repair' }).isDisabled());
|
||||
assert(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth), 'No horizontal overflow');
|
||||
await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' });
|
||||
assert(await trigger.evaluate(el => el === document.activeElement));
|
||||
}
|
||||
blocked = true;
|
||||
await page.getByRole('button', { name: 'Repair duplicate accounts', exact: true }).first().click();
|
||||
let dialog = page.getByRole('dialog');
|
||||
await dialog.getByText('Another account owns this identity.', { exact: true }).waitFor();
|
||||
assert(await dialog.getByRole('checkbox').isDisabled());
|
||||
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
|
||||
blocked = false;
|
||||
await page.getByRole('button', { name: 'Repair duplicate accounts', exact: true }).first().click(); dialog = page.getByRole('dialog');
|
||||
await dialog.getByLabel('Magent account to keep').selectOption('1880');
|
||||
await dialog.getByText('Magent 1880 · Keep', { exact: true }).waitFor();
|
||||
await dialog.getByRole('checkbox').check();
|
||||
const previousChecks = checks;
|
||||
await dialog.getByRole('button', { name: 'Confirm duplicate repair' }).click();
|
||||
await dialog.waitFor({ state: 'hidden' });
|
||||
await page.getByRole('button', { name: 'Run check again', exact: true }).waitFor();
|
||||
assert.deepEqual(writes, [{ user_id: 36, keep_id: 1880, revision: '1880' }]);
|
||||
assert(checks > previousChecks, 'Identity report refreshed');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('Passed: desktop/mobile duplicate review, recommended account, changed selection, explicit confirmation, conflict blocking, focus restoration and report refresh. All APIs intercepted.');
|
||||
} finally { await browser.close(); }
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
Reference in New Issue
Block a user