audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

@@ -0,0 +1,150 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Doctor.Detection;
using Xunit;
namespace StellaOps.Doctor.Tests.Detection;
[Trait("Category", "Unit")]
public sealed class RuntimeDetectorTests
{
[Fact]
public void Detect_ReturnsConsistentResult()
{
// Arrange
var detector = CreateDetector();
// Act
var result1 = detector.Detect();
var result2 = detector.Detect();
// Assert
result1.Should().Be(result2, "detection should be deterministic");
}
[Fact]
public void GetContextValues_ReturnsNonEmptyDictionary()
{
// Arrange
var detector = CreateDetector();
// Act
var values = detector.GetContextValues();
// Assert
values.Should().NotBeNull();
values.Should().ContainKey("RUNTIME");
}
[Fact]
public void GetContextValues_ContainsRuntimeValue()
{
// Arrange
var detector = CreateDetector();
// Act
var values = detector.GetContextValues();
var runtime = detector.Detect();
// Assert
values["RUNTIME"].Should().Be(runtime.ToString());
}
[Fact]
public void GetContextValues_ContainsDatabaseDefaults()
{
// Arrange
var detector = CreateDetector();
// Act
var values = detector.GetContextValues();
// Assert - These are defaults if no env vars are set
values.Should().ContainKey("DB_HOST");
values.Should().ContainKey("DB_PORT");
}
[Fact]
public void GetContextValues_ContainsValkeyDefaults()
{
// Arrange
var detector = CreateDetector();
// Act
var values = detector.GetContextValues();
// Assert
values.Should().ContainKey("VALKEY_HOST");
values.Should().ContainKey("VALKEY_PORT");
}
[Fact]
public void IsKubernetesContext_ReturnsFalse_WhenNoKubernetesEnvVars()
{
// Arrange
var detector = CreateDetector();
// Act - In test environment, there should be no Kubernetes context
var result = detector.IsKubernetesContext();
// Assert - We can't guarantee the environment, so just check it doesn't throw
result.Should().Be(result); // Tautology, but confirms no exception
}
[Fact]
public void GetKubernetesNamespace_ReturnsDefaultIfNotInCluster()
{
// Arrange
var detector = CreateDetector();
// Act
var ns = detector.GetKubernetesNamespace();
// Assert - Returns default or environment value
ns.Should().NotBeNullOrEmpty();
}
[Fact]
public void IsDockerAvailable_DoesNotThrow()
{
// Arrange
var detector = CreateDetector();
// Act & Assert - Should not throw regardless of Docker availability
var action = () => detector.IsDockerAvailable();
action.Should().NotThrow();
}
[Fact]
public void IsSystemdManaged_DoesNotThrow_ForNonExistentService()
{
// Arrange
var detector = CreateDetector();
// Act & Assert
var action = () => detector.IsSystemdManaged("nonexistent-service-12345");
action.Should().NotThrow();
}
[Fact]
public void GetComposeProjectPath_ReturnsNullOrValidPath()
{
// Arrange
var detector = CreateDetector();
// Act
var path = detector.GetComposeProjectPath();
// Assert
if (path != null)
{
(path.EndsWith(".yml") || path.EndsWith(".yaml")).Should().BeTrue(
"compose file should have .yml or .yaml extension");
}
}
private static RuntimeDetector CreateDetector()
{
return new RuntimeDetector(NullLogger<RuntimeDetector>.Instance);
}
}

View File

@@ -256,7 +256,10 @@ public sealed class DoctorEngineTests
// Add configuration
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Doctor:Evidence:Enabled"] = "false"
})
.Build();
services.AddSingleton<IConfiguration>(configuration);

View File

