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

@@ -1,6 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Doctor.Engine;
using StellaOps.Doctor.Export;
using StellaOps.Doctor.Output;
using StellaOps.Doctor.Plugins;
@@ -27,6 +28,10 @@ public static class DoctorServiceCollectionExtensions
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDoctorReportFormatter, MarkdownReportFormatter>());
services.TryAddSingleton<ReportFormatterFactory>();
// Export services
services.TryAddSingleton<ConfigurationSanitizer>();
services.TryAddSingleton<DiagnosticBundleGenerator>();
// Ensure TimeProvider is registered
services.TryAddSingleton(TimeProvider.System);

View File

@@ -0,0 +1,112 @@
using Microsoft.Extensions.Configuration;
namespace StellaOps.Doctor.Export;
/// <summary>
/// Sanitizes configuration by removing sensitive values.
/// </summary>
public sealed class ConfigurationSanitizer
{
private const string RedactedValue = "***REDACTED***";
private static readonly HashSet<string> SensitiveKeys = new(StringComparer.OrdinalIgnoreCase)
{
"password",
"secret",
"key",
"token",
"apikey",
"api_key",
"connectionstring",
"connection_string",
"credentials",
"accesskey",
"access_key",
"secretkey",
"secret_key",
"private",
"privatekey",
"private_key",
"cert",
"certificate",
"passphrase",
"auth",
"bearer",
"jwt",
"oauth",
"client_secret",
"clientsecret"
};
/// <summary>
/// Sanitizes the configuration, replacing sensitive values with [REDACTED].
/// </summary>
public SanitizedConfiguration Sanitize(IConfiguration configuration)
{
var sanitizedKeys = new List<string>();
var values = SanitizeSection(configuration, string.Empty, sanitizedKeys);
return new SanitizedConfiguration
{
Values = values,
SanitizedKeys = sanitizedKeys
};
}
private Dictionary<string, object> SanitizeSection(
IConfiguration config,
string prefix,
List<string> sanitizedKeys)
{
var result = new Dictionary<string, object>();
foreach (var section in config.GetChildren())
{
var fullKey = string.IsNullOrEmpty(prefix)
? section.Key
: $"{prefix}:{section.Key}";
var children = section.GetChildren().ToList();
if (children.Count == 0)
{
// Leaf value
if (IsSensitiveKey(section.Key))
{
result[section.Key] = RedactedValue;
sanitizedKeys.Add(fullKey);
}
else
{
result[section.Key] = section.Value ?? "(null)";
}
}
else
{
// Section with children
result[section.Key] = SanitizeSection(section, fullKey, sanitizedKeys);
}
}
return result;
}
private static bool IsSensitiveKey(string key)
{
// Check if any sensitive keyword is contained in the key
foreach (var sensitiveKey in SensitiveKeys)
{
if (key.Contains(sensitiveKey, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
/// <summary>
/// Checks if a key appears to be sensitive.
/// </summary>
public static bool IsKeySensitive(string key) => IsSensitiveKey(key);
}

View File

@@ -0,0 +1,147 @@
using StellaOps.Doctor.Models;
namespace StellaOps.Doctor.Export;
/// <summary>
/// Complete diagnostic bundle for support tickets and troubleshooting.
/// </summary>
public sealed record DiagnosticBundle
{
/// <summary>
/// When this bundle was generated.
/// </summary>
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>
/// Stella Ops version that generated this bundle.
/// </summary>
public required string Version { get; init; }
/// <summary>
/// Environment information.
/// </summary>
public required EnvironmentInfo Environment { get; init; }
/// <summary>
/// The doctor report with all check results.
/// </summary>
public required DoctorReport DoctorReport { get; init; }
/// <summary>
/// Sanitized configuration (secrets removed).
/// </summary>
public SanitizedConfiguration? Configuration { get; init; }
/// <summary>
/// Recent log file contents (filename -> content).
/// </summary>
public IReadOnlyDictionary<string, string>? Logs { get; init; }
/// <summary>
/// System resource information.
/// </summary>
public required SystemInfo SystemInfo { get; init; }
}
/// <summary>
/// Environment information for the diagnostic bundle.
/// </summary>
public sealed record EnvironmentInfo
{
/// <summary>
/// Machine hostname.
/// </summary>
public required string Hostname { get; init; }
/// <summary>
/// Operating system description.
/// </summary>
public required string Platform { get; init; }
/// <summary>
/// .NET runtime version.
/// </summary>
public required string DotNetVersion { get; init; }
/// <summary>
/// Current process ID.
/// </summary>
public required int ProcessId { get; init; }
/// <summary>
/// Working directory.
/// </summary>
public required string WorkingDirectory { get; init; }
/// <summary>
/// Process start time (UTC).
/// </summary>
public required DateTimeOffset StartTime { get; init; }
/// <summary>
/// Environment name (Development, Production, etc.).
/// </summary>
public string? EnvironmentName { get; init; }
}
/// <summary>
/// System resource information.
/// </summary>
public sealed record SystemInfo
{
/// <summary>
/// Total available memory in bytes.
/// </summary>
public required long TotalMemoryBytes { get; init; }
/// <summary>
/// Process working set in bytes.
/// </summary>
public required long ProcessMemoryBytes { get; init; }
/// <summary>
/// Number of logical processors.
/// </summary>
public required int ProcessorCount { get; init; }
/// <summary>
/// Process uptime.
/// </summary>
public required TimeSpan Uptime { get; init; }
/// <summary>
/// GC heap size in bytes.
/// </summary>
public long GcHeapSizeBytes { get; init; }
/// <summary>
/// Number of GC collections (Gen 0).
/// </summary>
public int Gen0Collections { get; init; }
/// <summary>
/// Number of GC collections (Gen 1).
/// </summary>
public int Gen1Collections { get; init; }
/// <summary>
/// Number of GC collections (Gen 2).
/// </summary>
public int Gen2Collections { get; init; }
}
/// <summary>
/// Sanitized configuration with secrets removed.
/// </summary>
public sealed record SanitizedConfiguration
{
/// <summary>
/// Configuration values (secrets replaced with [REDACTED]).
/// </summary>
public required IReadOnlyDictionary<string, object> Values { get; init; }
/// <summary>
/// List of keys that were sanitized.
/// </summary>
public required IReadOnlyList<string> SanitizedKeys { get; init; }
}

View File

@@ -0,0 +1,340 @@
using System.Diagnostics;
using System.Globalization;
using System.IO.Compression;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Doctor.Engine;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Output;
namespace StellaOps.Doctor.Export;
/// <summary>
/// Generates diagnostic bundles for support tickets.
/// </summary>
public sealed class DiagnosticBundleGenerator
{
private readonly DoctorEngine _engine;
private readonly IConfiguration _configuration;
private readonly TimeProvider _timeProvider;
private readonly IHostEnvironment? _hostEnvironment;
private readonly ILogger<DiagnosticBundleGenerator> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// Creates a new diagnostic bundle generator.
/// </summary>
public DiagnosticBundleGenerator(
DoctorEngine engine,
IConfiguration configuration,
TimeProvider timeProvider,
IHostEnvironment? hostEnvironment,
ILogger<DiagnosticBundleGenerator> logger)
{
_engine = engine;
_configuration = configuration;
_timeProvider = timeProvider;
_hostEnvironment = hostEnvironment;
_logger = logger;
}
/// <summary>
/// Generates a diagnostic bundle.
/// </summary>
public async Task<DiagnosticBundle> GenerateAsync(
DiagnosticBundleOptions options,
CancellationToken ct)
{
_logger.LogInformation("Generating diagnostic bundle");
// Run full doctor check
var report = await _engine.RunAsync(
new DoctorRunOptions { Mode = DoctorRunMode.Full },
cancellationToken: ct);
var sanitizer = new ConfigurationSanitizer();
var bundle = new DiagnosticBundle
{
GeneratedAt = _timeProvider.GetUtcNow(),
Version = GetVersion(),
Environment = GetEnvironmentInfo(),
DoctorReport = report,
Configuration = options.IncludeConfig ? sanitizer.Sanitize(_configuration) : null,
Logs = options.IncludeLogs ? await CollectLogsAsync(options, ct) : null,
SystemInfo = CollectSystemInfo()
};
_logger.LogInformation(
"Diagnostic bundle generated: {Passed} passed, {Failed} failed, {Warnings} warnings",
report.Summary.Passed,
report.Summary.Failed,
report.Summary.Warnings);
return bundle;
}
/// <summary>
/// Exports a diagnostic bundle to a ZIP file.
/// </summary>
public async Task<string> ExportToZipAsync(
DiagnosticBundle bundle,
string outputPath,
CancellationToken ct)
{
_logger.LogInformation("Exporting diagnostic bundle to {OutputPath}", outputPath);
var directory = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
await using var zipStream = File.Create(outputPath);
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Create);
// Add doctor report as JSON
await AddJsonEntryAsync(archive, "doctor-report.json", bundle.DoctorReport, ct);
// Add markdown summary
var markdownFormatter = new MarkdownReportFormatter();
var markdown = markdownFormatter.Format(bundle.DoctorReport, new DoctorOutputOptions
{
Verbose = true,
IncludeRemediation = true,
IncludeEvidence = true,
IncludePassed = true
});
await AddTextEntryAsync(archive, "doctor-report.md", markdown, ct);
// Add environment info
await AddJsonEntryAsync(archive, "environment.json", bundle.Environment, ct);
// Add system info
await AddJsonEntryAsync(archive, "system-info.json", bundle.SystemInfo, ct);
// Add sanitized config if included
if (bundle.Configuration is not null)
{
await AddJsonEntryAsync(archive, "config-sanitized.json", bundle.Configuration, ct);
}
// Add logs if included
if (bundle.Logs is not null)
{
foreach (var (name, content) in bundle.Logs)
{
await AddTextEntryAsync(archive, $"logs/{name}", content, ct);
}
}
// Add README
var readme = GenerateReadme(bundle);
await AddTextEntryAsync(archive, "README.md", readme, ct);
_logger.LogInformation("Diagnostic bundle exported to {OutputPath}", outputPath);
return outputPath;
}
private EnvironmentInfo GetEnvironmentInfo()
{
var process = Process.GetCurrentProcess();
return new EnvironmentInfo
{
Hostname = Environment.MachineName,
Platform = RuntimeInformation.OSDescription,
DotNetVersion = Environment.Version.ToString(),
ProcessId = Environment.ProcessId,
WorkingDirectory = Environment.CurrentDirectory,
StartTime = process.StartTime.ToUniversalTime(),
EnvironmentName = _hostEnvironment?.EnvironmentName
};
}
private SystemInfo CollectSystemInfo()
{
var gcInfo = GC.GetGCMemoryInfo();
var process = Process.GetCurrentProcess();
return new SystemInfo
{
TotalMemoryBytes = gcInfo.TotalAvailableMemoryBytes,
ProcessMemoryBytes = process.WorkingSet64,
ProcessorCount = Environment.ProcessorCount,
Uptime = _timeProvider.GetUtcNow() - process.StartTime.ToUniversalTime(),
GcHeapSizeBytes = GC.GetTotalMemory(forceFullCollection: false),
Gen0Collections = GC.CollectionCount(0),
Gen1Collections = GC.CollectionCount(1),
Gen2Collections = GC.CollectionCount(2)
};
}
private async Task<Dictionary<string, string>> CollectLogsAsync(
DiagnosticBundleOptions options,
CancellationToken ct)
{
var logs = new Dictionary<string, string>();
var logPaths = options.LogPaths ?? GetDefaultLogPaths();
foreach (var path in logPaths)
{
if (File.Exists(path))
{
try
{
var content = await ReadRecentLinesAsync(path, options.MaxLogLines, ct);
logs[Path.GetFileName(path)] = content;
_logger.LogDebug("Collected log file: {Path}", path);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read log file: {Path}", path);
logs[Path.GetFileName(path)] = $"Error reading log file: {ex.Message}";
}
}
}
return logs;
}
private static IReadOnlyList<string> GetDefaultLogPaths()
{
// Platform-specific log paths
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
return new[]
{
Path.Combine(appData, "StellaOps", "logs", "gateway.log"),
Path.Combine(appData, "StellaOps", "logs", "scanner.log"),
Path.Combine(appData, "StellaOps", "logs", "orchestrator.log")
};
}
return new[]
{
"/var/log/stellaops/gateway.log",
"/var/log/stellaops/scanner.log",
"/var/log/stellaops/orchestrator.log"
};
}
private static async Task<string> ReadRecentLinesAsync(
string path,
int maxLines,
CancellationToken ct)
{
var lines = new List<string>();
await using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite);
using var reader = new StreamReader(stream);
string? line;
while ((line = await reader.ReadLineAsync(ct)) is not null)
{
lines.Add(line);
if (lines.Count > maxLines)
{
lines.RemoveAt(0);
}
}
return string.Join(Environment.NewLine, lines);
}
private static string GetVersion()
{
var assembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly();
var version = assembly.GetName().Version;
return version?.ToString() ?? "unknown";
}
private static async Task AddJsonEntryAsync<T>(
ZipArchive archive,
string entryName,
T content,
CancellationToken ct)
{
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
await using var stream = entry.Open();
await JsonSerializer.SerializeAsync(stream, content, JsonOptions, ct);
}
private static async Task AddTextEntryAsync(
ZipArchive archive,
string entryName,
string content,
CancellationToken ct)
{
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
await using var stream = entry.Open();
await using var writer = new StreamWriter(stream, Encoding.UTF8);
await writer.WriteAsync(content.AsMemory(), ct);
}
private static string GenerateReadme(DiagnosticBundle bundle)
{
var sb = new StringBuilder();
sb.AppendLine("# Stella Ops Diagnostic Bundle");
sb.AppendLine();
sb.AppendLine($"Generated: {bundle.GeneratedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)} UTC");
sb.AppendLine($"Version: {bundle.Version}");
sb.AppendLine($"Hostname: {bundle.Environment.Hostname}");
sb.AppendLine();
sb.AppendLine("## Contents");
sb.AppendLine();
sb.AppendLine("- `doctor-report.json` - Full diagnostic check results");
sb.AppendLine("- `doctor-report.md` - Human-readable report");
sb.AppendLine("- `environment.json` - Environment information");
sb.AppendLine("- `system-info.json` - System resource information");
if (bundle.Configuration is not null)
{
sb.AppendLine("- `config-sanitized.json` - Sanitized configuration (secrets removed)");
}
if (bundle.Logs is not null && bundle.Logs.Count > 0)
{
sb.AppendLine("- `logs/` - Recent log files");
}
sb.AppendLine();
sb.AppendLine("## Summary");
sb.AppendLine();
sb.AppendLine($"- Passed: {bundle.DoctorReport.Summary.Passed}");
sb.AppendLine($"- Info: {bundle.DoctorReport.Summary.Info}");
sb.AppendLine($"- Warnings: {bundle.DoctorReport.Summary.Warnings}");
sb.AppendLine($"- Failed: {bundle.DoctorReport.Summary.Failed}");
sb.AppendLine($"- Skipped: {bundle.DoctorReport.Summary.Skipped}");
sb.AppendLine();
sb.AppendLine("## How to Use");
sb.AppendLine();
sb.AppendLine("Share this bundle with Stella Ops support by:");
sb.AppendLine("1. Creating a support ticket at https://support.stellaops.org");
sb.AppendLine("2. Attaching this ZIP file");
sb.AppendLine("3. Including any additional context about the issue");
sb.AppendLine();
sb.AppendLine("**Note:** This bundle has been sanitized to remove sensitive data.");
sb.AppendLine("Review contents before sharing externally.");
return sb.ToString();
}
}

View File

@@ -0,0 +1,37 @@
namespace StellaOps.Doctor.Export;
/// <summary>
/// Options for generating a diagnostic bundle.
/// </summary>
public sealed record DiagnosticBundleOptions
{
/// <summary>
/// Whether to include sanitized configuration in the bundle.
/// </summary>
public bool IncludeConfig { get; init; } = true;
/// <summary>
/// Whether to include recent log files in the bundle.
/// </summary>
public bool IncludeLogs { get; init; } = true;
/// <summary>
/// Duration of logs to include (from most recent).
/// </summary>
public TimeSpan LogDuration { get; init; } = TimeSpan.FromHours(1);
/// <summary>
/// Maximum number of log lines to include per file.
/// </summary>
public int MaxLogLines { get; init; } = 1000;
/// <summary>
/// Log file paths to include.
/// </summary>
public IReadOnlyList<string>? LogPaths { get; init; }
/// <summary>
/// Whether to include system information.
/// </summary>
public bool IncludeSystemInfo { get; init; } = true;
}

View File

@@ -0,0 +1,201 @@
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.Core;
using Xunit;
namespace StellaOps.Doctor.Plugins.Core.Tests;
[Trait("Category", "Unit")]
public class CorePluginTests
{
[Fact]
public void PluginId_ReturnsExpectedValue()
{
var plugin = new CorePlugin();
Assert.Equal("stellaops.doctor.core", plugin.PluginId);
}
[Fact]
public void DisplayName_ReturnsExpectedValue()
{
var plugin = new CorePlugin();
Assert.Equal("Core Platform", plugin.DisplayName);
}
[Fact]
public void Category_ReturnsCore()
{
var plugin = new CorePlugin();
Assert.Equal(DoctorCategory.Core, plugin.Category);
}
[Fact]
public void Version_ReturnsValidVersion()
{
var plugin = new CorePlugin();
Assert.NotNull(plugin.Version);
Assert.True(plugin.Version >= new Version(1, 0, 0));
}
[Fact]
public void MinEngineVersion_ReturnsValidVersion()
{
var plugin = new CorePlugin();
Assert.NotNull(plugin.MinEngineVersion);
Assert.True(plugin.MinEngineVersion >= new Version(1, 0, 0));
}
[Fact]
public void IsAvailable_ReturnsTrue()
{
var plugin = new CorePlugin();
var services = new ServiceCollection().BuildServiceProvider();
Assert.True(plugin.IsAvailable(services));
}
[Fact]
public void GetChecks_ReturnsNineChecks()
{
var plugin = new CorePlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Equal(9, checks.Count);
}
[Fact]
public void GetChecks_ContainsConfigurationLoadedCheck()
{
var plugin = new CorePlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.core.config.loaded");
}
[Fact]
public void GetChecks_ContainsRequiredSettingsCheck()
{
var plugin = new CorePlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.core.config.required");
}
[Fact]
public void GetChecks_ContainsEnvironmentVariablesCheck()
{
var plugin = new CorePlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.core.env.variables");
}
[Fact]
public void GetChecks_ContainsDiskSpaceCheck()
{
var plugin = new CorePlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.core.env.diskspace");
}
[Fact]
public void GetChecks_ContainsMemoryUsageCheck()
{
var plugin = new CorePlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.core.env.memory");
}
[Fact]
public void GetChecks_ContainsServiceHealthCheck()
{
var plugin = new CorePlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.core.services.health");
}
[Fact]
public void GetChecks_ContainsDependencyServicesCheck()
{
var plugin = new CorePlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.core.services.dependencies");
}
[Fact]
public void GetChecks_ContainsAuthenticationConfigCheck()
{
var plugin = new CorePlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.core.auth.config");
}
[Fact]
public void GetChecks_ContainsCryptoProvidersCheck()
{
var plugin = new CorePlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.core.crypto.available");
}
[Fact]
public async Task InitializeAsync_CompletesSuccessfully()
{
var plugin = new CorePlugin();
var context = CreateTestContext();
await plugin.InitializeAsync(context, CancellationToken.None);
// Should complete without throwing
}
private static DoctorPluginContext CreateTestContext(IConfiguration? configuration = null)
{
var services = new ServiceCollection().BuildServiceProvider();
configuration ??= new ConfigurationBuilder().Build();
return new DoctorPluginContext
{
Services = services,
Configuration = configuration,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = configuration.GetSection("Doctor:Plugins:Core")
};
}
}

View File

@@ -0,0 +1,190 @@
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.Database;
using Xunit;
namespace StellaOps.Doctor.Plugins.Database.Tests;
[Trait("Category", "Unit")]
public class DatabasePluginTests
{
[Fact]
public void PluginId_ReturnsExpectedValue()
{
var plugin = new DatabasePlugin();
Assert.Equal("stellaops.doctor.database", plugin.PluginId);
}
[Fact]
public void DisplayName_ReturnsExpectedValue()
{
var plugin = new DatabasePlugin();
Assert.Equal("Database", plugin.DisplayName);
}
[Fact]
public void Category_ReturnsDatabase()
{
var plugin = new DatabasePlugin();
Assert.Equal(DoctorCategory.Database, plugin.Category);
}
[Fact]
public void Version_ReturnsValidVersion()
{
var plugin = new DatabasePlugin();
Assert.NotNull(plugin.Version);
Assert.True(plugin.Version >= new Version(1, 0, 0));
}
[Fact]
public void MinEngineVersion_ReturnsValidVersion()
{
var plugin = new DatabasePlugin();
Assert.NotNull(plugin.MinEngineVersion);
Assert.True(plugin.MinEngineVersion >= new Version(1, 0, 0));
}
[Fact]
public void IsAvailable_ReturnsTrue()
{
var plugin = new DatabasePlugin();
var services = new ServiceCollection().BuildServiceProvider();
Assert.True(plugin.IsAvailable(services));
}
[Fact]
public void GetChecks_ReturnsEightChecks()
{
var plugin = new DatabasePlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Equal(8, checks.Count);
}
[Fact]
public void GetChecks_ContainsDatabaseConnectionCheck()
{
var plugin = new DatabasePlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.db.connection");
}
[Fact]
public void GetChecks_ContainsPendingMigrationsCheck()
{
var plugin = new DatabasePlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.db.migrations.pending");
}
[Fact]
public void GetChecks_ContainsFailedMigrationsCheck()
{
var plugin = new DatabasePlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.db.migrations.failed");
}
[Fact]
public void GetChecks_ContainsSchemaVersionCheck()
{
var plugin = new DatabasePlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.db.schema.version");
}
[Fact]
public void GetChecks_ContainsConnectionPoolHealthCheck()
{
var plugin = new DatabasePlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.db.pool.health");
}
[Fact]
public void GetChecks_ContainsConnectionPoolSizeCheck()
{
var plugin = new DatabasePlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.db.pool.size");
}
[Fact]
public void GetChecks_ContainsQueryLatencyCheck()
{
var plugin = new DatabasePlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.db.latency");
}
[Fact]
public void GetChecks_ContainsDatabasePermissionsCheck()
{
var plugin = new DatabasePlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.db.permissions");
}
[Fact]
public async Task InitializeAsync_CompletesSuccessfully()
{
var plugin = new DatabasePlugin();
var context = CreateTestContext();
await plugin.InitializeAsync(context, CancellationToken.None);
// Should complete without throwing
}
private static DoctorPluginContext CreateTestContext(IConfiguration? configuration = null)
{
var services = new ServiceCollection().BuildServiceProvider();
configuration ??= new ConfigurationBuilder().Build();
return new DoctorPluginContext
{
Services = services,
Configuration = configuration,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = configuration.GetSection("Doctor:Plugins:Database")
};
}
}

View File

@@ -0,0 +1,190 @@
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.Integration;
using Xunit;
namespace StellaOps.Doctor.Plugins.Integration.Tests;
[Trait("Category", "Unit")]
public class IntegrationPluginTests
{
[Fact]
public void PluginId_ReturnsExpectedValue()
{
var plugin = new IntegrationPlugin();
Assert.Equal("stellaops.doctor.integration", plugin.PluginId);
}
[Fact]
public void DisplayName_ReturnsExpectedValue()
{
var plugin = new IntegrationPlugin();
Assert.Equal("External Integrations", plugin.DisplayName);
}
[Fact]
public void Category_ReturnsIntegration()
{
var plugin = new IntegrationPlugin();
Assert.Equal(DoctorCategory.Integration, plugin.Category);
}
[Fact]
public void Version_ReturnsValidVersion()
{
var plugin = new IntegrationPlugin();
Assert.NotNull(plugin.Version);
Assert.True(plugin.Version >= new Version(1, 0, 0));
}
[Fact]
public void MinEngineVersion_ReturnsValidVersion()
{
var plugin = new IntegrationPlugin();
Assert.NotNull(plugin.MinEngineVersion);
Assert.True(plugin.MinEngineVersion >= new Version(1, 0, 0));
}
[Fact]
public void IsAvailable_ReturnsTrue()
{
var plugin = new IntegrationPlugin();
var services = new ServiceCollection().BuildServiceProvider();
Assert.True(plugin.IsAvailable(services));
}
[Fact]
public void GetChecks_ReturnsEightChecks()
{
var plugin = new IntegrationPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Equal(8, checks.Count);
}
[Fact]
public void GetChecks_ContainsOciRegistryCheck()
{
var plugin = new IntegrationPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.integration.oci.registry");
}
[Fact]
public void GetChecks_ContainsObjectStorageCheck()
{
var plugin = new IntegrationPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.integration.s3.storage");
}
[Fact]
public void GetChecks_ContainsSmtpCheck()
{
var plugin = new IntegrationPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.integration.smtp");
}
[Fact]
public void GetChecks_ContainsSlackWebhookCheck()
{
var plugin = new IntegrationPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.integration.slack");
}
[Fact]
public void GetChecks_ContainsTeamsWebhookCheck()
{
var plugin = new IntegrationPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.integration.teams");
}
[Fact]
public void GetChecks_ContainsGitProviderCheck()
{
var plugin = new IntegrationPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.integration.git");
}
[Fact]
public void GetChecks_ContainsLdapConnectivityCheck()
{
var plugin = new IntegrationPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.integration.ldap");
}
[Fact]
public void GetChecks_ContainsOidcProviderCheck()
{
var plugin = new IntegrationPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.integration.oidc");
}
[Fact]
public async Task InitializeAsync_CompletesSuccessfully()
{
var plugin = new IntegrationPlugin();
var context = CreateTestContext();
await plugin.InitializeAsync(context, CancellationToken.None);
// Should complete without throwing
}
private static DoctorPluginContext CreateTestContext(IConfiguration? configuration = null)
{
var services = new ServiceCollection().BuildServiceProvider();
configuration ??= new ConfigurationBuilder().Build();
return new DoctorPluginContext
{
Services = services,
Configuration = configuration,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = configuration.GetSection("Doctor:Plugins:Integration")
};
}
}

View File

@@ -0,0 +1,168 @@
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.Observability;
using Xunit;
namespace StellaOps.Doctor.Plugins.Observability.Tests;
[Trait("Category", "Unit")]
public class ObservabilityPluginTests
{
[Fact]
public void PluginId_ReturnsExpectedValue()
{
var plugin = new ObservabilityPlugin();
Assert.Equal("stellaops.doctor.observability", plugin.PluginId);
}
[Fact]
public void DisplayName_ReturnsExpectedValue()
{
var plugin = new ObservabilityPlugin();
Assert.Equal("Observability", plugin.DisplayName);
}
[Fact]
public void Category_ReturnsObservability()
{
var plugin = new ObservabilityPlugin();
Assert.Equal(DoctorCategory.Observability, plugin.Category);
}
[Fact]
public void Version_ReturnsValidVersion()
{
var plugin = new ObservabilityPlugin();
Assert.NotNull(plugin.Version);
Assert.True(plugin.Version >= new Version(1, 0, 0));
}
[Fact]
public void MinEngineVersion_ReturnsValidVersion()
{
var plugin = new ObservabilityPlugin();
Assert.NotNull(plugin.MinEngineVersion);
Assert.True(plugin.MinEngineVersion >= new Version(1, 0, 0));
}
[Fact]
public void IsAvailable_ReturnsTrue()
{
var plugin = new ObservabilityPlugin();
var services = new ServiceCollection().BuildServiceProvider();
Assert.True(plugin.IsAvailable(services));
}
[Fact]
public void GetChecks_ReturnsSixChecks()
{
var plugin = new ObservabilityPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Equal(6, checks.Count);
}
[Fact]
public void GetChecks_ContainsOpenTelemetryCheck()
{
var plugin = new ObservabilityPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.observability.otel");
}
[Fact]
public void GetChecks_ContainsLoggingConfigurationCheck()
{
var plugin = new ObservabilityPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.observability.logging");
}
[Fact]
public void GetChecks_ContainsMetricsCollectionCheck()
{
var plugin = new ObservabilityPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.observability.metrics");
}
[Fact]
public void GetChecks_ContainsTracingConfigurationCheck()
{
var plugin = new ObservabilityPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.observability.tracing");
}
[Fact]
public void GetChecks_ContainsHealthCheckEndpointsCheck()
{
var plugin = new ObservabilityPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.observability.healthchecks");
}
[Fact]
public void GetChecks_ContainsAlertingConfigurationCheck()
{
var plugin = new ObservabilityPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.observability.alerting");
}
[Fact]
public async Task InitializeAsync_CompletesSuccessfully()
{
var plugin = new ObservabilityPlugin();
var context = CreateTestContext();
await plugin.InitializeAsync(context, CancellationToken.None);
// Should complete without throwing
}
private static DoctorPluginContext CreateTestContext(IConfiguration? configuration = null)
{
var services = new ServiceCollection().BuildServiceProvider();
configuration ??= new ConfigurationBuilder().Build();
return new DoctorPluginContext
{
Services = services,
Configuration = configuration,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = configuration.GetSection("Doctor:Plugins:Observability")
};
}
}

View File

@@ -0,0 +1,212 @@
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.Security;
using Xunit;
namespace StellaOps.Doctor.Plugins.Security.Tests;
[Trait("Category", "Unit")]
public class SecurityPluginTests
{
[Fact]
public void PluginId_ReturnsExpectedValue()
{
var plugin = new SecurityPlugin();
Assert.Equal("stellaops.doctor.security", plugin.PluginId);
}
[Fact]
public void DisplayName_ReturnsExpectedValue()
{
var plugin = new SecurityPlugin();
Assert.Equal("Security Configuration", plugin.DisplayName);
}
[Fact]
public void Category_ReturnsSecurity()
{
var plugin = new SecurityPlugin();
Assert.Equal(DoctorCategory.Security, plugin.Category);
}
[Fact]
public void Version_ReturnsValidVersion()
{
var plugin = new SecurityPlugin();
Assert.NotNull(plugin.Version);
Assert.True(plugin.Version >= new Version(1, 0, 0));
}
[Fact]
public void MinEngineVersion_ReturnsValidVersion()
{
var plugin = new SecurityPlugin();
Assert.NotNull(plugin.MinEngineVersion);
Assert.True(plugin.MinEngineVersion >= new Version(1, 0, 0));
}
[Fact]
public void IsAvailable_ReturnsTrue()
{
var plugin = new SecurityPlugin();
var services = new ServiceCollection().BuildServiceProvider();
Assert.True(plugin.IsAvailable(services));
}
[Fact]
public void GetChecks_ReturnsTenChecks()
{
var plugin = new SecurityPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Equal(10, checks.Count);
}
[Fact]
public void GetChecks_ContainsTlsCertificateCheck()
{
var plugin = new SecurityPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.security.tls.certificate");
}
[Fact]
public void GetChecks_ContainsJwtConfigurationCheck()
{
var plugin = new SecurityPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.security.jwt.config");
}
[Fact]
public void GetChecks_ContainsCorsConfigurationCheck()
{
var plugin = new SecurityPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.security.cors");
}
[Fact]
public void GetChecks_ContainsRateLimitingCheck()
{
var plugin = new SecurityPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.security.ratelimit");
}
[Fact]
public void GetChecks_ContainsSecurityHeadersCheck()
{
var plugin = new SecurityPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.security.headers");
}
[Fact]
public void GetChecks_ContainsSecretsConfigurationCheck()
{
var plugin = new SecurityPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.security.secrets");
}
[Fact]
public void GetChecks_ContainsEncryptionKeyCheck()
{
var plugin = new SecurityPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.security.encryption");
}
[Fact]
public void GetChecks_ContainsPasswordPolicyCheck()
{
var plugin = new SecurityPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.security.password.policy");
}
[Fact]
public void GetChecks_ContainsAuditLoggingCheck()
{
var plugin = new SecurityPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.security.audit.logging");
}
[Fact]
public void GetChecks_ContainsApiKeySecurityCheck()
{
var plugin = new SecurityPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.security.apikey");
}
[Fact]
public async Task InitializeAsync_CompletesSuccessfully()
{
var plugin = new SecurityPlugin();
var context = CreateTestContext();
await plugin.InitializeAsync(context, CancellationToken.None);
// Should complete without throwing
}
private static DoctorPluginContext CreateTestContext(IConfiguration? configuration = null)
{
var services = new ServiceCollection().BuildServiceProvider();
configuration ??= new ConfigurationBuilder().Build();
return new DoctorPluginContext
{
Services = services,
Configuration = configuration,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = configuration.GetSection("Doctor:Plugins:Security")
};
}
}

View File

@@ -0,0 +1,168 @@
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.ServiceGraph;
using Xunit;
namespace StellaOps.Doctor.Plugins.ServiceGraph.Tests;
[Trait("Category", "Unit")]
public class ServiceGraphPluginTests
{
[Fact]
public void PluginId_ReturnsExpectedValue()
{
var plugin = new ServiceGraphPlugin();
Assert.Equal("stellaops.doctor.servicegraph", plugin.PluginId);
}
[Fact]
public void DisplayName_ReturnsExpectedValue()
{
var plugin = new ServiceGraphPlugin();
Assert.Equal("Service Graph", plugin.DisplayName);
}
[Fact]
public void Category_ReturnsServiceGraph()
{
var plugin = new ServiceGraphPlugin();
Assert.Equal(DoctorCategory.ServiceGraph, plugin.Category);
}
[Fact]
public void Version_ReturnsValidVersion()
{
var plugin = new ServiceGraphPlugin();
Assert.NotNull(plugin.Version);
Assert.True(plugin.Version >= new Version(1, 0, 0));
}
[Fact]
public void MinEngineVersion_ReturnsValidVersion()
{
var plugin = new ServiceGraphPlugin();
Assert.NotNull(plugin.MinEngineVersion);
Assert.True(plugin.MinEngineVersion >= new Version(1, 0, 0));
}
[Fact]
public void IsAvailable_ReturnsTrue()
{
var plugin = new ServiceGraphPlugin();
var services = new ServiceCollection().BuildServiceProvider();
Assert.True(plugin.IsAvailable(services));
}
[Fact]
public void GetChecks_ReturnsSixChecks()
{
var plugin = new ServiceGraphPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Equal(6, checks.Count);
}
[Fact]
public void GetChecks_ContainsBackendConnectivityCheck()
{
var plugin = new ServiceGraphPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.servicegraph.backend");
}
[Fact]
public void GetChecks_ContainsValkeyConnectivityCheck()
{
var plugin = new ServiceGraphPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.servicegraph.valkey");
}
[Fact]
public void GetChecks_ContainsMessageQueueCheck()
{
var plugin = new ServiceGraphPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.servicegraph.mq");
}
[Fact]
public void GetChecks_ContainsServiceEndpointsCheck()
{
var plugin = new ServiceGraphPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.servicegraph.endpoints");
}
[Fact]
public void GetChecks_ContainsCircuitBreakerStatusCheck()
{
var plugin = new ServiceGraphPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.servicegraph.circuitbreaker");
}
[Fact]
public void GetChecks_ContainsServiceTimeoutCheck()
{
var plugin = new ServiceGraphPlugin();
var context = CreateTestContext();
var checks = plugin.GetChecks(context);
Assert.Contains(checks, c => c.CheckId == "check.servicegraph.timeouts");
}
[Fact]
public async Task InitializeAsync_CompletesSuccessfully()
{
var plugin = new ServiceGraphPlugin();
var context = CreateTestContext();
await plugin.InitializeAsync(context, CancellationToken.None);
// Should complete without throwing
}
private static DoctorPluginContext CreateTestContext(IConfiguration? configuration = null)
{
var services = new ServiceCollection().BuildServiceProvider();
configuration ??= new ConfigurationBuilder().Build();
return new DoctorPluginContext
{
Services = services,
Configuration = configuration,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = configuration.GetSection("Doctor:Plugins:ServiceGraph")
};
}
}

View File

@@ -0,0 +1,367 @@
// <copyright file="DoctorEngineTests.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
// -----------------------------------------------------------------------------
// DoctorEngineTests.cs
// Sprint: SPRINT_20260112_001_001_DOCTOR_foundation
// Task: DOC-FND-008 - Doctor engine unit tests
// Description: Tests for the DoctorEngine orchestrator.
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Doctor.DependencyInjection;
using StellaOps.Doctor.Engine;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
using Xunit;
namespace StellaOps.Doctor.Tests.Engine;
[Trait("Category", "Unit")]
public sealed class DoctorEngineTests
{
[Fact]
public async Task RunAsync_WithNoPlugins_ReturnsEmptyReport()
{
// Arrange
var engine = CreateEngine();
// Act
var report = await engine.RunAsync();
// Assert
report.Should().NotBeNull();
report.Summary.Total.Should().Be(0);
report.OverallSeverity.Should().Be(DoctorSeverity.Pass);
}
[Fact]
public async Task RunAsync_GeneratesUniqueRunId()
{
// Arrange
var engine = CreateEngine();
// Act
var report1 = await engine.RunAsync();
var report2 = await engine.RunAsync();
// Assert
report1.RunId.Should().NotBeNullOrEmpty();
report2.RunId.Should().NotBeNullOrEmpty();
report1.RunId.Should().NotBe(report2.RunId);
}
[Fact]
public async Task RunAsync_RunIdStartsWithDrPrefix()
{
// Arrange
var engine = CreateEngine();
// Act
var report = await engine.RunAsync();
// Assert
report.RunId.Should().StartWith("dr_");
}
[Fact]
public async Task RunAsync_SetsStartAndEndTimes()
{
// Arrange
var engine = CreateEngine();
// Act
var report = await engine.RunAsync();
// Assert
report.StartedAt.Should().BeBefore(report.CompletedAt);
report.Duration.Should().BeGreaterThanOrEqualTo(TimeSpan.Zero);
}
[Fact]
public async Task RunAsync_WithCancellation_RespectsToken()
{
// Arrange
// Use a plugin that takes time, so cancellation can be checked
var slowPlugin = CreateSlowMockPlugin();
var engine = CreateEngine(slowPlugin);
var cts = new CancellationTokenSource();
// Act - Cancel after a short delay
var task = engine.RunAsync(null, null, cts.Token);
cts.CancelAfter(TimeSpan.FromMilliseconds(10));
// Assert - Either throws OperationCanceledException or completes (if too fast)
try
{
var report = await task;
// If it completes, we still verify it ran
report.Should().NotBeNull();
}
catch (OperationCanceledException)
{
// Expected if cancellation was honored
}
}
[Fact]
public void ListChecks_WithNoPlugins_ReturnsEmptyList()
{
// Arrange
var engine = CreateEngine();
// Act
var checks = engine.ListChecks();
// Assert
checks.Should().BeEmpty();
}
[Fact]
public void ListPlugins_WithNoPlugins_ReturnsEmptyList()
{
// Arrange
var engine = CreateEngine();
// Act
var plugins = engine.ListPlugins();
// Assert
plugins.Should().BeEmpty();
}
[Fact]
public void GetAvailableCategories_WithNoPlugins_ReturnsEmptyList()
{
// Arrange
var engine = CreateEngine();
// Act
var categories = engine.GetAvailableCategories();
// Assert
categories.Should().BeEmpty();
}
[Fact]
public async Task RunAsync_WithMockPlugin_ExecutesChecks()
{
// Arrange
var mockPlugin = new Mock<IDoctorPlugin>();
mockPlugin.Setup(p => p.PluginId).Returns("test.plugin");
mockPlugin.Setup(p => p.DisplayName).Returns("Test Plugin");
mockPlugin.Setup(p => p.Category).Returns(DoctorCategory.Core);
mockPlugin.Setup(p => p.Version).Returns(new Version(1, 0, 0));
mockPlugin.Setup(p => p.MinEngineVersion).Returns(new Version(1, 0, 0));
mockPlugin.Setup(p => p.IsAvailable(It.IsAny<IServiceProvider>())).Returns(true);
var mockCheck = new Mock<IDoctorCheck>();
mockCheck.Setup(c => c.CheckId).Returns("check.test.mock");
mockCheck.Setup(c => c.Name).Returns("Mock Check");
mockCheck.Setup(c => c.Description).Returns("A mock check for testing");
mockCheck.Setup(c => c.DefaultSeverity).Returns(DoctorSeverity.Fail);
mockCheck.Setup(c => c.Tags).Returns(new[] { "quick" });
mockCheck.Setup(c => c.EstimatedDuration).Returns(TimeSpan.FromSeconds(1));
mockCheck.Setup(c => c.CanRun(It.IsAny<DoctorPluginContext>())).Returns(true);
mockCheck.Setup(c => c.RunAsync(It.IsAny<DoctorPluginContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new DoctorCheckResult
{
CheckId = "check.test.mock",
PluginId = "test.plugin",
Category = "Core",
Severity = DoctorSeverity.Pass,
Diagnosis = "Check passed",
Evidence = new Evidence
{
Description = "Test evidence",
Data = new Dictionary<string, string>()
},
Duration = TimeSpan.FromMilliseconds(50),
ExecutedAt = DateTimeOffset.UtcNow
});
mockPlugin.Setup(p => p.GetChecks(It.IsAny<DoctorPluginContext>()))
.Returns(new[] { mockCheck.Object });
var engine = CreateEngine(mockPlugin.Object);
// Act
var report = await engine.RunAsync();
// Assert
report.Summary.Total.Should().Be(1);
report.Summary.Passed.Should().Be(1);
report.OverallSeverity.Should().Be(DoctorSeverity.Pass);
}
[Fact]
public async Task RunAsync_WithFailingCheck_ReturnsFailSeverity()
{
// Arrange
var mockPlugin = CreateMockPluginWithCheck(DoctorSeverity.Fail, "Check failed");
var engine = CreateEngine(mockPlugin);
// Act
var report = await engine.RunAsync();
// Assert
report.Summary.Failed.Should().Be(1);
report.OverallSeverity.Should().Be(DoctorSeverity.Fail);
}
[Fact]
public async Task RunAsync_WithWarningCheck_ReturnsWarnSeverity()
{
// Arrange
var mockPlugin = CreateMockPluginWithCheck(DoctorSeverity.Warn, "Check had warnings");
var engine = CreateEngine(mockPlugin);
// Act
var report = await engine.RunAsync();
// Assert
report.Summary.Warnings.Should().Be(1);
report.OverallSeverity.Should().Be(DoctorSeverity.Warn);
}
[Fact]
public async Task RunAsync_ReportsProgress()
{
// Arrange
var mockPlugin = CreateMockPluginWithCheck(DoctorSeverity.Pass, "Check passed");
var engine = CreateEngine(mockPlugin);
var progressReports = new List<DoctorCheckProgress>();
var progress = new Progress<DoctorCheckProgress>(p => progressReports.Add(p));
// Act
await engine.RunAsync(null, progress);
// Allow time for progress to be reported
await Task.Delay(100);
// Assert
progressReports.Should().NotBeEmpty();
}
private static DoctorEngine CreateEngine(params IDoctorPlugin[] plugins)
{
var services = new ServiceCollection();
// Add configuration
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
services.AddSingleton<IConfiguration>(configuration);
// Add time provider
services.AddSingleton(TimeProvider.System);
// Add logging
services.AddLogging();
// Add doctor services
services.AddDoctorEngine();
// Add mock plugins
foreach (var plugin in plugins)
{
services.AddSingleton<IDoctorPlugin>(plugin);
}
var provider = services.BuildServiceProvider();
return provider.GetRequiredService<DoctorEngine>();
}
private static IDoctorPlugin CreateSlowMockPlugin()
{
var mockPlugin = new Mock<IDoctorPlugin>();
mockPlugin.Setup(p => p.PluginId).Returns("test.slow");
mockPlugin.Setup(p => p.DisplayName).Returns("Slow Test Plugin");
mockPlugin.Setup(p => p.Category).Returns(DoctorCategory.Core);
mockPlugin.Setup(p => p.Version).Returns(new Version(1, 0, 0));
mockPlugin.Setup(p => p.MinEngineVersion).Returns(new Version(1, 0, 0));
mockPlugin.Setup(p => p.IsAvailable(It.IsAny<IServiceProvider>())).Returns(true);
var mockCheck = new Mock<IDoctorCheck>();
mockCheck.Setup(c => c.CheckId).Returns("check.test.slow");
mockCheck.Setup(c => c.Name).Returns("Slow Check");
mockCheck.Setup(c => c.Description).Returns("A slow check for testing cancellation");
mockCheck.Setup(c => c.DefaultSeverity).Returns(DoctorSeverity.Pass);
mockCheck.Setup(c => c.Tags).Returns(new[] { "slow" });
mockCheck.Setup(c => c.EstimatedDuration).Returns(TimeSpan.FromSeconds(5));
mockCheck.Setup(c => c.CanRun(It.IsAny<DoctorPluginContext>())).Returns(true);
mockCheck.Setup(c => c.RunAsync(It.IsAny<DoctorPluginContext>(), It.IsAny<CancellationToken>()))
.Returns<DoctorPluginContext, CancellationToken>(async (ctx, ct) =>
{
await Task.Delay(TimeSpan.FromSeconds(5), ct);
return new DoctorCheckResult
{
CheckId = "check.test.slow",
PluginId = "test.slow",
Category = "Core",
Severity = DoctorSeverity.Pass,
Diagnosis = "Slow check completed",
Evidence = new Evidence
{
Description = "Test evidence",
Data = new Dictionary<string, string>()
},
Duration = TimeSpan.FromSeconds(5),
ExecutedAt = DateTimeOffset.UtcNow
};
});
mockPlugin.Setup(p => p.GetChecks(It.IsAny<DoctorPluginContext>()))
.Returns(new[] { mockCheck.Object });
return mockPlugin.Object;
}
private static IDoctorPlugin CreateMockPluginWithCheck(DoctorSeverity severity, string diagnosis)
{
var mockPlugin = new Mock<IDoctorPlugin>();
mockPlugin.Setup(p => p.PluginId).Returns("test.plugin");
mockPlugin.Setup(p => p.DisplayName).Returns("Test Plugin");
mockPlugin.Setup(p => p.Category).Returns(DoctorCategory.Core);
mockPlugin.Setup(p => p.Version).Returns(new Version(1, 0, 0));
mockPlugin.Setup(p => p.MinEngineVersion).Returns(new Version(1, 0, 0));
mockPlugin.Setup(p => p.IsAvailable(It.IsAny<IServiceProvider>())).Returns(true);
var mockCheck = new Mock<IDoctorCheck>();
mockCheck.Setup(c => c.CheckId).Returns("check.test.mock");
mockCheck.Setup(c => c.Name).Returns("Mock Check");
mockCheck.Setup(c => c.Description).Returns("A mock check for testing");
mockCheck.Setup(c => c.DefaultSeverity).Returns(severity);
mockCheck.Setup(c => c.Tags).Returns(new[] { "quick" });
mockCheck.Setup(c => c.EstimatedDuration).Returns(TimeSpan.FromSeconds(1));
mockCheck.Setup(c => c.CanRun(It.IsAny<DoctorPluginContext>())).Returns(true);
mockCheck.Setup(c => c.RunAsync(It.IsAny<DoctorPluginContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new DoctorCheckResult
{
CheckId = "check.test.mock",
PluginId = "test.plugin",
Category = "Core",
Severity = severity,
Diagnosis = diagnosis,
Evidence = new Evidence
{
Description = "Test evidence",
Data = new Dictionary<string, string>()
},
Duration = TimeSpan.FromMilliseconds(50),
ExecutedAt = DateTimeOffset.UtcNow
});
mockPlugin.Setup(p => p.GetChecks(It.IsAny<DoctorPluginContext>()))
.Returns(new[] { mockCheck.Object });
return mockPlugin.Object;
}
}

View File

@@ -0,0 +1,201 @@
// <copyright file="DoctorReportTests.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
// -----------------------------------------------------------------------------
// DoctorReportTests.cs
// Sprint: SPRINT_20260112_001_001_DOCTOR_foundation
// Task: DOC-FND-008 - Doctor model unit tests
// Description: Tests for Doctor report and summary models.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Doctor.Models;
using Xunit;
namespace StellaOps.Doctor.Tests.Models;
[Trait("Category", "Unit")]
public sealed class DoctorReportTests
{
[Fact]
public void ComputeOverallSeverity_WithAllPassed_ReturnsPass()
{
// Arrange
var results = new[]
{
CreateResult(DoctorSeverity.Pass),
CreateResult(DoctorSeverity.Pass),
CreateResult(DoctorSeverity.Pass)
};
// Act
var severity = DoctorReport.ComputeOverallSeverity(results);
// Assert
severity.Should().Be(DoctorSeverity.Pass);
}
[Fact]
public void ComputeOverallSeverity_WithOneFail_ReturnsFail()
{
// Arrange
var results = new[]
{
CreateResult(DoctorSeverity.Pass),
CreateResult(DoctorSeverity.Fail),
CreateResult(DoctorSeverity.Pass)
};
// Act
var severity = DoctorReport.ComputeOverallSeverity(results);
// Assert
severity.Should().Be(DoctorSeverity.Fail);
}
[Fact]
public void ComputeOverallSeverity_WithOneWarn_NoFail_ReturnsWarn()
{
// Arrange
var results = new[]
{
CreateResult(DoctorSeverity.Pass),
CreateResult(DoctorSeverity.Warn),
CreateResult(DoctorSeverity.Pass)
};
// Act
var severity = DoctorReport.ComputeOverallSeverity(results);
// Assert
severity.Should().Be(DoctorSeverity.Warn);
}
[Fact]
public void ComputeOverallSeverity_WithOneInfo_NoWarnOrFail_ReturnsInfo()
{
// Arrange
var results = new[]
{
CreateResult(DoctorSeverity.Pass),
CreateResult(DoctorSeverity.Info),
CreateResult(DoctorSeverity.Pass)
};
// Act
var severity = DoctorReport.ComputeOverallSeverity(results);
// Assert
severity.Should().Be(DoctorSeverity.Info);
}
[Fact]
public void ComputeOverallSeverity_FailTakesPrecedenceOverWarn()
{
// Arrange
var results = new[]
{
CreateResult(DoctorSeverity.Pass),
CreateResult(DoctorSeverity.Warn),
CreateResult(DoctorSeverity.Fail)
};
// Act
var severity = DoctorReport.ComputeOverallSeverity(results);
// Assert
severity.Should().Be(DoctorSeverity.Fail);
}
[Fact]
public void ComputeOverallSeverity_WithEmpty_ReturnsPass()
{
// Arrange
var results = Array.Empty<DoctorCheckResult>();
// Act
var severity = DoctorReport.ComputeOverallSeverity(results);
// Assert
severity.Should().Be(DoctorSeverity.Pass);
}
[Fact]
public void DoctorReportSummary_Empty_HasZeroCounts()
{
// Act
var summary = DoctorReportSummary.Empty;
// Assert
summary.Passed.Should().Be(0);
summary.Info.Should().Be(0);
summary.Warnings.Should().Be(0);
summary.Failed.Should().Be(0);
summary.Skipped.Should().Be(0);
summary.Total.Should().Be(0);
}
[Fact]
public void DoctorReportSummary_FromResults_CountsCorrectly()
{
// Arrange
var results = new[]
{
CreateResult(DoctorSeverity.Pass),
CreateResult(DoctorSeverity.Pass),
CreateResult(DoctorSeverity.Info),
CreateResult(DoctorSeverity.Warn),
CreateResult(DoctorSeverity.Fail),
CreateResult(DoctorSeverity.Skip)
};
// Act
var summary = DoctorReportSummary.FromResults(results);
// Assert
summary.Passed.Should().Be(2);
summary.Info.Should().Be(1);
summary.Warnings.Should().Be(1);
summary.Failed.Should().Be(1);
summary.Skipped.Should().Be(1);
summary.Total.Should().Be(6);
}
[Fact]
public void DoctorReportSummary_Total_SumsAllCategories()
{
// Arrange
var summary = new DoctorReportSummary
{
Passed = 5,
Info = 2,
Warnings = 3,
Failed = 1,
Skipped = 4
};
// Act & Assert
summary.Total.Should().Be(15);
}
private static DoctorCheckResult CreateResult(DoctorSeverity severity)
{
return new DoctorCheckResult
{
CheckId = $"check.test.{Guid.NewGuid():N}",
PluginId = "test.plugin",
Category = "Test",
Severity = severity,
Diagnosis = $"Test result with {severity}",
Evidence = new Evidence
{
Description = "Test evidence",
Data = new Dictionary<string, string>()
},
Duration = TimeSpan.FromMilliseconds(10),
ExecutedAt = DateTimeOffset.UtcNow
};
}
}

View File

@@ -0,0 +1,270 @@
// <copyright file="JsonReportFormatterTests.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
// -----------------------------------------------------------------------------
// JsonReportFormatterTests.cs
// Sprint: SPRINT_20260112_001_001_DOCTOR_foundation
// Task: DOC-FND-008 - Output formatter unit tests
// Description: Tests for the JsonReportFormatter.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Output;
using Xunit;
namespace StellaOps.Doctor.Tests.Output;
[Trait("Category", "Unit")]
public sealed class JsonReportFormatterTests
{
[Fact]
public void FormatName_ReturnsJson()
{
// Arrange
var formatter = new JsonReportFormatter();
// Assert
formatter.FormatName.Should().Be("json");
}
[Fact]
public void Format_WithEmptyReport_ReturnsValidJson()
{
// Arrange
var formatter = new JsonReportFormatter();
var report = CreateEmptyReport();
// Act
var output = formatter.Format(report);
// Assert
output.Should().NotBeNullOrEmpty();
var action = () => JsonDocument.Parse(output);
action.Should().NotThrow();
}
[Fact]
public void Format_ContainsRunId()
{
// Arrange
var formatter = new JsonReportFormatter();
var report = CreateEmptyReport("dr_test_123456");
// Act
var output = formatter.Format(report);
using var doc = JsonDocument.Parse(output);
// Assert
doc.RootElement.GetProperty("runId").GetString().Should().Be("dr_test_123456");
}
[Fact]
public void Format_ContainsSummary()
{
// Arrange
var formatter = new JsonReportFormatter();
var report = CreateReportWithMultipleResults();
// Act
var output = formatter.Format(report);
using var doc = JsonDocument.Parse(output);
// Assert
var summary = doc.RootElement.GetProperty("summary");
summary.GetProperty("passed").GetInt32().Should().Be(2);
summary.GetProperty("warnings").GetInt32().Should().Be(1);
summary.GetProperty("failed").GetInt32().Should().Be(1);
summary.GetProperty("total").GetInt32().Should().Be(4);
}
[Fact]
public void Format_ContainsResults()
{
// Arrange
var formatter = new JsonReportFormatter();
var report = CreateReportWithResult(DoctorSeverity.Pass, "Test passed");
// Act
var output = formatter.Format(report);
using var doc = JsonDocument.Parse(output);
// Assert
var results = doc.RootElement.GetProperty("results");
results.GetArrayLength().Should().Be(1);
}
[Fact]
public void Format_ResultContainsCheckId()
{
// Arrange
var formatter = new JsonReportFormatter();
var report = CreateReportWithResult(DoctorSeverity.Pass, "Test", "check.test.example");
// Act
var output = formatter.Format(report);
using var doc = JsonDocument.Parse(output);
// Assert
var results = doc.RootElement.GetProperty("results");
results[0].GetProperty("checkId").GetString().Should().Be("check.test.example");
}
[Fact]
public void Format_ResultContainsSeverity()
{
// Arrange
var formatter = new JsonReportFormatter();
var report = CreateReportWithResult(DoctorSeverity.Fail, "Test failed");
// Act
var output = formatter.Format(report);
using var doc = JsonDocument.Parse(output);
// Assert
var results = doc.RootElement.GetProperty("results");
var severityValue = results[0].GetProperty("severity").GetString();
severityValue.Should().NotBeNullOrEmpty();
}
[Fact]
public void Format_ResultContainsDiagnosis()
{
// Arrange
var formatter = new JsonReportFormatter();
var report = CreateReportWithResult(DoctorSeverity.Pass, "Diagnosis message");
// Act
var output = formatter.Format(report);
using var doc = JsonDocument.Parse(output);
// Assert
var results = doc.RootElement.GetProperty("results");
results[0].GetProperty("diagnosis").GetString().Should().Be("Diagnosis message");
}
[Fact]
public void Format_ContainsOverallSeverity()
{
// Arrange
var formatter = new JsonReportFormatter();
var report = CreateReportWithResult(DoctorSeverity.Warn, "Warning");
// Act
var output = formatter.Format(report);
using var doc = JsonDocument.Parse(output);
// Assert
doc.RootElement.TryGetProperty("overallSeverity", out _).Should().BeTrue();
}
[Fact]
public void Format_ContainsDuration()
{
// Arrange
var formatter = new JsonReportFormatter();
var report = CreateEmptyReport();
// Act
var output = formatter.Format(report);
using var doc = JsonDocument.Parse(output);
// Assert
doc.RootElement.TryGetProperty("duration", out _).Should().BeTrue();
}
private static DoctorReport CreateEmptyReport(string? runId = null)
{
return new DoctorReport
{
RunId = runId ?? $"dr_test_{Guid.NewGuid():N}",
StartedAt = DateTimeOffset.UtcNow.AddSeconds(-1),
CompletedAt = DateTimeOffset.UtcNow,
Duration = TimeSpan.FromSeconds(1),
OverallSeverity = DoctorSeverity.Pass,
Summary = DoctorReportSummary.Empty,
Results = ImmutableArray<DoctorCheckResult>.Empty
};
}
private static DoctorReport CreateReportWithResult(
DoctorSeverity severity,
string diagnosis,
string? checkId = null)
{
var result = new DoctorCheckResult
{
CheckId = checkId ?? "check.test.example",
PluginId = "test.plugin",
Category = "Test",
Severity = severity,
Diagnosis = diagnosis,
Evidence = new Evidence
{
Description = "Test evidence",
Data = new Dictionary<string, string>()
},
Duration = TimeSpan.FromMilliseconds(50),
ExecutedAt = DateTimeOffset.UtcNow
};
var results = ImmutableArray.Create(result);
var summary = DoctorReportSummary.FromResults(results);
return new DoctorReport
{
RunId = $"dr_test_{Guid.NewGuid():N}",
StartedAt = DateTimeOffset.UtcNow.AddSeconds(-1),
CompletedAt = DateTimeOffset.UtcNow,
Duration = TimeSpan.FromSeconds(1),
OverallSeverity = severity,
Summary = summary,
Results = results
};
}
private static DoctorReport CreateReportWithMultipleResults()
{
var results = ImmutableArray.Create(
CreateCheckResult(DoctorSeverity.Pass, "check.test.1"),
CreateCheckResult(DoctorSeverity.Pass, "check.test.2"),
CreateCheckResult(DoctorSeverity.Warn, "check.test.3"),
CreateCheckResult(DoctorSeverity.Fail, "check.test.4")
);
var summary = DoctorReportSummary.FromResults(results);
return new DoctorReport
{
RunId = $"dr_test_{Guid.NewGuid():N}",
StartedAt = DateTimeOffset.UtcNow.AddSeconds(-1),
CompletedAt = DateTimeOffset.UtcNow,
Duration = TimeSpan.FromSeconds(1),
OverallSeverity = DoctorSeverity.Fail,
Summary = summary,
Results = results
};
}
private static DoctorCheckResult CreateCheckResult(DoctorSeverity severity, string checkId)
{
return new DoctorCheckResult
{
CheckId = checkId,
PluginId = "test.plugin",
Category = "Test",
Severity = severity,
Diagnosis = $"Result for {checkId}",
Evidence = new Evidence
{
Description = "Test evidence",
Data = new Dictionary<string, string>()
},
Duration = TimeSpan.FromMilliseconds(50),
ExecutedAt = DateTimeOffset.UtcNow
};
}
}

View File

@@ -0,0 +1,227 @@
// <copyright file="TextReportFormatterTests.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
// -----------------------------------------------------------------------------
// TextReportFormatterTests.cs
// Sprint: SPRINT_20260112_001_001_DOCTOR_foundation
// Task: DOC-FND-008 - Output formatter unit tests
// Description: Tests for the TextReportFormatter.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Output;
using Xunit;
namespace StellaOps.Doctor.Tests.Output;
[Trait("Category", "Unit")]
public sealed class TextReportFormatterTests
{
[Fact]
public void FormatName_ReturnsText()
{
// Arrange
var formatter = new TextReportFormatter();
// Assert
formatter.FormatName.Should().Be("text");
}
[Fact]
public void Format_WithEmptyReport_ReturnsValidOutput()
{
// Arrange
var formatter = new TextReportFormatter();
var report = CreateEmptyReport();
// Act
var output = formatter.Format(report);
// Assert
output.Should().NotBeNullOrEmpty();
output.Should().Contain("Passed:");
}
[Fact]
public void Format_WithPassedCheck_ContainsPassTag()
{
// Arrange
var formatter = new TextReportFormatter();
var report = CreateReportWithResult(DoctorSeverity.Pass, "All good");
// Act
var output = formatter.Format(report);
// Assert
output.Should().Contain("[PASS]");
output.Should().Contain("All good");
}
[Fact]
public void Format_WithFailedCheck_ContainsFailTag()
{
// Arrange
var formatter = new TextReportFormatter();
var report = CreateReportWithResult(DoctorSeverity.Fail, "Something failed");
// Act
var output = formatter.Format(report);
// Assert
output.Should().Contain("[FAIL]");
output.Should().Contain("Something failed");
}
[Fact]
public void Format_WithWarningCheck_ContainsWarnTag()
{
// Arrange
var formatter = new TextReportFormatter();
var report = CreateReportWithResult(DoctorSeverity.Warn, "Warning message");
// Act
var output = formatter.Format(report);
// Assert
output.Should().Contain("[WARN]");
output.Should().Contain("Warning message");
}
[Fact]
public void Format_ContainsRunId()
{
// Arrange
var formatter = new TextReportFormatter();
var report = CreateEmptyReport("dr_20260112_123456_abc123");
// Act
var output = formatter.Format(report);
// Assert
output.Should().Contain("dr_20260112_123456_abc123");
}
[Fact]
public void Format_ContainsSummary()
{
// Arrange
var formatter = new TextReportFormatter();
var report = CreateReportWithMultipleResults();
// Act
var output = formatter.Format(report);
// Assert
output.Should().Contain("Passed:");
output.Should().Contain("Failed:");
}
[Fact]
public void Format_ContainsCheckId()
{
// Arrange
var formatter = new TextReportFormatter();
var report = CreateReportWithResult(DoctorSeverity.Pass, "Test", "check.test.example");
// Act
var output = formatter.Format(report);
// Assert
output.Should().Contain("check.test.example");
}
private static DoctorReport CreateEmptyReport(string? runId = null)
{
return new DoctorReport
{
RunId = runId ?? $"dr_test_{Guid.NewGuid():N}",
StartedAt = DateTimeOffset.UtcNow.AddSeconds(-1),
CompletedAt = DateTimeOffset.UtcNow,
Duration = TimeSpan.FromSeconds(1),
OverallSeverity = DoctorSeverity.Pass,
Summary = DoctorReportSummary.Empty,
Results = ImmutableArray<DoctorCheckResult>.Empty
};
}
private static DoctorReport CreateReportWithResult(
DoctorSeverity severity,
string diagnosis,
string? checkId = null)
{
var result = new DoctorCheckResult
{
CheckId = checkId ?? "check.test.example",
PluginId = "test.plugin",
Category = "Test",
Severity = severity,
Diagnosis = diagnosis,
Evidence = new Evidence
{
Description = "Test evidence",
Data = new Dictionary<string, string>()
},
Duration = TimeSpan.FromMilliseconds(50),
ExecutedAt = DateTimeOffset.UtcNow
};
var results = ImmutableArray.Create(result);
var summary = DoctorReportSummary.FromResults(results);
return new DoctorReport
{
RunId = $"dr_test_{Guid.NewGuid():N}",
StartedAt = DateTimeOffset.UtcNow.AddSeconds(-1),
CompletedAt = DateTimeOffset.UtcNow,
Duration = TimeSpan.FromSeconds(1),
OverallSeverity = severity,
Summary = summary,
Results = results
};
}
private static DoctorReport CreateReportWithMultipleResults()
{
var results = ImmutableArray.Create(
CreateCheckResult(DoctorSeverity.Pass, "check.test.1"),
CreateCheckResult(DoctorSeverity.Pass, "check.test.2"),
CreateCheckResult(DoctorSeverity.Warn, "check.test.3"),
CreateCheckResult(DoctorSeverity.Fail, "check.test.4")
);
var summary = DoctorReportSummary.FromResults(results);
return new DoctorReport
{
RunId = $"dr_test_{Guid.NewGuid():N}",
StartedAt = DateTimeOffset.UtcNow.AddSeconds(-1),
CompletedAt = DateTimeOffset.UtcNow,
Duration = TimeSpan.FromSeconds(1),
OverallSeverity = DoctorSeverity.Fail,
Summary = summary,
Results = results
};
}
private static DoctorCheckResult CreateCheckResult(DoctorSeverity severity, string checkId)
{
return new DoctorCheckResult
{
CheckId = checkId,
PluginId = "test.plugin",
Category = "Test",
Severity = severity,
Diagnosis = $"Result for {checkId}",
Evidence = new Evidence
{
Description = "Test evidence",
Data = new Dictionary<string, string>()
},
Duration = TimeSpan.FromMilliseconds(50),
ExecutedAt = DateTimeOffset.UtcNow
};
}
}

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3.assert" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Doctor/StellaOps.Doctor.csproj" />
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>