diff --git a/.env.example b/.env.example index 34fa12ea..d47e1bb0 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,7 @@ DB_URL=sqlite:///mydatabase.db # AWS SES configs - required if testing email functionality locally. Ask amanda@biocommons.org.au for credentials values # AWS_ACCESS_KEY_ID= # AWS_SECRET_ACCESS_KEY= +CKAN_BASE_URL="https://aaidemo.bioplatforms.com" +# CKAN_API_KEY is the API key for a CKAN user with admin rights to https://aaidemo.bioplatforms.com to access information required for the aai-backend +CKAN_API_KEY="43a5247f-907b-41b1-a65a-3bd1318293bd" + diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d2b7d71f..63ab67ee 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -57,6 +57,8 @@ jobs: AWS_ZONE_DOMAIN=${{ secrets.AWS_ZONE_DOMAIN }} AWS_DB_HOST=${{ secrets.AWS_DB_HOST }} AWS_DB_SECRET=${{ secrets.AWS_DB_SECRET }} + CKAN_BASE_URL=${{ secrets.CKAN_BASE_URL }} + CKAN_API_KEY=${{ secrets.CKAN_API_KEY }} EOF - name: CDK Deploy diff --git a/routers/bpa_register.py b/routers/bpa_register.py index e0616942..f2dbba69 100644 --- a/routers/bpa_register.py +++ b/routers/bpa_register.py @@ -1,9 +1,12 @@ import logging from datetime import datetime, timezone +from typing import List +import httpx from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from httpx import HTTPStatusError from sqlmodel import Session +from starlette import status from starlette.responses import JSONResponse from auth.ses import EmailService @@ -13,9 +16,10 @@ from db.setup import get_db_session from routers.errors import RegistrationRoute from schemas.biocommons import Auth0UserData, BiocommonsRegisterData -from schemas.bpa import BPARegistrationRequest +from schemas.bpa import BPARegistrationRequest, OrgOut from schemas.responses import RegistrationErrorResponse, RegistrationResponse from schemas.service import Resource, Service +from services.ckan_client import CKANClient, get_ckan_client logger = logging.getLogger(__name__) @@ -142,3 +146,36 @@ def _create_bpa_user_record(auth0_user_data: Auth0UserData, session: Session) -> session.add(bpa_membership) session.commit() return db_user + +@router.get( + "/organizations/autoregister", + response_model=List[OrgOut], + summary="List CKAN organizations eligible for auto-registration", +) +def list_autoregister_organizations( + ckan: CKANClient = Depends(get_ckan_client), +) -> List[OrgOut]: + """ + Returns the minimal set of CKAN organizations eligible for auto-registration, + suitable for populating the portal dropdown. + """ + try: + return ckan.get_autoregister_organizations() + except httpx.HTTPError as e: + # Network / HTTP errors talking to CKAN + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Upstream CKAN error: {str(e)}", + ) + except ValueError as e: + # CKAN Action API returned success=false + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(e), + ) + except Exception: + # Unknown error + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Unexpected server error.", + ) diff --git a/schemas/bpa.py b/schemas/bpa.py index 5942012f..a989eb0e 100644 --- a/schemas/bpa.py +++ b/schemas/bpa.py @@ -12,3 +12,11 @@ class BPARegistrationRequest(BaseModel): reason: str password: BiocommonsPassword organizations: Dict[str, bool] + +class OrgOut(BaseModel): + """ + Minimal org payload for the portal dropdown. + """ + id: str + name: str + title: str diff --git a/services/ckan_client.py b/services/ckan_client.py new file mode 100644 index 00000000..d5f0a0ae --- /dev/null +++ b/services/ckan_client.py @@ -0,0 +1,54 @@ +__all__ = ["CKANClient", "get_ckan_client"] + +from typing import Optional + +import httpx +from fastapi import Depends + +from config import Settings, get_settings +from schemas.bpa import OrgOut + + +class CKANClient: + """ + Client for CKAN Action API calls used by aai-backend. + """ + + ACTION_AUTOREGISTER_ORGS = "/api/3/action/ytp_request_autoregister_organization_list" + + def __init__(self, base_url: str, api_key: Optional[str], timeout_s: float, verify_ssl: bool): + self.base_url = base_url.rstrip("/") + self.api_key = api_key + headers = {} + if api_key: + # CKAN expects the API key in the Authorization header (no Bearer prefix) + headers["Authorization"] = api_key + # Mirror the style of Auth0Client: keep a single sync client instance + self._client = httpx.Client(headers=headers, timeout=timeout_s, verify=verify_ssl) + + def get_autoregister_organizations(self) -> list[OrgOut]: + """ + Calls the CKAN action exposed by ckanext-ytp-request to fetch the + list of orgs eligible for auto-registration. + """ + url = f"{self.base_url}{self.ACTION_AUTOREGISTER_ORGS}" + resp = self._client.post(url, json={}) + resp.raise_for_status() + payload = resp.json() + if not payload.get("success"): + # CKAN Action API returns {"success": false, "error": {...}} on failure + raise ValueError("CKAN action reported success=false") + result = payload.get("result") or [] + return [OrgOut(**item) for item in result] + + +def get_ckan_client(settings: Settings = Depends(get_settings)) -> CKANClient: + """ + FastAPI dependency that wires CKAN config from Settings. + """ + return CKANClient( + base_url=settings.ckan_base_url, + api_key=settings.ckan_api_key, + timeout_s=10, + verify_ssl=settings.ckan_verify_ssl, + ) diff --git a/tests/test_bpa_register.py b/tests/test_bpa_register.py index 7638bd1d..0d65885e 100644 --- a/tests/test_bpa_register.py +++ b/tests/test_bpa_register.py @@ -10,8 +10,10 @@ PlatformMembership, PlatformMembershipHistory, ) +from main import app from schemas import Service from schemas.biocommons import BiocommonsRegisterData +from services.ckan_client import get_ckan_client from tests.datagen import ( Auth0UserDataFactory, BPARegistrationDataFactory, @@ -272,3 +274,50 @@ def test_all_organizations_selected( assert len(bpa_service.resources) == len(mock_settings.organizations) email_service_cls.return_value.send.assert_called_once() + + +class _DummyCKAN: + def __init__(self, return_value=None, raise_exc=None): + self.return_value = [] if return_value is None else return_value + self.raise_exc = raise_exc + self.called = False + + def get_autoregister_organizations(self): + self.called = True + if self.raise_exc: + raise self.raise_exc + return self.return_value + + +def test_get_bpa_orgs_success(test_client): + """Test successful retrieval of BPA organizations.""" + mock_organizations = [ + {"id": "org1", "name": "Org One", "title": "Org One"}, + {"id": "org2", "name": "Org Two", "title": "Org Two"}, + {"id": "org3", "name": "Org Three", "title": "Org Three"}, + ] + dummy = _DummyCKAN(return_value=mock_organizations) + + # Override the FastAPI dependency used by the route + app.dependency_overrides[get_ckan_client] = lambda: dummy + try: + response = test_client.get("/bpa/organizations/autoregister") + finally: + app.dependency_overrides.pop(get_ckan_client, None) + + assert response.status_code == 200 + assert response.json() == mock_organizations + assert dummy.called is True + + +def test_get_bpa_orgs_failure(test_client): + """If the underlying client raises, expect a 502 from the error handler.""" + dummy = _DummyCKAN(raise_exc=ValueError("boom")) + + app.dependency_overrides[get_ckan_client] = lambda: dummy + try: + response = test_client.get("/bpa/organizations/autoregister") + finally: + app.dependency_overrides.pop(get_ckan_client, None) + + assert response.status_code == 502 \ No newline at end of file diff --git a/tests/test_ckan_client.py b/tests/test_ckan_client.py new file mode 100644 index 00000000..9c4588c6 --- /dev/null +++ b/tests/test_ckan_client.py @@ -0,0 +1,173 @@ +import types +from typing import Any, Dict, List, Optional + +import httpx +import pytest + +from services.ckan_client import CKANClient, get_ckan_client + + +class _DummyResponse: + def __init__(self, *, status_code: int = 200, json_payload: Any = None, request: Optional[httpx.Request] = None): + self.status_code = status_code + self._json = json_payload + # httpx.HTTPStatusError needs both request and response objects + self.request = request or httpx.Request("POST", "https://example.org") + self.headers = {} + + def json(self) -> Any: + return self._json + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise httpx.HTTPStatusError( + f"{self.status_code} error", + request=self.request, + response=httpx.Response(self.status_code, request=self.request), + ) + + +class _DummyHttpxClient: + """ + Minimal sync httpx.Client stand-in that captures requests and returns a queued response. + """ + def __init__(self, *, headers: Optional[Dict[str, str]] = None, timeout: float = 10, verify: bool = True): + self.headers = headers or {} + self.timeout = timeout + self.verify = verify + self.calls: List[Dict[str, Any]] = [] + # Response to return; test will set this per-call + self.next_response: Optional[_DummyResponse] = None + + def post(self, url: str, json: Any = None): + self.calls.append({"method": "POST", "url": url, "json": json, "headers": dict(self.headers)}) + if self.next_response is None: + return _DummyResponse(json_payload={"success": True, "result": []}) + return self.next_response + + +@pytest.fixture +def dummy_client(monkeypatch): + """ + Monkeypatch httpx.Client used inside CKANClient to our dummy version. + Exposes the created dummy via closure so tests can control responses & inspect calls. + """ + created: Dict[str, _DummyHttpxClient] = {} + + def _factory(*, headers=None, timeout=None, verify=None, **_): + client = _DummyHttpxClient(headers=headers, timeout=timeout, verify=verify) + created["client"] = client + return client + + monkeypatch.setattr("services.ckan_client.httpx.Client", _factory) + return created + + +@pytest.fixture +def stub_orgout(monkeypatch): + """ + Replace the imported OrgOut symbol inside the module under test with a permissive stub + so tests don't depend on the exact pydantic schema fields. + """ + class _StubOrgOut: + def __init__(self, **data): + # store raw data for easy assertions if needed + self._data = data + + monkeypatch.setattr("services.ckan_client.OrgOut", _StubOrgOut) + return _StubOrgOut + + +def test_get_autoregister_organizations_success(dummy_client, stub_orgout): + client = CKANClient(base_url="https://ckan.example/api", api_key="abc123", timeout_s=5, verify_ssl=False) + + # Arrange dummy response + payload = { + "success": True, + "result": [ + {"id": "org-1", "name": "Org One", "title": "Org One"}, + {"id": "org-2", "name": "Org Two", "title": "Org Two"}, + ], + } + dummy_client["client"].next_response = _DummyResponse(json_payload=payload) + + # Act + orgs = client.get_autoregister_organizations() + + # Assert: returned list length & type creation via stub + assert len(orgs) == 2 + assert all(isinstance(o, stub_orgout) for o in orgs) + + # Assert: correct URL and Authorization header, empty JSON body per implementation + call = dummy_client["client"].calls[-1] + assert call["method"] == "POST" + # ACTION path is appended to base_url (base_url rstrip('/') in __init__) + assert call["url"].endswith("/api/3/action/ytp_request_autoregister_organization_list") + assert call["json"] == {} + assert call["headers"].get("Authorization") == "abc123" + # verify & timeout wired to httpx.Client + assert dummy_client["client"].verify is False + assert dummy_client["client"].timeout == 5 + + +def test_get_autoregister_organizations_empty_result(dummy_client, stub_orgout): + client = CKANClient(base_url="https://ckan.example/", api_key=None, timeout_s=10, verify_ssl=True) + + # No api_key => no Authorization header + dummy_client["client"].next_response = _DummyResponse(json_payload={"success": True, "result": []}) + orgs = client.get_autoregister_organizations() + assert orgs == [] + + call = dummy_client["client"].calls[-1] + assert "Authorization" not in call["headers"] + + +def test_get_autoregister_organizations_http_error_raises(dummy_client, stub_orgout): + client = CKANClient(base_url="https://ckan.example", api_key=None, timeout_s=10, verify_ssl=True) + dummy_client["client"].next_response = _DummyResponse(status_code=502, json_payload={"success": False}) + + with pytest.raises(httpx.HTTPStatusError): + client.get_autoregister_organizations() + + +def test_get_autoregister_organizations_success_false_raises(dummy_client, stub_orgout): + client = CKANClient(base_url="https://ckan.example", api_key=None, timeout_s=10, verify_ssl=True) + dummy_client["client"].next_response = _DummyResponse(json_payload={"success": False, "error": {"message": "boom"}}) + + with pytest.raises(ValueError): + client.get_autoregister_organizations() + + +def test_get_ckan_client_dependency_wiring(monkeypatch): + """ + Call get_ckan_client with an explicit Settings instance and verify fields are wired through. + """ + # Build a tiny fake Settings object with the fields the dependency expects + FakeSettings = types.SimpleNamespace + settings = FakeSettings( + ckan_base_url="https://ckan.example/", + ckan_api_key="sekret", + ckan_verify_ssl=True, + ) + + # Monkeypatch httpx.Client so we can inspect constructed client properties + captured = {} + + def _factory(*, headers=None, timeout=None, verify=None, **_): + captured["headers"] = headers or {} + captured["timeout"] = timeout + captured["verify"] = verify + # return a harmless dummy that won't be used in this test + return _DummyHttpxClient(headers=headers, timeout=timeout, verify=verify) + + monkeypatch.setattr("services.ckan_client.httpx.Client", _factory) + + # Act + ckan_client = get_ckan_client(settings=settings) + + # Assert: constructed CKANClient has a trimmed base_url and httpx client configured + assert isinstance(ckan_client, CKANClient) + assert ckan_client.base_url == "https://ckan.example" # rstrip("/") + assert captured["headers"].get("Authorization") == "sekret" + assert captured["timeout"] == 10 # hardcoded in dependency + assert captured["verify"] is True