audit work, doctors work

This commit is contained in:
master
2026-01-12 23:39:07 +02:00
parent 9330c64349
commit b8868a5f13
80 changed files with 12659 additions and 87 deletions

View File

@@ -0,0 +1,192 @@
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using StellaOps.Doctor.Export;
using Xunit;
namespace StellaOps.Doctor.Tests.Export;
[Trait("Category", "Unit")]
public class ConfigurationSanitizerTests
{
private readonly ConfigurationSanitizer _sanitizer = new();
[Fact]
public void Sanitize_RedactsPasswordKeys()
{
// Arrange
var config = BuildConfiguration(new Dictionary<string, string?>
{
["Database:ConnectionString"] = "Server=localhost;Password=secret123",
["Database:Server"] = "localhost",
["Api:Key"] = "abc123",
["Api:Endpoint"] = "https://api.example.com"
});
// Act
var result = _sanitizer.Sanitize(config);
// Assert
var dbSection = result.Values["Database"] as IDictionary<string, object>;
dbSection.Should().NotBeNull();
dbSection!["ConnectionString"].Should().Be("***REDACTED***");
dbSection["Server"].Should().Be("localhost");
var apiSection = result.Values["Api"] as IDictionary<string, object>;
apiSection.Should().NotBeNull();
apiSection!["Key"].Should().Be("***REDACTED***");
apiSection["Endpoint"].Should().Be("https://api.example.com");
}
[Fact]
public void Sanitize_RedactsSecretKeys()
{
// Arrange
var config = BuildConfiguration(new Dictionary<string, string?>
{
["OAuth:ClientSecret"] = "very-secret-value",
["OAuth:ClientId"] = "my-client-id"
});
// Act
var result = _sanitizer.Sanitize(config);
// Assert
var oauthSection = result.Values["OAuth"] as IDictionary<string, object>;
oauthSection.Should().NotBeNull();
oauthSection!["ClientSecret"].Should().Be("***REDACTED***");
oauthSection["ClientId"].Should().Be("my-client-id");
}
[Fact]
public void Sanitize_RedactsTokenKeys()
{
// Arrange
var config = BuildConfiguration(new Dictionary<string, string?>
{
["Auth:BearerToken"] = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
["Auth:RefreshToken"] = "refresh-token-value",
["Auth:TokenEndpoint"] = "https://auth.example.com/token"
});
// Act
var result = _sanitizer.Sanitize(config);
// Assert
var authSection = result.Values["Auth"] as IDictionary<string, object>;
authSection.Should().NotBeNull();
authSection!["BearerToken"].Should().Be("***REDACTED***");
authSection["RefreshToken"].Should().Be("***REDACTED***");
authSection["TokenEndpoint"].Should().Be("https://auth.example.com/token");
}
[Fact]
public void Sanitize_RecordsSanitizedKeys()
{
// Arrange
var config = BuildConfiguration(new Dictionary<string, string?>
{
["Database:Password"] = "secret",
["Api:ApiKey"] = "key123",
["Safe:Value"] = "not-secret"
});
// Act
var result = _sanitizer.Sanitize(config);
// Assert
result.SanitizedKeys.Should().Contain("Database:Password");
result.SanitizedKeys.Should().Contain("Api:ApiKey");
result.SanitizedKeys.Should().NotContain("Safe:Value");
}
[Fact]
public void Sanitize_HandlesEmptyConfiguration()
{
// Arrange
var config = BuildConfiguration(new Dictionary<string, string?>());
// Act
var result = _sanitizer.Sanitize(config);
// Assert
result.Values.Should().BeEmpty();
result.SanitizedKeys.Should().BeEmpty();
}
[Fact]
public void Sanitize_HandlesNullValues()
{
// Arrange
var config = BuildConfiguration(new Dictionary<string, string?>
{
["NullValue"] = null,
["EmptyValue"] = ""
});
// Act
var result = _sanitizer.Sanitize(config);
// Assert
result.Values["NullValue"].Should().Be("(null)");
result.Values["EmptyValue"].Should().Be("");
}
[Fact]
public void Sanitize_RedactsConnectionStringKeys()
{
// Arrange
var config = BuildConfiguration(new Dictionary<string, string?>
{
["ConnectionStrings:Default"] = "Server=localhost;Database=db;User=admin;Password=secret",
["ConnectionStrings:Redis"] = "redis://localhost:6379"
});
// Act
var result = _sanitizer.Sanitize(config);
// Assert
var connSection = result.Values["ConnectionStrings"] as IDictionary<string, object>;
connSection.Should().NotBeNull();
// ConnectionStrings key contains "connectionstring" so it should be redacted
connSection!["Default"].Should().Be("***REDACTED***");
}
[Theory]
[InlineData("password")]
[InlineData("Password")]
[InlineData("PASSWORD")]
[InlineData("secret")]
[InlineData("Secret")]
[InlineData("apikey")]
[InlineData("ApiKey")]
[InlineData("api_key")]
[InlineData("token")]
[InlineData("Token")]
[InlineData("credentials")]
[InlineData("Credentials")]
public void IsKeySensitive_DetectsVariousSensitivePatterns(string key)
{
// Act & Assert
ConfigurationSanitizer.IsKeySensitive(key).Should().BeTrue();
}
[Theory]
[InlineData("endpoint")]
[InlineData("host")]
[InlineData("port")]
[InlineData("name")]
[InlineData("value")]
[InlineData("enabled")]
public void IsKeySensitive_AllowsNonSensitiveKeys(string key)
{
// Act & Assert
ConfigurationSanitizer.IsKeySensitive(key).Should().BeFalse();
}
private static IConfiguration BuildConfiguration(Dictionary<string, string?> values)
{
return new ConfigurationBuilder()
.AddInMemoryCollection(values)
.Build();
}
}

