From a72151cfdcaa8f2043764096e7c9c0fd1fdc9a7f Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Tue, 3 Mar 2026 23:02:37 +0100 Subject: [PATCH 1/5] feat(frontend): show run and cancel buttons to all users Remove the auth.isAdmin guard from the Start check and Cancel check buttons in the Activity Monitor. Admin-only route links (check history, diagnostics) are unchanged. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/pages/DashboardPage.vue | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/pages/DashboardPage.vue b/frontend/src/pages/DashboardPage.vue index 8623927..c7da221 100644 --- a/frontend/src/pages/DashboardPage.vue +++ b/frontend/src/pages/DashboardPage.vue @@ -404,7 +404,7 @@ watch(
Date: Wed, 4 Mar 2026 02:17:18 +0100 Subject: [PATCH 2/5] feat(settings): hours interval, pdf queue for all users, fix users tab loading - Convert check interval setting from minutes to hours in the UI - Show PDF queue tab to all authenticated users (not admin-only); requeue actions remain admin-only in both backend and frontend - Fix users tab not loading on first visit by removing the AsyncStateGate wrapper that prevented child component refs from being populated before onMounted fired Co-Authored-By: Claude Sonnet 4.6 --- app/api/routers/admin_dbops.py | 6 +-- .../features/settings/SettingsAdminPanel.vue | 48 +++++++------------ .../components/AdminPdfQueueSection.vue | 6 ++- frontend/src/pages/SettingsPage.vue | 29 +++++++---- 4 files changed, 43 insertions(+), 46 deletions(-) diff --git a/app/api/routers/admin_dbops.py b/app/api/routers/admin_dbops.py index 0f4d1f6..ce63ea2 100644 --- a/app/api/routers/admin_dbops.py +++ b/app/api/routers/admin_dbops.py @@ -5,7 +5,7 @@ import logging from fastapi import APIRouter, Depends, Path, Query, Request from sqlalchemy.ext.asyncio import AsyncSession -from app.api.deps import get_api_admin_user +from app.api.deps import get_api_admin_user, get_api_current_user from app.api.errors import ApiException from app.api.responses import success_payload from app.api.schemas import ( @@ -185,7 +185,7 @@ async def get_pdf_queue( offset: int | None = Query(default=None, ge=0), status: str | None = Query(default=None), db_session: AsyncSession = Depends(get_db_session), - admin_user: User = Depends(get_api_admin_user), + current_user: User = Depends(get_api_current_user), ): normalized_status = (status or "").strip().lower() or None resolved_page, resolved_limit, resolved_offset = _resolve_pdf_queue_paging( @@ -204,7 +204,7 @@ async def get_pdf_queue( logger, "info", "api.admin.db.pdf_queue_listed", - admin_user_id=int(admin_user.id), + user_id=int(current_user.id), page=int(resolved_page), page_size=int(resolved_limit), offset=int(resolved_offset), diff --git a/frontend/src/features/settings/SettingsAdminPanel.vue b/frontend/src/features/settings/SettingsAdminPanel.vue index b306d1e..6e7e326 100644 --- a/frontend/src/features/settings/SettingsAdminPanel.vue +++ b/frontend/src/features/settings/SettingsAdminPanel.vue @@ -1,7 +1,7 @@ + + diff --git a/frontend/src/features/scholars/components/scholar-batch-parsing.ts b/frontend/src/features/scholars/components/scholar-batch-parsing.ts new file mode 100644 index 0000000..45baba7 --- /dev/null +++ b/frontend/src/features/scholars/components/scholar-batch-parsing.ts @@ -0,0 +1,76 @@ +const SCHOLAR_ID_PATTERN = /^[a-zA-Z0-9_-]{12}$/; + +export interface ParsedToken { + index: number; + raw: string; + id: string | null; + error: string | null; +} + +export function extractScholarIdFromUrl(token: string): string | null { + const cleaned = token.replace(/\/+$/, "").replace(/#.*$/, ""); + try { + const parsed = new URL(cleaned); + const userParam = parsed.searchParams.get("user"); + if (!userParam) return null; + const decoded = decodeURIComponent(userParam).trim(); + if (SCHOLAR_ID_PATTERN.test(decoded)) return decoded; + return null; + } catch { + return null; + } +} + +export function validateTokenAsId(token: string): string | null { + const trimmed = token.trim(); + if (!trimmed) return "empty input"; + if (/\s/.test(trimmed)) return "contains whitespace"; + if (trimmed.length !== 12) return `must be 12 characters (got ${trimmed.length})`; + if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) { + return "contains invalid characters (only a-z, A-Z, 0-9, _ and - allowed)"; + } + return null; +} + +export function parseScholarTokens(raw: string): ParsedToken[] { + const tokens = raw.split(/[\s,;]+/).map((v) => v.trim()).filter((v) => v.length > 0); + const results: ParsedToken[] = []; + const seen = new Set(); + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + + if (SCHOLAR_ID_PATTERN.test(token)) { + if (seen.has(token)) { + results.push({ index: i + 1, raw: token, id: null, error: "duplicate" }); + continue; + } + seen.add(token); + results.push({ index: i + 1, raw: token, id: token, error: null }); + continue; + } + + if (token.includes("scholar.google") || token.startsWith("http")) { + const extracted = extractScholarIdFromUrl(token); + if (extracted) { + if (seen.has(extracted)) { + results.push({ index: i + 1, raw: token, id: null, error: "duplicate" }); + continue; + } + seen.add(extracted); + results.push({ index: i + 1, raw: token, id: extracted, error: null }); + continue; + } + results.push({ index: i + 1, raw: token, id: null, error: "could not extract scholar ID from URL" }); + continue; + } + + const reason = validateTokenAsId(token); + results.push({ index: i + 1, raw: token, id: null, error: reason ?? "invalid scholar ID" }); + } + return results; +} + +export function parseScholarIds(raw: string): string[] { + return parseScholarTokens(raw).filter((t) => t.id !== null).map((t) => t.id!); +} diff --git a/frontend/src/pages/DashboardPage.vue b/frontend/src/pages/DashboardPage.vue index c7da221..3f7df35 100644 --- a/frontend/src/pages/DashboardPage.vue +++ b/frontend/src/pages/DashboardPage.vue @@ -494,6 +494,11 @@ watch(

@@ -527,6 +532,9 @@ watch( {{ formatDate(run.start_dt) }} • {{ run.new_publication_count }} new + diff --git a/frontend/src/pages/ScholarsPage.vue b/frontend/src/pages/ScholarsPage.vue index 16b2a96..29541d9 100644 --- a/frontend/src/pages/ScholarsPage.vue +++ b/frontend/src/pages/ScholarsPage.vue @@ -31,6 +31,7 @@ import ScholarAvatar from "@/features/scholars/components/ScholarAvatar.vue"; import ScholarBatchAdd from "@/features/scholars/components/ScholarBatchAdd.vue"; import ScholarNameSearch from "@/features/scholars/components/ScholarNameSearch.vue"; import ScholarSettingsModal from "@/features/scholars/components/ScholarSettingsModal.vue"; +import ScholarStatusBadges from "@/features/scholars/components/ScholarStatusBadges.vue"; import { ApiRequestError } from "@/lib/api/errors"; import { useRunStatusStore } from "@/stores/run_status"; @@ -505,7 +506,10 @@ watch(
-

{{ scholarLabel(item) }}

+

+ {{ scholarLabel(item) }} + +

Publications Open profile @@ -529,7 +533,10 @@ watch(
- {{ scholarLabel(item) }} + + {{ scholarLabel(item) }} + +
Publications Open profile diff --git a/tests/integration/test_api_scholars.py b/tests/integration/test_api_scholars.py index 0d6f3a0..1fd8ba9 100644 --- a/tests/integration/test_api_scholars.py +++ b/tests/integration/test_api_scholars.py @@ -70,6 +70,110 @@ async def test_api_scholars_crud_flow(db_session: AsyncSession) -> None: assert delete_response.json()["data"]["message"] == "Scholar deleted." +@pytest.mark.integration +@pytest.mark.db +@pytest.mark.asyncio +async def test_api_delete_scholar_returns_404_for_nonexistent(db_session: AsyncSession) -> None: + await insert_user( + db_session, + email="api-delete-404@example.com", + password="api-password", + ) + + client = TestClient(app) + login_user(client, email="api-delete-404@example.com", password="api-password") + headers = api_csrf_headers(client) + + response = client.delete("/api/v1/scholars/999999", headers=headers) + assert response.status_code == 404 + assert response.json()["error"]["code"] == "scholar_not_found" + + +@pytest.mark.integration +@pytest.mark.db +@pytest.mark.asyncio +async def test_api_delete_scholar_cascades_linked_publications(db_session: AsyncSession) -> None: + user_id = await insert_user( + db_session, + email="api-delete-cascade@example.com", + password="api-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": "delCASCADE01", "display_name": "Cascade Test"}, + ) + scholar_profile_id = int(scholar_result.scalar_one()) + pub_result = await db_session.execute( + text( + """ + INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count) + VALUES (:fingerprint, :title_raw, :title_normalized, 0) + RETURNING id + """ + ), + { + "fingerprint": f"{(user_id + 7000):064x}", + "title_raw": "Cascade Publication", + "title_normalized": "cascadepublication", + }, + ) + publication_id = int(pub_result.scalar_one()) + await db_session.execute( + text( + """ + INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read) + VALUES (:scholar_profile_id, :publication_id, false) + """ + ), + {"scholar_profile_id": scholar_profile_id, "publication_id": publication_id}, + ) + await db_session.commit() + + client = TestClient(app) + login_user(client, email="api-delete-cascade@example.com", password="api-password") + headers = api_csrf_headers(client) + + delete_response = client.delete(f"/api/v1/scholars/{scholar_profile_id}", headers=headers) + assert delete_response.status_code == 200 + assert delete_response.json()["data"]["message"] == "Scholar deleted." + + link_result = await db_session.execute( + text("SELECT count(*) FROM scholar_publications WHERE scholar_profile_id = :id"), + {"id": scholar_profile_id}, + ) + assert int(link_result.scalar_one()) == 0 + + +@pytest.mark.integration +@pytest.mark.db +@pytest.mark.asyncio +async def test_api_create_scholar_rejects_corrupted_id(db_session: AsyncSession) -> None: + await insert_user( + db_session, + email="api-bad-id@example.com", + password="api-password", + ) + + client = TestClient(app) + login_user(client, email="api-bad-id@example.com", password="api-password") + headers = api_csrf_headers(client) + + for bad_id in ["short", "ABCDEF12345!", "", " ", "AB CD EF1234"]: + response = client.post( + "/api/v1/scholars", + json={"scholar_id": bad_id}, + headers=headers, + ) + assert response.status_code == 400, f"Expected 400 for scholar_id={bad_id!r}" + assert response.json()["error"]["code"] == "invalid_scholar" + + @pytest.mark.integration @pytest.mark.db @pytest.mark.asyncio diff --git a/tests/unit/test_scholar_validators.py b/tests/unit/test_scholar_validators.py index 460b315..185d805 100644 --- a/tests/unit/test_scholar_validators.py +++ b/tests/unit/test_scholar_validators.py @@ -42,6 +42,34 @@ class TestValidateScholarId: with pytest.raises(ScholarServiceError, match="12"): validate_scholar_id("ABCDEF12345!") + def test_rejects_empty_string(self) -> None: + with pytest.raises(ScholarServiceError, match="12"): + validate_scholar_id("") + + def test_rejects_whitespace_only(self) -> None: + with pytest.raises(ScholarServiceError, match="12"): + validate_scholar_id(" ") + + def test_rejects_embedded_spaces(self) -> None: + with pytest.raises(ScholarServiceError, match="12"): + validate_scholar_id("ABCDEF 12345") + + def test_rejects_tab_characters(self) -> None: + with pytest.raises(ScholarServiceError, match="12"): + validate_scholar_id("ABCDEF\t12345") + + def test_rejects_url_as_id(self) -> None: + with pytest.raises(ScholarServiceError, match="12"): + validate_scholar_id("https://scholar.google.com/citations?user=ABCDEF123456") + + def test_rejects_newline_in_id(self) -> None: + with pytest.raises(ScholarServiceError, match="12"): + validate_scholar_id("ABCDEF\n12345") + + def test_rejects_unicode_characters(self) -> None: + with pytest.raises(ScholarServiceError, match="12"): + validate_scholar_id("ABCDEF12345\u00e9") + class TestNormalizeDisplayName: def test_returns_stripped_name(self) -> None: -- 2.49.1 From afbb25d2178ce99d6659ef90ff9d35a2e2900bd7 Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Sat, 7 Mar 2026 15:32:22 +0100 Subject: [PATCH 4/5] fix(ci): use theme tokens for badges, update pdf-queue test - Replace raw dark: utility variants with state-* theme tokens in ScholarStatusBadges and ScholarSettingsModal - Update integration test to expect 200 for pdf-queue listing (endpoint now uses get_api_current_user, not admin-only) --- .../features/scholars/components/ScholarSettingsModal.vue | 2 +- .../features/scholars/components/ScholarStatusBadges.vue | 8 ++++---- tests/integration/test_api_admin.py | 7 ++++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/frontend/src/features/scholars/components/ScholarSettingsModal.vue b/frontend/src/features/scholars/components/ScholarSettingsModal.vue index 511bf66..77af0ac 100644 --- a/frontend/src/features/scholars/components/ScholarSettingsModal.vue +++ b/frontend/src/features/scholars/components/ScholarSettingsModal.vue @@ -51,7 +51,7 @@ function scholarPublicationsRoute(profile: ScholarProfile): { name: string; quer {{ scholarLabel(scholar) }} Pending

diff --git a/frontend/src/features/scholars/components/ScholarStatusBadges.vue b/frontend/src/features/scholars/components/ScholarStatusBadges.vue index 5460658..86b2cd5 100644 --- a/frontend/src/features/scholars/components/ScholarStatusBadges.vue +++ b/frontend/src/features/scholars/components/ScholarStatusBadges.vue @@ -8,22 +8,22 @@ defineProps<{ diff --git a/tests/integration/test_api_admin.py b/tests/integration/test_api_admin.py index fb63f34..f80a8e3 100644 --- a/tests/integration/test_api_admin.py +++ b/tests/integration/test_api_admin.py @@ -340,10 +340,11 @@ async def test_api_admin_dbops_forbidden_for_non_admin_and_validates_scope(db_se assert forbidden_integrity.status_code == 403 assert forbidden_integrity.json()["error"]["code"] == "forbidden" - forbidden_pdf_queue = client.get("/api/v1/admin/db/pdf-queue") - assert forbidden_pdf_queue.status_code == 403 - assert forbidden_pdf_queue.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, -- 2.49.1 From 61ac6f206fbfd30cb2deebb572c51b350a248c31 Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Sat, 7 Mar 2026 15:51:48 +0100 Subject: [PATCH 5/5] fix(test): add Pinia setup and admin user to AdminPdfQueueSection tests The component uses useAuthStore() which requires an active Pinia instance, and the "Queue all" button is admin-only (v-if="auth.isAdmin"). --- .../settings/components/AdminPdfQueueSection.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/features/settings/components/AdminPdfQueueSection.test.ts b/frontend/src/features/settings/components/AdminPdfQueueSection.test.ts index bdd0793..c312922 100644 --- a/frontend/src/features/settings/components/AdminPdfQueueSection.test.ts +++ b/frontend/src/features/settings/components/AdminPdfQueueSection.test.ts @@ -1,6 +1,8 @@ // @vitest-environment happy-dom import { describe, expect, it, vi, beforeEach } from "vitest"; import { mount, flushPromises } from "@vue/test-utils"; +import { setActivePinia, createPinia } from "pinia"; +import { useAuthStore } from "@/stores/auth"; import AdminPdfQueueSection from "./AdminPdfQueueSection.vue"; vi.mock("@/features/admin_dbops", () => ({ @@ -36,6 +38,9 @@ function buildQueueItem(overrides: Record = {}) { describe("AdminPdfQueueSection", () => { beforeEach(() => { + setActivePinia(createPinia()); + const auth = useAuthStore(); + auth.$patch({ state: "authenticated", user: { id: 1, email: "admin@test.com", is_admin: true, is_active: true } }); mockedListQueue.mockReset(); }); -- 2.49.1