// // Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later. // 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; /// /// Default implementation of test evidence service. /// public sealed class TestEvidenceService : ITestEvidenceService { private readonly ILogger _logger; private readonly TimeProvider _timeProvider; private readonly ConcurrentDictionary _bundles = new(); private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = false, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; /// /// Initializes a new instance of the class. /// /// Logger instance. /// Time provider for timestamps. public TestEvidenceService( ILogger logger, TimeProvider timeProvider) { _logger = logger; _timeProvider = timeProvider; } /// public Task 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); } /// 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; } /// public Task 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); } /// public Task GetBundleAsync( string bundleId, CancellationToken ct = default) { _bundles.TryGetValue(bundleId, out var bundle); return Task.FromResult(bundle); } private static TestSummary ComputeSummary(ImmutableArray 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 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(); 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(); } }