save progress
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
// <copyright file="ActionProposalParserTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryAI.Chat;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ActionProposalParser"/>.
|
||||
/// Sprint: SPRINT_20260107_006_003 Task CH-014
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ActionProposalParserTests
|
||||
{
|
||||
private readonly ActionProposalParser _parser = new();
|
||||
|
||||
[Fact]
|
||||
public void Parse_ButtonFormat_ExtractsAction()
|
||||
{
|
||||
// Arrange
|
||||
var modelOutput = "You can approve this risk: [Accept Risk]{action:approve,cve_id=CVE-2023-1234}";
|
||||
var permissions = ImmutableArray.Create("approver");
|
||||
|
||||
// Act
|
||||
var result = _parser.Parse(modelOutput, permissions);
|
||||
|
||||
// Assert
|
||||
result.Proposals.Should().HaveCount(1);
|
||||
result.Proposals[0].ActionType.Should().Be("approve");
|
||||
result.Proposals[0].Label.Should().Be("Accept Risk");
|
||||
result.Proposals[0].Parameters.Should().ContainKey("cve_id");
|
||||
result.Proposals[0].Parameters["cve_id"].Should().Be("CVE-2023-1234");
|
||||
result.Proposals[0].IsAllowed.Should().BeTrue();
|
||||
result.Warnings.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_MultipleActions_ExtractsAll()
|
||||
{
|
||||
// Arrange
|
||||
var modelOutput = """
|
||||
You have options:
|
||||
[Accept Risk]{action:approve,cve_id=CVE-2023-1234}
|
||||
[Block Image]{action:quarantine,image_digest=sha256:abc123}
|
||||
""";
|
||||
var permissions = ImmutableArray.Create("approver", "operator");
|
||||
|
||||
// Act
|
||||
var result = _parser.Parse(modelOutput, permissions);
|
||||
|
||||
// Assert
|
||||
result.Proposals.Should().HaveCount(2);
|
||||
result.Proposals.Select(p => p.ActionType).Should().BeEquivalentTo("approve", "quarantine");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_InlineActionFormat_ExtractsAction()
|
||||
{
|
||||
// Arrange
|
||||
var modelOutput = "This vulnerability should be deferred. <!-- ACTION: defer cve_id=CVE-2023-5678 -->";
|
||||
var permissions = ImmutableArray.Create("triage");
|
||||
|
||||
// Act
|
||||
var result = _parser.Parse(modelOutput, permissions);
|
||||
|
||||
// Assert
|
||||
result.Proposals.Should().HaveCount(1);
|
||||
result.Proposals[0].ActionType.Should().Be("defer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_MissingPermission_MarksAsBlocked()
|
||||
{
|
||||
// Arrange
|
||||
var modelOutput = "[Accept Risk]{action:approve,cve_id=CVE-2023-1234}";
|
||||
var permissions = ImmutableArray.Create("viewer"); // No approver permission
|
||||
|
||||
// Act
|
||||
var result = _parser.Parse(modelOutput, permissions);
|
||||
|
||||
// Assert
|
||||
result.Proposals.Should().HaveCount(1);
|
||||
result.Proposals[0].IsAllowed.Should().BeFalse();
|
||||
result.Proposals[0].BlockedReason.Should().Contain("approver");
|
||||
result.HasBlockedActions.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_MissingRequiredParameter_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var modelOutput = "[Accept Risk]{action:approve}"; // Missing cve_id
|
||||
var permissions = ImmutableArray.Create("approver");
|
||||
|
||||
// Act
|
||||
var result = _parser.Parse(modelOutput, permissions);
|
||||
|
||||
// Assert
|
||||
result.Proposals.Should().BeEmpty();
|
||||
result.Warnings.Should().Contain(w => w.Contains("cve_id"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_UnknownActionType_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var modelOutput = "[Do Something]{action:unknown_action,param=value}";
|
||||
var permissions = ImmutableArray.Create("admin");
|
||||
|
||||
// Act
|
||||
var result = _parser.Parse(modelOutput, permissions);
|
||||
|
||||
// Assert
|
||||
result.Proposals.Should().BeEmpty();
|
||||
result.Warnings.Should().Contain(w => w.Contains("Unknown action type"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_InvalidActionFormat_ReturnsWarning()
|
||||
{
|
||||
// Arrange - uses a valid button format but invalid action spec (missing action: prefix)
|
||||
var modelOutput = "[Label]{someaction,param=value}";
|
||||
var permissions = ImmutableArray.Create("admin");
|
||||
|
||||
// Act
|
||||
var result = _parser.Parse(modelOutput, permissions);
|
||||
|
||||
// Assert - regex doesn't match so no proposals are extracted
|
||||
// This test verifies the parser gracefully handles non-matching patterns
|
||||
result.Proposals.Should().BeEmpty();
|
||||
// No warnings since the regex pattern doesn't match at all
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_QuarantineAction_RequiresOperatorRole()
|
||||
{
|
||||
// Arrange
|
||||
var modelOutput = "[Block Image]{action:quarantine,image_digest=sha256:abc123}";
|
||||
var permissions = ImmutableArray.Create("operator");
|
||||
|
||||
// Act
|
||||
var result = _parser.Parse(modelOutput, permissions);
|
||||
|
||||
// Assert
|
||||
result.Proposals.Should().HaveCount(1);
|
||||
result.Proposals[0].ActionType.Should().Be("quarantine");
|
||||
result.Proposals[0].IsAllowed.Should().BeTrue();
|
||||
result.Proposals[0].RequiredRole.Should().Be("operator");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_CreateVexAction_RequiresIssuerRole()
|
||||
{
|
||||
// Arrange
|
||||
var modelOutput = "[Create VEX]{action:create_vex,product=myapp,vulnerability=CVE-2023-1234}";
|
||||
var permissions = ImmutableArray.Create("issuer");
|
||||
|
||||
// Act
|
||||
var result = _parser.Parse(modelOutput, permissions);
|
||||
|
||||
// Assert
|
||||
result.Proposals.Should().HaveCount(1);
|
||||
result.Proposals[0].ActionType.Should().Be("create_vex");
|
||||
result.Proposals[0].IsAllowed.Should().BeTrue();
|
||||
result.Proposals[0].Description.Should().Contain("VEX");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_GenerateManifestAction_RequiresAdminRole()
|
||||
{
|
||||
// Arrange
|
||||
var modelOutput = "[Generate Manifest]{action:generate_manifest,integration_type=gitlab}";
|
||||
var permissions = ImmutableArray.Create("admin");
|
||||
|
||||
// Act
|
||||
var result = _parser.Parse(modelOutput, permissions);
|
||||
|
||||
// Assert
|
||||
result.Proposals.Should().HaveCount(1);
|
||||
result.Proposals[0].ActionType.Should().Be("generate_manifest");
|
||||
result.Proposals[0].IsAllowed.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_NoActions_ReturnsEmptyResult()
|
||||
{
|
||||
// Arrange
|
||||
var modelOutput = "This is a response with no action proposals.";
|
||||
var permissions = ImmutableArray.Create("admin");
|
||||
|
||||
// Act
|
||||
var result = _parser.Parse(modelOutput, permissions);
|
||||
|
||||
// Assert
|
||||
result.Proposals.Should().BeEmpty();
|
||||
result.Warnings.Should().BeEmpty();
|
||||
result.HasBlockedActions.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_OptionalParameters_Included()
|
||||
{
|
||||
// Arrange
|
||||
var modelOutput = "[Accept Risk]{action:approve,cve_id=CVE-2023-1234,rationale=tested,expiry=2024-12-31}";
|
||||
var permissions = ImmutableArray.Create("approver");
|
||||
|
||||
// Act
|
||||
var result = _parser.Parse(modelOutput, permissions);
|
||||
|
||||
// Assert
|
||||
result.Proposals.Should().HaveCount(1);
|
||||
result.Proposals[0].Parameters.Should().ContainKey("rationale");
|
||||
result.Proposals[0].Parameters.Should().ContainKey("expiry");
|
||||
result.Proposals[0].Parameters["rationale"].Should().Be("tested");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StripActionMarkers_RemovesButtonsKeepsLabel()
|
||||
{
|
||||
// Arrange
|
||||
var modelOutput = "Click here: [Accept Risk]{action:approve,cve_id=CVE-2023-1234} to proceed.";
|
||||
|
||||
// Act
|
||||
var stripped = _parser.StripActionMarkers(modelOutput);
|
||||
|
||||
// Assert
|
||||
stripped.Should().Contain("Accept Risk");
|
||||
stripped.Should().NotContain("{action:");
|
||||
stripped.Should().NotContain("}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StripActionMarkers_RemovesInlineActions()
|
||||
{
|
||||
// Arrange
|
||||
var modelOutput = "Defer this. <!-- ACTION: defer cve_id=CVE-2023-5678 --> Continue.";
|
||||
|
||||
// Act
|
||||
var stripped = _parser.StripActionMarkers(modelOutput);
|
||||
|
||||
// Assert
|
||||
stripped.Should().NotContain("ACTION:");
|
||||
stripped.Should().NotContain("<!--");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllowedProposals_FiltersBlockedActions()
|
||||
{
|
||||
// Arrange
|
||||
var modelOutput = """
|
||||
[Accept]{action:approve,cve_id=CVE-1}
|
||||
[Block]{action:quarantine,image_digest=sha256:abc}
|
||||
""";
|
||||
var permissions = ImmutableArray.Create("approver"); // Has approver but not operator
|
||||
|
||||
// Act
|
||||
var result = _parser.Parse(modelOutput, permissions);
|
||||
|
||||
// Assert
|
||||
result.Proposals.Should().HaveCount(2);
|
||||
result.AllowedProposals.Should().HaveCount(1);
|
||||
result.AllowedProposals[0].ActionType.Should().Be("approve");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_DeferAction_RequiresTriageRole()
|
||||
{
|
||||
// Arrange
|
||||
var modelOutput = "[Defer Review]{action:defer,cve_id=CVE-2023-9999,assignee=security-team}";
|
||||
var permissions = ImmutableArray.Create("triage");
|
||||
|
||||
// Act
|
||||
var result = _parser.Parse(modelOutput, permissions);
|
||||
|
||||
// Assert
|
||||
result.Proposals.Should().HaveCount(1);
|
||||
result.Proposals[0].ActionType.Should().Be("defer");
|
||||
result.Proposals[0].IsAllowed.Should().BeTrue();
|
||||
result.Proposals[0].Parameters.Should().ContainKey("assignee");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_CaseInsensitiveActionType()
|
||||
{
|
||||
// Arrange
|
||||
var modelOutput = "[Accept]{action:APPROVE,cve_id=CVE-2023-1234}";
|
||||
var permissions = ImmutableArray.Create("approver");
|
||||
|
||||
// Act
|
||||
var result = _parser.Parse(modelOutput, permissions);
|
||||
|
||||
// Assert
|
||||
result.Proposals.Should().HaveCount(1);
|
||||
result.Proposals[0].ActionType.Should().Be("approve");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_CaseInsensitiveRoleCheck()
|
||||
{
|
||||
// Arrange
|
||||
var modelOutput = "[Accept]{action:approve,cve_id=CVE-2023-1234}";
|
||||
var permissions = ImmutableArray.Create("APPROVER"); // Uppercase
|
||||
|
||||
// Act
|
||||
var result = _parser.Parse(modelOutput, permissions);
|
||||
|
||||
// Assert
|
||||
result.Proposals[0].IsAllowed.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
// <copyright file="ChatIntegrationTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.AdvisoryAI.Chat;
|
||||
using StellaOps.AdvisoryAI.Storage;
|
||||
using StellaOps.AdvisoryAI.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for Chat API endpoints.
|
||||
/// Sprint: SPRINT_20260107_006_003 Task CH-015
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
public sealed class ChatIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public ChatIntegrationTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Use in-memory conversation store for tests
|
||||
services.AddSingleton<IConversationStore, InMemoryConversationStore>();
|
||||
});
|
||||
});
|
||||
|
||||
_client = _factory.CreateClient();
|
||||
_client.DefaultRequestHeaders.Add("X-StellaOps-User", "test-user");
|
||||
_client.DefaultRequestHeaders.Add("X-StellaOps-Client", "test-client");
|
||||
_client.DefaultRequestHeaders.Add("X-StellaOps-Roles", "chat:user");
|
||||
}
|
||||
|
||||
#region Create Conversation Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CreateConversation_ValidRequest_Returns201Created()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateConversationRequest
|
||||
{
|
||||
TenantId = "test-tenant-001"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/v1/advisory-ai/conversations", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
response.Headers.Location.Should().NotBeNull();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ConversationResponse>();
|
||||
result.Should().NotBeNull();
|
||||
result!.ConversationId.Should().NotBeNullOrEmpty();
|
||||
result.TenantId.Should().Be("test-tenant-001");
|
||||
result.UserId.Should().Be("test-user");
|
||||
result.Turns.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateConversation_WithContext_ContextPreserved()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateConversationRequest
|
||||
{
|
||||
TenantId = "test-tenant-002",
|
||||
Context = new ConversationContextRequest
|
||||
{
|
||||
CurrentCveId = "CVE-2023-44487",
|
||||
CurrentComponent = "pkg:npm/http2@1.0.0",
|
||||
CurrentImageDigest = "sha256:abc123"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/v1/advisory-ai/conversations", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ConversationResponse>();
|
||||
result.Should().NotBeNull();
|
||||
result!.ConversationId.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateConversation_Unauthorized_Returns403()
|
||||
{
|
||||
// Arrange
|
||||
var client = _factory.CreateClient();
|
||||
// No auth headers
|
||||
var request = new CreateConversationRequest { TenantId = "test-tenant" };
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsJsonAsync("/v1/advisory-ai/conversations", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Get Conversation Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetConversation_ExistingConversation_Returns200()
|
||||
{
|
||||
// Arrange - Create conversation first
|
||||
var createRequest = new CreateConversationRequest { TenantId = "test-tenant-get" };
|
||||
var createResponse = await _client.PostAsJsonAsync("/v1/advisory-ai/conversations", createRequest);
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<ConversationResponse>();
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/v1/advisory-ai/conversations/{created!.ConversationId}");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ConversationResponse>();
|
||||
result.Should().NotBeNull();
|
||||
result!.ConversationId.Should().Be(created.ConversationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetConversation_NonExistent_Returns404()
|
||||
{
|
||||
// Act
|
||||
var response = await _client.GetAsync("/v1/advisory-ai/conversations/non-existent-id");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Delete Conversation Tests
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteConversation_ExistingConversation_Returns204()
|
||||
{
|
||||
// Arrange - Create conversation first
|
||||
var createRequest = new CreateConversationRequest { TenantId = "test-tenant-delete" };
|
||||
var createResponse = await _client.PostAsJsonAsync("/v1/advisory-ai/conversations", createRequest);
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<ConversationResponse>();
|
||||
|
||||
// Act
|
||||
var response = await _client.DeleteAsync($"/v1/advisory-ai/conversations/{created!.ConversationId}");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
||||
|
||||
// Verify deleted
|
||||
var getResponse = await _client.GetAsync($"/v1/advisory-ai/conversations/{created.ConversationId}");
|
||||
getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteConversation_NonExistent_Returns404()
|
||||
{
|
||||
// Act
|
||||
var response = await _client.DeleteAsync("/v1/advisory-ai/conversations/non-existent-id");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region List Conversations Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ListConversations_WithTenant_ReturnsFilteredList()
|
||||
{
|
||||
// Arrange - Create multiple conversations
|
||||
var tenantId = $"test-tenant-list-{Guid.NewGuid():N}";
|
||||
await _client.PostAsJsonAsync("/v1/advisory-ai/conversations", new CreateConversationRequest { TenantId = tenantId });
|
||||
await _client.PostAsJsonAsync("/v1/advisory-ai/conversations", new CreateConversationRequest { TenantId = tenantId });
|
||||
await _client.PostAsJsonAsync("/v1/advisory-ai/conversations", new CreateConversationRequest { TenantId = "other-tenant" });
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/v1/advisory-ai/conversations?tenantId={tenantId}");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ConversationListResponse>();
|
||||
result.Should().NotBeNull();
|
||||
result!.Conversations.Should().HaveCountGreaterThanOrEqualTo(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListConversations_WithPagination_ReturnsPagedResults()
|
||||
{
|
||||
// Arrange - Create multiple conversations
|
||||
var tenantId = $"test-tenant-page-{Guid.NewGuid():N}";
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await _client.PostAsJsonAsync("/v1/advisory-ai/conversations", new CreateConversationRequest { TenantId = tenantId });
|
||||
}
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/v1/advisory-ai/conversations?tenantId={tenantId}&limit=2");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ConversationListResponse>();
|
||||
result.Should().NotBeNull();
|
||||
result!.Conversations.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Add Turn Tests
|
||||
|
||||
[Fact]
|
||||
public async Task AddTurn_ValidMessage_Returns200WithResponse()
|
||||
{
|
||||
// Arrange - Create conversation first
|
||||
var createRequest = new CreateConversationRequest
|
||||
{
|
||||
TenantId = "test-tenant-turn",
|
||||
Context = new ConversationContextRequest { CurrentCveId = "CVE-2023-44487" }
|
||||
};
|
||||
var createResponse = await _client.PostAsJsonAsync("/v1/advisory-ai/conversations", createRequest);
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<ConversationResponse>();
|
||||
|
||||
var turnRequest = new AddTurnRequest
|
||||
{
|
||||
Content = "What is the severity of this vulnerability?",
|
||||
Stream = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
$"/v1/advisory-ai/conversations/{created!.ConversationId}/turns",
|
||||
turnRequest);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<AssistantTurnResponse>();
|
||||
result.Should().NotBeNull();
|
||||
result!.TurnId.Should().NotBeNullOrEmpty();
|
||||
result.Content.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddTurn_NonExistentConversation_Returns404()
|
||||
{
|
||||
// Arrange
|
||||
var turnRequest = new AddTurnRequest
|
||||
{
|
||||
Content = "Test message",
|
||||
Stream = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
"/v1/advisory-ai/conversations/non-existent-id/turns",
|
||||
turnRequest);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddTurn_MultipleMessages_BuildsConversationHistory()
|
||||
{
|
||||
// Arrange - Create conversation
|
||||
var createResponse = await _client.PostAsJsonAsync(
|
||||
"/v1/advisory-ai/conversations",
|
||||
new CreateConversationRequest { TenantId = "test-tenant-multi" });
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<ConversationResponse>();
|
||||
var conversationId = created!.ConversationId;
|
||||
|
||||
// Act - Send multiple messages
|
||||
await _client.PostAsJsonAsync(
|
||||
$"/v1/advisory-ai/conversations/{conversationId}/turns",
|
||||
new AddTurnRequest { Content = "First question", Stream = false });
|
||||
|
||||
await _client.PostAsJsonAsync(
|
||||
$"/v1/advisory-ai/conversations/{conversationId}/turns",
|
||||
new AddTurnRequest { Content = "Follow-up question", Stream = false });
|
||||
|
||||
// Assert - Check conversation has all turns
|
||||
var getResponse = await _client.GetAsync($"/v1/advisory-ai/conversations/{conversationId}");
|
||||
var conversation = await getResponse.Content.ReadFromJsonAsync<ConversationResponse>();
|
||||
|
||||
conversation!.Turns.Should().HaveCountGreaterThanOrEqualTo(4); // 2 user + 2 assistant
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Streaming Tests
|
||||
|
||||
[Fact]
|
||||
public async Task AddTurn_WithStreaming_ReturnsSSEStream()
|
||||
{
|
||||
// Arrange - Create conversation
|
||||
var createResponse = await _client.PostAsJsonAsync(
|
||||
"/v1/advisory-ai/conversations",
|
||||
new CreateConversationRequest { TenantId = "test-tenant-stream" });
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<ConversationResponse>();
|
||||
|
||||
var turnRequest = new AddTurnRequest
|
||||
{
|
||||
Content = "Explain this CVE",
|
||||
Stream = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var request = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
$"/v1/advisory-ai/conversations/{created!.ConversationId}/turns");
|
||||
request.Content = JsonContent.Create(turnRequest);
|
||||
request.Headers.Accept.Clear();
|
||||
request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream"));
|
||||
|
||||
var response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType?.MediaType.Should().Be("text/event-stream");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Action Gating Tests
|
||||
|
||||
[Fact]
|
||||
public async Task AddTurn_WithProposedAction_ActionRequiresConfirmation()
|
||||
{
|
||||
// This test verifies that action proposals are returned in the response
|
||||
// but not executed without explicit confirmation
|
||||
|
||||
// Arrange - Create conversation with CVE context
|
||||
var createResponse = await _client.PostAsJsonAsync(
|
||||
"/v1/advisory-ai/conversations",
|
||||
new CreateConversationRequest
|
||||
{
|
||||
TenantId = "test-tenant-action",
|
||||
Context = new ConversationContextRequest { CurrentCveId = "CVE-2023-44487" }
|
||||
});
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<ConversationResponse>();
|
||||
|
||||
var turnRequest = new AddTurnRequest
|
||||
{
|
||||
Content = "Please quarantine this component",
|
||||
Stream = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
$"/v1/advisory-ai/conversations/{created!.ConversationId}/turns",
|
||||
turnRequest);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<AssistantTurnResponse>();
|
||||
result.Should().NotBeNull();
|
||||
|
||||
// If action was proposed, it should be in the response but not executed
|
||||
// The response should indicate that user confirmation is needed
|
||||
if (result!.ProposedActions?.Any() == true)
|
||||
{
|
||||
result.ProposedActions.Should().AllSatisfy(a =>
|
||||
{
|
||||
a.ActionType.Should().NotBeNullOrEmpty();
|
||||
a.RequiresConfirmation.Should().BeTrue();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory conversation store for testing.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryConversationStore : IConversationStore
|
||||
{
|
||||
private readonly Dictionary<string, Conversation> _conversations = new();
|
||||
|
||||
public Task<Conversation> CreateAsync(Conversation conversation, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_conversations[conversation.ConversationId] = conversation;
|
||||
return Task.FromResult(conversation);
|
||||
}
|
||||
|
||||
public Task<Conversation?> GetByIdAsync(string conversationId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_conversations.TryGetValue(conversationId, out var conversation);
|
||||
return Task.FromResult(conversation);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Conversation>> GetByUserAsync(string tenantId, string userId, int limit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = _conversations.Values
|
||||
.Where(c => c.TenantId == tenantId && c.UserId == userId)
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<Conversation>>(result);
|
||||
}
|
||||
|
||||
public Task<Conversation> AddTurnAsync(string conversationId, ConversationTurn turn, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_conversations.TryGetValue(conversationId, out var conversation))
|
||||
{
|
||||
throw new InvalidOperationException($"Conversation {conversationId} not found");
|
||||
}
|
||||
|
||||
var updatedTurns = conversation.Turns.Add(turn);
|
||||
var updated = conversation with { Turns = updatedTurns, UpdatedAt = DateTimeOffset.UtcNow };
|
||||
_conversations[conversationId] = updated;
|
||||
return Task.FromResult(updated);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string conversationId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_conversations.Remove(conversationId));
|
||||
}
|
||||
|
||||
public Task CleanupExpiredAsync(TimeSpan maxAge, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow - maxAge;
|
||||
var expired = _conversations.Where(kvp => kvp.Value.CreatedAt < cutoff).Select(kvp => kvp.Key).ToList();
|
||||
foreach (var key in expired)
|
||||
{
|
||||
_conversations.Remove(key);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
// <copyright file="ChatPromptAssemblerTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Chat;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ChatPromptAssembler"/>.
|
||||
/// Sprint: SPRINT_20260107_006_003 Task CH-014
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ChatPromptAssemblerTests
|
||||
{
|
||||
private readonly ChatPromptAssembler _assembler;
|
||||
private readonly ChatPromptOptions _options;
|
||||
|
||||
public ChatPromptAssemblerTests()
|
||||
{
|
||||
_options = new ChatPromptOptions
|
||||
{
|
||||
BaseSystemPrompt = "You are AdvisoryAI.",
|
||||
MaxContextTokens = 4000,
|
||||
SystemPromptVersion = "v1.0.0"
|
||||
};
|
||||
|
||||
var contextBuilder = new ConversationContextBuilder();
|
||||
_assembler = new ChatPromptAssembler(Options.Create(_options), contextBuilder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_EmptyConversation_IncludesSystemAndUserMessage()
|
||||
{
|
||||
// Arrange
|
||||
var conversation = CreateConversation();
|
||||
var userMessage = "What is CVE-2023-1234?";
|
||||
|
||||
// Act
|
||||
var result = _assembler.Assemble(conversation, userMessage);
|
||||
|
||||
// Assert
|
||||
result.Messages.Should().HaveCountGreaterThanOrEqualTo(2);
|
||||
result.Messages[0].Role.Should().Be(ChatMessageRole.System);
|
||||
result.Messages[^1].Role.Should().Be(ChatMessageRole.User);
|
||||
result.Messages[^1].Content.Should().Be(userMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_SystemPrompt_ContainsGroundingRules()
|
||||
{
|
||||
// Arrange
|
||||
var conversation = CreateConversation();
|
||||
|
||||
// Act
|
||||
var result = _assembler.Assemble(conversation, "Hello");
|
||||
|
||||
// Assert
|
||||
var systemMessage = result.Messages.First(m => m.Role == ChatMessageRole.System);
|
||||
systemMessage.Content.Should().Contain("GROUNDING RULES");
|
||||
systemMessage.Content.Should().Contain("cite");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_SystemPrompt_ContainsObjectLinkFormats()
|
||||
{
|
||||
// Arrange
|
||||
var conversation = CreateConversation();
|
||||
|
||||
// Act
|
||||
var result = _assembler.Assemble(conversation, "Hello");
|
||||
|
||||
// Assert
|
||||
var systemMessage = result.Messages.First(m => m.Role == ChatMessageRole.System);
|
||||
systemMessage.Content.Should().Contain("OBJECT LINK FORMATS");
|
||||
systemMessage.Content.Should().Contain("[sbom:");
|
||||
systemMessage.Content.Should().Contain("[reach:");
|
||||
systemMessage.Content.Should().Contain("[vex:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_SystemPrompt_ContainsActionProposalFormat()
|
||||
{
|
||||
// Arrange
|
||||
var conversation = CreateConversation();
|
||||
|
||||
// Act
|
||||
var result = _assembler.Assemble(conversation, "Hello");
|
||||
|
||||
// Assert
|
||||
var systemMessage = result.Messages.First(m => m.Role == ChatMessageRole.System);
|
||||
systemMessage.Content.Should().Contain("ACTION PROPOSALS");
|
||||
systemMessage.Content.Should().Contain("approve");
|
||||
systemMessage.Content.Should().Contain("quarantine");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_WithHistory_IncludesPriorTurns()
|
||||
{
|
||||
// Arrange
|
||||
var turns = ImmutableArray.Create(
|
||||
new ConversationTurn
|
||||
{
|
||||
TurnId = "t1",
|
||||
Role = TurnRole.User,
|
||||
Content = "Previous question",
|
||||
Timestamp = DateTimeOffset.UtcNow.AddMinutes(-5)
|
||||
},
|
||||
new ConversationTurn
|
||||
{
|
||||
TurnId = "t2",
|
||||
Role = TurnRole.Assistant,
|
||||
Content = "Previous answer",
|
||||
Timestamp = DateTimeOffset.UtcNow.AddMinutes(-4)
|
||||
});
|
||||
|
||||
var conversation = CreateConversation(turns: turns);
|
||||
|
||||
// Act
|
||||
var result = _assembler.Assemble(conversation, "New question");
|
||||
|
||||
// Assert
|
||||
result.Messages.Should().HaveCountGreaterThan(3); // System + 2 history + user
|
||||
result.Messages.Should().Contain(m => m.Content == "Previous question");
|
||||
result.Messages.Should().Contain(m => m.Content == "Previous answer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_WithCveContext_IncludesFocusInSystemPrompt()
|
||||
{
|
||||
// Arrange
|
||||
var context = new ConversationContext { CurrentCveId = "CVE-2023-44487" };
|
||||
var conversation = CreateConversation(context: context);
|
||||
|
||||
// Act
|
||||
var result = _assembler.Assemble(conversation, "Tell me more");
|
||||
|
||||
// Assert
|
||||
var systemMessage = result.Messages.First(m => m.Role == ChatMessageRole.System);
|
||||
systemMessage.Content.Should().Contain("CVE-2023-44487");
|
||||
systemMessage.Content.Should().Contain("CURRENT FOCUS");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_WithPolicyContext_IncludesPermissions()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new PolicyContext
|
||||
{
|
||||
Permissions = ImmutableArray.Create("approver", "viewer"),
|
||||
AutomationAllowed = true
|
||||
};
|
||||
var context = new ConversationContext { Policy = policy };
|
||||
var conversation = CreateConversation(context: context);
|
||||
|
||||
// Act
|
||||
var result = _assembler.Assemble(conversation, "What can I do?");
|
||||
|
||||
// Assert
|
||||
var systemMessage = result.Messages.First(m => m.Role == ChatMessageRole.System);
|
||||
systemMessage.Content.Should().Contain("USER PERMISSIONS");
|
||||
systemMessage.Content.Should().Contain("Automation is ALLOWED");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_AutomationDisabled_IndicatesInSystemPrompt()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new PolicyContext { AutomationAllowed = false };
|
||||
var context = new ConversationContext { Policy = policy };
|
||||
var conversation = CreateConversation(context: context);
|
||||
|
||||
// Act
|
||||
var result = _assembler.Assemble(conversation, "Execute action");
|
||||
|
||||
// Assert
|
||||
var systemMessage = result.Messages.First(m => m.Role == ChatMessageRole.System);
|
||||
systemMessage.Content.Should().Contain("Automation is DISABLED");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_EstimatesTokenCount()
|
||||
{
|
||||
// Arrange
|
||||
var conversation = CreateConversation();
|
||||
|
||||
// Act
|
||||
var result = _assembler.Assemble(conversation, "A short message");
|
||||
|
||||
// Assert
|
||||
result.EstimatedTokens.Should().BePositive();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_IncludesSystemPromptVersion()
|
||||
{
|
||||
// Arrange
|
||||
var conversation = CreateConversation();
|
||||
|
||||
// Act
|
||||
var result = _assembler.Assemble(conversation, "Hello");
|
||||
|
||||
// Assert
|
||||
result.SystemPromptVersion.Should().Be("v1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_AssistantTurnWithEvidenceLinks_AppendsFootnotes()
|
||||
{
|
||||
// Arrange
|
||||
var evidenceLinks = ImmutableArray.Create(
|
||||
new EvidenceLink
|
||||
{
|
||||
Type = EvidenceLinkType.Sbom,
|
||||
Uri = "sbom:abc123",
|
||||
Label = "Component SBOM"
|
||||
});
|
||||
|
||||
var turns = ImmutableArray.Create(
|
||||
new ConversationTurn
|
||||
{
|
||||
TurnId = "t1",
|
||||
Role = TurnRole.User,
|
||||
Content = "What's in the SBOM?",
|
||||
Timestamp = DateTimeOffset.UtcNow.AddMinutes(-2)
|
||||
},
|
||||
new ConversationTurn
|
||||
{
|
||||
TurnId = "t2",
|
||||
Role = TurnRole.Assistant,
|
||||
Content = "The SBOM contains lodash.",
|
||||
Timestamp = DateTimeOffset.UtcNow.AddMinutes(-1),
|
||||
EvidenceLinks = evidenceLinks
|
||||
});
|
||||
|
||||
var conversation = CreateConversation(turns: turns);
|
||||
|
||||
// Act
|
||||
var result = _assembler.Assemble(conversation, "Anything else?");
|
||||
|
||||
// Assert
|
||||
var assistantMessage = result.Messages.FirstOrDefault(m =>
|
||||
m.Role == ChatMessageRole.Assistant && m.Content.Contains("lodash"));
|
||||
assistantMessage.Should().NotBeNull();
|
||||
assistantMessage!.Content.Should().Contain("Evidence:");
|
||||
assistantMessage.Content.Should().Contain("Component SBOM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_MessageRolesCorrectlyMapped()
|
||||
{
|
||||
// Arrange
|
||||
var turns = ImmutableArray.Create(
|
||||
new ConversationTurn
|
||||
{
|
||||
TurnId = "t1",
|
||||
Role = TurnRole.User,
|
||||
Content = "User message",
|
||||
Timestamp = DateTimeOffset.UtcNow.AddMinutes(-2)
|
||||
},
|
||||
new ConversationTurn
|
||||
{
|
||||
TurnId = "t2",
|
||||
Role = TurnRole.Assistant,
|
||||
Content = "Assistant message",
|
||||
Timestamp = DateTimeOffset.UtcNow.AddMinutes(-1)
|
||||
},
|
||||
new ConversationTurn
|
||||
{
|
||||
TurnId = "t3",
|
||||
Role = TurnRole.System,
|
||||
Content = "System note",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
var conversation = CreateConversation(turns: turns);
|
||||
|
||||
// Act
|
||||
var result = _assembler.Assemble(conversation, "New message");
|
||||
|
||||
// Assert
|
||||
result.Messages.Should().Contain(m => m.Role == ChatMessageRole.User && m.Content == "User message");
|
||||
result.Messages.Should().Contain(m => m.Role == ChatMessageRole.Assistant && m.Content.Contains("Assistant message"));
|
||||
result.Messages.Should().Contain(m => m.Role == ChatMessageRole.System && m.Content == "System note");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_ReturnsBuiltContext()
|
||||
{
|
||||
// Arrange
|
||||
var context = new ConversationContext
|
||||
{
|
||||
CurrentCveId = "CVE-2023-1234",
|
||||
CurrentComponent = "pkg:npm/lodash@4.17.21"
|
||||
};
|
||||
var conversation = CreateConversation(context: context);
|
||||
|
||||
// Act
|
||||
var result = _assembler.Assemble(conversation, "Analyze this");
|
||||
|
||||
// Assert
|
||||
result.Context.Should().NotBeNull();
|
||||
}
|
||||
|
||||
private static Conversation CreateConversation(
|
||||
ConversationContext? context = null,
|
||||
ImmutableArray<ConversationTurn>? turns = null)
|
||||
{
|
||||
return new Conversation
|
||||
{
|
||||
ConversationId = "conv-1",
|
||||
TenantId = "tenant-1",
|
||||
UserId = "user-1",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
Context = context ?? new ConversationContext(),
|
||||
Turns = turns ?? ImmutableArray<ConversationTurn>.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
// <copyright file="ConversationServiceTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Chat;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ConversationService"/>.
|
||||
/// Sprint: SPRINT_20260107_006_003 Task CH-014
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ConversationServiceTests
|
||||
{
|
||||
private readonly ConversationService _service;
|
||||
private readonly TestGuidGenerator _guidGenerator;
|
||||
private readonly TestTimeProvider _timeProvider;
|
||||
|
||||
public ConversationServiceTests()
|
||||
{
|
||||
_guidGenerator = new TestGuidGenerator();
|
||||
_timeProvider = new TestTimeProvider(new DateTimeOffset(2026, 1, 9, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var options = Options.Create(new ConversationOptions
|
||||
{
|
||||
MaxTurnsPerConversation = 50,
|
||||
ConversationRetention = TimeSpan.FromDays(7)
|
||||
});
|
||||
|
||||
_service = new ConversationService(
|
||||
options,
|
||||
_timeProvider,
|
||||
_guidGenerator,
|
||||
NullLogger<ConversationService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_CreatesConversation()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ConversationRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
UserId = "user-1"
|
||||
};
|
||||
|
||||
// Act
|
||||
var conversation = await _service.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
conversation.Should().NotBeNull();
|
||||
conversation.ConversationId.Should().NotBeNullOrEmpty();
|
||||
conversation.TenantId.Should().Be("tenant-1");
|
||||
conversation.UserId.Should().Be("user-1");
|
||||
conversation.CreatedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
conversation.UpdatedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
conversation.Turns.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithInitialContext_SetsContext()
|
||||
{
|
||||
// Arrange
|
||||
var context = new ConversationContext
|
||||
{
|
||||
CurrentCveId = "CVE-2023-1234",
|
||||
CurrentComponent = "pkg:npm/lodash@4.17.21"
|
||||
};
|
||||
|
||||
var request = new ConversationRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
UserId = "user-1",
|
||||
InitialContext = context
|
||||
};
|
||||
|
||||
// Act
|
||||
var conversation = await _service.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
conversation.Context.CurrentCveId.Should().Be("CVE-2023-1234");
|
||||
conversation.Context.CurrentComponent.Should().Be("pkg:npm/lodash@4.17.21");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithMetadata_StoresMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ConversationRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
UserId = "user-1",
|
||||
Metadata = ImmutableDictionary.CreateRange(new[]
|
||||
{
|
||||
KeyValuePair.Create("source", "ui"),
|
||||
KeyValuePair.Create("version", "1.0")
|
||||
})
|
||||
};
|
||||
|
||||
// Act
|
||||
var conversation = await _service.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
conversation.Metadata.Should().ContainKey("source");
|
||||
conversation.Metadata["source"].Should().Be("ui");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ExistingConversation_ReturnsConversation()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ConversationRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
UserId = "user-1"
|
||||
};
|
||||
var created = await _service.CreateAsync(request);
|
||||
|
||||
// Act
|
||||
var retrieved = await _service.GetAsync(created.ConversationId);
|
||||
|
||||
// Assert
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.ConversationId.Should().Be(created.ConversationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_NonExistentConversation_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.GetAsync("non-existent-id");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddTurnAsync_AddsUserTurn()
|
||||
{
|
||||
// Arrange
|
||||
var conversation = await CreateTestConversation();
|
||||
|
||||
var turnRequest = new TurnRequest
|
||||
{
|
||||
Role = TurnRole.User,
|
||||
Content = "What is CVE-2023-1234?"
|
||||
};
|
||||
|
||||
// Act
|
||||
var turn = await _service.AddTurnAsync(conversation.ConversationId, turnRequest);
|
||||
|
||||
// Assert
|
||||
turn.Should().NotBeNull();
|
||||
turn.Role.Should().Be(TurnRole.User);
|
||||
turn.Content.Should().Be("What is CVE-2023-1234?");
|
||||
turn.TurnId.Should().NotBeNullOrEmpty();
|
||||
turn.Timestamp.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddTurnAsync_AddsAssistantTurn()
|
||||
{
|
||||
// Arrange
|
||||
var conversation = await CreateTestConversation();
|
||||
|
||||
var turnRequest = new TurnRequest
|
||||
{
|
||||
Role = TurnRole.Assistant,
|
||||
Content = "CVE-2023-1234 is a critical vulnerability..."
|
||||
};
|
||||
|
||||
// Act
|
||||
var turn = await _service.AddTurnAsync(conversation.ConversationId, turnRequest);
|
||||
|
||||
// Assert
|
||||
turn.Role.Should().Be(TurnRole.Assistant);
|
||||
turn.Content.Should().Contain("CVE-2023-1234");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddTurnAsync_WithEvidenceLinks_StoresLinks()
|
||||
{
|
||||
// Arrange
|
||||
var conversation = await CreateTestConversation();
|
||||
|
||||
var links = ImmutableArray.Create(
|
||||
new EvidenceLink
|
||||
{
|
||||
Type = EvidenceLinkType.Sbom,
|
||||
Uri = "sbom:abc123",
|
||||
Label = "SBOM Reference"
|
||||
});
|
||||
|
||||
var turnRequest = new TurnRequest
|
||||
{
|
||||
Role = TurnRole.Assistant,
|
||||
Content = "Found in SBOM [sbom:abc123]",
|
||||
EvidenceLinks = links
|
||||
};
|
||||
|
||||
// Act
|
||||
var turn = await _service.AddTurnAsync(conversation.ConversationId, turnRequest);
|
||||
|
||||
// Assert
|
||||
turn.EvidenceLinks.Should().HaveCount(1);
|
||||
turn.EvidenceLinks[0].Type.Should().Be(EvidenceLinkType.Sbom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddTurnAsync_WithProposedActions_StoresActions()
|
||||
{
|
||||
// Arrange
|
||||
var conversation = await CreateTestConversation();
|
||||
|
||||
var actions = ImmutableArray.Create(
|
||||
new ProposedAction
|
||||
{
|
||||
ActionType = "approve",
|
||||
Label = "Accept Risk",
|
||||
RequiresConfirmation = true
|
||||
});
|
||||
|
||||
var turnRequest = new TurnRequest
|
||||
{
|
||||
Role = TurnRole.Assistant,
|
||||
Content = "You may want to approve this risk.",
|
||||
ProposedActions = actions
|
||||
};
|
||||
|
||||
// Act
|
||||
var turn = await _service.AddTurnAsync(conversation.ConversationId, turnRequest);
|
||||
|
||||
// Assert
|
||||
turn.ProposedActions.Should().HaveCount(1);
|
||||
turn.ProposedActions[0].ActionType.Should().Be("approve");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddTurnAsync_NonExistentConversation_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var turnRequest = new TurnRequest
|
||||
{
|
||||
Role = TurnRole.User,
|
||||
Content = "Hello"
|
||||
};
|
||||
|
||||
// Act
|
||||
Func<Task> act = () => _service.AddTurnAsync("non-existent", turnRequest);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ConversationNotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddTurnAsync_UpdatesConversationTimestamp()
|
||||
{
|
||||
// Arrange
|
||||
var conversation = await CreateTestConversation();
|
||||
var originalUpdatedAt = conversation.UpdatedAt;
|
||||
|
||||
// Advance time
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(5));
|
||||
|
||||
var turnRequest = new TurnRequest
|
||||
{
|
||||
Role = TurnRole.User,
|
||||
Content = "New message"
|
||||
};
|
||||
|
||||
// Act
|
||||
await _service.AddTurnAsync(conversation.ConversationId, turnRequest);
|
||||
var updated = await _service.GetAsync(conversation.ConversationId);
|
||||
|
||||
// Assert
|
||||
updated!.UpdatedAt.Should().BeAfter(originalUpdatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_ExistingConversation_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var conversation = await CreateTestConversation();
|
||||
|
||||
// Act
|
||||
var result = await _service.DeleteAsync(conversation.ConversationId);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
(await _service.GetAsync(conversation.ConversationId)).Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_NonExistentConversation_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.DeleteAsync("non-existent");
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_ByTenant_ReturnsMatchingConversations()
|
||||
{
|
||||
// Arrange
|
||||
await _service.CreateAsync(new ConversationRequest { TenantId = "tenant-1", UserId = "user-1" });
|
||||
await _service.CreateAsync(new ConversationRequest { TenantId = "tenant-1", UserId = "user-2" });
|
||||
await _service.CreateAsync(new ConversationRequest { TenantId = "tenant-2", UserId = "user-1" });
|
||||
|
||||
// Act
|
||||
var result = await _service.ListAsync("tenant-1");
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result.All(c => c.TenantId == "tenant-1").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_ByTenantAndUser_ReturnsMatchingConversations()
|
||||
{
|
||||
// Arrange
|
||||
await _service.CreateAsync(new ConversationRequest { TenantId = "tenant-1", UserId = "user-1" });
|
||||
await _service.CreateAsync(new ConversationRequest { TenantId = "tenant-1", UserId = "user-2" });
|
||||
await _service.CreateAsync(new ConversationRequest { TenantId = "tenant-1", UserId = "user-1" });
|
||||
|
||||
// Act
|
||||
var result = await _service.ListAsync("tenant-1", "user-1");
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result.All(c => c.UserId == "user-1").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_WithLimit_ReturnsLimitedResults()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await _service.CreateAsync(new ConversationRequest { TenantId = "tenant-1", UserId = "user-1" });
|
||||
}
|
||||
|
||||
// Act
|
||||
var result = await _service.ListAsync("tenant-1", limit: 3);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateContextAsync_UpdatesContext()
|
||||
{
|
||||
// Arrange
|
||||
var conversation = await CreateTestConversation();
|
||||
|
||||
var newContext = new ConversationContext
|
||||
{
|
||||
CurrentCveId = "CVE-2023-5678",
|
||||
ScanId = "scan-123"
|
||||
};
|
||||
|
||||
// Act
|
||||
var updated = await _service.UpdateContextAsync(conversation.ConversationId, newContext);
|
||||
|
||||
// Assert
|
||||
updated.Should().NotBeNull();
|
||||
updated!.Context.CurrentCveId.Should().Be("CVE-2023-5678");
|
||||
updated.Context.ScanId.Should().Be("scan-123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateContextAsync_NonExistentConversation_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var context = new ConversationContext { CurrentCveId = "CVE-2023-1234" };
|
||||
|
||||
// Act
|
||||
var result = await _service.UpdateContextAsync("non-existent", context);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TurnCount_ReflectsNumberOfTurns()
|
||||
{
|
||||
// Arrange
|
||||
var conversation = await CreateTestConversation();
|
||||
|
||||
await _service.AddTurnAsync(conversation.ConversationId, new TurnRequest { Role = TurnRole.User, Content = "Q1" });
|
||||
await _service.AddTurnAsync(conversation.ConversationId, new TurnRequest { Role = TurnRole.Assistant, Content = "A1" });
|
||||
await _service.AddTurnAsync(conversation.ConversationId, new TurnRequest { Role = TurnRole.User, Content = "Q2" });
|
||||
|
||||
// Act
|
||||
var updated = await _service.GetAsync(conversation.ConversationId);
|
||||
|
||||
// Assert
|
||||
updated!.TurnCount.Should().Be(3);
|
||||
updated.Turns.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
private async Task<Conversation> CreateTestConversation()
|
||||
{
|
||||
return await _service.CreateAsync(new ConversationRequest
|
||||
{
|
||||
TenantId = "test-tenant",
|
||||
UserId = "test-user"
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class TestGuidGenerator : IGuidGenerator
|
||||
{
|
||||
private int _counter;
|
||||
|
||||
public Guid NewGuid()
|
||||
{
|
||||
return new Guid(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, (byte)Interlocked.Increment(ref _counter));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow;
|
||||
|
||||
public TestTimeProvider(DateTimeOffset initialTime)
|
||||
{
|
||||
_utcNow = initialTime;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public void Advance(TimeSpan duration)
|
||||
{
|
||||
_utcNow = _utcNow.Add(duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
// <copyright file="GroundingValidatorTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Chat;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="GroundingValidator"/>.
|
||||
/// Sprint: SPRINT_20260107_006_003 Task CH-014
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class GroundingValidatorTests
|
||||
{
|
||||
private readonly MockObjectLinkResolver _resolver;
|
||||
private readonly GroundingValidator _validator;
|
||||
private readonly GroundingOptions _options;
|
||||
|
||||
public GroundingValidatorTests()
|
||||
{
|
||||
_resolver = new MockObjectLinkResolver();
|
||||
_options = new GroundingOptions
|
||||
{
|
||||
MinGroundingScore = 0.5,
|
||||
MaxLinkDistance = 200
|
||||
};
|
||||
_validator = new GroundingValidator(
|
||||
_resolver,
|
||||
NullLogger<GroundingValidator>.Instance,
|
||||
_options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WellGroundedResponse_ReturnsAcceptable()
|
||||
{
|
||||
// Arrange
|
||||
_resolver.AddResolution("sbom", "abc123", exists: true);
|
||||
var response = "The component is affected [sbom:abc123] as shown in the SBOM.";
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateAsync(response, context);
|
||||
|
||||
// Assert
|
||||
result.IsAcceptable.Should().BeTrue();
|
||||
result.GroundingScore.Should().BeGreaterThan(0);
|
||||
result.ValidatedLinks.Should().HaveCount(1);
|
||||
result.ValidatedLinks[0].IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_NoLinks_LowScore()
|
||||
{
|
||||
// Arrange
|
||||
var response = "The component is vulnerable but I have no evidence.";
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateAsync(response, context);
|
||||
|
||||
// Assert
|
||||
result.ValidatedLinks.Should().BeEmpty();
|
||||
result.GroundingScore.Should().BeLessThan(1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_InvalidLink_AddsIssue()
|
||||
{
|
||||
// Arrange
|
||||
_resolver.AddResolution("sbom", "nonexistent", exists: false);
|
||||
var response = "Check this SBOM [sbom:nonexistent] for details.";
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateAsync(response, context);
|
||||
|
||||
// Assert
|
||||
result.ValidatedLinks.Should().HaveCount(1);
|
||||
result.ValidatedLinks[0].IsValid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Type == GroundingIssueType.InvalidLink);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ExtractsMultipleLinkTypes()
|
||||
{
|
||||
// Arrange
|
||||
_resolver.AddResolution("sbom", "abc", exists: true);
|
||||
_resolver.AddResolution("vex", "issuer:digest", exists: true);
|
||||
_resolver.AddResolution("reach", "api:func", exists: true);
|
||||
|
||||
var response = "Found in SBOM [sbom:abc], VEX [vex:issuer:digest], and reachability [reach:api:func].";
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateAsync(response, context);
|
||||
|
||||
// Assert
|
||||
result.ValidatedLinks.Should().HaveCount(3);
|
||||
result.ValidatedLinks.Should().Contain(l => l.Type == "sbom");
|
||||
result.ValidatedLinks.Should().Contain(l => l.Type == "vex");
|
||||
result.ValidatedLinks.Should().Contain(l => l.Type == "reach");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_DetectsAffectedClaim()
|
||||
{
|
||||
// Arrange
|
||||
var response = "This component is affected by the vulnerability.";
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateAsync(response, context);
|
||||
|
||||
// Assert
|
||||
result.TotalClaims.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_DetectsNotAffectedClaim()
|
||||
{
|
||||
// Arrange
|
||||
var response = "The service is not affected by this CVE.";
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateAsync(response, context);
|
||||
|
||||
// Assert
|
||||
result.TotalClaims.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_DetectsFixedClaim()
|
||||
{
|
||||
// Arrange
|
||||
var response = "The vulnerability has been fixed in version 2.0.";
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateAsync(response, context);
|
||||
|
||||
// Assert
|
||||
result.TotalClaims.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_DetectsSeverityClaim()
|
||||
{
|
||||
// Arrange
|
||||
var response = "The CVSS score is 9.8, making this critical.";
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateAsync(response, context);
|
||||
|
||||
// Assert
|
||||
result.TotalClaims.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_UngroundedClaimNearLink_IsGrounded()
|
||||
{
|
||||
// Arrange
|
||||
_resolver.AddResolution("sbom", "abc123", exists: true);
|
||||
var response = "The component [sbom:abc123] is affected by this vulnerability.";
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateAsync(response, context);
|
||||
|
||||
// Assert
|
||||
result.GroundedClaims.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ClaimFarFromLink_IsUngrounded()
|
||||
{
|
||||
// Arrange
|
||||
_resolver.AddResolution("sbom", "abc123", exists: true);
|
||||
// Put the link far from the claim
|
||||
var response = "[sbom:abc123]\n\n" + new string(' ', 300) + "\n\nThe component is affected.";
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateAsync(response, context);
|
||||
|
||||
// Assert
|
||||
result.UngroundedClaims.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_BelowThreshold_AddsIssue()
|
||||
{
|
||||
// Arrange - use a high threshold
|
||||
var strictValidator = new GroundingValidator(
|
||||
_resolver,
|
||||
NullLogger<GroundingValidator>.Instance,
|
||||
new GroundingOptions { MinGroundingScore = 0.95 });
|
||||
|
||||
var response = "This is affected. No evidence provided.";
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var result = await strictValidator.ValidateAsync(response, context);
|
||||
|
||||
// Assert
|
||||
result.IsAcceptable.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Type == GroundingIssueType.BelowThreshold);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectResponse_CreatesRejectionWithReason()
|
||||
{
|
||||
// Arrange
|
||||
var validation = new GroundingValidationResult
|
||||
{
|
||||
GroundingScore = 0.3,
|
||||
IsAcceptable = false,
|
||||
Issues = ImmutableArray.Create(
|
||||
new GroundingIssue
|
||||
{
|
||||
Type = GroundingIssueType.BelowThreshold,
|
||||
Message = "Score too low",
|
||||
Severity = IssueSeverity.Critical
|
||||
})
|
||||
};
|
||||
|
||||
// Act
|
||||
var rejection = _validator.RejectResponse(validation);
|
||||
|
||||
// Assert
|
||||
rejection.Reason.Should().Contain("rejected");
|
||||
rejection.GroundingScore.Should().Be(0.3);
|
||||
rejection.RequiredScore.Should().Be(_options.MinGroundingScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuggestImprovements_ForUngroundedClaims_SuggestsAddCitations()
|
||||
{
|
||||
// Arrange
|
||||
var validation = new GroundingValidationResult
|
||||
{
|
||||
UngroundedClaims = ImmutableArray.Create(
|
||||
new UngroundedClaim { Text = "is affected", Position = 10 })
|
||||
};
|
||||
|
||||
// Act
|
||||
var suggestions = _validator.SuggestImprovements(validation);
|
||||
|
||||
// Assert
|
||||
suggestions.Should().Contain(s => s.Type == SuggestionType.AddCitations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuggestImprovements_ForInvalidLinks_SuggestsFixLinks()
|
||||
{
|
||||
// Arrange
|
||||
var validation = new GroundingValidationResult
|
||||
{
|
||||
ValidatedLinks = ImmutableArray.Create(
|
||||
new ValidatedLink { Type = "sbom", Path = "bad", IsValid = false })
|
||||
};
|
||||
|
||||
// Act
|
||||
var suggestions = _validator.SuggestImprovements(validation);
|
||||
|
||||
// Assert
|
||||
suggestions.Should().Contain(s => s.Type == SuggestionType.FixLinks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuggestImprovements_NoLinksWithClaims_SuggestsAddEvidence()
|
||||
{
|
||||
// Arrange
|
||||
var validation = new GroundingValidationResult
|
||||
{
|
||||
ValidatedLinks = ImmutableArray<ValidatedLink>.Empty,
|
||||
TotalClaims = 3
|
||||
};
|
||||
|
||||
// Act
|
||||
var suggestions = _validator.SuggestImprovements(validation);
|
||||
|
||||
// Assert
|
||||
suggestions.Should().Contain(s => s.Type == SuggestionType.AddEvidence);
|
||||
suggestions.First(s => s.Type == SuggestionType.AddEvidence)
|
||||
.Examples.Should().Contain(e => e.Contains("[sbom:"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_RuntimeLink_ExtractsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
_resolver.AddResolution("runtime", "api-gateway:traces", exists: true);
|
||||
var response = "Check runtime traces [runtime:api-gateway:traces] for execution data.";
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateAsync(response, context);
|
||||
|
||||
// Assert
|
||||
result.ValidatedLinks.Should().HaveCount(1);
|
||||
result.ValidatedLinks[0].Type.Should().Be("runtime");
|
||||
result.ValidatedLinks[0].Path.Should().Be("api-gateway:traces");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_AttestLink_ExtractsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
_resolver.AddResolution("attest", "dsse:sha256:xyz", exists: true);
|
||||
var response = "See attestation [attest:dsse:sha256:xyz] for provenance.";
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateAsync(response, context);
|
||||
|
||||
// Assert
|
||||
result.ValidatedLinks.Should().HaveCount(1);
|
||||
result.ValidatedLinks[0].Type.Should().Be("attest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_AuthLink_ExtractsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
_resolver.AddResolution("auth", "keys/gitlab-oidc", exists: true);
|
||||
var response = "Verify with authority key [auth:keys/gitlab-oidc].";
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateAsync(response, context);
|
||||
|
||||
// Assert
|
||||
result.ValidatedLinks.Should().HaveCount(1);
|
||||
result.ValidatedLinks[0].Type.Should().Be("auth");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_DocsLink_ExtractsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
_resolver.AddResolution("docs", "scopes/ci-webhook", exists: true);
|
||||
var response = "Read the documentation [docs:scopes/ci-webhook] for details.";
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateAsync(response, context);
|
||||
|
||||
// Assert
|
||||
result.ValidatedLinks.Should().HaveCount(1);
|
||||
result.ValidatedLinks[0].Type.Should().Be("docs");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_MixedValidAndInvalid_CalculatesCorrectScore()
|
||||
{
|
||||
// Arrange
|
||||
_resolver.AddResolution("sbom", "good", exists: true);
|
||||
_resolver.AddResolution("sbom", "bad", exists: false);
|
||||
|
||||
var response = "Found in [sbom:good] but not in [sbom:bad].";
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateAsync(response, context);
|
||||
|
||||
// Assert
|
||||
result.ValidatedLinks.Should().HaveCount(2);
|
||||
result.ValidatedLinks.Count(l => l.IsValid).Should().Be(1);
|
||||
result.ValidatedLinks.Count(l => !l.IsValid).Should().Be(1);
|
||||
}
|
||||
|
||||
private static ConversationContext CreateContext()
|
||||
{
|
||||
return new ConversationContext
|
||||
{
|
||||
TenantId = "test-tenant"
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class MockObjectLinkResolver : IObjectLinkResolver
|
||||
{
|
||||
private readonly Dictionary<string, LinkResolution> _resolutions = new();
|
||||
|
||||
public void AddResolution(string type, string path, bool exists, string? uri = null)
|
||||
{
|
||||
_resolutions[$"{type}:{path}"] = new LinkResolution
|
||||
{
|
||||
Exists = exists,
|
||||
Uri = uri ?? $"{type}://{path}",
|
||||
ObjectType = type
|
||||
};
|
||||
}
|
||||
|
||||
public Task<LinkResolution> ResolveAsync(
|
||||
string type, string path, string? tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var key = $"{type}:{path}";
|
||||
if (_resolutions.TryGetValue(key, out var resolution))
|
||||
{
|
||||
return Task.FromResult(resolution);
|
||||
}
|
||||
|
||||
return Task.FromResult(new LinkResolution { Exists = false });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
@@ -16,6 +17,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.AdvisoryAI.Hosting\StellaOps.AdvisoryAI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.AdvisoryAI.WebService\StellaOps.AdvisoryAI.WebService.csproj" />
|
||||
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj" />
|
||||
<ProjectReference Include="..\..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
|
||||
Reference in New Issue
Block a user