@@ -0,0 +1,202 @@
using FluentAssertions;
using StellaOps.Doctor.Detection;
using StellaOps.Doctor.Models;
using Xunit;
namespace StellaOps.Doctor.Tests.Models;
[Trait("Category", "Unit")]
public sealed class RemediationModelsTests
{
[Fact]
public void LikelyCause_Create_SetsAllProperties()
{
// Act
var cause = LikelyCause.Create(1, "Test description", "https://docs.example.com");
// Assert
cause.Priority.Should().Be(1);
cause.Description.Should().Be("Test description");
cause.DocumentationUrl.Should().Be("https://docs.example.com");
}
[Fact]
public void LikelyCause_Create_WithoutUrl_HasNullUrl()
{
// Act
var cause = LikelyCause.Create(2, "No docs");
// Assert
cause.Priority.Should().Be(2);
cause.DocumentationUrl.Should().BeNull();
}
[Fact]
public void RemediationCommand_RequiresMinimalProperties()
{
// Act
var command = new RemediationCommand
{
Runtime = RuntimeEnvironment.DockerCompose,
Command = "docker compose up -d",
Description = "Start containers"
};
// Assert
command.Runtime.Should().Be(RuntimeEnvironment.DockerCompose);
command.Command.Should().Be("docker compose up -d");
command.Description.Should().Be("Start containers");
command.RequiresSudo.Should().BeFalse();
command.IsDangerous.Should().BeFalse();
}
[Fact]
public void RemediationCommand_WithSudo_SetsSudoFlag()
{
// Act
var command = new RemediationCommand
{
Runtime = RuntimeEnvironment.Systemd,
Command = "sudo systemctl start postgresql",
Description = "Start PostgreSQL",
RequiresSudo = true
};
// Assert
command.RequiresSudo.Should().BeTrue();
}
[Fact]
public void RemediationCommand_WithDangerous_SetsDangerousFlag()
{
// Act
var command = new RemediationCommand
{
Runtime = RuntimeEnvironment.Any,
Command = "stella migrations-run --module all",
Description = "Apply all migrations",
IsDangerous = true,
DangerWarning = "This will modify the database schema"
};
// Assert
command.IsDangerous.Should().BeTrue();
command.DangerWarning.Should().NotBeNullOrEmpty();
}
[Fact]
public void RemediationCommand_WithPlaceholders_StoresPlaceholders()
{
// Act
var command = new RemediationCommand
{
Runtime = RuntimeEnvironment.Any,
Command = "pg_isready -h {{HOST}} -p {{PORT}}",
Description = "Check PostgreSQL",
Placeholders = new Dictionary<string, string>
{
["HOST"] = "localhost",
["PORT"] = "5432"
}
};
// Assert
command.Placeholders.Should().HaveCount(2);
command.Placeholders!["HOST"].Should().Be("localhost");
command.Placeholders["PORT"].Should().Be("5432");
}
[Fact]
public void WizardRemediation_Empty_HasNoCommands()
{
// Act
var remediation = WizardRemediation.Empty;
// Assert
remediation.LikelyCauses.Should().BeEmpty();
remediation.Commands.Should().BeEmpty();
remediation.VerificationCommand.Should().BeNull();
}
[Fact]
public void WizardRemediation_GetCommandsForRuntime_ReturnsMatchingCommands()
{
// Arrange
var remediation = new WizardRemediation
{
LikelyCauses = [],
Commands =
[
new RemediationCommand
{
Runtime = RuntimeEnvironment.DockerCompose,
Command = "docker compose up -d",
Description = "Docker"
},
new RemediationCommand
{
Runtime = RuntimeEnvironment.Kubernetes,
Command = "kubectl apply",
Description = "K8s"
},
new RemediationCommand
{
Runtime = RuntimeEnvironment.Any,
Command = "echo verify",
Description = "Any"
}
]
};
// Act
var dockerCommands = remediation.GetCommandsForRuntime(RuntimeEnvironment.DockerCompose).ToList();
var k8sCommands = remediation.GetCommandsForRuntime(RuntimeEnvironment.Kubernetes).ToList();
// Assert
dockerCommands.Should().HaveCount(2); // Docker + Any
dockerCommands.Should().Contain(c => c.Description == "Docker");
dockerCommands.Should().Contain(c => c.Description == "Any");
k8sCommands.Should().HaveCount(2); // K8s + Any
k8sCommands.Should().Contain(c => c.Description == "K8s");
k8sCommands.Should().Contain(c => c.Description == "Any");
}
[Fact]
public void WizardRemediation_GetCommandsForRuntime_ReturnsAnyCommands_WhenNoExactMatch()
{
// Arrange
var remediation = new WizardRemediation
{
LikelyCauses = [],
Commands =
[
new RemediationCommand
{
Runtime = RuntimeEnvironment.Any,
Command = "generic command",
Description = "Works everywhere"
}
]
};
// Act
var systemdCommands = remediation.GetCommandsForRuntime(RuntimeEnvironment.Systemd).ToList();
// Assert
systemdCommands.Should().HaveCount(1);
systemdCommands[0].Description.Should().Be("Works everywhere");
}
[Fact]
public void RuntimeEnvironment_HasExpectedValues()
{
// Assert - Verify all expected runtime types exist
Enum.GetValues<RuntimeEnvironment>().Should().Contain(RuntimeEnvironment.DockerCompose);
Enum.GetValues<RuntimeEnvironment>().Should().Contain(RuntimeEnvironment.Kubernetes);
Enum.GetValues<RuntimeEnvironment>().Should().Contain(RuntimeEnvironment.Systemd);
Enum.GetValues<RuntimeEnvironment>().Should().Contain(RuntimeEnvironment.WindowsService);
Enum.GetValues<RuntimeEnvironment>().Should().Contain(RuntimeEnvironment.Bare);
Enum.GetValues<RuntimeEnvironment>().Should().Contain(RuntimeEnvironment.Any);
}
}

