Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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-access-key-id>
# AWS_SECRET_ACCESS_KEY=<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"

2 changes: 2 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 38 additions & 1 deletion routers/bpa_register.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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__)

Expand Down Expand Up @@ -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.",
)
8 changes: 8 additions & 0 deletions schemas/bpa.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
54 changes: 54 additions & 0 deletions services/ckan_client.py
Original file line number Diff line number Diff line change
@@ -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,
)
49 changes: 49 additions & 0 deletions tests/test_bpa_register.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
173 changes: 173 additions & 0 deletions tests/test_ckan_client.py
Original file line number Diff line number Diff line change
@@ -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