audit work, doctors work
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user