342 lines
12 KiB
C#
342 lines
12 KiB
C#
// <copyright file="UnknownsGreyQueueCommandTests.cs" company="StellaOps">
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
// Sprint: SPRINT_20260112_010_CLI_unknowns_grey_queue_cli (CLI-UNK-005)
|
|
// </copyright>
|
|
|
|
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<IHttpClientFactory> _httpClientFactoryMock;
|
|
private readonly Mock<HttpMessageHandler> _httpHandlerMock;
|
|
private readonly IServiceProvider _services;
|
|
|
|
public UnknownsGreyQueueCommandTests()
|
|
{
|
|
_httpHandlerMock = new Mock<HttpMessageHandler>();
|
|
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
|
|
|
|
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<TestUnknownsSummaryResponse>(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<TestUnknownDto>(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<TestTriggerDto>
|
|
{
|
|
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<TestEvidenceRefDto>
|
|
{
|
|
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<TestTriggerDto>? Triggers { get; init; }
|
|
public IReadOnlyList<string>? 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<TestConflictDetailDto> 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<TestTriggerDto> Triggers { get; init; } = [];
|
|
public IReadOnlyList<TestEvidenceRefDto> EvidenceRefs { get; init; } = [];
|
|
public string? ObservationState { get; init; }
|
|
public TestConflictInfoDto? ConflictInfo { get; init; }
|
|
}
|
|
|
|
private sealed record TestTriageRequest(string Action, string Reason, int? DurationDays);
|
|
}
|