View File

@@ -0,0 +1,178 @@
// <copyright file="DoctorEvidenceLogWriterTests.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Output;
using Xunit;
namespace StellaOps.Doctor.Tests.Output;
[Trait("Category", "Unit")]
public sealed class DoctorEvidenceLogWriterTests
{
[Fact]
public async Task WriteAsync_WritesJsonlWithDoctorCommand()
{
var outputRoot = CreateTempRoot();
var configuration = CreateConfiguration(outputRoot, dsseEnabled: false);
var writer = new DoctorEvidenceLogWriter(
configuration,
NullLogger<DoctorEvidenceLogWriter>.Instance);
var report = CreateReport();
var options = new DoctorRunOptions
{
DoctorCommand = "stella doctor run --format json"
};
var artifacts = await writer.WriteAsync(report, options, CancellationToken.None);
artifacts.JsonlPath.Should().NotBeNullOrEmpty();
File.Exists(artifacts.JsonlPath!).Should().BeTrue();
var lines = await File.ReadAllLinesAsync(artifacts.JsonlPath!, CancellationToken.None);
lines.Should().HaveCount(1);
using var doc = JsonDocument.Parse(lines[0]);
var root = doc.RootElement;
root.GetProperty("runId").GetString().Should().Be("dr_test_001");
root.GetProperty("doctor_command").GetString().Should().Be("stella doctor run --format json");
root.GetProperty("severity").GetString().Should().Be("fail");
root.GetProperty("how_to_fix").GetProperty("commands").GetArrayLength().Should().Be(1);
root.GetProperty("evidence").GetProperty("data").GetProperty("token").GetString().Should().Be("[REDACTED]");
}
[Fact]
public async Task WriteAsync_WritesDsseSummaryWhenEnabled()
{
var outputRoot = CreateTempRoot();
var configuration = CreateConfiguration(outputRoot, dsseEnabled: true);
var writer = new DoctorEvidenceLogWriter(
configuration,
NullLogger<DoctorEvidenceLogWriter>.Instance);
var report = CreateReport();
var options = new DoctorRunOptions
{
DoctorCommand = "stella doctor run --format json"
};
var artifacts = await writer.WriteAsync(report, options, CancellationToken.None);
artifacts.DssePath.Should().NotBeNullOrEmpty();
File.Exists(artifacts.DssePath!).Should().BeTrue();
var envelopeJson = await File.ReadAllTextAsync(artifacts.DssePath!, CancellationToken.None);
using var envelopeDoc = JsonDocument.Parse(envelopeJson);
var envelope = envelopeDoc.RootElement;
envelope.GetProperty("payloadType").GetString().Should()
.Be("application/vnd.stellaops.doctor.summary+json");
envelope.GetProperty("signatures").GetArrayLength().Should().Be(0);
var payloadBase64 = envelope.GetProperty("payload").GetString();
payloadBase64.Should().NotBeNullOrEmpty();
var payloadJson = Encoding.UTF8.GetString(Convert.FromBase64String(payloadBase64!));
using var payloadDoc = JsonDocument.Parse(payloadJson);
var payload = payloadDoc.RootElement;
payload.GetProperty("doctor_command").GetString().Should().Be("stella doctor run --format json");
payload.GetProperty("evidenceLog").GetProperty("jsonlPath").GetString().Should()
.Be("artifacts/doctor/doctor-run-dr_test_001.ndjson");
var expectedDigest = ComputeSha256Hex(artifacts.JsonlPath!);
payload.GetProperty("evidenceLog").GetProperty("sha256").GetString().Should().Be(expectedDigest);
}
private static IConfiguration CreateConfiguration(string outputRoot, bool dsseEnabled)
{
return new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Doctor:Evidence:Enabled"] = "true",
["Doctor:Evidence:Root"] = outputRoot,
["Doctor:Evidence:IncludeEvidence"] = "true",
["Doctor:Evidence:RedactSensitive"] = "true",
["Doctor:Evidence:Dsse:Enabled"] = dsseEnabled.ToString()
})
.Build();
}
private static DoctorReport CreateReport()
{
var startedAt = new DateTimeOffset(2026, 1, 12, 14, 30, 52, TimeSpan.Zero);
var completedAt = startedAt.AddSeconds(1);
var evidence = new Evidence
{
Description = "Test evidence",
Data = new Dictionary<string, string>
{
["endpoint"] = "https://example.test",
["token"] = "super-secret"
}.ToImmutableDictionary(StringComparer.Ordinal),
SensitiveKeys = ImmutableArray.Create("token")
};
var remediation = new Remediation
{
Steps = ImmutableArray.Create(
new RemediationStep
{
Order = 1,
Description = "Apply fix",
Command = "stella doctor fix --from report.json",
CommandType = CommandType.Shell
})
};
var result = new DoctorCheckResult
{
CheckId = "check.test.mock",
PluginId = "test.plugin",
Category = "Core",
Severity = DoctorSeverity.Fail,
Diagnosis = "Test failure",
Evidence = evidence,
Remediation = remediation,
Duration = TimeSpan.FromMilliseconds(250),
ExecutedAt = startedAt
};
var summary = DoctorReportSummary.FromResults(new[] { result });
return new DoctorReport
{
RunId = "dr_test_001",
StartedAt = startedAt,
CompletedAt = completedAt,
Duration = completedAt - startedAt,
OverallSeverity = DoctorSeverity.Fail,
Summary = summary,
Results = ImmutableArray.Create(result)
};
}
private static string CreateTempRoot()
{
var root = Path.Combine(Path.GetTempPath(), "stellaops-doctor-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
return root;
}
private static string ComputeSha256Hex(string path)
{
using var stream = File.OpenRead(path);
using var hasher = SHA256.Create();
var hash = hasher.ComputeHash(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,146 @@
// <copyright file="DoctorPackCheckTests.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Packs;
using StellaOps.Doctor.Plugins;
using Xunit;
namespace StellaOps.Doctor.Tests.Packs;
[Trait("Category", "Unit")]
public sealed class DoctorPackCheckTests
{
[Fact]
public async Task RunAsync_PassesWhenExpectationsMet()
{
var definition = CreateDefinition(
new DoctorPackParseRules
{
ExpectContains =
[
new DoctorPackExpectContains { Contains = "OK" }
]
});
var check = new DoctorPackCheck(
definition,
"doctor.pack",
DoctorCategory.Integration,
new FakeRunner(new DoctorPackCommandResult
{
ExitCode = 0,
StdOut = "OK",
StdErr = string.Empty
}));
var context = CreateContext();
var result = await check.RunAsync(context, CancellationToken.None);
result.Severity.Should().Be(DoctorSeverity.Pass);
result.Remediation.Should().BeNull();
}
[Fact]
public async Task RunAsync_FailsWhenJsonExpectationNotMet()
{
var definition = CreateDefinition(
new DoctorPackParseRules
{
ExpectJson =
[
new DoctorPackExpectJson
{
Path = "$.allCompliant",
ExpectedValue = true
}
]
},
new DoctorPackHowToFix
{
Summary = "Apply policy pack",
Commands = ["stella policy apply --preset strict"]
});
var check = new DoctorPackCheck(
definition,
"doctor.pack",
DoctorCategory.Integration,
new FakeRunner(new DoctorPackCommandResult
{
ExitCode = 0,
StdOut = "{\"allCompliant\":false}",
StdErr = string.Empty
}));
var context = CreateContext();
var result = await check.RunAsync(context, CancellationToken.None);
result.Severity.Should().Be(DoctorSeverity.Fail);
result.Diagnosis.Should().Contain("Expectations failed");
result.Remediation.Should().NotBeNull();
result.Remediation!.Steps.Should().HaveCount(1);
}
private static DoctorPluginContext CreateContext()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
var services = new ServiceCollection()
.AddSingleton<IConfiguration>(configuration)
.BuildServiceProvider();
return new DoctorPluginContext
{
Services = services,
Configuration = configuration,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = configuration.GetSection("Doctor:Plugins")
};
}
private static DoctorPackCheckDefinition CreateDefinition(
DoctorPackParseRules parse,
DoctorPackHowToFix? howToFix = null)
{
return new DoctorPackCheckDefinition
{
CheckId = "pack.check",
Name = "Pack check",
Description = "Pack check description",
DefaultSeverity = DoctorSeverity.Fail,
Tags = ImmutableArray<string>.Empty,
EstimatedDuration = TimeSpan.FromSeconds(1),
Run = new DoctorPackCommand("echo ok"),
Parse = parse,
HowToFix = howToFix
};
}
private sealed class FakeRunner : IDoctorPackCommandRunner
{
private readonly DoctorPackCommandResult _result;
public FakeRunner(DoctorPackCommandResult result)
{
_result = result;
}
public Task<DoctorPackCommandResult> RunAsync(
DoctorPackCommand command,
DoctorPluginContext context,
CancellationToken ct)
{
return Task.FromResult(_result);
}
}
}

View File

@@ -0,0 +1,182 @@
// <copyright file="DoctorPackLoaderTests.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Doctor.Packs;
using StellaOps.Doctor.Plugins;
using Xunit;
namespace StellaOps.Doctor.Tests.Packs;
[Trait("Category", "Unit")]
public sealed class DoctorPackLoaderTests
{
[Fact]
public void LoadPlugins_LoadsYamlPack()
{
var root = CreateTempRoot();
try
{
var packDir = Path.Combine(root, "plugins", "doctor");
Directory.CreateDirectory(packDir);
var manifestPath = Path.Combine(packDir, "release-orchestrator.gitlab.yaml");
File.WriteAllText(manifestPath, GetSampleManifest());
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Doctor:Packs:Root"] = root,
["Doctor:Packs:SearchPaths:0"] = packDir
})
.Build();
var context = CreateContext(config);
var loader = new DoctorPackLoader(new FakeRunner(), NullLogger<DoctorPackLoader>.Instance);
var plugins = loader.LoadPlugins(context);
plugins.Should().HaveCount(1);
plugins[0].PluginId.Should().Be("doctor-release-orchestrator-gitlab");
plugins[0].GetChecks(context).Should().HaveCount(1);
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Fact]
public void DoctorPackPlugin_IsAvailable_RespectsDiscovery()
{
var root = CreateTempRoot();
var previousEnv = Environment.GetEnvironmentVariable("PACK_TEST_ENV");
try
{
var packDir = Path.Combine(root, "plugins", "doctor");
Directory.CreateDirectory(packDir);
var configDir = Path.Combine(root, "config");
Directory.CreateDirectory(configDir);
File.WriteAllText(Path.Combine(configDir, "doctor-pack.yaml"), "ok");
var manifestPath = Path.Combine(packDir, "discovery.yaml");
File.WriteAllText(manifestPath, GetDiscoveryManifest());
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Doctor:Packs:Root"] = root,
["Doctor:Packs:SearchPaths:0"] = packDir
})
.Build();
Environment.SetEnvironmentVariable("PACK_TEST_ENV", "ready");
var context = CreateContext(config);
var loader = new DoctorPackLoader(new FakeRunner(), NullLogger<DoctorPackLoader>.Instance);
var plugin = loader.LoadPlugins(context).Single();
plugin.IsAvailable(context.Services).Should().BeTrue();
Environment.SetEnvironmentVariable("PACK_TEST_ENV", null);
plugin.IsAvailable(context.Services).Should().BeFalse();
}
finally
{
Environment.SetEnvironmentVariable("PACK_TEST_ENV", previousEnv);
Directory.Delete(root, recursive: true);
}
}
private static DoctorPluginContext CreateContext(IConfiguration configuration)
{
var services = new ServiceCollection()
.AddSingleton(configuration)
.BuildServiceProvider();
return new DoctorPluginContext
{
Services = services,
Configuration = configuration,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = configuration.GetSection("Doctor:Plugins")
};
}
private static string CreateTempRoot()
{
var root = Path.Combine(Path.GetTempPath(), "stellaops-doctor-tests", Path.GetRandomFileName());
Directory.CreateDirectory(root);
return root;
}
private static string GetSampleManifest()
{
return """
apiVersion: stella.ops/doctor.v1
kind: DoctorPlugin
metadata:
name: doctor-release-orchestrator-gitlab
labels:
module: release-orchestrator
integration: gitlab
spec:
checks:
- id: scm.webhook.reachability
description: "GitLab webhook is reachable"
run:
exec: "echo 200 OK"
parse:
expect:
- contains: "200 OK"
how_to_fix:
summary: "Fix webhook"
commands:
- "stella orchestrator scm create-webhook"
""";
}
private static string GetDiscoveryManifest()
{
return """
apiVersion: stella.ops/doctor.v1
kind: DoctorPlugin
metadata:
name: doctor-pack-discovery
spec:
discovery:
when:
- env: PACK_TEST_ENV
- fileExists: config/doctor-pack.yaml
checks:
- id: discovery.check
description: "Discovery check"
run:
exec: "echo ok"
parse:
expect:
- contains: "ok"
""";
}
private sealed class FakeRunner : IDoctorPackCommandRunner
{
public Task<DoctorPackCommandResult> RunAsync(
DoctorPackCommand command,
DoctorPluginContext context,
CancellationToken ct)
{
return Task.FromResult(new DoctorPackCommandResult
{
ExitCode = 0,
StdOut = "ok",
StdErr = string.Empty
});
}
}
}

