partly or unimplemented features - now implemented
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user