diff --git a/registry/api/agent_routes.py b/registry/api/agent_routes.py index b97af05c..78f6d538 100644 --- a/registry/api/agent_routes.py +++ b/registry/api/agent_routes.py @@ -29,6 +29,7 @@ AgentProvider, AgentRegistrationRequest, ) +from pydantic import BaseModel from ..core.config import settings @@ -44,6 +45,10 @@ router = APIRouter() +class RatingRequest(BaseModel): + rating: int + + def _normalize_path( path: Optional[str], agent_name: Optional[str] = None, @@ -390,50 +395,9 @@ async def list_agents( } -@router.get("/agents/{path:path}") -async def get_agent( - path: str, - user_context: Annotated[dict, Depends(nginx_proxied_auth)], -): - """ - Get a single agent by path. - - Public agents are visible without special permissions. - Private and group-restricted agents require authorization. - - Args: - path: Agent path - user_context: Authenticated user context - - Returns: - Complete agent card - - Raises: - HTTPException: 404 if not found, 403 if not authorized - """ - path = _normalize_path(path) - - agent_card = agent_service.get_agent_info(path) - if not agent_card: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Agent not found at path '{path}'", - ) - - accessible = _filter_agents_by_access([agent_card], user_context) - - if not accessible: - logger.warning( - f"User {user_context['username']} attempted to access agent {path} " - f"without permission" - ) - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="You do not have access to this agent", - ) - - return agent_card.model_dump() +# IMPORTANT: Specific routes with path suffixes (/health, /rate, /rating, /toggle) +# must come BEFORE catch-all {path:path} routes to prevent FastAPI from matching them incorrectly @router.post("/agents/{path:path}/health") async def check_agent_health( @@ -513,6 +477,183 @@ async def check_agent_health( } +@router.post("/agents/{path:path}/rate") +async def rate_agent( + path: str, + request: RatingRequest, + user_context: Annotated[dict, Depends(nginx_proxied_auth)], +): + """Save integer ratings to agent card.""" + path = _normalize_path(path) + + agent_card = agent_service.get_agent_info(path) + if not agent_card: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Agent not found at path '{path}'", + ) + + accessible = _filter_agents_by_access([agent_card], user_context) + if not accessible: + logger.warning( + f"User {user_context['username']} attempted to rate agent {path} without permission" + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have access to this agent", + ) + + success = agent_service.update_rating(path, user_context["username"], request.rating) + if not success: + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": "Failed to save rating"}, + ) + + return {"message": "Rating added successfully"} + + +@router.get("/agents/{path:path}/rating") +async def get_agent_rating( + path: str, + user_context: Annotated[dict, Depends(nginx_proxied_auth)], +): + """Get agent rating information.""" + path = _normalize_path(path) + + agent_card = agent_service.get_agent_info(path) + if not agent_card: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Agent not found at path '{path}'", + ) + + accessible = _filter_agents_by_access([agent_card], user_context) + if not accessible: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have access to this agent", + ) + + return { + "num_stars": agent_card.num_stars, + "rating_details": agent_card.rating_details, + } + + +@router.post("/agents/{path:path}/toggle") +async def toggle_agent( + path: str, + enabled: bool, + user_context: Annotated[dict, Depends(nginx_proxied_auth)], +): + """ + Enable or disable an agent. + + Requires toggle_service permission for the agent. + + Args: + path: Agent path + enabled: New enabled state + user_context: Authenticated user context + + Returns: + Updated agent status + + Raises: + HTTPException: 404 if not found, 403 if unauthorized + """ + path = _normalize_path(path) + + agent_card = agent_service.get_agent_info(path) + if not agent_card: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Agent not found at path '{path}'", + ) + + _check_agent_permission("toggle_service", agent_card.name, user_context) + + success = agent_service.toggle_agent(path, enabled) + + if not success: + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": "Failed to toggle agent state"}, + ) + + from ..search.service import faiss_service + + await faiss_service.add_or_update_entity( + path, + agent_card.model_dump(), + "a2a_agent", + enabled, + ) + + logger.info( + f"Agent '{agent_card.name}' ({path}) toggled to {enabled} by user " + f"'{user_context['username']}'" + ) + + return { + "message": f"Agent {'enabled' if enabled else 'disabled'} successfully", + "path": path, + "is_enabled": enabled, + } + + +@router.get("/agents/{path:path}") +async def get_agent( + path: str, + user_context: Annotated[dict, Depends(nginx_proxied_auth)], +): + """ + Get a single agent by path. + + Public agents are visible without special permissions. + Private and group-restricted agents require authorization. + + Args: + path: Agent path + user_context: Authenticated user context + + Returns: + Complete agent card + + Raises: + HTTPException: 404 if not found, 403 if not authorized + """ + path = _normalize_path(path) + + agent_card = agent_service.get_agent_info(path) + if not agent_card: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Agent not found at path '{path}'", + ) + + accessible = _filter_agents_by_access([agent_card], user_context) + + if not accessible: + logger.warning( + f"User {user_context['username']} attempted to access agent {path} " + f"without permission" + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have access to this agent", + ) + + return agent_card.model_dump() + + + + + + + + @router.put("/agents/{path:path}") async def update_agent( path: str, @@ -693,66 +834,6 @@ async def delete_agent( ) -@router.post("/agents/{path:path}/toggle") -async def toggle_agent( - path: str, - enabled: bool, - user_context: Annotated[dict, Depends(nginx_proxied_auth)], -): - """ - Enable or disable an agent. - - Requires toggle_service permission for the agent. - - Args: - path: Agent path - enabled: New enabled state - user_context: Authenticated user context - - Returns: - Updated agent status - - Raises: - HTTPException: 404 if not found, 403 if unauthorized - """ - path = _normalize_path(path) - - agent_card = agent_service.get_agent_info(path) - if not agent_card: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Agent not found at path '{path}'", - ) - - _check_agent_permission("toggle_service", agent_card.name, user_context) - - success = agent_service.toggle_agent(path, enabled) - - if not success: - return JSONResponse( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={"detail": "Failed to toggle agent state"}, - ) - - from ..search.service import faiss_service - - await faiss_service.add_or_update_entity( - path, - agent_card.model_dump(), - "a2a_agent", - enabled, - ) - - logger.info( - f"Agent '{agent_card.name}' ({path}) toggled to {enabled} by user " - f"'{user_context['username']}'" - ) - - return { - "message": f"Agent {'enabled' if enabled else 'disabled'} successfully", - "path": path, - "is_enabled": enabled, - } @router.post("/agents/discover") diff --git a/registry/services/agent_service.py b/registry/services/agent_service.py index 11275e42..edef1b14 100644 --- a/registry/services/agent_service.py +++ b/registry/services/agent_service.py @@ -369,6 +369,83 @@ def list_agents(self) -> List[AgentCard]: """ return list(self.registered_agents.values()) + def update_rating( + self, + path: str, + username: str, + rating: int, + ) -> float: + """ + Log a user rating for an agent. If the user has already rated, update their rating. + + Args: + path: Agent path + username: The user who submitted rating + rating: integer between 1-5 + + Return: + Updated average rating + + Raises: + ValueError: If agent not found + """ + if path not in self.registered_agents: + logger.error(f"Cannot update agent at path '{path}': not found") + raise ValueError(f"Agent not found at path: {path}") + + # Validate rating + if not isinstance(rating, int): + logger.error(f"Invalid rating type: {rating} (type={type(rating)})") + raise ValueError("Rating must be an integer") + if rating < 1 or rating > 5: + logger.error(f"Invalid rating value: {rating}. Must be between 1 and 5.") + raise ValueError("Rating must be between 1 and 5 (inclusive)") + + # Get existing agent + existing_agent = self.registered_agents[path] + + # Ensure num_stars exists and ratings is a list + if "num_stars" not in existing_agent: + existing_agent["num_stars"] = 0.0 + if "rating_details" not in existing_agent or not isinstance(existing_agent["rating_details"], list): + existing_agent["rating_details"] = [] + + # Check if this user has already rated; if so, update their rating + user_found = False + for entry in existing_agent["rating_details"]: + if entry.get("user") == username: + entry["rating"] = rating + user_found = True + break + + # If no existing rating from this user, append a new one + if not user_found: + existing_agent["rating_details"].append({ + "user": username, + "rating": rating, + }) + + # Compute average rating + all_ratings = [entry["rating"] for entry in existing_agent["rating_details"]] + existing_agent["num_stars"] = float(sum(all_ratings) / len(all_ratings)) + + # Validate updated agent + try: + updated_agent = AgentCard(**existing_agent) + except Exception as e: + logger.error(f"Failed to validate updated agent: {e}") + raise ValueError(f"Invalid agent update: {e}") + + # Save to disk + if not _save_agent_to_disk(updated_agent, settings.agents_dir): + raise ValueError(f"Failed to save updated agent to disk") + + # Update in-memory registry + self.registered_agents[path] = updated_agent + + logger.info(f"Agent '{updated_agent.name}' ({path}) updated") + + return existing_agent["num_stars"] def update_agent( self, diff --git a/tests/test_rate_agent_curl.sh b/tests/test_rate_agent_curl.sh new file mode 100755 index 00000000..e4053ce0 --- /dev/null +++ b/tests/test_rate_agent_curl.sh @@ -0,0 +1,350 @@ +#!/bin/bash + +################################################################################ +# Test script for the rate_agent function in agent_routes.py +# +# This script demonstrates how to test the rate_agent endpoint using curl +# +# Usage: +# bash test_rate_agent_curl.sh +# bash test_rate_agent_curl.sh /path/to/token.json +# TOKEN_FILE=/path/to/token.json bash test_rate_agent_curl.sh +# +# Token Resolution (in order of precedence): +# 1. Command-line argument (first parameter) +# 2. TOKEN_FILE environment variable +# 3. Default: .oauth-tokens/admin-bot-token.json +# +# Note: Requires Docker containers running (docker-compose up -d) +# API accessible via Nginx reverse proxy on port 80 +################################################################################ + +# Configuration +HOST="http://localhost" +AGENT_PATH="test-reviewer" # Using existing agent from the registry +USERNAME="admin" +PASSWORD="anrwangAdminPassword" + +# Colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +################################################################################ +# Token Resolution and Validation +################################################################################ + +TOKEN="" +TOKEN_FILE="" + +# Check command-line argument first +if [ -n "$1" ]; then + TOKEN_FILE="$1" +# Check environment variable second +elif [ -n "$TOKEN_FILE" ]; then + TOKEN_FILE="$TOKEN_FILE" +# Use default +else + TOKEN_FILE=".oauth-tokens/admin-bot-token.json" +fi + +# Verify token file exists +if [ ! -f "$TOKEN_FILE" ]; then + echo "" + echo -e "${RED}✗ ERROR: Token file not found!${NC}" + echo "" + echo "Looked for: $TOKEN_FILE" + echo "" + echo -e "${YELLOW}To generate a token, run:${NC}" + echo " ./keycloak/setup/generate-agent-token.sh admin-bot" + echo "" + exit 1 +fi + +# Extract token from file +TOKEN=$(jq -r '.access_token' "$TOKEN_FILE" 2>/dev/null) + +# Validate token exists and is not empty +if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then + echo "" + echo -e "${RED}✗ ERROR: Failed to extract token from: $TOKEN_FILE${NC}" + echo "" + exit 1 +fi + +echo -e "${GREEN}✓ Using token from: $TOKEN_FILE${NC}" +echo "" + +# Helper function to print sections +section() { + echo "" + echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}" + echo -e "${BLUE}║ $1${NC}" + echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}" + echo "" +} + +# Helper function to print commands +print_cmd() { + echo -e "${YELLOW}▶ Command:${NC}" + echo " $1" + echo "" +} + +# Helper function to print responses +print_response() { + echo -e "${YELLOW}◀ Response:${NC}" + echo "$1" | jq . 2>/dev/null || echo "$1" + echo "" +} + +################################################################################ +# STEP 0: Get authentication token (if token file doesn't exist) +################################################################################ +if [ ! -f "$TOKEN_FILE" ]; then + section "STEP 0: Get Authentication Token" + + echo -e "${YELLOW}Token file not found. Attempting to authenticate with credentials...${NC}" + echo "" + + print_cmd "POST /auth/token" + + AUTH_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST \ + "$HOST/auth/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=$USERNAME&password=$PASSWORD") + + HTTP_CODE=$(echo "$AUTH_RESPONSE" | grep "HTTP_CODE" | cut -d: -f2) + BODY=$(echo "$AUTH_RESPONSE" | grep -v "HTTP_CODE") + + echo -e "${YELLOW}◀ Response (HTTP $HTTP_CODE):${NC}" + echo "$BODY" | jq . 2>/dev/null || echo "$BODY" + echo "" + + if [ "$HTTP_CODE" = "200" ]; then + TOKEN=$(echo "$BODY" | jq -r '.access_token' 2>/dev/null) + if [ -n "$TOKEN" ] && [ "$TOKEN" != "null" ]; then + echo -e "${GREEN}✓ Authentication successful!${NC}" + echo "" + else + echo -e "${RED}✗ Failed to extract token from response${NC}" + echo "" + exit 1 + fi + else + echo -e "${RED}✗ Authentication failed (HTTP $HTTP_CODE)${NC}" + echo "" + exit 1 + fi +fi + +################################################################################ +# STEP 1: List available agents +################################################################################ +section "STEP 1: List Available Agents" + +print_cmd "GET /api/agents" + +RESPONSE=$(curl -s -X GET \ + "$HOST/api/agents" \ + -H "Authorization: Bearer $TOKEN") + +print_response "$RESPONSE" + +echo -e "${GREEN}Available agents:${NC}" +echo "$RESPONSE" | jq -r '.agents[]? | " - \(.name) (path: \(.path))"' 2>/dev/null || echo " No agents found" +echo "" + +################################################################################ +# STEP 2: Get agent details before rating +################################################################################ +section "STEP 2: Get Agent Details (Before Rating)" + +print_cmd "GET /api/agents/$AGENT_PATH" + +RESPONSE=$(curl -s -X GET \ + "$HOST/api/agents/$AGENT_PATH" \ + -H "Authorization: Bearer $TOKEN") + +print_response "$RESPONSE" + +CURRENT_RATING=$(echo "$RESPONSE" | jq -r '.num_stars // 0' 2>/dev/null) +echo -e "${GREEN}Current rating: $CURRENT_RATING stars${NC}" +echo "" + +################################################################################ +# STEP 3: Rate the agent with 5 stars +################################################################################ +section "STEP 3: Rate Agent (5 stars)" + +print_cmd "POST /api/agents/$AGENT_PATH/rate" + +RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST \ + "$HOST/api/agents/$AGENT_PATH/rate" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"rating": 5}') + +HTTP_CODE=$(echo "$RESPONSE" | grep "HTTP_CODE" | cut -d: -f2) +BODY=$(echo "$RESPONSE" | grep -v "HTTP_CODE") + +echo -e "${YELLOW}◀ Response (HTTP $HTTP_CODE):${NC}" +echo "$BODY" | jq . 2>/dev/null || echo "$BODY" +echo "" + +if [ "$HTTP_CODE" = "200" ]; then + echo -e "${GREEN}✓ Rating submitted successfully!${NC}" +else + echo -e "${RED}✗ Failed to submit rating (HTTP $HTTP_CODE)${NC}" +fi + +################################################################################ +# STEP 4: Get agent rating details +################################################################################ +section "STEP 4: Get Agent Rating Details" + +print_cmd "GET /api/agents/$AGENT_PATH/rating" + +RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X GET \ + "$HOST/api/agents/$AGENT_PATH/rating" \ + -H "Authorization: Bearer $TOKEN") + +HTTP_CODE=$(echo "$RESPONSE" | grep "HTTP_CODE" | cut -d: -f2) +BODY=$(echo "$RESPONSE" | grep -v "HTTP_CODE") + +echo -e "${YELLOW}◀ Response (HTTP $HTTP_CODE):${NC}" +echo "$BODY" | jq . 2>/dev/null || echo "$BODY" +echo "" + +if [ "$HTTP_CODE" = "200" ]; then + echo -e "${GREEN}✓ Rating details retrieved successfully!${NC}" +else + echo -e "${RED}✗ Failed to get rating details (HTTP $HTTP_CODE)${NC}" +fi + +################################################################################ +# STEP 5: Rate the agent with 3 stars (update rating) +################################################################################ +section "STEP 5: Update Rating (3 stars)" + +print_cmd "POST /api/agents/$AGENT_PATH/rate" + +RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST \ + "$HOST/api/agents/$AGENT_PATH/rate" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"rating": 3}') + +HTTP_CODE=$(echo "$RESPONSE" | grep "HTTP_CODE" | cut -d: -f2) +BODY=$(echo "$RESPONSE" | grep -v "HTTP_CODE") + +echo -e "${YELLOW}◀ Response (HTTP $HTTP_CODE):${NC}" +echo "$BODY" | jq . 2>/dev/null || echo "$BODY" +echo "" + +if [ "$HTTP_CODE" = "200" ]; then + echo -e "${GREEN}✓ Rating updated successfully!${NC}" +else + echo -e "${RED}✗ Failed to update rating (HTTP $HTTP_CODE)${NC}" +fi + +################################################################################ +# STEP 6: Verify updated rating +################################################################################ +section "STEP 6: Verify Updated Rating" + +print_cmd "GET /api/agents/$AGENT_PATH" + +RESPONSE=$(curl -s -X GET \ + "$HOST/api/agents/$AGENT_PATH" \ + -H "Authorization: Bearer $TOKEN") + +print_response "$RESPONSE" + +NEW_RATING=$(echo "$RESPONSE" | jq -r '.num_stars // 0' 2>/dev/null) +echo -e "${GREEN}Updated rating: $NEW_RATING stars${NC}" +echo "" + +################################################################################ +# STEP 7: Test invalid rating (out of range) +################################################################################ +section "STEP 7: Test Invalid Rating (Out of Range)" + +print_cmd "POST /api/agents/$AGENT_PATH/rate (rating: 10)" + +RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST \ + "$HOST/api/agents/$AGENT_PATH/rate" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"rating": 10}') + +HTTP_CODE=$(echo "$RESPONSE" | grep "HTTP_CODE" | cut -d: -f2) +BODY=$(echo "$RESPONSE" | grep -v "HTTP_CODE") + +echo -e "${YELLOW}◀ Response (HTTP $HTTP_CODE):${NC}" +echo "$BODY" | jq . 2>/dev/null || echo "$BODY" +echo "" + +if [ "$HTTP_CODE" = "422" ] || [ "$HTTP_CODE" = "400" ] || [ "$HTTP_CODE" = "500" ]; then + echo -e "${GREEN}✓ Invalid rating correctly rejected!${NC}" +else + echo -e "${RED}✗ Invalid rating should have been rejected (HTTP $HTTP_CODE)${NC}" +fi + +################################################################################ +# STEP 8: Test rating non-existent agent +################################################################################ +section "STEP 8: Test Rating Non-Existent Agent" + +print_cmd "POST /api/agents/non-existent-agent/rate" + +RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST \ + "$HOST/api/agents/non-existent-agent/rate" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"rating": 5}') + +HTTP_CODE=$(echo "$RESPONSE" | grep "HTTP_CODE" | cut -d: -f2) +BODY=$(echo "$RESPONSE" | grep -v "HTTP_CODE") + +echo -e "${YELLOW}◀ Response (HTTP $HTTP_CODE):${NC}" +echo "$BODY" | jq . 2>/dev/null || echo "$BODY" +echo "" + +if [ "$HTTP_CODE" = "404" ]; then + echo -e "${GREEN}✓ Non-existent agent correctly returned 404!${NC}" +else + echo -e "${RED}✗ Should have returned 404 for non-existent agent (HTTP $HTTP_CODE)${NC}" +fi + +################################################################################ +# Summary +################################################################################ +section "Rate Agent Test Summary" + +cat << 'EOF' +What we tested: + +1. LIST - Listed all available agents +2. GET - Retrieved agent details before rating +3. RATE - Submitted a 5-star rating +4. GET - Retrieved rating details +5. UPDATE - Updated rating to 3 stars +6. VERIFY - Verified the updated rating +7. INVALID - Tested invalid rating (out of range) +8. NOT FOUND - Tested rating non-existent agent + +Expected behaviors: +✓ Valid ratings (1-5) should return HTTP 200 +✓ Invalid ratings should return HTTP 422/400/500 +✓ Non-existent agents should return HTTP 404 +✓ Ratings should be stored and retrievable +✓ Users can update their own ratings + +EOF + +echo "" +echo -e "${GREEN}✓ Rate Agent Test Complete!${NC}" +echo "" \ No newline at end of file diff --git a/tests/unit/api/test_rate_agent.py b/tests/unit/api/test_rate_agent.py new file mode 100644 index 00000000..38b323dc --- /dev/null +++ b/tests/unit/api/test_rate_agent.py @@ -0,0 +1,422 @@ +""" +Unit tests for rate_agent endpoint in agent_routes.py +""" + +import pytest +from typing import Any, Dict +from unittest.mock import patch, Mock +from fastapi import status +from fastapi.testclient import TestClient + +from registry.main import app +from registry.services.agent_service import agent_service +from registry.schemas.agent_models import AgentCard + + +@pytest.fixture +def mock_user_context() -> Dict[str, Any]: + """Mock authenticated user context.""" + return { + "username": "testuser", + "groups": ["users"], + "is_admin": False, + "ui_permissions": {}, + "accessible_agents": ["all"], + } + + +@pytest.fixture +def mock_admin_context() -> Dict[str, Any]: + """Mock admin user context.""" + return { + "username": "admin", + "groups": ["admins"], + "is_admin": True, + "ui_permissions": {}, + "accessible_agents": ["all"], + } + + +@pytest.fixture +def sample_agent_card() -> AgentCard: + """Create a sample agent card for testing.""" + return AgentCard( + protocol_version="1.0", + name="Test Agent", + description="A test agent", + url="http://localhost:8080/test-agent", + path="/test-agent", + version="1.0.0", + tags=["test"], + skills=[], + visibility="public", + registered_by="admin", + num_stars=0.0, + rating_details=[], + ) + + +@pytest.mark.unit +class TestRateAgent: + """Test suite for POST /agents/{path}/rate endpoint.""" + + def test_rate_agent_success( + self, + mock_user_context: Dict[str, Any], + sample_agent_card: AgentCard, + ) -> None: + """Test successfully rating an agent.""" + from registry.auth.dependencies import nginx_proxied_auth + + def _mock_auth(session=None): + return mock_user_context + + app.dependency_overrides[nginx_proxied_auth] = _mock_auth + + with patch.object( + agent_service, + "get_agent_info", + return_value=sample_agent_card, + ), patch.object( + agent_service, + "update_rating", + return_value=4.5, + ): + client = TestClient(app) + response = client.post( + "/agents/test-agent/rate", + json={"rating": 5}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["message"] == "Rating added successfully" + + app.dependency_overrides.clear() + + def test_rate_agent_not_found( + self, + mock_user_context: Dict[str, Any], + ) -> None: + """Test rating a non-existent agent returns 404.""" + from registry.auth.dependencies import nginx_proxied_auth + + def _mock_auth(session=None): + return mock_user_context + + app.dependency_overrides[nginx_proxied_auth] = _mock_auth + + with patch.object( + agent_service, + "get_agent_info", + return_value=None, + ): + client = TestClient(app) + response = client.post( + "/agents/nonexistent/rate", + json={"rating": 5}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "not found" in response.json()["detail"].lower() + + app.dependency_overrides.clear() + + def test_rate_agent_no_access( + self, + sample_agent_card: AgentCard, + ) -> None: + """Test rating an agent without access returns 403.""" + from registry.auth.dependencies import nginx_proxied_auth + + # User with restricted access + restricted_context = { + "username": "restricted_user", + "groups": [], + "is_admin": False, + "ui_permissions": {}, + "accessible_agents": ["other-agent"], # Not the test agent + } + + def _mock_auth(session=None): + return restricted_context + + app.dependency_overrides[nginx_proxied_auth] = _mock_auth + + with patch.object( + agent_service, + "get_agent_info", + return_value=sample_agent_card, + ): + client = TestClient(app) + response = client.post( + "/agents/test-agent/rate", + json={"rating": 5}, + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert "access" in response.json()["detail"].lower() + + app.dependency_overrides.clear() + + def test_rate_agent_invalid_rating_type( + self, + mock_user_context: Dict[str, Any], + sample_agent_card: AgentCard, + ) -> None: + """Test rating with invalid type returns validation error.""" + from registry.auth.dependencies import nginx_proxied_auth + + def _mock_auth(session=None): + return mock_user_context + + app.dependency_overrides[nginx_proxied_auth] = _mock_auth + + with patch.object( + agent_service, + "get_agent_info", + return_value=sample_agent_card, + ): + client = TestClient(app) + response = client.post( + "/agents/test-agent/rate", + json={"rating": "five"}, # String instead of int + ) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + app.dependency_overrides.clear() + + def test_rate_agent_missing_rating( + self, + mock_user_context: Dict[str, Any], + sample_agent_card: AgentCard, + ) -> None: + """Test rating without rating field returns validation error.""" + from registry.auth.dependencies import nginx_proxied_auth + + def _mock_auth(session=None): + return mock_user_context + + app.dependency_overrides[nginx_proxied_auth] = _mock_auth + + with patch.object( + agent_service, + "get_agent_info", + return_value=sample_agent_card, + ): + client = TestClient(app) + response = client.post( + "/agents/test-agent/rate", + json={}, # Missing rating field + ) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + app.dependency_overrides.clear() + + def test_rate_agent_update_rating_fails( + self, + mock_user_context: Dict[str, Any], + sample_agent_card: AgentCard, + ) -> None: + """Test handling when update_rating fails.""" + from registry.auth.dependencies import nginx_proxied_auth + + def _mock_auth(session=None): + return mock_user_context + + app.dependency_overrides[nginx_proxied_auth] = _mock_auth + + with patch.object( + agent_service, + "get_agent_info", + return_value=sample_agent_card, + ), patch.object( + agent_service, + "update_rating", + side_effect=ValueError("Failed to save rating"), + ): + client = TestClient(app) + response = client.post( + "/agents/test-agent/rate", + json={"rating": 5}, + ) + + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert "Failed to save rating" in response.json()["detail"] + + app.dependency_overrides.clear() + + def test_rate_agent_with_different_ratings( + self, + mock_user_context: Dict[str, Any], + sample_agent_card: AgentCard, + ) -> None: + """Test rating an agent with different valid rating values.""" + from registry.auth.dependencies import nginx_proxied_auth + + def _mock_auth(session=None): + return mock_user_context + + app.dependency_overrides[nginx_proxied_auth] = _mock_auth + + for rating_value in [1, 2, 3, 4, 5]: + with patch.object( + agent_service, + "get_agent_info", + return_value=sample_agent_card, + ), patch.object( + agent_service, + "update_rating", + return_value=float(rating_value), + ): + client = TestClient(app) + response = client.post( + "/agents/test-agent/rate", + json={"rating": rating_value}, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["message"] == "Rating added successfully" + + app.dependency_overrides.clear() + + def test_rate_agent_path_normalization( + self, + mock_user_context: Dict[str, Any], + sample_agent_card: AgentCard, + ) -> None: + """Test that agent path is normalized correctly.""" + from registry.auth.dependencies import nginx_proxied_auth + + def _mock_auth(session=None): + return mock_user_context + + app.dependency_overrides[nginx_proxied_auth] = _mock_auth + + with patch.object( + agent_service, + "get_agent_info", + return_value=sample_agent_card, + ) as mock_get_agent, patch.object( + agent_service, + "update_rating", + return_value=5.0, + ) as mock_update: + client = TestClient(app) + # Test with path without leading slash + response = client.post( + "/agents/test-agent/rate", + json={"rating": 5}, + ) + + assert response.status_code == status.HTTP_200_OK + # Verify the path was normalized (should have leading slash) + mock_get_agent.assert_called_once_with("/test-agent") + mock_update.assert_called_once_with("/test-agent", "testuser", 5) + + app.dependency_overrides.clear() + + def test_rate_agent_private_agent_by_owner( + self, + mock_user_context: Dict[str, Any], + ) -> None: + """Test that agent owner can rate their private agent.""" + from registry.auth.dependencies import nginx_proxied_auth + + # Create private agent owned by testuser + private_agent = AgentCard( + protocol_version="1.0", + name="Private Agent", + description="A private agent", + url="http://localhost:8080/private-agent", + path="/private-agent", + version="1.0.0", + tags=["test"], + skills=[], + visibility="private", + registered_by="testuser", # Same as mock_user_context username + num_stars=0.0, + rating_details=[], + ) + + def _mock_auth(session=None): + return mock_user_context + + app.dependency_overrides[nginx_proxied_auth] = _mock_auth + + with patch.object( + agent_service, + "get_agent_info", + return_value=private_agent, + ), patch.object( + agent_service, + "update_rating", + return_value=5.0, + ): + client = TestClient(app) + response = client.post( + "/agents/private-agent/rate", + json={"rating": 5}, + ) + + assert response.status_code == status.HTTP_200_OK + + app.dependency_overrides.clear() + + def test_rate_agent_group_restricted_with_access( + self, + ) -> None: + """Test rating a group-restricted agent when user is in allowed group.""" + from registry.auth.dependencies import nginx_proxied_auth + + # User in the allowed group + user_context = { + "username": "groupuser", + "groups": ["allowed-group"], + "is_admin": False, + "ui_permissions": {}, + "accessible_agents": ["all"], + } + + # Group-restricted agent + group_agent = AgentCard( + protocol_version="1.0", + name="Group Agent", + description="A group-restricted agent", + url="http://localhost:8080/group-agent", + path="/group-agent", + version="1.0.0", + tags=["test"], + skills=[], + visibility="group-restricted", + allowed_groups=["allowed-group"], + registered_by="admin", + num_stars=0.0, + rating_details=[], + ) + + def _mock_auth(session=None): + return user_context + + app.dependency_overrides[nginx_proxied_auth] = _mock_auth + + with patch.object( + agent_service, + "get_agent_info", + return_value=group_agent, + ), patch.object( + agent_service, + "update_rating", + return_value=4.0, + ): + client = TestClient(app) + response = client.post( + "/agents/group-agent/rate", + json={"rating": 4}, + ) + + assert response.status_code == status.HTTP_200_OK + + app.dependency_overrides.clear()