View File

@@ -0,0 +1,286 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Doctor.Detection;
using StellaOps.Doctor.Resolver;
using Xunit;
namespace StellaOps.Doctor.Tests.Resolver;
[Trait("Category", "Unit")]
public sealed class PlaceholderResolverTests
{
[Fact]
public void Resolve_WithNoPlaceholders_ReturnsOriginalCommand()
{
// Arrange
var resolver = CreateResolver();
var command = "echo hello world";
// Act
var result = resolver.Resolve(command);
// Assert
result.Should().Be("echo hello world");
}
[Fact]
public void Resolve_WithUserValues_ReplacesPlaceholders()
{
// Arrange
var resolver = CreateResolver();
var command = "curl http://{{HOST}}:{{PORT}}/health";
var values = new Dictionary<string, string>
{
["HOST"] = "localhost",
["PORT"] = "8080"
};
// Act
var result = resolver.Resolve(command, values);
// Assert
result.Should().Be("curl http://localhost:8080/health");
}
[Fact]
public void Resolve_WithDefaultValues_UsesDefault()
{
// Arrange
var resolver = CreateResolver();
var command = "ping {{HOST:-localhost}}";
// Act
var result = resolver.Resolve(command);
// Assert
result.Should().Be("ping localhost");
}
[Fact]
public void Resolve_WithUserValueOverridesDefault()
{
// Arrange
var resolver = CreateResolver();
var command = "ping {{HOST:-localhost}}";
var values = new Dictionary<string, string>
{
["HOST"] = "192.168.1.1"
};
// Act
var result = resolver.Resolve(command, values);
// Assert
result.Should().Be("ping 192.168.1.1");
}
[Fact]
public void Resolve_WithSensitivePlaceholder_DoesNotResolve()
{
// Arrange
var resolver = CreateResolver();
var command = "vault write auth/approle/login secret_id={{SECRET_ID}}";
var values = new Dictionary<string, string>
{
["SECRET_ID"] = "supersecret"
};
// Act
var result = resolver.Resolve(command, values);
// Assert - Sensitive placeholders are NOT replaced
result.Should().Contain("{{SECRET_ID}}");
}
[Fact]
public void Resolve_WithNullCommand_ReturnsNull()
{
// Arrange
var resolver = CreateResolver();
// Act
var result = resolver.Resolve(null!);
// Assert
result.Should().BeNull();
}
[Fact]
public void Resolve_WithEmptyCommand_ReturnsEmpty()
{
// Arrange
var resolver = CreateResolver();
// Act
var result = resolver.Resolve(string.Empty);
// Assert
result.Should().BeEmpty();
}
[Theory]
[InlineData("PASSWORD")]
[InlineData("TOKEN")]
[InlineData("SECRET")]
[InlineData("SECRET_KEY")]
[InlineData("API_KEY")]
[InlineData("APIKEY")]
[InlineData("DB_PASSWORD")]
public void IsSensitivePlaceholder_ReturnsTrueForSensitiveNames(string name)
{
// Arrange
var resolver = CreateResolver();
// Act
var result = resolver.IsSensitivePlaceholder(name);
// Assert
result.Should().BeTrue($"'{name}' should be considered sensitive");
}
[Theory]
[InlineData("HOST")]
[InlineData("PORT")]
[InlineData("NAMESPACE")]
[InlineData("DATABASE")]
[InlineData("USER")]
public void IsSensitivePlaceholder_ReturnsFalseForNonSensitiveNames(string name)
{
// Arrange
var resolver = CreateResolver();
// Act
var result = resolver.IsSensitivePlaceholder(name);
// Assert
result.Should().BeFalse($"'{name}' should not be considered sensitive");
}
[Fact]
public void ExtractPlaceholders_FindsAllPlaceholders()
{
// Arrange
var resolver = CreateResolver();
var command = "{{HOST}}:{{PORT:-5432}}/{{DB_NAME}}";
// Act
var placeholders = resolver.ExtractPlaceholders(command);
// Assert
placeholders.Should().HaveCount(3);
placeholders.Should().Contain(p => p.Name == "HOST");
placeholders.Should().Contain(p => p.Name == "PORT");
placeholders.Should().Contain(p => p.Name == "DB_NAME");
}
[Fact]
public void ExtractPlaceholders_IdentifiesDefaultValues()
{
// Arrange
var resolver = CreateResolver();
var command = "{{HOST:-localhost}}:{{PORT:-5432}}";
// Act
var placeholders = resolver.ExtractPlaceholders(command);
// Assert
var hostPlaceholder = placeholders.Single(p => p.Name == "HOST");
var portPlaceholder = placeholders.Single(p => p.Name == "PORT");
hostPlaceholder.DefaultValue.Should().Be("localhost");
portPlaceholder.DefaultValue.Should().Be("5432");
}
[Fact]
public void ExtractPlaceholders_MarksSensitivePlaceholders()
{
// Arrange
var resolver = CreateResolver();
var command = "{{HOST}} {{PASSWORD}}";
// Act
var placeholders = resolver.ExtractPlaceholders(command);
// Assert
var hostPlaceholder = placeholders.Single(p => p.Name == "HOST");
var passwordPlaceholder = placeholders.Single(p => p.Name == "PASSWORD");
hostPlaceholder.IsSensitive.Should().BeFalse();
passwordPlaceholder.IsSensitive.Should().BeTrue();
}
[Fact]
public void ExtractPlaceholders_IdentifiesRequiredPlaceholders()
{
// Arrange
var resolver = CreateResolver();
var command = "{{HOST}} {{PORT:-5432}}";
// Act
var placeholders = resolver.ExtractPlaceholders(command);
// Assert
var hostPlaceholder = placeholders.Single(p => p.Name == "HOST");
var portPlaceholder = placeholders.Single(p => p.Name == "PORT");
hostPlaceholder.IsRequired.Should().BeTrue();
portPlaceholder.IsRequired.Should().BeFalse();
}
[Fact]
public void ExtractPlaceholders_HandlesEmptyCommand()
{
// Arrange
var resolver = CreateResolver();
// Act
var placeholders = resolver.ExtractPlaceholders(string.Empty);
// Assert
placeholders.Should().BeEmpty();
}
[Fact]
public void ExtractPlaceholders_HandlesNoPlaceholders()
{
// Arrange
var resolver = CreateResolver();
var command = "echo hello world";
// Act
var placeholders = resolver.ExtractPlaceholders(command);
// Assert
placeholders.Should().BeEmpty();
}
[Fact]
public void Resolve_UsesContextValuesFromRuntimeDetector()
{
// Arrange
var mockDetector = new Mock<IRuntimeDetector>();
mockDetector.Setup(d => d.GetContextValues())
.Returns(new Dictionary<string, string>
{
["NAMESPACE"] = "custom-ns"
});
var resolver = new PlaceholderResolver(mockDetector.Object);
var command = "kubectl get pods -n {{NAMESPACE}}";
// Act
var result = resolver.Resolve(command);
// Assert
result.Should().Be("kubectl get pods -n custom-ns");
}
private static PlaceholderResolver CreateResolver()
{
var mockDetector = new Mock<IRuntimeDetector>();
mockDetector.Setup(d => d.GetContextValues())
.Returns(new Dictionary<string, string>());
return new PlaceholderResolver(mockDetector.Object);
}
}

