tests fixes and sprints work
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CorpusMirrorFreshnessCheckTests.cs
|
||||
// Sprint: SPRINT_20260121_036_BinaryIndex_golden_corpus_bundle_verification
|
||||
// Task: GCB-004 - Doctor checks for ground-truth corpus health
|
||||
// Description: Unit tests for CorpusMirrorFreshnessCheck
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugin.BinaryAnalysis.Checks;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugin.BinaryAnalysis.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class CorpusMirrorFreshnessCheckTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly CorpusMirrorFreshnessCheck _check = new();
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public CorpusMirrorFreshnessCheckTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"corpus-mirror-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 22, 12, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedValue()
|
||||
{
|
||||
// Assert
|
||||
_check.CheckId.Should().Be("check.binaryanalysis.corpus.mirror.freshness");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsCorpusMirrorFreshness()
|
||||
{
|
||||
// Assert
|
||||
_check.Name.Should().Be("Corpus Mirror Freshness");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsWarn()
|
||||
{
|
||||
// Assert
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
// Assert
|
||||
_check.Tags.Should().Contain("binaryanalysis");
|
||||
_check.Tags.Should().Contain("corpus");
|
||||
_check.Tags.Should().Contain("mirrors");
|
||||
_check.Tags.Should().Contain("groundtruth");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_Always()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act & Assert
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsWarn_WhenMirrorsDirectoryDoesNotExist()
|
||||
{
|
||||
// Arrange
|
||||
var nonExistentDir = Path.Combine(_tempDir, "nonexistent");
|
||||
var context = CreateContext(nonExistentDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("does not exist");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_WhenNoMirrorsExist()
|
||||
{
|
||||
// Arrange - directory exists but no mirrors
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("No corpus mirrors found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsPass_WhenAllMirrorsAreFresh()
|
||||
{
|
||||
// Arrange - create fresh mirrors
|
||||
CreateMirrorWithFiles(_tempDir, "debian/archive", daysOld: 1);
|
||||
CreateMirrorWithFiles(_tempDir, "debian/snapshot", daysOld: 2);
|
||||
CreateMirrorWithFiles(_tempDir, "ubuntu/usn-index", daysOld: 3);
|
||||
CreateMirrorWithFiles(_tempDir, "alpine/secdb", daysOld: 4);
|
||||
CreateMirrorWithFiles(_tempDir, "osv", daysOld: 5);
|
||||
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("fresh");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_WhenAllMirrorsAreStale()
|
||||
{
|
||||
// Arrange - create stale mirrors (> 7 days old by default)
|
||||
CreateMirrorWithFiles(_tempDir, "debian/archive", daysOld: 10);
|
||||
CreateMirrorWithFiles(_tempDir, "debian/snapshot", daysOld: 15);
|
||||
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("stale");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsWarn_WhenSomeMirrorsAreStale()
|
||||
{
|
||||
// Arrange - mix of fresh and stale mirrors
|
||||
CreateMirrorWithFiles(_tempDir, "debian/archive", daysOld: 2); // Fresh
|
||||
CreateMirrorWithFiles(_tempDir, "debian/snapshot", daysOld: 10); // Stale
|
||||
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("stale");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_RespectsConfiguredThreshold()
|
||||
{
|
||||
// Arrange - create mirror that is 5 days old
|
||||
CreateMirrorWithFiles(_tempDir, "debian/archive", daysOld: 5);
|
||||
|
||||
// Configure threshold to 3 days (so 5 days is stale)
|
||||
var context = CreateContext(_tempDir, staleThresholdDays: 3);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("stale");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesVerificationCommand()
|
||||
{
|
||||
// Arrange
|
||||
CreateMirrorWithFiles(_tempDir, "debian/archive", daysOld: 2);
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.VerificationCommand.Should().NotBeNullOrEmpty();
|
||||
result.VerificationCommand.Should().Contain("stella doctor --check");
|
||||
result.VerificationCommand.Should().Contain(_check.CheckId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesRemediationSteps_OnFailure()
|
||||
{
|
||||
// Arrange - no mirrors
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Remediation.Should().NotBeNull();
|
||||
result.Remediation!.Steps.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
private DoctorPluginContext CreateContext(string mirrorsRoot, int? staleThresholdDays = null)
|
||||
{
|
||||
var configValues = new Dictionary<string, string?>
|
||||
{
|
||||
["BinaryAnalysis:Corpus:MirrorsDirectory"] = mirrorsRoot
|
||||
};
|
||||
|
||||
if (staleThresholdDays.HasValue)
|
||||
{
|
||||
configValues["BinaryAnalysis:Corpus:StalenessThresholdDays"] = staleThresholdDays.Value.ToString();
|
||||
}
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configValues)
|
||||
.Build();
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = new ServiceCollection().BuildServiceProvider(),
|
||||
Configuration = config,
|
||||
TimeProvider = _timeProvider,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = config.GetSection("Doctor:Plugins")
|
||||
};
|
||||
}
|
||||
|
||||
private void CreateMirrorWithFiles(string root, string subdir, int daysOld)
|
||||
{
|
||||
var mirrorPath = Path.Combine(root, subdir);
|
||||
Directory.CreateDirectory(mirrorPath);
|
||||
|
||||
// Create some test files
|
||||
var testFile = Path.Combine(mirrorPath, "test-data.json");
|
||||
File.WriteAllText(testFile, "{}");
|
||||
|
||||
// Set modification time
|
||||
var modTime = _timeProvider.GetUtcNow().AddDays(-daysOld).DateTime;
|
||||
File.SetLastWriteTimeUtc(testFile, modTime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// KpiBaselineExistsCheckTests.cs
|
||||
// Sprint: SPRINT_20260121_036_BinaryIndex_golden_corpus_bundle_verification
|
||||
// Task: GCB-004 - Doctor checks for ground-truth corpus health
|
||||
// Description: Unit tests for KpiBaselineExistsCheck
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugin.BinaryAnalysis.Checks;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugin.BinaryAnalysis.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class KpiBaselineExistsCheckTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly KpiBaselineExistsCheck _check = new();
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public KpiBaselineExistsCheckTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"kpi-baseline-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 22, 12, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedValue()
|
||||
{
|
||||
// Assert
|
||||
_check.CheckId.Should().Be("check.binaryanalysis.corpus.kpi.baseline");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsKpiBaselineConfiguration()
|
||||
{
|
||||
// Assert
|
||||
_check.Name.Should().Be("KPI Baseline Configuration");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsWarn()
|
||||
{
|
||||
// Assert
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
// Assert
|
||||
_check.Tags.Should().Contain("binaryanalysis");
|
||||
_check.Tags.Should().Contain("corpus");
|
||||
_check.Tags.Should().Contain("kpi");
|
||||
_check.Tags.Should().Contain("baseline");
|
||||
_check.Tags.Should().Contain("groundtruth");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_Always()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act & Assert
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsWarn_WhenBaselineDirectoryDoesNotExist()
|
||||
{
|
||||
// Arrange
|
||||
var nonExistentDir = Path.Combine(_tempDir, "nonexistent");
|
||||
var context = CreateContext(nonExistentDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("does not exist");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsWarn_WhenBaselineFileDoesNotExist()
|
||||
{
|
||||
// Arrange - directory exists but no baseline file
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("No KPI baseline file found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsWarn_WhenOtherBaselineFilesExist()
|
||||
{
|
||||
// Arrange - create a different baseline file
|
||||
var otherBaseline = Path.Combine(_tempDir, "baseline-20260120.json");
|
||||
File.WriteAllText(otherBaseline, CreateValidBaselineJson());
|
||||
|
||||
var context = CreateContext(_tempDir); // Will look for current.json
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("other baseline file");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_WhenBaselineFileIsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var baselinePath = Path.Combine(_tempDir, "current.json");
|
||||
File.WriteAllText(baselinePath, "");
|
||||
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("invalid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_WhenBaselineFileHasInvalidJson()
|
||||
{
|
||||
// Arrange
|
||||
var baselinePath = Path.Combine(_tempDir, "current.json");
|
||||
File.WriteAllText(baselinePath, "{ invalid json }");
|
||||
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("invalid");
|
||||
result.Diagnosis.Should().Contain("JSON");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_WhenBaselineFileHasNoKpiFields()
|
||||
{
|
||||
// Arrange
|
||||
var baselinePath = Path.Combine(_tempDir, "current.json");
|
||||
File.WriteAllText(baselinePath, "{ \"unrelated\": \"data\" }");
|
||||
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("invalid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsWarn_WhenSomeKpiFieldsAreMissing()
|
||||
{
|
||||
// Arrange - partial baseline with only some fields
|
||||
var baselinePath = Path.Combine(_tempDir, "current.json");
|
||||
var partialBaseline = new
|
||||
{
|
||||
precision = 0.95,
|
||||
recall = 0.92
|
||||
// Missing: falseNegativeRate, deterministicReplayRate, ttfrpP95Ms
|
||||
};
|
||||
File.WriteAllText(baselinePath, JsonSerializer.Serialize(partialBaseline));
|
||||
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("missing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsPass_WhenBaselineIsValid()
|
||||
{
|
||||
// Arrange
|
||||
var baselinePath = Path.Combine(_tempDir, "current.json");
|
||||
File.WriteAllText(baselinePath, CreateValidBaselineJson());
|
||||
|
||||
// Set file modification time to 2 days ago
|
||||
File.SetLastWriteTimeUtc(baselinePath, _timeProvider.GetUtcNow().AddDays(-2).DateTime);
|
||||
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("valid");
|
||||
result.Diagnosis.Should().Contain("2 days ago");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_AcceptsCamelCaseKpiFields()
|
||||
{
|
||||
// Arrange
|
||||
var baselinePath = Path.Combine(_tempDir, "current.json");
|
||||
var baseline = new
|
||||
{
|
||||
precision = 0.95,
|
||||
recall = 0.92,
|
||||
falseNegativeRate = 0.08,
|
||||
deterministicReplayRate = 1.0,
|
||||
ttfrpP95Ms = 150
|
||||
};
|
||||
File.WriteAllText(baselinePath, JsonSerializer.Serialize(baseline));
|
||||
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesVerificationCommand()
|
||||
{
|
||||
// Arrange
|
||||
var baselinePath = Path.Combine(_tempDir, "current.json");
|
||||
File.WriteAllText(baselinePath, CreateValidBaselineJson());
|
||||
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.VerificationCommand.Should().NotBeNullOrEmpty();
|
||||
result.VerificationCommand.Should().Contain("stella doctor --check");
|
||||
result.VerificationCommand.Should().Contain(_check.CheckId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesRemediationSteps_OnFailure()
|
||||
{
|
||||
// Arrange - no baseline
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Remediation.Should().NotBeNull();
|
||||
result.Remediation!.Steps.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_RespectsCustomFilename()
|
||||
{
|
||||
// Arrange
|
||||
var customFilename = "my-baseline.json";
|
||||
var baselinePath = Path.Combine(_tempDir, customFilename);
|
||||
File.WriteAllText(baselinePath, CreateValidBaselineJson());
|
||||
|
||||
var context = CreateContext(_tempDir, customFilename);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
}
|
||||
|
||||
private DoctorPluginContext CreateContext(string baselineDir, string? baselineFilename = null)
|
||||
{
|
||||
var configValues = new Dictionary<string, string?>
|
||||
{
|
||||
["BinaryAnalysis:Corpus:BaselineDirectory"] = baselineDir
|
||||
};
|
||||
|
||||
if (baselineFilename != null)
|
||||
{
|
||||
configValues["BinaryAnalysis:Corpus:BaselineFilename"] = baselineFilename;
|
||||
}
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configValues)
|
||||
.Build();
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = new ServiceCollection().BuildServiceProvider(),
|
||||
Configuration = config,
|
||||
TimeProvider = _timeProvider,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = config.GetSection("Doctor:Plugins")
|
||||
};
|
||||
}
|
||||
|
||||
private static string CreateValidBaselineJson()
|
||||
{
|
||||
var baseline = new
|
||||
{
|
||||
precision = 0.95,
|
||||
recall = 0.92,
|
||||
falseNegativeRate = 0.08,
|
||||
deterministicReplayRate = 1.0,
|
||||
ttfrpP95Ms = 150,
|
||||
timestamp = "2026-01-20T10:00:00Z"
|
||||
};
|
||||
return JsonSerializer.Serialize(baseline);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CorpusHealthChecksIntegrationTests.cs
|
||||
// Sprint: SPRINT_20260121_036_BinaryIndex_golden_corpus_bundle_verification
|
||||
// Task: GCB-004 - Integration test with mock services
|
||||
// Description: Integration tests for corpus health Doctor checks with mock services
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using StellaOps.Doctor.Plugin.BinaryAnalysis.Checks;
|
||||
using StellaOps.Doctor.Plugin.BinaryAnalysis.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugin.BinaryAnalysis.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for ground-truth corpus Doctor checks.
|
||||
/// These tests verify the health checks work correctly with mock services
|
||||
/// simulating various infrastructure states.
|
||||
/// </summary>
|
||||
public sealed class CorpusHealthChecksIntegrationTests : IDisposable
|
||||
{
|
||||
private readonly string _testOutputDir;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
public CorpusHealthChecksIntegrationTests()
|
||||
{
|
||||
_testOutputDir = Path.Combine(Path.GetTempPath(), $"corpus-health-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_testOutputDir);
|
||||
_serviceProvider = BuildServiceProvider();
|
||||
}
|
||||
|
||||
#region DebuginfodAvailabilityCheck Tests
|
||||
|
||||
[Fact]
|
||||
public async Task DebuginfodAvailabilityCheck_AllUrlsReachable_ReturnsPass()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = CreateMockHttpClient(HttpStatusCode.OK);
|
||||
var options = CreateDebuginfodOptions(["https://debuginfod.fedora.org", "https://debuginfod.ubuntu.com"]);
|
||||
var check = new DebuginfodAvailabilityCheck(httpClient, options, NullLogger<DebuginfodAvailabilityCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Pass);
|
||||
result.Message.Should().Contain("reachable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DebuginfodAvailabilityCheck_SomeUrlsUnreachable_ReturnsWarn()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = CreateMockHttpClient(statusByUrl: new Dictionary<string, HttpStatusCode>
|
||||
{
|
||||
["https://debuginfod.fedora.org"] = HttpStatusCode.OK,
|
||||
["https://debuginfod.ubuntu.com"] = HttpStatusCode.ServiceUnavailable
|
||||
});
|
||||
var options = CreateDebuginfodOptions(["https://debuginfod.fedora.org", "https://debuginfod.ubuntu.com"]);
|
||||
var check = new DebuginfodAvailabilityCheck(httpClient, options, NullLogger<DebuginfodAvailabilityCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Warn);
|
||||
result.Message.Should().Contain("unreachable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DebuginfodAvailabilityCheck_AllUrlsUnreachable_ReturnsFail()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = CreateMockHttpClient(HttpStatusCode.ServiceUnavailable);
|
||||
var options = CreateDebuginfodOptions(["https://debuginfod.fedora.org"]);
|
||||
var check = new DebuginfodAvailabilityCheck(httpClient, options, NullLogger<DebuginfodAvailabilityCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Fail);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CorpusMirrorFreshnessCheck Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CorpusMirrorFreshnessCheck_FreshMirrors_ReturnsPass()
|
||||
{
|
||||
// Arrange
|
||||
SetupFreshMirrors();
|
||||
var options = CreateMirrorOptions(_testOutputDir);
|
||||
var check = new CorpusMirrorFreshnessCheck(options, NullLogger<CorpusMirrorFreshnessCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Pass);
|
||||
result.Message.Should().Contain("fresh");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorpusMirrorFreshnessCheck_StaleMirrors_ReturnsWarn()
|
||||
{
|
||||
// Arrange
|
||||
SetupStaleMirrors(daysOld: 5); // Within warning threshold
|
||||
var options = CreateMirrorOptions(_testOutputDir, staleDays: 7, warnDays: 3);
|
||||
var check = new CorpusMirrorFreshnessCheck(options, NullLogger<CorpusMirrorFreshnessCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Warn);
|
||||
result.Message.Should().Contain("stale");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorpusMirrorFreshnessCheck_VeryOldMirrors_ReturnsFail()
|
||||
{
|
||||
// Arrange
|
||||
SetupStaleMirrors(daysOld: 30); // Beyond stale threshold
|
||||
var options = CreateMirrorOptions(_testOutputDir, staleDays: 7);
|
||||
var check = new CorpusMirrorFreshnessCheck(options, NullLogger<CorpusMirrorFreshnessCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Fail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorpusMirrorFreshnessCheck_MissingLastSync_ReturnsFail()
|
||||
{
|
||||
// Arrange - no .last-sync files
|
||||
Directory.CreateDirectory(Path.Combine(_testOutputDir, "mirrors", "debian"));
|
||||
var options = CreateMirrorOptions(_testOutputDir);
|
||||
var check = new CorpusMirrorFreshnessCheck(options, NullLogger<CorpusMirrorFreshnessCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Fail);
|
||||
result.Message.Should().Contain("never synced");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region KpiBaselineExistsCheck Tests
|
||||
|
||||
[Fact]
|
||||
public async Task KpiBaselineExistsCheck_BaselineExists_ReturnsPass()
|
||||
{
|
||||
// Arrange
|
||||
SetupValidBaseline();
|
||||
var options = CreateKpiOptions(_testOutputDir);
|
||||
var check = new KpiBaselineExistsCheck(options, NullLogger<KpiBaselineExistsCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Pass);
|
||||
result.Message.Should().Contain("baseline");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KpiBaselineExistsCheck_BaselineMissing_ReturnsFail()
|
||||
{
|
||||
// Arrange - no baseline file
|
||||
var options = CreateKpiOptions(_testOutputDir);
|
||||
var check = new KpiBaselineExistsCheck(options, NullLogger<KpiBaselineExistsCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Fail);
|
||||
result.Message.Should().Contain("not found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KpiBaselineExistsCheck_OldBaseline_ReturnsWarn()
|
||||
{
|
||||
// Arrange
|
||||
SetupOldBaseline(daysOld: 45); // Older than recommended refresh interval
|
||||
var options = CreateKpiOptions(_testOutputDir, maxBaselineAgeDays: 30);
|
||||
var check = new KpiBaselineExistsCheck(options, NullLogger<KpiBaselineExistsCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Warn);
|
||||
result.Message.Should().Contain("old");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region BuildinfoCacheAccessibleCheck Tests
|
||||
|
||||
[Fact]
|
||||
public async Task BuildinfoCacheAccessibleCheck_CacheAccessible_ReturnsPass()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = CreateMockHttpClient(HttpStatusCode.OK);
|
||||
var options = CreateBuildinfoOptions("https://buildinfos.debian.net");
|
||||
var check = new BuildinfoCacheAccessibleCheck(httpClient, options, NullLogger<BuildinfoCacheAccessibleCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Pass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildinfoCacheAccessibleCheck_CacheUnreachable_ReturnsFail()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = CreateMockHttpClient(HttpStatusCode.ServiceUnavailable);
|
||||
var options = CreateBuildinfoOptions("https://buildinfos.debian.net");
|
||||
var check = new BuildinfoCacheAccessibleCheck(httpClient, options, NullLogger<BuildinfoCacheAccessibleCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Fail);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SymbolRecoveryFallbackCheck Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SymbolRecoveryFallbackCheck_FallbackConfigured_ReturnsPass()
|
||||
{
|
||||
// Arrange
|
||||
SetupFallbackSymbols();
|
||||
var options = CreateFallbackOptions(_testOutputDir, fallbackEnabled: true);
|
||||
var check = new SymbolRecoveryFallbackCheck(options, NullLogger<SymbolRecoveryFallbackCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Pass);
|
||||
result.Message.Should().Contain("fallback");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SymbolRecoveryFallbackCheck_FallbackDisabled_ReturnsWarn()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateFallbackOptions(_testOutputDir, fallbackEnabled: false);
|
||||
var check = new SymbolRecoveryFallbackCheck(options, NullLogger<SymbolRecoveryFallbackCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Warn);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Plugin Integration Tests
|
||||
|
||||
[Fact]
|
||||
public void BinaryAnalysisDoctorPlugin_RegistersAllCorpusChecks()
|
||||
{
|
||||
// Arrange
|
||||
var plugin = new BinaryAnalysisDoctorPlugin();
|
||||
|
||||
// Act
|
||||
var checks = plugin.GetChecks(_serviceProvider).ToList();
|
||||
|
||||
// Assert
|
||||
checks.Should().Contain(c => c.Name.Contains("debuginfod", StringComparison.OrdinalIgnoreCase));
|
||||
checks.Should().Contain(c => c.Name.Contains("mirror", StringComparison.OrdinalIgnoreCase));
|
||||
checks.Should().Contain(c => c.Name.Contains("baseline", StringComparison.OrdinalIgnoreCase));
|
||||
checks.Should().Contain(c => c.Name.Contains("buildinfo", StringComparison.OrdinalIgnoreCase));
|
||||
checks.Should().Contain(c => c.Name.Contains("fallback", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllCorpusChecks_RunWithoutException()
|
||||
{
|
||||
// Arrange
|
||||
SetupFreshMirrors();
|
||||
SetupValidBaseline();
|
||||
SetupFallbackSymbols();
|
||||
|
||||
var plugin = new BinaryAnalysisDoctorPlugin();
|
||||
var checks = plugin.GetChecks(_serviceProvider).ToList();
|
||||
|
||||
// Act & Assert
|
||||
foreach (var check in checks.Where(c => c.Category == "corpus"))
|
||||
{
|
||||
var act = async () => await check.RunAsync(CancellationToken.None);
|
||||
await act.Should().NotThrowAsync();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private IServiceProvider BuildServiceProvider()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSingleton(CreateMockHttpClient(HttpStatusCode.OK));
|
||||
services.Configure<DebuginfodOptions>(o =>
|
||||
{
|
||||
o.Urls = ["https://debuginfod.fedora.org"];
|
||||
});
|
||||
services.Configure<CorpusMirrorOptions>(o =>
|
||||
{
|
||||
o.MirrorsPath = Path.Combine(_testOutputDir, "mirrors");
|
||||
});
|
||||
services.Configure<KpiOptions>(o =>
|
||||
{
|
||||
o.BaselinePath = Path.Combine(_testOutputDir, "baselines", "current.json");
|
||||
});
|
||||
services.Configure<BuildinfoOptions>(o =>
|
||||
{
|
||||
o.CacheUrl = "https://buildinfos.debian.net";
|
||||
});
|
||||
services.Configure<SymbolFallbackOptions>(o =>
|
||||
{
|
||||
o.FallbackPath = Path.Combine(_testOutputDir, "fallback");
|
||||
o.Enabled = true;
|
||||
});
|
||||
|
||||
services.AddLogging();
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
private static HttpClient CreateMockHttpClient(HttpStatusCode defaultStatus)
|
||||
{
|
||||
var handler = new MockHttpMessageHandler(defaultStatus);
|
||||
return new HttpClient(handler);
|
||||
}
|
||||
|
||||
private static HttpClient CreateMockHttpClient(Dictionary<string, HttpStatusCode> statusByUrl)
|
||||
{
|
||||
var handler = new MockHttpMessageHandler(statusByUrl);
|
||||
return new HttpClient(handler);
|
||||
}
|
||||
|
||||
private static IOptions<DebuginfodOptions> CreateDebuginfodOptions(string[] urls)
|
||||
{
|
||||
return Options.Create(new DebuginfodOptions { Urls = urls });
|
||||
}
|
||||
|
||||
private IOptions<CorpusMirrorOptions> CreateMirrorOptions(string basePath, int staleDays = 7, int warnDays = 3)
|
||||
{
|
||||
return Options.Create(new CorpusMirrorOptions
|
||||
{
|
||||
MirrorsPath = Path.Combine(basePath, "mirrors"),
|
||||
StaleDaysThreshold = staleDays,
|
||||
WarnDaysThreshold = warnDays
|
||||
});
|
||||
}
|
||||
|
||||
private IOptions<KpiOptions> CreateKpiOptions(string basePath, int maxBaselineAgeDays = 30)
|
||||
{
|
||||
return Options.Create(new KpiOptions
|
||||
{
|
||||
BaselinePath = Path.Combine(basePath, "baselines", "current.json"),
|
||||
MaxBaselineAgeDays = maxBaselineAgeDays
|
||||
});
|
||||
}
|
||||
|
||||
private static IOptions<BuildinfoOptions> CreateBuildinfoOptions(string url)
|
||||
{
|
||||
return Options.Create(new BuildinfoOptions { CacheUrl = url });
|
||||
}
|
||||
|
||||
private IOptions<SymbolFallbackOptions> CreateFallbackOptions(string basePath, bool fallbackEnabled)
|
||||
{
|
||||
return Options.Create(new SymbolFallbackOptions
|
||||
{
|
||||
FallbackPath = Path.Combine(basePath, "fallback"),
|
||||
Enabled = fallbackEnabled
|
||||
});
|
||||
}
|
||||
|
||||
private void SetupFreshMirrors()
|
||||
{
|
||||
var mirrorsPath = Path.Combine(_testOutputDir, "mirrors");
|
||||
foreach (var mirror in new[] { "debian", "ubuntu", "alpine", "osv" })
|
||||
{
|
||||
var mirrorDir = Path.Combine(mirrorsPath, mirror);
|
||||
Directory.CreateDirectory(mirrorDir);
|
||||
File.WriteAllText(Path.Combine(mirrorDir, ".last-sync"), DateTime.UtcNow.ToString("O"));
|
||||
}
|
||||
}
|
||||
|
||||
private void SetupStaleMirrors(int daysOld)
|
||||
{
|
||||
var mirrorsPath = Path.Combine(_testOutputDir, "mirrors");
|
||||
foreach (var mirror in new[] { "debian", "ubuntu" })
|
||||
{
|
||||
var mirrorDir = Path.Combine(mirrorsPath, mirror);
|
||||
Directory.CreateDirectory(mirrorDir);
|
||||
File.WriteAllText(Path.Combine(mirrorDir, ".last-sync"),
|
||||
DateTime.UtcNow.AddDays(-daysOld).ToString("O"));
|
||||
}
|
||||
}
|
||||
|
||||
private void SetupValidBaseline()
|
||||
{
|
||||
var baselineDir = Path.Combine(_testOutputDir, "baselines");
|
||||
Directory.CreateDirectory(baselineDir);
|
||||
var baseline = new
|
||||
{
|
||||
baselineId = Guid.NewGuid().ToString(),
|
||||
createdAt = DateTime.UtcNow.ToString("O"),
|
||||
precision = 0.95,
|
||||
recall = 0.92
|
||||
};
|
||||
File.WriteAllText(Path.Combine(baselineDir, "current.json"),
|
||||
System.Text.Json.JsonSerializer.Serialize(baseline));
|
||||
}
|
||||
|
||||
private void SetupOldBaseline(int daysOld)
|
||||
{
|
||||
var baselineDir = Path.Combine(_testOutputDir, "baselines");
|
||||
Directory.CreateDirectory(baselineDir);
|
||||
var baseline = new
|
||||
{
|
||||
baselineId = Guid.NewGuid().ToString(),
|
||||
createdAt = DateTime.UtcNow.AddDays(-daysOld).ToString("O"),
|
||||
precision = 0.95,
|
||||
recall = 0.92
|
||||
};
|
||||
File.WriteAllText(Path.Combine(baselineDir, "current.json"),
|
||||
System.Text.Json.JsonSerializer.Serialize(baseline));
|
||||
}
|
||||
|
||||
private void SetupFallbackSymbols()
|
||||
{
|
||||
var fallbackDir = Path.Combine(_testOutputDir, "fallback");
|
||||
Directory.CreateDirectory(fallbackDir);
|
||||
File.WriteAllText(Path.Combine(fallbackDir, "fallback-config.json"), "{}");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_testOutputDir))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(_testOutputDir, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mock HTTP Handler
|
||||
|
||||
private sealed class MockHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _defaultStatus;
|
||||
private readonly Dictionary<string, HttpStatusCode>? _statusByUrl;
|
||||
|
||||
public MockHttpMessageHandler(HttpStatusCode defaultStatus)
|
||||
{
|
||||
_defaultStatus = defaultStatus;
|
||||
}
|
||||
|
||||
public MockHttpMessageHandler(Dictionary<string, HttpStatusCode> statusByUrl)
|
||||
{
|
||||
_statusByUrl = statusByUrl;
|
||||
_defaultStatus = HttpStatusCode.OK;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var url = request.RequestUri?.ToString() ?? "";
|
||||
var status = _statusByUrl?.GetValueOrDefault(url, _defaultStatus) ?? _defaultStatus;
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(status));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
// Enum for HTTP status codes (if not already in scope)
|
||||
file enum HttpStatusCode
|
||||
{
|
||||
OK = 200,
|
||||
ServiceUnavailable = 503
|
||||
}
|
||||
|
||||
// Check status enum (if not already defined in the Doctor plugin)
|
||||
file enum CheckStatus
|
||||
{
|
||||
Pass,
|
||||
Warn,
|
||||
Fail
|
||||
}
|
||||
@@ -10,8 +10,15 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
Reference in New Issue
Block a user