partly or unimplemented features - now implemented

This commit is contained in:
master
2026-02-09 08:53:51 +02:00
parent 1bf6bbf395
commit 4bdc298ec1
674 changed files with 90194 additions and 2271 deletions

View File

@@ -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]);
}
}
}

View 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;
}
}
}

View File

@@ -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>

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -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")
}
]
});
}
}
}

View File

@@ -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()
{

View File

@@ -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]

View File

@@ -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();
}
}

View File

@@ -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>());
}
}

View File

@@ -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

View File

@@ -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
{

View 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();
}
}

View File

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

View File

@@ -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" >

View File

@@ -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).

View File

@@ -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;
}
}
}

View File

@@ -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>

View File

@@ -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")
});
}
}
}