View File

@@ -0,0 +1,207 @@
using System.Runtime.InteropServices;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Doctor.Detection;
using StellaOps.Doctor.Resolver;
using Xunit;
namespace StellaOps.Doctor.Tests.Resolver;
[Trait("Category", "Unit")]
public sealed class VerificationExecutorTests
{
[Fact]
public async Task ExecuteAsync_WithEmptyCommand_ReturnsError()
{
// Arrange
var executor = CreateExecutor();
// Act
var result = await executor.ExecuteAsync("", TimeSpan.FromSeconds(5));
// Assert
result.Success.Should().BeFalse();
result.Error.Should().Contain("empty");
}
[Fact]
public async Task ExecuteAsync_WithWhitespaceCommand_ReturnsError()
{
// Arrange
var executor = CreateExecutor();
// Act
var result = await executor.ExecuteAsync(" ", TimeSpan.FromSeconds(5));
// Assert
result.Success.Should().BeFalse();
result.Error.Should().Contain("empty");
}
[Fact]
public async Task ExecuteAsync_WithSimpleCommand_Succeeds()
{
// Arrange
var executor = CreateExecutor();
var command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? "echo hello"
: "echo hello";
// Act
var result = await executor.ExecuteAsync(command, TimeSpan.FromSeconds(10));
// Assert
result.Success.Should().BeTrue();
result.ExitCode.Should().Be(0);
result.Output.Should().Contain("hello");
}
[Fact]
public async Task ExecuteAsync_WithFailingCommand_ReturnsFailure()
{
// Arrange
var executor = CreateExecutor();
var command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? "cmd /c exit 1"
: "exit 1";
// Act
var result = await executor.ExecuteAsync(command, TimeSpan.FromSeconds(10));
// Assert
result.Success.Should().BeFalse();
result.ExitCode.Should().NotBe(0);
}
[Fact]
public async Task ExecuteAsync_RecordsDuration()
{
// Arrange
var executor = CreateExecutor();
var command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? "echo test"
: "echo test";
// Act
var result = await executor.ExecuteAsync(command, TimeSpan.FromSeconds(10));
// Assert
result.Duration.Should().BeGreaterThan(TimeSpan.Zero);
}
[Fact]
public async Task ExecuteAsync_WithTimeout_TimesOut()
{
// Arrange
var executor = CreateExecutor();
// Command that takes too long
var command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? "ping -n 30 127.0.0.1"
: "sleep 30";
// Act
var result = await executor.ExecuteAsync(command, TimeSpan.FromMilliseconds(500));
// Assert
result.Success.Should().BeFalse();
result.TimedOut.Should().BeTrue();
}
[Fact]
public async Task ExecuteWithPlaceholdersAsync_ResolvesPlaceholders()
{
// Arrange
var mockDetector = new Mock<IRuntimeDetector>();
mockDetector.Setup(d => d.GetContextValues())
.Returns(new Dictionary<string, string>());
var placeholderResolver = new PlaceholderResolver(mockDetector.Object);
var executor = new VerificationExecutor(
placeholderResolver,
NullLogger<VerificationExecutor>.Instance);
var command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? "echo {{MESSAGE}}"
: "echo {{MESSAGE}}";
var values = new Dictionary<string, string> { ["MESSAGE"] = "resolved" };
// Act
var result = await executor.ExecuteWithPlaceholdersAsync(
command, values, TimeSpan.FromSeconds(10));
// Assert
result.Success.Should().BeTrue();
result.Output.Should().Contain("resolved");
}
[Fact]
public async Task ExecuteWithPlaceholdersAsync_WithMissingRequired_ReturnsError()
{
// Arrange
var mockDetector = new Mock<IRuntimeDetector>();
mockDetector.Setup(d => d.GetContextValues())
.Returns(new Dictionary<string, string>());
var placeholderResolver = new PlaceholderResolver(mockDetector.Object);
var executor = new VerificationExecutor(
placeholderResolver,
NullLogger<VerificationExecutor>.Instance);
var command = "curl http://{{HOST}}:{{PORT}}/health";
// Act
var result = await executor.ExecuteWithPlaceholdersAsync(
command, null, TimeSpan.FromSeconds(10));
// Assert
result.Success.Should().BeFalse();
result.Error.Should().Contain("Missing required placeholder");
}
[Fact]
public async Task ExecuteAsync_WithNonExistentCommand_ReturnsError()
{
// Arrange
var executor = CreateExecutor();
var command = "nonexistent_command_12345";
// Act
var result = await executor.ExecuteAsync(command, TimeSpan.FromSeconds(5));
// Assert
result.Success.Should().BeFalse();
}
[Fact]
public async Task ExecuteAsync_WithCancellation_StopsEarly()
{
// Arrange
var executor = CreateExecutor();
var command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? "ping -n 30 127.0.0.1"
: "sleep 30";
var cts = new CancellationTokenSource();
// Act
var task = executor.ExecuteAsync(command, TimeSpan.FromMinutes(1), cts.Token);
await Task.Delay(100);
cts.Cancel();
// Assert
var result = await task;
result.Success.Should().BeFalse();
}
private static VerificationExecutor CreateExecutor()
{
var mockDetector = new Mock<IRuntimeDetector>();
mockDetector.Setup(d => d.GetContextValues())
.Returns(new Dictionary<string, string>());
var placeholderResolver = new PlaceholderResolver(mockDetector.Object);
return new VerificationExecutor(
placeholderResolver,
NullLogger<VerificationExecutor>.Instance);
}
}