audit notes work completed, test fixes work (95% done), new sprints, new data sources setup and configuration
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
using StellaOps.DistroIntel;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.DistroIntel.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DerivativeConfidence enum.
|
||||
/// </summary>
|
||||
public sealed class DerivativeConfidenceTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(DerivativeConfidence.High)]
|
||||
[InlineData(DerivativeConfidence.Medium)]
|
||||
public void DerivativeConfidence_AllValues_AreDefined(DerivativeConfidence confidence)
|
||||
{
|
||||
Assert.True(Enum.IsDefined(confidence));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DerivativeConfidence_AllValues_AreCounted()
|
||||
{
|
||||
var values = Enum.GetValues<DerivativeConfidence>();
|
||||
Assert.Equal(2, values.Length);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DistroDerivative record.
|
||||
/// </summary>
|
||||
public sealed class DistroDerivativeTests
|
||||
{
|
||||
[Fact]
|
||||
public void DistroDerivative_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var derivative = new DistroDerivative("rhel", "almalinux", 9, DerivativeConfidence.High);
|
||||
|
||||
Assert.Equal("rhel", derivative.CanonicalDistro);
|
||||
Assert.Equal("almalinux", derivative.DerivativeDistro);
|
||||
Assert.Equal(9, derivative.MajorRelease);
|
||||
Assert.Equal(DerivativeConfidence.High, derivative.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistroDerivative_RecordEquality_WorksCorrectly()
|
||||
{
|
||||
var d1 = new DistroDerivative("rhel", "rocky", 9, DerivativeConfidence.High);
|
||||
var d2 = new DistroDerivative("rhel", "rocky", 9, DerivativeConfidence.High);
|
||||
|
||||
Assert.Equal(d1, d2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistroDerivative_DifferentReleases_AreNotEqual()
|
||||
{
|
||||
var d1 = new DistroDerivative("rhel", "rocky", 8, DerivativeConfidence.High);
|
||||
var d2 = new DistroDerivative("rhel", "rocky", 9, DerivativeConfidence.High);
|
||||
|
||||
Assert.NotEqual(d1, d2);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DistroMappings static class.
|
||||
/// </summary>
|
||||
public sealed class DistroMappingsTests
|
||||
{
|
||||
[Fact]
|
||||
public void DistroMappings_Derivatives_ContainsKnownMappings()
|
||||
{
|
||||
Assert.NotEmpty(DistroMappings.Derivatives);
|
||||
Assert.True(DistroMappings.Derivatives.Length >= 10);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("rhel", 8)]
|
||||
[InlineData("rhel", 9)]
|
||||
[InlineData("debian", 11)]
|
||||
[InlineData("debian", 12)]
|
||||
[InlineData("ubuntu", 20)]
|
||||
[InlineData("ubuntu", 22)]
|
||||
public void FindDerivativesFor_KnownCanonical_ReturnsDerivatives(string canonical, int release)
|
||||
{
|
||||
var derivatives = DistroMappings.FindDerivativesFor(canonical, release).ToList();
|
||||
|
||||
Assert.NotEmpty(derivatives);
|
||||
Assert.All(derivatives, d => Assert.Equal(canonical, d.CanonicalDistro));
|
||||
Assert.All(derivatives, d => Assert.Equal(release, d.MajorRelease));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindDerivativesFor_UnknownDistro_ReturnsEmpty()
|
||||
{
|
||||
var derivatives = DistroMappings.FindDerivativesFor("unknowndistro", 1).ToList();
|
||||
|
||||
Assert.Empty(derivatives);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindDerivativesFor_ResultsOrderedByConfidence()
|
||||
{
|
||||
var derivatives = DistroMappings.FindDerivativesFor("rhel", 9).ToList();
|
||||
|
||||
Assert.NotEmpty(derivatives);
|
||||
// All RHEL derivatives should be High confidence
|
||||
Assert.All(derivatives, d => Assert.Equal(DerivativeConfidence.High, d.Confidence));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("almalinux", 9, "rhel")]
|
||||
[InlineData("rocky", 9, "rhel")]
|
||||
[InlineData("centos", 7, "rhel")]
|
||||
[InlineData("oracle", 8, "rhel")]
|
||||
public void FindCanonicalFor_KnownDerivative_ReturnsCanonical(string derivative, int release, string expectedCanonical)
|
||||
{
|
||||
var canonical = DistroMappings.FindCanonicalFor(derivative, release);
|
||||
|
||||
Assert.NotNull(canonical);
|
||||
Assert.Equal(expectedCanonical, canonical.CanonicalDistro);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindCanonicalFor_UnknownDerivative_ReturnsNull()
|
||||
{
|
||||
var canonical = DistroMappings.FindCanonicalFor("unknowndistro", 1);
|
||||
|
||||
Assert.Null(canonical);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DerivativeConfidence.High, 0.95)]
|
||||
[InlineData(DerivativeConfidence.Medium, 0.80)]
|
||||
public void GetConfidenceMultiplier_ReturnsExpectedValues(DerivativeConfidence confidence, decimal expected)
|
||||
{
|
||||
var multiplier = DistroMappings.GetConfidenceMultiplier(confidence);
|
||||
|
||||
Assert.Equal(expected, multiplier);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("rhel", true)]
|
||||
[InlineData("debian", true)]
|
||||
[InlineData("ubuntu", true)]
|
||||
[InlineData("sles", true)]
|
||||
[InlineData("alpine", true)]
|
||||
[InlineData("almalinux", false)]
|
||||
[InlineData("rocky", false)]
|
||||
[InlineData("linuxmint", false)]
|
||||
public void IsCanonicalDistro_ReturnsCorrectResult(string distro, bool expected)
|
||||
{
|
||||
var result = DistroMappings.IsCanonicalDistro(distro);
|
||||
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("redhat", "rhel")]
|
||||
[InlineData("red hat", "rhel")]
|
||||
[InlineData("red-hat", "rhel")]
|
||||
[InlineData("RHEL", "rhel")]
|
||||
[InlineData("alma", "almalinux")]
|
||||
[InlineData("almalinux-os", "almalinux")]
|
||||
[InlineData("rockylinux", "rocky")]
|
||||
[InlineData("rocky-linux", "rocky")]
|
||||
[InlineData("oracle linux", "oracle")]
|
||||
[InlineData("oraclelinux", "oracle")]
|
||||
[InlineData("opensuse", "opensuse-leap")]
|
||||
[InlineData("mint", "linuxmint")]
|
||||
[InlineData("popos", "pop")]
|
||||
[InlineData("pop_os", "pop")]
|
||||
[InlineData("debian", "debian")]
|
||||
[InlineData("ubuntu", "ubuntu")]
|
||||
public void NormalizeDistroName_ReturnsCanonicalForm(string input, string expected)
|
||||
{
|
||||
var result = DistroMappings.NormalizeDistroName(input);
|
||||
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindDerivativesFor_CaseInsensitive()
|
||||
{
|
||||
var lower = DistroMappings.FindDerivativesFor("rhel", 9).ToList();
|
||||
var upper = DistroMappings.FindDerivativesFor("RHEL", 9).ToList();
|
||||
var mixed = DistroMappings.FindDerivativesFor("RhEl", 9).ToList();
|
||||
|
||||
Assert.Equal(lower.Count, upper.Count);
|
||||
Assert.Equal(lower.Count, mixed.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindCanonicalFor_CaseInsensitive()
|
||||
{
|
||||
var lower = DistroMappings.FindCanonicalFor("almalinux", 9);
|
||||
var upper = DistroMappings.FindCanonicalFor("ALMALINUX", 9);
|
||||
var mixed = DistroMappings.FindCanonicalFor("AlmaLinux", 9);
|
||||
|
||||
Assert.NotNull(lower);
|
||||
Assert.NotNull(upper);
|
||||
Assert.NotNull(mixed);
|
||||
Assert.Equal(lower, upper);
|
||||
Assert.Equal(lower, mixed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.DistroIntel\StellaOps.DistroIntel.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"diagnosticMessages": true,
|
||||
"parallelizeAssembly": true,
|
||||
"parallelizeTestCollections": true,
|
||||
"maxParallelThreads": -1
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Authority.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AuthorityPluginTests
|
||||
{
|
||||
private readonly AuthorityPlugin _plugin = new();
|
||||
|
||||
[Fact]
|
||||
public void PluginId_ReturnsExpectedValue()
|
||||
{
|
||||
_plugin.PluginId.Should().Be("stellaops.doctor.authority");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisplayName_ReturnsExpectedValue()
|
||||
{
|
||||
_plugin.DisplayName.Should().Be("Authority");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Category_ReturnsAuthority()
|
||||
{
|
||||
_plugin.Category.Should().Be(DoctorCategory.Authority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChecks_ReturnsAllExpectedChecks()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var checks = _plugin.GetChecks(context);
|
||||
|
||||
// Assert
|
||||
checks.Should().HaveCount(5);
|
||||
checks.Select(c => c.CheckId).Should().BeEquivalentTo(new[]
|
||||
{
|
||||
"check.authority.plugin.configured",
|
||||
"check.authority.plugin.connectivity",
|
||||
"check.authority.bootstrap.exists",
|
||||
"check.users.superuser.exists",
|
||||
"check.users.password.policy"
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChecks_AllChecksHaveUniqueIds()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var checks = _plugin.GetChecks(context);
|
||||
|
||||
// Assert
|
||||
var checkIds = checks.Select(c => c.CheckId);
|
||||
checkIds.Should().OnlyHaveUniqueItems();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChecks_AllChecksHaveDescriptions()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var checks = _plugin.GetChecks(context);
|
||||
|
||||
// Assert
|
||||
foreach (var check in checks)
|
||||
{
|
||||
check.Description.Should().NotBeNullOrWhiteSpace(
|
||||
$"Check {check.CheckId} should have a description");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChecks_AllChecksHaveTags()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var checks = _plugin.GetChecks(context);
|
||||
|
||||
// Assert
|
||||
foreach (var check in checks)
|
||||
{
|
||||
check.Tags.Should().NotBeEmpty(
|
||||
$"Check {check.CheckId} should have at least one tag");
|
||||
}
|
||||
}
|
||||
|
||||
private static DoctorPluginContext CreateContext()
|
||||
{
|
||||
var config = new ConfigurationBuilder().Build();
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = new ServiceCollection().BuildServiceProvider(),
|
||||
Configuration = config,
|
||||
TimeProvider = TimeProvider.System,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = config.GetSection("Doctor:Plugins")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Authority.Checks;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Authority.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AuthorityPluginConfigurationCheckTests
|
||||
{
|
||||
private readonly AuthorityPluginConfigurationCheck _check = new();
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedValue()
|
||||
{
|
||||
_check.CheckId.Should().Be("check.authority.plugin.configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsExpectedValue()
|
||||
{
|
||||
_check.Name.Should().Be("Authority Plugin Configuration");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsCritical()
|
||||
{
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Fail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
_check.Tags.Should().Contain("authority");
|
||||
_check.Tags.Should().Contain("authentication");
|
||||
_check.Tags.Should().Contain("security");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue()
|
||||
{
|
||||
var context = CreateContext(new Dictionary<string, string?>());
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Fails_WhenNoPluginsConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>());
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("No authentication plugins configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Passes_WhenStandardPluginEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("1 authentication plugin(s) configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Passes_WhenStandardSectionExists()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:PasswordPolicy:MinLength"] = "12"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Passes_WhenLdapPluginConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Ldap:Enabled"] = "true",
|
||||
["Authority:Plugins:Ldap:Server"] = "ldap://ldap.example.com"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("1 authentication plugin(s) configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Warns_WhenLdapEnabledButServerMissing()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Ldap:Enabled"] = "true"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Passes_WhenOidcPluginConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Oidc:Enabled"] = "true",
|
||||
["Authority:Plugins:Oidc:Authority"] = "https://login.example.com"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Warns_WhenOidcEnabledButAuthorityMissing()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Oidc:Enabled"] = "true"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Passes_WhenSamlPluginEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Saml:Enabled"] = "true"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReportsMultiplePlugins()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true",
|
||||
["Authority:Plugins:Ldap:Enabled"] = "true",
|
||||
["Authority:Plugins:Ldap:Server"] = "ldap://ldap.example.com"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("2 authentication plugin(s) configured");
|
||||
}
|
||||
|
||||
private static DoctorPluginContext CreateContext(Dictionary<string, string?> configValues)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configValues)
|
||||
.Build();
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = new ServiceCollection().BuildServiceProvider(),
|
||||
Configuration = config,
|
||||
TimeProvider = TimeProvider.System,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = config.GetSection("Doctor:Plugins")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Authority.Checks;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Authority.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AuthorityPluginConnectivityCheckTests
|
||||
{
|
||||
private readonly AuthorityPluginConnectivityCheck _check = new();
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedValue()
|
||||
{
|
||||
_check.CheckId.Should().Be("check.authority.plugin.connectivity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsExpectedValue()
|
||||
{
|
||||
_check.Name.Should().Be("Authority Backend Connectivity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsCritical()
|
||||
{
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Fail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
_check.Tags.Should().Contain("authority");
|
||||
_check.Tags.Should().Contain("connectivity");
|
||||
_check.Tags.Should().Contain("ldap");
|
||||
_check.Tags.Should().Contain("database");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsFalse_WhenNoPluginsConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>());
|
||||
|
||||
// Act & Assert
|
||||
_check.CanRun(context).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenStandardPluginSectionExists()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true"
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenLdapPluginSectionExists()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Ldap:Server"] = "ldap://ldap.example.com"
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Skips_WhenNoBackendsToTest()
|
||||
{
|
||||
// Arrange - Sections exist but no plugins enabled
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:SomeSetting"] = "value",
|
||||
["Authority:Plugins:Standard:Enabled"] = "false"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Skip);
|
||||
result.Diagnosis.Should().Contain("No authentication backends configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Fails_WhenDatabaseConnectionStringMissing()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("unreachable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Passes_WhenDatabaseConnectionStringConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true",
|
||||
["ConnectionStrings:Authority"] = "Host=localhost;Database=authority"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("reachable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_UsesDefaultConnectionString_WhenAuthorityConnectionMissing()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true",
|
||||
["ConnectionStrings:Default"] = "Host=localhost;Database=stellaops"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_TestsBothBackends_WhenBothEnabled()
|
||||
{
|
||||
// Arrange - Standard with DB and LDAP both enabled
|
||||
// Note: LDAP will fail in test because we can't actually connect
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true",
|
||||
["ConnectionStrings:Authority"] = "Host=localhost;Database=authority",
|
||||
["Authority:Plugins:Ldap:Enabled"] = "true",
|
||||
["Authority:Plugins:Ldap:Server"] = "ldap://127.0.0.1:3899"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - LDAP will fail (can't connect in test), so overall should fail
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("unreachable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_SkipsLdapTest_WhenLdapEnabledButNoServer()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true",
|
||||
["ConnectionStrings:Authority"] = "Host=localhost;Database=authority",
|
||||
["Authority:Plugins:Ldap:Enabled"] = "true"
|
||||
// No Ldap:Server configured
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - Should only test Standard (DB), which passes
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("1 backend(s) reachable");
|
||||
}
|
||||
|
||||
private static DoctorPluginContext CreateContext(Dictionary<string, string?> configValues)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configValues)
|
||||
.Build();
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = new ServiceCollection().BuildServiceProvider(),
|
||||
Configuration = config,
|
||||
TimeProvider = TimeProvider.System,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = config.GetSection("Doctor:Plugins")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Authority.Checks;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Authority.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class BootstrapUserExistsCheckTests
|
||||
{
|
||||
private readonly BootstrapUserExistsCheck _check = new();
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedValue()
|
||||
{
|
||||
_check.CheckId.Should().Be("check.authority.bootstrap.exists");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsExpectedValue()
|
||||
{
|
||||
_check.Name.Should().Be("Bootstrap User Exists");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsCritical()
|
||||
{
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Fail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
_check.Tags.Should().Contain("authority");
|
||||
_check.Tags.Should().Contain("user");
|
||||
_check.Tags.Should().Contain("bootstrap");
|
||||
_check.Tags.Should().Contain("admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsFalse_WhenStandardPluginNotEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>());
|
||||
|
||||
// Act & Assert
|
||||
_check.CanRun(context).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenStandardPluginEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true"
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Fails_WhenAutoBootstrapDisabledAndNoConfig()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true",
|
||||
["Authority:Bootstrap:Enabled"] = "false"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("No bootstrap user configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Info_WhenAutoBootstrapEnabledButNoConfig()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true",
|
||||
["Authority:Bootstrap:Enabled"] = "true"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Info);
|
||||
result.Diagnosis.Should().Contain("auto-created");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Warns_WhenUsernameMissing()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true",
|
||||
["Authority:Bootstrap:Email"] = "admin@example.com"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("incomplete");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Warns_WhenEmailMissing()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true",
|
||||
["Authority:Bootstrap:Username"] = "admin"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("incomplete");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Passes_WhenFullyConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true",
|
||||
["Authority:Bootstrap:Username"] = "admin",
|
||||
["Authority:Bootstrap:Email"] = "admin@example.com"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("properly configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReadsFromAlternativeConfigPath()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true",
|
||||
["Authority:Plugins:Standard:Bootstrap:Username"] = "admin",
|
||||
["Authority:Plugins:Standard:Bootstrap:Email"] = "admin@example.com"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
}
|
||||
|
||||
private static DoctorPluginContext CreateContext(Dictionary<string, string?> configValues)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configValues)
|
||||
.Build();
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = new ServiceCollection().BuildServiceProvider(),
|
||||
Configuration = config,
|
||||
TimeProvider = TimeProvider.System,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = config.GetSection("Doctor:Plugins")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Authority.Checks;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Authority.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SuperUserExistsCheckTests
|
||||
{
|
||||
private readonly SuperUserExistsCheck _check = new();
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedValue()
|
||||
{
|
||||
_check.CheckId.Should().Be("check.users.superuser.exists");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsExpectedValue()
|
||||
{
|
||||
_check.Name.Should().Be("Super User Exists");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsCritical()
|
||||
{
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Fail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
_check.Tags.Should().Contain("authority");
|
||||
_check.Tags.Should().Contain("user");
|
||||
_check.Tags.Should().Contain("admin");
|
||||
_check.Tags.Should().Contain("superuser");
|
||||
_check.Tags.Should().Contain("security");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsFalse_WhenStandardPluginNotEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>());
|
||||
|
||||
// Act & Assert
|
||||
_check.CanRun(context).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenStandardPluginEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true"
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Fails_WhenNoAdminsAndAutoBootstrapDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true",
|
||||
["Authority:Bootstrap:Enabled"] = "false"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("No administrator users configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Info_WhenNoAdminsButAutoBootstrapEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true",
|
||||
["Authority:Bootstrap:Enabled"] = "true"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Info);
|
||||
result.Diagnosis.Should().Contain("auto-bootstrap");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Passes_WhenBootstrapUsernameConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true",
|
||||
["Authority:Bootstrap:Username"] = "admin"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("1 administrator(s) configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Passes_WhenAdministratorsConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true",
|
||||
["Authority:Users:Administrators:0"] = "admin1",
|
||||
["Authority:Users:Administrators:1"] = "admin2"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("2 administrator(s) configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Passes_WhenAdminRolesConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true",
|
||||
["Authority:Roles:Administrators:0"] = "alice",
|
||||
["Authority:Roles:Administrators:1"] = "bob"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("2 administrator(s) configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_DeduplicatesAdminUsers()
|
||||
{
|
||||
// Arrange - Same user in both bootstrap and admin list
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true",
|
||||
["Authority:Bootstrap:Username"] = "admin",
|
||||
["Authority:Users:Administrators:0"] = "admin"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("1 administrator(s) configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_DefaultsAutoBootstrapToTrue()
|
||||
{
|
||||
// Arrange - No explicit Bootstrap:Enabled setting
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
// Should return Info because auto-bootstrap defaults to true
|
||||
result.Severity.Should().Be(DoctorSeverity.Info);
|
||||
}
|
||||
|
||||
private static DoctorPluginContext CreateContext(Dictionary<string, string?> configValues)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configValues)
|
||||
.Build();
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = new ServiceCollection().BuildServiceProvider(),
|
||||
Configuration = config,
|
||||
TimeProvider = TimeProvider.System,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = config.GetSection("Doctor:Plugins")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Authority.Checks;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Authority.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class UserPasswordPolicyCheckTests
|
||||
{
|
||||
private readonly UserPasswordPolicyCheck _check = new();
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedValue()
|
||||
{
|
||||
_check.CheckId.Should().Be("check.users.password.policy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsExpectedValue()
|
||||
{
|
||||
_check.Name.Should().Be("Password Policy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsWarn()
|
||||
{
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
_check.Tags.Should().Contain("authority");
|
||||
_check.Tags.Should().Contain("password");
|
||||
_check.Tags.Should().Contain("policy");
|
||||
_check.Tags.Should().Contain("security");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsFalse_WhenStandardPluginNotEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>());
|
||||
|
||||
// Act & Assert
|
||||
_check.CanRun(context).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenStandardPluginEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true"
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Fails_WhenMinLengthBelowAbsoluteMinimum()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true",
|
||||
["Authority:PasswordPolicy:MinLength"] = "6"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("violation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Warns_WhenMinLengthBelowRecommended()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true",
|
||||
["Authority:PasswordPolicy:MinLength"] = "10"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("recommendation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Warns_WhenInsufficientComplexityRequirements()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true",
|
||||
["Authority:PasswordPolicy:MinLength"] = "12",
|
||||
["Authority:PasswordPolicy:RequireUppercase"] = "true",
|
||||
["Authority:PasswordPolicy:RequireLowercase"] = "true",
|
||||
["Authority:PasswordPolicy:RequireDigit"] = "false",
|
||||
["Authority:PasswordPolicy:RequireSpecialCharacter"] = "false"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("recommendation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Warns_WhenMaxAgeVeryShort()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true",
|
||||
["Authority:PasswordPolicy:MinLength"] = "12",
|
||||
["Authority:PasswordPolicy:MaxAgeDays"] = "14"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("recommendation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Warns_WhenPreventReuseCountLow()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true",
|
||||
["Authority:PasswordPolicy:MinLength"] = "12",
|
||||
["Authority:PasswordPolicy:PreventReuseCount"] = "2"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("recommendation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Passes_WhenPolicyMeetsRequirements()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true",
|
||||
["Authority:PasswordPolicy:MinLength"] = "14",
|
||||
["Authority:PasswordPolicy:RequireUppercase"] = "true",
|
||||
["Authority:PasswordPolicy:RequireLowercase"] = "true",
|
||||
["Authority:PasswordPolicy:RequireDigit"] = "true",
|
||||
["Authority:PasswordPolicy:RequireSpecialCharacter"] = "true",
|
||||
["Authority:PasswordPolicy:PreventReuseCount"] = "5"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("meets security requirements");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_UsesDefaults_WhenNoPolicyConfigured()
|
||||
{
|
||||
// Arrange - Only Standard enabled, no password policy explicitly set
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Plugins:Standard:Enabled"] = "true"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
// Default MinLength is 8, which is below recommended 12, so should warn
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
private static DoctorPluginContext CreateContext(Dictionary<string, string?> configValues)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configValues)
|
||||
.Build();
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = new ServiceCollection().BuildServiceProvider(),
|
||||
Configuration = config,
|
||||
TimeProvider = TimeProvider.System,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = config.GetSection("Doctor:Plugins")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Doctor\StellaOps.Doctor.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Doctor.Plugins.Authority\StellaOps.Doctor.Plugins.Authority.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,296 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Notify.Checks;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Notify.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class NotifyChannelConfigurationCheckTests
|
||||
{
|
||||
private readonly NotifyChannelConfigurationCheck _check = new();
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedValue()
|
||||
{
|
||||
_check.CheckId.Should().Be("check.notify.channel.configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsExpectedValue()
|
||||
{
|
||||
_check.Name.Should().Be("Notification Channel Configuration");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsInfo()
|
||||
{
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Info);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
_check.Tags.Should().Contain("notify");
|
||||
_check.Tags.Should().Contain("channel");
|
||||
_check.Tags.Should().Contain("configuration");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EstimatedDuration_IsShort()
|
||||
{
|
||||
_check.EstimatedDuration.Should().BeLessThanOrEqualTo(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue()
|
||||
{
|
||||
var context = CreateContext(new Dictionary<string, string?>());
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsInfo_WhenNoChannelsConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>());
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Info);
|
||||
result.Diagnosis.Should().Contain("No notification channels configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Passes_WhenEmailChannelConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true",
|
||||
["Notify:Channels:Email:SmtpHost"] = "smtp.example.com"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("1 notification channel(s) configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Warns_WhenEmailEnabledButSmtpHostMissing()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Passes_WhenSlackChannelConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Slack:Enabled"] = "true",
|
||||
["Notify:Channels:Slack:WebhookUrl"] = "https://hooks.slack.com/services/..."
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("1 notification channel(s) configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Passes_WhenSlackTokenConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Slack:Enabled"] = "true",
|
||||
["Notify:Channels:Slack:Token"] = "xoxb-123456789"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Warns_WhenSlackEnabledButNoWebhookOrToken()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Slack:Enabled"] = "true"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Passes_WhenTeamsChannelConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Teams:Enabled"] = "true",
|
||||
["Notify:Channels:Teams:WebhookUrl"] = "https://outlook.office.com/webhook/..."
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("1 notification channel(s) configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Warns_WhenTeamsEnabledButNoWebhook()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Teams:Enabled"] = "true"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Passes_WhenWebhookChannelConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Webhook:Enabled"] = "true",
|
||||
["Notify:Channels:Webhook:Endpoint"] = "https://api.example.com/webhook"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("1 notification channel(s) configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Warns_WhenWebhookEnabledButNoEndpoint()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Webhook:Enabled"] = "true"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReportsMultipleChannels()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true",
|
||||
["Notify:Channels:Email:SmtpHost"] = "smtp.example.com",
|
||||
["Notify:Channels:Slack:Enabled"] = "true",
|
||||
["Notify:Channels:Slack:WebhookUrl"] = "https://hooks.slack.com/services/..."
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("2 notification channel(s) configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesIssues_WhenSomeChannelsMisconfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true",
|
||||
["Notify:Channels:Email:SmtpHost"] = "smtp.example.com",
|
||||
["Notify:Channels:Slack:Enabled"] = "true" // Missing webhook URL
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("1 channel(s) configured");
|
||||
result.Diagnosis.Should().Contain("1 issue(s)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_DetectsChannelFromSection_WhenEnabledNotExplicit()
|
||||
{
|
||||
// Arrange - Section exists but Enabled not set (defaults to true for existing sections)
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:SmtpHost"] = "smtp.example.com",
|
||||
["Notify:Channels:Email:SmtpPort"] = "587"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
}
|
||||
|
||||
private static DoctorPluginContext CreateContext(Dictionary<string, string?> configValues)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configValues)
|
||||
.Build();
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = new ServiceCollection().BuildServiceProvider(),
|
||||
Configuration = config,
|
||||
TimeProvider = TimeProvider.System,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = config.GetSection("Doctor:Plugins")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Notify.Checks;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Notify.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class NotifyChannelConnectivityCheckTests
|
||||
{
|
||||
private readonly NotifyChannelConnectivityCheck _check = new();
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedValue()
|
||||
{
|
||||
_check.CheckId.Should().Be("check.notify.channel.connectivity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsExpectedValue()
|
||||
{
|
||||
_check.Name.Should().Be("Notification Channel Connectivity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Description_ReturnsExpectedValue()
|
||||
{
|
||||
_check.Description.Should().Contain("connectivity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsWarn()
|
||||
{
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
_check.Tags.Should().Contain("notify");
|
||||
_check.Tags.Should().Contain("channel");
|
||||
_check.Tags.Should().Contain("connectivity");
|
||||
_check.Tags.Should().Contain("network");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EstimatedDuration_AllowsForNetworkTimeout()
|
||||
{
|
||||
_check.EstimatedDuration.Should().BeGreaterThanOrEqualTo(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsFalse_WhenNoChannelsConfigured()
|
||||
{
|
||||
var context = CreateContext(new Dictionary<string, string?>());
|
||||
_check.CanRun(context).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenEmailChannelConfigured()
|
||||
{
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:SmtpHost"] = "smtp.example.com"
|
||||
});
|
||||
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenSlackChannelConfigured()
|
||||
{
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Slack:WebhookUrl"] = "https://hooks.slack.com/services/..."
|
||||
});
|
||||
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenTeamsChannelConfigured()
|
||||
{
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Teams:WebhookUrl"] = "https://outlook.office.com/webhook/..."
|
||||
});
|
||||
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenWebhookChannelConfigured()
|
||||
{
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Webhook:Endpoint"] = "https://api.example.com/webhook"
|
||||
});
|
||||
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Skips_WhenNoChannelsConfiguredToTest()
|
||||
{
|
||||
// Arrange - Channels exist but are disabled
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "false"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Skip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_TestsEmailSmtpConnectivity()
|
||||
{
|
||||
// Arrange - Use localhost which should fail quickly
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true",
|
||||
["Notify:Channels:Email:SmtpHost"] = "localhost",
|
||||
["Notify:Channels:Email:SmtpPort"] = "9999" // Unlikely to be open
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - Should warn about connectivity failure
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("unreachable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_UsesDefaultSmtpPort_WhenNotSpecified()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true",
|
||||
["Notify:Channels:Email:SmtpHost"] = "localhost"
|
||||
// No port specified - should use 587
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - Should attempt connection (will likely fail but uses correct port)
|
||||
result.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReportsInvalidWebhookUrl()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Slack:Enabled"] = "true",
|
||||
["Notify:Channels:Slack:WebhookUrl"] = "not-a-valid-url"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesRemediationForFailedChannels()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true",
|
||||
["Notify:Channels:Email:SmtpHost"] = "nonexistent.example.com"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Remediation.Should().NotBeNull();
|
||||
result.Remediation!.Steps.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReportsMultipleChannelResults()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true",
|
||||
["Notify:Channels:Email:SmtpHost"] = "localhost",
|
||||
["Notify:Channels:Slack:Enabled"] = "true",
|
||||
["Notify:Channels:Slack:WebhookUrl"] = "https://hooks.slack.com/test"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Evidence.Should().NotBeNull();
|
||||
result.Evidence.Description.Should().Contain("Connectivity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_SkipsDisabledChannels()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "false",
|
||||
["Notify:Channels:Email:SmtpHost"] = "smtp.example.com",
|
||||
["Notify:Channels:Slack:Enabled"] = "true",
|
||||
["Notify:Channels:Slack:WebhookUrl"] = "https://hooks.slack.com/test"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - Email should not be tested since it's disabled
|
||||
// But Slack should be tested
|
||||
result.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesVerificationCommand()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true",
|
||||
["Notify:Channels:Email:SmtpHost"] = "localhost"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.VerificationCommand.Should().Contain("stella doctor --check check.notify.channel.connectivity");
|
||||
}
|
||||
|
||||
private static DoctorPluginContext CreateContext(Dictionary<string, string?> configValues)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configValues)
|
||||
.Build();
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = new ServiceCollection().BuildServiceProvider(),
|
||||
Configuration = config,
|
||||
TimeProvider = TimeProvider.System,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = config.GetSection("Doctor:Plugins")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Notify.Checks;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Notify.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class NotifyDeliveryTestCheckTests
|
||||
{
|
||||
private readonly NotifyDeliveryTestCheck _check = new();
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedValue()
|
||||
{
|
||||
_check.CheckId.Should().Be("check.notify.delivery.test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsExpectedValue()
|
||||
{
|
||||
_check.Name.Should().Be("Notification Delivery Health");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Description_ReturnsExpectedValue()
|
||||
{
|
||||
_check.Description.Should().Contain("delivery");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsWarn()
|
||||
{
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
_check.Tags.Should().Contain("notify");
|
||||
_check.Tags.Should().Contain("delivery");
|
||||
_check.Tags.Should().Contain("queue");
|
||||
_check.Tags.Should().Contain("health");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EstimatedDuration_IsReasonable()
|
||||
{
|
||||
_check.EstimatedDuration.Should().BeLessThanOrEqualTo(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsFalse_WhenNotifyNotConfigured()
|
||||
{
|
||||
var context = CreateContext(new Dictionary<string, string?>());
|
||||
_check.CanRun(context).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenNotifySectionExists()
|
||||
{
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true"
|
||||
});
|
||||
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Passes_WithDefaultConfiguration()
|
||||
{
|
||||
// Arrange - Notify section exists but no specific delivery settings
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - Should pass with defaults
|
||||
result.Severity.Should().BeOneOf(DoctorSeverity.Pass, DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Warns_WhenNoQueueTransportConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true"
|
||||
// No queue transport configured
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
if (result.Severity == DoctorSeverity.Warn && result.LikelyCauses is not null)
|
||||
{
|
||||
result.LikelyCauses.Should().Contain(c => c.Contains("queue") || c.Contains("in-memory"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Passes_WhenRedisQueueConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true",
|
||||
["Notify:Queue:Transport"] = "Redis",
|
||||
["Notify:Queue:Redis:ConnectionString"] = "localhost:6379"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().BeOneOf(DoctorSeverity.Pass, DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Passes_WhenNatsQueueConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true",
|
||||
["Notify:Queue:Transport"] = "NATS",
|
||||
["Notify:Queue:Nats:Url"] = "nats://localhost:4222"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().BeOneOf(DoctorSeverity.Pass, DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Fails_WhenMaxRetriesIsZero()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true",
|
||||
["Notify:Delivery:MaxRetries"] = "0"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Fails_WhenMaxRetriesIsNegative()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true",
|
||||
["Notify:Delivery:MaxRetries"] = "-1"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Warns_WhenMaxRetriesIsVeryHigh()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true",
|
||||
["Notify:Delivery:MaxRetries"] = "15"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Warns_WhenThrottleLimitIsVeryLow()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true",
|
||||
["Notify:Throttle:Enabled"] = "true",
|
||||
["Notify:Throttle:Limit"] = "5"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Passes_WhenThrottleConfiguredProperly()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true",
|
||||
["Notify:Throttle:Enabled"] = "true",
|
||||
["Notify:Throttle:Limit"] = "100",
|
||||
["Notify:Throttle:Window"] = "1m"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().BeOneOf(DoctorSeverity.Pass, DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesEvidenceForConfiguration()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true",
|
||||
["Notify:Queue:Transport"] = "Redis",
|
||||
["Notify:Delivery:MaxRetries"] = "5"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Evidence.Should().NotBeNull();
|
||||
result.Evidence.Description.Should().Contain("Delivery");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesDefaultChannel_WhenConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true",
|
||||
["Notify:DefaultChannel"] = "email"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Evidence.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesRemediation_OnFailure()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true",
|
||||
["Notify:Delivery:MaxRetries"] = "0"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Remediation.Should().NotBeNull();
|
||||
result.Remediation!.Steps.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesVerificationCommand()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
if (result.Severity != DoctorSeverity.Pass)
|
||||
{
|
||||
result.VerificationCommand.Should().Contain("stella doctor --check check.notify.delivery.test");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_FallsBackToRedisConnectionString_WhenTransportNotSet()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true",
|
||||
["ConnectionStrings:Redis"] = "localhost:6379"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - Should detect Redis from connection string
|
||||
result.Severity.Should().BeOneOf(DoctorSeverity.Pass, DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_FallsBackToNatsUrl_WhenTransportNotSet()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true",
|
||||
["Nats:Url"] = "nats://localhost:4222"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - Should detect NATS from global config
|
||||
result.Severity.Should().BeOneOf(DoctorSeverity.Pass, DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReportsDigestConfiguration()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Enabled"] = "true",
|
||||
["Notify:Digest:Enabled"] = "true",
|
||||
["Notify:Digest:Interval"] = "1h"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Evidence.Should().NotBeNull();
|
||||
result.Evidence.Data.Should().ContainKey("DigestEnabled");
|
||||
}
|
||||
|
||||
private static DoctorPluginContext CreateContext(Dictionary<string, string?> configValues)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configValues)
|
||||
.Build();
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = new ServiceCollection().BuildServiceProvider(),
|
||||
Configuration = config,
|
||||
TimeProvider = TimeProvider.System,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = config.GetSection("Doctor:Plugins")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Notify.Checks;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Notify.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class NotifyPluginTests
|
||||
{
|
||||
private readonly NotifyPlugin _plugin = new();
|
||||
|
||||
[Fact]
|
||||
public void PluginId_ReturnsExpectedValue()
|
||||
{
|
||||
_plugin.PluginId.Should().Be("stellaops.doctor.notify");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisplayName_ReturnsExpectedValue()
|
||||
{
|
||||
_plugin.DisplayName.Should().Be("Notifications");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Category_ReturnsNotify()
|
||||
{
|
||||
_plugin.Category.Should().Be(DoctorCategory.Notify);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Version_ReturnsVersion1()
|
||||
{
|
||||
_plugin.Version.Should().Be(new Version(1, 0, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinEngineVersion_ReturnsVersion1()
|
||||
{
|
||||
_plugin.MinEngineVersion.Should().Be(new Version(1, 0, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAvailable_ReturnsTrue()
|
||||
{
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
_plugin.IsAvailable(services).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChecks_ReturnsThreeChecks()
|
||||
{
|
||||
var context = CreateContext(new Dictionary<string, string?>());
|
||||
var checks = _plugin.GetChecks(context);
|
||||
|
||||
checks.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChecks_ContainsChannelConfigurationCheck()
|
||||
{
|
||||
var context = CreateContext(new Dictionary<string, string?>());
|
||||
var checks = _plugin.GetChecks(context);
|
||||
|
||||
checks.Should().ContainSingle(c => c is NotifyChannelConfigurationCheck);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChecks_ContainsChannelConnectivityCheck()
|
||||
{
|
||||
var context = CreateContext(new Dictionary<string, string?>());
|
||||
var checks = _plugin.GetChecks(context);
|
||||
|
||||
checks.Should().ContainSingle(c => c is NotifyChannelConnectivityCheck);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChecks_ContainsDeliveryTestCheck()
|
||||
{
|
||||
var context = CreateContext(new Dictionary<string, string?>());
|
||||
var checks = _plugin.GetChecks(context);
|
||||
|
||||
checks.Should().ContainSingle(c => c is NotifyDeliveryTestCheck);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_CompletesSuccessfully()
|
||||
{
|
||||
var context = CreateContext(new Dictionary<string, string?>());
|
||||
var act = () => _plugin.InitializeAsync(context, CancellationToken.None);
|
||||
|
||||
await act.Should().NotThrowAsync();
|
||||
}
|
||||
|
||||
private static DoctorPluginContext CreateContext(Dictionary<string, string?> configValues)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configValues)
|
||||
.Build();
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = new ServiceCollection().BuildServiceProvider(),
|
||||
Configuration = config,
|
||||
TimeProvider = TimeProvider.System,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = config.GetSection("Doctor:Plugins")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Doctor\StellaOps.Doctor.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Doctor.Plugins.Notify\StellaOps.Doctor.Plugins.Notify.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,271 @@
|
||||
using StellaOps.Orchestrator.Schemas;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Orchestrator.Schemas.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for OrchestratorEnvelope generic record.
|
||||
/// </summary>
|
||||
public sealed class OrchestratorEnvelopeTests
|
||||
{
|
||||
[Fact]
|
||||
public void OrchestratorEnvelope_RequiredProperties_HaveDefaults()
|
||||
{
|
||||
var envelope = new OrchestratorEnvelope<string>
|
||||
{
|
||||
Payload = "test"
|
||||
};
|
||||
|
||||
Assert.Equal(Guid.Empty, envelope.EventId);
|
||||
Assert.Equal(string.Empty, envelope.Kind);
|
||||
Assert.Equal(0, envelope.Version);
|
||||
Assert.Equal(string.Empty, envelope.Tenant);
|
||||
Assert.Equal(string.Empty, envelope.Source);
|
||||
Assert.Equal(string.Empty, envelope.IdempotencyKey);
|
||||
Assert.Equal("test", envelope.Payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrchestratorEnvelope_WithAllProperties_ContainsValues()
|
||||
{
|
||||
var eventId = Guid.NewGuid();
|
||||
var occurredAt = DateTimeOffset.UtcNow;
|
||||
var recordedAt = occurredAt.AddSeconds(1);
|
||||
|
||||
var envelope = new OrchestratorEnvelope<string>
|
||||
{
|
||||
EventId = eventId,
|
||||
Kind = OrchestratorEventKinds.ScannerReportReady,
|
||||
Version = 1,
|
||||
Tenant = "tenant-001",
|
||||
OccurredAt = occurredAt,
|
||||
RecordedAt = recordedAt,
|
||||
Source = "scanner-service",
|
||||
IdempotencyKey = "idem-key-123",
|
||||
CorrelationId = "corr-456",
|
||||
TraceId = "trace-789",
|
||||
SpanId = "span-abc",
|
||||
Payload = "report-data"
|
||||
};
|
||||
|
||||
Assert.Equal(eventId, envelope.EventId);
|
||||
Assert.Equal(OrchestratorEventKinds.ScannerReportReady, envelope.Kind);
|
||||
Assert.Equal(1, envelope.Version);
|
||||
Assert.Equal("tenant-001", envelope.Tenant);
|
||||
Assert.Equal(occurredAt, envelope.OccurredAt);
|
||||
Assert.Equal(recordedAt, envelope.RecordedAt);
|
||||
Assert.Equal("scanner-service", envelope.Source);
|
||||
Assert.Equal("idem-key-123", envelope.IdempotencyKey);
|
||||
Assert.Equal("corr-456", envelope.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrchestratorEnvelope_WithScope_ContainsScope()
|
||||
{
|
||||
var scope = new OrchestratorScope
|
||||
{
|
||||
Namespace = "production",
|
||||
Repo = "myapp",
|
||||
Digest = "sha256:abc123",
|
||||
Component = "api",
|
||||
Image = "myapp:v1.0.0"
|
||||
};
|
||||
|
||||
var envelope = new OrchestratorEnvelope<string>
|
||||
{
|
||||
Scope = scope,
|
||||
Payload = "test"
|
||||
};
|
||||
|
||||
Assert.NotNull(envelope.Scope);
|
||||
Assert.Equal("production", envelope.Scope.Namespace);
|
||||
Assert.Equal("myapp", envelope.Scope.Repo);
|
||||
Assert.Equal("sha256:abc123", envelope.Scope.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrchestratorEnvelope_WithAttributes_ContainsAttributes()
|
||||
{
|
||||
var attributes = new Dictionary<string, string>
|
||||
{
|
||||
["region"] = "us-east-1",
|
||||
["environment"] = "staging"
|
||||
};
|
||||
|
||||
var envelope = new OrchestratorEnvelope<string>
|
||||
{
|
||||
Attributes = attributes,
|
||||
Payload = "test"
|
||||
};
|
||||
|
||||
Assert.NotNull(envelope.Attributes);
|
||||
Assert.Equal(2, envelope.Attributes.Count);
|
||||
Assert.Equal("us-east-1", envelope.Attributes["region"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrchestratorEnvelope_OptionalProperties_AreNullByDefault()
|
||||
{
|
||||
var envelope = new OrchestratorEnvelope<string>
|
||||
{
|
||||
Payload = "test"
|
||||
};
|
||||
|
||||
Assert.Null(envelope.RecordedAt);
|
||||
Assert.Null(envelope.CorrelationId);
|
||||
Assert.Null(envelope.TraceId);
|
||||
Assert.Null(envelope.SpanId);
|
||||
Assert.Null(envelope.Scope);
|
||||
Assert.Null(envelope.Attributes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrchestratorEnvelope_GenericPayload_WorksWithComplexTypes()
|
||||
{
|
||||
var payload = new ScannerReportReadyPayload
|
||||
{
|
||||
ReportId = "report-001",
|
||||
Verdict = "PASS"
|
||||
};
|
||||
|
||||
var envelope = new OrchestratorEnvelope<ScannerReportReadyPayload>
|
||||
{
|
||||
Kind = OrchestratorEventKinds.ScannerReportReady,
|
||||
Payload = payload
|
||||
};
|
||||
|
||||
Assert.Equal("report-001", envelope.Payload.ReportId);
|
||||
Assert.Equal("PASS", envelope.Payload.Verdict);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for OrchestratorScope record.
|
||||
/// </summary>
|
||||
public sealed class OrchestratorScopeTests
|
||||
{
|
||||
[Fact]
|
||||
public void OrchestratorScope_RequiredProperties_HaveDefaults()
|
||||
{
|
||||
var scope = new OrchestratorScope();
|
||||
|
||||
Assert.Null(scope.Namespace);
|
||||
Assert.Equal(string.Empty, scope.Repo);
|
||||
Assert.Equal(string.Empty, scope.Digest);
|
||||
Assert.Null(scope.Component);
|
||||
Assert.Null(scope.Image);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrchestratorScope_WithAllProperties_ContainsValues()
|
||||
{
|
||||
var scope = new OrchestratorScope
|
||||
{
|
||||
Namespace = "default",
|
||||
Repo = "registry.example.com/myapp",
|
||||
Digest = "sha256:1234567890abcdef",
|
||||
Component = "backend",
|
||||
Image = "myapp:latest"
|
||||
};
|
||||
|
||||
Assert.Equal("default", scope.Namespace);
|
||||
Assert.Equal("registry.example.com/myapp", scope.Repo);
|
||||
Assert.Equal("sha256:1234567890abcdef", scope.Digest);
|
||||
Assert.Equal("backend", scope.Component);
|
||||
Assert.Equal("myapp:latest", scope.Image);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrchestratorScope_RecordEquality_WorksCorrectly()
|
||||
{
|
||||
var scope1 = new OrchestratorScope
|
||||
{
|
||||
Repo = "myapp",
|
||||
Digest = "sha256:abc"
|
||||
};
|
||||
|
||||
var scope2 = new OrchestratorScope
|
||||
{
|
||||
Repo = "myapp",
|
||||
Digest = "sha256:abc"
|
||||
};
|
||||
|
||||
Assert.Equal(scope1, scope2);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for OrchestratorEventKinds constants.
|
||||
/// </summary>
|
||||
public sealed class OrchestratorEventKindsTests
|
||||
{
|
||||
[Fact]
|
||||
public void OrchestratorEventKinds_ScannerReportReady_HasCorrectValue()
|
||||
{
|
||||
Assert.Equal("scanner.event.report.ready", OrchestratorEventKinds.ScannerReportReady);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrchestratorEventKinds_ScannerScanCompleted_HasCorrectValue()
|
||||
{
|
||||
Assert.Equal("scanner.event.scan.completed", OrchestratorEventKinds.ScannerScanCompleted);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for ScannerReportReadyPayload record.
|
||||
/// </summary>
|
||||
public sealed class ScannerReportReadyPayloadTests
|
||||
{
|
||||
[Fact]
|
||||
public void ScannerReportReadyPayload_RequiredProperties_HaveDefaults()
|
||||
{
|
||||
var payload = new ScannerReportReadyPayload();
|
||||
|
||||
Assert.Equal(string.Empty, payload.ReportId);
|
||||
Assert.Null(payload.ScanId);
|
||||
Assert.Null(payload.ImageDigest);
|
||||
Assert.Equal(string.Empty, payload.Verdict);
|
||||
Assert.NotNull(payload.Summary);
|
||||
Assert.NotNull(payload.Links);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScannerReportReadyPayload_WithAllProperties_ContainsValues()
|
||||
{
|
||||
var generatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
var payload = new ScannerReportReadyPayload
|
||||
{
|
||||
ReportId = "rpt-001",
|
||||
ScanId = "scan-001",
|
||||
ImageDigest = "sha256:xyz789",
|
||||
GeneratedAt = generatedAt,
|
||||
Verdict = "FAIL",
|
||||
QuietedFindingCount = 5
|
||||
};
|
||||
|
||||
Assert.Equal("rpt-001", payload.ReportId);
|
||||
Assert.Equal("scan-001", payload.ScanId);
|
||||
Assert.Equal("sha256:xyz789", payload.ImageDigest);
|
||||
Assert.Equal(generatedAt, payload.GeneratedAt);
|
||||
Assert.Equal("FAIL", payload.Verdict);
|
||||
Assert.Equal(5, payload.QuietedFindingCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PASS")]
|
||||
[InlineData("FAIL")]
|
||||
[InlineData("WARN")]
|
||||
[InlineData("UNKNOWN")]
|
||||
public void ScannerReportReadyPayload_Verdict_SupportedValues(string verdict)
|
||||
{
|
||||
var payload = new ScannerReportReadyPayload
|
||||
{
|
||||
Verdict = verdict
|
||||
};
|
||||
|
||||
Assert.Equal(verdict, payload.Verdict);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Orchestrator.Schemas\StellaOps.Orchestrator.Schemas.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"diagnosticMessages": true,
|
||||
"parallelizeAssembly": true,
|
||||
"parallelizeTestCollections": true,
|
||||
"maxParallelThreads": -1
|
||||
}
|
||||
@@ -324,7 +324,7 @@ internal static class LatticeArbs
|
||||
|
||||
public static Arbitrary<List<EvidenceType>> EvidenceSequence(int minLength, int maxLength) =>
|
||||
(from length in Gen.Choose(minLength, maxLength)
|
||||
from sequence in Gen.ListOf(length, Gen.Elements(AllEvidenceTypes))
|
||||
from sequence in Gen.ListOf<EvidenceType>(Gen.Elements(AllEvidenceTypes), length)
|
||||
select sequence.ToList()).ToArbitrary();
|
||||
|
||||
public static Arbitrary<(LatticeState, EvidenceType)> ReinforcingEvidencePair()
|
||||
@@ -345,7 +345,8 @@ internal static class LatticeArbs
|
||||
{
|
||||
Purl = "pkg:npm/test@1.0.0",
|
||||
Namespace = "test",
|
||||
SymbolName = "testFunc"
|
||||
Type = "_",
|
||||
Method = "testFunc"
|
||||
};
|
||||
|
||||
var reachableResult = new StaticReachabilityResult
|
||||
@@ -381,7 +382,8 @@ internal static class LatticeArbs
|
||||
{
|
||||
Purl = "pkg:npm/test@1.0.0",
|
||||
Namespace = "test",
|
||||
SymbolName = "testFunc"
|
||||
Type = "_",
|
||||
Method = "testFunc"
|
||||
};
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
using StellaOps.Signals.Contracts;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Contracts.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for SignalEnvelope model.
|
||||
/// </summary>
|
||||
public sealed class SignalEnvelopeTests
|
||||
{
|
||||
[Fact]
|
||||
public void SignalEnvelope_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var envelope = new SignalEnvelope
|
||||
{
|
||||
SignalKey = "pkg:npm/lodash@4.17.21:reachability",
|
||||
SignalType = SignalType.Reachability,
|
||||
Value = new { reachable = true, confidence = 0.95 },
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
SourceService = "reachability-analyzer"
|
||||
};
|
||||
|
||||
Assert.Equal("pkg:npm/lodash@4.17.21:reachability", envelope.SignalKey);
|
||||
Assert.Equal(SignalType.Reachability, envelope.SignalType);
|
||||
Assert.Equal("reachability-analyzer", envelope.SourceService);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SignalType.Reachability)]
|
||||
[InlineData(SignalType.Entropy)]
|
||||
[InlineData(SignalType.Exploitability)]
|
||||
[InlineData(SignalType.Trust)]
|
||||
[InlineData(SignalType.UnknownSymbol)]
|
||||
[InlineData(SignalType.Custom)]
|
||||
public void SignalEnvelope_SignalType_AllValues_AreValid(SignalType type)
|
||||
{
|
||||
var envelope = new SignalEnvelope
|
||||
{
|
||||
SignalKey = $"test:{type.ToString().ToLowerInvariant()}",
|
||||
SignalType = type,
|
||||
Value = new { test = true },
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
SourceService = "test-service"
|
||||
};
|
||||
|
||||
Assert.Equal(type, envelope.SignalType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignalEnvelope_DefaultSchemaVersion_IsOne()
|
||||
{
|
||||
var envelope = new SignalEnvelope
|
||||
{
|
||||
SignalKey = "test:schema",
|
||||
SignalType = SignalType.Custom,
|
||||
Value = new { },
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
SourceService = "test"
|
||||
};
|
||||
|
||||
Assert.Equal("1.0", envelope.SchemaVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignalEnvelope_OptionalProperties_AreNullByDefault()
|
||||
{
|
||||
var envelope = new SignalEnvelope
|
||||
{
|
||||
SignalKey = "test:optional",
|
||||
SignalType = SignalType.Reachability,
|
||||
Value = new { },
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
SourceService = "test"
|
||||
};
|
||||
|
||||
Assert.Null(envelope.TenantId);
|
||||
Assert.Null(envelope.CorrelationId);
|
||||
Assert.Null(envelope.ProvenanceDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignalEnvelope_WithAllOptionalProperties_ContainsValues()
|
||||
{
|
||||
var envelope = new SignalEnvelope
|
||||
{
|
||||
SignalKey = "pkg:pypi/django@4.2.0:trust",
|
||||
SignalType = SignalType.Trust,
|
||||
Value = new { score = 0.85 },
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
SourceService = "trust-engine",
|
||||
TenantId = "tenant-001",
|
||||
CorrelationId = "corr-abc123",
|
||||
ProvenanceDigest = "sha256:xyz789",
|
||||
SchemaVersion = "2.0"
|
||||
};
|
||||
|
||||
Assert.Equal("tenant-001", envelope.TenantId);
|
||||
Assert.Equal("corr-abc123", envelope.CorrelationId);
|
||||
Assert.Equal("sha256:xyz789", envelope.ProvenanceDigest);
|
||||
Assert.Equal("2.0", envelope.SchemaVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignalEnvelope_Value_CanBeAnyObject()
|
||||
{
|
||||
var reachabilityValue = new
|
||||
{
|
||||
reachable = true,
|
||||
paths = new[] { "main->helper->vulnerable" },
|
||||
confidence = 0.92
|
||||
};
|
||||
|
||||
var envelope = new SignalEnvelope
|
||||
{
|
||||
SignalKey = "test:value",
|
||||
SignalType = SignalType.Reachability,
|
||||
Value = reachabilityValue,
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
SourceService = "analyzer"
|
||||
};
|
||||
|
||||
Assert.NotNull(envelope.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignalEnvelope_RecordEquality_WorksCorrectly()
|
||||
{
|
||||
var computedAt = DateTimeOffset.UtcNow;
|
||||
var value = new { test = 123 };
|
||||
|
||||
var envelope1 = new SignalEnvelope
|
||||
{
|
||||
SignalKey = "test:eq",
|
||||
SignalType = SignalType.Entropy,
|
||||
Value = value,
|
||||
ComputedAt = computedAt,
|
||||
SourceService = "test"
|
||||
};
|
||||
|
||||
var envelope2 = new SignalEnvelope
|
||||
{
|
||||
SignalKey = "test:eq",
|
||||
SignalType = SignalType.Entropy,
|
||||
Value = value,
|
||||
ComputedAt = computedAt,
|
||||
SourceService = "test"
|
||||
};
|
||||
|
||||
Assert.Equal(envelope1, envelope2);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for SignalType enum.
|
||||
/// </summary>
|
||||
public sealed class SignalTypeTests
|
||||
{
|
||||
[Fact]
|
||||
public void SignalType_AllDefinedValues_AreCounted()
|
||||
{
|
||||
var values = Enum.GetValues<SignalType>();
|
||||
|
||||
// Ensure we have expected signal types
|
||||
Assert.Equal(6, values.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignalType_Reachability_HasValue()
|
||||
{
|
||||
Assert.Equal(0, (int)SignalType.Reachability);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignalType_Custom_IsLast()
|
||||
{
|
||||
var values = Enum.GetValues<SignalType>();
|
||||
var last = values.Max();
|
||||
|
||||
Assert.Equal(SignalType.Custom, last);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Signals.Contracts\StellaOps.Signals.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"diagnosticMessages": true,
|
||||
"parallelizeAssembly": true,
|
||||
"parallelizeTestCollections": true,
|
||||
"maxParallelThreads": -1
|
||||
}
|
||||
Reference in New Issue
Block a user