partly or unimplemented features - now implemented
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Commands.Advise;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Chat;
|
||||
using StellaOps.Cli.Services.Models.Chat;
|
||||
|
||||
namespace StellaOps.Cli.AdviseParity.Tests;
|
||||
|
||||
public sealed class AdviseParityIsolationTests : IDisposable
|
||||
{
|
||||
private readonly string _tempRoot;
|
||||
|
||||
public AdviseParityIsolationTests()
|
||||
{
|
||||
_tempRoot = Path.Combine(Path.GetTempPath(), $"advise-parity-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempRoot))
|
||||
{
|
||||
Directory.Delete(_tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ask_WithFile_ProcessesBatchQueriesAsJson()
|
||||
{
|
||||
var chatClient = new FakeChatClient();
|
||||
chatClient.QueryResponseFactory = request => CreateQueryResponse($"resp-{request.Query.Replace(' ', '-')}", request.Query);
|
||||
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IChatClient>(chatClient)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var options = new StellaOpsCliOptions();
|
||||
var command = AdviseChatCommandGroup.BuildAskCommand(
|
||||
services,
|
||||
options,
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None);
|
||||
var root = new RootCommand { command };
|
||||
|
||||
var batchPath = Path.Combine(_tempRoot, "queries.jsonl");
|
||||
await File.WriteAllTextAsync(
|
||||
batchPath,
|
||||
"""
|
||||
{"query":"first question"}
|
||||
"second question"
|
||||
"""
|
||||
);
|
||||
|
||||
var output = new StringWriter();
|
||||
var original = Console.Out;
|
||||
try
|
||||
{
|
||||
Console.SetOut(output);
|
||||
await root.Parse($"ask --file \"{batchPath}\" --format json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(original);
|
||||
}
|
||||
|
||||
var json = output.ToString();
|
||||
Assert.Contains("\"count\": 2", json, StringComparison.Ordinal);
|
||||
Assert.Contains("\"query\": \"first question\"", json, StringComparison.Ordinal);
|
||||
Assert.Contains("\"query\": \"second question\"", json, StringComparison.Ordinal);
|
||||
Assert.Equal(2, chatClient.QueryCalls.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_WithoutConversationId_ListsAndExportsSortedConversations()
|
||||
{
|
||||
var chatClient = new FakeChatClient();
|
||||
chatClient.ListResponse = new ChatConversationListResponse
|
||||
{
|
||||
TotalCount = 2,
|
||||
Conversations =
|
||||
[
|
||||
new ChatConversationSummary
|
||||
{
|
||||
ConversationId = "conv-b",
|
||||
CreatedAt = DateTimeOffset.Parse("2026-01-02T00:00:00Z"),
|
||||
UpdatedAt = DateTimeOffset.Parse("2026-01-02T01:00:00Z"),
|
||||
TurnCount = 1
|
||||
},
|
||||
new ChatConversationSummary
|
||||
{
|
||||
ConversationId = "conv-a",
|
||||
CreatedAt = DateTimeOffset.Parse("2026-01-01T00:00:00Z"),
|
||||
UpdatedAt = DateTimeOffset.Parse("2026-01-01T01:00:00Z"),
|
||||
TurnCount = 1
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
chatClient.ConversationById["conv-a"] = CreateConversation("conv-a", "Tenant-1", "User-1", "hello a");
|
||||
chatClient.ConversationById["conv-b"] = CreateConversation("conv-b", "Tenant-1", "User-1", "hello b");
|
||||
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IChatClient>(chatClient)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var options = new StellaOpsCliOptions();
|
||||
var command = AdviseChatCommandGroup.BuildExportCommand(
|
||||
services,
|
||||
options,
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None);
|
||||
var root = new RootCommand { command };
|
||||
|
||||
var output = new StringWriter();
|
||||
var original = Console.Out;
|
||||
try
|
||||
{
|
||||
Console.SetOut(output);
|
||||
await root.Parse("export --format json --tenant tenant-1 --user user-1").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(original);
|
||||
}
|
||||
|
||||
var json = output.ToString();
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var rootNode = document.RootElement;
|
||||
Assert.Equal(2, rootNode.GetProperty("conversationCount").GetInt32());
|
||||
|
||||
var conversations = rootNode.GetProperty("conversations");
|
||||
Assert.Equal("conv-a", conversations[0].GetProperty("conversationId").GetString());
|
||||
Assert.Equal("conv-b", conversations[1].GetProperty("conversationId").GetString());
|
||||
Assert.Equal(2, chatClient.GetConversationCalls.Count);
|
||||
}
|
||||
|
||||
private static ChatQueryResponse CreateQueryResponse(string responseId, string summary)
|
||||
{
|
||||
return new ChatQueryResponse
|
||||
{
|
||||
ResponseId = responseId,
|
||||
Intent = "triage",
|
||||
GeneratedAt = DateTimeOffset.Parse("2026-01-15T09:30:00Z"),
|
||||
Summary = summary,
|
||||
Confidence = new ChatConfidence
|
||||
{
|
||||
Overall = 0.9,
|
||||
EvidenceQuality = 0.8,
|
||||
ModelCertainty = 0.85
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static ChatConversationResponse CreateConversation(string id, string tenant, string user, string content)
|
||||
{
|
||||
return new ChatConversationResponse
|
||||
{
|
||||
ConversationId = id,
|
||||
TenantId = tenant,
|
||||
UserId = user,
|
||||
CreatedAt = DateTimeOffset.Parse("2026-01-15T09:30:00Z"),
|
||||
UpdatedAt = DateTimeOffset.Parse("2026-01-15T09:31:00Z"),
|
||||
Turns =
|
||||
[
|
||||
new ChatConversationTurn
|
||||
{
|
||||
TurnId = $"{id}-1",
|
||||
Role = "user",
|
||||
Content = content,
|
||||
Timestamp = DateTimeOffset.Parse("2026-01-15T09:30:00Z")
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FakeChatClient : IChatClient
|
||||
{
|
||||
public List<ChatQueryRequest> QueryCalls { get; } = [];
|
||||
|
||||
public List<string> GetConversationCalls { get; } = [];
|
||||
|
||||
public Func<ChatQueryRequest, ChatQueryResponse>? QueryResponseFactory { get; set; }
|
||||
|
||||
public ChatConversationListResponse ListResponse { get; set; } = new();
|
||||
|
||||
public Dictionary<string, ChatConversationResponse> ConversationById { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public Task<ChatQueryResponse> QueryAsync(ChatQueryRequest request, string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
QueryCalls.Add(request);
|
||||
return Task.FromResult(QueryResponseFactory?.Invoke(request) ?? CreateQueryResponse("resp-default", request.Query));
|
||||
}
|
||||
|
||||
public Task<ChatDoctorResponse> GetDoctorAsync(string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ChatSettingsResponse> GetSettingsAsync(string scope = "effective", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ChatSettingsResponse> UpdateSettingsAsync(ChatSettingsUpdateRequest request, string scope = "user", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task ClearSettingsAsync(string scope = "user", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ChatConversationListResponse> ListConversationsAsync(string? tenantId = null, string? userId = null, int? limit = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(ListResponse);
|
||||
|
||||
public Task<ChatConversationResponse> GetConversationAsync(string conversationId, string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
GetConversationCalls.Add(conversationId);
|
||||
return Task.FromResult(ConversationById[conversationId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
104
src/Cli/__Tests/StellaOps.Cli.AdviseParity.Tests/CompatStubs.cs
Normal file
104
src/Cli/__Tests/StellaOps.Cli.AdviseParity.Tests/CompatStubs.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using System.CommandLine;
|
||||
using StellaOps.Cli.Services.Chat;
|
||||
using StellaOps.Cli.Services.Models.Chat;
|
||||
|
||||
namespace StellaOps.Cli.Configuration
|
||||
{
|
||||
public sealed class StellaOpsCliOptions
|
||||
{
|
||||
public AdvisoryAiOptions AdvisoryAi { get; } = new();
|
||||
|
||||
public sealed class AdvisoryAiOptions
|
||||
{
|
||||
public bool Configured { get; set; } = true;
|
||||
|
||||
public bool HasConfiguredProvider() => Configured;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace StellaOps.Cli.Services.Chat
|
||||
{
|
||||
internal class ChatException : Exception
|
||||
{
|
||||
public ChatException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ChatGuardrailException : ChatException
|
||||
{
|
||||
public ChatGuardrailException(string message, ChatErrorResponse? errorResponse = null) : base(message)
|
||||
{
|
||||
ErrorResponse = errorResponse;
|
||||
}
|
||||
|
||||
public ChatErrorResponse? ErrorResponse { get; }
|
||||
}
|
||||
|
||||
internal sealed class ChatToolDeniedException : ChatException
|
||||
{
|
||||
public ChatToolDeniedException(string message, ChatErrorResponse? errorResponse = null) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ChatQuotaExceededException : ChatException
|
||||
{
|
||||
public ChatQuotaExceededException(string message, ChatErrorResponse? errorResponse = null) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ChatServiceUnavailableException : ChatException
|
||||
{
|
||||
public ChatServiceUnavailableException(string message, ChatErrorResponse? errorResponse = null) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ChatClient : IChatClient
|
||||
{
|
||||
public ChatClient(HttpClient httpClient, StellaOps.Cli.Configuration.StellaOpsCliOptions options)
|
||||
{
|
||||
}
|
||||
|
||||
public Task ClearSettingsAsync(string scope = "user", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ChatDoctorResponse> GetDoctorAsync(string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ChatConversationResponse> GetConversationAsync(string conversationId, string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ChatSettingsResponse> GetSettingsAsync(string scope = "effective", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ChatConversationListResponse> ListConversationsAsync(string? tenantId = null, string? userId = null, int? limit = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ChatQueryResponse> QueryAsync(ChatQueryRequest request, string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ChatSettingsResponse> UpdateSettingsAsync(ChatSettingsUpdateRequest request, string scope = "user", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
namespace System.CommandLine
|
||||
{
|
||||
// Compatibility shims for the API shape expected by AdviseChatCommandGroup.
|
||||
public static class OptionCompatExtensions
|
||||
{
|
||||
public static Option<T> SetDefaultValue<T>(this Option<T> option, T value)
|
||||
{
|
||||
return option;
|
||||
}
|
||||
|
||||
public static Option<T> FromAmong<T>(this Option<T> option, params T[] values)
|
||||
{
|
||||
return option;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
<RootNamespace>StellaOps.Cli.AdviseParity.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.CommandLine" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\..\StellaOps.Cli\Commands\Advise\AdviseChatCommandGroup.cs" Link="Commands\AdviseChatCommandGroup.cs" />
|
||||
<Compile Include="..\..\StellaOps.Cli\Commands\Advise\ChatRenderer.cs" Link="Commands\ChatRenderer.cs" />
|
||||
<Compile Include="..\..\StellaOps.Cli\Services\Chat\IChatClient.cs" Link="Services\IChatClient.cs" />
|
||||
<Compile Include="..\..\StellaOps.Cli\Services\Models\Chat\ChatModels.cs" Link="Services\ChatModels.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Cli.Commands.Compare;
|
||||
|
||||
namespace StellaOps.Cli.CompareOverlay.Tests;
|
||||
|
||||
public sealed class CompareVerificationOverlayBuilderIsolationTests : IDisposable
|
||||
{
|
||||
private readonly string _tempRoot;
|
||||
|
||||
public CompareVerificationOverlayBuilderIsolationTests()
|
||||
{
|
||||
_tempRoot = Path.Combine(Path.GetTempPath(), $"compare-overlay-isolated-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempRoot))
|
||||
{
|
||||
Directory.Delete(_tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_ParsesVerificationReportChecks()
|
||||
{
|
||||
var reportPath = Path.Combine(_tempRoot, "verify-report.json");
|
||||
await File.WriteAllTextAsync(
|
||||
reportPath,
|
||||
"""
|
||||
{
|
||||
"overallStatus": "PASSED_WITH_WARNINGS",
|
||||
"checks": [
|
||||
{ "name": "checksum:inputs/sbom.cdx.json", "passed": true, "message": "Hash matches", "severity": "info" },
|
||||
{ "name": "dsse:inputs/sbom.cdx.json.dsse.json", "passed": false, "message": "No signatures found", "severity": "error" }
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
var overlay = await CompareVerificationOverlayBuilder.BuildAsync(
|
||||
reportPath,
|
||||
reverifyBundlePath: null,
|
||||
determinismManifestPath: null,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(overlay);
|
||||
Assert.Equal("verification-report", overlay.Source);
|
||||
Assert.Equal("FAILED", overlay.OverallStatus);
|
||||
|
||||
var artifact = Assert.Single(overlay.Artifacts);
|
||||
Assert.Equal("inputs/sbom.cdx.json", artifact.Artifact);
|
||||
Assert.Equal("pass", artifact.HashStatus);
|
||||
Assert.Equal("fail", artifact.SignatureStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_ReverifyBundle_ComputesHashAndSignatureStatus()
|
||||
{
|
||||
var bundleDir = Path.Combine(_tempRoot, "bundle");
|
||||
var inputsDir = Path.Combine(bundleDir, "inputs");
|
||||
Directory.CreateDirectory(inputsDir);
|
||||
|
||||
var artifactPath = Path.Combine(inputsDir, "sbom.cdx.json");
|
||||
await File.WriteAllTextAsync(artifactPath, """{"bomFormat":"CycloneDX"}""");
|
||||
var digest = ComputeSha256Hex(await File.ReadAllTextAsync(artifactPath));
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(bundleDir, "manifest.json"),
|
||||
$$"""
|
||||
{
|
||||
"bundle": {
|
||||
"artifacts": [
|
||||
{
|
||||
"path": "inputs/sbom.cdx.json",
|
||||
"digest": "sha256:{{digest}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(inputsDir, "sbom.cdx.json.dsse.json"),
|
||||
"""
|
||||
{
|
||||
"payloadType": "application/vnd.in-toto+json",
|
||||
"signatures": [
|
||||
{ "keyid": "test-key", "sig": "dGVzdA==" }
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
var overlay = await CompareVerificationOverlayBuilder.BuildAsync(
|
||||
verificationReportPath: null,
|
||||
reverifyBundlePath: bundleDir,
|
||||
determinismManifestPath: null,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(overlay);
|
||||
Assert.True(overlay.Reverified);
|
||||
Assert.Equal("reverify-bundle", overlay.Source);
|
||||
|
||||
var artifact = Assert.Single(overlay.Artifacts);
|
||||
Assert.Equal("inputs/sbom.cdx.json", artifact.Artifact);
|
||||
Assert.Equal("pass", artifact.HashStatus);
|
||||
Assert.Equal("pass", artifact.SignatureStatus);
|
||||
Assert.Equal("PASSED", overlay.OverallStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_AttachesDeterminismManifestSummary()
|
||||
{
|
||||
var manifestPath = Path.Combine(_tempRoot, "determinism.json");
|
||||
await File.WriteAllTextAsync(
|
||||
manifestPath,
|
||||
"""
|
||||
{
|
||||
"overall_score": 0.973,
|
||||
"thresholds": {
|
||||
"overall_min": 0.950
|
||||
},
|
||||
"images": [
|
||||
{ "digest": "sha256:aaa" },
|
||||
{ "digest": "sha256:bbb" }
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
var overlay = await CompareVerificationOverlayBuilder.BuildAsync(
|
||||
verificationReportPath: null,
|
||||
reverifyBundlePath: null,
|
||||
determinismManifestPath: manifestPath,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(overlay);
|
||||
Assert.NotNull(overlay.Determinism);
|
||||
Assert.Equal("determinism-manifest", overlay.Source);
|
||||
Assert.Equal(0.973, overlay.Determinism.OverallScore);
|
||||
Assert.Equal(0.950, overlay.Determinism.Threshold);
|
||||
Assert.Equal("pass", overlay.Determinism.Status);
|
||||
Assert.Equal(2, overlay.Determinism.ImageCount);
|
||||
Assert.Equal("PASSED", overlay.OverallStatus);
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
<RootNamespace>StellaOps.Cli.CompareOverlay.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\..\StellaOps.Cli\Commands\Compare\CompareVerificationOverlayBuilder.cs" Link="Compare\CompareVerificationOverlayBuilder.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -2,12 +2,17 @@
|
||||
// Licensed under the BUSL-1.1 license.
|
||||
|
||||
using System;
|
||||
using System.CommandLine;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Commands.Advise;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Chat;
|
||||
using StellaOps.Cli.Services.Models.Chat;
|
||||
using Xunit;
|
||||
|
||||
@@ -252,6 +257,124 @@ public sealed class AdviseChatCommandTests
|
||||
Assert.Contains("Query: What vulnerabilities affect my image?", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderConversationExport_Json_RendersConversationsPayload()
|
||||
{
|
||||
var export = CreateSampleConversationExport();
|
||||
var sb = new StringBuilder();
|
||||
await using var writer = new StringWriter(sb);
|
||||
|
||||
await ChatRenderer.RenderConversationExportAsync(export, ChatOutputFormat.Json, writer, CancellationToken.None);
|
||||
var output = sb.ToString();
|
||||
|
||||
Assert.Contains("\"conversationCount\"", output);
|
||||
Assert.Contains("\"conv-001\"", output);
|
||||
Assert.Contains("\"turns\"", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAskCommand_AllowsFileOptionWithoutQuery()
|
||||
{
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var command = AdviseChatCommandGroup.BuildAskCommand(
|
||||
services,
|
||||
new StellaOpsCliOptions(),
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None);
|
||||
|
||||
var parseResult = command.Parse("--file queries.jsonl");
|
||||
Assert.Empty(parseResult.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildExportCommand_HasExpectedOptions()
|
||||
{
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var command = AdviseChatCommandGroup.BuildExportCommand(
|
||||
services,
|
||||
new StellaOpsCliOptions(),
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("export", command.Name);
|
||||
Assert.Contains(command.Options, static option => option.Name == "--conversation-id");
|
||||
Assert.Contains(command.Options, static option => option.Name == "--limit");
|
||||
Assert.Contains(command.Options, static option => option.Name == "--format");
|
||||
Assert.Contains(command.Options, static option => option.Name == "--output");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AskCommand_FileBatch_InvokesClientAndWritesJson()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"advise-batch-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var batchPath = Path.Combine(tempDir, "queries.jsonl");
|
||||
var outputPath = Path.Combine(tempDir, "output.json");
|
||||
await File.WriteAllTextAsync(batchPath, """
|
||||
{"query":"What changed in stage?"}
|
||||
"List high severity CVEs"
|
||||
""");
|
||||
|
||||
var fakeClient = new FakeChatClient();
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IChatClient>(fakeClient)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var options = new StellaOpsCliOptions
|
||||
{
|
||||
AdvisoryAi = new StellaOpsCliAdvisoryAiOptions
|
||||
{
|
||||
Enabled = true,
|
||||
OpenAi = new StellaOpsCliLlmProviderOptions { ApiKey = "test-key" }
|
||||
}
|
||||
};
|
||||
|
||||
var command = AdviseChatCommandGroup.BuildAskCommand(
|
||||
services,
|
||||
options,
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None);
|
||||
|
||||
var parseResult = command.Parse($"--file \"{batchPath}\" --format json --output \"{outputPath}\"");
|
||||
var exitCode = await parseResult.InvokeAsync();
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.Equal(2, fakeClient.Queries.Count);
|
||||
using var outputJson = JsonDocument.Parse(await File.ReadAllTextAsync(outputPath));
|
||||
Assert.Equal(2, outputJson.RootElement.GetProperty("count").GetInt32());
|
||||
Assert.Contains("What changed in stage?", outputJson.RootElement.GetRawText());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportCommand_UsesConversationEndpointsAndWritesJson()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"advise-export-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var outputPath = Path.Combine(tempDir, "conversation-export.json");
|
||||
|
||||
var fakeClient = new FakeChatClient();
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IChatClient>(fakeClient)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var command = AdviseChatCommandGroup.BuildExportCommand(
|
||||
services,
|
||||
new StellaOpsCliOptions(),
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None);
|
||||
|
||||
var parseResult = command.Parse($"--tenant tenant-001 --user user-001 --format json --output \"{outputPath}\"");
|
||||
var exitCode = await parseResult.InvokeAsync();
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.Equal(1, fakeClient.ListCalls);
|
||||
Assert.Equal(1, fakeClient.GetCalls);
|
||||
|
||||
var json = await File.ReadAllTextAsync(outputPath);
|
||||
Assert.Contains("\"conversationCount\": 1", json);
|
||||
Assert.Contains("\"conversationId\": \"conv-001\"", json);
|
||||
}
|
||||
|
||||
private static ChatQueryResponse CreateSampleQueryResponse()
|
||||
{
|
||||
return new ChatQueryResponse
|
||||
@@ -401,4 +524,136 @@ public sealed class AdviseChatCommandTests
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static ChatConversationExport CreateSampleConversationExport()
|
||||
{
|
||||
return new ChatConversationExport
|
||||
{
|
||||
GeneratedAt = DateTimeOffset.Parse("2026-02-08T00:00:00Z"),
|
||||
TenantId = "tenant-001",
|
||||
UserId = "user-001",
|
||||
ConversationCount = 1,
|
||||
Conversations =
|
||||
[
|
||||
new ChatConversationResponse
|
||||
{
|
||||
ConversationId = "conv-001",
|
||||
TenantId = "tenant-001",
|
||||
UserId = "user-001",
|
||||
CreatedAt = DateTimeOffset.Parse("2026-02-08T00:00:00Z"),
|
||||
UpdatedAt = DateTimeOffset.Parse("2026-02-08T00:01:00Z"),
|
||||
Turns =
|
||||
[
|
||||
new ChatConversationTurn
|
||||
{
|
||||
TurnId = "turn-1",
|
||||
Role = "user",
|
||||
Content = "What changed?",
|
||||
Timestamp = DateTimeOffset.Parse("2026-02-08T00:00:10Z")
|
||||
},
|
||||
new ChatConversationTurn
|
||||
{
|
||||
TurnId = "turn-2",
|
||||
Role = "assistant",
|
||||
Content = "Two vulnerabilities were remediated.",
|
||||
Timestamp = DateTimeOffset.Parse("2026-02-08T00:00:20Z")
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FakeChatClient : IChatClient
|
||||
{
|
||||
public List<string> Queries { get; } = [];
|
||||
|
||||
public int ListCalls { get; private set; }
|
||||
|
||||
public int GetCalls { get; private set; }
|
||||
|
||||
public Task<ChatQueryResponse> QueryAsync(
|
||||
ChatQueryRequest request,
|
||||
string? tenantId = null,
|
||||
string? userId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Queries.Add(request.Query);
|
||||
return Task.FromResult(new ChatQueryResponse
|
||||
{
|
||||
ResponseId = $"resp-{Queries.Count}",
|
||||
Intent = "test",
|
||||
GeneratedAt = DateTimeOffset.Parse("2026-02-08T00:00:00Z"),
|
||||
Summary = $"Handled {request.Query}",
|
||||
Confidence = new ChatConfidence
|
||||
{
|
||||
Overall = 1,
|
||||
EvidenceQuality = 1,
|
||||
ModelCertainty = 1
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public Task<ChatDoctorResponse> GetDoctorAsync(string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ChatSettingsResponse> GetSettingsAsync(string scope = "effective", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ChatSettingsResponse> UpdateSettingsAsync(ChatSettingsUpdateRequest request, string scope = "user", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task ClearSettingsAsync(string scope = "user", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ChatConversationListResponse> ListConversationsAsync(string? tenantId = null, string? userId = null, int? limit = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ListCalls++;
|
||||
return Task.FromResult(new ChatConversationListResponse
|
||||
{
|
||||
TotalCount = 1,
|
||||
Conversations =
|
||||
[
|
||||
new ChatConversationSummary
|
||||
{
|
||||
ConversationId = "conv-001",
|
||||
CreatedAt = DateTimeOffset.Parse("2026-02-08T00:00:00Z"),
|
||||
UpdatedAt = DateTimeOffset.Parse("2026-02-08T00:01:00Z"),
|
||||
TurnCount = 2,
|
||||
Preview = "What changed?"
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
public Task<ChatConversationResponse> GetConversationAsync(string conversationId, string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
GetCalls++;
|
||||
return Task.FromResult(new ChatConversationResponse
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
TenantId = tenantId ?? "tenant-001",
|
||||
UserId = userId ?? "user-001",
|
||||
CreatedAt = DateTimeOffset.Parse("2026-02-08T00:00:00Z"),
|
||||
UpdatedAt = DateTimeOffset.Parse("2026-02-08T00:01:00Z"),
|
||||
Turns =
|
||||
[
|
||||
new ChatConversationTurn
|
||||
{
|
||||
TurnId = "turn-1",
|
||||
Role = "user",
|
||||
Content = "What changed?",
|
||||
Timestamp = DateTimeOffset.Parse("2026-02-08T00:00:10Z")
|
||||
},
|
||||
new ChatConversationTurn
|
||||
{
|
||||
TurnId = "turn-2",
|
||||
Role = "assistant",
|
||||
Content = "Two updates were deployed.",
|
||||
Timestamp = DateTimeOffset.Parse("2026-02-08T00:00:20Z")
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,17 @@ public sealed class CommandFactoryTests
|
||||
Assert.Contains(sbom.Subcommands, command => string.Equals(command.Name, "upload", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ExposesAdviseExportCommand()
|
||||
{
|
||||
using var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.None));
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var root = CommandFactory.Create(services, new StellaOpsCliOptions(), CancellationToken.None, loggerFactory);
|
||||
|
||||
var advise = Assert.Single(root.Subcommands, command => string.Equals(command.Name, "advise", StringComparison.Ordinal));
|
||||
Assert.Contains(advise.Subcommands, command => string.Equals(command.Name, "export", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ExposesTimestampCommands()
|
||||
{
|
||||
|
||||
@@ -204,6 +204,51 @@ public class CompareCommandTests
|
||||
Assert.NotNull(backendUrlOption);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiffCommand_HasVerificationReportOption()
|
||||
{
|
||||
// Arrange
|
||||
var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken);
|
||||
var diffCommand = command.Subcommands.First(c => c.Name == "diff");
|
||||
|
||||
// Act
|
||||
var option = diffCommand.Options.FirstOrDefault(o =>
|
||||
o.Name == "--verification-report" || o.Aliases.Contains("--verification-report"));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(option);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiffCommand_HasReverifyBundleOption()
|
||||
{
|
||||
// Arrange
|
||||
var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken);
|
||||
var diffCommand = command.Subcommands.First(c => c.Name == "diff");
|
||||
|
||||
// Act
|
||||
var option = diffCommand.Options.FirstOrDefault(o =>
|
||||
o.Name == "--reverify-bundle" || o.Aliases.Contains("--reverify-bundle"));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(option);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiffCommand_HasDeterminismManifestOption()
|
||||
{
|
||||
// Arrange
|
||||
var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken);
|
||||
var diffCommand = command.Subcommands.First(c => c.Name == "diff");
|
||||
|
||||
// Act
|
||||
var option = diffCommand.Options.FirstOrDefault(o =>
|
||||
o.Name == "--determinism-manifest" || o.Aliases.Contains("--determinism-manifest"));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(option);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Parse Tests
|
||||
@@ -307,7 +352,7 @@ public class CompareCommandTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareDiff_FailsWithoutBase()
|
||||
public void CompareDiff_ParsesWithoutBase()
|
||||
{
|
||||
// Arrange
|
||||
var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken);
|
||||
@@ -317,7 +362,22 @@ public class CompareCommandTests
|
||||
var result = root.Parse("compare diff -t sha256:def456");
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(result.Errors);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareDiff_ParsesWithVerificationOverlayOptions()
|
||||
{
|
||||
// Arrange
|
||||
var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken);
|
||||
var root = new RootCommand { command };
|
||||
|
||||
// Act
|
||||
var result = root.Parse(
|
||||
"compare diff -b sha256:abc123 -t sha256:def456 --verification-report verify.json --reverify-bundle ./bundle --determinism-manifest determinism.json");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Cli.Commands.Compare;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class CompareVerificationOverlayBuilderTests : IDisposable
|
||||
{
|
||||
private readonly string _tempRoot;
|
||||
|
||||
public CompareVerificationOverlayBuilderTests()
|
||||
{
|
||||
_tempRoot = Path.Combine(Path.GetTempPath(), $"compare-overlay-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempRoot))
|
||||
{
|
||||
Directory.Delete(_tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_ParsesVerificationReportChecks()
|
||||
{
|
||||
// Arrange
|
||||
var reportPath = Path.Combine(_tempRoot, "verify-report.json");
|
||||
await File.WriteAllTextAsync(
|
||||
reportPath,
|
||||
"""
|
||||
{
|
||||
"overallStatus": "PASSED_WITH_WARNINGS",
|
||||
"checks": [
|
||||
{ "name": "checksum:inputs/sbom.cdx.json", "passed": true, "message": "Hash matches", "severity": "info" },
|
||||
{ "name": "dsse:inputs/sbom.cdx.json.dsse.json", "passed": false, "message": "No signatures found", "severity": "warning" }
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
var overlay = await CompareVerificationOverlayBuilder.BuildAsync(
|
||||
reportPath,
|
||||
reverifyBundlePath: null,
|
||||
determinismManifestPath: null,
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(overlay);
|
||||
Assert.Equal("verification-report", overlay.Source);
|
||||
Assert.Equal("PASSED_WITH_WARNINGS", overlay.OverallStatus);
|
||||
Assert.Single(overlay.Artifacts);
|
||||
|
||||
var artifact = Assert.Single(overlay.Artifacts);
|
||||
Assert.Equal("inputs/sbom.cdx.json", artifact.Artifact);
|
||||
Assert.Equal("pass", artifact.HashStatus);
|
||||
Assert.Equal("warning", artifact.SignatureStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_ReverifyBundle_ComputesHashAndSignatureStatus()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = Path.Combine(_tempRoot, "bundle");
|
||||
var inputsDir = Path.Combine(bundleDir, "inputs");
|
||||
Directory.CreateDirectory(inputsDir);
|
||||
|
||||
var artifactPath = Path.Combine(inputsDir, "sbom.cdx.json");
|
||||
await File.WriteAllTextAsync(artifactPath, """{"bomFormat":"CycloneDX"}""");
|
||||
var digest = ComputeSha256Hex(await File.ReadAllTextAsync(artifactPath));
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(bundleDir, "manifest.json"),
|
||||
$$"""
|
||||
{
|
||||
"bundle": {
|
||||
"artifacts": [
|
||||
{
|
||||
"path": "inputs/sbom.cdx.json",
|
||||
"digest": "sha256:{{digest}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(inputsDir, "sbom.cdx.json.dsse.json"),
|
||||
"""
|
||||
{
|
||||
"payloadType": "application/vnd.in-toto+json",
|
||||
"signatures": [
|
||||
{ "keyid": "test-key", "sig": "dGVzdA==" }
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
var overlay = await CompareVerificationOverlayBuilder.BuildAsync(
|
||||
verificationReportPath: null,
|
||||
reverifyBundlePath: bundleDir,
|
||||
determinismManifestPath: null,
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(overlay);
|
||||
Assert.True(overlay.Reverified);
|
||||
Assert.Equal("reverify-bundle", overlay.Source);
|
||||
Assert.Single(overlay.Artifacts);
|
||||
|
||||
var artifact = Assert.Single(overlay.Artifacts);
|
||||
Assert.Equal("inputs/sbom.cdx.json", artifact.Artifact);
|
||||
Assert.Equal("pass", artifact.HashStatus);
|
||||
Assert.Equal("pass", artifact.SignatureStatus);
|
||||
Assert.Equal("PASSED", overlay.OverallStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_AttachesDeterminismManifestSummary()
|
||||
{
|
||||
// Arrange
|
||||
var manifestPath = Path.Combine(_tempRoot, "determinism.json");
|
||||
await File.WriteAllTextAsync(
|
||||
manifestPath,
|
||||
"""
|
||||
{
|
||||
"overall_score": 0.973,
|
||||
"thresholds": {
|
||||
"overall_min": 0.950
|
||||
},
|
||||
"images": [
|
||||
{ "digest": "sha256:aaa" },
|
||||
{ "digest": "sha256:bbb" }
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
var overlay = await CompareVerificationOverlayBuilder.BuildAsync(
|
||||
verificationReportPath: null,
|
||||
reverifyBundlePath: null,
|
||||
determinismManifestPath: manifestPath,
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(overlay);
|
||||
Assert.NotNull(overlay.Determinism);
|
||||
Assert.Equal("determinism-manifest", overlay.Source);
|
||||
Assert.Equal(0.973, overlay.Determinism.OverallScore);
|
||||
Assert.Equal(0.950, overlay.Determinism.Threshold);
|
||||
Assert.Equal("pass", overlay.Determinism.Status);
|
||||
Assert.Equal(2, overlay.Determinism.ImageCount);
|
||||
Assert.Equal("PASSED", overlay.OverallStatus);
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EvidenceReferrerCommandTests.cs
|
||||
// Sprint: SPRINT_20260208_032_Cli_oci_referrers_for_evidence_storage
|
||||
// Description: Unit tests for push-referrer and list-referrers CLI commands.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EvidenceReferrerCommandTests
|
||||
{
|
||||
// ── ParseImageReference ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ParseImageReference_WithDigest_ParsesCorrectly()
|
||||
{
|
||||
var result = EvidenceReferrerCommands.ParseImageReference(
|
||||
"registry.example.com/repo/image@sha256:abcdef1234567890");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Registry.Should().Be("registry.example.com");
|
||||
result.Repository.Should().Be("repo/image");
|
||||
result.Digest.Should().Be("sha256:abcdef1234567890");
|
||||
result.Tag.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseImageReference_NoSlash_ReturnsNull()
|
||||
{
|
||||
var result = EvidenceReferrerCommands.ParseImageReference("noslash");
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseImageReference_WithTag_ParsesCorrectly()
|
||||
{
|
||||
var result = EvidenceReferrerCommands.ParseImageReference(
|
||||
"registry.example.com/repo:latest");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Registry.Should().Be("registry.example.com");
|
||||
result.Repository.Should().Be("repo");
|
||||
result.Tag.Should().Be("latest");
|
||||
}
|
||||
|
||||
// ── ParseAnnotations ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ParseAnnotations_Null_ReturnsEmpty()
|
||||
{
|
||||
var result = EvidenceReferrerCommands.ParseAnnotations(null);
|
||||
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAnnotations_ValidPairs_ParsesAll()
|
||||
{
|
||||
var result = EvidenceReferrerCommands.ParseAnnotations(
|
||||
["key1=value1", "key2=value2"]);
|
||||
|
||||
result.Should().HaveCount(2);
|
||||
result["key1"].Should().Be("value1");
|
||||
result["key2"].Should().Be("value2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAnnotations_NoEquals_Skipped()
|
||||
{
|
||||
var result = EvidenceReferrerCommands.ParseAnnotations(
|
||||
["valid=yes", "noequalssign"]);
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result["valid"].Should().Be("yes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAnnotations_ValueWithEquals_PreservesFullValue()
|
||||
{
|
||||
var result = EvidenceReferrerCommands.ParseAnnotations(
|
||||
["key=value=with=equals"]);
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result["key"].Should().Be("value=with=equals");
|
||||
}
|
||||
|
||||
// ── BuildReferrerManifest ──────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BuildReferrerManifest_ProducesValidOciManifest()
|
||||
{
|
||||
var manifest = EvidenceReferrerCommands.BuildReferrerManifest(
|
||||
"application/vnd.stellaops.verdict.attestation.v1+json",
|
||||
"sha256:deadbeef",
|
||||
1024,
|
||||
"sha256:cafebabe",
|
||||
new Dictionary<string, string> { ["key"] = "value" });
|
||||
|
||||
manifest.SchemaVersion.Should().Be(2);
|
||||
manifest.MediaType.Should().Be("application/vnd.oci.image.manifest.v2+json");
|
||||
manifest.ArtifactType.Should().Be("application/vnd.stellaops.verdict.attestation.v1+json");
|
||||
manifest.Layers.Should().HaveCount(1);
|
||||
manifest.Layers[0].Digest.Should().Be("sha256:deadbeef");
|
||||
manifest.Layers[0].Size.Should().Be(1024);
|
||||
manifest.Subject.Should().NotBeNull();
|
||||
manifest.Subject!.Digest.Should().Be("sha256:cafebabe");
|
||||
manifest.Config.Should().NotBeNull();
|
||||
manifest.Config!.MediaType.Should().Be("application/vnd.oci.empty.v1+json");
|
||||
manifest.Annotations.Should().ContainKey("key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildReferrerManifest_NoAnnotations_NullAnnotations()
|
||||
{
|
||||
var manifest = EvidenceReferrerCommands.BuildReferrerManifest(
|
||||
"test/type", "sha256:abc", 100, "sha256:def",
|
||||
new Dictionary<string, string>());
|
||||
|
||||
manifest.Annotations.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildReferrerManifest_SerializesToValidJson()
|
||||
{
|
||||
var manifest = EvidenceReferrerCommands.BuildReferrerManifest(
|
||||
OciMediaTypes.SbomAttestation, "sha256:1234", 2048, "sha256:5678",
|
||||
new Dictionary<string, string>());
|
||||
|
||||
var json = JsonSerializer.Serialize(manifest);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("schemaVersion").GetInt32().Should().Be(2);
|
||||
doc.RootElement.GetProperty("artifactType").GetString()
|
||||
.Should().Be(OciMediaTypes.SbomAttestation);
|
||||
doc.RootElement.GetProperty("layers").GetArrayLength().Should().Be(1);
|
||||
}
|
||||
|
||||
// ── HandleOfflinePush ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void HandleOfflinePush_ReturnsZero()
|
||||
{
|
||||
var exitCode = EvidenceReferrerCommands.HandleOfflinePush(
|
||||
"registry/repo@sha256:abc",
|
||||
OciMediaTypes.VerdictAttestation,
|
||||
"/tmp/artifact.json",
|
||||
"sha256:deadbeef",
|
||||
1024,
|
||||
new Dictionary<string, string>());
|
||||
|
||||
exitCode.Should().Be(0);
|
||||
}
|
||||
|
||||
// ── HandleOfflineList ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void HandleOfflineList_TableFormat_ReturnsZero()
|
||||
{
|
||||
var exitCode = EvidenceReferrerCommands.HandleOfflineList(
|
||||
"registry/repo@sha256:abc", null, null, "table");
|
||||
|
||||
exitCode.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleOfflineList_JsonFormat_ReturnsZero()
|
||||
{
|
||||
var exitCode = EvidenceReferrerCommands.HandleOfflineList(
|
||||
"registry/repo@sha256:abc", null, null, "json");
|
||||
|
||||
exitCode.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleOfflineList_FilterByArtifactType_FiltersResults()
|
||||
{
|
||||
// Simulate by checking the offline handler doesn't crash with filter
|
||||
var exitCode = EvidenceReferrerCommands.HandleOfflineList(
|
||||
"registry/repo@sha256:abc",
|
||||
null,
|
||||
OciMediaTypes.VerdictAttestation,
|
||||
"table");
|
||||
|
||||
exitCode.Should().Be(0);
|
||||
}
|
||||
|
||||
// ── ExecutePushReferrerAsync ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ExecutePushReferrer_FileNotFound_ReturnsError()
|
||||
{
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var logger = NullLogger.Instance;
|
||||
|
||||
var exitCode = await EvidenceReferrerCommands.ExecutePushReferrerAsync(
|
||||
services,
|
||||
"registry.example.com/repo@sha256:abc",
|
||||
OciMediaTypes.VerdictAttestation,
|
||||
"/nonexistent/file.json",
|
||||
null,
|
||||
offline: false,
|
||||
verbose: false,
|
||||
logger,
|
||||
CancellationToken.None);
|
||||
|
||||
exitCode.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecutePushReferrer_OfflineMode_ReturnsSuccess()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
try
|
||||
{
|
||||
var filePath = Path.Combine(tempDir, "evidence.json");
|
||||
await File.WriteAllTextAsync(filePath, """{"type":"test"}""");
|
||||
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var logger = NullLogger.Instance;
|
||||
|
||||
var exitCode = await EvidenceReferrerCommands.ExecutePushReferrerAsync(
|
||||
services,
|
||||
"registry.example.com/repo@sha256:abc",
|
||||
OciMediaTypes.VerdictAttestation,
|
||||
filePath,
|
||||
["org.opencontainers.image.created=2026-01-01"],
|
||||
offline: true,
|
||||
verbose: false,
|
||||
logger,
|
||||
CancellationToken.None);
|
||||
|
||||
exitCode.Should().Be(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecutePushReferrer_NoOciClient_ReturnsError()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
try
|
||||
{
|
||||
var filePath = Path.Combine(tempDir, "evidence.json");
|
||||
await File.WriteAllTextAsync(filePath, """{"type":"test"}""");
|
||||
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var logger = NullLogger.Instance;
|
||||
|
||||
var exitCode = await EvidenceReferrerCommands.ExecutePushReferrerAsync(
|
||||
services,
|
||||
"registry.example.com/repo@sha256:abc",
|
||||
OciMediaTypes.VerdictAttestation,
|
||||
filePath,
|
||||
null,
|
||||
offline: false,
|
||||
verbose: false,
|
||||
logger,
|
||||
CancellationToken.None);
|
||||
|
||||
exitCode.Should().Be(1); // No IOciRegistryClient registered
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecutePushReferrer_InvalidImageRef_ReturnsError()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
try
|
||||
{
|
||||
var filePath = Path.Combine(tempDir, "evidence.json");
|
||||
await File.WriteAllTextAsync(filePath, """{"type":"test"}""");
|
||||
|
||||
// Register a mock OCI client so we get past the null check
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IOciRegistryClient>(new FakeOciClient())
|
||||
.BuildServiceProvider();
|
||||
var logger = NullLogger.Instance;
|
||||
|
||||
var exitCode = await EvidenceReferrerCommands.ExecutePushReferrerAsync(
|
||||
services,
|
||||
"noslash", // invalid — no registry/repo split
|
||||
OciMediaTypes.VerdictAttestation,
|
||||
filePath,
|
||||
null,
|
||||
offline: false,
|
||||
verbose: false,
|
||||
logger,
|
||||
CancellationToken.None);
|
||||
|
||||
exitCode.Should().Be(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
// ── ExecuteListReferrersAsync ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteListReferrers_OfflineMode_ReturnsSuccess()
|
||||
{
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var logger = NullLogger.Instance;
|
||||
|
||||
var exitCode = await EvidenceReferrerCommands.ExecuteListReferrersAsync(
|
||||
services,
|
||||
"registry.example.com/repo@sha256:abc",
|
||||
null, null, "table",
|
||||
offline: true,
|
||||
verbose: false,
|
||||
logger,
|
||||
CancellationToken.None);
|
||||
|
||||
exitCode.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteListReferrers_NoOciClient_ReturnsError()
|
||||
{
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var logger = NullLogger.Instance;
|
||||
|
||||
var exitCode = await EvidenceReferrerCommands.ExecuteListReferrersAsync(
|
||||
services,
|
||||
"registry.example.com/repo@sha256:abc",
|
||||
null, null, "table",
|
||||
offline: false,
|
||||
verbose: false,
|
||||
logger,
|
||||
CancellationToken.None);
|
||||
|
||||
exitCode.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteListReferrers_InvalidImageRef_ReturnsError()
|
||||
{
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IOciRegistryClient>(new FakeOciClient())
|
||||
.BuildServiceProvider();
|
||||
var logger = NullLogger.Instance;
|
||||
|
||||
var exitCode = await EvidenceReferrerCommands.ExecuteListReferrersAsync(
|
||||
services,
|
||||
"noslash",
|
||||
null, null, "table",
|
||||
offline: false,
|
||||
verbose: false,
|
||||
logger,
|
||||
CancellationToken.None);
|
||||
|
||||
exitCode.Should().Be(1);
|
||||
}
|
||||
|
||||
// ── Fake OCI client for testing ────────────────────────────────────
|
||||
|
||||
private sealed class FakeOciClient : IOciRegistryClient
|
||||
{
|
||||
public Task<string> ResolveDigestAsync(OciImageReference reference, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult("sha256:fakedigest0000000000000000000000000000000000000000000000000000");
|
||||
|
||||
public Task<string> ResolveTagAsync(string registry, string repository, string tag, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult("sha256:fakedigest0000000000000000000000000000000000000000000000000000");
|
||||
|
||||
public Task<OciReferrersResponse> ListReferrersAsync(OciImageReference reference, string digest, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new OciReferrersResponse());
|
||||
|
||||
public Task<IReadOnlyList<OciReferrerDescriptor>> GetReferrersAsync(string registry, string repository, string digest, string? artifactType = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<OciReferrerDescriptor>>([]);
|
||||
|
||||
public Task<OciManifest> GetManifestAsync(OciImageReference reference, string digest, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new OciManifest());
|
||||
|
||||
public Task<byte[]> GetBlobAsync(OciImageReference reference, string digest, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Array.Empty<byte>());
|
||||
}
|
||||
}
|
||||
@@ -237,6 +237,17 @@ public class Sprint3500_0004_0001_CommandTests
|
||||
Assert.NotNull(resolveCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownsCommand_HasExportSubcommand()
|
||||
{
|
||||
// Act
|
||||
var command = UnknownsCommandGroup.BuildUnknownsCommand(_services, _verboseOption, _cancellationToken);
|
||||
var exportCommand = command.Subcommands.FirstOrDefault(c => c.Name == "export");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(exportCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownsList_ParsesWithBandOption()
|
||||
{
|
||||
@@ -279,6 +290,34 @@ public class Sprint3500_0004_0001_CommandTests
|
||||
Assert.NotEmpty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownsExport_ParsesSchemaVersionOption()
|
||||
{
|
||||
// Arrange
|
||||
var command = UnknownsCommandGroup.BuildUnknownsCommand(_services, _verboseOption, _cancellationToken);
|
||||
var root = new RootCommand { command };
|
||||
|
||||
// Act
|
||||
var result = root.Parse("unknowns export --format json --schema-version unknowns.export.v2");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownsExport_InvalidFormat_ReturnsParseError()
|
||||
{
|
||||
// Arrange
|
||||
var command = UnknownsCommandGroup.BuildUnknownsCommand(_services, _verboseOption, _cancellationToken);
|
||||
var root = new RootCommand { command };
|
||||
|
||||
// Act
|
||||
var result = root.Parse("unknowns export --format xml");
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(result.Errors);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ScanGraphCommandGroup Tests
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -259,6 +260,135 @@ public class UnknownsGreyQueueCommandTests
|
||||
return value;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownsExport_Json_IncludesSchemaEnvelopeAndDeterministicMetadata()
|
||||
{
|
||||
// Arrange
|
||||
SetupPolicyUnknownsResponse("""
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"packageId": "pkg:npm/a",
|
||||
"packageVersion": "1.0.0",
|
||||
"band": "warm",
|
||||
"score": 65.5,
|
||||
"reasonCode": "Reachability",
|
||||
"reasonCodeShort": "U-RCH",
|
||||
"firstSeenAt": "2026-01-10T12:00:00Z",
|
||||
"lastEvaluatedAt": "2026-01-15T08:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "22222222-2222-2222-2222-222222222222",
|
||||
"packageId": "pkg:npm/b",
|
||||
"packageVersion": "2.0.0",
|
||||
"band": "hot",
|
||||
"score": 90.0,
|
||||
"reasonCode": "PolicyConflict",
|
||||
"reasonCodeShort": "U-POL",
|
||||
"firstSeenAt": "2026-01-09T12:00:00Z",
|
||||
"lastEvaluatedAt": "2026-01-15T09:30:00Z"
|
||||
}
|
||||
],
|
||||
"totalCount": 2
|
||||
}
|
||||
""");
|
||||
|
||||
var command = UnknownsCommandGroup.BuildUnknownsCommand(_services, new Option<bool>("--verbose"), CancellationToken.None);
|
||||
var root = new RootCommand { command };
|
||||
using var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("unknowns export --format json --schema-version unknowns.export.v2").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, exitCode);
|
||||
using var doc = JsonDocument.Parse(writer.ToString());
|
||||
var rootElement = doc.RootElement;
|
||||
|
||||
Assert.Equal("unknowns.export.v2", rootElement.GetProperty("schemaVersion").GetString());
|
||||
Assert.Equal(2, rootElement.GetProperty("itemCount").GetInt32());
|
||||
|
||||
var exportedAt = rootElement.GetProperty("exportedAt").GetDateTimeOffset();
|
||||
Assert.Equal(DateTimeOffset.Parse("2026-01-15T09:30:00+00:00"), exportedAt);
|
||||
|
||||
var items = rootElement.GetProperty("items");
|
||||
Assert.Equal(2, items.GetArrayLength());
|
||||
Assert.Equal("hot", items[0].GetProperty("band").GetString());
|
||||
Assert.Equal("warm", items[1].GetProperty("band").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownsExport_Csv_IncludesSchemaHeaderLine()
|
||||
{
|
||||
// Arrange
|
||||
SetupPolicyUnknownsResponse("""
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "33333333-3333-3333-3333-333333333333",
|
||||
"packageId": "pkg:npm/c",
|
||||
"packageVersion": "3.0.0",
|
||||
"band": "cold",
|
||||
"score": 10.0,
|
||||
"reasonCode": "None",
|
||||
"reasonCodeShort": "U-NA",
|
||||
"firstSeenAt": "2026-01-01T00:00:00Z",
|
||||
"lastEvaluatedAt": "2026-01-02T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"totalCount": 1
|
||||
}
|
||||
""");
|
||||
|
||||
var command = UnknownsCommandGroup.BuildUnknownsCommand(_services, new Option<bool>("--verbose"), CancellationToken.None);
|
||||
var root = new RootCommand { command };
|
||||
using var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("unknowns export --format csv --schema-version unknowns.export.v9").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, exitCode);
|
||||
var output = writer.ToString();
|
||||
Assert.Contains("# schema_version=unknowns.export.v9;", output);
|
||||
Assert.Contains("item_count=1", output);
|
||||
Assert.Contains("id,package_id,package_version,band,score", output);
|
||||
}
|
||||
|
||||
private void SetupPolicyUnknownsResponse(string json)
|
||||
{
|
||||
_httpHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.Is<HttpRequestMessage>(request =>
|
||||
request.Method == HttpMethod.Get &&
|
||||
request.RequestUri != null &&
|
||||
request.RequestUri.ToString().Contains("/api/v1/policy/unknowns", StringComparison.Ordinal)),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json)
|
||||
});
|
||||
}
|
||||
|
||||
// Test DTOs matching the CLI internal types
|
||||
private sealed record TestUnknownsSummaryResponse
|
||||
{
|
||||
|
||||
193
src/Cli/__Tests/StellaOps.Cli.Tests/PolicyCliIntegrationTests.cs
Normal file
193
src/Cli/__Tests/StellaOps.Cli.Tests/PolicyCliIntegrationTests.cs
Normal file
@@ -0,0 +1,193 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.PolicyDsl;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Plugins.Policy.Tests;
|
||||
|
||||
public class PolicyCliIntegrationTests
|
||||
{
|
||||
private const string ValidPolicySource = @"
|
||||
policy ""Test Policy"" syntax ""stella-dsl@1"" {
|
||||
metadata {
|
||||
author: ""test@example.com""
|
||||
version: ""1.0.0""
|
||||
}
|
||||
|
||||
settings {
|
||||
default_action: ""allow""
|
||||
}
|
||||
|
||||
rule allow_all (10) {
|
||||
when true
|
||||
then {
|
||||
allow()
|
||||
}
|
||||
}
|
||||
}
|
||||
";
|
||||
|
||||
private const string InvalidPolicySource = @"
|
||||
policy ""Invalid Policy""
|
||||
// Missing syntax declaration
|
||||
{
|
||||
metadata {
|
||||
author: ""test@example.com""
|
||||
}
|
||||
}
|
||||
";
|
||||
|
||||
[Fact]
|
||||
public void PolicyParser_ParsesValidPolicy_ReturnsSuccess()
|
||||
{
|
||||
// Act
|
||||
var result = PolicyParser.Parse(ValidPolicySource);
|
||||
|
||||
// Assert
|
||||
result.Document.Should().NotBeNull();
|
||||
result.Diagnostics.Where(d => d.Severity == StellaOps.Policy.PolicyIssueSeverity.Error)
|
||||
.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyParser_ParsesInvalidPolicy_ReturnsDiagnostics()
|
||||
{
|
||||
// Act
|
||||
var result = PolicyParser.Parse(InvalidPolicySource);
|
||||
|
||||
// Assert
|
||||
result.Diagnostics.Where(d => d.Severity == StellaOps.Policy.PolicyIssueSeverity.Error)
|
||||
.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyCompiler_CompilesValidPolicy_ReturnsChecksum()
|
||||
{
|
||||
// Arrange
|
||||
var compiler = new PolicyCompiler();
|
||||
|
||||
// Act
|
||||
var result = compiler.Compile(ValidPolicySource);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Checksum.Should().NotBeNullOrEmpty();
|
||||
result.Checksum.Should().HaveLength(64); // SHA-256 hex
|
||||
result.Document.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyCompiler_CompilesInvalidPolicy_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var compiler = new PolicyCompiler();
|
||||
|
||||
// Act
|
||||
var result = compiler.Compile(InvalidPolicySource);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyCompiler_IsDeterministic_SameInputProducesSameChecksum()
|
||||
{
|
||||
// Arrange
|
||||
var compiler = new PolicyCompiler();
|
||||
|
||||
// Act
|
||||
var result1 = compiler.Compile(ValidPolicySource);
|
||||
var result2 = compiler.Compile(ValidPolicySource);
|
||||
|
||||
// Assert
|
||||
result1.Success.Should().BeTrue();
|
||||
result2.Success.Should().BeTrue();
|
||||
result1.Checksum.Should().Be(result2.Checksum);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyEngineFactory_CreatesEngineFromSource()
|
||||
{
|
||||
// Arrange
|
||||
var factory = new PolicyEngineFactory();
|
||||
|
||||
// Act
|
||||
var result = factory.CreateFromSource(ValidPolicySource);
|
||||
|
||||
// Assert
|
||||
result.Engine.Should().NotBeNull();
|
||||
result.Engine!.Name.Should().Be("Test Policy");
|
||||
result.Engine.Syntax.Should().Be("stella-dsl@1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyEngine_EvaluatesAgainstEmptyContext()
|
||||
{
|
||||
// Arrange
|
||||
var factory = new PolicyEngineFactory();
|
||||
var engineResult = factory.CreateFromSource(ValidPolicySource);
|
||||
var engine = engineResult.Engine!;
|
||||
var context = new SignalContext();
|
||||
|
||||
// Act
|
||||
var result = engine.Evaluate(context);
|
||||
|
||||
// Assert
|
||||
result.PolicyName.Should().Be("Test Policy");
|
||||
result.PolicyChecksum.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyEngine_EvaluatesAgainstSignalContext()
|
||||
{
|
||||
// Arrange
|
||||
var factory = new PolicyEngineFactory();
|
||||
var engineResult = factory.CreateFromSource(ValidPolicySource);
|
||||
var engine = engineResult.Engine!;
|
||||
var context = new SignalContext()
|
||||
.SetSignal("cvss.score", 8.5)
|
||||
.SetSignal("cve.reachable", true);
|
||||
|
||||
// Act
|
||||
var result = engine.Evaluate(context);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.MatchedRules.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignalContext_StoresAndRetrievesValues()
|
||||
{
|
||||
// Arrange
|
||||
var context = new SignalContext();
|
||||
|
||||
// Act
|
||||
context.SetSignal("test.string", "hello");
|
||||
context.SetSignal("test.number", 42);
|
||||
context.SetSignal("test.boolean", true);
|
||||
|
||||
// Assert
|
||||
context.HasSignal("test.string").Should().BeTrue();
|
||||
context.GetSignal<string>("test.string").Should().Be("hello");
|
||||
context.GetSignal<int>("test.number").Should().Be(42);
|
||||
context.GetSignal<bool>("test.boolean").Should().BeTrue();
|
||||
context.HasSignal("nonexistent").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyIrSerializer_SerializesToDeterministicBytes()
|
||||
{
|
||||
// Arrange
|
||||
var compiler = new PolicyCompiler();
|
||||
var result = compiler.Compile(ValidPolicySource);
|
||||
|
||||
// Act
|
||||
var bytes1 = PolicyIrSerializer.Serialize(result.Document!);
|
||||
var bytes2 = PolicyIrSerializer.Serialize(result.Document!);
|
||||
|
||||
// Assert
|
||||
bytes1.SequenceEqual(bytes2).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BaselineResolverTests.cs
|
||||
// Sprint: SPRINT_20260208_029_Cli_baseline_selection_logic
|
||||
// Description: Unit tests for baseline resolution service.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Services;
|
||||
|
||||
public sealed class BaselineResolverTests
|
||||
{
|
||||
private readonly IForensicSnapshotClient _forensicClient;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
private readonly ILogger<BaselineResolver> _logger;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly BaselineResolver _resolver;
|
||||
|
||||
public BaselineResolverTests()
|
||||
{
|
||||
_forensicClient = Substitute.For<IForensicSnapshotClient>();
|
||||
_options = new StellaOpsCliOptions { DefaultTenant = "test-tenant" };
|
||||
_logger = Substitute.For<ILogger<BaselineResolver>>();
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
|
||||
_resolver = new BaselineResolver(
|
||||
_forensicClient,
|
||||
_options,
|
||||
_logger,
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ExplicitStrategy_ReturnsProvidedDigest()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BaselineResolutionRequest
|
||||
{
|
||||
ArtifactId = "pkg:oci/myapp@sha256:abc123",
|
||||
Strategy = BaselineStrategy.Explicit,
|
||||
ExplicitDigest = "sha256:cafebabe"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("sha256:cafebabe", result.Digest);
|
||||
Assert.Equal(BaselineStrategy.Explicit, result.Strategy);
|
||||
Assert.Null(result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ExplicitStrategy_WithoutDigest_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BaselineResolutionRequest
|
||||
{
|
||||
ArtifactId = "pkg:oci/myapp@sha256:abc123",
|
||||
Strategy = BaselineStrategy.Explicit,
|
||||
ExplicitDigest = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Null(result.Digest);
|
||||
Assert.Contains("requires a digest", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_LastGreen_ReturnsLatestPassingSnapshot()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BaselineResolutionRequest
|
||||
{
|
||||
ArtifactId = "pkg:oci/myapp",
|
||||
Strategy = BaselineStrategy.LastGreen
|
||||
};
|
||||
|
||||
var snapshot = new ForensicSnapshotDocument
|
||||
{
|
||||
SnapshotId = "snap-001",
|
||||
CaseId = "case-001",
|
||||
Tenant = "test-tenant",
|
||||
Status = ForensicSnapshotStatus.Ready,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
Tags = ["verdict:pass", "artifact:pkg%3Aoci%2Fmyapp"],
|
||||
Manifest = new ForensicSnapshotManifest
|
||||
{
|
||||
ManifestId = "manifest-001",
|
||||
Digest = "sha256:lastgreen123"
|
||||
}
|
||||
};
|
||||
|
||||
_forensicClient
|
||||
.ListSnapshotsAsync(Arg.Any<ForensicSnapshotListQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ForensicSnapshotListResponse
|
||||
{
|
||||
Snapshots = [snapshot],
|
||||
Total = 1
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("sha256:lastgreen123", result.Digest);
|
||||
Assert.Equal(BaselineStrategy.LastGreen, result.Strategy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_LastGreen_NoPassingSnapshots_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BaselineResolutionRequest
|
||||
{
|
||||
ArtifactId = "pkg:oci/myapp",
|
||||
Strategy = BaselineStrategy.LastGreen
|
||||
};
|
||||
|
||||
_forensicClient
|
||||
.ListSnapshotsAsync(Arg.Any<ForensicSnapshotListQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ForensicSnapshotListResponse
|
||||
{
|
||||
Snapshots = [],
|
||||
Total = 0
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Null(result.Digest);
|
||||
Assert.Contains("No passing snapshot found", result.Error);
|
||||
Assert.NotNull(result.Suggestion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_PreviousRelease_ReturnsOlderRelease()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BaselineResolutionRequest
|
||||
{
|
||||
ArtifactId = "pkg:oci/myapp",
|
||||
Strategy = BaselineStrategy.PreviousRelease,
|
||||
CurrentVersion = "v2.0.0"
|
||||
};
|
||||
|
||||
var v2Snapshot = new ForensicSnapshotDocument
|
||||
{
|
||||
SnapshotId = "snap-002",
|
||||
CaseId = "case-001",
|
||||
Tenant = "test-tenant",
|
||||
Status = ForensicSnapshotStatus.Ready,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Tags = ["release:true", "version:v2.0.0", "artifact:pkg%3Aoci%2Fmyapp"],
|
||||
Manifest = new ForensicSnapshotManifest
|
||||
{
|
||||
ManifestId = "manifest-002",
|
||||
Digest = "sha256:v2digest"
|
||||
}
|
||||
};
|
||||
|
||||
var v1Snapshot = new ForensicSnapshotDocument
|
||||
{
|
||||
SnapshotId = "snap-001",
|
||||
CaseId = "case-001",
|
||||
Tenant = "test-tenant",
|
||||
Status = ForensicSnapshotStatus.Ready,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-7),
|
||||
Tags = ["release:true", "version:v1.0.0", "artifact:pkg%3Aoci%2Fmyapp"],
|
||||
Manifest = new ForensicSnapshotManifest
|
||||
{
|
||||
ManifestId = "manifest-001",
|
||||
Digest = "sha256:v1digest"
|
||||
}
|
||||
};
|
||||
|
||||
_forensicClient
|
||||
.ListSnapshotsAsync(Arg.Any<ForensicSnapshotListQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ForensicSnapshotListResponse
|
||||
{
|
||||
Snapshots = [v2Snapshot, v1Snapshot],
|
||||
Total = 2
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("sha256:v1digest", result.Digest);
|
||||
Assert.Equal(BaselineStrategy.PreviousRelease, result.Strategy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_PreviousRelease_NoReleases_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BaselineResolutionRequest
|
||||
{
|
||||
ArtifactId = "pkg:oci/myapp",
|
||||
Strategy = BaselineStrategy.PreviousRelease
|
||||
};
|
||||
|
||||
_forensicClient
|
||||
.ListSnapshotsAsync(Arg.Any<ForensicSnapshotListQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ForensicSnapshotListResponse
|
||||
{
|
||||
Snapshots = [],
|
||||
Total = 0
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Null(result.Digest);
|
||||
Assert.Contains("No release snapshots found", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ClientException_ReturnsFalseWithError()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BaselineResolutionRequest
|
||||
{
|
||||
ArtifactId = "pkg:oci/myapp",
|
||||
Strategy = BaselineStrategy.LastGreen
|
||||
};
|
||||
|
||||
_forensicClient
|
||||
.ListSnapshotsAsync(Arg.Any<ForensicSnapshotListQuery>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Connection refused"));
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Null(result.Digest);
|
||||
Assert.Contains("Connection refused", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSuggestionsAsync_ReturnsSuggestionsFromMultipleSources()
|
||||
{
|
||||
// Arrange
|
||||
var passingSnapshot = new ForensicSnapshotDocument
|
||||
{
|
||||
SnapshotId = "snap-pass-001",
|
||||
CaseId = "case-001",
|
||||
Tenant = "test-tenant",
|
||||
Status = ForensicSnapshotStatus.Ready,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
Tags = ["verdict:pass", "version:v1.0.0", "artifact:pkg%3Aoci%2Fmyapp"],
|
||||
Manifest = new ForensicSnapshotManifest
|
||||
{
|
||||
ManifestId = "manifest-001",
|
||||
Digest = "sha256:passing123"
|
||||
}
|
||||
};
|
||||
|
||||
var releaseSnapshot = new ForensicSnapshotDocument
|
||||
{
|
||||
SnapshotId = "snap-rel-001",
|
||||
CaseId = "case-001",
|
||||
Tenant = "test-tenant",
|
||||
Status = ForensicSnapshotStatus.Ready,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-2),
|
||||
Tags = ["release:true", "version:v0.9.0", "artifact:pkg%3Aoci%2Fmyapp"],
|
||||
Manifest = new ForensicSnapshotManifest
|
||||
{
|
||||
ManifestId = "manifest-002",
|
||||
Digest = "sha256:release123"
|
||||
}
|
||||
};
|
||||
|
||||
_forensicClient
|
||||
.ListSnapshotsAsync(Arg.Any<ForensicSnapshotListQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
new ForensicSnapshotListResponse { Snapshots = [passingSnapshot], Total = 1 },
|
||||
new ForensicSnapshotListResponse { Snapshots = [releaseSnapshot], Total = 1 });
|
||||
|
||||
// Act
|
||||
var suggestions = await _resolver.GetSuggestionsAsync("pkg:oci/myapp");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, suggestions.Count);
|
||||
Assert.Contains(suggestions, s => s.Digest == "sha256:passing123" && s.RecommendedStrategy == BaselineStrategy.LastGreen);
|
||||
Assert.Contains(suggestions, s => s.Digest == "sha256:release123" && s.RecommendedStrategy == BaselineStrategy.PreviousRelease);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_UsesDefaultTenant_WhenNotProvided()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BaselineResolutionRequest
|
||||
{
|
||||
ArtifactId = "pkg:oci/myapp",
|
||||
Strategy = BaselineStrategy.LastGreen,
|
||||
TenantId = null
|
||||
};
|
||||
|
||||
_forensicClient
|
||||
.ListSnapshotsAsync(Arg.Any<ForensicSnapshotListQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ForensicSnapshotListResponse { Snapshots = [], Total = 0 });
|
||||
|
||||
// Act
|
||||
await _resolver.ResolveAsync(request);
|
||||
|
||||
// Assert
|
||||
await _forensicClient.Received(1).ListSnapshotsAsync(
|
||||
Arg.Is<ForensicSnapshotListQuery>(q => q.Tenant == "test-tenant"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_UsesTenantFromRequest_WhenProvided()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BaselineResolutionRequest
|
||||
{
|
||||
ArtifactId = "pkg:oci/myapp",
|
||||
Strategy = BaselineStrategy.LastGreen,
|
||||
TenantId = "custom-tenant"
|
||||
};
|
||||
|
||||
_forensicClient
|
||||
.ListSnapshotsAsync(Arg.Any<ForensicSnapshotListQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ForensicSnapshotListResponse { Snapshots = [], Total = 0 });
|
||||
|
||||
// Act
|
||||
await _resolver.ResolveAsync(request);
|
||||
|
||||
// Assert
|
||||
await _forensicClient.Received(1).ListSnapshotsAsync(
|
||||
Arg.Is<ForensicSnapshotListQuery>(q => q.Tenant == "custom-tenant"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_UnknownStrategy_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BaselineResolutionRequest
|
||||
{
|
||||
ArtifactId = "pkg:oci/myapp",
|
||||
Strategy = (BaselineStrategy)99 // Invalid strategy
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("Unknown baseline strategy", result.Error);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="Spectre.Console.Testing" />
|
||||
<PackageReference Include="System.CommandLine" />
|
||||
<PackageReference Include="coverlet.collector" >
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# CLI Tests Task Board
|
||||
# CLI Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
@@ -34,3 +34,14 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| ATT-005 | DONE | SPRINT_20260119_010 - Timestamp CLI workflow tests added. |
|
||||
| TASK-032-004 | DONE | SPRINT_20260120_032 - Analytics CLI tests and fixtures added. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| SPRINT_20260208_030-TESTS | DONE | Added isolated advise parity validation in StellaOps.Cli.AdviseParity.Tests; command passed (2 tests, 2026-02-08).
|
||||
| SPRINT_20260208_033-TESTS | DONE | Added isolated Unknowns export deterministic validation in StellaOps.Cli.UnknownsExport.Tests; command passed (3 tests, 2026-02-08).
|
||||
|
||||
| SPRINT_20260208_031-TESTS | DONE | Isolated compare overlay deterministic validation added in StellaOps.Cli.CompareOverlay.Tests; command passed (3 tests, 2026-02-08).
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace StellaOps.Cli.Extensions
|
||||
{
|
||||
// Isolated test project stub namespace for UnknownsCommandGroup compile.
|
||||
internal static class CompatStubs
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
namespace StellaOps.Policy.Unknowns.Models
|
||||
{
|
||||
// Isolated test project stubs to satisfy UnknownsCommandGroup compile.
|
||||
public sealed record UnknownPlaceholder;
|
||||
}
|
||||
|
||||
namespace System.CommandLine
|
||||
{
|
||||
// Compatibility shims for the System.CommandLine API shape expected by UnknownsCommandGroup.
|
||||
public static class OptionCompatExtensions
|
||||
{
|
||||
public static Option<T> SetDefaultValue<T>(this Option<T> option, T value)
|
||||
{
|
||||
return option;
|
||||
}
|
||||
|
||||
public static Option<T> FromAmong<T>(this Option<T> option, params T[] values)
|
||||
{
|
||||
return option;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
<RootNamespace>StellaOps.Cli.UnknownsExport.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.CommandLine" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\..\StellaOps.Cli\Commands\UnknownsCommandGroup.cs" Link="Commands\UnknownsCommandGroup.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Commands;
|
||||
|
||||
namespace StellaOps.Cli.UnknownsExport.Tests;
|
||||
|
||||
public sealed class UnknownsExportIsolationTests
|
||||
{
|
||||
[Fact]
|
||||
public void UnknownsExport_ParsesSchemaVersionAndFormat()
|
||||
{
|
||||
var services = BuildServices([]);
|
||||
var command = UnknownsCommandGroup.BuildUnknownsCommand(
|
||||
services,
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None);
|
||||
var root = new RootCommand { command };
|
||||
|
||||
var result = root.Parse("unknowns export --format json --schema-version unknowns.export.v9");
|
||||
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownsExport_DefaultFormat_Parses()
|
||||
{
|
||||
var services = BuildServices([]);
|
||||
var command = UnknownsCommandGroup.BuildUnknownsCommand(
|
||||
services,
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None);
|
||||
var root = new RootCommand { command };
|
||||
|
||||
var result = root.Parse("unknowns export");
|
||||
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownsExport_Json_IncludesSchemaEnvelopeAndMetadata()
|
||||
{
|
||||
var payload = """
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"packageId": "pkg:npm/a@1.0.0",
|
||||
"packageVersion": "1.0.0",
|
||||
"band": "hot",
|
||||
"score": 0.99,
|
||||
"reasonCode": "r1",
|
||||
"reasonCodeShort": "r1",
|
||||
"firstSeenAt": "2026-01-01T00:00:00Z",
|
||||
"lastEvaluatedAt": "2026-01-15T09:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": "22222222-2222-2222-2222-222222222222",
|
||||
"packageId": "pkg:npm/b@1.0.0",
|
||||
"packageVersion": "1.0.0",
|
||||
"band": "warm",
|
||||
"score": 0.55,
|
||||
"reasonCode": "r2",
|
||||
"reasonCodeShort": "r2",
|
||||
"firstSeenAt": "2026-01-01T00:00:00Z",
|
||||
"lastEvaluatedAt": "2026-01-14T09:30:00Z"
|
||||
}
|
||||
],
|
||||
"totalCount": 2
|
||||
}
|
||||
""";
|
||||
|
||||
var services = BuildServices([("/api/v1/policy/unknowns?limit=10000", payload)]);
|
||||
var command = UnknownsCommandGroup.BuildUnknownsCommand(
|
||||
services,
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None);
|
||||
var root = new RootCommand { command };
|
||||
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("unknowns export --format json --schema-version unknowns.export.v2").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
var output = writer.ToString();
|
||||
Assert.True(exitCode == 0, $"ExitCode={exitCode}; Output={output}");
|
||||
Assert.Contains("\"schemaVersion\": \"unknowns.export.v2\"", output, StringComparison.Ordinal);
|
||||
Assert.Contains("\"itemCount\": 2", output, StringComparison.Ordinal);
|
||||
Assert.Contains("\"exportedAt\": \"2026-01-15T09:30:00+00:00\"", output, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static ServiceProvider BuildServices(IReadOnlyList<(string Path, string Json)> payloads)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(static builder => builder.SetMinimumLevel(LogLevel.Warning));
|
||||
services.AddHttpClient("PolicyApi")
|
||||
.ConfigureHttpClient(static client => client.BaseAddress = new Uri("http://localhost"))
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new TestHandler(payloads));
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
private sealed class TestHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Dictionary<string, string> _responses;
|
||||
|
||||
public TestHandler(IReadOnlyList<(string Path, string Json)> payloads)
|
||||
{
|
||||
_responses = payloads.ToDictionary(
|
||||
static x => x.Path,
|
||||
static x => x.Json,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var key = request.RequestUri?.PathAndQuery ?? string.Empty;
|
||||
if (_responses.TryGetValue(key, out var json))
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
Content = new StringContent("""{"error":"not found"}""", Encoding.UTF8, "application/json")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user