diff --git a/apps/api/app/services/auth/current_user_authentication_service.py b/apps/api/app/services/auth/current_user_authentication_service.py index a32c02068..f018a23a5 100644 --- a/apps/api/app/services/auth/current_user_authentication_service.py +++ b/apps/api/app/services/auth/current_user_authentication_service.py @@ -2,6 +2,8 @@ from __future__ import annotations +import hashlib + from app.services.auth.api_key_authentication_service import ( APIKeyAuthenticationService, ) @@ -9,9 +11,12 @@ DashboardJWTAuthenticationService, get_dashboard_jwt_authentication_service, ) +from loguru import logger from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert as postgres_insert from sqlalchemy.ext.asyncio import AsyncSession +from shared.core.database import get_db_context from shared.core.exceptions.domain_exceptions import AuthException from shared.models.database.user import User from shared.utils.api_keys import is_api_key_token @@ -79,13 +84,30 @@ async def _ensure_authenticated_user_exists( if result.scalar_one_or_none() is not None: return - raise AuthException( - user_message="Invalid authentication credentials", - internal_message=( - "Authenticated user id is not present in the user table: " - f"user_id={user_id}" - ), + await _provision_dashboard_user_reference(user_id) + + +async def _provision_dashboard_user_reference(user_id: str) -> None: + async with get_db_context() as session: + await session.execute( + postgres_insert(User) + .values( + id=user_id, + name="Dashboard User", + email=_build_dashboard_user_reference_email(user_id), + ) + .on_conflict_do_nothing(index_elements=[User.id]) ) + await session.flush() + logger.info( + "Provisioned Dashboard-authenticated API user reference: user_id={}", + user_id, + ) + + +def _build_dashboard_user_reference_email(user_id: str) -> str: + user_id_hash = hashlib.sha256(user_id.encode("utf-8")).hexdigest() + return f"dashboard-user-{user_id_hash}@reference.knowhere.local" _current_user_authentication_service = CurrentUserAuthenticationService() diff --git a/apps/api/app/services/demo/official_library_catalog.py b/apps/api/app/services/demo/official_library_catalog.py index 2aa5bde2d..04f7b5e4c 100644 --- a/apps/api/app/services/demo/official_library_catalog.py +++ b/apps/api/app/services/demo/official_library_catalog.py @@ -313,6 +313,10 @@ def get_official_library_catalog( source_payload_by_demo_source_id=source_payload_by_demo_source_id, ) for source in _OFFICIAL_LIBRARY_SOURCES + if _is_publishable_ready_source( + source=source, + source_payload_by_demo_source_id=source_payload_by_demo_source_id, + ) ], } @@ -337,6 +341,16 @@ def iter_official_library_categories() -> tuple[OfficialLibraryCategoryDefinitio return _OFFICIAL_LIBRARY_CATEGORIES +def _is_publishable_ready_source( + *, + source: OfficialLibrarySourceDefinition, + source_payload_by_demo_source_id: dict[str, dict[str, Any]], +) -> bool: + if source.status != "ready" or source.demo_source_id is None: + return False + return source.demo_source_id in source_payload_by_demo_source_id + + def _source_payload( *, source: OfficialLibrarySourceDefinition, diff --git a/apps/api/tests/contract/test_demo_documents_contract.py b/apps/api/tests/contract/test_demo_documents_contract.py index d68b37c3b..05d740501 100644 --- a/apps/api/tests/contract/test_demo_documents_contract.py +++ b/apps/api/tests/contract/test_demo_documents_contract.py @@ -208,7 +208,10 @@ async def test_should_return_demo_catalog_with_resolvable_canonical_citations( "demo-stem-transformers-tutorial" ) assert library_sources_by_id["stem-transformers-tutorial"]["chunk_count"] == 519 - assert library_sources_by_id["stem-information-theory"]["status"] == "planned" + assert "stem-information-theory" not in library_sources_by_id + assert {str(library_source["status"]) for library_source in library_sources} == { + "ready" + } async with api_client_factory() as api_client: chunks_response = await api_client.get( diff --git a/apps/api/tests/contract/test_job_creation_contract.py b/apps/api/tests/contract/test_job_creation_contract.py index 34ee54ac9..592d4c65e 100644 --- a/apps/api/tests/contract/test_job_creation_contract.py +++ b/apps/api/tests/contract/test_job_creation_contract.py @@ -1,6 +1,7 @@ from collections.abc import Callable from contextlib import AbstractAsyncContextManager from datetime import datetime, timedelta, timezone +import hashlib import json import socket from typing import cast @@ -410,11 +411,11 @@ async def test_should_reject_a_malformed_authorization_header_when_creating_a_jo @pytest.mark.asyncio -async def test_should_reject_authenticated_user_id_missing_from_user_table( +async def test_should_create_user_reference_for_dashboard_authenticated_user( api_client_factory: Callable[[], AbstractAsyncContextManager[AsyncClient]], monkeypatch: MonkeyPatch, ) -> None: - user_id = f"contract-missing-user-{uuid4().hex[:12]}" + user_id = f"contract-dashboard-user-{uuid4().hex[:12]}" jwt_secret = f"contract-jwt-secret-{uuid4().hex[:12]}" token = jwt.encode( { @@ -428,7 +429,7 @@ async def test_should_reject_authenticated_user_id_missing_from_user_table( "namespace": "contract-jobs", "source_type": "file", "file_name": "contract-upload.pdf", - "data_id": "contract-job-missing-user", + "data_id": "contract-job-dashboard-user", } async with api_client_factory() as api_client: @@ -445,17 +446,52 @@ async def test_should_reject_authenticated_user_id_missing_from_user_table( api_client.headers.update({"Authorization": f"Bearer {token}"}) response = await api_client.post("/api/v1/jobs", json=payload) - assert response.status_code == 401 + assert response.status_code == 200 assert response.headers["x-request-id"] response_json: dict[str, object] = response.json() - error = cast(dict[str, object], response_json["error"]) + job_id = cast(str, response_json["job_id"]) - assert response_json["success"] is False - assert error["code"] == "UNAUTHENTICATED" - assert error["message"] == "Invalid authentication credentials" - assert "details" not in error - assert await _count_jobs() == 0 + assert job_id.startswith("job_") + assert response_json["status"] == "waiting-file" + assert response_json["source_type"] == "file" + assert response_json["namespace"] == payload["namespace"] + assert response_json["data_id"] == payload["data_id"] + + engine = await _create_contract_engine() + try: + async with engine.begin() as connection: + user_row = ( + await connection.execute( + text( + """ + SELECT + id, + name, + email + FROM "user" + WHERE id = :user_id + """ + ), + {"user_id": user_id}, + ) + ).mappings().one() + finally: + await engine.dispose() + + job_record = await _load_job_record(job_id) + + assert dict(user_row) == { + "id": user_id, + "name": "Dashboard User", + "email": ( + "dashboard-user-" + f"{hashlib.sha256(user_id.encode('utf-8')).hexdigest()}" + "@reference.knowhere.local" + ), + } + assert job_record["user_id"] == user_id + assert await _count_jobs() == 1 @pytest.mark.asyncio