View File

@@ -0,0 +1,260 @@
using System.IO.Compression;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Doctor.Engine;
using StellaOps.Doctor.Export;
using StellaOps.Doctor.Models;
using Xunit;
namespace StellaOps.Doctor.Tests.Export;
[Trait("Category", "Unit")]
public class DiagnosticBundleGeneratorTests
{
private readonly Mock<DoctorEngine> _mockEngine;
private readonly IConfiguration _configuration;
private readonly TimeProvider _timeProvider;
private readonly Mock<IHostEnvironment> _mockHostEnvironment;
private readonly DiagnosticBundleGenerator _generator;
public DiagnosticBundleGeneratorTests()
{
_mockEngine = new Mock<DoctorEngine>(MockBehavior.Loose, null!, null!, null!, null!, null!, null!);
_configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Database:Server"] = "localhost",
["Database:Password"] = "secret"
})
.Build();
_timeProvider = TimeProvider.System;
_mockHostEnvironment = new Mock<IHostEnvironment>();
_mockHostEnvironment.Setup(h => h.EnvironmentName).Returns("Test");
// Create a mock report
var mockReport = new DoctorReport
{
RunId = "dr_test_123",
StartedAt = DateTimeOffset.UtcNow,
CompletedAt = DateTimeOffset.UtcNow.AddSeconds(5),
Duration = TimeSpan.FromSeconds(5),
OverallSeverity = DoctorSeverity.Pass,
Summary = new DoctorReportSummary
{
Passed = 3,
Info = 0,
Warnings = 1,
Failed = 0,
Skipped = 0
},
Results = []
};
_mockEngine.Setup(e => e.RunAsync(
It.IsAny<DoctorRunOptions>(),
It.IsAny<IProgress<DoctorCheckProgress>?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(mockReport);
_generator = new DiagnosticBundleGenerator(
_mockEngine.Object,
_configuration,
_timeProvider,
_mockHostEnvironment.Object,
NullLogger<DiagnosticBundleGenerator>.Instance);
}
[Fact]
public async Task GenerateAsync_ReturnsBundle_WithCorrectStructure()
{
// Arrange
var options = new DiagnosticBundleOptions
{
IncludeConfig = true,
IncludeLogs = false
};
// Act
var bundle = await _generator.GenerateAsync(options, CancellationToken.None);
// Assert
bundle.Should().NotBeNull();
bundle.DoctorReport.Should().NotBeNull();
bundle.Environment.Should().NotBeNull();
bundle.SystemInfo.Should().NotBeNull();
bundle.Version.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task GenerateAsync_IncludesSanitizedConfig_WhenEnabled()
{
// Arrange
var options = new DiagnosticBundleOptions
{
IncludeConfig = true,
IncludeLogs = false
};
// Act
var bundle = await _generator.GenerateAsync(options, CancellationToken.None);
// Assert
bundle.Configuration.Should().NotBeNull();
bundle.Configuration!.SanitizedKeys.Should().Contain("Database:Password");
}
[Fact]
public async Task GenerateAsync_ExcludesConfig_WhenDisabled()
{
// Arrange
var options = new DiagnosticBundleOptions
{
IncludeConfig = false,
IncludeLogs = false
};
// Act
var bundle = await _generator.GenerateAsync(options, CancellationToken.None);
// Assert
bundle.Configuration.Should().BeNull();
}
[Fact]
public async Task GenerateAsync_IncludesEnvironmentInfo()
{
// Arrange
var options = new DiagnosticBundleOptions();
// Act
var bundle = await _generator.GenerateAsync(options, CancellationToken.None);
// Assert
bundle.Environment.Hostname.Should().NotBeNullOrEmpty();
bundle.Environment.Platform.Should().NotBeNullOrEmpty();
bundle.Environment.DotNetVersion.Should().NotBeNullOrEmpty();
bundle.Environment.ProcessId.Should().BeGreaterThan(0);
bundle.Environment.EnvironmentName.Should().Be("Test");
}
[Fact]
public async Task GenerateAsync_IncludesSystemInfo()
{
// Arrange
var options = new DiagnosticBundleOptions();
// Act
var bundle = await _generator.GenerateAsync(options, CancellationToken.None);
// Assert
bundle.SystemInfo.TotalMemoryBytes.Should().BeGreaterThan(0);
bundle.SystemInfo.ProcessMemoryBytes.Should().BeGreaterThan(0);
bundle.SystemInfo.ProcessorCount.Should().BeGreaterThan(0);
}
[Fact]
public async Task ExportToZipAsync_CreatesValidZipFile()
{
// Arrange
var options = new DiagnosticBundleOptions
{
IncludeConfig = true,
IncludeLogs = false
};
var bundle = await _generator.GenerateAsync(options, CancellationToken.None);
var tempPath = Path.Combine(Path.GetTempPath(), $"test-bundle-{Guid.NewGuid():N}.zip");
try
{
// Act
await _generator.ExportToZipAsync(bundle, tempPath, CancellationToken.None);
// Assert
File.Exists(tempPath).Should().BeTrue();
using var archive = ZipFile.OpenRead(tempPath);
archive.Entries.Select(e => e.FullName).Should().Contain("doctor-report.json");
archive.Entries.Select(e => e.FullName).Should().Contain("doctor-report.md");
archive.Entries.Select(e => e.FullName).Should().Contain("environment.json");
archive.Entries.Select(e => e.FullName).Should().Contain("system-info.json");
archive.Entries.Select(e => e.FullName).Should().Contain("config-sanitized.json");
archive.Entries.Select(e => e.FullName).Should().Contain("README.md");
}
finally
{
if (File.Exists(tempPath))
{
File.Delete(tempPath);
}
}
}
[Fact]
public async Task ExportToZipAsync_ExcludesConfig_WhenNotIncluded()
{
// Arrange
var options = new DiagnosticBundleOptions
{
IncludeConfig = false,
IncludeLogs = false
};
var bundle = await _generator.GenerateAsync(options, CancellationToken.None);
var tempPath = Path.Combine(Path.GetTempPath(), $"test-bundle-{Guid.NewGuid():N}.zip");
try
{
// Act
await _generator.ExportToZipAsync(bundle, tempPath, CancellationToken.None);
// Assert
using var archive = ZipFile.OpenRead(tempPath);
archive.Entries.Select(e => e.FullName).Should().NotContain("config-sanitized.json");
}
finally
{
if (File.Exists(tempPath))
{
File.Delete(tempPath);
}
}
}
[Fact]
public async Task ExportToZipAsync_ReadmeContainsSummary()
{
// Arrange
var options = new DiagnosticBundleOptions();
var bundle = await _generator.GenerateAsync(options, CancellationToken.None);
var tempPath = Path.Combine(Path.GetTempPath(), $"test-bundle-{Guid.NewGuid():N}.zip");
try
{
// Act
await _generator.ExportToZipAsync(bundle, tempPath, CancellationToken.None);
// Assert
using var archive = ZipFile.OpenRead(tempPath);
var readmeEntry = archive.GetEntry("README.md");
readmeEntry.Should().NotBeNull();
using var stream = readmeEntry!.Open();
using var reader = new StreamReader(stream);
var readmeContent = await reader.ReadToEndAsync();
readmeContent.Should().Contain("Stella Ops Diagnostic Bundle");
readmeContent.Should().Contain("Passed: 3");
readmeContent.Should().Contain("Warnings: 1");
}
finally
{
if (File.Exists(tempPath))
{
File.Delete(tempPath);
}
}
}
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Doctor\StellaOps.Doctor.csproj" />
</ItemGroup>
</Project>