audit, advisories and doctors/setup work

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.

View File

@@ -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]);
}
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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. |

View File

@@ -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);
}
}
}

View File

@@ -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

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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
};
}

View File

@@ -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;
}
}
}

View File

@@ -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>

View File

@@ -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()
{

View File

@@ -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). |

View File

@@ -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;
}

View File

@@ -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
}

View File

@@ -120,3 +120,5 @@ public class VulnerableSymbolTests
vulnSymbol.Type.Should().Be(type);
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -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;
}
}