From 9c40929197067dd17f042c7fc623a52842c7713a Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Thu, 22 Jan 2026 23:55:29 +0700 Subject: [PATCH] feat(backend): develop get /api/organizations/{orgid} api (#12274) Co-authored-by: rohitvinodmalhotra@gmail.com Co-authored-by: openhands Co-authored-by: Chuck Butkus Co-authored-by: Tim O'Farrell --- enterprise/server/routes/org_models.py | 8 + enterprise/server/routes/orgs.py | 59 ++++ enterprise/storage/org_service.py | 53 ++++ .../tests/unit/server/routes/test_orgs.py | 272 ++++++++++++++++++ enterprise/tests/unit/test_org_service.py | 97 +++++++ 5 files changed, 489 insertions(+) diff --git a/enterprise/server/routes/org_models.py b/enterprise/server/routes/org_models.py index a01ae563b7..327804e3ce 100644 --- a/enterprise/server/routes/org_models.py +++ b/enterprise/server/routes/org_models.py @@ -28,6 +28,14 @@ class OrgDatabaseError(OrgCreationError): pass +class OrgNotFoundError(Exception): + """Raised when organization is not found or user doesn't have access.""" + + def __init__(self, org_id: str): + self.org_id = org_id + super().__init__(f'Organization with id "{org_id}" not found') + + class OrgCreate(BaseModel): """Request model for creating a new organization.""" diff --git a/enterprise/server/routes/orgs.py b/enterprise/server/routes/orgs.py index 8c4e89a4eb..67bb2822d8 100644 --- a/enterprise/server/routes/orgs.py +++ b/enterprise/server/routes/orgs.py @@ -1,4 +1,5 @@ from typing import Annotated +from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, status from server.email_validation import get_admin_user_id @@ -7,6 +8,7 @@ from server.routes.org_models import ( OrgCreate, OrgDatabaseError, OrgNameExistsError, + OrgNotFoundError, OrgPage, OrgResponse, ) @@ -165,3 +167,60 @@ async def create_org( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='An unexpected error occurred', ) + + +@org_router.get('/{org_id}', response_model=OrgResponse, status_code=status.HTTP_200_OK) +async def get_org( + org_id: UUID, + user_id: str = Depends(get_user_id), +) -> OrgResponse: + """Get organization details by ID. + + This endpoint allows authenticated users who are members of an organization + to retrieve its details. Only members of the organization can access this endpoint. + + Args: + org_id: Organization ID (UUID) + user_id: Authenticated user ID (injected by dependency) + + Returns: + OrgResponse: The organization details + + Raises: + HTTPException: 422 if org_id is not a valid UUID (handled by FastAPI) + HTTPException: 404 if organization not found or user is not a member + HTTPException: 500 if retrieval fails + """ + logger.info( + 'Retrieving organization details', + extra={ + 'user_id': user_id, + 'org_id': str(org_id), + }, + ) + + try: + # Use service layer to get organization with membership validation + org = await OrgService.get_org_by_id( + org_id=org_id, + user_id=user_id, + ) + + # Retrieve credits from LiteLLM + credits = await OrgService.get_org_credits(user_id, org.id) + + return OrgResponse.from_org(org, credits=credits) + except OrgNotFoundError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) + except Exception as e: + logger.exception( + 'Unexpected error retrieving organization', + extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)}, + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='An unexpected error occurred', + ) diff --git a/enterprise/storage/org_service.py b/enterprise/storage/org_service.py index 17a9dad8c5..0f52e6373f 100644 --- a/enterprise/storage/org_service.py +++ b/enterprise/storage/org_service.py @@ -11,6 +11,7 @@ from server.routes.org_models import ( LiteLLMIntegrationError, OrgDatabaseError, OrgNameExistsError, + OrgNotFoundError, ) from storage.lite_llm_manager import LiteLlmManager from storage.org import Org @@ -480,3 +481,55 @@ class OrgService: ) return orgs, next_page_id + + @staticmethod + async def get_org_by_id(org_id: UUID, user_id: str) -> Org: + """ + Get organization by ID with membership validation. + + This method verifies that the user is a member of the organization + before returning the organization details. + + Args: + org_id: Organization ID + user_id: User ID (string that will be converted to UUID) + + Returns: + Org: The organization object + + Raises: + OrgNotFoundError: If organization not found or user is not a member + """ + logger.info( + 'Retrieving organization', + extra={'user_id': user_id, 'org_id': str(org_id)}, + ) + + # Verify user is a member of the organization + org_member = OrgMemberStore.get_org_member(org_id, parse_uuid(user_id)) + if not org_member: + logger.warning( + 'User is not a member of organization or organization does not exist', + extra={'user_id': user_id, 'org_id': str(org_id)}, + ) + raise OrgNotFoundError(str(org_id)) + + # Retrieve organization + org = OrgStore.get_org_by_id(org_id) + if not org: + logger.error( + 'Organization not found despite valid membership', + extra={'user_id': user_id, 'org_id': str(org_id)}, + ) + raise OrgNotFoundError(str(org_id)) + + logger.info( + 'Successfully retrieved organization', + extra={ + 'org_id': str(org.id), + 'org_name': org.name, + 'user_id': user_id, + }, + ) + + return org diff --git a/enterprise/tests/unit/server/routes/test_orgs.py b/enterprise/tests/unit/server/routes/test_orgs.py index 0faac75d80..8d66c9ffe6 100644 --- a/enterprise/tests/unit/server/routes/test_orgs.py +++ b/enterprise/tests/unit/server/routes/test_orgs.py @@ -20,6 +20,7 @@ with patch('storage.database.engine', create=True), patch( LiteLLMIntegrationError, OrgDatabaseError, OrgNameExistsError, + OrgNotFoundError, ) from server.routes.orgs import org_router from storage.org import Org @@ -650,3 +651,274 @@ async def test_list_user_orgs_all_fields_present(mock_app_list): assert org_data['enable_solvability_analysis'] is True assert org_data['v1_enabled'] is True assert org_data['credits'] is None + + +@pytest.fixture +def mock_app_with_get_user_id(): + """Create a test FastAPI app with organization routes and mocked get_user_id auth.""" + app = FastAPI() + app.include_router(org_router) + + # Override the auth dependency to return a test user + def mock_get_user_id(): + return 'test-user-123' + + app.dependency_overrides[get_user_id] = mock_get_user_id + + return app + + +@pytest.mark.asyncio +async def test_get_org_success(mock_app_with_get_user_id): + """ + GIVEN: Valid org_id and authenticated user who is a member + WHEN: GET /api/organizations/{org_id} is called + THEN: Organization details are returned with 200 status + """ + # Arrange + org_id = uuid.uuid4() + mock_org = Org( + id=org_id, + name='Test Organization', + contact_name='John Doe', + contact_email='john@example.com', + org_version=5, + default_llm_model='claude-opus-4-5-20251101', + enable_default_condenser=True, + enable_proactive_conversation_starters=True, + ) + + with ( + patch( + 'server.routes.orgs.OrgService.get_org_by_id', + AsyncMock(return_value=mock_org), + ), + patch( + 'server.routes.orgs.OrgService.get_org_credits', + AsyncMock(return_value=75.5), + ), + ): + client = TestClient(mock_app_with_get_user_id) + + # Act + response = client.get(f'/api/organizations/{org_id}') + + # Assert + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + assert response_data['id'] == str(org_id) + assert response_data['name'] == 'Test Organization' + assert response_data['contact_name'] == 'John Doe' + assert response_data['contact_email'] == 'john@example.com' + assert response_data['credits'] == 75.5 + assert response_data['org_version'] == 5 + + +@pytest.mark.asyncio +async def test_get_org_user_not_member(mock_app_with_get_user_id): + """ + GIVEN: User is not a member of the organization + WHEN: GET /api/organizations/{org_id} is called + THEN: 404 Not Found error is returned + """ + # Arrange + org_id = uuid.uuid4() + + with patch( + 'server.routes.orgs.OrgService.get_org_by_id', + AsyncMock(side_effect=OrgNotFoundError(str(org_id))), + ): + client = TestClient(mock_app_with_get_user_id) + + # Act + response = client.get(f'/api/organizations/{org_id}') + + # Assert + assert response.status_code == status.HTTP_404_NOT_FOUND + assert 'not found' in response.json()['detail'].lower() + + +@pytest.mark.asyncio +async def test_get_org_not_found(mock_app_with_get_user_id): + """ + GIVEN: Organization does not exist + WHEN: GET /api/organizations/{org_id} is called + THEN: 404 Not Found error is returned + """ + # Arrange + org_id = uuid.uuid4() + + with patch( + 'server.routes.orgs.OrgService.get_org_by_id', + AsyncMock(side_effect=OrgNotFoundError(str(org_id))), + ): + client = TestClient(mock_app_with_get_user_id) + + # Act + response = client.get(f'/api/organizations/{org_id}') + + # Assert + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.asyncio +async def test_get_org_invalid_uuid(mock_app_with_get_user_id): + """ + GIVEN: Invalid UUID format for org_id + WHEN: GET /api/organizations/{org_id} is called + THEN: 422 Unprocessable Entity error is returned + """ + # Arrange + invalid_org_id = 'not-a-valid-uuid' + + client = TestClient(mock_app_with_get_user_id) + + # Act + response = client.get(f'/api/organizations/{invalid_org_id}') + + # Assert + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + +@pytest.mark.asyncio +async def test_get_org_unauthorized(): + """ + GIVEN: User is not authenticated + WHEN: GET /api/organizations/{org_id} is called + THEN: 401 Unauthorized error is returned + """ + # Arrange + app = FastAPI() + app.include_router(org_router) + + # Override to simulate unauthenticated user + async def mock_unauthenticated(): + raise HTTPException(status_code=401, detail='User not authenticated') + + app.dependency_overrides[get_user_id] = mock_unauthenticated + + org_id = uuid.uuid4() + client = TestClient(app) + + # Act + response = client.get(f'/api/organizations/{org_id}') + + # Assert + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +@pytest.mark.asyncio +async def test_get_org_unexpected_error(mock_app_with_get_user_id): + """ + GIVEN: Unexpected error occurs during retrieval + WHEN: GET /api/organizations/{org_id} is called + THEN: 500 Internal Server Error is returned + """ + # Arrange + org_id = uuid.uuid4() + + with patch( + 'server.routes.orgs.OrgService.get_org_by_id', + AsyncMock(side_effect=RuntimeError('Unexpected database error')), + ): + client = TestClient(mock_app_with_get_user_id) + + # Act + response = client.get(f'/api/organizations/{org_id}') + + # Assert + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert 'unexpected error' in response.json()['detail'].lower() + + +@pytest.mark.asyncio +async def test_get_org_with_credits_none(mock_app_with_get_user_id): + """ + GIVEN: Organization exists but credits retrieval returns None + WHEN: GET /api/organizations/{org_id} is called + THEN: Organization is returned with credits as None + """ + # Arrange + org_id = uuid.uuid4() + mock_org = Org( + id=org_id, + name='Test Organization', + contact_name='John Doe', + contact_email='john@example.com', + org_version=5, + default_llm_model='claude-opus-4-5-20251101', + enable_default_condenser=True, + enable_proactive_conversation_starters=True, + ) + + with ( + patch( + 'server.routes.orgs.OrgService.get_org_by_id', + AsyncMock(return_value=mock_org), + ), + patch( + 'server.routes.orgs.OrgService.get_org_credits', + AsyncMock(return_value=None), + ), + ): + client = TestClient(mock_app_with_get_user_id) + + # Act + response = client.get(f'/api/organizations/{org_id}') + + # Assert + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + assert response_data['credits'] is None + + +@pytest.mark.asyncio +async def test_get_org_sensitive_fields_not_exposed(mock_app_with_get_user_id): + """ + GIVEN: Organization is retrieved successfully + WHEN: Response is returned + THEN: Sensitive fields (API keys) are not exposed + """ + # Arrange + org_id = uuid.uuid4() + mock_org = Org( + id=org_id, + name='Test Organization', + contact_name='John Doe', + contact_email='john@example.com', + org_version=5, + default_llm_model='claude-opus-4-5-20251101', + search_api_key='secret-search-key-123', # Should not be exposed + sandbox_api_key='secret-sandbox-key-123', # Should not be exposed + enable_default_condenser=True, + enable_proactive_conversation_starters=True, + ) + + with ( + patch( + 'server.routes.orgs.OrgService.get_org_by_id', + AsyncMock(return_value=mock_org), + ), + patch( + 'server.routes.orgs.OrgService.get_org_credits', + AsyncMock(return_value=100.0), + ), + ): + client = TestClient(mock_app_with_get_user_id) + + # Act + response = client.get(f'/api/organizations/{org_id}') + + # Assert + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + + # Verify sensitive fields are not in response or are None + assert ( + 'search_api_key' not in response_data + or response_data.get('search_api_key') is None + ) + assert ( + 'sandbox_api_key' not in response_data + or response_data.get('sandbox_api_key') is None + ) diff --git a/enterprise/tests/unit/test_org_service.py b/enterprise/tests/unit/test_org_service.py index 7f4078f641..4fffffa272 100644 --- a/enterprise/tests/unit/test_org_service.py +++ b/enterprise/tests/unit/test_org_service.py @@ -18,6 +18,7 @@ with patch('storage.database.engine', create=True), patch( LiteLLMIntegrationError, OrgDatabaseError, OrgNameExistsError, + OrgNotFoundError, ) from storage.org import Org from storage.org_member import OrgMember @@ -564,6 +565,102 @@ async def test_get_org_credits_api_failure_returns_none(mock_litellm_api): assert credits is None +@pytest.mark.asyncio +async def test_get_org_by_id_success(session_maker, owner_role): + """ + GIVEN: Valid org_id and user_id where user is a member + WHEN: get_org_by_id is called + THEN: Organization is returned successfully + """ + # Arrange + org_id = uuid.uuid4() + user_id = uuid.uuid4() + org_name = 'Test Organization' + + # Create mock objects + mock_org = Org(id=org_id, name=org_name) + mock_org_member = OrgMember( + org_id=org_id, + user_id=user_id, + role_id=1, + llm_api_key='test-key', + status='active', + ) + + with ( + patch('storage.org_service.OrgMemberStore.get_org_member') as mock_get_member, + patch('storage.org_service.OrgStore.get_org_by_id') as mock_get_org, + ): + mock_get_member.return_value = mock_org_member + mock_get_org.return_value = mock_org + + # Act + result = await OrgService.get_org_by_id(org_id, str(user_id)) + + # Assert + assert result is not None + assert result.id == org_id + assert result.name == org_name + mock_get_member.assert_called_once() + mock_get_org.assert_called_once_with(org_id) + + +@pytest.mark.asyncio +async def test_get_org_by_id_user_not_member(): + """ + GIVEN: User is not a member of the organization + WHEN: get_org_by_id is called + THEN: OrgNotFoundError is raised + """ + # Arrange + org_id = uuid.uuid4() + user_id = str(uuid.uuid4()) + + with patch( + 'storage.org_service.OrgMemberStore.get_org_member', + return_value=None, + ): + # Act & Assert + with pytest.raises(OrgNotFoundError) as exc_info: + await OrgService.get_org_by_id(org_id, user_id) + + assert str(org_id) in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_get_org_by_id_org_not_found(): + """ + GIVEN: User is a member but organization doesn't exist (edge case) + WHEN: get_org_by_id is called + THEN: OrgNotFoundError is raised + """ + # Arrange + org_id = uuid.uuid4() + user_id = uuid.uuid4() + + # Create mock org member (but org doesn't exist) + mock_org_member = OrgMember( + org_id=org_id, + user_id=user_id, + role_id=1, + llm_api_key='test-key', + status='active', + ) + + with ( + patch( + 'storage.org_service.OrgMemberStore.get_org_member', + return_value=mock_org_member, + ), + patch('storage.org_service.OrgStore.get_org_by_id', return_value=None), + ): + # Act & Assert + with pytest.raises(OrgNotFoundError) as exc_info: + await OrgService.get_org_by_id(org_id, str(user_id)) + + assert str(org_id) in str(exc_info.value) + + def test_get_user_orgs_paginated_success(session_maker, mock_litellm_api): """ GIVEN: User has organizations in database