save progress

This commit is contained in:
StellaOps Bot
2026-01-06 09:42:02 +02:00
parent 94d68bee8b
commit 37e11918e0
443 changed files with 85863 additions and 897 deletions

View File

@@ -0,0 +1,278 @@
// <copyright file="BlastRadiusTestRunner.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// Sprint: SPRINT_20260105_002_005_TEST_cross_cutting
// Task: CCUT-002
using System.Collections.Immutable;
using System.Diagnostics;
namespace StellaOps.TestKit.BlastRadius;
/// <summary>
/// Runs tests filtered by blast radius for incident response.
/// </summary>
public static class BlastRadiusTestRunner
{
/// <summary>
/// Get xUnit filter for specific blast radii.
/// </summary>
/// <param name="blastRadii">Blast radii to filter by.</param>
/// <returns>xUnit filter string.</returns>
/// <exception cref="ArgumentException">Thrown when no blast radii provided.</exception>
public static string GetFilter(params string[] blastRadii)
{
if (blastRadii.Length == 0)
{
throw new ArgumentException("At least one blast radius required", nameof(blastRadii));
}
var filters = blastRadii.Select(br => $"BlastRadius={br}");
return string.Join("|", filters);
}
/// <summary>
/// Get xUnit filter for specific blast radii (IEnumerable overload).
/// </summary>
/// <param name="blastRadii">Blast radii to filter by.</param>
/// <returns>xUnit filter string.</returns>
public static string GetFilter(IEnumerable<string> blastRadii)
{
return GetFilter(blastRadii.ToArray());
}
/// <summary>
/// Get the dotnet test command for specific blast radii.
/// </summary>
/// <param name="testProject">Test project path or solution.</param>
/// <param name="blastRadii">Blast radii to filter by.</param>
/// <param name="additionalArgs">Additional dotnet test arguments.</param>
/// <returns>Complete dotnet test command.</returns>
public static string GetCommand(
string testProject,
IEnumerable<string> blastRadii,
string? additionalArgs = null)
{
var filter = GetFilter(blastRadii);
var args = $"test {testProject} --filter \"{filter}\"";
if (!string.IsNullOrWhiteSpace(additionalArgs))
{
args += $" {additionalArgs}";
}
return $"dotnet {args}";
}
/// <summary>
/// Run tests for specific operational surfaces.
/// </summary>
/// <param name="testProject">Test project path or solution.</param>
/// <param name="blastRadii">Blast radii to run tests for.</param>
/// <param name="workingDirectory">Working directory for test execution.</param>
/// <param name="timeoutMs">Timeout in milliseconds.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Test run result.</returns>
public static async Task<TestRunResult> RunForBlastRadiiAsync(
string testProject,
string[] blastRadii,
string? workingDirectory = null,
int timeoutMs = 600000,
CancellationToken ct = default)
{
var filter = GetFilter(blastRadii);
var startInfo = new ProcessStartInfo
{
FileName = "dotnet",
Arguments = $"test {testProject} --filter \"{filter}\" --logger trx --verbosity normal",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
if (!string.IsNullOrWhiteSpace(workingDirectory))
{
startInfo.WorkingDirectory = workingDirectory;
}
var stdout = new List<string>();
var stderr = new List<string>();
var sw = Stopwatch.StartNew();
using var process = new Process { StartInfo = startInfo };
process.OutputDataReceived += (_, e) =>
{
if (e.Data != null)
{
stdout.Add(e.Data);
}
};
process.ErrorDataReceived += (_, e) =>
{
if (e.Data != null)
{
stderr.Add(e.Data);
}
};
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(timeoutMs);
try
{
await process.WaitForExitAsync(cts.Token);
}
catch (OperationCanceledException)
{
try
{
process.Kill(entireProcessTree: true);
}
catch
{
// Ignore kill errors
}
return new TestRunResult(
ExitCode: -1,
BlastRadii: [.. blastRadii],
Filter: filter,
DurationMs: sw.ElapsedMilliseconds,
Output: [.. stdout],
Errors: [.. stderr],
TimedOut: true);
}
sw.Stop();
return new TestRunResult(
ExitCode: process.ExitCode,
BlastRadii: [.. blastRadii],
Filter: filter,
DurationMs: sw.ElapsedMilliseconds,
Output: [.. stdout],
Errors: [.. stderr],
TimedOut: false);
}
/// <summary>
/// Run tests for a single blast radius.
/// </summary>
/// <param name="testProject">Test project path or solution.</param>
/// <param name="blastRadius">Blast radius to run tests for.</param>
/// <param name="workingDirectory">Working directory for test execution.</param>
/// <param name="timeoutMs">Timeout in milliseconds.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Test run result.</returns>
public static Task<TestRunResult> RunForBlastRadiusAsync(
string testProject,
string blastRadius,
string? workingDirectory = null,
int timeoutMs = 600000,
CancellationToken ct = default)
{
return RunForBlastRadiiAsync(testProject, [blastRadius], workingDirectory, timeoutMs, ct);
}
/// <summary>
/// Parse test results from TRX output.
/// </summary>
/// <param name="result">Test run result.</param>
/// <returns>Summary of test results.</returns>
public static TestRunSummary ParseSummary(TestRunResult result)
{
var summary = new TestRunSummary(
Passed: 0,
Failed: 0,
Skipped: 0,
Total: 0);
foreach (var line in result.Output)
{
// Parse dotnet test output format: "Passed: X" etc.
if (line.Contains("Passed:", StringComparison.OrdinalIgnoreCase))
{
var match = System.Text.RegularExpressions.Regex.Match(line, @"Passed:\s*(\d+)");
if (match.Success && int.TryParse(match.Groups[1].Value, out var passed))
{
summary = summary with { Passed = passed };
}
}
if (line.Contains("Failed:", StringComparison.OrdinalIgnoreCase))
{
var match = System.Text.RegularExpressions.Regex.Match(line, @"Failed:\s*(\d+)");
if (match.Success && int.TryParse(match.Groups[1].Value, out var failed))
{
summary = summary with { Failed = failed };
}
}
if (line.Contains("Skipped:", StringComparison.OrdinalIgnoreCase))
{
var match = System.Text.RegularExpressions.Regex.Match(line, @"Skipped:\s*(\d+)");
if (match.Success && int.TryParse(match.Groups[1].Value, out var skipped))
{
summary = summary with { Skipped = skipped };
}
}
if (line.Contains("Total:", StringComparison.OrdinalIgnoreCase))
{
var match = System.Text.RegularExpressions.Regex.Match(line, @"Total:\s*(\d+)");
if (match.Success && int.TryParse(match.Groups[1].Value, out var total))
{
summary = summary with { Total = total };
}
}
}
return summary;
}
}
/// <summary>
/// Result of running tests for blast radii.
/// </summary>
/// <param name="ExitCode">Process exit code (0 = success).</param>
/// <param name="BlastRadii">Blast radii that were tested.</param>
/// <param name="Filter">xUnit filter that was used.</param>
/// <param name="DurationMs">Duration of test run in milliseconds.</param>
/// <param name="Output">Standard output lines.</param>
/// <param name="Errors">Standard error lines.</param>
/// <param name="TimedOut">Whether the test run timed out.</param>
public sealed record TestRunResult(
int ExitCode,
ImmutableArray<string> BlastRadii,
string Filter,
long DurationMs,
ImmutableArray<string> Output,
ImmutableArray<string> Errors,
bool TimedOut)
{
/// <summary>
/// Gets a value indicating whether the test run was successful.
/// </summary>
public bool IsSuccess => ExitCode == 0 && !TimedOut;
}
/// <summary>
/// Summary of test run results.
/// </summary>
/// <param name="Passed">Number of passed tests.</param>
/// <param name="Failed">Number of failed tests.</param>
/// <param name="Skipped">Number of skipped tests.</param>
/// <param name="Total">Total number of tests.</param>
public sealed record TestRunSummary(
int Passed,
int Failed,
int Skipped,
int Total);

View File

@@ -0,0 +1,241 @@
// <copyright file="BlastRadiusValidator.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// Sprint: SPRINT_20260105_002_005_TEST_cross_cutting
// Task: CCUT-003
using System.Collections.Immutable;
using System.Reflection;
namespace StellaOps.TestKit.BlastRadius;
/// <summary>
/// Validates that tests have appropriate blast-radius annotations.
/// </summary>
public sealed class BlastRadiusValidator
{
private readonly IReadOnlyList<Type> _testClasses;
private readonly BlastRadiusValidationConfig _config;
/// <summary>
/// Initializes a new instance of the <see cref="BlastRadiusValidator"/> class.
/// </summary>
/// <param name="testClasses">Test classes to validate.</param>
/// <param name="config">Validation configuration.</param>
public BlastRadiusValidator(
IEnumerable<Type> testClasses,
BlastRadiusValidationConfig? config = null)
{
_testClasses = testClasses.ToList();
_config = config ?? new BlastRadiusValidationConfig();
}
/// <summary>
/// Create a validator from assemblies.
/// </summary>
/// <param name="assemblies">Assemblies to scan for test classes.</param>
/// <param name="config">Validation configuration.</param>
/// <returns>BlastRadiusValidator instance.</returns>
public static BlastRadiusValidator FromAssemblies(
IEnumerable<Assembly> assemblies,
BlastRadiusValidationConfig? config = null)
{
var testClasses = assemblies
.SelectMany(a => a.GetTypes())
.Where(IsTestClass)
.ToList();
return new BlastRadiusValidator(testClasses, config);
}
/// <summary>
/// Validate all tests that require blast-radius annotations.
/// </summary>
/// <returns>Validation result.</returns>
public BlastRadiusValidationResult Validate()
{
var violations = new List<BlastRadiusViolation>();
foreach (var testClass in _testClasses)
{
var classTraits = GetTraits(testClass);
// Check if class has a category that requires blast radius
var categories = classTraits
.Where(t => t.Name == "Category")
.Select(t => t.Value)
.ToList();
var requiresBlastRadius = categories
.Any(c => _config.CategoriesRequiringBlastRadius.Contains(c));
if (!requiresBlastRadius)
{
continue;
}
// Check if class has blast radius annotation
var hasBlastRadius = classTraits.Any(t => t.Name == "BlastRadius");
if (!hasBlastRadius)
{
violations.Add(new BlastRadiusViolation(
TestClass: testClass.FullName ?? testClass.Name,
Category: string.Join(", ", categories.Where(c => _config.CategoriesRequiringBlastRadius.Contains(c))),
Message: $"Test class requires BlastRadius annotation because it has category: {string.Join(", ", categories.Where(c => _config.CategoriesRequiringBlastRadius.Contains(c)))}"));
}
}
return new BlastRadiusValidationResult(
IsValid: violations.Count == 0,
Violations: [.. violations],
TotalTestClasses: _testClasses.Count,
TestClassesRequiringBlastRadius: _testClasses.Count(c =>
GetTraits(c).Any(t =>
t.Name == "Category" &&
_config.CategoriesRequiringBlastRadius.Contains(t.Value))));
}
/// <summary>
/// Get coverage report by blast radius.
/// </summary>
/// <returns>Coverage report.</returns>
public BlastRadiusCoverageReport GetCoverageReport()
{
var byBlastRadius = new Dictionary<string, List<string>>();
var uncategorized = new List<string>();
foreach (var testClass in _testClasses)
{
var traits = GetTraits(testClass);
var blastRadii = traits
.Where(t => t.Name == "BlastRadius")
.Select(t => t.Value)
.ToList();
if (blastRadii.Count == 0)
{
uncategorized.Add(testClass.FullName ?? testClass.Name);
}
else
{
foreach (var br in blastRadii)
{
if (!byBlastRadius.TryGetValue(br, out var list))
{
list = [];
byBlastRadius[br] = list;
}
list.Add(testClass.FullName ?? testClass.Name);
}
}
}
return new BlastRadiusCoverageReport(
ByBlastRadius: byBlastRadius.ToImmutableDictionary(
kvp => kvp.Key,
kvp => kvp.Value.ToImmutableArray()),
UncategorizedTestClasses: [.. uncategorized],
TotalTestClasses: _testClasses.Count);
}
/// <summary>
/// Get all blast radius values found in test classes.
/// </summary>
/// <returns>Distinct blast radius values.</returns>
public IReadOnlyList<string> GetBlastRadiusValues()
{
return _testClasses
.SelectMany(c => GetTraits(c))
.Where(t => t.Name == "BlastRadius")
.Select(t => t.Value)
.Distinct()
.OrderBy(v => v)
.ToList();
}
private static bool IsTestClass(Type type)
{
if (!type.IsClass || type.IsAbstract)
{
return false;
}
// Check for xUnit test methods
return type.GetMethods()
.Any(m => m.GetCustomAttributes()
.Any(a => a.GetType().Name is "FactAttribute" or "TheoryAttribute"));
}
private static IEnumerable<(string Name, string Value)> GetTraits(Type type)
{
var traitAttributes = type.GetCustomAttributes()
.Where(a => a.GetType().Name == "TraitAttribute")
.ToList();
foreach (var attr in traitAttributes)
{
var nameProperty = attr.GetType().GetProperty("Name");
var valueProperty = attr.GetType().GetProperty("Value");
if (nameProperty != null && valueProperty != null)
{
var name = nameProperty.GetValue(attr)?.ToString() ?? string.Empty;
var value = valueProperty.GetValue(attr)?.ToString() ?? string.Empty;
yield return (name, value);
}
}
}
}
/// <summary>
/// Configuration for blast-radius validation.
/// </summary>
/// <param name="CategoriesRequiringBlastRadius">Categories that require blast-radius annotations.</param>
public sealed record BlastRadiusValidationConfig(
ImmutableArray<string> CategoriesRequiringBlastRadius = default)
{
/// <summary>
/// Gets the categories requiring blast-radius annotations.
/// </summary>
public ImmutableArray<string> CategoriesRequiringBlastRadius { get; init; } =
CategoriesRequiringBlastRadius.IsDefaultOrEmpty
? [TestCategories.Integration, TestCategories.Contract, TestCategories.Security]
: CategoriesRequiringBlastRadius;
}
/// <summary>
/// Result of blast-radius validation.
/// </summary>
/// <param name="IsValid">Whether all tests pass validation.</param>
/// <param name="Violations">List of violations found.</param>
/// <param name="TotalTestClasses">Total number of test classes examined.</param>
/// <param name="TestClassesRequiringBlastRadius">Number of test classes that require blast-radius.</param>
public sealed record BlastRadiusValidationResult(
bool IsValid,
ImmutableArray<BlastRadiusViolation> Violations,
int TotalTestClasses,
int TestClassesRequiringBlastRadius);
/// <summary>
/// A blast-radius validation violation.
/// </summary>
/// <param name="TestClass">Test class with violation.</param>
/// <param name="Category">Category requiring blast-radius.</param>
/// <param name="Message">Violation message.</param>
public sealed record BlastRadiusViolation(
string TestClass,
string Category,
string Message);
/// <summary>
/// Coverage report by blast radius.
/// </summary>
/// <param name="ByBlastRadius">Test classes grouped by blast radius.</param>
/// <param name="UncategorizedTestClasses">Test classes without blast-radius annotation.</param>
/// <param name="TotalTestClasses">Total number of test classes.</param>
public sealed record BlastRadiusCoverageReport(
ImmutableDictionary<string, ImmutableArray<string>> ByBlastRadius,
ImmutableArray<string> UncategorizedTestClasses,
int TotalTestClasses);

View File

@@ -128,4 +128,94 @@ public static class TestCategories
/// Storage migration tests: Schema migrations, versioning, idempotent migration application.
/// </summary>
public const string StorageMigration = "StorageMigration";
// =========================================================================
// Blast-Radius annotations - operational surfaces affected by test failures
// Use these to enable targeted test runs during incidents
// =========================================================================
/// <summary>
/// Blast-radius annotations for operational surfaces.
/// </summary>
/// <remarks>
/// Usage with xUnit:
/// <code>
/// [Fact]
/// [Trait("Category", TestCategories.Integration)]
/// [Trait("BlastRadius", TestCategories.BlastRadius.Auth)]
/// [Trait("BlastRadius", TestCategories.BlastRadius.Api)]
/// public async Task TestTokenValidation() { }
/// </code>
///
/// Filter by blast radius during test runs:
/// <code>
/// dotnet test --filter "BlastRadius=Auth|BlastRadius=Api"
/// </code>
/// </remarks>
public static class BlastRadius
{
/// <summary>
/// Authentication, authorization, identity, tokens, sessions.
/// </summary>
public const string Auth = "Auth";
/// <summary>
/// SBOM generation, vulnerability scanning, reachability analysis.
/// </summary>
public const string Scanning = "Scanning";
/// <summary>
/// Attestation, evidence storage, audit trails, proof chains.
/// </summary>
public const string Evidence = "Evidence";
/// <summary>
/// Regulatory compliance, GDPR, data retention, audit logging.
/// </summary>
public const string Compliance = "Compliance";
/// <summary>
/// Advisory ingestion, VEX processing, feed synchronization.
/// </summary>
public const string Advisories = "Advisories";
/// <summary>
/// Risk scoring, policy evaluation, verdicts.
/// </summary>
public const string RiskPolicy = "RiskPolicy";
/// <summary>
/// Cryptographic operations, signing, verification, key management.
/// </summary>
public const string Crypto = "Crypto";
/// <summary>
/// External integrations, webhooks, notifications.
/// </summary>
public const string Integrations = "Integrations";
/// <summary>
/// Data persistence, database operations, storage.
/// </summary>
public const string Persistence = "Persistence";
/// <summary>
/// API surface, contract compatibility, endpoint behavior.
/// </summary>
public const string Api = "Api";
}
// =========================================================================
// Schema evolution categories
// =========================================================================
/// <summary>
/// Schema evolution tests: Backward/forward compatibility across schema versions.
/// </summary>
public const string SchemaEvolution = "SchemaEvolution";
/// <summary>
/// Config-diff tests: Behavioral delta tests for configuration changes.
/// </summary>
public const string ConfigDiff = "ConfigDiff";
}