from __future__ import annotations import pytest from fastapi.testclient import TestClient from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from app.main import app from app.settings import settings from tests.integration.helpers import ( api_bootstrap_csrf_headers, api_csrf_headers, insert_user, login_user, seed_publication_link_for_user, ) @pytest.mark.integration @pytest.mark.db @pytest.mark.asyncio async def test_api_admin_user_management_endpoints(db_session: AsyncSession) -> None: admin_user_id = await insert_user( db_session, email="api-admin@example.com", password="admin-password", is_admin=True, ) target_user_id = await insert_user( db_session, email="api-target@example.com", password="target-password", is_admin=False, ) await insert_user( db_session, email="api-member@example.com", password="member-password", is_admin=False, ) client = TestClient(app) login_user(client, email="api-admin@example.com", password="admin-password") headers = api_csrf_headers(client) list_response = client.get("/api/v1/admin/users") assert list_response.status_code == 200 users = list_response.json()["data"]["users"] assert any(item["email"] == "api-target@example.com" for item in users) create_response = client.post( "/api/v1/admin/users", json={ "email": "api-created@example.com", "password": "created-password", "is_admin": True, }, headers=headers, ) assert create_response.status_code == 201 created_user = create_response.json()["data"] assert created_user["email"] == "api-created@example.com" created_user_id = int(created_user["id"]) deactivate_response = client.patch( f"/api/v1/admin/users/{target_user_id}/active", json={"is_active": False}, headers=headers, ) assert deactivate_response.status_code == 200 assert deactivate_response.json()["data"]["is_active"] is False reactivate_response = client.patch( f"/api/v1/admin/users/{target_user_id}/active", json={"is_active": True}, headers=headers, ) assert reactivate_response.status_code == 200 assert reactivate_response.json()["data"]["is_active"] is True reset_response = client.post( f"/api/v1/admin/users/{target_user_id}/reset-password", json={"new_password": "target-password-updated"}, headers=headers, ) assert reset_response.status_code == 200 assert "Password reset" in reset_response.json()["data"]["message"] self_deactivate = client.patch( f"/api/v1/admin/users/{admin_user_id}/active", json={"is_active": False}, headers=headers, ) assert self_deactivate.status_code == 400 assert self_deactivate.json()["error"]["code"] == "cannot_deactivate_self" logout_response = client.post("/api/v1/auth/logout", headers=headers) assert logout_response.status_code == 200 target_headers = api_bootstrap_csrf_headers(client) target_login = client.post( "/api/v1/auth/login", json={"email": "api-target@example.com", "password": "target-password-updated"}, headers=target_headers, ) assert target_login.status_code == 200 non_admin_headers = {"X-CSRF-Token": target_login.json()["data"]["csrf_token"]} forbidden_response = client.get("/api/v1/admin/users") assert forbidden_response.status_code == 403 assert forbidden_response.json()["error"]["code"] == "forbidden" forbidden_create = client.post( "/api/v1/admin/users", json={ "email": "should-not-work@example.com", "password": "password-123", "is_admin": False, }, headers=non_admin_headers, ) assert forbidden_create.status_code == 403 created_exists = await db_session.execute( text("SELECT COUNT(*) FROM users WHERE id = :user_id"), {"user_id": created_user_id}, ) assert created_exists.scalar_one() == 1 @pytest.mark.integration @pytest.mark.db @pytest.mark.asyncio async def test_api_admin_scholar_http_settings_endpoints(db_session: AsyncSession) -> None: await insert_user( db_session, email="api-admin-http@example.com", password="admin-password", is_admin=True, ) await insert_user( db_session, email="api-member-http@example.com", password="member-password", is_admin=False, ) previous_user_agent = settings.scholar_http_user_agent previous_rotate = settings.scholar_http_rotate_user_agent previous_accept_language = settings.scholar_http_accept_language previous_cookie = settings.scholar_http_cookie try: client = TestClient(app) login_user(client, email="api-admin-http@example.com", password="admin-password") headers = api_csrf_headers(client) read_response = client.get("/api/v1/admin/settings/scholar-http") assert read_response.status_code == 200 update_response = client.put( "/api/v1/admin/settings/scholar-http", json={ "user_agent": "Mozilla/5.0 Test Runner", "rotate_user_agent": False, "accept_language": "en-US,en;q=0.8", "cookie": "SID=test-cookie", }, headers=headers, ) assert update_response.status_code == 200 payload = update_response.json()["data"] assert payload["user_agent"] == "Mozilla/5.0 Test Runner" assert payload["cookie"] == "SID=test-cookie" assert settings.scholar_http_user_agent == "Mozilla/5.0 Test Runner" client.post("/api/v1/auth/logout", headers=headers) login_user(client, email="api-member-http@example.com", password="member-password") forbidden_response = client.get("/api/v1/admin/settings/scholar-http") assert forbidden_response.status_code == 403 finally: object.__setattr__(settings, "scholar_http_user_agent", previous_user_agent) object.__setattr__(settings, "scholar_http_rotate_user_agent", previous_rotate) object.__setattr__(settings, "scholar_http_accept_language", previous_accept_language) object.__setattr__(settings, "scholar_http_cookie", previous_cookie) @pytest.mark.integration @pytest.mark.db @pytest.mark.asyncio async def test_api_admin_dbops_integrity_and_repair_flow(db_session: AsyncSession) -> None: await insert_user(db_session, email="api-admin-dbops@example.com", password="admin-password", is_admin=True) target_user_id = await insert_user(db_session, email="api-dbops-target@example.com", password="target-password") _scholar_id, publication_id = await seed_publication_link_for_user( db_session, user_id=target_user_id, scholar_id="dbopsTarget01", title="DB Ops Target Paper", fingerprint=f"{(target_user_id + 31):064x}", ) client = TestClient(app) login_user(client, email="api-admin-dbops@example.com", password="admin-password") headers = api_csrf_headers(client) integrity_response = client.get("/api/v1/admin/db/integrity") assert integrity_response.status_code == 200 integrity_payload = integrity_response.json()["data"] assert integrity_payload["status"] in {"ok", "warning", "failed"} assert any(check["name"] == "missing_pdf_url" for check in integrity_payload["checks"]) repair_response = client.post( "/api/v1/admin/db/repairs/publication-links", json={"user_id": target_user_id, "dry_run": True}, headers=headers, ) assert repair_response.status_code == 200 repair_data = repair_response.json()["data"] assert repair_data["status"] == "completed" assert bool(repair_data["summary"]["dry_run"]) is True job_id = int(repair_data["job_id"]) jobs_response = client.get("/api/v1/admin/db/repair-jobs?limit=10") assert jobs_response.status_code == 200 jobs = jobs_response.json()["data"]["jobs"] assert any(int(job["id"]) == job_id for job in jobs) pdf_queue_response = client.get("/api/v1/admin/db/pdf-queue?limit=20") assert pdf_queue_response.status_code == 200 pdf_queue_payload = pdf_queue_response.json()["data"] assert isinstance(pdf_queue_payload["items"], list) assert int(pdf_queue_payload["page"]) == 1 assert int(pdf_queue_payload["page_size"]) == 20 assert int(pdf_queue_payload["total_count"]) >= 0 assert isinstance(pdf_queue_payload["has_next"], bool) assert isinstance(pdf_queue_payload["has_prev"], bool) page_two_response = client.get("/api/v1/admin/db/pdf-queue?page=2&page_size=1") assert page_two_response.status_code == 200 page_two_payload = page_two_response.json()["data"] assert int(page_two_payload["page"]) == 2 assert int(page_two_payload["page_size"]) == 1 assert page_two_payload["has_prev"] is True untracked_response = client.get("/api/v1/admin/db/pdf-queue?limit=20&status=untracked") assert untracked_response.status_code == 200 assert any(item["status"] == "untracked" for item in untracked_response.json()["data"]["items"]) link_count_result = await db_session.execute( text("SELECT count(*) FROM scholar_publications WHERE publication_id = :publication_id"), {"publication_id": publication_id}, ) assert int(link_count_result.scalar_one()) == 1 @pytest.mark.integration @pytest.mark.db @pytest.mark.asyncio async def test_api_admin_dbops_pdf_queue_requeue_endpoint( db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, ) -> None: await insert_user(db_session, email="api-admin-pdf@example.com", password="admin-password", is_admin=True) target_user_id = await insert_user(db_session, email="api-pdf-target@example.com", password="target-password") _scholar_id, publication_id = await seed_publication_link_for_user( db_session, user_id=target_user_id, scholar_id="dbopsPdf01", title="PDF Queue Target", fingerprint=f"{(target_user_id + 73):064x}", ) monkeypatch.setattr( "app.services.publications.pdf_queue.schedule_rows", lambda **_kwargs: None, ) client = TestClient(app) login_user(client, email="api-admin-pdf@example.com", password="admin-password") headers = api_csrf_headers(client) first_response = client.post( f"/api/v1/admin/db/pdf-queue/{publication_id}/requeue", headers=headers, ) assert first_response.status_code == 200 first_data = first_response.json()["data"] assert first_data["publication_id"] == publication_id assert first_data["queued"] is True assert first_data["status"] == "queued" second_response = client.post( f"/api/v1/admin/db/pdf-queue/{publication_id}/requeue", headers=headers, ) assert second_response.status_code == 200 second_data = second_response.json()["data"] assert second_data["queued"] is False assert second_data["status"] == "blocked" @pytest.mark.integration @pytest.mark.db @pytest.mark.asyncio async def test_api_admin_dbops_pdf_queue_requeue_all_endpoint( db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, ) -> None: await insert_user(db_session, email="api-admin-pdf-all@example.com", password="admin-password", is_admin=True) target_user_id = await insert_user(db_session, email="api-pdf-all-target@example.com", password="target-password") _scholar_id, _publication_id = await seed_publication_link_for_user( db_session, user_id=target_user_id, scholar_id="dbopsPdfAll01", title="PDF Queue All Target", fingerprint=f"{(target_user_id + 83):064x}", ) monkeypatch.setattr( "app.services.publications.pdf_queue.schedule_rows", lambda **_kwargs: None, ) client = TestClient(app) login_user(client, email="api-admin-pdf-all@example.com", password="admin-password") headers = api_csrf_headers(client) response = client.post( "/api/v1/admin/db/pdf-queue/requeue-all?limit=500", headers=headers, ) assert response.status_code == 200 payload = response.json()["data"] assert int(payload["requested_count"]) >= 1 assert int(payload["queued_count"]) >= 1 @pytest.mark.integration @pytest.mark.db @pytest.mark.asyncio async def test_api_admin_dbops_forbidden_for_non_admin_and_validates_scope(db_session: AsyncSession) -> None: await insert_user(db_session, email="api-admin-dbops2@example.com", password="admin-password", is_admin=True) member_user_id = await insert_user(db_session, email="api-dbops-member@example.com", password="member-password") client = TestClient(app) login_user(client, email="api-dbops-member@example.com", password="member-password") headers = api_csrf_headers(client) forbidden_integrity = client.get("/api/v1/admin/db/integrity") assert forbidden_integrity.status_code == 403 assert forbidden_integrity.json()["error"]["code"] == "forbidden" # PDF queue listing is accessible to all authenticated users (not admin-only). allowed_pdf_queue = client.get("/api/v1/admin/db/pdf-queue") assert allowed_pdf_queue.status_code == 200 # But requeue actions still require admin. forbidden_requeue = client.post( "/api/v1/admin/db/pdf-queue/1/requeue", headers=headers, ) assert forbidden_requeue.status_code == 403 assert forbidden_requeue.json()["error"]["code"] == "forbidden" forbidden_requeue_all = client.post( "/api/v1/admin/db/pdf-queue/requeue-all?limit=100", headers=headers, ) assert forbidden_requeue_all.status_code == 403 assert forbidden_requeue_all.json()["error"]["code"] == "forbidden" forbidden_repair = client.post( "/api/v1/admin/db/repairs/publication-links", json={"user_id": member_user_id, "dry_run": True}, headers=headers, ) assert forbidden_repair.status_code == 403 assert forbidden_repair.json()["error"]["code"] == "forbidden" forbidden_near_duplicate = client.post( "/api/v1/admin/db/repairs/publication-near-duplicates", json={"dry_run": True}, headers=headers, ) assert forbidden_near_duplicate.status_code == 403 assert forbidden_near_duplicate.json()["error"]["code"] == "forbidden" admin_headers = api_bootstrap_csrf_headers(client) admin_login = client.post( "/api/v1/auth/login", json={"email": "api-admin-dbops2@example.com", "password": "admin-password"}, headers=admin_headers, ) assert admin_login.status_code == 200 post_login_headers = {"X-CSRF-Token": admin_login.json()["data"]["csrf_token"]} invalid_scope = client.post( "/api/v1/admin/db/repairs/publication-links", json={"user_id": member_user_id, "dry_run": True}, headers=post_login_headers, ) assert invalid_scope.status_code == 400 assert invalid_scope.json()["error"]["code"] == "invalid_repair_scope" @pytest.mark.integration @pytest.mark.db @pytest.mark.asyncio async def test_api_admin_dbops_all_users_apply_requires_confirmation(db_session: AsyncSession) -> None: await insert_user(db_session, email="api-admin-dbops3@example.com", password="admin-password", is_admin=True) first_user_id = await insert_user(db_session, email="api-dbops-a@example.com", password="user-password") second_user_id = await insert_user(db_session, email="api-dbops-b@example.com", password="user-password") await seed_publication_link_for_user( db_session, user_id=first_user_id, scholar_id="dbopsAll01", title="DB Ops All User Paper One", fingerprint=f"{(first_user_id + 61):064x}", ) await seed_publication_link_for_user( db_session, user_id=second_user_id, scholar_id="dbopsAll02", title="DB Ops All User Paper Two", fingerprint=f"{(second_user_id + 71):064x}", ) client = TestClient(app) login_user(client, email="api-admin-dbops3@example.com", password="admin-password") headers = api_csrf_headers(client) missing_confirmation = client.post( "/api/v1/admin/db/repairs/publication-links", json={"scope_mode": "all_users", "dry_run": False}, headers=headers, ) assert missing_confirmation.status_code == 422 assert "confirmation_text" in str(missing_confirmation.json()) apply_response = client.post( "/api/v1/admin/db/repairs/publication-links", json={ "scope_mode": "all_users", "dry_run": False, "confirmation_text": "REPAIR ALL USERS", }, headers=headers, ) assert apply_response.status_code == 200 apply_data = apply_response.json()["data"] assert apply_data["status"] == "completed" assert apply_data["scope"]["scope_mode"] == "all_users" assert int(apply_data["summary"]["links_deleted"]) >= 2 remaining_links = await db_session.execute(text("SELECT count(*) FROM scholar_publications")) assert int(remaining_links.scalar_one()) == 0 @pytest.mark.integration @pytest.mark.db @pytest.mark.asyncio async def test_api_admin_dbops_near_duplicate_repair_scan_and_apply( db_session: AsyncSession, ) -> None: await insert_user( db_session, email="api-admin-near-dup@example.com", password="admin-password", is_admin=True, ) user_id = await insert_user( db_session, email="api-near-dup-target@example.com", password="user-password", ) scholar_result = await db_session.execute( text( """ INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled) VALUES (:user_id, :scholar_id, :display_name, true) RETURNING id """ ), { "user_id": user_id, "scholar_id": "nearDupScholar01", "display_name": "Near Dup Target", }, ) scholar_profile_id = int(scholar_result.scalar_one()) first_pub = await db_session.execute( text( """ INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, year, citation_count) VALUES (:fingerprint, :title_raw, :title_normalized, 2014, 100) RETURNING id """ ), { "fingerprint": f"{(user_id + 1201):064x}", "title_raw": "Adam: A method for stochastic optimization", "title_normalized": "adam a method for stochastic optimization", }, ) first_publication_id = int(first_pub.scalar_one()) second_pub = await db_session.execute( text( """ INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, year, citation_count) VALUES (:fingerprint, :title_raw, :title_normalized, 2015, 10) RETURNING id """ ), { "fingerprint": f"{(user_id + 1202):064x}", "title_raw": "†œAdam: A method for stochastic optimization, †3rd Int. Conf. Learn. Represent.", "title_normalized": "adam method for stochastic optimization", }, ) second_publication_id = int(second_pub.scalar_one()) await db_session.execute( text( """ INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read) VALUES (:scholar_profile_id, :first_publication_id, false), (:scholar_profile_id, :second_publication_id, false) """ ), { "scholar_profile_id": scholar_profile_id, "first_publication_id": first_publication_id, "second_publication_id": second_publication_id, }, ) await db_session.commit() client = TestClient(app) login_user(client, email="api-admin-near-dup@example.com", password="admin-password") headers = api_csrf_headers(client) scan_response = client.post( "/api/v1/admin/db/repairs/publication-near-duplicates", json={"dry_run": True, "max_clusters": 20}, headers=headers, ) assert scan_response.status_code == 200 scan_data = scan_response.json()["data"] assert scan_data["status"] == "completed" assert int(scan_data["summary"]["candidate_cluster_count"]) >= 1 assert len(scan_data["clusters"]) >= 1 selected_key = str(scan_data["clusters"][0]["cluster_key"]) missing_confirmation = client.post( "/api/v1/admin/db/repairs/publication-near-duplicates", json={"dry_run": False, "selected_cluster_keys": [selected_key]}, headers=headers, ) assert missing_confirmation.status_code == 422 assert "confirmation_text" in str(missing_confirmation.json()) apply_response = client.post( "/api/v1/admin/db/repairs/publication-near-duplicates", json={ "dry_run": False, "selected_cluster_keys": [selected_key], "confirmation_text": "MERGE SELECTED DUPLICATES", }, headers=headers, ) assert apply_response.status_code == 200 apply_data = apply_response.json()["data"] assert apply_data["status"] == "completed" assert int(apply_data["summary"]["merged_publications"]) >= 1 remaining = await db_session.execute( text( """ SELECT count(*) FROM publications WHERE id IN (:first_publication_id, :second_publication_id) """ ), { "first_publication_id": first_publication_id, "second_publication_id": second_publication_id, }, ) assert int(remaining.scalar_one()) == 1