Skip to content
Open
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
20 changes: 19 additions & 1 deletion src/app/endpoints/feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
StatusResponse,
UnauthorizedResponse,
)
from utils.endpoints import check_configuration_loaded
from utils.endpoints import check_configuration_loaded, retrieve_conversation
from utils.suid import get_suid

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -112,12 +112,30 @@ async def feedback_endpoint_handler(
Response indicating the status of the feedback storage request.

Raises:
HTTPException: Returns HTTP 404 if conversation does not exist.
HTTPException: Returns HTTP 403 if conversation belongs to a different user.
HTTPException: Returns HTTP 500 if feedback storage fails.
"""
logger.debug("Feedback received %s", str(feedback_request))

user_id, _, _, _ = auth
check_configuration_loaded(configuration)

# Validate conversation exists and belongs to the user
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this new logic should also be unit tested

conversation_id = feedback_request.conversation_id
conversation = retrieve_conversation(conversation_id)
if conversation is None:
response = NotFoundResponse(
resource="conversation", resource_id=conversation_id
)
raise HTTPException(**response.model_dump())

if conversation.user_id != user_id:
response = ForbiddenResponse.conversation(
action="submit feedback for", resource_id=conversation_id, user_id=user_id
)
raise HTTPException(**response.model_dump())

store_feedback(user_id, feedback_request.model_dump(exclude={"model_config"}))

return FeedbackResponse(response="feedback received")
Expand Down
29 changes: 23 additions & 6 deletions tests/e2e/features/feedback.feature
Original file line number Diff line number Diff line change
Expand Up @@ -224,10 +224,8 @@ Feature: feedback endpoint API tests
}
"""

@skip
Scenario: Check if feedback submittion fails when nonexisting conversation ID is passed
Scenario: Check if feedback submission fails when nonexisting conversation ID is passed
Given The system is in default state
And A new conversation is initialized
And The feedback is enabled
When I submit the following feedback for nonexisting conversation "12345678-abcd-0000-0123-456789abcdef"
"""
Expand All @@ -238,14 +236,33 @@ Feature: feedback endpoint API tests
"user_question": "Sample Question"
}
"""
Then The status code of the response is 422
Then The status code of the response is 404
And The body of the response is the following
"""
{
"response": "User has no access to this conversation"
"detail": {
"response": "Conversation not found",
"cause": "Conversation with ID 12345678-abcd-0000-0123-456789abcdef does not exist"
}
}
"""


Scenario: Check if feedback submission fails when conversation belongs to a different user
Given The system is in default state
And A conversation owned by a different user is initialized
And The feedback is enabled
When I submit the following feedback for the conversation created before
"""
{
"llm_response": "Sample Response",
"sentiment": -1,
"user_feedback": "Not satisfied with the response quality",
"user_question": "Sample Question"
}
"""
Then The status code of the response is 403
And The body of the response contains User does not have permission to perform this action

Scenario: Check if feedback endpoint is not working when not authorized
Given The system is in default state
And A new conversation is initialized
Expand Down
47 changes: 47 additions & 0 deletions tests/e2e/features/steps/feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,53 @@ def initialize_conversation(context: Context) -> None:
context.response = response


@given("A conversation owned by a different user is initialized") # type: ignore
def initialize_conversation_different_user(context: Context) -> None:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move this into test steps within the feature file, this test step should not be needed at all

"""Create a conversation owned by a different user for testing access control.

This step temporarily switches to a different auth token to create a conversation
that will be owned by a different user, then restores the original auth header.
"""
# Save original auth headers
original_auth_headers = (
context.auth_headers.copy() if hasattr(context, "auth_headers") else {}
)

# Set a different auth token (different user_id in the sub claim)
# This token has sub: "different_user_id" instead of "1234567890"
different_user_token = (
"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
"eyJzdWIiOiJkaWZmZXJlbnRfdXNlcl9pZCIsIm5hbWUiOiJPdGhlclVzZXIifQ."
"placeholder_signature"
)
context.auth_headers = {"Authorization": different_user_token}

# Create a conversation as the different user
endpoint = "query"
base = f"http://{context.hostname}:{context.port}"
path = f"{context.api_prefix}/{endpoint}".replace("//", "/")
url = base + path
payload = {
"query": "Say Hello.",
"system_prompt": "You are a helpful assistant",
"model": context.default_model,
"provider": context.default_provider,
}

response = requests.post(url, headers=context.auth_headers, json=payload)
assert (
response.status_code == 200
), f"Failed to create conversation as different user: {response.text}"

body = response.json()
context.conversation_id = body["conversation_id"]
assert context.conversation_id, "Conversation was not created."
context.feedback_conversations.append(context.conversation_id)

# Restore original auth headers
context.auth_headers = original_auth_headers


@given("An invalid feedback storage path is configured") # type: ignore
def configure_invalid_feedback_storage_path(context: Context) -> None:
"""Set an invalid feedback storage path and restart the container."""
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/app/endpoints/test_feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,17 @@ async def test_feedback_endpoint_handler(
mocker.patch("app.endpoints.feedback.assert_feedback_enabled", return_value=None)
mocker.patch("app.endpoints.feedback.store_feedback", return_value=None)

# Mock retrieve_conversation to return a conversation owned by test_user_id
mock_conversation = mocker.Mock()
mock_conversation.user_id = "test_user_id"
mocker.patch(
"app.endpoints.feedback.retrieve_conversation", return_value=mock_conversation
)

# Prepare the feedback request mock
feedback_request = mocker.Mock()
feedback_request.model_dump.return_value = feedback_request_data
feedback_request.conversation_id = "12345678-abcd-0000-0123-456789abcdef"

# Authorization tuple required by URL endpoint handler
auth: AuthTuple = ("test_user_id", "test_user", True, "test_token")
Expand All @@ -126,6 +134,14 @@ async def test_feedback_endpoint_handler_error(mocker: MockerFixture) -> None:
mock_authorization_resolvers(mocker)
mocker.patch("app.endpoints.feedback.assert_feedback_enabled", return_value=None)
mocker.patch("app.endpoints.feedback.check_configuration_loaded", return_value=None)

# Mock retrieve_conversation to return a conversation owned by test_user_id
mock_conversation = mocker.Mock()
mock_conversation.user_id = "test_user_id"
mocker.patch(
"app.endpoints.feedback.retrieve_conversation", return_value=mock_conversation
)

# Mock Path.mkdir to raise OSError so the try block in store_feedback catches it
mocker.patch(
"app.endpoints.feedback.Path.mkdir", side_effect=OSError("Permission denied")
Expand Down Expand Up @@ -300,6 +316,14 @@ async def test_feedback_endpoint_valid_requests(
"""Test endpoint with valid feedback payloads."""
mock_authorization_resolvers(mocker)
mocker.patch("app.endpoints.feedback.store_feedback")

# Mock retrieve_conversation to return a conversation owned by mock_user_id
mock_conversation = mocker.Mock()
mock_conversation.user_id = "mock_user_id"
mocker.patch(
"app.endpoints.feedback.retrieve_conversation", return_value=mock_conversation
)

request = FeedbackRequest(**{**VALID_BASE, **payload})
response = await feedback_endpoint_handler(
feedback_request=request,
Expand Down
Loading