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