diff --git a/.github/workflows/build-ecr.yml b/.github/workflows/build-ecr.yml index ce78c081..ecf78dc5 100644 --- a/.github/workflows/build-ecr.yml +++ b/.github/workflows/build-ecr.yml @@ -7,7 +7,7 @@ on: permissions: contents: read - id-token: write # Required for OIDC + id-token: write # Required for OIDC actions: read env: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c1f27b05..b3e72c7b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -58,4 +58,4 @@ jobs: EOF - name: CDK Deploy - run: cdk deploy --require-approval never \ No newline at end of file + run: cdk deploy --require-approval never diff --git a/README.md b/README.md index 20a22204..0da9856e 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,8 @@ uv run pytest # Deployment -Currently the service is deployed to AWS via the CDK scripts in `deploy/`, +Currently the service is deployed to AWS via the CDK scripts in `deploy/`, and updated on each commit to `main`. Secrets/configuration variables for the deployment are stored in the -GitHub Secrets for the repository. \ No newline at end of file +GitHub Secrets for the repository. diff --git a/auth/config.py b/auth/config.py index e8e92374..85b47881 100644 --- a/auth/config.py +++ b/auth/config.py @@ -14,4 +14,4 @@ class Settings(BaseSettings): @lru_cache() def get_settings(): - return Settings() \ No newline at end of file + return Settings() diff --git a/auth/management.py b/auth/management.py index 1527ce6e..0584ed75 100644 --- a/auth/management.py +++ b/auth/management.py @@ -5,13 +5,13 @@ def get_management_token(): settings = get_settings() - url = f'https://{settings.auth0_domain}/oauth/token' + url = f"https://{settings.auth0_domain}/oauth/token" payload = { - 'grant_type': 'client_credentials', - 'client_id': settings.auth0_management_id, - 'client_secret': settings.auth0_management_secret, - 'audience': f'https://{settings.auth0_domain}/api/v2/' + "grant_type": "client_credentials", + "client_id": settings.auth0_management_id, + "client_secret": settings.auth0_management_secret, + "audience": f"https://{settings.auth0_domain}/api/v2/", } response = httpx.post(url, json=payload) response.raise_for_status() - return response.json()['access_token'] + return response.json()["access_token"] diff --git a/auth/validator.py b/auth/validator.py index e76435c4..b625a719 100644 --- a/auth/validator.py +++ b/auth/validator.py @@ -1,25 +1,14 @@ -from typing import Dict - import httpx -from fastapi import HTTPException -from jose import jwt, jwk -from jose.exceptions import JWTError from auth.config import get_settings +from fastapi import Depends, HTTPException +from fastapi.security import OAuth2PasswordBearer +from jose import jwt, jwk +from jose.exceptions import JWTError from schemas.tokens import AccessTokenPayload +from schemas.user import User - -def get_rsa_key(token: str) -> jwk.RSAKey | None: - settings = get_settings() - jwks_url = f"https://{settings.auth0_domain}/.well-known/jwks.json" - response = httpx.get(jwks_url) - jwks = response.json() - unverified_header = jwt.get_unverified_header(token) - - for key in jwks["keys"]: - if key["kid"] == unverified_header["kid"]: - return jwk.construct(key) - return None +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") def verify_jwt(token: str) -> AccessTokenPayload: @@ -28,8 +17,11 @@ def verify_jwt(token: str) -> AccessTokenPayload: rsa_key = get_rsa_key(token) except JWTError as e: raise HTTPException(status_code=401, detail=f"Invalid token: {e}") + if rsa_key is None: - raise HTTPException(status_code=401, detail="Couldn't find a matching signing key.") + raise HTTPException( + status_code=401, detail="Couldn't find a matching signing key." + ) try: payload = jwt.decode( @@ -37,17 +29,42 @@ def verify_jwt(token: str) -> AccessTokenPayload: rsa_key, algorithms=settings.auth0_algorithms, audience=settings.auth0_audience, - issuer=f"https://{settings.auth0_domain}/" + issuer=f"https://{settings.auth0_domain}/", ) except JWTError as e: raise HTTPException(status_code=401, detail=f"Invalid token: {e}") roles_claim = "biocommons.org.au/roles" if roles_claim not in payload: - raise HTTPException(status_code=403, detail=f"Missing required claim: {roles_claim}") + raise HTTPException( + status_code=403, detail=f"Missing required claim: {roles_claim}" + ) roles = payload[roles_claim] - if not isinstance(roles, list) or not any("admin" in role.lower() for role in roles): - raise HTTPException(status_code=403, detail=f"Access denied: Insufficient permissions") + if not isinstance(roles, list) or not any( + "admin" in role.lower() for role in roles + ): + raise HTTPException( + status_code=403, detail="Access denied: Insufficient permissions" + ) return AccessTokenPayload(**payload) + + +def get_rsa_key(token: str) -> jwk.RSAKey | None: # type: ignore + settings = get_settings() + jwks_url = f"https://{settings.auth0_domain}/.well-known/jwks.json" + response = httpx.get(jwks_url) + jwks = response.json() + unverified_header = jwt.get_unverified_header(token) + + for key in jwks["keys"]: + if key["kid"] == unverified_header["kid"]: + return jwk.construct(key) + + return None + + +def get_current_user(token: str = Depends(oauth2_scheme)) -> User: + access_token = verify_jwt(token) + return User(access_token=access_token) diff --git a/main.py b/main.py index 0b0930d1..d8e102f9 100644 --- a/main.py +++ b/main.py @@ -1,22 +1,12 @@ -from fastapi import Depends, FastAPI -from fastapi.security import OAuth2PasswordBearer - -from auth.management import get_management_token -from auth.validator import verify_jwt -from schemas.user import User +from fastapi import FastAPI +from routers import user app = FastAPI() -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") - -def get_current_user(token: str = Depends(oauth2_scheme)) -> User: - access_token = verify_jwt(token) - return User(access_token=access_token) @app.get("/") def public_route(): - return {"message": "Public route"} + return {"message": "AAI Backend API"} + -@app.get("/private") -def private_route(user=Depends(get_current_user)): - return {"message": "Private route", "user_claims": user, "management_token": get_management_token()} +app.include_router(user.router) diff --git a/routers/user.py b/routers/user.py new file mode 100644 index 00000000..0dc43e59 --- /dev/null +++ b/routers/user.py @@ -0,0 +1,237 @@ +from typing import Dict, List, Any +from httpx import AsyncClient + +from auth.config import get_settings +from auth.management import get_management_token +from auth.validator import get_current_user +from datetime import datetime, timezone +from fastapi import APIRouter, Depends, HTTPException +from schemas.requests import ResourceRequest, ServiceRequest +from schemas.service import Auth0User, Service, Resource +from schemas.user import User + +router = APIRouter( + prefix="/me", tags=["user"], responses={401: {"description": "Unauthorized"}} +) + + +async def get_user_data(user: User) -> Auth0User: + """Fetch and return user data from Auth0.""" + url = f"https://{get_settings().auth0_domain}/api/v2/users/{user.access_token.sub}" + token = get_management_token() + headers = {"Authorization": f"Bearer {token}"} + + try: + async with AsyncClient() as client: + response = await client.get(url, headers=headers) + if response.status_code != 200: + raise HTTPException( + status_code=403, + detail="Failed to fetch user data", + ) + return Auth0User(**response.json()) + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=403, detail=f"Failed to fetch user data: {str(e)}" + ) + + +async def update_user_metadata( + user_id: str, token: str, metadata: Dict[str, Any] +) -> Dict[str, Any]: + """Utility function to update user metadata in Auth0.""" + url = f"https://{get_settings().auth0_domain}/api/v2/users/{user_id}" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + try: + async with AsyncClient() as client: + response = await client.patch( + url, headers=headers, json={"app_metadata": metadata} + ) + if response.status_code != 200: + raise HTTPException( + status_code=403, + detail="Failed to update user metadata", + ) + return response.json() + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=403, + detail=f"Failed to update user metadata: {str(e)}", + ) + + +@router.get("/services", response_model=Dict[str, List[Service]]) +async def get_services(user: User = Depends(get_current_user)): + """Get all services for the authenticated user.""" + user_data = await get_user_data(user) + return {"services": user_data.app_metadata.services} + + +@router.get("/services/approved", response_model=Dict[str, List[Service]]) +async def get_approved_services(user: User = Depends(get_current_user)): + """Get approved services for the authenticated user.""" + user_data = await get_user_data(user) + return {"approved_services": user_data.approved_services} + + +@router.get("/services/pending", response_model=Dict[str, List[Service]]) +async def get_pending_services(user: User = Depends(get_current_user)): + """Get pending services for the authenticated user.""" + user_data = await get_user_data(user) + return {"pending_services": user_data.pending_services} + + +@router.get("/resources", response_model=Dict[str, List[Resource]]) +async def get_resources(user: User = Depends(get_current_user)): + """Get all resources for the authenticated user.""" + user_data = await get_user_data(user) + return {"resources": user_data.app_metadata.get_all_resources()} + + +@router.get("/resources/approved", response_model=Dict[str, List[Resource]]) +async def get_approved_resources(user: User = Depends(get_current_user)): + """Get approved resources for the authenticated user.""" + user_data = await get_user_data(user) + return {"approved_resources": user_data.approved_resources} + + +@router.get("/resources/pending", response_model=Dict[str, List[Resource]]) +async def get_pending_resources(user: User = Depends(get_current_user)): + """Get pending resources for the authenticated user.""" + user_data = await get_user_data(user) + return {"pending_resources": user_data.pending_resources} + + +@router.get("/all/pending", response_model=Dict[str, List[Any]]) +async def get_all_pending(user: User = Depends(get_current_user)): + """Get all pending services and resources.""" + user_data = await get_user_data(user) + return { + "pending_services": user_data.pending_services, + "pending_resources": user_data.pending_resources, + } + + +@router.post( + "/request/service", + response_model=Dict[str, Any], + responses={ + 400: {"description": "Bad Request - Service already exists"}, + 403: {"description": "Forbidden - User ID mismatch"}, + 500: {"description": "Internal server error"}, + }, +) +async def request_service( + service_request: ServiceRequest, user: User = Depends(get_current_user) +) -> Dict[str, Any]: + """Submit a request for a service.""" + if user.access_token.sub != service_request.user_id: + raise HTTPException( + status_code=403, + detail="User ID in request does not match authenticated user", + ) + + user_data = await get_user_data(user) + + if any(s.id == service_request.id for s in user_data.app_metadata.services): + raise HTTPException( + status_code=400, + detail=f"Service request with ID {service_request.id} already exists", + ) + + new_service = Service( + name=service_request.name, + id=service_request.id, + status="pending", + last_updated=datetime.now(timezone.utc), + updated_by=user.access_token.sub, + resources=[], + ) + + user_data.app_metadata.services.append(new_service) + await update_user_metadata( + user.access_token.sub, + get_management_token(), + user_data.app_metadata.model_dump(), + ) + + return { + "message": "Service request submitted successfully", + "service": new_service.model_dump(mode="json"), + } + + +@router.post( + "/request/{service_id}/{resource_id}", + response_model=Dict[str, Any], + responses={ + 400: {"description": "Bad Request"}, + 403: {"description": "Forbidden"}, + 404: {"description": "Service not found"}, + 500: {"description": "Internal server error"}, + }, +) +async def request_resource( + service_id: str, + resource_id: str, + resource_request: ResourceRequest, + user: User = Depends(get_current_user), +) -> Dict[str, Any]: + """Submit a request for a resource within a service.""" + if user.access_token.sub != resource_request.user_id: + raise HTTPException( + status_code=403, + detail="User ID in request does not match authenticated user", + ) + + if service_id != resource_request.service_id: + raise HTTPException( + status_code=400, detail="Service ID in path does not match request body" + ) + + user_data = await get_user_data(user) + service = user_data.app_metadata.get_service_by_id(service_id) + + if not service: + raise HTTPException( + status_code=404, detail=f"Service with ID {service_id} not found" + ) + + if service.status != "approved": + raise HTTPException( + status_code=400, + detail="Cannot request resources for a service that is not approved", + ) + + if any(r.id == resource_id for r in service.resources): + raise HTTPException( + status_code=400, + detail=f"Resource request with ID {resource_id} already exists", + ) + + new_resource = Resource( + name=resource_request.name, id=resource_id, status="pending" + ) + + service.resources.append(new_resource) + service.last_updated = datetime.now(timezone.utc) + service.updated_by = user.access_token.sub + + await update_user_metadata( + user.access_token.sub, + get_management_token(), + user_data.app_metadata.model_dump(), + ) + + return { + "message": "Resource request submitted successfully", + "service": service.model_dump(mode="json"), + "resource": new_resource.model_dump(mode="json"), + } diff --git a/schemas/__init__.py b/schemas/__init__.py index 68bf508c..f6e9fa5a 100644 --- a/schemas/__init__.py +++ b/schemas/__init__.py @@ -1,4 +1,5 @@ -from .service import Service, Resource from .group import Group +from .service import Resource, Service -__all__ = ["Service", "Resource", "Group"] \ No newline at end of file + +__all__ = ["Service", "Resource", "Group"] diff --git a/schemas/group.py b/schemas/group.py index 0085fdb3..b82385c0 100644 --- a/schemas/group.py +++ b/schemas/group.py @@ -3,4 +3,4 @@ class Group(BaseModel): name: str - id: str \ No newline at end of file + id: str diff --git a/schemas/requests.py b/schemas/requests.py new file mode 100644 index 00000000..d929c9aa --- /dev/null +++ b/schemas/requests.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel + + +class ServiceRequest(BaseModel): + name: str + id: str + user_id: str + + +class ResourceRequest(BaseModel): + name: str + id: str + service_id: str + user_id: str diff --git a/schemas/service.py b/schemas/service.py index b9378416..8bfdfb5b 100644 --- a/schemas/service.py +++ b/schemas/service.py @@ -1,6 +1,6 @@ -from pydantic import BaseModel, Field -from typing import Literal from datetime import datetime +from pydantic import BaseModel, Field, HttpUrl +from typing import List, Literal, Optional class Resource(BaseModel): @@ -8,10 +8,89 @@ class Resource(BaseModel): status: Literal["approved", "revoked", "pending"] id: str + class Service(BaseModel): name: str id: str status: Literal["approved", "revoked", "pending"] last_updated: datetime updated_by: str - resources: list[Resource] = Field(default_factory=list) + resources: List[Resource] = Field(default_factory=list) + + +class Group(BaseModel): + name: str + id: str + + +class AppMetadata(BaseModel): + groups: List[Group] = Field(default_factory=list) + services: List[Service] = Field(default_factory=list) + + def get_pending_services(self) -> List[Service]: + """Get all pending services.""" + return [s for s in self.services if s.status == "pending"] + + def get_approved_services(self) -> List[Service]: + """Get all approved services.""" + return [s for s in self.services if s.status == "approved"] + + def get_all_resources(self) -> List[Resource]: + """Get all resources across services.""" + return [r for s in self.services for r in s.resources] + + def get_pending_resources(self) -> List[Resource]: + """Get all pending resources.""" + return [r for s in self.services for r in s.resources if r.status == "pending"] + + def get_approved_resources(self) -> List[Resource]: + """Get all approved resources.""" + return [r for s in self.services for r in s.resources if r.status == "approved"] + + def get_service_by_id(self, service_id: str) -> Optional[Service]: + """Get a service by its ID.""" + return next((s for s in self.services if s.id == service_id), None) + + +class Identity(BaseModel): + connection: str + provider: str + user_id: str + isSocial: bool + + +class Auth0User(BaseModel): + created_at: datetime + email: str + email_verified: bool + identities: List[Identity] + name: str + nickname: str + picture: HttpUrl + updated_at: datetime + user_id: str + user_metadata: dict = Field(default_factory=dict) + app_metadata: AppMetadata + last_ip: Optional[str] = None + last_login: Optional[datetime] = None + logins_count: Optional[int] = None + + @property + def pending_services(self) -> List[Service]: + """Get all services with pending status.""" + return self.app_metadata.get_pending_services() + + @property + def approved_services(self) -> List[Service]: + """Get all services with approved status.""" + return self.app_metadata.get_approved_services() + + @property + def pending_resources(self) -> List[Resource]: + """Get all resources with pending status across all services.""" + return self.app_metadata.get_pending_resources() + + @property + def approved_resources(self) -> List[Resource]: + """Get all resources with approved status across all services.""" + return self.app_metadata.get_approved_resources() diff --git a/schemas/tokens.py b/schemas/tokens.py index ea88d00b..a6ba2246 100644 --- a/schemas/tokens.py +++ b/schemas/tokens.py @@ -6,9 +6,10 @@ class AccessTokenPayload(BaseModel): """ Schema for the access token payload. """ + biocommons_roles: list[str] = Field( alias="biocommons.org.au/roles", - description="BioCommons-specific roles assigned to the user" + description="BioCommons-specific roles assigned to the user", ) email: Optional[str] = Field(None, description="Email address") iss: str = Field(description="Issuer identifier") diff --git a/schemas/user.py b/schemas/user.py index 7aef6284..b9f7b8fb 100644 --- a/schemas/user.py +++ b/schemas/user.py @@ -8,6 +8,7 @@ class User(BaseModel): permissions checks here, instead of doing individual checks in different places. """ + access_token: AccessTokenPayload def is_admin(self) -> bool: @@ -20,4 +21,3 @@ def is_admin(self) -> bool: if "admin" in role.lower(): return True return False - diff --git a/tests/datagen.py b/tests/datagen.py index 52af0de9..8fae5e63 100644 --- a/tests/datagen.py +++ b/tests/datagen.py @@ -1,12 +1,14 @@ from polyfactory.factories.pydantic_factory import ModelFactory +from schemas.service import Auth0User from schemas.tokens import AccessTokenPayload from schemas.user import User -class AccessTokenPayloadFactory(ModelFactory[AccessTokenPayload]): - ... +class AccessTokenPayloadFactory(ModelFactory[AccessTokenPayload]): ... -class UserFactory(ModelFactory[User]): - ... \ No newline at end of file +class UserFactory(ModelFactory[User]): ... + + +class Auth0UserFactory(ModelFactory[Auth0User]): ... diff --git a/tests/test_main.py b/tests/test_main.py index c8393df5..a5918cd3 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,91 +1,10 @@ -import pytest from fastapi.testclient import TestClient - -from auth.config import Settings from main import app -from tests.datagen import AccessTokenPayloadFactory client = TestClient(app) -def test_public(): +def test_root(): response = client.get("/") assert response.status_code == 200 - assert response.json() == {"message": "Public route"} - - -def test_private_valid_token(mocker): - mocker.patch( - "auth.config.get_settings", - return_value=Settings( - auth0_domain="mock-domain", - auth0_audience="mock-audience", - auth0_management_id="mock-id", - auth0_management_secret="mock-secret", - auth0_algorithms=["RS256"], - ), - ) - token = AccessTokenPayloadFactory.build( - sub="auth0|123456789", - biocommons_roles=["acdc/indexd_admin"], - ) - mocker.patch( - "main.verify_jwt", - return_value=token, - ) - mocker.patch("main.get_management_token", return_value="mock_management_token") - headers = {"Authorization": "Bearer valid_token"} - response = client.get("/private", headers=headers) - assert response.status_code == 200 - assert response.json() == { - "message": "Private route", - "user_claims": { - "access_token": token.model_dump(by_alias=True) - }, - "management_token": "mock_management_token", - } - - -def test_private_missing_token(): - response = client.get("/private") - assert response.status_code == 401 - assert response.json() == {"detail": "Not authenticated"} - - -def test_private_invalid_token(mocker): - mocker.patch( - "httpx.get", return_value=mocker.Mock(json=lambda: {"keys": []}) - ) - mocker.patch( - "auth.validator.verify_jwt", - side_effect=Exception("Invalid token: Error decoding token headers."), - ) - - headers = {"Authorization": "Bearer invalid_token"} - response = client.get("/private", headers=headers) - assert response.status_code == 401 - assert response.json() == { - "detail": "Invalid token: Error decoding token headers." - } - - -@pytest.mark.parametrize("roles", [[], ["user_only"]]) -def test_private_insufficient_permissions(roles, mocker): - """ - Test that we return an error when a user has no admin roles - """ - # Bypass finding the signing key - mocker.patch( - "auth.validator.get_rsa_key", - return_value={"kid": "dummy"} - ) - mocker.patch( - "jose.jwt.decode", - return_value={"biocommons.org.au/roles": roles} - ) - headers = {"Authorization": "Bearer insufficient_permissions_token"} - response = client.get("/private", headers=headers) - assert response.status_code == 403 - assert response.json() == { - "detail": "Access denied: Insufficient permissions" - } + assert response.json() == {"message": "AAI Backend API"} diff --git a/tests/test_user.py b/tests/test_user.py new file mode 100644 index 00000000..cf484a1e --- /dev/null +++ b/tests/test_user.py @@ -0,0 +1,485 @@ +from fastapi import HTTPException +from datetime import datetime +import pytest + +from auth.config import Settings +from fastapi.testclient import TestClient +from main import app +from schemas.service import Service, Resource, Group, AppMetadata +from tests.datagen import AccessTokenPayloadFactory, Auth0UserFactory + +client = TestClient(app) + + +# --- Test Fixtures --- +@pytest.fixture(autouse=True) +def mock_auth_settings(mocker): + """Fixture to mock auth settings globally""" + mock_settings = Settings( + auth0_domain="mock-domain.com", + auth0_audience="mock-audience", + auth0_management_id="mock-id", + auth0_management_secret="mock-secret", + auth0_algorithms=["RS256"], + ) + mocker.patch("auth.config.get_settings", return_value=mock_settings) + mocker.patch("auth.management.get_settings", return_value=mock_settings) + return mock_settings + + +@pytest.fixture +def mock_auth_token(mocker): + """Fixture to mock authentication token""" + token = AccessTokenPayloadFactory.build( + sub="auth0|123456789", + biocommons_roles=["acdc/indexd_admin"], + ) + mocker.patch("auth.validator.verify_jwt", return_value=token) + mocker.patch("auth.management.get_management_token", return_value="mock_token") + return token + + +@pytest.fixture +def auth_headers(): + """Fixture to provide auth headers""" + return {"Authorization": "Bearer valid_token"} + + +@pytest.fixture +def mock_user_data(): + """Fixture to provide mock user data""" + return Auth0UserFactory.build( + app_metadata=AppMetadata( + groups=[Group(name="Australian University", id="AU")], + services=[ + Service( + id="service1", + name="Service 1", + status="approved", + last_updated=datetime.now(), + updated_by="test@example.com", + resources=[ + Resource(id="resource1", name="Resource 1", status="approved"), + Resource(id="resource2", name="Resource 2", status="pending"), + ], + ), + Service( + id="service2", + name="Service 2", + status="pending", + last_updated=datetime.now(), + updated_by="test@example.com", + resources=[ + Resource(id="resource3", name="Resource 3", status="pending") + ], + ), + ], + ), + ) + + +# --- Authentication Tests (GET) --- +@pytest.mark.parametrize( + "endpoint", + [ + "/me/services", + "/me/services/approved", + "/me/services/pending", + "/me/resources", + "/me/resources/approved", + "/me/resources/pending", + "/me/all/pending", + ], +) +def test_endpoints_require_auth(endpoint): + """Test that all endpoints require authentication""" + response = client.get(endpoint) + assert response.status_code == 401 + assert response.json() == {"detail": "Not authenticated"} + + +# --- Service Endpoints (GET) --- +def test_get_all_services( + mock_auth_settings, mock_auth_token, auth_headers, mock_user_data, mocker +): + """Test getting all services""" + mocker.patch( + "routers.user.get_user_data", # Changed from fetch_user_data + return_value=mock_user_data, + ) + mocker.patch( + "routers.user.get_management_token", return_value="mock_management_token" + ) + + response = client.get("/me/services", headers=auth_headers) + assert response.status_code == 200 + + expected_services = [ + s.model_dump(mode="json") for s in mock_user_data.app_metadata.services + ] + assert response.json() == {"services": expected_services} + + +def test_get_approved_services( + mock_auth_settings, mock_auth_token, auth_headers, mock_user_data, mocker +): + """Test getting approved services""" + mocker.patch( + "routers.user.get_user_data", # Changed from fetch_user_data + return_value=mock_user_data, + ) + mocker.patch( + "routers.user.get_management_token", return_value="mock_management_token" + ) + + response = client.get("/me/services/approved", headers=auth_headers) + assert response.status_code == 200 + + approved_services = [ + s.model_dump(mode="json") + for s in mock_user_data.app_metadata.services + if s.status == "approved" + ] + assert response.json() == {"approved_services": approved_services} + + +def test_get_pending_services( + mock_auth_settings, mock_auth_token, auth_headers, mock_user_data, mocker +): + """Test getting pending services""" + mocker.patch( + "routers.user.get_user_data", # Changed from fetch_user_data + return_value=mock_user_data, + ) + mocker.patch( + "routers.user.get_management_token", return_value="mock_management_token" + ) + + response = client.get("/me/services/pending", headers=auth_headers) + assert response.status_code == 200 + + pending_services = [ + s.model_dump(mode="json") + for s in mock_user_data.app_metadata.services + if s.status == "pending" + ] + assert response.json() == {"pending_services": pending_services} + + +def test_get_services_failed_fetch( + mock_auth_settings, mock_auth_token, auth_headers, mocker +): + """Test handling of failed API calls""" + mocker.patch( + "routers.user.get_user_data", # Changed from fetch_user_data + side_effect=HTTPException(status_code=403, detail="Failed to fetch user data"), + ) + mocker.patch( + "routers.user.get_management_token", return_value="mock_management_token" + ) + + response = client.get("/me/services", headers=auth_headers) + assert response.status_code == 403 + assert response.json() == {"detail": "Failed to fetch user data"} + + +def test_get_services_empty_metadata( + mock_auth_settings, mock_auth_token, auth_headers, mocker +): + """Test handling of empty metadata""" + empty_user = Auth0UserFactory.build( + app_metadata=AppMetadata(services=[], groups=[]), + ) + mocker.patch("routers.user.get_user_data", return_value=empty_user) + mocker.patch( + "routers.user.get_management_token", return_value="mock_management_token" + ) + + response = client.get("/me/services", headers=auth_headers) + assert response.status_code == 200 + assert response.json() == {"services": []} + + +def test_get_services_no_metadata( + mock_auth_settings, mock_auth_token, auth_headers, mocker +): + """Test handling of missing metadata""" + no_metadata_user = Auth0UserFactory.build(app_metadata=AppMetadata()) + mocker.patch("routers.user.get_user_data", return_value=no_metadata_user) + mocker.patch( + "routers.user.get_management_token", return_value="mock_management_token" + ) + + response = client.get("/me/services", headers=auth_headers) + assert response.status_code == 200 + assert response.json() == {"services": []} + + +# --- Resource Endpoints (GET) --- +def test_get_all_resources( + mock_auth_settings, mock_auth_token, auth_headers, mock_user_data, mocker +): + """Test getting all resources""" + mocker.patch( + "routers.user.get_user_data", # Changed from fetch_user_data + return_value=mock_user_data, + ) + mocker.patch( + "routers.user.get_management_token", return_value="mock_management_token" + ) + + response = client.get("/me/resources", headers=auth_headers) + assert response.status_code == 200 + all_resources = [ + r.model_dump() + for s in mock_user_data.app_metadata.services + for r in s.resources + ] + assert response.json() == {"resources": all_resources} + + +def test_get_approved_resources( + mock_auth_settings, mock_auth_token, auth_headers, mock_user_data, mocker +): + """Test getting approved resources""" + mocker.patch( + "routers.user.get_user_data", # Changed from fetch_user_data + return_value=mock_user_data, + ) + mocker.patch( + "routers.user.get_management_token", return_value="mock_management_token" + ) + + response = client.get("/me/resources/approved", headers=auth_headers) + assert response.status_code == 200 + approved_resources = [ + r.model_dump() + for s in mock_user_data.app_metadata.services + for r in s.resources + if r.status == "approved" + ] + assert response.json() == {"approved_resources": approved_resources} + + +def test_get_resources_empty_metadata( + mock_auth_settings, mock_auth_token, auth_headers, mocker +): + """Test handling of empty resource metadata""" + empty_user = Auth0UserFactory.build(app_metadata=AppMetadata(services=[], groups=[]), + ) + mocker.patch("routers.user.get_user_data", return_value=empty_user) + mocker.patch( + "routers.user.get_management_token", return_value="mock_management_token" + ) + + response = client.get("/me/resources", headers=auth_headers) + assert response.status_code == 200 + assert response.json() == {"resources": []} + + +def test_get_resources_no_metadata( + mock_auth_settings, mock_auth_token, auth_headers, mocker +): + """Test handling of missing resource metadata""" + no_metadata_user = Auth0UserFactory.build(app_metadata=AppMetadata()) + mocker.patch("routers.user.get_user_data", return_value=no_metadata_user) + mocker.patch( + "routers.user.get_management_token", return_value="mock_management_token" + ) + + response = client.get("/me/resources", headers=auth_headers) + assert response.status_code == 200 + assert response.json() == {"resources": []} + + +# --- Service Request Endpoints (POST) --- +def test_request_service_success( + mock_auth_settings, mock_auth_token, auth_headers, mock_user_data, mocker +): + """Test successful service request""" + mocker.patch("routers.user.get_user_data", return_value=mock_user_data) + mocker.patch( + "routers.user.get_management_token", return_value="mock_management_token" + ) + mocker.patch("routers.user.update_user_metadata", return_value={}) + + new_service = { + "name": "New Service", + "id": "service3", + "user_id": mock_auth_token.sub, + } + + response = client.post( + "/me/request/service", json=new_service, headers=auth_headers + ) + assert response.status_code == 200 + assert response.json()["message"] == "Service request submitted successfully" + assert response.json()["service"]["id"] == "service3" + + +def test_request_service_duplicate( + mock_auth_settings, mock_auth_token, auth_headers, mock_user_data, mocker +): + """Test duplicate service request""" + mocker.patch("routers.user.get_user_data", return_value=mock_user_data) + mocker.patch( + "routers.user.get_management_token", return_value="mock_management_token" + ) + + existing_service = { + "name": "Service 1", + "id": "service1", + "user_id": mock_auth_token.sub, + } + + response = client.post( + "/me/request/service", json=existing_service, headers=auth_headers + ) + assert response.status_code == 400 + assert ( + response.json()["detail"] == "Service request with ID service1 already exists" + ) + + +def test_request_service_user_mismatch( + mock_auth_settings, mock_auth_token, auth_headers, mock_user_data, mocker +): + """Test service request with mismatched user""" + request_payload = { + "name": "Service Mismatch", + "id": "svc-mismatch", + "user_id": "auth0|WRONG_USER", + } + + response = client.post( + "/me/request/service", json=request_payload, headers=auth_headers + ) + assert response.status_code == 403 + assert ( + response.json()["detail"] + == "User ID in request does not match authenticated user" + ) + + +# --- Resource Request Endpoints (POST) --- +def test_request_resource_success( + mock_auth_settings, mock_auth_token, auth_headers, mock_user_data, mocker +): + """Test successful resource request""" + mocker.patch("routers.user.get_user_data", return_value=mock_user_data) + mocker.patch( + "routers.user.get_management_token", return_value="mock_management_token" + ) + mocker.patch("routers.user.update_user_metadata", return_value={}) + + request_payload = { + "name": "New Resource", + "id": "resource-new", + "user_id": mock_auth_token.sub, + "service_id": "service1", + } + + response = client.post( + "/me/request/service1/resource-new", json=request_payload, headers=auth_headers + ) + assert response.status_code == 200 + assert response.json()["resource"]["id"] == "resource-new" + + +def test_request_resource_user_mismatch( + mock_auth_settings, mock_auth_token, auth_headers, mock_user_data, mocker +): + """Test resource request with mismatched user""" + request_payload = { + "name": "Invalid Resource", + "id": "res-invalid", + "user_id": "wrong-user", + "service_id": "service1", + } + + response = client.post( + "/me/request/service1/res-invalid", json=request_payload, headers=auth_headers + ) + assert response.status_code == 403 + assert ( + response.json()["detail"] + == "User ID in request does not match authenticated user" + ) + + +def test_request_resource_non_approved_service( + mock_auth_settings, mock_auth_token, auth_headers, mock_user_data, mocker +): + """Test resource request for non-approved service""" + mocker.patch("routers.user.get_user_data", return_value=mock_user_data) + mocker.patch( + "routers.user.get_management_token", return_value="mock_management_token" + ) + + request_payload = { + "name": "Blocked Resource", + "id": "blocked-resource", + "user_id": mock_auth_token.sub, + "service_id": "service2", + } + + response = client.post( + "/me/request/service2/blocked-resource", + json=request_payload, + headers=auth_headers, + ) + assert response.status_code == 400 + assert ( + response.json()["detail"] + == "Cannot request resources for a service that is not approved" + ) + + +def test_request_resource_duplicate( + mock_auth_settings, mock_auth_token, auth_headers, mock_user_data, mocker +): + """Test duplicate resource request""" + mocker.patch("routers.user.get_user_data", return_value=mock_user_data) + mocker.patch( + "routers.user.get_management_token", return_value="mock_management_token" + ) + + existing_resource = { + "name": "Resource 1", + "id": "resource1", + "user_id": mock_auth_token.sub, + "service_id": "service1", + } + + response = client.post( + "/me/request/service1/resource1", json=existing_resource, headers=auth_headers + ) + assert response.status_code == 400 + assert ( + response.json()["detail"] == "Resource request with ID resource1 already exists" + ) + + +def test_request_resource_invalid_service( + mock_auth_settings, mock_auth_token, auth_headers, mock_user_data, mocker +): + """Test resource request for non-existent service""" + mocker.patch("routers.user.get_user_data", return_value=mock_user_data) + mocker.patch( + "routers.user.get_management_token", return_value="mock_management_token" + ) + + request_payload = { + "name": "Invalid Service Resource", + "id": "resource-invalid", + "user_id": mock_auth_token.sub, + "service_id": "non-existent-service", + } + + response = client.post( + "/me/request/non-existent-service/resource-invalid", + json=request_payload, + headers=auth_headers, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Service with ID non-existent-service not found"