192 lines
6.0 KiB
C#
192 lines
6.0 KiB
C#
// <copyright file="TestEvidenceService.cs" company="StellaOps">
|
|
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
|
// </copyright>
|
|
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Immutable;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace StellaOps.Testing.Evidence;
|
|
|
|
/// <summary>
|
|
/// Default implementation of test evidence service.
|
|
/// </summary>
|
|
public sealed class TestEvidenceService : ITestEvidenceService
|
|
{
|
|
private readonly ILogger<TestEvidenceService> _logger;
|
|
private readonly TimeProvider _timeProvider;
|
|
private readonly ConcurrentDictionary<string, TestEvidenceBundle> _bundles = new();
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
WriteIndented = false,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
};
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="TestEvidenceService"/> class.
|
|
/// </summary>
|
|
/// <param name="logger">Logger instance.</param>
|
|
/// <param name="timeProvider">Time provider for timestamps.</param>
|
|
public TestEvidenceService(
|
|
ILogger<TestEvidenceService> logger,
|
|
TimeProvider timeProvider)
|
|
{
|
|
_logger = logger;
|
|
_timeProvider = timeProvider;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public Task<TestEvidenceSession> BeginSessionAsync(
|
|
TestSessionMetadata metadata,
|
|
CancellationToken ct = default)
|
|
{
|
|
var session = new TestEvidenceSession(metadata);
|
|
|
|
_logger.LogInformation(
|
|
"Started test evidence session {SessionId} for suite {TestSuiteId}",
|
|
metadata.SessionId, metadata.TestSuiteId);
|
|
|
|
return Task.FromResult(session);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public Task RecordTestResultAsync(
|
|
TestEvidenceSession session,
|
|
TestResultRecord result,
|
|
CancellationToken ct = default)
|
|
{
|
|
session.AddResult(result);
|
|
|
|
_logger.LogDebug(
|
|
"Recorded test result {TestId}: {Outcome}",
|
|
result.TestId, result.Outcome);
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public Task<TestEvidenceBundle> FinalizeSessionAsync(
|
|
TestEvidenceSession session,
|
|
CancellationToken ct = default)
|
|
{
|
|
if (session.IsFinalized)
|
|
{
|
|
throw new InvalidOperationException("Session is already finalized.");
|
|
}
|
|
|
|
session.MarkAsFinalized();
|
|
|
|
var results = session.GetResults();
|
|
var summary = ComputeSummary(results);
|
|
var merkleRoot = ComputeMerkleRoot(results);
|
|
var bundleId = GenerateBundleId(session.Metadata, merkleRoot);
|
|
|
|
var bundle = new TestEvidenceBundle(
|
|
BundleId: bundleId,
|
|
MerkleRoot: merkleRoot,
|
|
Metadata: session.Metadata,
|
|
Summary: summary,
|
|
Results: results,
|
|
FinalizedAt: _timeProvider.GetUtcNow(),
|
|
EvidenceLockerRef: $"evidence://{bundleId}");
|
|
|
|
_bundles[bundleId] = bundle;
|
|
|
|
_logger.LogInformation(
|
|
"Finalized test evidence bundle {BundleId} with {TotalTests} tests ({Passed} passed, {Failed} failed)",
|
|
bundleId, summary.TotalTests, summary.Passed, summary.Failed);
|
|
|
|
return Task.FromResult(bundle);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public Task<TestEvidenceBundle?> GetBundleAsync(
|
|
string bundleId,
|
|
CancellationToken ct = default)
|
|
{
|
|
_bundles.TryGetValue(bundleId, out var bundle);
|
|
return Task.FromResult(bundle);
|
|
}
|
|
|
|
private static TestSummary ComputeSummary(ImmutableArray<TestResultRecord> results)
|
|
{
|
|
var byCategory = results
|
|
.SelectMany(r => r.Categories.Select(c => (Category: c, Result: r)))
|
|
.GroupBy(x => x.Category)
|
|
.ToImmutableDictionary(g => g.Key, g => g.Count());
|
|
|
|
var byBlastRadius = results
|
|
.SelectMany(r => r.BlastRadiusAnnotations.Select(b => (BlastRadius: b, Result: r)))
|
|
.GroupBy(x => x.BlastRadius)
|
|
.ToImmutableDictionary(g => g.Key, g => g.Count());
|
|
|
|
return new TestSummary(
|
|
TotalTests: results.Length,
|
|
Passed: results.Count(r => r.Outcome == TestOutcome.Passed),
|
|
Failed: results.Count(r => r.Outcome == TestOutcome.Failed),
|
|
Skipped: results.Count(r => r.Outcome == TestOutcome.Skipped),
|
|
TotalDuration: TimeSpan.FromTicks(results.Sum(r => r.Duration.Ticks)),
|
|
ResultsByCategory: byCategory,
|
|
ResultsByBlastRadius: byBlastRadius);
|
|
}
|
|
|
|
private static string ComputeMerkleRoot(ImmutableArray<TestResultRecord> results)
|
|
{
|
|
if (results.IsEmpty)
|
|
{
|
|
return ComputeSha256("empty");
|
|
}
|
|
|
|
// Compute leaf hashes
|
|
var leaves = results
|
|
.OrderBy(r => r.TestId)
|
|
.Select(r => ComputeResultHash(r))
|
|
.ToList();
|
|
|
|
// Build Merkle tree
|
|
while (leaves.Count > 1)
|
|
{
|
|
var newLevel = new List<string>();
|
|
|
|
for (int i = 0; i < leaves.Count; i += 2)
|
|
{
|
|
if (i + 1 < leaves.Count)
|
|
{
|
|
newLevel.Add(ComputeSha256(leaves[i] + leaves[i + 1]));
|
|
}
|
|
else
|
|
{
|
|
newLevel.Add(leaves[i]); // Odd leaf promoted
|
|
}
|
|
}
|
|
|
|
leaves = newLevel;
|
|
}
|
|
|
|
return leaves[0];
|
|
}
|
|
|
|
private static string ComputeResultHash(TestResultRecord result)
|
|
{
|
|
var json = JsonSerializer.Serialize(result, JsonOptions);
|
|
return ComputeSha256(json);
|
|
}
|
|
|
|
private static string GenerateBundleId(TestSessionMetadata metadata, string merkleRoot)
|
|
{
|
|
var input = $"{metadata.SessionId}:{metadata.TestSuiteId}:{merkleRoot}";
|
|
var hash = ComputeSha256(input);
|
|
return $"teb-{hash[..16]}";
|
|
}
|
|
|
|
private static string ComputeSha256(string input)
|
|
{
|
|
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
|
return Convert.ToHexString(bytes).ToLowerInvariant();
|
|
}
|
|
}
|