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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# Policy Tools Tests Charter
|
||||
|
||||
## Mission
|
||||
Validate policy tool runner behavior and deterministic outputs.
|
||||
|
||||
## Responsibilities
|
||||
- Keep tests deterministic and offline-friendly.
|
||||
- Use local fixtures; avoid network calls.
|
||||
- Track task status in `TASKS.md`.
|
||||
|
||||
## Required Reading
|
||||
- `docs/modules/policy/architecture.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status in the sprint file and local `TASKS.md`.
|
||||
- 2. Prefer fixed timestamps and stable temp paths.
|
||||
- 3. Add tests for new runner behaviors and summary outputs.
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tools.Tests;
|
||||
|
||||
public sealed class PolicySchemaExporterRunnerTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RunAsync_WritesLfLineEndings()
|
||||
{
|
||||
using var temp = new TempDirectory("schema-export");
|
||||
var runner = new PolicySchemaExporterRunner();
|
||||
var options = new PolicySchemaExportOptions
|
||||
{
|
||||
OutputDirectory = temp.RootPath
|
||||
};
|
||||
|
||||
var exitCode = await runner.RunAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
var export = PolicySchemaExporterSchema.BuildExports().First();
|
||||
var outputPath = Path.Combine(temp.RootPath, export.FileName);
|
||||
var bytes = await File.ReadAllBytesAsync(outputPath, CancellationToken.None);
|
||||
|
||||
Assert.True(bytes.Length > 1);
|
||||
Assert.Equal((byte)'\n', bytes[^1]);
|
||||
Assert.NotEqual((byte)'\r', bytes[^2]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tools.Tests;
|
||||
|
||||
public sealed class PolicySimulationSmokeRunnerTests
|
||||
{
|
||||
private const string PolicyJson = "{\n \"version\": \"1.0\",\n \"rules\": [\n {\n \"name\": \"block-low\",\n \"action\": \"block\",\n \"severity\": [\"low\"]\n }\n ]\n}\n";
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RunAsync_ReportsInvalidSeverity()
|
||||
{
|
||||
using var temp = new TempDirectory("policy-sim-invalid-severity");
|
||||
WritePolicy(temp.RootPath);
|
||||
|
||||
var scenario = new PolicySimulationScenario
|
||||
{
|
||||
Name = "invalid-severity",
|
||||
PolicyPath = "policy.json",
|
||||
Findings = new List<ScenarioFinding>
|
||||
{
|
||||
new() { FindingId = "F-1", Severity = "NotASeverity" }
|
||||
},
|
||||
ExpectedDiffs = new List<ScenarioExpectedDiff>()
|
||||
};
|
||||
|
||||
var scenarioRoot = WriteScenario(temp.RootPath, scenario);
|
||||
var outputRoot = Path.Combine(temp.RootPath, "out");
|
||||
var options = BuildOptions(scenarioRoot, outputRoot, temp.RootPath);
|
||||
|
||||
var runner = new PolicySimulationSmokeRunner();
|
||||
var exitCode = await runner.RunAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, exitCode);
|
||||
|
||||
var summaryPath = Path.Combine(outputRoot, "policy-simulation-summary.json");
|
||||
using var document = JsonDocument.Parse(await File.ReadAllTextAsync(summaryPath, CancellationToken.None));
|
||||
var entry = document.RootElement.EnumerateArray().Single();
|
||||
|
||||
Assert.False(entry.GetProperty("Success").GetBoolean());
|
||||
|
||||
var failures = entry.GetProperty("Failures")
|
||||
.EnumerateArray()
|
||||
.Select(value => value.GetString())
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains("Scenario 'invalid-severity' finding 'F-1' has invalid severity 'NotASeverity'.", failures);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RunAsync_ReportsInvalidBaselineStatus()
|
||||
{
|
||||
using var temp = new TempDirectory("policy-sim-invalid-status");
|
||||
WritePolicy(temp.RootPath);
|
||||
|
||||
var scenario = new PolicySimulationScenario
|
||||
{
|
||||
Name = "invalid-status",
|
||||
PolicyPath = "policy.json",
|
||||
Findings = new List<ScenarioFinding>
|
||||
{
|
||||
new() { FindingId = "F-1", Severity = "Low" }
|
||||
},
|
||||
ExpectedDiffs = new List<ScenarioExpectedDiff>(),
|
||||
Baseline = new List<ScenarioBaseline>
|
||||
{
|
||||
new() { FindingId = "F-1", Status = "BadStatus" }
|
||||
}
|
||||
};
|
||||
|
||||
var scenarioRoot = WriteScenario(temp.RootPath, scenario);
|
||||
var outputRoot = Path.Combine(temp.RootPath, "out");
|
||||
var options = BuildOptions(scenarioRoot, outputRoot, temp.RootPath);
|
||||
|
||||
var runner = new PolicySimulationSmokeRunner();
|
||||
var exitCode = await runner.RunAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, exitCode);
|
||||
|
||||
var summaryPath = Path.Combine(outputRoot, "policy-simulation-summary.json");
|
||||
using var document = JsonDocument.Parse(await File.ReadAllTextAsync(summaryPath, CancellationToken.None));
|
||||
var entry = document.RootElement.EnumerateArray().Single();
|
||||
|
||||
Assert.False(entry.GetProperty("Success").GetBoolean());
|
||||
|
||||
var failures = entry.GetProperty("Failures")
|
||||
.EnumerateArray()
|
||||
.Select(value => value.GetString())
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains("Scenario 'invalid-status' baseline 'F-1' has invalid status 'BadStatus'.", failures);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RunAsync_SortsActualStatusesInSummary()
|
||||
{
|
||||
using var temp = new TempDirectory("policy-sim-ordering");
|
||||
WritePolicy(temp.RootPath);
|
||||
|
||||
var scenario = new PolicySimulationScenario
|
||||
{
|
||||
Name = "ordering",
|
||||
PolicyPath = "policy.json",
|
||||
Findings = new List<ScenarioFinding>
|
||||
{
|
||||
new() { FindingId = "b", Severity = "Low" },
|
||||
new() { FindingId = "a", Severity = "Low" }
|
||||
},
|
||||
ExpectedDiffs = new List<ScenarioExpectedDiff>
|
||||
{
|
||||
new() { FindingId = "b", Status = "Blocked" },
|
||||
new() { FindingId = "a", Status = "Blocked" }
|
||||
}
|
||||
};
|
||||
|
||||
var scenarioRoot = WriteScenario(temp.RootPath, scenario);
|
||||
var outputRoot = Path.Combine(temp.RootPath, "out");
|
||||
var options = BuildOptions(scenarioRoot, outputRoot, temp.RootPath);
|
||||
|
||||
var runner = new PolicySimulationSmokeRunner();
|
||||
var exitCode = await runner.RunAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
var summaryPath = Path.Combine(outputRoot, "policy-simulation-summary.json");
|
||||
using var document = JsonDocument.Parse(await File.ReadAllTextAsync(summaryPath, CancellationToken.None));
|
||||
var entry = document.RootElement.EnumerateArray().Single();
|
||||
|
||||
var actualStatuses = entry.GetProperty("ActualStatuses").EnumerateObject().Select(pair => pair.Name).ToArray();
|
||||
|
||||
Assert.Equal(new[] { "a", "b" }, actualStatuses);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResolveFixedTime_UsesDefaultWhenMissing()
|
||||
{
|
||||
var resolved = PolicySimulationSmokeDefaults.ResolveFixedTime(null);
|
||||
|
||||
Assert.Equal(PolicySimulationSmokeDefaults.DefaultFixedTime, resolved);
|
||||
}
|
||||
|
||||
private static PolicySimulationSmokeOptions BuildOptions(string scenarioRoot, string outputRoot, string repoRoot)
|
||||
=> new()
|
||||
{
|
||||
ScenarioRoot = scenarioRoot,
|
||||
OutputDirectory = outputRoot,
|
||||
RepoRoot = repoRoot,
|
||||
FixedTime = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
|
||||
private static void WritePolicy(string rootPath)
|
||||
{
|
||||
var policyPath = Path.Combine(rootPath, "policy.json");
|
||||
File.WriteAllText(policyPath, PolicyJson);
|
||||
}
|
||||
|
||||
private static string WriteScenario(string rootPath, PolicySimulationScenario scenario)
|
||||
{
|
||||
var scenarioRoot = Path.Combine(rootPath, "scenarios");
|
||||
Directory.CreateDirectory(scenarioRoot);
|
||||
|
||||
var scenarioPath = Path.Combine(scenarioRoot, "scenario.json");
|
||||
var scenarioJson = JsonSerializer.Serialize(
|
||||
scenario,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true });
|
||||
File.WriteAllText(scenarioPath, scenarioJson);
|
||||
|
||||
return scenarioRoot;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Policy.Tools/StellaOps.Policy.Tools.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
# Policy Tools Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0096-A | DONE | Added Policy.Tools runner coverage 2026-01-14. |
|
||||
@@ -0,0 +1,23 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace StellaOps.Policy.Tools.Tests;
|
||||
|
||||
internal sealed class TempDirectory : IDisposable
|
||||
{
|
||||
public TempDirectory(string name)
|
||||
{
|
||||
RootPath = Path.Combine(Path.GetTempPath(), "stellaops-policy-tools-tests", $"{name}-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(RootPath);
|
||||
}
|
||||
|
||||
public string RootPath { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(RootPath))
|
||||
{
|
||||
Directory.Delete(RootPath, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (C) 2025 StellaOps Contributors
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Provcache.Api;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
namespace StellaOps.Provcache.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -15,6 +15,8 @@ namespace StellaOps.Provcache.Tests;
|
||||
/// </summary>
|
||||
public sealed class ApiContractTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
@@ -24,7 +26,7 @@ public sealed class ApiContractTests
|
||||
#region CacheSource Contract Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[Theory]
|
||||
[InlineData("none")]
|
||||
[InlineData("inMemory")]
|
||||
[InlineData("redis")]
|
||||
@@ -48,7 +50,7 @@ public sealed class ApiContractTests
|
||||
#region TrustScoreBreakdown Contract Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void TrustScoreBreakdown_DefaultWeights_SumToOne()
|
||||
{
|
||||
// Verify the standard weights sum to 1.0 (100%)
|
||||
@@ -64,7 +66,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void TrustScoreBreakdown_StandardWeights_MatchDocumentation()
|
||||
{
|
||||
// Verify weights match the documented percentages
|
||||
@@ -79,7 +81,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void TrustScoreBreakdown_ComputeTotal_ReturnsCorrectWeightedSum()
|
||||
{
|
||||
// Given all scores at 100, total should be 100
|
||||
@@ -94,7 +96,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void TrustScoreBreakdown_ComputeTotal_WithZeroScores_ReturnsZero()
|
||||
{
|
||||
var breakdown = TrustScoreBreakdown.CreateDefault();
|
||||
@@ -103,7 +105,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void TrustScoreBreakdown_ComputeTotal_WithMixedScores_ComputesCorrectly()
|
||||
{
|
||||
// Specific test case:
|
||||
@@ -124,7 +126,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void TrustScoreBreakdown_Serialization_IncludesAllComponents()
|
||||
{
|
||||
var breakdown = TrustScoreBreakdown.CreateDefault(50, 60, 70, 80, 90);
|
||||
@@ -139,7 +141,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void TrustScoreComponent_Contribution_CalculatesCorrectly()
|
||||
{
|
||||
var component = new TrustScoreComponent { Score = 80, Weight = 0.25m };
|
||||
@@ -164,8 +166,8 @@ public sealed class ApiContractTests
|
||||
VerdictHash = "sha256:def",
|
||||
ProofRoot = "sha256:ghi",
|
||||
ReplaySeed = new ReplaySeed { FeedIds = [], RuleIds = [] },
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
|
||||
CreatedAt = FixedNow,
|
||||
ExpiresAt = FixedNow.AddHours(1),
|
||||
TrustScore = 85,
|
||||
TrustScoreBreakdown = null
|
||||
};
|
||||
@@ -179,7 +181,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void DecisionDigest_WithBreakdown_SerializesCorrectly()
|
||||
{
|
||||
var digest = new DecisionDigest
|
||||
@@ -189,8 +191,8 @@ public sealed class ApiContractTests
|
||||
VerdictHash = "sha256:def",
|
||||
ProofRoot = "sha256:ghi",
|
||||
ReplaySeed = new ReplaySeed { FeedIds = [], RuleIds = [] },
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
|
||||
CreatedAt = FixedNow,
|
||||
ExpiresAt = FixedNow.AddHours(1),
|
||||
TrustScore = 79,
|
||||
TrustScoreBreakdown = TrustScoreBreakdown.CreateDefault(80, 90, 70, 100, 60)
|
||||
};
|
||||
@@ -206,7 +208,7 @@ public sealed class ApiContractTests
|
||||
#region InputManifest Contract Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void InputManifestResponse_RequiredFields_NotNull()
|
||||
{
|
||||
var manifest = new InputManifestResponse
|
||||
@@ -218,7 +220,7 @@ public sealed class ApiContractTests
|
||||
Policy = new PolicyInfoDto { Hash = "sha256:policy" },
|
||||
Signers = new SignerInfoDto { SetHash = "sha256:signers" },
|
||||
TimeWindow = new TimeWindowInfoDto { Bucket = "2024-12-24" },
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
GeneratedAt = FixedNow,
|
||||
};
|
||||
|
||||
manifest.VeriKey.Should().NotBeNull();
|
||||
@@ -231,7 +233,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void InputManifestResponse_Serialization_IncludesAllComponents()
|
||||
{
|
||||
var manifest = new InputManifestResponse
|
||||
@@ -243,7 +245,7 @@ public sealed class ApiContractTests
|
||||
Policy = new PolicyInfoDto { Hash = "sha256:policy" },
|
||||
Signers = new SignerInfoDto { SetHash = "sha256:signers" },
|
||||
TimeWindow = new TimeWindowInfoDto { Bucket = "2024-12-24" },
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
GeneratedAt = FixedNow,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(manifest, JsonOptions);
|
||||
@@ -259,7 +261,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void SbomInfoDto_OptionalFields_CanBeNull()
|
||||
{
|
||||
var sbom = new SbomInfoDto
|
||||
@@ -277,7 +279,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void VexInfoDto_Sources_CanBeEmpty()
|
||||
{
|
||||
var vex = new VexInfoDto
|
||||
@@ -292,7 +294,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void PolicyInfoDto_OptionalFields_PreserveValues()
|
||||
{
|
||||
var policy = new PolicyInfoDto
|
||||
@@ -310,7 +312,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void SignerInfoDto_Certificates_CanBeNull()
|
||||
{
|
||||
var signers = new SignerInfoDto
|
||||
@@ -324,7 +326,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void SignerCertificateDto_AllFields_AreOptional()
|
||||
{
|
||||
var cert = new SignerCertificateDto
|
||||
@@ -346,8 +348,8 @@ public sealed class ApiContractTests
|
||||
var timeWindow = new TimeWindowInfoDto
|
||||
{
|
||||
Bucket = "2024-W52",
|
||||
StartsAt = DateTimeOffset.Parse("2024-12-23T00:00:00Z"),
|
||||
EndsAt = DateTimeOffset.Parse("2024-12-30T00:00:00Z")
|
||||
StartsAt = DateTimeOffset.Parse("2024-12-23T00:00:00Z", CultureInfo.InvariantCulture),
|
||||
EndsAt = DateTimeOffset.Parse("2024-12-30T00:00:00Z", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
timeWindow.Bucket.Should().NotBeNullOrEmpty();
|
||||
@@ -358,7 +360,7 @@ public sealed class ApiContractTests
|
||||
#region API Response Backwards Compatibility
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void ProvcacheGetResponse_Status_ValidValues()
|
||||
{
|
||||
// Verify status field uses expected values
|
||||
|
||||
@@ -8,6 +8,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Moq;
|
||||
using StellaOps.Provcache.Api;
|
||||
using StellaOps.TestKit.Deterministic;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
@@ -18,6 +19,7 @@ namespace StellaOps.Provcache.Tests;
|
||||
/// </summary>
|
||||
public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private IHost? _host;
|
||||
private HttpClient? _client;
|
||||
private Mock<IEvidenceChunkRepository>? _mockChunkRepository;
|
||||
@@ -42,7 +44,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
// Add mock IProvcacheService to satisfy the main endpoints
|
||||
services.AddSingleton(Mock.Of<IProvcacheService>());
|
||||
// Add TimeProvider for InputManifest endpoint
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<TimeProvider>(new FixedTimeProvider(FixedNow));
|
||||
})
|
||||
.Configure(app =>
|
||||
{
|
||||
@@ -68,7 +70,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetEvidenceChunks_ReturnsChunksWithPagination()
|
||||
{
|
||||
@@ -80,7 +82,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
TotalChunks = 15,
|
||||
TotalSize = 15000,
|
||||
Chunks = [],
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
GeneratedAt = FixedNow
|
||||
};
|
||||
|
||||
var chunks = new List<EvidenceChunk>
|
||||
@@ -108,7 +110,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
result.NextCursor.Should().Be("10");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetEvidenceChunks_WithOffset_ReturnsPaginatedResults()
|
||||
{
|
||||
@@ -120,7 +122,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
TotalChunks = 5,
|
||||
TotalSize = 5000,
|
||||
Chunks = [],
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
GeneratedAt = FixedNow
|
||||
};
|
||||
|
||||
var chunks = new List<EvidenceChunk>
|
||||
@@ -147,7 +149,35 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
result.HasMore.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetEvidenceChunks_WithNegativeOffset_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var proofRoot = "sha256:bad-offset";
|
||||
|
||||
// Act
|
||||
var response = await _client!.GetAsync($"/v1/provcache/proofs/{Uri.EscapeDataString(proofRoot)}?offset=-1");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetEvidenceChunks_WithInvalidLimit_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var proofRoot = "sha256:bad-limit";
|
||||
|
||||
// Act
|
||||
var response = await _client!.GetAsync($"/v1/provcache/proofs/{Uri.EscapeDataString(proofRoot)}?limit=0");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetEvidenceChunks_WithIncludeData_ReturnsBase64Blobs()
|
||||
{
|
||||
@@ -159,7 +189,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
TotalChunks = 1,
|
||||
TotalSize = 100,
|
||||
Chunks = [],
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
GeneratedAt = FixedNow
|
||||
};
|
||||
|
||||
var chunks = new List<EvidenceChunk>
|
||||
@@ -182,7 +212,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
result!.Chunks[0].Data.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetEvidenceChunks_NotFound_Returns404()
|
||||
{
|
||||
@@ -198,7 +228,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetProofManifest_ReturnsManifestWithChunkMetadata()
|
||||
{
|
||||
@@ -211,11 +241,11 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
TotalSize = 3000,
|
||||
Chunks = new List<ChunkMetadata>
|
||||
{
|
||||
new() { ChunkId = Guid.NewGuid(), Index = 0, Hash = "sha256:chunk0", Size = 1000, ContentType = "application/octet-stream" },
|
||||
new() { ChunkId = Guid.NewGuid(), Index = 1, Hash = "sha256:chunk1", Size = 1000, ContentType = "application/octet-stream" },
|
||||
new() { ChunkId = Guid.NewGuid(), Index = 2, Hash = "sha256:chunk2", Size = 1000, ContentType = "application/octet-stream" }
|
||||
new() { ChunkId = CreateGuid(1), Index = 0, Hash = "sha256:chunk0", Size = 1000, ContentType = "application/octet-stream" },
|
||||
new() { ChunkId = CreateGuid(2), Index = 1, Hash = "sha256:chunk1", Size = 1000, ContentType = "application/octet-stream" },
|
||||
new() { ChunkId = CreateGuid(3), Index = 2, Hash = "sha256:chunk2", Size = 1000, ContentType = "application/octet-stream" }
|
||||
},
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
GeneratedAt = FixedNow
|
||||
};
|
||||
|
||||
_mockChunkRepository!.Setup(x => x.GetManifestAsync(proofRoot, It.IsAny<CancellationToken>()))
|
||||
@@ -233,7 +263,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
result.Chunks.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetProofManifest_NotFound_Returns404()
|
||||
{
|
||||
@@ -249,7 +279,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetSingleChunk_ReturnsChunkWithData()
|
||||
{
|
||||
@@ -272,7 +302,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
result.Data.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetSingleChunk_NotFound_Returns404()
|
||||
{
|
||||
@@ -288,7 +318,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task VerifyProof_ValidChunks_ReturnsIsValidTrue()
|
||||
{
|
||||
@@ -320,7 +350,40 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
result.ChunkResults.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task VerifyProof_OrdersChunksBeforeHashing()
|
||||
{
|
||||
// Arrange
|
||||
var proofRoot = "sha256:ordered-proof";
|
||||
var chunk1 = CreateChunk(proofRoot, 1, 100);
|
||||
var chunk0 = CreateChunk(proofRoot, 0, 100);
|
||||
var orderedHashes = new[] { chunk0.ChunkHash, chunk1.ChunkHash };
|
||||
var captured = new List<string>();
|
||||
|
||||
_mockChunkRepository!.Setup(x => x.GetChunksAsync(proofRoot, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<EvidenceChunk> { chunk1, chunk0 });
|
||||
|
||||
_mockChunker!.Setup(x => x.VerifyChunk(It.IsAny<EvidenceChunk>()))
|
||||
.Returns(true);
|
||||
|
||||
_mockChunker.Setup(x => x.ComputeMerkleRoot(It.IsAny<IEnumerable<string>>()))
|
||||
.Callback<IEnumerable<string>>(hashes =>
|
||||
{
|
||||
captured.Clear();
|
||||
captured.AddRange(hashes);
|
||||
})
|
||||
.Returns(proofRoot);
|
||||
|
||||
// Act
|
||||
var response = await _client!.PostAsync($"/v1/provcache/proofs/{Uri.EscapeDataString(proofRoot)}/verify", null);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
captured.Should().Equal(orderedHashes);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task VerifyProof_MerkleRootMismatch_ReturnsIsValidFalse()
|
||||
{
|
||||
@@ -351,7 +414,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
result.Error.Should().Contain("Merkle root mismatch");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task VerifyProof_NoChunks_Returns404()
|
||||
{
|
||||
@@ -369,21 +432,27 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
|
||||
private static EvidenceChunk CreateChunk(string proofRoot, int index, int size)
|
||||
{
|
||||
var random = new DeterministicRandom(index + size);
|
||||
var data = new byte[size];
|
||||
Random.Shared.NextBytes(data);
|
||||
random.NextBytes(data);
|
||||
|
||||
return new EvidenceChunk
|
||||
{
|
||||
ChunkId = Guid.NewGuid(),
|
||||
ChunkId = random.NextGuid(),
|
||||
ProofRoot = proofRoot,
|
||||
ChunkIndex = index,
|
||||
ChunkHash = $"sha256:chunk{index}",
|
||||
Blob = data,
|
||||
BlobSize = size,
|
||||
ContentType = "application/octet-stream",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
CreatedAt = FixedNow.AddMinutes(index)
|
||||
};
|
||||
}
|
||||
|
||||
private static Guid CreateGuid(int seed)
|
||||
{
|
||||
return new DeterministicRandom(seed).NextGuid();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Provcache;
|
||||
using StellaOps.TestKit;
|
||||
using StellaOps.TestKit.Deterministic;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Provcache.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -11,6 +11,7 @@ namespace StellaOps.Provcache.Tests;
|
||||
/// </summary>
|
||||
public sealed class EvidenceChunkerTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private readonly ProvcacheOptions _options;
|
||||
private readonly EvidenceChunker _chunker;
|
||||
|
||||
@@ -21,12 +22,11 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ChunkAsync_ShouldSplitEvidenceIntoMultipleChunks_WhenLargerThanChunkSize()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = new byte[200];
|
||||
Random.Shared.NextBytes(evidence);
|
||||
var evidence = CreateDeterministicBytes(200, 1);
|
||||
const string contentType = "application/octet-stream";
|
||||
|
||||
// Act
|
||||
@@ -48,12 +48,11 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ChunkAsync_ShouldCreateSingleChunk_WhenSmallerThanChunkSize()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = new byte[32];
|
||||
Random.Shared.NextBytes(evidence);
|
||||
var evidence = CreateDeterministicBytes(32, 2);
|
||||
const string contentType = "application/json";
|
||||
|
||||
// Act
|
||||
@@ -67,7 +66,7 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ChunkAsync_ShouldHandleEmptyEvidence()
|
||||
{
|
||||
// Arrange
|
||||
@@ -84,7 +83,7 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ChunkAsync_ShouldProduceUniqueHashForEachChunk()
|
||||
{
|
||||
// Arrange - create evidence with distinct bytes per chunk
|
||||
@@ -102,12 +101,11 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ReassembleAsync_ShouldRecoverOriginalEvidence()
|
||||
{
|
||||
// Arrange
|
||||
var original = new byte[200];
|
||||
Random.Shared.NextBytes(original);
|
||||
var original = CreateDeterministicBytes(200, 3);
|
||||
const string contentType = "application/octet-stream";
|
||||
|
||||
var chunked = await _chunker.ChunkAsync(original, contentType);
|
||||
@@ -120,12 +118,11 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ReassembleAsync_ShouldThrow_WhenMerkleRootMismatch()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = new byte[100];
|
||||
Random.Shared.NextBytes(evidence);
|
||||
var evidence = CreateDeterministicBytes(100, 4);
|
||||
const string contentType = "application/octet-stream";
|
||||
|
||||
var chunked = await _chunker.ChunkAsync(evidence, contentType);
|
||||
@@ -137,12 +134,11 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ReassembleAsync_ShouldThrow_WhenChunkCorrupted()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = new byte[100];
|
||||
Random.Shared.NextBytes(evidence);
|
||||
var evidence = CreateDeterministicBytes(100, 5);
|
||||
const string contentType = "application/octet-stream";
|
||||
|
||||
var chunked = await _chunker.ChunkAsync(evidence, contentType);
|
||||
@@ -161,24 +157,23 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void VerifyChunk_ShouldReturnTrue_WhenChunkValid()
|
||||
{
|
||||
// Arrange
|
||||
var data = new byte[32];
|
||||
Random.Shared.NextBytes(data);
|
||||
var data = CreateDeterministicBytes(32, 6);
|
||||
var hash = ComputeHash(data);
|
||||
|
||||
var chunk = new EvidenceChunk
|
||||
{
|
||||
ChunkId = Guid.NewGuid(),
|
||||
ChunkId = CreateGuid(6),
|
||||
ProofRoot = "sha256:test",
|
||||
ChunkIndex = 0,
|
||||
ChunkHash = hash,
|
||||
Blob = data,
|
||||
BlobSize = data.Length,
|
||||
ContentType = "application/octet-stream",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
CreatedAt = FixedNow
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
@@ -186,20 +181,20 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void VerifyChunk_ShouldReturnFalse_WhenHashMismatch()
|
||||
{
|
||||
// Arrange
|
||||
var chunk = new EvidenceChunk
|
||||
{
|
||||
ChunkId = Guid.NewGuid(),
|
||||
ChunkId = CreateGuid(7),
|
||||
ProofRoot = "sha256:test",
|
||||
ChunkIndex = 0,
|
||||
ChunkHash = "sha256:wrong_hash",
|
||||
Blob = new byte[32],
|
||||
BlobSize = 32,
|
||||
ContentType = "application/octet-stream",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
CreatedAt = FixedNow
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
@@ -207,7 +202,7 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void ComputeMerkleRoot_ShouldReturnSameResult_ForSameInput()
|
||||
{
|
||||
// Arrange
|
||||
@@ -223,7 +218,7 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void ComputeMerkleRoot_ShouldHandleSingleHash()
|
||||
{
|
||||
// Arrange
|
||||
@@ -237,7 +232,7 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void ComputeMerkleRoot_ShouldHandleOddNumberOfHashes()
|
||||
{
|
||||
// Arrange
|
||||
@@ -252,12 +247,11 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ChunkStreamAsync_ShouldYieldChunksInOrder()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = new byte[200];
|
||||
Random.Shared.NextBytes(evidence);
|
||||
var evidence = CreateDeterministicBytes(200, 8);
|
||||
using var stream = new MemoryStream(evidence);
|
||||
const string contentType = "application/octet-stream";
|
||||
|
||||
@@ -277,15 +271,14 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task Roundtrip_ShouldPreserveDataIntegrity()
|
||||
{
|
||||
// Arrange - use realistic chunk size
|
||||
var options = new ProvcacheOptions { ChunkSize = 1024 };
|
||||
var chunker = new EvidenceChunker(options);
|
||||
|
||||
var original = new byte[5000]; // ~5 chunks
|
||||
Random.Shared.NextBytes(original);
|
||||
var original = CreateDeterministicBytes(5000, 9); // ~5 chunks
|
||||
const string contentType = "application/octet-stream";
|
||||
|
||||
// Act
|
||||
@@ -302,4 +295,17 @@ public sealed class EvidenceChunkerTests
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(data);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static byte[] CreateDeterministicBytes(int length, int seed)
|
||||
{
|
||||
var random = new DeterministicRandom(seed);
|
||||
var data = new byte[length];
|
||||
random.NextBytes(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
private static Guid CreateGuid(int seed)
|
||||
{
|
||||
return new DeterministicRandom(seed).NextGuid();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
using StellaOps.TestKit.Deterministic;
|
||||
|
||||
namespace StellaOps.Provcache.Tests;
|
||||
|
||||
public sealed class LazyFetchTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private readonly Mock<IEvidenceChunkRepository> _repositoryMock;
|
||||
private readonly LazyFetchOrchestrator _orchestrator;
|
||||
|
||||
@@ -259,11 +261,11 @@ public sealed class LazyFetchTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void FileChunkFetcher_FetcherType_ReturnsFile()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
var tempDir = CreateTempDir(1);
|
||||
var fetcher = new FileChunkFetcher(tempDir, NullLogger<FileChunkFetcher>.Instance);
|
||||
|
||||
// Act & Assert
|
||||
@@ -271,12 +273,12 @@ public sealed class LazyFetchTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task FileChunkFetcher_IsAvailableAsync_ReturnsTrueWhenDirectoryExists()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var tempDir = CreateTempDir(2);
|
||||
EnsureCleanDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -290,16 +292,17 @@ public sealed class LazyFetchTests
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, true);
|
||||
TryDeleteDirectory(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task FileChunkFetcher_IsAvailableAsync_ReturnsFalseWhenDirectoryMissing()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
var tempDir = CreateTempDir(3);
|
||||
TryDeleteDirectory(tempDir);
|
||||
var fetcher = new FileChunkFetcher(tempDir, NullLogger<FileChunkFetcher>.Instance);
|
||||
|
||||
// Act
|
||||
@@ -310,12 +313,12 @@ public sealed class LazyFetchTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task FileChunkFetcher_FetchChunkAsync_ReturnsNullWhenChunkNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var tempDir = CreateTempDir(4);
|
||||
EnsureCleanDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -329,7 +332,7 @@ public sealed class LazyFetchTests
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, true);
|
||||
TryDeleteDirectory(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,15 +350,93 @@ public sealed class LazyFetchTests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void HttpChunkFetcher_DisallowedHost_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = new HttpClient { BaseAddress = new Uri("https://blocked.example") };
|
||||
var options = new LazyFetchHttpOptions();
|
||||
options.AllowedHosts.Add("allowed.example");
|
||||
|
||||
// Act
|
||||
var action = () => new HttpChunkFetcher(
|
||||
httpClient,
|
||||
ownsClient: false,
|
||||
NullLogger<HttpChunkFetcher>.Instance,
|
||||
options);
|
||||
|
||||
// Assert
|
||||
action.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*not allowlisted*");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void HttpChunkFetcher_DisallowedScheme_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = new HttpClient { BaseAddress = new Uri("http://example.test") };
|
||||
var options = new LazyFetchHttpOptions();
|
||||
options.AllowedHosts.Add("example.test");
|
||||
options.AllowedSchemes.Add("https");
|
||||
|
||||
// Act
|
||||
var action = () => new HttpChunkFetcher(
|
||||
httpClient,
|
||||
ownsClient: false,
|
||||
NullLogger<HttpChunkFetcher>.Instance,
|
||||
options);
|
||||
|
||||
// Assert
|
||||
action.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*scheme*");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void HttpChunkFetcher_AppliesTimeout()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri("https://example.test"),
|
||||
Timeout = Timeout.InfiniteTimeSpan
|
||||
};
|
||||
var options = new LazyFetchHttpOptions
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(2)
|
||||
};
|
||||
options.AllowedHosts.Add("example.test");
|
||||
|
||||
// Act
|
||||
_ = new HttpChunkFetcher(
|
||||
httpClient,
|
||||
ownsClient: false,
|
||||
NullLogger<HttpChunkFetcher>.Instance,
|
||||
options);
|
||||
|
||||
// Assert
|
||||
httpClient.Timeout.Should().Be(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task HttpChunkFetcher_IsAvailableAsync_ReturnsFalseWhenHostUnreachable()
|
||||
{
|
||||
// Arrange - use a non-routable IP to ensure connection failure
|
||||
var httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri("http://192.0.2.1:9999"),
|
||||
Timeout = TimeSpan.FromMilliseconds(100) // Short timeout for test speed
|
||||
// Arrange - handler throws to avoid network access
|
||||
var options = new LazyFetchHttpOptions { Timeout = TimeSpan.FromMilliseconds(50) };
|
||||
options.AllowedHosts.Add("example.test");
|
||||
|
||||
var httpClient = new HttpClient(new ThrowingHttpMessageHandler())
|
||||
{
|
||||
BaseAddress = new Uri("https://example.test"),
|
||||
Timeout = Timeout.InfiniteTimeSpan
|
||||
};
|
||||
var fetcher = new HttpChunkFetcher(httpClient, ownsClient: false, NullLogger<HttpChunkFetcher>.Instance);
|
||||
|
||||
var fetcher = new HttpChunkFetcher(
|
||||
httpClient,
|
||||
ownsClient: false,
|
||||
NullLogger<HttpChunkFetcher>.Instance,
|
||||
options);
|
||||
|
||||
// Act
|
||||
var result = await fetcher.IsAvailableAsync();
|
||||
@@ -371,7 +452,7 @@ public sealed class LazyFetchTests
|
||||
var chunks = Enumerable.Range(0, chunkCount)
|
||||
.Select(i => new ChunkMetadata
|
||||
{
|
||||
ChunkId = Guid.NewGuid(),
|
||||
ChunkId = CreateGuid(100 + i),
|
||||
Index = i,
|
||||
Hash = ComputeTestHash(i),
|
||||
Size = 100 + i,
|
||||
@@ -385,7 +466,7 @@ public sealed class LazyFetchTests
|
||||
TotalChunks = chunkCount,
|
||||
TotalSize = chunks.Sum(c => c.Size),
|
||||
Chunks = chunks,
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
GeneratedAt = FixedNow
|
||||
};
|
||||
}
|
||||
|
||||
@@ -397,14 +478,14 @@ public sealed class LazyFetchTests
|
||||
var data = CreateTestData(i);
|
||||
return new EvidenceChunk
|
||||
{
|
||||
ChunkId = Guid.NewGuid(),
|
||||
ChunkId = CreateGuid(200 + i),
|
||||
ProofRoot = proofRoot,
|
||||
ChunkIndex = i,
|
||||
ChunkHash = ComputeActualHash(data),
|
||||
Blob = data,
|
||||
BlobSize = data.Length,
|
||||
ContentType = "application/octet-stream",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
CreatedAt = FixedNow.AddMinutes(i)
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
@@ -438,6 +519,41 @@ public sealed class LazyFetchTests
|
||||
{
|
||||
return Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(data)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static Guid CreateGuid(int seed)
|
||||
{
|
||||
return new DeterministicRandom(seed).NextGuid();
|
||||
}
|
||||
|
||||
private static string CreateTempDir(int seed)
|
||||
{
|
||||
var directoryName = CreateGuid(seed).ToString("N");
|
||||
return Path.Combine(Path.GetTempPath(), "stellaops-provcache-tests", directoryName);
|
||||
}
|
||||
|
||||
private static void EnsureCleanDirectory(string path)
|
||||
{
|
||||
TryDeleteDirectory(path);
|
||||
Directory.CreateDirectory(path);
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string path)
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
Directory.Delete(path, true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new HttpRequestException("Simulated failure");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extension method for async enumerable from list
|
||||
|
||||
@@ -8,6 +8,8 @@ using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.TestKit.Deterministic;
|
||||
namespace StellaOps.Provcache.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -18,6 +20,7 @@ public sealed class MinimalProofExporterTests
|
||||
private readonly Mock<IProvcacheService> _mockService;
|
||||
private readonly Mock<IEvidenceChunkRepository> _mockChunkRepo;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly SequentialGuidProvider _guidProvider;
|
||||
private readonly MinimalProofExporter _exporter;
|
||||
|
||||
// Test data
|
||||
@@ -39,13 +42,14 @@ public sealed class MinimalProofExporterTests
|
||||
_mockService = new Mock<IProvcacheService>();
|
||||
_mockChunkRepo = new Mock<IEvidenceChunkRepository>();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
_guidProvider = new SequentialGuidProvider();
|
||||
|
||||
_exporter = new MinimalProofExporter(
|
||||
_mockService.Object,
|
||||
_mockChunkRepo.Object,
|
||||
signer: null,
|
||||
_timeProvider,
|
||||
guidProvider: null,
|
||||
guidProvider: _guidProvider,
|
||||
NullLogger<MinimalProofExporter>.Instance);
|
||||
|
||||
// Create test data
|
||||
@@ -81,18 +85,19 @@ public sealed class MinimalProofExporterTests
|
||||
_testChunks = Enumerable.Range(0, 5)
|
||||
.Select(i =>
|
||||
{
|
||||
var random = new DeterministicRandom(i + 10);
|
||||
var data = new byte[1024];
|
||||
Random.Shared.NextBytes(data);
|
||||
random.NextBytes(data);
|
||||
return new EvidenceChunk
|
||||
{
|
||||
ChunkId = Guid.NewGuid(),
|
||||
ChunkId = random.NextGuid(),
|
||||
ProofRoot = proofRoot,
|
||||
ChunkIndex = i,
|
||||
ChunkHash = $"sha256:{Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(data))}",
|
||||
Blob = data,
|
||||
BlobSize = 1024,
|
||||
ContentType = "application/octet-stream",
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
CreatedAt = _timeProvider.GetUtcNow().AddMinutes(i)
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
@@ -455,6 +460,81 @@ public sealed class MinimalProofExporterTests
|
||||
bundle.Signature.SignatureBytes.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VerifyAsync_SignedBundle_ReturnsValidSignature()
|
||||
{
|
||||
// Arrange
|
||||
SetupMocks();
|
||||
var exporter = CreateExporterWithSigner([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
var options = new MinimalProofExportOptions
|
||||
{
|
||||
Density = ProofDensity.Lite,
|
||||
Sign = true,
|
||||
SigningKeyId = "test-key"
|
||||
};
|
||||
|
||||
// Act
|
||||
var bundle = await exporter.ExportAsync(_testEntry.VeriKey, options);
|
||||
var verification = await exporter.VerifyAsync(bundle);
|
||||
|
||||
// Assert
|
||||
verification.SignatureValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VerifyAsync_SignedBundle_TamperedSignature_Fails()
|
||||
{
|
||||
// Arrange
|
||||
SetupMocks();
|
||||
var exporter = CreateExporterWithSigner([9, 8, 7, 6, 5, 4, 3, 2]);
|
||||
var options = new MinimalProofExportOptions
|
||||
{
|
||||
Density = ProofDensity.Lite,
|
||||
Sign = true,
|
||||
SigningKeyId = "test-key"
|
||||
};
|
||||
|
||||
var bundle = await exporter.ExportAsync(_testEntry.VeriKey, options);
|
||||
var tamperedBundle = bundle with
|
||||
{
|
||||
Signature = bundle.Signature with
|
||||
{
|
||||
SignatureBytes = Convert.ToBase64String(new byte[] { 1, 1, 1, 1 })
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var verification = await exporter.VerifyAsync(tamperedBundle);
|
||||
|
||||
// Assert
|
||||
verification.SignatureValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VerifyAsync_SignedBundleWithoutVerifier_ReturnsInvalidSignature()
|
||||
{
|
||||
// Arrange
|
||||
SetupMocks();
|
||||
var exporter = CreateExporterWithSigner([1, 1, 2, 3, 5, 8, 13, 21]);
|
||||
var options = new MinimalProofExportOptions
|
||||
{
|
||||
Density = ProofDensity.Lite,
|
||||
Sign = true,
|
||||
SigningKeyId = "test-key"
|
||||
};
|
||||
|
||||
var bundle = await exporter.ExportAsync(_testEntry.VeriKey, options);
|
||||
|
||||
// Act
|
||||
var verification = await _exporter.VerifyAsync(bundle);
|
||||
|
||||
// Assert
|
||||
verification.SignatureValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private void SetupMocks()
|
||||
@@ -474,6 +554,23 @@ public sealed class MinimalProofExporterTests
|
||||
_testChunks.Skip(start).Take(count).ToList());
|
||||
}
|
||||
|
||||
private MinimalProofExporter CreateExporterWithSigner(byte[] keyMaterial)
|
||||
{
|
||||
var keyProvider = new InMemoryKeyProvider("test-key", keyMaterial);
|
||||
var hmac = DefaultCryptoHmac.CreateForTests();
|
||||
var signer = new HmacSigner(keyProvider, hmac);
|
||||
|
||||
return new MinimalProofExporter(
|
||||
_mockService.Object,
|
||||
_mockChunkRepo.Object,
|
||||
signer,
|
||||
_timeProvider,
|
||||
guidProvider: new SequentialGuidProvider(),
|
||||
NullLogger<MinimalProofExporter>.Instance,
|
||||
cryptoHmac: hmac,
|
||||
keyProvider: keyProvider);
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
|
||||
@@ -23,6 +23,7 @@ namespace StellaOps.Provcache.Tests;
|
||||
/// </summary>
|
||||
public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private readonly Mock<IProvcacheService> _mockService;
|
||||
private readonly IHost _host;
|
||||
private readonly HttpClient _client;
|
||||
@@ -38,7 +39,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton(_mockService.Object);
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<TimeProvider>(new FixedTimeProvider(FixedNow));
|
||||
services.AddRouting();
|
||||
services.AddLogging(b => b.SetMinimumLevel(LogLevel.Warning));
|
||||
})
|
||||
@@ -66,7 +67,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
|
||||
#region GET /v1/provcache/{veriKey}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetByVeriKey_CacheHit_Returns200WithEntry()
|
||||
{
|
||||
@@ -90,7 +91,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
content.Source.Should().Be("valkey");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetByVeriKey_CacheMiss_Returns204()
|
||||
{
|
||||
@@ -108,7 +109,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetByVeriKey_Expired_Returns410Gone()
|
||||
{
|
||||
@@ -127,8 +128,8 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Gone);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetByVeriKey_WithBypassCache_PassesFlagToService()
|
||||
{
|
||||
// Arrange
|
||||
@@ -145,11 +146,30 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
_mockService.Verify(s => s.GetAsync(veriKey, true, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetByVeriKey_WhenException_Returns500WithRedactedDetail()
|
||||
{
|
||||
// Arrange
|
||||
const string veriKey = "sha256:error123error123error123error123error123error123error123error123";
|
||||
_mockService.Setup(s => s.GetAsync(veriKey, false, It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("boom"));
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/v1/provcache/{Uri.EscapeDataString(veriKey)}");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
payload.Should().Contain("An unexpected error occurred");
|
||||
payload.Should().NotContain("boom");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region POST /v1/provcache
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task CreateOrUpdate_ValidRequest_Returns201Created()
|
||||
{
|
||||
@@ -176,7 +196,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
content.Success.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task CreateOrUpdate_NullEntry_Returns400BadRequest()
|
||||
{
|
||||
@@ -194,7 +214,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
|
||||
#region POST /v1/provcache/invalidate
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Invalidate_SingleVeriKey_Returns200WithAffectedCount()
|
||||
{
|
||||
@@ -222,7 +242,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
content.Type.Should().Be("verikey");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Invalidate_ByPolicyHash_Returns200WithBulkResult()
|
||||
{
|
||||
@@ -233,7 +253,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
{
|
||||
EntriesAffected = 5,
|
||||
Request = invalidationRequest,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = FixedNow
|
||||
};
|
||||
|
||||
_mockService.Setup(s => s.InvalidateByAsync(
|
||||
@@ -258,7 +278,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
content!.EntriesAffected.Should().Be(5);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Invalidate_ByPattern_Returns200WithPatternResult()
|
||||
{
|
||||
@@ -269,7 +289,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
{
|
||||
EntriesAffected = 10,
|
||||
Request = invalidationRequest,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = FixedNow
|
||||
};
|
||||
|
||||
_mockService.Setup(s => s.InvalidateByAsync(
|
||||
@@ -298,7 +318,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
|
||||
#region GET /v1/provcache/metrics
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetMetrics_Returns200WithMetrics()
|
||||
{
|
||||
@@ -314,7 +334,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
P99LatencyMs = 10.0,
|
||||
ValkeyCacheHealthy = true,
|
||||
PostgresRepositoryHealthy = true,
|
||||
CollectedAt = DateTimeOffset.UtcNow
|
||||
CollectedAt = FixedNow
|
||||
};
|
||||
|
||||
_mockService.Setup(s => s.GetMetricsAsync(It.IsAny<CancellationToken>()))
|
||||
@@ -334,9 +354,38 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
|
||||
#endregion
|
||||
|
||||
#region GET /v1/provcache/{veriKey}/manifest
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetInputManifest_ReturnsPlaceholderHashes()
|
||||
{
|
||||
// Arrange
|
||||
const string veriKey = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
var entry = CreateTestEntry(veriKey);
|
||||
var result = ProvcacheServiceResult.Hit(entry, "valkey", 1.0);
|
||||
|
||||
_mockService.Setup(s => s.GetAsync(veriKey, false, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(result);
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/v1/provcache/{Uri.EscapeDataString(veriKey)}/manifest");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var manifest = await response.Content.ReadFromJsonAsync<InputManifestResponse>();
|
||||
manifest.Should().NotBeNull();
|
||||
var expectedHash = "sha256:" + new string('a', 32) + "...";
|
||||
manifest!.Sbom.Hash.Should().Be(expectedHash);
|
||||
manifest.Vex.HashSetHash.Should().Be(expectedHash);
|
||||
manifest.GeneratedAt.Should().Be(FixedNow);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Contract Verification Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetByVeriKey_ResponseContract_HasRequiredFields()
|
||||
{
|
||||
@@ -362,7 +411,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
root.TryGetProperty("entry", out _).Should().BeTrue("Response must have 'entry' field");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task CreateOrUpdate_ResponseContract_HasRequiredFields()
|
||||
{
|
||||
@@ -387,7 +436,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
root.TryGetProperty("expiresAt", out _).Should().BeTrue("Response must have 'expiresAt' field");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task InvalidateResponse_Contract_HasRequiredFields()
|
||||
{
|
||||
@@ -415,7 +464,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
root.TryGetProperty("value", out _).Should().BeTrue("Response must have 'value' field");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task MetricsResponse_Contract_HasRequiredFields()
|
||||
{
|
||||
@@ -431,7 +480,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
P99LatencyMs = 5.0,
|
||||
ValkeyCacheHealthy = true,
|
||||
PostgresRepositoryHealthy = true,
|
||||
CollectedAt = DateTimeOffset.UtcNow
|
||||
CollectedAt = FixedNow
|
||||
};
|
||||
|
||||
_mockService.Setup(s => s.GetMetricsAsync(It.IsAny<CancellationToken>()))
|
||||
@@ -457,7 +506,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
|
||||
private static ProvcacheEntry CreateTestEntry(string veriKey, bool expired = false)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
return new ProvcacheEntry
|
||||
{
|
||||
VeriKey = veriKey,
|
||||
@@ -484,8 +533,8 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
FeedIds = ["cve-nvd", "ghsa-2024"],
|
||||
RuleIds = ["base-policy"]
|
||||
},
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
|
||||
CreatedAt = FixedNow,
|
||||
ExpiresAt = FixedNow.AddHours(1),
|
||||
TrustScore = 85
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@ using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Provcache.Entities;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
using StellaOps.TestKit.Deterministic;
|
||||
namespace StellaOps.Provcache.Tests;
|
||||
|
||||
public sealed class RevocationLedgerTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private readonly InMemoryRevocationLedger _ledger;
|
||||
|
||||
public RevocationLedgerTests()
|
||||
@@ -16,7 +17,7 @@ public sealed class RevocationLedgerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task RecordAsync_AssignsSeqNo()
|
||||
{
|
||||
// Arrange
|
||||
@@ -32,7 +33,7 @@ public sealed class RevocationLedgerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task RecordAsync_AssignsIncrementingSeqNos()
|
||||
{
|
||||
// Arrange
|
||||
@@ -52,7 +53,7 @@ public sealed class RevocationLedgerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task GetEntriesSinceAsync_ReturnsEntriesAfterSeqNo()
|
||||
{
|
||||
// Arrange
|
||||
@@ -71,7 +72,7 @@ public sealed class RevocationLedgerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task GetEntriesSinceAsync_RespectsLimit()
|
||||
{
|
||||
// Arrange
|
||||
@@ -88,7 +89,7 @@ public sealed class RevocationLedgerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task GetEntriesByTypeAsync_FiltersCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
@@ -106,17 +107,17 @@ public sealed class RevocationLedgerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task GetEntriesByTypeAsync_FiltersBySinceTime()
|
||||
{
|
||||
// Arrange
|
||||
var oldEntry = CreateTestEntry(RevocationTypes.Signer, "s1") with
|
||||
{
|
||||
RevokedAt = DateTimeOffset.UtcNow.AddDays(-5)
|
||||
RevokedAt = FixedNow.AddDays(-5)
|
||||
};
|
||||
var newEntry = CreateTestEntry(RevocationTypes.Signer, "s2") with
|
||||
{
|
||||
RevokedAt = DateTimeOffset.UtcNow.AddDays(-1)
|
||||
RevokedAt = FixedNow.AddDays(-1)
|
||||
};
|
||||
|
||||
await _ledger.RecordAsync(oldEntry);
|
||||
@@ -125,7 +126,7 @@ public sealed class RevocationLedgerTests
|
||||
// Act
|
||||
var entries = await _ledger.GetEntriesByTypeAsync(
|
||||
RevocationTypes.Signer,
|
||||
since: DateTimeOffset.UtcNow.AddDays(-2));
|
||||
since: FixedNow.AddDays(-2));
|
||||
|
||||
// Assert
|
||||
entries.Should().HaveCount(1);
|
||||
@@ -133,7 +134,7 @@ public sealed class RevocationLedgerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task GetLatestSeqNoAsync_ReturnsZeroWhenEmpty()
|
||||
{
|
||||
// Act
|
||||
@@ -144,7 +145,7 @@ public sealed class RevocationLedgerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task GetLatestSeqNoAsync_ReturnsLatest()
|
||||
{
|
||||
// Arrange
|
||||
@@ -160,7 +161,7 @@ public sealed class RevocationLedgerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task GetRevocationsForKeyAsync_ReturnsMatchingEntries()
|
||||
{
|
||||
// Arrange
|
||||
@@ -177,7 +178,7 @@ public sealed class RevocationLedgerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task GetStatsAsync_ReturnsCorrectStats()
|
||||
{
|
||||
// Arrange
|
||||
@@ -200,7 +201,7 @@ public sealed class RevocationLedgerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task Clear_RemovesAllEntries()
|
||||
{
|
||||
// Arrange
|
||||
@@ -222,19 +223,45 @@ public sealed class RevocationLedgerTests
|
||||
{
|
||||
return new RevocationEntry
|
||||
{
|
||||
RevocationId = Guid.NewGuid(),
|
||||
RevocationId = CreateDeterministicGuid(revocationType, revokedKey),
|
||||
RevocationType = revocationType,
|
||||
RevokedKey = revokedKey,
|
||||
Reason = "Test revocation",
|
||||
EntriesInvalidated = invalidated,
|
||||
Source = "unit-test",
|
||||
RevokedAt = DateTimeOffset.UtcNow
|
||||
RevokedAt = FixedNow
|
||||
};
|
||||
}
|
||||
|
||||
private static Guid CreateDeterministicGuid(string revocationType, string revokedKey)
|
||||
{
|
||||
var seed = ComputeSeed(revocationType, revokedKey);
|
||||
return new DeterministicRandom(seed).NextGuid();
|
||||
}
|
||||
|
||||
private static int ComputeSeed(string revocationType, string revokedKey)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
var seed = 17;
|
||||
foreach (var ch in revocationType)
|
||||
{
|
||||
seed = (seed * 31) + ch;
|
||||
}
|
||||
|
||||
foreach (var ch in revokedKey)
|
||||
{
|
||||
seed = (seed * 31) + ch;
|
||||
}
|
||||
|
||||
return seed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RevocationReplayServiceTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private readonly InMemoryRevocationLedger _ledger;
|
||||
private readonly Mock<IProvcacheRepository> _repositoryMock;
|
||||
private readonly RevocationReplayService _replayService;
|
||||
@@ -250,7 +277,7 @@ public sealed class RevocationReplayServiceTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ReplayFromAsync_ReplaysAllEntries()
|
||||
{
|
||||
// Arrange
|
||||
@@ -276,7 +303,7 @@ public sealed class RevocationReplayServiceTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ReplayFromAsync_StartsFromCheckpoint()
|
||||
{
|
||||
// Arrange
|
||||
@@ -297,7 +324,7 @@ public sealed class RevocationReplayServiceTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ReplayFromAsync_RespectsMaxEntries()
|
||||
{
|
||||
// Arrange
|
||||
@@ -319,7 +346,7 @@ public sealed class RevocationReplayServiceTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ReplayFromAsync_ReturnsEmptyWhenNoEntries()
|
||||
{
|
||||
// Act
|
||||
@@ -331,7 +358,7 @@ public sealed class RevocationReplayServiceTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task GetCheckpointAsync_ReturnsZeroInitially()
|
||||
{
|
||||
// Act
|
||||
@@ -342,7 +369,7 @@ public sealed class RevocationReplayServiceTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task SaveCheckpointAsync_PersistsCheckpoint()
|
||||
{
|
||||
// Act
|
||||
@@ -357,13 +384,38 @@ public sealed class RevocationReplayServiceTests
|
||||
{
|
||||
return new RevocationEntry
|
||||
{
|
||||
RevocationId = Guid.NewGuid(),
|
||||
RevocationId = CreateDeterministicGuid(revocationType, revokedKey),
|
||||
RevocationType = revocationType,
|
||||
RevokedKey = revokedKey,
|
||||
Reason = "Test revocation",
|
||||
EntriesInvalidated = 0,
|
||||
Source = "unit-test",
|
||||
RevokedAt = DateTimeOffset.UtcNow
|
||||
RevokedAt = FixedNow
|
||||
};
|
||||
}
|
||||
|
||||
private static Guid CreateDeterministicGuid(string revocationType, string revokedKey)
|
||||
{
|
||||
var seed = ComputeSeed(revocationType, revokedKey);
|
||||
return new DeterministicRandom(seed).NextGuid();
|
||||
}
|
||||
|
||||
private static int ComputeSeed(string revocationType, string revokedKey)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
var seed = 17;
|
||||
foreach (var ch in revocationType)
|
||||
{
|
||||
seed = (seed * 31) + ch;
|
||||
}
|
||||
|
||||
foreach (var ch in revokedKey)
|
||||
{
|
||||
seed = (seed * 31) + ch;
|
||||
}
|
||||
|
||||
return seed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>StellaOps.Provcache.Tests</RootNamespace>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -25,4 +26,4 @@
|
||||
<ProjectReference Include="../../StellaOps.Provcache.Api/StellaOps.Provcache.Api.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using FluentAssertions;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
@@ -14,6 +14,7 @@ namespace StellaOps.Provcache.Tests;
|
||||
/// </summary>
|
||||
public class WriteBehindQueueTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private readonly ProvcacheOptions _options = new()
|
||||
{
|
||||
EnableWriteBehind = true,
|
||||
@@ -38,18 +39,18 @@ public class WriteBehindQueueTests
|
||||
FeedIds = ["feed1"],
|
||||
RuleIds = ["rule1"]
|
||||
},
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24)
|
||||
CreatedAt = FixedNow,
|
||||
ExpiresAt = FixedNow.AddHours(24)
|
||||
},
|
||||
PolicyHash = "sha256:policy",
|
||||
SignerSetHash = "sha256:signers",
|
||||
FeedEpoch = "2024-12-24T12:00:00Z",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
|
||||
CreatedAt = FixedNow,
|
||||
ExpiresAt = FixedNow.AddHours(24),
|
||||
HitCount = 0
|
||||
};
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task EnqueueAsync_SingleEntry_UpdatesMetrics()
|
||||
{
|
||||
@@ -58,7 +59,8 @@ public class WriteBehindQueueTests
|
||||
var queue = new WriteBehindQueue(
|
||||
repository.Object,
|
||||
Options.Create(_options),
|
||||
NullLogger<WriteBehindQueue>.Instance);
|
||||
NullLogger<WriteBehindQueue>.Instance,
|
||||
new FixedTimeProvider(FixedNow));
|
||||
|
||||
var entry = CreateTestEntry("1");
|
||||
|
||||
@@ -71,7 +73,7 @@ public class WriteBehindQueueTests
|
||||
metrics.CurrentQueueDepth.Should().Be(1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task EnqueueAsync_MultipleEntries_TracksQueueDepth()
|
||||
{
|
||||
@@ -80,7 +82,8 @@ public class WriteBehindQueueTests
|
||||
var queue = new WriteBehindQueue(
|
||||
repository.Object,
|
||||
Options.Create(_options),
|
||||
NullLogger<WriteBehindQueue>.Instance);
|
||||
NullLogger<WriteBehindQueue>.Instance,
|
||||
new FixedTimeProvider(FixedNow));
|
||||
|
||||
// Act
|
||||
for (int i = 0; i < 10; i++)
|
||||
@@ -94,7 +97,7 @@ public class WriteBehindQueueTests
|
||||
metrics.CurrentQueueDepth.Should().Be(10);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public void GetMetrics_InitialState_AllZeros()
|
||||
{
|
||||
@@ -103,7 +106,8 @@ public class WriteBehindQueueTests
|
||||
var queue = new WriteBehindQueue(
|
||||
repository.Object,
|
||||
Options.Create(_options),
|
||||
NullLogger<WriteBehindQueue>.Instance);
|
||||
NullLogger<WriteBehindQueue>.Instance,
|
||||
new FixedTimeProvider(FixedNow));
|
||||
|
||||
// Act
|
||||
var metrics = queue.GetMetrics();
|
||||
@@ -117,7 +121,7 @@ public class WriteBehindQueueTests
|
||||
metrics.CurrentQueueDepth.Should().Be(0);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task ProcessBatch_SuccessfulPersist_UpdatesPersistMetrics()
|
||||
{
|
||||
@@ -129,7 +133,8 @@ public class WriteBehindQueueTests
|
||||
var queue = new WriteBehindQueue(
|
||||
repository.Object,
|
||||
Options.Create(_options),
|
||||
NullLogger<WriteBehindQueue>.Instance);
|
||||
NullLogger<WriteBehindQueue>.Instance,
|
||||
new FixedTimeProvider(FixedNow));
|
||||
|
||||
// Enqueue entries
|
||||
for (int i = 0; i < 5; i++)
|
||||
@@ -145,7 +150,8 @@ public class WriteBehindQueueTests
|
||||
await Task.Delay(500);
|
||||
|
||||
// Stop
|
||||
await queue.StopAsync(CancellationToken.None);
|
||||
using var stopCts = new CancellationTokenSource();
|
||||
await queue.StopAsync(stopCts.Token);
|
||||
|
||||
// Assert
|
||||
var metrics = queue.GetMetrics();
|
||||
@@ -153,7 +159,7 @@ public class WriteBehindQueueTests
|
||||
metrics.TotalBatches.Should().BeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public void WriteBehindMetrics_Timestamp_IsRecent()
|
||||
{
|
||||
@@ -162,14 +168,15 @@ public class WriteBehindQueueTests
|
||||
var queue = new WriteBehindQueue(
|
||||
repository.Object,
|
||||
Options.Create(_options),
|
||||
NullLogger<WriteBehindQueue>.Instance);
|
||||
NullLogger<WriteBehindQueue>.Instance,
|
||||
new FixedTimeProvider(FixedNow));
|
||||
|
||||
// Act
|
||||
var metrics = queue.GetMetrics();
|
||||
|
||||
// Assert
|
||||
metrics.Timestamp.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
metrics.Timestamp.Should().Be(FixedNow);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -177,6 +184,7 @@ public class WriteBehindQueueTests
|
||||
/// </summary>
|
||||
public class ProvcacheServiceStorageIntegrationTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private readonly ProvcacheOptions _options = new()
|
||||
{
|
||||
DefaultTtl = TimeSpan.FromHours(24),
|
||||
@@ -201,18 +209,18 @@ public class ProvcacheServiceStorageIntegrationTests
|
||||
FeedIds = ["nvd:2024"],
|
||||
RuleIds = ["rule:cve-critical"]
|
||||
},
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24)
|
||||
CreatedAt = FixedNow,
|
||||
ExpiresAt = FixedNow.AddHours(24)
|
||||
},
|
||||
PolicyHash = "sha256:policy789",
|
||||
SignerSetHash = "sha256:signers000",
|
||||
FeedEpoch = "2024-12-24T00:00:00Z",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
|
||||
CreatedAt = FixedNow,
|
||||
ExpiresAt = FixedNow.AddHours(24),
|
||||
HitCount = 0
|
||||
};
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task SetAsync_ThenGetAsync_ReturnsEntry()
|
||||
{
|
||||
@@ -234,7 +242,8 @@ public class ProvcacheServiceStorageIntegrationTests
|
||||
store.Object,
|
||||
repository.Object,
|
||||
Options.Create(_options),
|
||||
NullLogger<ProvcacheService>.Instance);
|
||||
NullLogger<ProvcacheService>.Instance,
|
||||
timeProvider: new FixedTimeProvider(FixedNow));
|
||||
|
||||
// Act
|
||||
await service.SetAsync(entry);
|
||||
@@ -247,7 +256,7 @@ public class ProvcacheServiceStorageIntegrationTests
|
||||
result.Source.Should().Be("valkey");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetAsync_CacheMissWithDbHit_BackfillsCache()
|
||||
{
|
||||
@@ -269,7 +278,8 @@ public class ProvcacheServiceStorageIntegrationTests
|
||||
store.Object,
|
||||
repository.Object,
|
||||
Options.Create(_options),
|
||||
NullLogger<ProvcacheService>.Instance);
|
||||
NullLogger<ProvcacheService>.Instance,
|
||||
timeProvider: new FixedTimeProvider(FixedNow));
|
||||
|
||||
// Act
|
||||
var result = await service.GetAsync(veriKey);
|
||||
@@ -283,7 +293,7 @@ public class ProvcacheServiceStorageIntegrationTests
|
||||
store.Verify(s => s.SetAsync(It.Is<ProvcacheEntry>(e => e.VeriKey == veriKey), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetAsync_FullMiss_ReturnsMissResult()
|
||||
{
|
||||
@@ -302,7 +312,8 @@ public class ProvcacheServiceStorageIntegrationTests
|
||||
store.Object,
|
||||
repository.Object,
|
||||
Options.Create(_options),
|
||||
NullLogger<ProvcacheService>.Instance);
|
||||
NullLogger<ProvcacheService>.Instance,
|
||||
timeProvider: new FixedTimeProvider(FixedNow));
|
||||
|
||||
// Act
|
||||
var result = await service.GetAsync(veriKey);
|
||||
@@ -313,7 +324,7 @@ public class ProvcacheServiceStorageIntegrationTests
|
||||
result.Entry.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetOrComputeAsync_CacheHit_DoesNotCallFactory()
|
||||
{
|
||||
@@ -332,7 +343,8 @@ public class ProvcacheServiceStorageIntegrationTests
|
||||
store.Object,
|
||||
repository.Object,
|
||||
Options.Create(_options),
|
||||
NullLogger<ProvcacheService>.Instance);
|
||||
NullLogger<ProvcacheService>.Instance,
|
||||
timeProvider: new FixedTimeProvider(FixedNow));
|
||||
|
||||
// Act
|
||||
var result = await service.GetOrComputeAsync(veriKey, async _ =>
|
||||
@@ -346,7 +358,7 @@ public class ProvcacheServiceStorageIntegrationTests
|
||||
result.VeriKey.Should().Be(veriKey);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetOrComputeAsync_CacheMiss_CallsFactoryAndStores()
|
||||
{
|
||||
@@ -371,7 +383,8 @@ public class ProvcacheServiceStorageIntegrationTests
|
||||
store.Object,
|
||||
repository.Object,
|
||||
Options.Create(_options),
|
||||
NullLogger<ProvcacheService>.Instance);
|
||||
NullLogger<ProvcacheService>.Instance,
|
||||
timeProvider: new FixedTimeProvider(FixedNow));
|
||||
|
||||
// Act
|
||||
var result = await service.GetOrComputeAsync(veriKey, async _ =>
|
||||
@@ -386,7 +399,7 @@ public class ProvcacheServiceStorageIntegrationTests
|
||||
store.Verify(s => s.SetAsync(It.Is<ProvcacheEntry>(e => e.VeriKey == veriKey), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task InvalidateAsync_RemovesFromBothStoreLayers()
|
||||
{
|
||||
@@ -405,7 +418,8 @@ public class ProvcacheServiceStorageIntegrationTests
|
||||
store.Object,
|
||||
repository.Object,
|
||||
Options.Create(_options),
|
||||
NullLogger<ProvcacheService>.Instance);
|
||||
NullLogger<ProvcacheService>.Instance,
|
||||
timeProvider: new FixedTimeProvider(FixedNow));
|
||||
|
||||
// Act
|
||||
var result = await service.InvalidateAsync(veriKey, "test invalidation");
|
||||
@@ -416,7 +430,7 @@ public class ProvcacheServiceStorageIntegrationTests
|
||||
repository.Verify(r => r.DeleteAsync(veriKey, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetAsync_BypassCache_ReturnsbypassedResult()
|
||||
{
|
||||
@@ -430,7 +444,8 @@ public class ProvcacheServiceStorageIntegrationTests
|
||||
store.Object,
|
||||
repository.Object,
|
||||
Options.Create(_options),
|
||||
NullLogger<ProvcacheService>.Instance);
|
||||
NullLogger<ProvcacheService>.Instance,
|
||||
timeProvider: new FixedTimeProvider(FixedNow));
|
||||
|
||||
// Act
|
||||
var result = await service.GetAsync(veriKey, bypassCache: true);
|
||||
@@ -440,7 +455,7 @@ public class ProvcacheServiceStorageIntegrationTests
|
||||
store.Verify(s => s.GetAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetMetricsAsync_ReturnsCurrentMetrics()
|
||||
{
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0032-M | DONE | Revalidated 2026-01-08; open findings tracked in audit report. |
|
||||
| AUDIT-0032-T | DONE | Revalidated 2026-01-08; open findings tracked in audit report. |
|
||||
| AUDIT-0032-A | DONE | Waived (test project; revalidated 2026-01-08). |
|
||||
| AUDIT-0032-A | DONE | Applied 2026-01-13 (deterministic fixtures, Integration tagging, warnings-as-errors). |
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.Provcache.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Fixed TimeProvider for deterministic tests.
|
||||
/// </summary>
|
||||
public sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _utcNow;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset utcNow)
|
||||
{
|
||||
_utcNow = utcNow;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
}
|
||||
@@ -79,14 +79,14 @@ public sealed class CveSymbolMappingIntegrationTests
|
||||
|
||||
var osvMapping = CveSymbolMapping.Create(
|
||||
"CVE-2024-MERGE",
|
||||
new[] { VulnerableSymbol.Create(symbol2, VulnerabilityType.Source, 0.8) },
|
||||
new[] { VulnerableSymbol.Create(symbol2, VulnerabilityType.TaintSource, 0.8) },
|
||||
MappingSource.OsvDatabase,
|
||||
0.8,
|
||||
_timeProvider);
|
||||
|
||||
var manualMapping = CveSymbolMapping.Create(
|
||||
"CVE-2024-MERGE",
|
||||
new[] { VulnerableSymbol.Create(symbol3, VulnerabilityType.Gadget, 0.95) },
|
||||
new[] { VulnerableSymbol.Create(symbol3, VulnerabilityType.GadgetEntry, 0.95) },
|
||||
MappingSource.ManualCuration,
|
||||
0.95,
|
||||
_timeProvider);
|
||||
@@ -224,8 +224,8 @@ public sealed class CveSymbolMappingIntegrationTests
|
||||
var symbols = new[]
|
||||
{
|
||||
VulnerableSymbol.Create(lookupSymbol, VulnerabilityType.Sink, 0.99),
|
||||
VulnerableSymbol.Create(messageSymbol, VulnerabilityType.Source, 0.95),
|
||||
VulnerableSymbol.Create(interpolatorSymbol, VulnerabilityType.Gadget, 0.97)
|
||||
VulnerableSymbol.Create(messageSymbol, VulnerabilityType.TaintSource, 0.95),
|
||||
VulnerableSymbol.Create(interpolatorSymbol, VulnerabilityType.GadgetEntry, 0.97)
|
||||
};
|
||||
|
||||
var mapping = CveSymbolMapping.Create(
|
||||
@@ -243,8 +243,8 @@ public sealed class CveSymbolMappingIntegrationTests
|
||||
result.Should().NotBeNull();
|
||||
result!.Symbols.Should().HaveCount(3);
|
||||
result.Symbols.Should().Contain(s => s.Type == VulnerabilityType.Sink);
|
||||
result.Symbols.Should().Contain(s => s.Type == VulnerabilityType.Source);
|
||||
result.Symbols.Should().Contain(s => s.Type == VulnerabilityType.Gadget);
|
||||
result.Symbols.Should().Contain(s => s.Type == VulnerabilityType.TaintSource);
|
||||
result.Symbols.Should().Contain(s => s.Type == VulnerabilityType.GadgetEntry);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Spring4Shell CVE-2022-22965: Class loader manipulation")]
|
||||
@@ -257,7 +257,7 @@ public sealed class CveSymbolMappingIntegrationTests
|
||||
var symbols = new[]
|
||||
{
|
||||
VulnerableSymbol.Create(beanWrapperSymbol, VulnerabilityType.Sink, 0.98),
|
||||
VulnerableSymbol.Create(classLoaderSymbol, VulnerabilityType.Gadget, 0.90)
|
||||
VulnerableSymbol.Create(classLoaderSymbol, VulnerabilityType.GadgetEntry, 0.90)
|
||||
};
|
||||
|
||||
var mapping = CveSymbolMapping.Create(
|
||||
@@ -368,3 +368,4 @@ public sealed class CveSymbolMappingIntegrationTests
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -120,3 +120,5 @@ public class VulnerableSymbolTests
|
||||
vulnSymbol.Type.Should().Be(type);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
using FluentAssertions;
|
||||
using FsCheck;
|
||||
using FsCheck.Fluent;
|
||||
using FsCheck.Xunit;
|
||||
using StellaOps.Reachability.Core;
|
||||
|
||||
@@ -319,13 +320,12 @@ internal static class LatticeArbs
|
||||
];
|
||||
|
||||
public static Arbitrary<EvidenceType> AnyEvidenceType() =>
|
||||
Arb.From(Gen.Elements(AllEvidenceTypes));
|
||||
Gen.Elements(AllEvidenceTypes).ToArbitrary();
|
||||
|
||||
public static Arbitrary<List<EvidenceType>> EvidenceSequence(int minLength, int maxLength) =>
|
||||
Arb.From(
|
||||
from length in Gen.Choose(minLength, maxLength)
|
||||
from sequence in Gen.ListOf(length, Gen.Elements(AllEvidenceTypes))
|
||||
select sequence.ToList());
|
||||
(from length in Gen.Choose(minLength, maxLength)
|
||||
from sequence in Gen.ListOf(length, Gen.Elements(AllEvidenceTypes))
|
||||
select sequence.ToList()).ToArbitrary();
|
||||
|
||||
public static Arbitrary<(LatticeState, EvidenceType)> ReinforcingEvidencePair()
|
||||
{
|
||||
@@ -336,7 +336,7 @@ internal static class LatticeArbs
|
||||
(LatticeState.ConfirmedUnreachable, EvidenceType.StaticUnreachable),
|
||||
(LatticeState.ConfirmedUnreachable, EvidenceType.RuntimeUnobserved),
|
||||
};
|
||||
return Arb.From(Gen.Elements(pairs));
|
||||
return Gen.Elements(pairs).ToArbitrary();
|
||||
}
|
||||
|
||||
public static Arbitrary<StaticReachabilityResult?> AnyStaticResult()
|
||||
@@ -368,11 +368,11 @@ internal static class LatticeArbs
|
||||
AnalyzedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
return Arb.From(Gen.OneOf(
|
||||
return Gen.OneOf(
|
||||
Gen.Constant<StaticReachabilityResult?>(null),
|
||||
Gen.Constant<StaticReachabilityResult?>(reachableResult),
|
||||
Gen.Constant<StaticReachabilityResult?>(unreachableResult)
|
||||
));
|
||||
).ToArbitrary();
|
||||
}
|
||||
|
||||
public static Arbitrary<RuntimeReachabilityResult?> AnyRuntimeResult()
|
||||
@@ -407,10 +407,10 @@ internal static class LatticeArbs
|
||||
HitCount = 0
|
||||
};
|
||||
|
||||
return Arb.From(Gen.OneOf(
|
||||
return Gen.OneOf(
|
||||
Gen.Constant<RuntimeReachabilityResult?>(null),
|
||||
Gen.Constant<RuntimeReachabilityResult?>(observedResult),
|
||||
Gen.Constant<RuntimeReachabilityResult?>(unobservedResult)
|
||||
));
|
||||
).ToArbitrary();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-FileCopyrightText: 2026 StellaOps Contributors
|
||||
|
||||
using FluentAssertions;
|
||||
using FsCheck;
|
||||
using FsCheck.Fluent;
|
||||
using FsCheck.Xunit;
|
||||
using StellaOps.Reachability.Core;
|
||||
|
||||
namespace StellaOps.Reachability.Core.Tests.Properties;
|
||||
|
||||
/// <summary>
|
||||
/// Property-based tests for the 8-state reachability lattice.
|
||||
/// Verifies lattice monotonicity, confidence bounds, and determinism.
|
||||
/// </summary>
|
||||
public sealed class ReachabilityLatticePropertyTests
|
||||
{
|
||||
// Lattice ordering: Unknown (0) < Static (1-2) < Runtime (3-4) < Confirmed (5-6), Contested (7) is special
|
||||
private static readonly Dictionary<LatticeState, int> EvidenceStrength = new()
|
||||
{
|
||||
[LatticeState.Unknown] = 0,
|
||||
[LatticeState.StaticReachable] = 1,
|
||||
[LatticeState.StaticUnreachable] = 2,
|
||||
[LatticeState.RuntimeObserved] = 3,
|
||||
[LatticeState.RuntimeUnobserved] = 3,
|
||||
[LatticeState.ConfirmedReachable] = 4,
|
||||
[LatticeState.ConfirmedUnreachable] = 4,
|
||||
[LatticeState.Contested] = -1, // Special case - conflict state
|
||||
};
|
||||
|
||||
#region Lattice Monotonicity Property
|
||||
|
||||
/// <summary>
|
||||
/// Property: State transitions from Unknown always move forward (increase evidence strength),
|
||||
/// except when transitioning to Contested due to conflicting evidence.
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public Property ApplyEvidence_FromUnknown_AlwaysIncreasesOrConflicts()
|
||||
{
|
||||
return Prop.ForAll(
|
||||
LatticeArbs.AnyEvidenceType(),
|
||||
evidence =>
|
||||
{
|
||||
var lattice = new ReachabilityLattice();
|
||||
var initial = lattice.CurrentState;
|
||||
|
||||
lattice.ApplyEvidence(evidence);
|
||||
var result = lattice.CurrentState;
|
||||
|
||||
var initialStrength = EvidenceStrength[initial];
|
||||
var resultStrength = EvidenceStrength[result];
|
||||
|
||||
// Either strength increased OR we went to Contested
|
||||
return (resultStrength > initialStrength || result == LatticeState.Contested)
|
||||
.Label($"From {initial} with {evidence}: {result} (strength {initialStrength} -> {resultStrength})");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Property: State transitions generally increase evidence strength,
|
||||
/// except when conflicting evidence produces Contested state.
|
||||
/// </summary>
|
||||
[Property(MaxTest = 200)]
|
||||
public Property ApplyEvidence_Sequence_MonotonicExceptContested()
|
||||
{
|
||||
return Prop.ForAll(
|
||||
LatticeArbs.EvidenceSequence(1, 5),
|
||||
evidenceSequence =>
|
||||
{
|
||||
var lattice = new ReachabilityLattice();
|
||||
var previousStrength = EvidenceStrength[LatticeState.Unknown];
|
||||
var monotonic = true;
|
||||
var wentToContested = false;
|
||||
|
||||
foreach (var evidence in evidenceSequence)
|
||||
{
|
||||
lattice.ApplyEvidence(evidence);
|
||||
var currentStrength = EvidenceStrength[lattice.CurrentState];
|
||||
|
||||
if (lattice.CurrentState == LatticeState.Contested)
|
||||
{
|
||||
wentToContested = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (currentStrength < previousStrength)
|
||||
{
|
||||
monotonic = false;
|
||||
break;
|
||||
}
|
||||
|
||||
previousStrength = currentStrength;
|
||||
}
|
||||
|
||||
return (monotonic || wentToContested)
|
||||
.Label($"Monotonic: {monotonic}, Contested: {wentToContested}, Final: {lattice.CurrentState}");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Property: Confirmed states remain stable with reinforcing evidence.
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public Property ConfirmedState_WithReinforcingEvidence_RemainsConfirmed()
|
||||
{
|
||||
return Prop.ForAll(
|
||||
LatticeArbs.ReinforcingEvidencePair(),
|
||||
pair =>
|
||||
{
|
||||
var (confirmedState, reinforcingEvidence) = pair;
|
||||
var lattice = new ReachabilityLattice();
|
||||
|
||||
// Get to confirmed state
|
||||
if (confirmedState == LatticeState.ConfirmedReachable)
|
||||
{
|
||||
lattice.ApplyEvidence(EvidenceType.StaticReachable);
|
||||
lattice.ApplyEvidence(EvidenceType.RuntimeObserved);
|
||||
}
|
||||
else
|
||||
{
|
||||
lattice.ApplyEvidence(EvidenceType.StaticUnreachable);
|
||||
lattice.ApplyEvidence(EvidenceType.RuntimeUnobserved);
|
||||
}
|
||||
|
||||
var beforeState = lattice.CurrentState;
|
||||
lattice.ApplyEvidence(reinforcingEvidence);
|
||||
var afterState = lattice.CurrentState;
|
||||
|
||||
return (beforeState == afterState)
|
||||
.Label($"{beforeState} + {reinforcingEvidence} = {afterState}");
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Confidence Bounds Property
|
||||
|
||||
/// <summary>
|
||||
/// Property: Confidence is always clamped between 0.0 and 1.0.
|
||||
/// </summary>
|
||||
[Property(MaxTest = 200)]
|
||||
public Property Confidence_AlwaysWithinBounds()
|
||||
{
|
||||
return Prop.ForAll(
|
||||
LatticeArbs.EvidenceSequence(1, 10),
|
||||
evidenceSequence =>
|
||||
{
|
||||
var lattice = new ReachabilityLattice();
|
||||
|
||||
foreach (var evidence in evidenceSequence)
|
||||
{
|
||||
lattice.ApplyEvidence(evidence);
|
||||
|
||||
var confidence = lattice.Confidence;
|
||||
if (confidence < 0.0 || confidence > 1.0)
|
||||
{
|
||||
return false.Label($"Confidence {confidence} out of bounds after {evidence}");
|
||||
}
|
||||
}
|
||||
|
||||
return (lattice.Confidence >= 0.0 && lattice.Confidence <= 1.0)
|
||||
.Label($"Final confidence: {lattice.Confidence}");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Property: Confidence increases with positive evidence, with some exceptions.
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public Property Confidence_IncreasesWithPositiveEvidence()
|
||||
{
|
||||
return Prop.ForAll(
|
||||
LatticeArbs.AnyEvidenceType(),
|
||||
evidence =>
|
||||
{
|
||||
var lattice = new ReachabilityLattice();
|
||||
var beforeConfidence = lattice.Confidence;
|
||||
|
||||
var transition = lattice.ApplyEvidence(evidence);
|
||||
var afterConfidence = lattice.Confidence;
|
||||
|
||||
// If transition has positive delta, confidence should increase
|
||||
// If negative delta (conflict), confidence may decrease
|
||||
if (transition is null)
|
||||
return true.Label("No transition");
|
||||
|
||||
if (transition.ConfidenceDelta > 0)
|
||||
{
|
||||
return (afterConfidence >= beforeConfidence)
|
||||
.Label($"Positive delta {transition.ConfidenceDelta}: {beforeConfidence} -> {afterConfidence}");
|
||||
}
|
||||
|
||||
return true.Label($"Non-positive delta {transition.ConfidenceDelta}");
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism Property
|
||||
|
||||
/// <summary>
|
||||
/// Property: Same evidence sequence produces same final state.
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public Property SameInputs_ProduceSameOutput()
|
||||
{
|
||||
return Prop.ForAll(
|
||||
LatticeArbs.EvidenceSequence(1, 5),
|
||||
evidenceSequence =>
|
||||
{
|
||||
var lattice1 = new ReachabilityLattice();
|
||||
var lattice2 = new ReachabilityLattice();
|
||||
|
||||
foreach (var evidence in evidenceSequence)
|
||||
{
|
||||
lattice1.ApplyEvidence(evidence);
|
||||
lattice2.ApplyEvidence(evidence);
|
||||
}
|
||||
|
||||
return (lattice1.CurrentState == lattice2.CurrentState &&
|
||||
Math.Abs(lattice1.Confidence - lattice2.Confidence) < 0.0001)
|
||||
.Label($"L1: {lattice1.CurrentState}/{lattice1.Confidence:F4}, L2: {lattice2.CurrentState}/{lattice2.Confidence:F4}");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Property: Combine method is deterministic - same inputs produce same output.
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public Property Combine_IsDeterministic()
|
||||
{
|
||||
return Prop.ForAll(
|
||||
LatticeArbs.AnyStaticResult(),
|
||||
LatticeArbs.AnyRuntimeResult(),
|
||||
(staticResult, runtimeResult) =>
|
||||
{
|
||||
var result1 = ReachabilityLattice.Combine(staticResult, runtimeResult);
|
||||
var result2 = ReachabilityLattice.Combine(staticResult, runtimeResult);
|
||||
|
||||
return (result1.State == result2.State &&
|
||||
Math.Abs(result1.Confidence - result2.Confidence) < 0.0001)
|
||||
.Label($"R1: {result1.State}/{result1.Confidence:F4}, R2: {result2.State}/{result2.Confidence:F4}");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Property: Evidence order affects final state (non-commutative in some cases).
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public Property EvidenceOrder_MayAffectResult()
|
||||
{
|
||||
// This test documents that evidence order CAN matter, not that it always does
|
||||
return Prop.ForAll(
|
||||
LatticeArbs.AnyEvidenceType(),
|
||||
LatticeArbs.AnyEvidenceType(),
|
||||
(e1, e2) =>
|
||||
{
|
||||
if (e1 == e2) return true.Label("Same evidence, skip");
|
||||
|
||||
var latticeAB = new ReachabilityLattice();
|
||||
latticeAB.ApplyEvidence(e1);
|
||||
latticeAB.ApplyEvidence(e2);
|
||||
|
||||
var latticeBA = new ReachabilityLattice();
|
||||
latticeBA.ApplyEvidence(e2);
|
||||
latticeBA.ApplyEvidence(e1);
|
||||
|
||||
// Document whether order matters - both results should be valid states
|
||||
var bothValid = Enum.IsDefined(latticeAB.CurrentState) &&
|
||||
Enum.IsDefined(latticeBA.CurrentState);
|
||||
|
||||
return bothValid
|
||||
.Label($"{e1}+{e2}={latticeAB.CurrentState}, {e2}+{e1}={latticeBA.CurrentState}");
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reset Property
|
||||
|
||||
/// <summary>
|
||||
/// Property: Reset returns lattice to initial state.
|
||||
/// </summary>
|
||||
[Property(MaxTest = 50)]
|
||||
public Property Reset_ReturnsToInitialState()
|
||||
{
|
||||
return Prop.ForAll(
|
||||
LatticeArbs.EvidenceSequence(1, 5),
|
||||
evidenceSequence =>
|
||||
{
|
||||
var lattice = new ReachabilityLattice();
|
||||
|
||||
foreach (var evidence in evidenceSequence)
|
||||
{
|
||||
lattice.ApplyEvidence(evidence);
|
||||
}
|
||||
|
||||
lattice.Reset();
|
||||
|
||||
return (lattice.CurrentState == LatticeState.Unknown &&
|
||||
lattice.Confidence == 0.0)
|
||||
.Label($"After reset: {lattice.CurrentState}, {lattice.Confidence}");
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Custom FsCheck arbitraries for reachability lattice types.
|
||||
/// </summary>
|
||||
internal static class LatticeArbs
|
||||
{
|
||||
private static readonly EvidenceType[] AllEvidenceTypes =
|
||||
[
|
||||
EvidenceType.StaticReachable,
|
||||
EvidenceType.StaticUnreachable,
|
||||
EvidenceType.RuntimeObserved,
|
||||
EvidenceType.RuntimeUnobserved
|
||||
];
|
||||
|
||||
public static Arbitrary<EvidenceType> AnyEvidenceType() =>
|
||||
Gen.Elements(AllEvidenceTypes).ToArbitrary();
|
||||
|
||||
public static Arbitrary<List<EvidenceType>> EvidenceSequence(int minLength, int maxLength) =>
|
||||
(from length in Gen.Choose(minLength, maxLength)
|
||||
from sequence in Gen.ListOf(length, Gen.Elements(AllEvidenceTypes))
|
||||
select sequence.ToList()).ToArbitrary();
|
||||
|
||||
public static Arbitrary<(LatticeState, EvidenceType)> ReinforcingEvidencePair()
|
||||
{
|
||||
var pairs = new (LatticeState, EvidenceType)[]
|
||||
{
|
||||
(LatticeState.ConfirmedReachable, EvidenceType.StaticReachable),
|
||||
(LatticeState.ConfirmedReachable, EvidenceType.RuntimeObserved),
|
||||
(LatticeState.ConfirmedUnreachable, EvidenceType.StaticUnreachable),
|
||||
(LatticeState.ConfirmedUnreachable, EvidenceType.RuntimeUnobserved),
|
||||
};
|
||||
return Gen.Elements(pairs).ToArbitrary();
|
||||
}
|
||||
|
||||
public static Arbitrary<StaticReachabilityResult?> AnyStaticResult()
|
||||
{
|
||||
var testSymbol = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:npm/test@1.0.0",
|
||||
Namespace = "test",
|
||||
SymbolName = "testFunc"
|
||||
};
|
||||
|
||||
var reachableResult = new StaticReachabilityResult
|
||||
{
|
||||
Symbol = testSymbol,
|
||||
ArtifactDigest = "sha256:test123",
|
||||
IsReachable = true,
|
||||
PathCount = 1,
|
||||
ShortestPathLength = 3,
|
||||
AnalyzedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var unreachableResult = new StaticReachabilityResult
|
||||
{
|
||||
Symbol = testSymbol,
|
||||
ArtifactDigest = "sha256:test123",
|
||||
IsReachable = false,
|
||||
PathCount = 0,
|
||||
ShortestPathLength = null,
|
||||
AnalyzedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
return Gen.OneOf(
|
||||
Gen.Constant<StaticReachabilityResult?>(null),
|
||||
Gen.Constant<StaticReachabilityResult?>(reachableResult),
|
||||
Gen.Constant<StaticReachabilityResult?>(unreachableResult)
|
||||
).ToArbitrary();
|
||||
}
|
||||
|
||||
public static Arbitrary<RuntimeReachabilityResult?> AnyRuntimeResult()
|
||||
{
|
||||
var testSymbol = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:npm/test@1.0.0",
|
||||
Namespace = "test",
|
||||
SymbolName = "testFunc"
|
||||
};
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var observedResult = new RuntimeReachabilityResult
|
||||
{
|
||||
Symbol = testSymbol,
|
||||
ArtifactDigest = "sha256:test123",
|
||||
WasObserved = true,
|
||||
ObservationWindow = TimeSpan.FromDays(7),
|
||||
WindowStart = now.AddDays(-7),
|
||||
WindowEnd = now,
|
||||
HitCount = 10
|
||||
};
|
||||
|
||||
var unobservedResult = new RuntimeReachabilityResult
|
||||
{
|
||||
Symbol = testSymbol,
|
||||
ArtifactDigest = "sha256:test123",
|
||||
WasObserved = false,
|
||||
ObservationWindow = TimeSpan.FromDays(7),
|
||||
WindowStart = now.AddDays(-7),
|
||||
WindowEnd = now,
|
||||
HitCount = 0
|
||||
};
|
||||
|
||||
return Gen.OneOf(
|
||||
Gen.Constant<RuntimeReachabilityResult?>(null),
|
||||
Gen.Constant<RuntimeReachabilityResult?>(observedResult),
|
||||
Gen.Constant<RuntimeReachabilityResult?>(unobservedResult)
|
||||
).ToArbitrary();
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,14 @@
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.Reachability.Core.Tests</RootNamespace>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="FsCheck" />
|
||||
<PackageReference Include="FsCheck.Xunit" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="FsCheck.Xunit.v3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using FluentAssertions;
|
||||
using StellaOps.TestKit;
|
||||
using StellaOps.TestKit.Connectors;
|
||||
using StellaOps.TestKit.Fixtures;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.TestKit.Tests;
|
||||
|
||||
public sealed class TestKitFixtureTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public async Task ConnectorHttpFixture_CreateClient_ReturnsCannedResponse()
|
||||
{
|
||||
using var fixture = new ConnectorHttpFixture();
|
||||
fixture.AddJsonResponse("https://example.test/api", "{\"status\":\"ok\"}");
|
||||
|
||||
using var client = fixture.CreateClient();
|
||||
var response = await client.GetAsync("https://example.test/api");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
body.Should().Be("{\"status\":\"ok\"}");
|
||||
fixture.CapturedRequests.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public void TestRequestContext_UsesTimeProvider()
|
||||
{
|
||||
var fixedTime = new DateTimeOffset(2025, 1, 1, 10, 0, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FixedTimeProvider(fixedTime);
|
||||
var context = new TestRequestContext(timeProvider);
|
||||
|
||||
context.RecordRequest("GET", "/health", 200);
|
||||
|
||||
var record = context.GetRequests().Single();
|
||||
record.Timestamp.Should().Be(fixedTime.UtcDateTime);
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _utcNow;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset utcNow)
|
||||
{
|
||||
_utcNow = utcNow;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user