Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Plugins.AI.CrewAI;
using Moq;
using Moq.Protected;
Expand All @@ -18,6 +19,7 @@ namespace SemanticKernel.Plugins.AI.UnitTests.CrewAI;
public sealed partial class CrewAIEnterpriseClientTests
{
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
private readonly Mock<Func<Task<string>>> _authTokenProviderMock;
private readonly CrewAIEnterpriseClient _client;

/// <summary>
Expand All @@ -26,10 +28,12 @@ public sealed partial class CrewAIEnterpriseClientTests
public CrewAIEnterpriseClientTests()
{
this._httpMessageHandlerMock = new Mock<HttpMessageHandler>();
this._authTokenProviderMock = new Mock<Func<Task<string>>>();
this._authTokenProviderMock.Setup(provider => provider()).ReturnsAsync("token");
using var httpClientFactory = new MockHttpClientFactory(this._httpMessageHandlerMock);
this._client = new CrewAIEnterpriseClient(
endpoint: new Uri("http://example.com"),
authTokenProvider: () => Task.FromResult("token"),
authTokenProvider: this._authTokenProviderMock.Object,
httpClientFactory);
}

Expand Down Expand Up @@ -148,4 +152,73 @@ public async Task GetStatusAsyncReturnsCrewAIStatusResponseAsync(CrewAIKickoffSt
Assert.NotNull(result.LastStep);
Assert.Equal("value1", result.LastStep["step1"].ToString());
}

/// <summary>
/// Verifies that valid task identifiers are preserved in the status request path.
/// </summary>
[Theory]
[InlineData("12345")]
[InlineData("AZaz09_-")]
[InlineData("640f9a3a-2734-4ba8-8b8a-2f796f419d03")]
public async Task GetStatusAsyncPreservesValidTaskIdAsync(string taskId)
{
using var responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"state\":\"SUCCESS\",\"result\":\"The Result\"}")
};

this._httpMessageHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.Is<HttpRequestMessage>(request =>
request.RequestUri!.AbsolutePath == $"/status/{taskId}"),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(responseMessage);

var result = await this._client.GetStatusAsync(taskId);

Assert.Equal(CrewAIKickoffState.Success, result.State);
Assert.Equal("The Result", result.Result);
this._httpMessageHandlerMock.Protected().Verify(
"SendAsync",
Times.Once(),
ItExpr.Is<HttpRequestMessage>(request =>
request.RequestUri!.AbsoluteUri == $"http://example.com/status/{taskId}"),
ItExpr.IsAny<CancellationToken>());
}

/// <summary>
/// Verifies that invalid task identifiers are rejected before requesting credentials or sending HTTP requests.
/// </summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("task.id")]
[InlineData("task/id")]
[InlineData(@"task\id")]
[InlineData("task%id")]
[InlineData("task?id")]
[InlineData("task#id")]
[InlineData("task id")]
[InlineData("task\n")]
[InlineData("task\r\n")]
[InlineData("task\t")]
[InlineData("task\u0000")]
[InlineData("t\u00E1sk")]
[InlineData("\uFF11\uFF12\uFF13")]
public async Task GetStatusAsyncRejectsInvalidTaskIdBeforeAuthenticationAsync(string? taskId)
{
var exception = await Assert.ThrowsAsync<KernelException>(
() => this._client.GetStatusAsync(taskId!));

var argumentException = Assert.IsAssignableFrom<ArgumentException>(exception.InnerException);
Assert.Equal("taskId", argumentException.ParamName);
this._authTokenProviderMock.Verify(provider => provider(), Times.Never());
this._httpMessageHandlerMock.Protected().Verify(
"SendAsync",
Times.Never(),
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System;
using System.Net.Http;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.SemanticKernel.Http;
Expand Down Expand Up @@ -125,6 +126,15 @@ public async Task<CrewAIStatusResponse> GetStatusAsync(string taskId, Cancellati
{
try
{
Verify.NotNullOrWhiteSpace(taskId, nameof(taskId));

if (!Regex.IsMatch(taskId, @"\A[A-Za-z0-9_-]+\z"))
{
throw new ArgumentException(
"Task ID must contain only ASCII letters, digits, underscores, or hyphens.",
nameof(taskId));
}

using var client = await this.CreateHttpClientAsync().ConfigureAwait(false);
using var requestMessage = HttpRequest.CreateGetRequest($"/status/{taskId}");
using var response = await client.SendWithSuccessCheckAsync(requestMessage, cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.

import re
from typing import Any

import aiohttp
Expand Down Expand Up @@ -89,6 +90,9 @@ async def get_status(self, task_id: str) -> CrewAIStatusResponse:
Returns:
CrewAIStatusResponse: The status response of the task.
"""
if not isinstance(task_id, str) or re.fullmatch(r"[A-Za-z0-9_-]+", task_id) is None:
raise ValueError("Task ID must contain only ASCII letters, digits, underscores, or hyphens.")

async with (
self.session.get(f"{self.endpoint}/status/{task_id}", headers=self.request_header) as response, # type: ignore
):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Copyright (c) Microsoft. All rights reserved.

from unittest.mock import MagicMock

import aiohttp
import pytest
from yarl import URL

from semantic_kernel.core_plugins.crew_ai.crew_ai_enterprise_client import CrewAIEnterpriseClient
from semantic_kernel.core_plugins.crew_ai.crew_ai_models import CrewAIEnterpriseKickoffState


@pytest.fixture
def crew_ai_client():
client = CrewAIEnterpriseClient(endpoint="https://test.com", auth_token="FakeToken")
client.session = MagicMock(spec=aiohttp.ClientSession)
return client


@pytest.mark.parametrize(
"task_id",
["12345", "AZaz09_-", "640f9a3a-2734-4ba8-8b8a-2f796f419d03"],
)
async def test_get_status_preserves_valid_task_id(crew_ai_client, task_id):
response = MagicMock(spec=aiohttp.ClientResponse)
response.text.return_value = '{"state":"SUCCESS","result":"The Result"}'
crew_ai_client.session.get.return_value.__aenter__.return_value = response

result = await crew_ai_client.get_status(task_id)

assert result.state == CrewAIEnterpriseKickoffState.Success
assert result.result == "The Result"
crew_ai_client.session.get.assert_called_once_with(
f"https://test.com/status/{task_id}",
headers=crew_ai_client.request_header,
)
request_url = crew_ai_client.session.get.call_args.args[0]
assert URL(request_url).raw_path == f"/status/{task_id}"
response.raise_for_status.assert_called_once()


@pytest.mark.parametrize(
"task_id",
[
None,
123,
[],
"",
" ",
"task.id",
"task/id",
r"task\id",
"task%id",
"task?id",
"task#id",
"task id",
"task\n",
"task\r\n",
"task\t",
"task\x00",
"t\u00e1sk",
"\uff11\uff12\uff13",
],
)
async def test_get_status_rejects_invalid_task_id_without_request(crew_ai_client, task_id):
with pytest.raises(
ValueError,
match="Task ID must contain only ASCII letters, digits, underscores, or hyphens",
):
await crew_ai_client.get_status(task_id)

crew_ai_client.session.get.assert_not_called()
Loading