//
// 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);
}