// // SPDX-License-Identifier: BUSL-1.1 // Sprint: SPRINT_20260112_010_CLI_unknowns_grey_queue_cli (CLI-UNK-005) // using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Moq.Protected; using StellaOps.Cli.Commands; using StellaOps.TestKit; using Xunit; namespace StellaOps.Cli.Tests.Commands; [Trait("Category", TestCategories.Unit)] public class UnknownsGreyQueueCommandTests { private readonly Mock _httpClientFactoryMock; private readonly Mock _httpHandlerMock; private readonly IServiceProvider _services; public UnknownsGreyQueueCommandTests() { _httpHandlerMock = new Mock(); _httpClientFactoryMock = new Mock(); var httpClient = new HttpClient(_httpHandlerMock.Object) { BaseAddress = new Uri("http://localhost:8080") }; _httpClientFactoryMock .Setup(f => f.CreateClient("PolicyApi")) .Returns(httpClient); var services = new ServiceCollection(); services.AddSingleton(_httpClientFactoryMock.Object); services.AddSingleton(NullLoggerFactory.Instance); _services = services.BuildServiceProvider(); } [Fact] public void UnknownsSummaryResponse_DeserializesCorrectly() { // Arrange var json = """ { "hot": 5, "warm": 10, "cold": 25, "resolved": 100, "total": 140 } """; // Act var response = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); // Assert Assert.NotNull(response); Assert.Equal(5, response.Hot); Assert.Equal(10, response.Warm); Assert.Equal(25, response.Cold); Assert.Equal(100, response.Resolved); Assert.Equal(140, response.Total); } [Fact] public void UnknownDto_WithGreyQueueFields_DeserializesCorrectly() { // Arrange var json = """ { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "packageId": "pkg:npm/lodash", "packageVersion": "4.17.21", "band": "hot", "score": 85.5, "uncertaintyFactor": 0.7, "exploitPressure": 0.9, "firstSeenAt": "2026-01-10T12:00:00Z", "lastEvaluatedAt": "2026-01-15T08:00:00Z", "reasonCode": "Reachability", "reasonCodeShort": "U-RCH", "fingerprintId": "sha256:abc123", "triggers": [ { "eventType": "epss.updated", "eventVersion": 1, "source": "concelier", "receivedAt": "2026-01-15T07:00:00Z", "correlationId": "corr-123" } ], "nextActions": ["request_vex", "verify_reachability"], "conflictInfo": { "hasConflict": true, "severity": 0.8, "suggestedPath": "RequireManualReview", "conflicts": [ { "signal1": "VEX:not_affected", "signal2": "Reachability:reachable", "type": "VexReachabilityContradiction", "description": "VEX says not affected but reachability shows path", "severity": 0.8 } ] }, "observationState": "Disputed" } """; // Act var unknown = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); // Assert Assert.NotNull(unknown); Assert.Equal("pkg:npm/lodash", unknown.PackageId); Assert.Equal("4.17.21", unknown.PackageVersion); Assert.Equal("hot", unknown.Band); Assert.Equal(85.5m, unknown.Score); Assert.Equal("sha256:abc123", unknown.FingerprintId); Assert.NotNull(unknown.Triggers); Assert.Single(unknown.Triggers); Assert.Equal("epss.updated", unknown.Triggers[0].EventType); Assert.Equal(1, unknown.Triggers[0].EventVersion); Assert.NotNull(unknown.NextActions); Assert.Equal(2, unknown.NextActions.Count); Assert.Contains("request_vex", unknown.NextActions); Assert.NotNull(unknown.ConflictInfo); Assert.True(unknown.ConflictInfo.HasConflict); Assert.Equal(0.8, unknown.ConflictInfo.Severity); Assert.Equal("RequireManualReview", unknown.ConflictInfo.SuggestedPath); Assert.Single(unknown.ConflictInfo.Conflicts); Assert.Equal("Disputed", unknown.ObservationState); } [Fact] public void UnknownProof_HasDeterministicStructure() { // Arrange var proof = new TestUnknownProof { Id = Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890"), FingerprintId = "sha256:abc123", PackageId = "pkg:npm/lodash", PackageVersion = "4.17.21", Band = "hot", Score = 85.5m, ReasonCode = "Reachability", Triggers = new List { new() { EventType = "vex.updated", EventVersion = 1, ReceivedAt = DateTimeOffset.Parse("2026-01-15T08:00:00Z") }, new() { EventType = "epss.updated", EventVersion = 1, ReceivedAt = DateTimeOffset.Parse("2026-01-15T07:00:00Z") } }, EvidenceRefs = new List { new() { Type = "sbom", Uri = "oci://registry/sbom@sha256:def" }, new() { Type = "attestation", Uri = "oci://registry/att@sha256:ghi" } }, ObservationState = "PendingDeterminization" }; // Act var json = JsonSerializer.Serialize(proof, new JsonSerializerOptions { WriteIndented = true }); // Assert - After ToLowerInvariant(), all text including property names are lowercase Assert.Contains("\"fingerprintid\"", json.ToLowerInvariant()); Assert.Contains("\"triggers\"", json.ToLowerInvariant()); Assert.Contains("\"evidencerefs\"", json.ToLowerInvariant()); Assert.Contains("\"observationstate\"", json.ToLowerInvariant()); } [Theory] [InlineData("accept-risk")] [InlineData("require-fix")] [InlineData("defer")] [InlineData("escalate")] [InlineData("dispute")] public void TriageAction_ValidActions_AreRecognized(string action) { // Arrange var validActions = new[] { "accept-risk", "require-fix", "defer", "escalate", "dispute" }; // Act & Assert Assert.Contains(action, validActions); } [Theory] [InlineData("invalid")] [InlineData("approve")] [InlineData("reject")] [InlineData("")] public void TriageAction_InvalidActions_AreNotRecognized(string action) { // Arrange var validActions = new[] { "accept-risk", "require-fix", "defer", "escalate", "dispute" }; // Act & Assert Assert.DoesNotContain(action, validActions); } [Fact] public void TriageRequest_SerializesCorrectly() { // Arrange var request = new TestTriageRequest("accept-risk", "Low priority, mitigated by WAF", 90); // Act var json = JsonSerializer.Serialize(request, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); // Assert Assert.Contains("\"action\":\"accept-risk\"", json); Assert.Contains("\"reason\":\"Low priority, mitigated by WAF\"", json); Assert.Contains("\"durationDays\":90", json); } [Fact] public void ExportFormat_CsvEscaping_HandlesSpecialCharacters() { // Arrange var testCases = new[] { ("simple", "simple"), ("with,comma", "\"with,comma\""), ("with\"quote", "\"with\"\"quote\""), ("with\nnewline", "\"with\nnewline\""), ("normal-value", "normal-value") }; // Act & Assert foreach (var (input, expected) in testCases) { var result = EscapeCsv(input); Assert.Equal(expected, result); } } private static string EscapeCsv(string value) { if (value.Contains(',') || value.Contains('"') || value.Contains('\n')) { return $"\"{value.Replace("\"", "\"\"")}\""; } return value; } // Test DTOs matching the CLI internal types private sealed record TestUnknownsSummaryResponse { public int Hot { get; init; } public int Warm { get; init; } public int Cold { get; init; } public int Resolved { get; init; } public int Total { get; init; } } private sealed record TestUnknownDto { public Guid Id { get; init; } public string PackageId { get; init; } = string.Empty; public string PackageVersion { get; init; } = string.Empty; public string Band { get; init; } = string.Empty; public decimal Score { get; init; } public decimal UncertaintyFactor { get; init; } public decimal ExploitPressure { get; init; } public DateTimeOffset FirstSeenAt { get; init; } public DateTimeOffset LastEvaluatedAt { get; init; } public string ReasonCode { get; init; } = string.Empty; public string ReasonCodeShort { get; init; } = string.Empty; public string? FingerprintId { get; init; } public IReadOnlyList? Triggers { get; init; } public IReadOnlyList? NextActions { get; init; } public TestConflictInfoDto? ConflictInfo { get; init; } public string? ObservationState { get; init; } } private sealed record TestTriggerDto { public string EventType { get; init; } = string.Empty; public int EventVersion { get; init; } public string? Source { get; init; } public DateTimeOffset ReceivedAt { get; init; } public string? CorrelationId { get; init; } } private sealed record TestConflictInfoDto { public bool HasConflict { get; init; } public double Severity { get; init; } public string SuggestedPath { get; init; } = string.Empty; public IReadOnlyList Conflicts { get; init; } = []; } private sealed record TestConflictDetailDto { public string Signal1 { get; init; } = string.Empty; public string Signal2 { get; init; } = string.Empty; public string Type { get; init; } = string.Empty; public string Description { get; init; } = string.Empty; public double Severity { get; init; } } private sealed record TestEvidenceRefDto { public string Type { get; init; } = string.Empty; public string Uri { get; init; } = string.Empty; public string? Digest { get; init; } } private sealed record TestUnknownProof { public Guid Id { get; init; } public string? FingerprintId { get; init; } public string PackageId { get; init; } = string.Empty; public string PackageVersion { get; init; } = string.Empty; public string Band { get; init; } = string.Empty; public decimal Score { get; init; } public string ReasonCode { get; init; } = string.Empty; public IReadOnlyList Triggers { get; init; } = []; public IReadOnlyList EvidenceRefs { get; init; } = []; public string? ObservationState { get; init; } public TestConflictInfoDto? ConflictInfo { get; init; } } private sealed record TestTriageRequest(string Action, string Reason, int? DurationDays); }