Add unit tests for VexLens normalizer, CPE parser, product mapper, and PURL parser
- Implemented comprehensive tests for VexLensNormalizer including format detection and normalization scenarios. - Added tests for CpeParser covering CPE 2.3 and 2.2 formats, invalid inputs, and canonical key generation. - Created tests for ProductMapper to validate parsing and matching logic across different strictness levels. - Developed tests for PurlParser to ensure correct parsing of various PURL formats and validation of identifiers. - Introduced stubs for Monaco editor and worker to facilitate testing in the web application. - Updated project file for the test project to include necessary dependencies.
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
namespace StellaOps.TaskRunner.Core.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Sink for pack run timeline events (Kafka, NATS, file, etc.).
|
||||
/// Per TASKRUN-OBS-52-001.
|
||||
/// </summary>
|
||||
public interface IPackRunTimelineEventSink
|
||||
{
|
||||
/// <summary>
|
||||
/// Writes a timeline event to the sink.
|
||||
/// </summary>
|
||||
Task<PackRunTimelineSinkWriteResult> WriteAsync(
|
||||
PackRunTimelineEvent evt,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Writes multiple timeline events to the sink.
|
||||
/// </summary>
|
||||
Task<PackRunTimelineSinkBatchWriteResult> WriteBatchAsync(
|
||||
IEnumerable<PackRunTimelineEvent> events,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of writing to pack run timeline sink.
|
||||
/// </summary>
|
||||
public sealed record PackRunTimelineSinkWriteResult(
|
||||
/// <summary>Whether the event was written successfully.</summary>
|
||||
bool Success,
|
||||
|
||||
/// <summary>Assigned sequence number if applicable.</summary>
|
||||
long? Sequence,
|
||||
|
||||
/// <summary>Whether the event was deduplicated.</summary>
|
||||
bool Deduplicated,
|
||||
|
||||
/// <summary>Error message if write failed.</summary>
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Result of batch writing to pack run timeline sink.
|
||||
/// </summary>
|
||||
public sealed record PackRunTimelineSinkBatchWriteResult(
|
||||
/// <summary>Number of events written successfully.</summary>
|
||||
int Written,
|
||||
|
||||
/// <summary>Number of events deduplicated.</summary>
|
||||
int Deduplicated,
|
||||
|
||||
/// <summary>Number of events that failed.</summary>
|
||||
int Failed);
|
||||
|
||||
/// <summary>
|
||||
/// In-memory pack run timeline event sink for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryPackRunTimelineEventSink : IPackRunTimelineEventSink
|
||||
{
|
||||
private readonly List<PackRunTimelineEvent> _events = new();
|
||||
private readonly HashSet<Guid> _seenIds = new();
|
||||
private readonly object _lock = new();
|
||||
private long _sequence;
|
||||
|
||||
public Task<PackRunTimelineSinkWriteResult> WriteAsync(
|
||||
PackRunTimelineEvent evt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_seenIds.Add(evt.EventId))
|
||||
{
|
||||
return Task.FromResult(new PackRunTimelineSinkWriteResult(
|
||||
Success: true,
|
||||
Sequence: null,
|
||||
Deduplicated: true,
|
||||
Error: null));
|
||||
}
|
||||
|
||||
var seq = ++_sequence;
|
||||
var eventWithSeq = evt.WithSequence(seq);
|
||||
_events.Add(eventWithSeq);
|
||||
|
||||
return Task.FromResult(new PackRunTimelineSinkWriteResult(
|
||||
Success: true,
|
||||
Sequence: seq,
|
||||
Deduplicated: false,
|
||||
Error: null));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<PackRunTimelineSinkBatchWriteResult> WriteBatchAsync(
|
||||
IEnumerable<PackRunTimelineEvent> events,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var written = 0;
|
||||
var deduplicated = 0;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var evt in events)
|
||||
{
|
||||
if (!_seenIds.Add(evt.EventId))
|
||||
{
|
||||
deduplicated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var seq = ++_sequence;
|
||||
_events.Add(evt.WithSequence(seq));
|
||||
written++;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(new PackRunTimelineSinkBatchWriteResult(written, deduplicated, 0));
|
||||
}
|
||||
|
||||
/// <summary>Gets all events (for testing).</summary>
|
||||
public IReadOnlyList<PackRunTimelineEvent> GetEvents()
|
||||
{
|
||||
lock (_lock) { return _events.ToList(); }
|
||||
}
|
||||
|
||||
/// <summary>Gets events for a tenant (for testing).</summary>
|
||||
public IReadOnlyList<PackRunTimelineEvent> GetEvents(string tenantId)
|
||||
{
|
||||
lock (_lock) { return _events.Where(e => e.TenantId == tenantId).ToList(); }
|
||||
}
|
||||
|
||||
/// <summary>Gets events for a run (for testing).</summary>
|
||||
public IReadOnlyList<PackRunTimelineEvent> GetEventsForRun(string runId)
|
||||
{
|
||||
lock (_lock) { return _events.Where(e => e.RunId == runId).ToList(); }
|
||||
}
|
||||
|
||||
/// <summary>Gets events by type (for testing).</summary>
|
||||
public IReadOnlyList<PackRunTimelineEvent> GetEventsByType(string eventType)
|
||||
{
|
||||
lock (_lock) { return _events.Where(e => e.EventType == eventType).ToList(); }
|
||||
}
|
||||
|
||||
/// <summary>Gets step events for a run (for testing).</summary>
|
||||
public IReadOnlyList<PackRunTimelineEvent> GetStepEvents(string runId, string stepId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _events
|
||||
.Where(e => e.RunId == runId && e.StepId == stepId)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clears all events (for testing).</summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_events.Clear();
|
||||
_seenIds.Clear();
|
||||
_sequence = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets the current event count.</summary>
|
||||
public int Count
|
||||
{
|
||||
get { lock (_lock) { return _events.Count; } }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null sink that discards all events.
|
||||
/// </summary>
|
||||
public sealed class NullPackRunTimelineEventSink : IPackRunTimelineEventSink
|
||||
{
|
||||
public static NullPackRunTimelineEventSink Instance { get; } = new();
|
||||
|
||||
private NullPackRunTimelineEventSink() { }
|
||||
|
||||
public Task<PackRunTimelineSinkWriteResult> WriteAsync(
|
||||
PackRunTimelineEvent evt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new PackRunTimelineSinkWriteResult(
|
||||
Success: true,
|
||||
Sequence: null,
|
||||
Deduplicated: false,
|
||||
Error: null));
|
||||
}
|
||||
|
||||
public Task<PackRunTimelineSinkBatchWriteResult> WriteBatchAsync(
|
||||
IEnumerable<PackRunTimelineEvent> events,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var count = events.Count();
|
||||
return Task.FromResult(new PackRunTimelineSinkBatchWriteResult(count, 0, 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Timeline event for pack run audit trail, observability, and evidence chain tracking.
|
||||
/// Per TASKRUN-OBS-52-001 and timeline-event.schema.json.
|
||||
/// </summary>
|
||||
public sealed record PackRunTimelineEvent(
|
||||
/// <summary>Monotonically increasing sequence number for ordering.</summary>
|
||||
long? EventSeq,
|
||||
|
||||
/// <summary>Globally unique event identifier.</summary>
|
||||
Guid EventId,
|
||||
|
||||
/// <summary>Tenant scope for multi-tenant isolation.</summary>
|
||||
string TenantId,
|
||||
|
||||
/// <summary>Event type identifier following namespace convention.</summary>
|
||||
string EventType,
|
||||
|
||||
/// <summary>Service or component that emitted this event.</summary>
|
||||
string Source,
|
||||
|
||||
/// <summary>When the event actually occurred.</summary>
|
||||
DateTimeOffset OccurredAt,
|
||||
|
||||
/// <summary>When the event was received by timeline indexer.</summary>
|
||||
DateTimeOffset? ReceivedAt,
|
||||
|
||||
/// <summary>Correlation ID linking related events across services.</summary>
|
||||
string? CorrelationId,
|
||||
|
||||
/// <summary>OpenTelemetry trace ID for distributed tracing.</summary>
|
||||
string? TraceId,
|
||||
|
||||
/// <summary>OpenTelemetry span ID within the trace.</summary>
|
||||
string? SpanId,
|
||||
|
||||
/// <summary>User, service account, or system that triggered the event.</summary>
|
||||
string? Actor,
|
||||
|
||||
/// <summary>Event severity level.</summary>
|
||||
PackRunEventSeverity Severity,
|
||||
|
||||
/// <summary>Key-value attributes for filtering and querying.</summary>
|
||||
IReadOnlyDictionary<string, string>? Attributes,
|
||||
|
||||
/// <summary>SHA-256 hash of the raw payload for integrity.</summary>
|
||||
string? PayloadHash,
|
||||
|
||||
/// <summary>Original event payload as JSON string.</summary>
|
||||
string? RawPayloadJson,
|
||||
|
||||
/// <summary>Canonicalized JSON for deterministic hashing.</summary>
|
||||
string? NormalizedPayloadJson,
|
||||
|
||||
/// <summary>Reference to associated evidence bundle or attestation.</summary>
|
||||
PackRunEvidencePointer? EvidencePointer,
|
||||
|
||||
/// <summary>Run ID for this pack run.</summary>
|
||||
string RunId,
|
||||
|
||||
/// <summary>Plan hash for the pack run.</summary>
|
||||
string? PlanHash,
|
||||
|
||||
/// <summary>Step ID if this event is associated with a step.</summary>
|
||||
string? StepId,
|
||||
|
||||
/// <summary>Project ID scope within tenant.</summary>
|
||||
string? ProjectId)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new timeline event with generated ID.
|
||||
/// </summary>
|
||||
public static PackRunTimelineEvent Create(
|
||||
string tenantId,
|
||||
string eventType,
|
||||
string source,
|
||||
DateTimeOffset occurredAt,
|
||||
string runId,
|
||||
string? planHash = null,
|
||||
string? stepId = null,
|
||||
string? actor = null,
|
||||
PackRunEventSeverity severity = PackRunEventSeverity.Info,
|
||||
IReadOnlyDictionary<string, string>? attributes = null,
|
||||
string? correlationId = null,
|
||||
string? traceId = null,
|
||||
string? spanId = null,
|
||||
string? projectId = null,
|
||||
object? payload = null,
|
||||
PackRunEvidencePointer? evidencePointer = null)
|
||||
{
|
||||
string? rawPayload = null;
|
||||
string? normalizedPayload = null;
|
||||
string? payloadHash = null;
|
||||
|
||||
if (payload is not null)
|
||||
{
|
||||
rawPayload = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
normalizedPayload = NormalizeJson(rawPayload);
|
||||
payloadHash = ComputeHash(normalizedPayload);
|
||||
}
|
||||
|
||||
return new PackRunTimelineEvent(
|
||||
EventSeq: null,
|
||||
EventId: Guid.NewGuid(),
|
||||
TenantId: tenantId,
|
||||
EventType: eventType,
|
||||
Source: source,
|
||||
OccurredAt: occurredAt,
|
||||
ReceivedAt: null,
|
||||
CorrelationId: correlationId,
|
||||
TraceId: traceId,
|
||||
SpanId: spanId,
|
||||
Actor: actor,
|
||||
Severity: severity,
|
||||
Attributes: attributes,
|
||||
PayloadHash: payloadHash,
|
||||
RawPayloadJson: rawPayload,
|
||||
NormalizedPayloadJson: normalizedPayload,
|
||||
EvidencePointer: evidencePointer,
|
||||
RunId: runId,
|
||||
PlanHash: planHash,
|
||||
StepId: stepId,
|
||||
ProjectId: projectId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the event to JSON.
|
||||
/// </summary>
|
||||
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
|
||||
|
||||
/// <summary>
|
||||
/// Parses a timeline event from JSON.
|
||||
/// </summary>
|
||||
public static PackRunTimelineEvent? FromJson(string json)
|
||||
=> JsonSerializer.Deserialize<PackRunTimelineEvent>(json, JsonOptions);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a copy with received timestamp set.
|
||||
/// </summary>
|
||||
public PackRunTimelineEvent WithReceivedAt(DateTimeOffset receivedAt)
|
||||
=> this with { ReceivedAt = receivedAt };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a copy with sequence number set.
|
||||
/// </summary>
|
||||
public PackRunTimelineEvent WithSequence(long seq)
|
||||
=> this with { EventSeq = seq };
|
||||
|
||||
/// <summary>
|
||||
/// Generates an idempotency key for this event.
|
||||
/// </summary>
|
||||
public string GenerateIdempotencyKey()
|
||||
=> $"timeline:pack:{TenantId}:{EventType}:{EventId}";
|
||||
|
||||
private static string NormalizeJson(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return JsonSerializer.Serialize(doc.RootElement, CanonicalJsonOptions);
|
||||
}
|
||||
|
||||
private static string ComputeHash(string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event severity level for pack run timeline events.
|
||||
/// </summary>
|
||||
public enum PackRunEventSeverity
|
||||
{
|
||||
Debug,
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
Critical
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to associated evidence bundle or attestation.
|
||||
/// </summary>
|
||||
public sealed record PackRunEvidencePointer(
|
||||
/// <summary>Type of evidence being referenced.</summary>
|
||||
PackRunEvidencePointerType Type,
|
||||
|
||||
/// <summary>Evidence bundle identifier.</summary>
|
||||
Guid? BundleId,
|
||||
|
||||
/// <summary>Content digest of the evidence bundle.</summary>
|
||||
string? BundleDigest,
|
||||
|
||||
/// <summary>Subject URI for the attestation.</summary>
|
||||
string? AttestationSubject,
|
||||
|
||||
/// <summary>Digest of the attestation envelope.</summary>
|
||||
string? AttestationDigest,
|
||||
|
||||
/// <summary>URI to the evidence manifest.</summary>
|
||||
string? ManifestUri,
|
||||
|
||||
/// <summary>Path within evidence locker storage.</summary>
|
||||
string? LockerPath)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a bundle evidence pointer.
|
||||
/// </summary>
|
||||
public static PackRunEvidencePointer Bundle(Guid bundleId, string? bundleDigest = null)
|
||||
=> new(PackRunEvidencePointerType.Bundle, bundleId, bundleDigest, null, null, null, null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an attestation evidence pointer.
|
||||
/// </summary>
|
||||
public static PackRunEvidencePointer Attestation(string subject, string? digest = null)
|
||||
=> new(PackRunEvidencePointerType.Attestation, null, null, subject, digest, null, null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a manifest evidence pointer.
|
||||
/// </summary>
|
||||
public static PackRunEvidencePointer Manifest(string uri, string? lockerPath = null)
|
||||
=> new(PackRunEvidencePointerType.Manifest, null, null, null, null, uri, lockerPath);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an artifact evidence pointer.
|
||||
/// </summary>
|
||||
public static PackRunEvidencePointer Artifact(string lockerPath, string? digest = null)
|
||||
=> new(PackRunEvidencePointerType.Artifact, null, digest, null, null, null, lockerPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of evidence being referenced.
|
||||
/// </summary>
|
||||
public enum PackRunEvidencePointerType
|
||||
{
|
||||
Bundle,
|
||||
Attestation,
|
||||
Manifest,
|
||||
Artifact
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pack run timeline event types.
|
||||
/// </summary>
|
||||
public static class PackRunEventTypes
|
||||
{
|
||||
/// <summary>Prefix for all pack run events.</summary>
|
||||
public const string Prefix = "pack.";
|
||||
|
||||
/// <summary>Pack run started.</summary>
|
||||
public const string PackStarted = "pack.started";
|
||||
|
||||
/// <summary>Pack run completed successfully.</summary>
|
||||
public const string PackCompleted = "pack.completed";
|
||||
|
||||
/// <summary>Pack run failed.</summary>
|
||||
public const string PackFailed = "pack.failed";
|
||||
|
||||
/// <summary>Pack run paused (awaiting approvals/gates).</summary>
|
||||
public const string PackPaused = "pack.paused";
|
||||
|
||||
/// <summary>Step started execution.</summary>
|
||||
public const string StepStarted = "pack.step.started";
|
||||
|
||||
/// <summary>Step completed successfully.</summary>
|
||||
public const string StepCompleted = "pack.step.completed";
|
||||
|
||||
/// <summary>Step failed.</summary>
|
||||
public const string StepFailed = "pack.step.failed";
|
||||
|
||||
/// <summary>Step scheduled for retry.</summary>
|
||||
public const string StepRetryScheduled = "pack.step.retry_scheduled";
|
||||
|
||||
/// <summary>Step skipped.</summary>
|
||||
public const string StepSkipped = "pack.step.skipped";
|
||||
|
||||
/// <summary>Approval gate satisfied.</summary>
|
||||
public const string ApprovalSatisfied = "pack.approval.satisfied";
|
||||
|
||||
/// <summary>Policy gate evaluated.</summary>
|
||||
public const string PolicyEvaluated = "pack.policy.evaluated";
|
||||
|
||||
/// <summary>Checks if the event type is a pack run event.</summary>
|
||||
public static bool IsPackRunEvent(string eventType) =>
|
||||
eventType.StartsWith(Prefix, StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Service for emitting pack run timeline events with trace IDs, deduplication, and retries.
|
||||
/// Per TASKRUN-OBS-52-001.
|
||||
/// </summary>
|
||||
public interface IPackRunTimelineEventEmitter
|
||||
{
|
||||
/// <summary>
|
||||
/// Emits a timeline event.
|
||||
/// </summary>
|
||||
Task<PackRunTimelineEmitResult> EmitAsync(
|
||||
PackRunTimelineEvent evt,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Emits multiple timeline events in batch.
|
||||
/// </summary>
|
||||
Task<PackRunTimelineBatchEmitResult> EmitBatchAsync(
|
||||
IEnumerable<PackRunTimelineEvent> events,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Emits a pack.started event.
|
||||
/// </summary>
|
||||
Task<PackRunTimelineEmitResult> EmitPackStartedAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string planHash,
|
||||
string? actor = null,
|
||||
string? correlationId = null,
|
||||
string? traceId = null,
|
||||
string? projectId = null,
|
||||
IReadOnlyDictionary<string, string>? attributes = null,
|
||||
PackRunEvidencePointer? evidencePointer = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Emits a pack.completed event.
|
||||
/// </summary>
|
||||
Task<PackRunTimelineEmitResult> EmitPackCompletedAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string planHash,
|
||||
string? actor = null,
|
||||
string? correlationId = null,
|
||||
string? traceId = null,
|
||||
string? projectId = null,
|
||||
IReadOnlyDictionary<string, string>? attributes = null,
|
||||
PackRunEvidencePointer? evidencePointer = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Emits a pack.failed event.
|
||||
/// </summary>
|
||||
Task<PackRunTimelineEmitResult> EmitPackFailedAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string planHash,
|
||||
string? failureReason = null,
|
||||
string? actor = null,
|
||||
string? correlationId = null,
|
||||
string? traceId = null,
|
||||
string? projectId = null,
|
||||
IReadOnlyDictionary<string, string>? attributes = null,
|
||||
PackRunEvidencePointer? evidencePointer = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Emits a pack.step.started event.
|
||||
/// </summary>
|
||||
Task<PackRunTimelineEmitResult> EmitStepStartedAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string planHash,
|
||||
string stepId,
|
||||
int attempt,
|
||||
string? actor = null,
|
||||
string? correlationId = null,
|
||||
string? traceId = null,
|
||||
string? projectId = null,
|
||||
IReadOnlyDictionary<string, string>? attributes = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Emits a pack.step.completed event.
|
||||
/// </summary>
|
||||
Task<PackRunTimelineEmitResult> EmitStepCompletedAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string planHash,
|
||||
string stepId,
|
||||
int attempt,
|
||||
double? durationMs = null,
|
||||
string? actor = null,
|
||||
string? correlationId = null,
|
||||
string? traceId = null,
|
||||
string? projectId = null,
|
||||
IReadOnlyDictionary<string, string>? attributes = null,
|
||||
PackRunEvidencePointer? evidencePointer = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Emits a pack.step.failed event.
|
||||
/// </summary>
|
||||
Task<PackRunTimelineEmitResult> EmitStepFailedAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string planHash,
|
||||
string stepId,
|
||||
int attempt,
|
||||
string? error = null,
|
||||
string? actor = null,
|
||||
string? correlationId = null,
|
||||
string? traceId = null,
|
||||
string? projectId = null,
|
||||
IReadOnlyDictionary<string, string>? attributes = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of timeline event emission.
|
||||
/// </summary>
|
||||
public sealed record PackRunTimelineEmitResult(
|
||||
/// <summary>Whether the event was emitted successfully.</summary>
|
||||
bool Success,
|
||||
|
||||
/// <summary>The emitted event (with sequence if assigned).</summary>
|
||||
PackRunTimelineEvent Event,
|
||||
|
||||
/// <summary>Whether the event was deduplicated.</summary>
|
||||
bool Deduplicated,
|
||||
|
||||
/// <summary>Error message if emission failed.</summary>
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Result of batch timeline event emission.
|
||||
/// </summary>
|
||||
public sealed record PackRunTimelineBatchEmitResult(
|
||||
/// <summary>Number of events emitted successfully.</summary>
|
||||
int Emitted,
|
||||
|
||||
/// <summary>Number of events deduplicated.</summary>
|
||||
int Deduplicated,
|
||||
|
||||
/// <summary>Number of events that failed.</summary>
|
||||
int Failed,
|
||||
|
||||
/// <summary>Errors encountered.</summary>
|
||||
IReadOnlyList<string> Errors)
|
||||
{
|
||||
/// <summary>Total events processed.</summary>
|
||||
public int Total => Emitted + Deduplicated + Failed;
|
||||
|
||||
/// <summary>Whether any events were emitted.</summary>
|
||||
public bool HasEmitted => Emitted > 0;
|
||||
|
||||
/// <summary>Whether any errors occurred.</summary>
|
||||
public bool HasErrors => Failed > 0 || Errors.Count > 0;
|
||||
|
||||
/// <summary>Creates an empty result.</summary>
|
||||
public static PackRunTimelineBatchEmitResult Empty => new(0, 0, 0, []);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of pack run timeline event emitter.
|
||||
/// </summary>
|
||||
public sealed class PackRunTimelineEventEmitter : IPackRunTimelineEventEmitter
|
||||
{
|
||||
private const string Source = "taskrunner-worker";
|
||||
private readonly IPackRunTimelineEventSink _sink;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<PackRunTimelineEventEmitter> _logger;
|
||||
private readonly PackRunTimelineEmitterOptions _options;
|
||||
|
||||
public PackRunTimelineEventEmitter(
|
||||
IPackRunTimelineEventSink sink,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PackRunTimelineEventEmitter> logger,
|
||||
PackRunTimelineEmitterOptions? options = null)
|
||||
{
|
||||
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options ?? PackRunTimelineEmitterOptions.Default;
|
||||
}
|
||||
|
||||
public async Task<PackRunTimelineEmitResult> EmitAsync(
|
||||
PackRunTimelineEvent evt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evt);
|
||||
|
||||
var eventWithReceived = evt.WithReceivedAt(_timeProvider.GetUtcNow());
|
||||
|
||||
try
|
||||
{
|
||||
var result = await EmitWithRetryAsync(eventWithReceived, cancellationToken);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to emit timeline event {EventId} type {EventType} for tenant {TenantId} run {RunId}",
|
||||
evt.EventId, evt.EventType, evt.TenantId, evt.RunId);
|
||||
|
||||
return new PackRunTimelineEmitResult(
|
||||
Success: false,
|
||||
Event: eventWithReceived,
|
||||
Deduplicated: false,
|
||||
Error: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PackRunTimelineBatchEmitResult> EmitBatchAsync(
|
||||
IEnumerable<PackRunTimelineEvent> events,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(events);
|
||||
|
||||
var emitted = 0;
|
||||
var deduplicated = 0;
|
||||
var failed = 0;
|
||||
var errors = new List<string>();
|
||||
|
||||
// Order by occurredAt then eventId for deterministic fan-out
|
||||
var ordered = events
|
||||
.OrderBy(e => e.OccurredAt)
|
||||
.ThenBy(e => e.EventId)
|
||||
.ToList();
|
||||
|
||||
foreach (var evt in ordered)
|
||||
{
|
||||
var result = await EmitAsync(evt, cancellationToken);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
if (result.Deduplicated)
|
||||
deduplicated++;
|
||||
else
|
||||
emitted++;
|
||||
}
|
||||
else
|
||||
{
|
||||
failed++;
|
||||
if (result.Error is not null)
|
||||
errors.Add($"{evt.EventId}: {result.Error}");
|
||||
}
|
||||
}
|
||||
|
||||
return new PackRunTimelineBatchEmitResult(emitted, deduplicated, failed, errors);
|
||||
}
|
||||
|
||||
public Task<PackRunTimelineEmitResult> EmitPackStartedAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string planHash,
|
||||
string? actor = null,
|
||||
string? correlationId = null,
|
||||
string? traceId = null,
|
||||
string? projectId = null,
|
||||
IReadOnlyDictionary<string, string>? attributes = null,
|
||||
PackRunEvidencePointer? evidencePointer = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var attrs = MergeAttributes(attributes, new Dictionary<string, string>
|
||||
{
|
||||
["runId"] = runId,
|
||||
["planHash"] = planHash
|
||||
});
|
||||
|
||||
var evt = PackRunTimelineEvent.Create(
|
||||
tenantId: tenantId,
|
||||
eventType: PackRunEventTypes.PackStarted,
|
||||
source: Source,
|
||||
occurredAt: _timeProvider.GetUtcNow(),
|
||||
runId: runId,
|
||||
planHash: planHash,
|
||||
actor: actor,
|
||||
severity: PackRunEventSeverity.Info,
|
||||
attributes: attrs,
|
||||
correlationId: correlationId,
|
||||
traceId: traceId,
|
||||
projectId: projectId,
|
||||
evidencePointer: evidencePointer);
|
||||
|
||||
return EmitAsync(evt, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<PackRunTimelineEmitResult> EmitPackCompletedAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string planHash,
|
||||
string? actor = null,
|
||||
string? correlationId = null,
|
||||
string? traceId = null,
|
||||
string? projectId = null,
|
||||
IReadOnlyDictionary<string, string>? attributes = null,
|
||||
PackRunEvidencePointer? evidencePointer = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var attrs = MergeAttributes(attributes, new Dictionary<string, string>
|
||||
{
|
||||
["runId"] = runId,
|
||||
["planHash"] = planHash
|
||||
});
|
||||
|
||||
var evt = PackRunTimelineEvent.Create(
|
||||
tenantId: tenantId,
|
||||
eventType: PackRunEventTypes.PackCompleted,
|
||||
source: Source,
|
||||
occurredAt: _timeProvider.GetUtcNow(),
|
||||
runId: runId,
|
||||
planHash: planHash,
|
||||
actor: actor,
|
||||
severity: PackRunEventSeverity.Info,
|
||||
attributes: attrs,
|
||||
correlationId: correlationId,
|
||||
traceId: traceId,
|
||||
projectId: projectId,
|
||||
evidencePointer: evidencePointer);
|
||||
|
||||
return EmitAsync(evt, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<PackRunTimelineEmitResult> EmitPackFailedAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string planHash,
|
||||
string? failureReason = null,
|
||||
string? actor = null,
|
||||
string? correlationId = null,
|
||||
string? traceId = null,
|
||||
string? projectId = null,
|
||||
IReadOnlyDictionary<string, string>? attributes = null,
|
||||
PackRunEvidencePointer? evidencePointer = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var attrDict = new Dictionary<string, string>
|
||||
{
|
||||
["runId"] = runId,
|
||||
["planHash"] = planHash
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(failureReason))
|
||||
{
|
||||
attrDict["failureReason"] = failureReason;
|
||||
}
|
||||
|
||||
var attrs = MergeAttributes(attributes, attrDict);
|
||||
|
||||
var evt = PackRunTimelineEvent.Create(
|
||||
tenantId: tenantId,
|
||||
eventType: PackRunEventTypes.PackFailed,
|
||||
source: Source,
|
||||
occurredAt: _timeProvider.GetUtcNow(),
|
||||
runId: runId,
|
||||
planHash: planHash,
|
||||
actor: actor,
|
||||
severity: PackRunEventSeverity.Error,
|
||||
attributes: attrs,
|
||||
correlationId: correlationId,
|
||||
traceId: traceId,
|
||||
projectId: projectId,
|
||||
payload: failureReason != null ? new { reason = failureReason } : null,
|
||||
evidencePointer: evidencePointer);
|
||||
|
||||
return EmitAsync(evt, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<PackRunTimelineEmitResult> EmitStepStartedAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string planHash,
|
||||
string stepId,
|
||||
int attempt,
|
||||
string? actor = null,
|
||||
string? correlationId = null,
|
||||
string? traceId = null,
|
||||
string? projectId = null,
|
||||
IReadOnlyDictionary<string, string>? attributes = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var attrs = MergeAttributes(attributes, new Dictionary<string, string>
|
||||
{
|
||||
["runId"] = runId,
|
||||
["planHash"] = planHash,
|
||||
["stepId"] = stepId,
|
||||
["attempt"] = attempt.ToString()
|
||||
});
|
||||
|
||||
var evt = PackRunTimelineEvent.Create(
|
||||
tenantId: tenantId,
|
||||
eventType: PackRunEventTypes.StepStarted,
|
||||
source: Source,
|
||||
occurredAt: _timeProvider.GetUtcNow(),
|
||||
runId: runId,
|
||||
planHash: planHash,
|
||||
stepId: stepId,
|
||||
actor: actor,
|
||||
severity: PackRunEventSeverity.Info,
|
||||
attributes: attrs,
|
||||
correlationId: correlationId,
|
||||
traceId: traceId,
|
||||
projectId: projectId,
|
||||
payload: new { stepId, attempt });
|
||||
|
||||
return EmitAsync(evt, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<PackRunTimelineEmitResult> EmitStepCompletedAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string planHash,
|
||||
string stepId,
|
||||
int attempt,
|
||||
double? durationMs = null,
|
||||
string? actor = null,
|
||||
string? correlationId = null,
|
||||
string? traceId = null,
|
||||
string? projectId = null,
|
||||
IReadOnlyDictionary<string, string>? attributes = null,
|
||||
PackRunEvidencePointer? evidencePointer = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var attrDict = new Dictionary<string, string>
|
||||
{
|
||||
["runId"] = runId,
|
||||
["planHash"] = planHash,
|
||||
["stepId"] = stepId,
|
||||
["attempt"] = attempt.ToString()
|
||||
};
|
||||
|
||||
if (durationMs.HasValue)
|
||||
{
|
||||
attrDict["durationMs"] = durationMs.Value.ToString("F2");
|
||||
}
|
||||
|
||||
var attrs = MergeAttributes(attributes, attrDict);
|
||||
|
||||
var evt = PackRunTimelineEvent.Create(
|
||||
tenantId: tenantId,
|
||||
eventType: PackRunEventTypes.StepCompleted,
|
||||
source: Source,
|
||||
occurredAt: _timeProvider.GetUtcNow(),
|
||||
runId: runId,
|
||||
planHash: planHash,
|
||||
stepId: stepId,
|
||||
actor: actor,
|
||||
severity: PackRunEventSeverity.Info,
|
||||
attributes: attrs,
|
||||
correlationId: correlationId,
|
||||
traceId: traceId,
|
||||
projectId: projectId,
|
||||
payload: new { stepId, attempt, durationMs },
|
||||
evidencePointer: evidencePointer);
|
||||
|
||||
return EmitAsync(evt, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<PackRunTimelineEmitResult> EmitStepFailedAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string planHash,
|
||||
string stepId,
|
||||
int attempt,
|
||||
string? error = null,
|
||||
string? actor = null,
|
||||
string? correlationId = null,
|
||||
string? traceId = null,
|
||||
string? projectId = null,
|
||||
IReadOnlyDictionary<string, string>? attributes = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var attrDict = new Dictionary<string, string>
|
||||
{
|
||||
["runId"] = runId,
|
||||
["planHash"] = planHash,
|
||||
["stepId"] = stepId,
|
||||
["attempt"] = attempt.ToString()
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(error))
|
||||
{
|
||||
attrDict["error"] = error;
|
||||
}
|
||||
|
||||
var attrs = MergeAttributes(attributes, attrDict);
|
||||
|
||||
var evt = PackRunTimelineEvent.Create(
|
||||
tenantId: tenantId,
|
||||
eventType: PackRunEventTypes.StepFailed,
|
||||
source: Source,
|
||||
occurredAt: _timeProvider.GetUtcNow(),
|
||||
runId: runId,
|
||||
planHash: planHash,
|
||||
stepId: stepId,
|
||||
actor: actor,
|
||||
severity: PackRunEventSeverity.Error,
|
||||
attributes: attrs,
|
||||
correlationId: correlationId,
|
||||
traceId: traceId,
|
||||
projectId: projectId,
|
||||
payload: new { stepId, attempt, error });
|
||||
|
||||
return EmitAsync(evt, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<PackRunTimelineEmitResult> EmitWithRetryAsync(
|
||||
PackRunTimelineEvent evt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var attempt = 0;
|
||||
var delay = _options.RetryDelay;
|
||||
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sinkResult = await _sink.WriteAsync(evt, cancellationToken);
|
||||
|
||||
if (sinkResult.Deduplicated)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Timeline event {EventId} deduplicated",
|
||||
evt.EventId);
|
||||
|
||||
return new PackRunTimelineEmitResult(
|
||||
Success: true,
|
||||
Event: evt,
|
||||
Deduplicated: true,
|
||||
Error: null);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Emitted timeline event {EventId} type {EventType} tenant {TenantId} run {RunId} seq {Seq}",
|
||||
evt.EventId, evt.EventType, evt.TenantId, evt.RunId, sinkResult.Sequence);
|
||||
|
||||
return new PackRunTimelineEmitResult(
|
||||
Success: true,
|
||||
Event: sinkResult.Sequence.HasValue ? evt.WithSequence(sinkResult.Sequence.Value) : evt,
|
||||
Deduplicated: false,
|
||||
Error: null);
|
||||
}
|
||||
catch (Exception ex) when (attempt < _options.MaxRetries && IsTransient(ex))
|
||||
{
|
||||
attempt++;
|
||||
_logger.LogWarning(ex,
|
||||
"Transient failure emitting timeline event {EventId}, attempt {Attempt}/{MaxRetries}",
|
||||
evt.EventId, attempt, _options.MaxRetries);
|
||||
|
||||
await Task.Delay(delay, cancellationToken);
|
||||
delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> MergeAttributes(
|
||||
IReadOnlyDictionary<string, string>? existing,
|
||||
Dictionary<string, string> additional)
|
||||
{
|
||||
if (existing is null || existing.Count == 0)
|
||||
return additional;
|
||||
|
||||
var merged = new Dictionary<string, string>(existing);
|
||||
foreach (var (key, value) in additional)
|
||||
{
|
||||
merged.TryAdd(key, value);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
private static bool IsTransient(Exception ex)
|
||||
{
|
||||
return ex is TimeoutException or
|
||||
TaskCanceledException or
|
||||
System.Net.Http.HttpRequestException or
|
||||
System.IO.IOException;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for pack run timeline event emitter.
|
||||
/// </summary>
|
||||
public sealed record PackRunTimelineEmitterOptions(
|
||||
/// <summary>Maximum retry attempts for transient failures.</summary>
|
||||
int MaxRetries,
|
||||
|
||||
/// <summary>Base delay between retries.</summary>
|
||||
TimeSpan RetryDelay,
|
||||
|
||||
/// <summary>Whether to include evidence pointers.</summary>
|
||||
bool IncludeEvidencePointers)
|
||||
{
|
||||
/// <summary>Default emitter options.</summary>
|
||||
public static PackRunTimelineEmitterOptions Default => new(
|
||||
MaxRetries: 3,
|
||||
RetryDelay: TimeSpan.FromSeconds(1),
|
||||
IncludeEvidencePointers: true);
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.TaskRunner.Core.Events;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Service for capturing pack run evidence snapshots.
|
||||
/// Per TASKRUN-OBS-53-001.
|
||||
/// </summary>
|
||||
public interface IPackRunEvidenceSnapshotService
|
||||
{
|
||||
/// <summary>
|
||||
/// Captures a run completion snapshot with all materials.
|
||||
/// </summary>
|
||||
Task<PackRunEvidenceSnapshotResult> CaptureRunCompletionAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string planHash,
|
||||
PackRunState state,
|
||||
IReadOnlyList<PackRunStepTranscript>? transcripts = null,
|
||||
IReadOnlyList<PackRunApprovalEvidence>? approvals = null,
|
||||
IReadOnlyList<PackRunPolicyEvidence>? policyEvaluations = null,
|
||||
PackRunEnvironmentDigest? environmentDigest = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Captures a step execution snapshot.
|
||||
/// </summary>
|
||||
Task<PackRunEvidenceSnapshotResult> CaptureStepExecutionAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string planHash,
|
||||
PackRunStepTranscript transcript,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Captures an approval decision snapshot.
|
||||
/// </summary>
|
||||
Task<PackRunEvidenceSnapshotResult> CaptureApprovalDecisionAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string planHash,
|
||||
PackRunApprovalEvidence approval,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Captures a policy evaluation snapshot.
|
||||
/// </summary>
|
||||
Task<PackRunEvidenceSnapshotResult> CapturePolicyEvaluationAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string planHash,
|
||||
PackRunPolicyEvidence evaluation,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of evidence snapshot capture.
|
||||
/// </summary>
|
||||
public sealed record PackRunEvidenceSnapshotResult(
|
||||
/// <summary>Whether capture was successful.</summary>
|
||||
bool Success,
|
||||
|
||||
/// <summary>The captured snapshot.</summary>
|
||||
PackRunEvidenceSnapshot? Snapshot,
|
||||
|
||||
/// <summary>Evidence pointer for timeline events.</summary>
|
||||
PackRunEvidencePointer? EvidencePointer,
|
||||
|
||||
/// <summary>Error message if capture failed.</summary>
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of evidence snapshot service.
|
||||
/// </summary>
|
||||
public sealed class PackRunEvidenceSnapshotService : IPackRunEvidenceSnapshotService
|
||||
{
|
||||
private readonly IPackRunEvidenceStore _store;
|
||||
private readonly IPackRunRedactionGuard _redactionGuard;
|
||||
private readonly IPackRunTimelineEventEmitter? _timelineEmitter;
|
||||
private readonly ILogger<PackRunEvidenceSnapshotService> _logger;
|
||||
private readonly PackRunEvidenceSnapshotOptions _options;
|
||||
|
||||
public PackRunEvidenceSnapshotService(
|
||||
IPackRunEvidenceStore store,
|
||||
IPackRunRedactionGuard redactionGuard,
|
||||
ILogger<PackRunEvidenceSnapshotService> logger,
|
||||
IPackRunTimelineEventEmitter? timelineEmitter = null,
|
||||
PackRunEvidenceSnapshotOptions? options = null)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_redactionGuard = redactionGuard ?? throw new ArgumentNullException(nameof(redactionGuard));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timelineEmitter = timelineEmitter;
|
||||
_options = options ?? PackRunEvidenceSnapshotOptions.Default;
|
||||
}
|
||||
|
||||
public async Task<PackRunEvidenceSnapshotResult> CaptureRunCompletionAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string planHash,
|
||||
PackRunState state,
|
||||
IReadOnlyList<PackRunStepTranscript>? transcripts = null,
|
||||
IReadOnlyList<PackRunApprovalEvidence>? approvals = null,
|
||||
IReadOnlyList<PackRunPolicyEvidence>? policyEvaluations = null,
|
||||
PackRunEnvironmentDigest? environmentDigest = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var materials = new List<PackRunEvidenceMaterial>();
|
||||
|
||||
// Add state summary
|
||||
var stateSummary = CreateStateSummary(state);
|
||||
materials.Add(PackRunEvidenceMaterial.FromJson(
|
||||
"summary",
|
||||
"run-state.json",
|
||||
stateSummary));
|
||||
|
||||
// Add transcripts (redacted)
|
||||
if (transcripts is not null)
|
||||
{
|
||||
foreach (var transcript in transcripts)
|
||||
{
|
||||
var redacted = _redactionGuard.RedactTranscript(transcript);
|
||||
materials.Add(PackRunEvidenceMaterial.FromJson(
|
||||
"transcript",
|
||||
$"{redacted.StepId}.json",
|
||||
redacted,
|
||||
new Dictionary<string, string> { ["stepId"] = redacted.StepId }));
|
||||
}
|
||||
}
|
||||
|
||||
// Add approvals (redacted)
|
||||
if (approvals is not null)
|
||||
{
|
||||
foreach (var approval in approvals)
|
||||
{
|
||||
var redacted = _redactionGuard.RedactApproval(approval);
|
||||
materials.Add(PackRunEvidenceMaterial.FromJson(
|
||||
"approval",
|
||||
$"{redacted.ApprovalId}.json",
|
||||
redacted,
|
||||
new Dictionary<string, string> { ["approvalId"] = redacted.ApprovalId }));
|
||||
}
|
||||
}
|
||||
|
||||
// Add policy evaluations
|
||||
if (policyEvaluations is not null)
|
||||
{
|
||||
foreach (var evaluation in policyEvaluations)
|
||||
{
|
||||
materials.Add(PackRunEvidenceMaterial.FromJson(
|
||||
"policy",
|
||||
$"{evaluation.PolicyName}.json",
|
||||
evaluation,
|
||||
new Dictionary<string, string> { ["policyName"] = evaluation.PolicyName }));
|
||||
}
|
||||
}
|
||||
|
||||
// Add environment digest (redacted)
|
||||
if (environmentDigest is not null)
|
||||
{
|
||||
var redacted = _redactionGuard.RedactEnvironment(environmentDigest);
|
||||
materials.Add(PackRunEvidenceMaterial.FromJson(
|
||||
"environment",
|
||||
"digest.json",
|
||||
redacted));
|
||||
}
|
||||
|
||||
// Create snapshot
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["runId"] = runId,
|
||||
["planHash"] = planHash,
|
||||
["stepCount"] = state.Steps.Count.ToString(),
|
||||
["capturedAt"] = DateTimeOffset.UtcNow.ToString("O")
|
||||
};
|
||||
|
||||
var snapshot = PackRunEvidenceSnapshot.Create(
|
||||
tenantId,
|
||||
runId,
|
||||
planHash,
|
||||
PackRunEvidenceSnapshotKind.RunCompletion,
|
||||
materials,
|
||||
metadata);
|
||||
|
||||
// Store snapshot
|
||||
await _store.StoreAsync(snapshot, cancellationToken);
|
||||
|
||||
var evidencePointer = PackRunEvidencePointer.Bundle(
|
||||
snapshot.SnapshotId,
|
||||
snapshot.RootHash);
|
||||
|
||||
// Emit timeline event if emitter available
|
||||
if (_timelineEmitter is not null)
|
||||
{
|
||||
await _timelineEmitter.EmitAsync(
|
||||
PackRunTimelineEvent.Create(
|
||||
tenantId: tenantId,
|
||||
eventType: "pack.evidence.captured",
|
||||
source: "taskrunner-evidence",
|
||||
occurredAt: DateTimeOffset.UtcNow,
|
||||
runId: runId,
|
||||
planHash: planHash,
|
||||
attributes: new Dictionary<string, string>
|
||||
{
|
||||
["snapshotId"] = snapshot.SnapshotId.ToString(),
|
||||
["rootHash"] = snapshot.RootHash,
|
||||
["materialCount"] = materials.Count.ToString()
|
||||
},
|
||||
evidencePointer: evidencePointer),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Captured run completion evidence for run {RunId} with {MaterialCount} materials, root hash {RootHash}",
|
||||
runId, materials.Count, snapshot.RootHash);
|
||||
|
||||
return new PackRunEvidenceSnapshotResult(
|
||||
Success: true,
|
||||
Snapshot: snapshot,
|
||||
EvidencePointer: evidencePointer,
|
||||
Error: null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to capture run completion evidence for run {RunId}",
|
||||
runId);
|
||||
|
||||
return new PackRunEvidenceSnapshotResult(
|
||||
Success: false,
|
||||
Snapshot: null,
|
||||
EvidencePointer: null,
|
||||
Error: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PackRunEvidenceSnapshotResult> CaptureStepExecutionAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string planHash,
|
||||
PackRunStepTranscript transcript,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var redacted = _redactionGuard.RedactTranscript(transcript);
|
||||
var materials = new List<PackRunEvidenceMaterial>
|
||||
{
|
||||
PackRunEvidenceMaterial.FromJson(
|
||||
"transcript",
|
||||
$"{redacted.StepId}.json",
|
||||
redacted,
|
||||
new Dictionary<string, string> { ["stepId"] = redacted.StepId })
|
||||
};
|
||||
|
||||
// Add artifacts if present
|
||||
if (redacted.Artifacts is not null)
|
||||
{
|
||||
foreach (var artifact in redacted.Artifacts)
|
||||
{
|
||||
materials.Add(new PackRunEvidenceMaterial(
|
||||
Section: "artifact",
|
||||
Path: artifact.Name,
|
||||
Sha256: artifact.Sha256,
|
||||
SizeBytes: artifact.SizeBytes,
|
||||
MediaType: artifact.MediaType,
|
||||
Attributes: new Dictionary<string, string> { ["stepId"] = redacted.StepId }));
|
||||
}
|
||||
}
|
||||
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["runId"] = runId,
|
||||
["planHash"] = planHash,
|
||||
["stepId"] = transcript.StepId,
|
||||
["status"] = transcript.Status,
|
||||
["attempt"] = transcript.Attempt.ToString()
|
||||
};
|
||||
|
||||
var snapshot = PackRunEvidenceSnapshot.Create(
|
||||
tenantId,
|
||||
runId,
|
||||
planHash,
|
||||
PackRunEvidenceSnapshotKind.StepExecution,
|
||||
materials,
|
||||
metadata);
|
||||
|
||||
await _store.StoreAsync(snapshot, cancellationToken);
|
||||
|
||||
var evidencePointer = PackRunEvidencePointer.Bundle(
|
||||
snapshot.SnapshotId,
|
||||
snapshot.RootHash);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Captured step execution evidence for run {RunId} step {StepId}",
|
||||
runId, transcript.StepId);
|
||||
|
||||
return new PackRunEvidenceSnapshotResult(
|
||||
Success: true,
|
||||
Snapshot: snapshot,
|
||||
EvidencePointer: evidencePointer,
|
||||
Error: null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to capture step execution evidence for run {RunId} step {StepId}",
|
||||
runId, transcript.StepId);
|
||||
|
||||
return new PackRunEvidenceSnapshotResult(
|
||||
Success: false,
|
||||
Snapshot: null,
|
||||
EvidencePointer: null,
|
||||
Error: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PackRunEvidenceSnapshotResult> CaptureApprovalDecisionAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string planHash,
|
||||
PackRunApprovalEvidence approval,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var redacted = _redactionGuard.RedactApproval(approval);
|
||||
var materials = new List<PackRunEvidenceMaterial>
|
||||
{
|
||||
PackRunEvidenceMaterial.FromJson(
|
||||
"approval",
|
||||
$"{redacted.ApprovalId}.json",
|
||||
redacted)
|
||||
};
|
||||
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["runId"] = runId,
|
||||
["planHash"] = planHash,
|
||||
["approvalId"] = approval.ApprovalId,
|
||||
["decision"] = approval.Decision,
|
||||
["approver"] = _redactionGuard.RedactIdentity(approval.Approver)
|
||||
};
|
||||
|
||||
var snapshot = PackRunEvidenceSnapshot.Create(
|
||||
tenantId,
|
||||
runId,
|
||||
planHash,
|
||||
PackRunEvidenceSnapshotKind.ApprovalDecision,
|
||||
materials,
|
||||
metadata);
|
||||
|
||||
await _store.StoreAsync(snapshot, cancellationToken);
|
||||
|
||||
var evidencePointer = PackRunEvidencePointer.Bundle(
|
||||
snapshot.SnapshotId,
|
||||
snapshot.RootHash);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Captured approval decision evidence for run {RunId} approval {ApprovalId}",
|
||||
runId, approval.ApprovalId);
|
||||
|
||||
return new PackRunEvidenceSnapshotResult(
|
||||
Success: true,
|
||||
Snapshot: snapshot,
|
||||
EvidencePointer: evidencePointer,
|
||||
Error: null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to capture approval decision evidence for run {RunId}",
|
||||
runId);
|
||||
|
||||
return new PackRunEvidenceSnapshotResult(
|
||||
Success: false,
|
||||
Snapshot: null,
|
||||
EvidencePointer: null,
|
||||
Error: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PackRunEvidenceSnapshotResult> CapturePolicyEvaluationAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string planHash,
|
||||
PackRunPolicyEvidence evaluation,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var materials = new List<PackRunEvidenceMaterial>
|
||||
{
|
||||
PackRunEvidenceMaterial.FromJson(
|
||||
"policy",
|
||||
$"{evaluation.PolicyName}.json",
|
||||
evaluation)
|
||||
};
|
||||
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["runId"] = runId,
|
||||
["planHash"] = planHash,
|
||||
["policyName"] = evaluation.PolicyName,
|
||||
["result"] = evaluation.Result
|
||||
};
|
||||
|
||||
if (evaluation.PolicyVersion is not null)
|
||||
{
|
||||
metadata["policyVersion"] = evaluation.PolicyVersion;
|
||||
}
|
||||
|
||||
var snapshot = PackRunEvidenceSnapshot.Create(
|
||||
tenantId,
|
||||
runId,
|
||||
planHash,
|
||||
PackRunEvidenceSnapshotKind.PolicyEvaluation,
|
||||
materials,
|
||||
metadata);
|
||||
|
||||
await _store.StoreAsync(snapshot, cancellationToken);
|
||||
|
||||
var evidencePointer = PackRunEvidencePointer.Bundle(
|
||||
snapshot.SnapshotId,
|
||||
snapshot.RootHash);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Captured policy evaluation evidence for run {RunId} policy {PolicyName}",
|
||||
runId, evaluation.PolicyName);
|
||||
|
||||
return new PackRunEvidenceSnapshotResult(
|
||||
Success: true,
|
||||
Snapshot: snapshot,
|
||||
EvidencePointer: evidencePointer,
|
||||
Error: null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to capture policy evaluation evidence for run {RunId}",
|
||||
runId);
|
||||
|
||||
return new PackRunEvidenceSnapshotResult(
|
||||
Success: false,
|
||||
Snapshot: null,
|
||||
EvidencePointer: null,
|
||||
Error: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static object CreateStateSummary(PackRunState state)
|
||||
{
|
||||
var stepSummaries = state.Steps.Values.Select(s => new
|
||||
{
|
||||
s.StepId,
|
||||
Kind = s.Kind.ToString(),
|
||||
s.Enabled,
|
||||
Status = s.Status.ToString(),
|
||||
s.Attempts,
|
||||
s.StatusReason
|
||||
}).ToList();
|
||||
|
||||
return new
|
||||
{
|
||||
state.RunId,
|
||||
state.PlanHash,
|
||||
state.RequestedAt,
|
||||
state.CreatedAt,
|
||||
state.UpdatedAt,
|
||||
StepCount = state.Steps.Count,
|
||||
Steps = stepSummaries
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for evidence snapshot service.
|
||||
/// </summary>
|
||||
public sealed record PackRunEvidenceSnapshotOptions(
|
||||
/// <summary>Maximum transcript output length before truncation.</summary>
|
||||
int MaxTranscriptOutputLength,
|
||||
|
||||
/// <summary>Maximum comment length before truncation.</summary>
|
||||
int MaxCommentLength,
|
||||
|
||||
/// <summary>Whether to include step outputs.</summary>
|
||||
bool IncludeStepOutput,
|
||||
|
||||
/// <summary>Whether to emit timeline events.</summary>
|
||||
bool EmitTimelineEvents)
|
||||
{
|
||||
/// <summary>Default options.</summary>
|
||||
public static PackRunEvidenceSnapshotOptions Default => new(
|
||||
MaxTranscriptOutputLength: 64 * 1024, // 64KB
|
||||
MaxCommentLength: 4096,
|
||||
IncludeStepOutput: true,
|
||||
EmitTimelineEvents: true);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
namespace StellaOps.TaskRunner.Core.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Store for pack run evidence snapshots.
|
||||
/// Per TASKRUN-OBS-53-001.
|
||||
/// </summary>
|
||||
public interface IPackRunEvidenceStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores an evidence snapshot.
|
||||
/// </summary>
|
||||
Task StoreAsync(
|
||||
PackRunEvidenceSnapshot snapshot,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves an evidence snapshot by ID.
|
||||
/// </summary>
|
||||
Task<PackRunEvidenceSnapshot?> GetAsync(
|
||||
Guid snapshotId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists evidence snapshots for a run.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PackRunEvidenceSnapshot>> ListByRunAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists evidence snapshots by kind for a run.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PackRunEvidenceSnapshot>> ListByKindAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
PackRunEvidenceSnapshotKind kind,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the integrity of a snapshot by recomputing its Merkle root.
|
||||
/// </summary>
|
||||
Task<PackRunEvidenceVerificationResult> VerifyAsync(
|
||||
Guid snapshotId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of evidence verification.
|
||||
/// </summary>
|
||||
public sealed record PackRunEvidenceVerificationResult(
|
||||
/// <summary>Whether verification passed.</summary>
|
||||
bool Valid,
|
||||
|
||||
/// <summary>The snapshot that was verified.</summary>
|
||||
Guid SnapshotId,
|
||||
|
||||
/// <summary>Expected root hash.</summary>
|
||||
string ExpectedHash,
|
||||
|
||||
/// <summary>Computed root hash.</summary>
|
||||
string ComputedHash,
|
||||
|
||||
/// <summary>Error message if verification failed.</summary>
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// In-memory evidence store for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryPackRunEvidenceStore : IPackRunEvidenceStore
|
||||
{
|
||||
private readonly Dictionary<Guid, PackRunEvidenceSnapshot> _snapshots = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public Task StoreAsync(
|
||||
PackRunEvidenceSnapshot snapshot,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_snapshots[snapshot.SnapshotId] = snapshot;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<PackRunEvidenceSnapshot?> GetAsync(
|
||||
Guid snapshotId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_snapshots.TryGetValue(snapshotId, out var snapshot);
|
||||
return Task.FromResult(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PackRunEvidenceSnapshot>> ListByRunAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var results = _snapshots.Values
|
||||
.Where(s => s.TenantId == tenantId && s.RunId == runId)
|
||||
.OrderBy(s => s.CreatedAt)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<PackRunEvidenceSnapshot>>(results);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PackRunEvidenceSnapshot>> ListByKindAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
PackRunEvidenceSnapshotKind kind,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var results = _snapshots.Values
|
||||
.Where(s => s.TenantId == tenantId && s.RunId == runId && s.Kind == kind)
|
||||
.OrderBy(s => s.CreatedAt)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<PackRunEvidenceSnapshot>>(results);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<PackRunEvidenceVerificationResult> VerifyAsync(
|
||||
Guid snapshotId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_snapshots.TryGetValue(snapshotId, out var snapshot))
|
||||
{
|
||||
return Task.FromResult(new PackRunEvidenceVerificationResult(
|
||||
Valid: false,
|
||||
SnapshotId: snapshotId,
|
||||
ExpectedHash: string.Empty,
|
||||
ComputedHash: string.Empty,
|
||||
Error: "Snapshot not found"));
|
||||
}
|
||||
|
||||
// Recompute by creating a new snapshot with same materials
|
||||
var recomputed = PackRunEvidenceSnapshot.Create(
|
||||
snapshot.TenantId,
|
||||
snapshot.RunId,
|
||||
snapshot.PlanHash,
|
||||
snapshot.Kind,
|
||||
snapshot.Materials,
|
||||
snapshot.Metadata);
|
||||
|
||||
var valid = snapshot.RootHash == recomputed.RootHash;
|
||||
|
||||
return Task.FromResult(new PackRunEvidenceVerificationResult(
|
||||
Valid: valid,
|
||||
SnapshotId: snapshotId,
|
||||
ExpectedHash: snapshot.RootHash,
|
||||
ComputedHash: recomputed.RootHash,
|
||||
Error: valid ? null : "Root hash mismatch"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets all snapshots (for testing).</summary>
|
||||
public IReadOnlyList<PackRunEvidenceSnapshot> GetAll()
|
||||
{
|
||||
lock (_lock) { return _snapshots.Values.ToList(); }
|
||||
}
|
||||
|
||||
/// <summary>Clears all snapshots (for testing).</summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_lock) { _snapshots.Clear(); }
|
||||
}
|
||||
|
||||
/// <summary>Gets snapshot count.</summary>
|
||||
public int Count
|
||||
{
|
||||
get { lock (_lock) { return _snapshots.Count; } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Redaction guard for sensitive data in evidence snapshots.
|
||||
/// Per TASKRUN-OBS-53-001.
|
||||
/// </summary>
|
||||
public interface IPackRunRedactionGuard
|
||||
{
|
||||
/// <summary>
|
||||
/// Redacts sensitive data from a step transcript.
|
||||
/// </summary>
|
||||
PackRunStepTranscript RedactTranscript(PackRunStepTranscript transcript);
|
||||
|
||||
/// <summary>
|
||||
/// Redacts sensitive data from an approval evidence record.
|
||||
/// </summary>
|
||||
PackRunApprovalEvidence RedactApproval(PackRunApprovalEvidence approval);
|
||||
|
||||
/// <summary>
|
||||
/// Redacts sensitive data from an environment digest.
|
||||
/// </summary>
|
||||
PackRunEnvironmentDigest RedactEnvironment(PackRunEnvironmentDigest digest);
|
||||
|
||||
/// <summary>
|
||||
/// Redacts an identity string (e.g., email, username).
|
||||
/// </summary>
|
||||
string RedactIdentity(string identity);
|
||||
|
||||
/// <summary>
|
||||
/// Redacts a string value that may contain secrets.
|
||||
/// </summary>
|
||||
string RedactValue(string value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for redaction guard.
|
||||
/// </summary>
|
||||
public sealed record PackRunRedactionGuardOptions(
|
||||
/// <summary>Patterns that indicate sensitive variable names.</summary>
|
||||
IReadOnlyList<string> SensitiveVariablePatterns,
|
||||
|
||||
/// <summary>Patterns that indicate sensitive content in output.</summary>
|
||||
IReadOnlyList<string> SensitiveContentPatterns,
|
||||
|
||||
/// <summary>Whether to hash redacted values for correlation.</summary>
|
||||
bool HashRedactedValues,
|
||||
|
||||
/// <summary>Maximum length of output before truncation.</summary>
|
||||
int MaxOutputLength,
|
||||
|
||||
/// <summary>Whether to preserve email domain.</summary>
|
||||
bool PreserveEmailDomain)
|
||||
{
|
||||
/// <summary>Default redaction options.</summary>
|
||||
public static PackRunRedactionGuardOptions Default => new(
|
||||
SensitiveVariablePatterns: new[]
|
||||
{
|
||||
"(?i)password",
|
||||
"(?i)secret",
|
||||
"(?i)token",
|
||||
"(?i)api_key",
|
||||
"(?i)apikey",
|
||||
"(?i)auth",
|
||||
"(?i)credential",
|
||||
"(?i)private_key",
|
||||
"(?i)privatekey",
|
||||
"(?i)access_key",
|
||||
"(?i)accesskey",
|
||||
"(?i)connection_string",
|
||||
"(?i)connectionstring"
|
||||
},
|
||||
SensitiveContentPatterns: new[]
|
||||
{
|
||||
@"(?i)bearer\s+[a-zA-Z0-9\-_.]+",
|
||||
@"(?i)basic\s+[a-zA-Z0-9+/=]+",
|
||||
@"-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----",
|
||||
@"(?i)password\s*[=:]\s*\S+",
|
||||
@"(?i)secret\s*[=:]\s*\S+",
|
||||
@"(?i)token\s*[=:]\s*\S+"
|
||||
},
|
||||
HashRedactedValues: true,
|
||||
MaxOutputLength: 64 * 1024,
|
||||
PreserveEmailDomain: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of redaction guard.
|
||||
/// </summary>
|
||||
public sealed partial class PackRunRedactionGuard : IPackRunRedactionGuard
|
||||
{
|
||||
private const string RedactedPlaceholder = "[REDACTED]";
|
||||
private const string TruncatedSuffix = "...[TRUNCATED]";
|
||||
|
||||
private readonly PackRunRedactionGuardOptions _options;
|
||||
private readonly List<Regex> _sensitiveVarPatterns;
|
||||
private readonly List<Regex> _sensitiveContentPatterns;
|
||||
|
||||
public PackRunRedactionGuard(PackRunRedactionGuardOptions? options = null)
|
||||
{
|
||||
_options = options ?? PackRunRedactionGuardOptions.Default;
|
||||
_sensitiveVarPatterns = _options.SensitiveVariablePatterns
|
||||
.Select(p => new Regex(p, RegexOptions.Compiled))
|
||||
.ToList();
|
||||
_sensitiveContentPatterns = _options.SensitiveContentPatterns
|
||||
.Select(p => new Regex(p, RegexOptions.Compiled))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public PackRunStepTranscript RedactTranscript(PackRunStepTranscript transcript)
|
||||
{
|
||||
var redactedOutput = transcript.Output is not null
|
||||
? RedactOutput(transcript.Output)
|
||||
: null;
|
||||
|
||||
var redactedError = transcript.Error is not null
|
||||
? RedactOutput(transcript.Error)
|
||||
: null;
|
||||
|
||||
var redactedEnvDigest = transcript.EnvironmentDigest is not null
|
||||
? RedactEnvDigestString(transcript.EnvironmentDigest)
|
||||
: null;
|
||||
|
||||
return transcript with
|
||||
{
|
||||
Output = redactedOutput,
|
||||
Error = redactedError,
|
||||
EnvironmentDigest = redactedEnvDigest
|
||||
};
|
||||
}
|
||||
|
||||
public PackRunApprovalEvidence RedactApproval(PackRunApprovalEvidence approval)
|
||||
{
|
||||
var redactedApprover = RedactIdentity(approval.Approver);
|
||||
var redactedComments = approval.Comments is not null
|
||||
? RedactOutput(approval.Comments)
|
||||
: null;
|
||||
|
||||
var redactedGrantedBy = approval.GrantedBy?.Select(RedactIdentity).ToList();
|
||||
|
||||
return approval with
|
||||
{
|
||||
Approver = redactedApprover,
|
||||
Comments = redactedComments,
|
||||
GrantedBy = redactedGrantedBy
|
||||
};
|
||||
}
|
||||
|
||||
public PackRunEnvironmentDigest RedactEnvironment(PackRunEnvironmentDigest digest)
|
||||
{
|
||||
// Seeds are already expected to be redacted or hashed
|
||||
// Environment variable names are kept, values should not be present
|
||||
// Tool images are public information
|
||||
return digest;
|
||||
}
|
||||
|
||||
public string RedactIdentity(string identity)
|
||||
{
|
||||
if (string.IsNullOrEmpty(identity))
|
||||
return identity;
|
||||
|
||||
// Check if it's an email
|
||||
if (identity.Contains('@'))
|
||||
{
|
||||
var parts = identity.Split('@');
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
var localPart = parts[0];
|
||||
var domain = parts[1];
|
||||
|
||||
var redactedLocal = localPart.Length <= 2
|
||||
? RedactedPlaceholder
|
||||
: $"{localPart[0]}***{localPart[^1]}";
|
||||
|
||||
if (_options.PreserveEmailDomain)
|
||||
{
|
||||
return $"{redactedLocal}@{domain}";
|
||||
}
|
||||
return $"{redactedLocal}@[DOMAIN]";
|
||||
}
|
||||
}
|
||||
|
||||
// For non-email identities, hash if configured
|
||||
if (_options.HashRedactedValues)
|
||||
{
|
||||
return $"[USER:{ComputeShortHash(identity)}]";
|
||||
}
|
||||
|
||||
return RedactedPlaceholder;
|
||||
}
|
||||
|
||||
public string RedactValue(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return value;
|
||||
|
||||
if (_options.HashRedactedValues)
|
||||
{
|
||||
return $"[HASH:{ComputeShortHash(value)}]";
|
||||
}
|
||||
|
||||
return RedactedPlaceholder;
|
||||
}
|
||||
|
||||
private string RedactOutput(string output)
|
||||
{
|
||||
if (string.IsNullOrEmpty(output))
|
||||
return output;
|
||||
|
||||
var result = output;
|
||||
|
||||
// Apply content pattern redaction
|
||||
foreach (var pattern in _sensitiveContentPatterns)
|
||||
{
|
||||
result = pattern.Replace(result, match =>
|
||||
{
|
||||
if (_options.HashRedactedValues)
|
||||
{
|
||||
return $"[REDACTED:{ComputeShortHash(match.Value)}]";
|
||||
}
|
||||
return RedactedPlaceholder;
|
||||
});
|
||||
}
|
||||
|
||||
// Truncate if too long
|
||||
if (result.Length > _options.MaxOutputLength)
|
||||
{
|
||||
result = result[..(_options.MaxOutputLength - TruncatedSuffix.Length)] + TruncatedSuffix;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private string RedactEnvDigestString(string digest)
|
||||
{
|
||||
// Environment digest is typically already a hash, preserve it
|
||||
return digest;
|
||||
}
|
||||
|
||||
private static string ComputeShortHash(string value)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
// Return first 8 characters of hex hash
|
||||
return Convert.ToHexString(hash)[..8].ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// No-op redaction guard for testing (preserves all data).
|
||||
/// </summary>
|
||||
public sealed class NoOpPackRunRedactionGuard : IPackRunRedactionGuard
|
||||
{
|
||||
public static NoOpPackRunRedactionGuard Instance { get; } = new();
|
||||
|
||||
private NoOpPackRunRedactionGuard() { }
|
||||
|
||||
public PackRunStepTranscript RedactTranscript(PackRunStepTranscript transcript) => transcript;
|
||||
|
||||
public PackRunApprovalEvidence RedactApproval(PackRunApprovalEvidence approval) => approval;
|
||||
|
||||
public PackRunEnvironmentDigest RedactEnvironment(PackRunEnvironmentDigest digest) => digest;
|
||||
|
||||
public string RedactIdentity(string identity) => identity;
|
||||
|
||||
public string RedactValue(string value) => value;
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Evidence snapshot for pack run execution.
|
||||
/// Per TASKRUN-OBS-53-001.
|
||||
/// </summary>
|
||||
public sealed record PackRunEvidenceSnapshot(
|
||||
/// <summary>Unique snapshot identifier.</summary>
|
||||
Guid SnapshotId,
|
||||
|
||||
/// <summary>Tenant scope.</summary>
|
||||
string TenantId,
|
||||
|
||||
/// <summary>Run ID this snapshot belongs to.</summary>
|
||||
string RunId,
|
||||
|
||||
/// <summary>Plan hash that was executed.</summary>
|
||||
string PlanHash,
|
||||
|
||||
/// <summary>When the snapshot was created.</summary>
|
||||
DateTimeOffset CreatedAt,
|
||||
|
||||
/// <summary>Snapshot kind.</summary>
|
||||
PackRunEvidenceSnapshotKind Kind,
|
||||
|
||||
/// <summary>Materials included in this snapshot.</summary>
|
||||
IReadOnlyList<PackRunEvidenceMaterial> Materials,
|
||||
|
||||
/// <summary>Computed Merkle root hash of all materials.</summary>
|
||||
string RootHash,
|
||||
|
||||
/// <summary>Snapshot metadata.</summary>
|
||||
IReadOnlyDictionary<string, string>? Metadata)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new snapshot with computed root hash.
|
||||
/// </summary>
|
||||
public static PackRunEvidenceSnapshot Create(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string planHash,
|
||||
PackRunEvidenceSnapshotKind kind,
|
||||
IReadOnlyList<PackRunEvidenceMaterial> materials,
|
||||
IReadOnlyDictionary<string, string>? metadata = null)
|
||||
{
|
||||
var rootHash = ComputeMerkleRoot(materials);
|
||||
|
||||
return new PackRunEvidenceSnapshot(
|
||||
SnapshotId: Guid.NewGuid(),
|
||||
TenantId: tenantId,
|
||||
RunId: runId,
|
||||
PlanHash: planHash,
|
||||
CreatedAt: DateTimeOffset.UtcNow,
|
||||
Kind: kind,
|
||||
Materials: materials,
|
||||
RootHash: rootHash,
|
||||
Metadata: metadata);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes Merkle root from materials.
|
||||
/// </summary>
|
||||
private static string ComputeMerkleRoot(IReadOnlyList<PackRunEvidenceMaterial> materials)
|
||||
{
|
||||
if (materials.Count == 0)
|
||||
{
|
||||
// Empty root: 64 zeros
|
||||
return "sha256:" + new string('0', 64);
|
||||
}
|
||||
|
||||
// Sort materials by canonical path for determinism
|
||||
var sorted = materials
|
||||
.OrderBy(m => m.Section, StringComparer.Ordinal)
|
||||
.ThenBy(m => m.Path, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// Build leaves from material hashes
|
||||
var leaves = sorted.Select(m => m.Sha256).ToList();
|
||||
|
||||
// Compute Merkle root
|
||||
while (leaves.Count > 1)
|
||||
{
|
||||
var nextLevel = new List<string>();
|
||||
for (var i = 0; i < leaves.Count; i += 2)
|
||||
{
|
||||
if (i + 1 < leaves.Count)
|
||||
{
|
||||
nextLevel.Add(HashPair(leaves[i], leaves[i + 1]));
|
||||
}
|
||||
else
|
||||
{
|
||||
nextLevel.Add(HashPair(leaves[i], leaves[i]));
|
||||
}
|
||||
}
|
||||
leaves = nextLevel;
|
||||
}
|
||||
|
||||
return leaves[0];
|
||||
}
|
||||
|
||||
private static string HashPair(string left, string right)
|
||||
{
|
||||
var combined = left + right;
|
||||
var bytes = Encoding.UTF8.GetBytes(combined);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes to JSON.
|
||||
/// </summary>
|
||||
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes from JSON.
|
||||
/// </summary>
|
||||
public static PackRunEvidenceSnapshot? FromJson(string json)
|
||||
=> JsonSerializer.Deserialize<PackRunEvidenceSnapshot>(json, JsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kind of pack run evidence snapshot.
|
||||
/// </summary>
|
||||
public enum PackRunEvidenceSnapshotKind
|
||||
{
|
||||
/// <summary>Run completion snapshot.</summary>
|
||||
RunCompletion,
|
||||
|
||||
/// <summary>Step execution snapshot.</summary>
|
||||
StepExecution,
|
||||
|
||||
/// <summary>Approval decision snapshot.</summary>
|
||||
ApprovalDecision,
|
||||
|
||||
/// <summary>Policy evaluation snapshot.</summary>
|
||||
PolicyEvaluation,
|
||||
|
||||
/// <summary>Artifact manifest snapshot.</summary>
|
||||
ArtifactManifest,
|
||||
|
||||
/// <summary>Environment digest snapshot.</summary>
|
||||
EnvironmentDigest
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Material included in evidence snapshot.
|
||||
/// </summary>
|
||||
public sealed record PackRunEvidenceMaterial(
|
||||
/// <summary>Section (e.g., "transcript", "artifact", "policy").</summary>
|
||||
string Section,
|
||||
|
||||
/// <summary>Path within section.</summary>
|
||||
string Path,
|
||||
|
||||
/// <summary>SHA-256 digest of content.</summary>
|
||||
string Sha256,
|
||||
|
||||
/// <summary>Size in bytes.</summary>
|
||||
long SizeBytes,
|
||||
|
||||
/// <summary>Media type.</summary>
|
||||
string MediaType,
|
||||
|
||||
/// <summary>Custom attributes.</summary>
|
||||
IReadOnlyDictionary<string, string>? Attributes)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates material from content bytes.
|
||||
/// </summary>
|
||||
public static PackRunEvidenceMaterial FromContent(
|
||||
string section,
|
||||
string path,
|
||||
byte[] content,
|
||||
string mediaType = "application/octet-stream",
|
||||
IReadOnlyDictionary<string, string>? attributes = null)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
var sha256 = $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
|
||||
return new PackRunEvidenceMaterial(
|
||||
Section: section,
|
||||
Path: path,
|
||||
Sha256: sha256,
|
||||
SizeBytes: content.Length,
|
||||
MediaType: mediaType,
|
||||
Attributes: attributes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates material from string content.
|
||||
/// </summary>
|
||||
public static PackRunEvidenceMaterial FromString(
|
||||
string section,
|
||||
string path,
|
||||
string content,
|
||||
string mediaType = "text/plain",
|
||||
IReadOnlyDictionary<string, string>? attributes = null)
|
||||
{
|
||||
return FromContent(section, path, Encoding.UTF8.GetBytes(content), mediaType, attributes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates material from JSON object.
|
||||
/// </summary>
|
||||
public static PackRunEvidenceMaterial FromJson<T>(
|
||||
string section,
|
||||
string path,
|
||||
T obj,
|
||||
IReadOnlyDictionary<string, string>? attributes = null)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(obj, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
return FromString(section, path, json, "application/json", attributes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical path for ordering.
|
||||
/// </summary>
|
||||
public string CanonicalPath => $"{Section}/{Path}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Step transcript for evidence capture.
|
||||
/// </summary>
|
||||
public sealed record PackRunStepTranscript(
|
||||
/// <summary>Step identifier.</summary>
|
||||
string StepId,
|
||||
|
||||
/// <summary>Step kind.</summary>
|
||||
string Kind,
|
||||
|
||||
/// <summary>Execution start time.</summary>
|
||||
DateTimeOffset StartedAt,
|
||||
|
||||
/// <summary>Execution end time.</summary>
|
||||
DateTimeOffset? EndedAt,
|
||||
|
||||
/// <summary>Final status.</summary>
|
||||
string Status,
|
||||
|
||||
/// <summary>Attempt number.</summary>
|
||||
int Attempt,
|
||||
|
||||
/// <summary>Duration in milliseconds.</summary>
|
||||
double? DurationMs,
|
||||
|
||||
/// <summary>Output (redacted if needed).</summary>
|
||||
string? Output,
|
||||
|
||||
/// <summary>Error message (redacted if needed).</summary>
|
||||
string? Error,
|
||||
|
||||
/// <summary>Environment variables digest.</summary>
|
||||
string? EnvironmentDigest,
|
||||
|
||||
/// <summary>Artifacts produced.</summary>
|
||||
IReadOnlyList<PackRunArtifactReference>? Artifacts);
|
||||
|
||||
/// <summary>
|
||||
/// Reference to artifact in evidence.
|
||||
/// </summary>
|
||||
public sealed record PackRunArtifactReference(
|
||||
/// <summary>Artifact name.</summary>
|
||||
string Name,
|
||||
|
||||
/// <summary>SHA-256 digest.</summary>
|
||||
string Sha256,
|
||||
|
||||
/// <summary>Size in bytes.</summary>
|
||||
long SizeBytes,
|
||||
|
||||
/// <summary>Media type.</summary>
|
||||
string MediaType);
|
||||
|
||||
/// <summary>
|
||||
/// Approval record for evidence.
|
||||
/// </summary>
|
||||
public sealed record PackRunApprovalEvidence(
|
||||
/// <summary>Approval identifier.</summary>
|
||||
string ApprovalId,
|
||||
|
||||
/// <summary>Approver identity.</summary>
|
||||
string Approver,
|
||||
|
||||
/// <summary>When approved.</summary>
|
||||
DateTimeOffset ApprovedAt,
|
||||
|
||||
/// <summary>Approval decision.</summary>
|
||||
string Decision,
|
||||
|
||||
/// <summary>Required grants.</summary>
|
||||
IReadOnlyList<string> RequiredGrants,
|
||||
|
||||
/// <summary>Granted by.</summary>
|
||||
IReadOnlyList<string>? GrantedBy,
|
||||
|
||||
/// <summary>Comments (redacted if needed).</summary>
|
||||
string? Comments);
|
||||
|
||||
/// <summary>
|
||||
/// Policy evaluation record for evidence.
|
||||
/// </summary>
|
||||
public sealed record PackRunPolicyEvidence(
|
||||
/// <summary>Policy name.</summary>
|
||||
string PolicyName,
|
||||
|
||||
/// <summary>Policy version.</summary>
|
||||
string? PolicyVersion,
|
||||
|
||||
/// <summary>Evaluation result.</summary>
|
||||
string Result,
|
||||
|
||||
/// <summary>When evaluated.</summary>
|
||||
DateTimeOffset EvaluatedAt,
|
||||
|
||||
/// <summary>Evaluation duration in milliseconds.</summary>
|
||||
double DurationMs,
|
||||
|
||||
/// <summary>Matched rules.</summary>
|
||||
IReadOnlyList<string>? MatchedRules,
|
||||
|
||||
/// <summary>Policy digest for reproducibility.</summary>
|
||||
string? PolicyDigest);
|
||||
|
||||
/// <summary>
|
||||
/// Environment digest for evidence.
|
||||
/// </summary>
|
||||
public sealed record PackRunEnvironmentDigest(
|
||||
/// <summary>When digest was computed.</summary>
|
||||
DateTimeOffset ComputedAt,
|
||||
|
||||
/// <summary>Tool image digests (name -> sha256).</summary>
|
||||
IReadOnlyDictionary<string, string> ToolImages,
|
||||
|
||||
/// <summary>Seed values (redacted).</summary>
|
||||
IReadOnlyDictionary<string, string>? Seeds,
|
||||
|
||||
/// <summary>Environment variables (redacted).</summary>
|
||||
IReadOnlyList<string>? EnvironmentVariableNames,
|
||||
|
||||
/// <summary>Combined digest of all inputs.</summary>
|
||||
string InputsDigest);
|
||||
@@ -0,0 +1,710 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.TaskRunner.Core.Events;
|
||||
using StellaOps.TaskRunner.Core.Evidence;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Execution.Simulation;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for pack run evidence snapshot domain model, store, redaction guard, and service.
|
||||
/// Per TASKRUN-OBS-53-001.
|
||||
/// </summary>
|
||||
public sealed class PackRunEvidenceSnapshotTests
|
||||
{
|
||||
private const string TestTenantId = "test-tenant";
|
||||
private const string TestRunId = "run-12345";
|
||||
private const string TestPlanHash = "sha256:abc123def456789012345678901234567890123456789012345678901234";
|
||||
private const string TestStepId = "plan-step";
|
||||
|
||||
#region PackRunEvidenceSnapshot Tests
|
||||
|
||||
[Fact]
|
||||
public void Create_WithMaterials_ComputesMerkleRoot()
|
||||
{
|
||||
// Arrange
|
||||
var materials = new List<PackRunEvidenceMaterial>
|
||||
{
|
||||
PackRunEvidenceMaterial.FromString("transcript", "step-001.json", "{\"stepId\":\"step-001\"}"),
|
||||
PackRunEvidenceMaterial.FromString("transcript", "step-002.json", "{\"stepId\":\"step-002\"}")
|
||||
};
|
||||
|
||||
// Act
|
||||
var snapshot = PackRunEvidenceSnapshot.Create(
|
||||
TestTenantId,
|
||||
TestRunId,
|
||||
TestPlanHash,
|
||||
PackRunEvidenceSnapshotKind.RunCompletion,
|
||||
materials);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(Guid.Empty, snapshot.SnapshotId);
|
||||
Assert.Equal(TestTenantId, snapshot.TenantId);
|
||||
Assert.Equal(TestRunId, snapshot.RunId);
|
||||
Assert.Equal(TestPlanHash, snapshot.PlanHash);
|
||||
Assert.Equal(PackRunEvidenceSnapshotKind.RunCompletion, snapshot.Kind);
|
||||
Assert.Equal(2, snapshot.Materials.Count);
|
||||
Assert.StartsWith("sha256:", snapshot.RootHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithEmptyMaterials_ReturnsZeroHash()
|
||||
{
|
||||
// Act
|
||||
var snapshot = PackRunEvidenceSnapshot.Create(
|
||||
TestTenantId,
|
||||
TestRunId,
|
||||
TestPlanHash,
|
||||
PackRunEvidenceSnapshotKind.RunCompletion,
|
||||
new List<PackRunEvidenceMaterial>());
|
||||
|
||||
// Assert
|
||||
Assert.Equal("sha256:" + new string('0', 64), snapshot.RootHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithMetadata_StoresMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["key1"] = "value1",
|
||||
["key2"] = "value2"
|
||||
};
|
||||
|
||||
// Act
|
||||
var snapshot = PackRunEvidenceSnapshot.Create(
|
||||
TestTenantId,
|
||||
TestRunId,
|
||||
TestPlanHash,
|
||||
PackRunEvidenceSnapshotKind.StepExecution,
|
||||
new List<PackRunEvidenceMaterial>(),
|
||||
metadata);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(snapshot.Metadata);
|
||||
Assert.Equal("value1", snapshot.Metadata["key1"]);
|
||||
Assert.Equal("value2", snapshot.Metadata["key2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_SameMaterials_ProducesDeterministicHash()
|
||||
{
|
||||
// Arrange
|
||||
var materials = new List<PackRunEvidenceMaterial>
|
||||
{
|
||||
PackRunEvidenceMaterial.FromString("transcript", "step-001.json", "{\"data\":\"test\"}")
|
||||
};
|
||||
|
||||
// Act
|
||||
var snapshot1 = PackRunEvidenceSnapshot.Create(
|
||||
TestTenantId, TestRunId, TestPlanHash,
|
||||
PackRunEvidenceSnapshotKind.StepExecution, materials);
|
||||
|
||||
var snapshot2 = PackRunEvidenceSnapshot.Create(
|
||||
TestTenantId, TestRunId, TestPlanHash,
|
||||
PackRunEvidenceSnapshotKind.StepExecution, materials);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(snapshot1.RootHash, snapshot2.RootHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_MaterialOrderDoesNotAffectHash()
|
||||
{
|
||||
// Arrange - materials in different order
|
||||
var materials1 = new List<PackRunEvidenceMaterial>
|
||||
{
|
||||
PackRunEvidenceMaterial.FromString("transcript", "a.json", "{}"),
|
||||
PackRunEvidenceMaterial.FromString("transcript", "b.json", "{}")
|
||||
};
|
||||
|
||||
var materials2 = new List<PackRunEvidenceMaterial>
|
||||
{
|
||||
PackRunEvidenceMaterial.FromString("transcript", "b.json", "{}"),
|
||||
PackRunEvidenceMaterial.FromString("transcript", "a.json", "{}")
|
||||
};
|
||||
|
||||
// Act
|
||||
var snapshot1 = PackRunEvidenceSnapshot.Create(
|
||||
TestTenantId, TestRunId, TestPlanHash,
|
||||
PackRunEvidenceSnapshotKind.RunCompletion, materials1);
|
||||
|
||||
var snapshot2 = PackRunEvidenceSnapshot.Create(
|
||||
TestTenantId, TestRunId, TestPlanHash,
|
||||
PackRunEvidenceSnapshotKind.RunCompletion, materials2);
|
||||
|
||||
// Assert - hash should be same due to canonical ordering
|
||||
Assert.Equal(snapshot1.RootHash, snapshot2.RootHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_AndFromJson_RoundTrips()
|
||||
{
|
||||
// Arrange
|
||||
var materials = new List<PackRunEvidenceMaterial>
|
||||
{
|
||||
PackRunEvidenceMaterial.FromString("test", "file.txt", "content")
|
||||
};
|
||||
var snapshot = PackRunEvidenceSnapshot.Create(
|
||||
TestTenantId, TestRunId, TestPlanHash,
|
||||
PackRunEvidenceSnapshotKind.RunCompletion, materials);
|
||||
|
||||
// Act
|
||||
var json = snapshot.ToJson();
|
||||
var restored = PackRunEvidenceSnapshot.FromJson(json);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(restored);
|
||||
Assert.Equal(snapshot.SnapshotId, restored.SnapshotId);
|
||||
Assert.Equal(snapshot.RootHash, restored.RootHash);
|
||||
Assert.Equal(snapshot.TenantId, restored.TenantId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PackRunEvidenceMaterial Tests
|
||||
|
||||
[Fact]
|
||||
public void FromString_ComputesSha256Hash()
|
||||
{
|
||||
// Act
|
||||
var material = PackRunEvidenceMaterial.FromString(
|
||||
"transcript", "output.txt", "Hello, World!");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("transcript", material.Section);
|
||||
Assert.Equal("output.txt", material.Path);
|
||||
Assert.StartsWith("sha256:", material.Sha256);
|
||||
Assert.Equal("text/plain", material.MediaType);
|
||||
Assert.Equal(13, material.SizeBytes); // "Hello, World!" is 13 bytes
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_ComputesSha256Hash()
|
||||
{
|
||||
// Arrange
|
||||
var obj = new { stepId = "step-001", status = "completed" };
|
||||
|
||||
// Act
|
||||
var material = PackRunEvidenceMaterial.FromJson("transcript", "step.json", obj);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("transcript", material.Section);
|
||||
Assert.Equal("step.json", material.Path);
|
||||
Assert.StartsWith("sha256:", material.Sha256);
|
||||
Assert.Equal("application/json", material.MediaType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromContent_WithAttributes_StoresAttributes()
|
||||
{
|
||||
// Arrange
|
||||
var attributes = new Dictionary<string, string> { ["stepId"] = "step-001" };
|
||||
|
||||
// Act
|
||||
var material = PackRunEvidenceMaterial.FromContent(
|
||||
"artifact", "output.bin", new byte[] { 1, 2, 3 },
|
||||
"application/octet-stream", attributes);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(material.Attributes);
|
||||
Assert.Equal("step-001", material.Attributes["stepId"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanonicalPath_CombinesSectionAndPath()
|
||||
{
|
||||
// Act
|
||||
var material = PackRunEvidenceMaterial.FromString("transcript", "step-001.json", "{}");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("transcript/step-001.json", material.CanonicalPath);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region InMemoryPackRunEvidenceStore Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Store_AndGet_ReturnsSnapshot()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryPackRunEvidenceStore();
|
||||
var snapshot = PackRunEvidenceSnapshot.Create(
|
||||
TestTenantId, TestRunId, TestPlanHash,
|
||||
PackRunEvidenceSnapshotKind.RunCompletion,
|
||||
new List<PackRunEvidenceMaterial>());
|
||||
|
||||
// Act
|
||||
await store.StoreAsync(snapshot, TestContext.Current.CancellationToken);
|
||||
var retrieved = await store.GetAsync(snapshot.SnapshotId, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(snapshot.SnapshotId, retrieved.SnapshotId);
|
||||
Assert.Equal(snapshot.RootHash, retrieved.RootHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_NonExistent_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryPackRunEvidenceStore();
|
||||
|
||||
// Act
|
||||
var result = await store.GetAsync(Guid.NewGuid(), TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListByRun_ReturnsMatchingSnapshots()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryPackRunEvidenceStore();
|
||||
var snapshot1 = PackRunEvidenceSnapshot.Create(
|
||||
TestTenantId, TestRunId, TestPlanHash,
|
||||
PackRunEvidenceSnapshotKind.StepExecution,
|
||||
new List<PackRunEvidenceMaterial>());
|
||||
var snapshot2 = PackRunEvidenceSnapshot.Create(
|
||||
TestTenantId, TestRunId, TestPlanHash,
|
||||
PackRunEvidenceSnapshotKind.ApprovalDecision,
|
||||
new List<PackRunEvidenceMaterial>());
|
||||
var otherRunSnapshot = PackRunEvidenceSnapshot.Create(
|
||||
TestTenantId, "other-run", TestPlanHash,
|
||||
PackRunEvidenceSnapshotKind.StepExecution,
|
||||
new List<PackRunEvidenceMaterial>());
|
||||
|
||||
await store.StoreAsync(snapshot1, TestContext.Current.CancellationToken);
|
||||
await store.StoreAsync(snapshot2, TestContext.Current.CancellationToken);
|
||||
await store.StoreAsync(otherRunSnapshot, TestContext.Current.CancellationToken);
|
||||
|
||||
// Act
|
||||
var results = await store.ListByRunAsync(TestTenantId, TestRunId, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.All(results, s => Assert.Equal(TestRunId, s.RunId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListByKind_ReturnsMatchingSnapshots()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryPackRunEvidenceStore();
|
||||
var stepSnapshot1 = PackRunEvidenceSnapshot.Create(
|
||||
TestTenantId, TestRunId, TestPlanHash,
|
||||
PackRunEvidenceSnapshotKind.StepExecution,
|
||||
new List<PackRunEvidenceMaterial>());
|
||||
var stepSnapshot2 = PackRunEvidenceSnapshot.Create(
|
||||
TestTenantId, TestRunId, TestPlanHash,
|
||||
PackRunEvidenceSnapshotKind.StepExecution,
|
||||
new List<PackRunEvidenceMaterial>());
|
||||
var approvalSnapshot = PackRunEvidenceSnapshot.Create(
|
||||
TestTenantId, TestRunId, TestPlanHash,
|
||||
PackRunEvidenceSnapshotKind.ApprovalDecision,
|
||||
new List<PackRunEvidenceMaterial>());
|
||||
|
||||
await store.StoreAsync(stepSnapshot1, TestContext.Current.CancellationToken);
|
||||
await store.StoreAsync(stepSnapshot2, TestContext.Current.CancellationToken);
|
||||
await store.StoreAsync(approvalSnapshot, TestContext.Current.CancellationToken);
|
||||
|
||||
// Act
|
||||
var results = await store.ListByKindAsync(
|
||||
TestTenantId, TestRunId,
|
||||
PackRunEvidenceSnapshotKind.StepExecution,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.All(results, s => Assert.Equal(PackRunEvidenceSnapshotKind.StepExecution, s.Kind));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verify_ValidSnapshot_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryPackRunEvidenceStore();
|
||||
var materials = new List<PackRunEvidenceMaterial>
|
||||
{
|
||||
PackRunEvidenceMaterial.FromString("test", "file.txt", "content")
|
||||
};
|
||||
var snapshot = PackRunEvidenceSnapshot.Create(
|
||||
TestTenantId, TestRunId, TestPlanHash,
|
||||
PackRunEvidenceSnapshotKind.RunCompletion, materials);
|
||||
|
||||
await store.StoreAsync(snapshot, TestContext.Current.CancellationToken);
|
||||
|
||||
// Act
|
||||
var result = await store.VerifyAsync(snapshot.SnapshotId, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Valid);
|
||||
Assert.Equal(snapshot.RootHash, result.ExpectedHash);
|
||||
Assert.Equal(snapshot.RootHash, result.ComputedHash);
|
||||
Assert.Null(result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verify_NonExistent_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryPackRunEvidenceStore();
|
||||
|
||||
// Act
|
||||
var result = await store.VerifyAsync(Guid.NewGuid(), TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Valid);
|
||||
Assert.Equal("Snapshot not found", result.Error);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PackRunRedactionGuard Tests
|
||||
|
||||
[Fact]
|
||||
public void RedactTranscript_RedactsSensitiveOutput()
|
||||
{
|
||||
// Arrange
|
||||
var guard = new PackRunRedactionGuard();
|
||||
var transcript = new PackRunStepTranscript(
|
||||
StepId: TestStepId,
|
||||
Kind: "shell",
|
||||
StartedAt: DateTimeOffset.UtcNow,
|
||||
EndedAt: DateTimeOffset.UtcNow,
|
||||
Status: "completed",
|
||||
Attempt: 1,
|
||||
DurationMs: 100,
|
||||
Output: "Connecting with Bearer eyJhbGciOiJIUzI1NiJ9.token",
|
||||
Error: null,
|
||||
EnvironmentDigest: null,
|
||||
Artifacts: null);
|
||||
|
||||
// Act
|
||||
var redacted = guard.RedactTranscript(transcript);
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("eyJhbGciOiJIUzI1NiJ9", redacted.Output);
|
||||
Assert.Contains("[REDACTED", redacted.Output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactTranscript_PreservesNonSensitiveOutput()
|
||||
{
|
||||
// Arrange
|
||||
var guard = new PackRunRedactionGuard();
|
||||
var transcript = new PackRunStepTranscript(
|
||||
StepId: TestStepId,
|
||||
Kind: "shell",
|
||||
StartedAt: DateTimeOffset.UtcNow,
|
||||
EndedAt: DateTimeOffset.UtcNow,
|
||||
Status: "completed",
|
||||
Attempt: 1,
|
||||
DurationMs: 100,
|
||||
Output: "Build completed successfully",
|
||||
Error: null,
|
||||
EnvironmentDigest: null,
|
||||
Artifacts: null);
|
||||
|
||||
// Act
|
||||
var redacted = guard.RedactTranscript(transcript);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Build completed successfully", redacted.Output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactIdentity_RedactsEmail()
|
||||
{
|
||||
// Arrange
|
||||
var guard = new PackRunRedactionGuard();
|
||||
|
||||
// Act
|
||||
var redacted = guard.RedactIdentity("john.doe@example.com");
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("john.doe", redacted);
|
||||
Assert.DoesNotContain("example.com", redacted);
|
||||
Assert.Contains("[", redacted); // Contains redaction markers
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactIdentity_HashesNonEmailIdentity()
|
||||
{
|
||||
// Arrange
|
||||
var guard = new PackRunRedactionGuard();
|
||||
|
||||
// Act
|
||||
var redacted = guard.RedactIdentity("admin-user-12345");
|
||||
|
||||
// Assert
|
||||
Assert.StartsWith("[USER:", redacted);
|
||||
Assert.EndsWith("]", redacted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactApproval_RedactsApproverAndComments()
|
||||
{
|
||||
// Arrange
|
||||
var guard = new PackRunRedactionGuard();
|
||||
var approval = new PackRunApprovalEvidence(
|
||||
ApprovalId: "approval-001",
|
||||
Approver: "jane.doe@example.com",
|
||||
ApprovedAt: DateTimeOffset.UtcNow,
|
||||
Decision: "approved",
|
||||
RequiredGrants: new[] { "deploy:production" },
|
||||
GrantedBy: new[] { "team-lead@example.com" },
|
||||
Comments: "Approved. Use token=abc123xyz for deployment.");
|
||||
|
||||
// Act
|
||||
var redacted = guard.RedactApproval(approval);
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("jane.doe", redacted.Approver);
|
||||
Assert.DoesNotContain("team-lead", redacted.GrantedBy![0]);
|
||||
Assert.Contains("[REDACTED", redacted.Comments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactValue_ReturnsHashedValue()
|
||||
{
|
||||
// Arrange
|
||||
var guard = new PackRunRedactionGuard();
|
||||
|
||||
// Act
|
||||
var redacted = guard.RedactValue("super-secret-value");
|
||||
|
||||
// Assert
|
||||
Assert.StartsWith("[HASH:", redacted);
|
||||
Assert.EndsWith("]", redacted);
|
||||
Assert.DoesNotContain("super-secret-value", redacted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoOpRedactionGuard_PreservesAllData()
|
||||
{
|
||||
// Arrange
|
||||
var guard = NoOpPackRunRedactionGuard.Instance;
|
||||
var transcript = new PackRunStepTranscript(
|
||||
StepId: TestStepId,
|
||||
Kind: "shell",
|
||||
StartedAt: DateTimeOffset.UtcNow,
|
||||
EndedAt: DateTimeOffset.UtcNow,
|
||||
Status: "completed",
|
||||
Attempt: 1,
|
||||
DurationMs: 100,
|
||||
Output: "Bearer secret-token-12345",
|
||||
Error: null,
|
||||
EnvironmentDigest: null,
|
||||
Artifacts: null);
|
||||
|
||||
// Act
|
||||
var result = guard.RedactTranscript(transcript);
|
||||
|
||||
// Assert
|
||||
Assert.Same(transcript, result);
|
||||
Assert.Equal("Bearer secret-token-12345", result.Output);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PackRunEvidenceSnapshotService Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureRunCompletion_StoresSnapshot()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryPackRunEvidenceStore();
|
||||
var sink = new InMemoryPackRunTimelineEventSink();
|
||||
var emitter = new PackRunTimelineEventEmitter(
|
||||
sink, TimeProvider.System, NullLogger<PackRunTimelineEventEmitter>.Instance);
|
||||
var service = new PackRunEvidenceSnapshotService(
|
||||
store,
|
||||
new PackRunRedactionGuard(),
|
||||
NullLogger<PackRunEvidenceSnapshotService>.Instance,
|
||||
emitter);
|
||||
|
||||
var state = CreateTestPackRunState();
|
||||
|
||||
// Act
|
||||
var result = await service.CaptureRunCompletionAsync(
|
||||
TestTenantId, TestRunId, TestPlanHash, state,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Snapshot);
|
||||
Assert.NotNull(result.EvidencePointer);
|
||||
Assert.Equal(PackRunEvidenceSnapshotKind.RunCompletion, result.Snapshot.Kind);
|
||||
Assert.Equal(1, store.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureRunCompletion_WithTranscripts_IncludesRedactedTranscripts()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryPackRunEvidenceStore();
|
||||
var service = new PackRunEvidenceSnapshotService(
|
||||
store,
|
||||
new PackRunRedactionGuard(),
|
||||
NullLogger<PackRunEvidenceSnapshotService>.Instance);
|
||||
|
||||
var state = CreateTestPackRunState();
|
||||
var transcripts = new List<PackRunStepTranscript>
|
||||
{
|
||||
new(TestStepId, "shell", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow,
|
||||
"completed", 1, 100, "Bearer token123", null, null, null)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await service.CaptureRunCompletionAsync(
|
||||
TestTenantId, TestRunId, TestPlanHash, state,
|
||||
transcripts: transcripts,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
var transcriptMaterial = result.Snapshot!.Materials
|
||||
.FirstOrDefault(m => m.Section == "transcript");
|
||||
Assert.NotNull(transcriptMaterial);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureStepExecution_CapturesTranscript()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryPackRunEvidenceStore();
|
||||
var service = new PackRunEvidenceSnapshotService(
|
||||
store,
|
||||
new PackRunRedactionGuard(),
|
||||
NullLogger<PackRunEvidenceSnapshotService>.Instance);
|
||||
|
||||
var transcript = new PackRunStepTranscript(
|
||||
TestStepId, "shell", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow,
|
||||
"completed", 1, 150, "Build output", null, null, null);
|
||||
|
||||
// Act
|
||||
var result = await service.CaptureStepExecutionAsync(
|
||||
TestTenantId, TestRunId, TestPlanHash, transcript,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(PackRunEvidenceSnapshotKind.StepExecution, result.Snapshot!.Kind);
|
||||
Assert.Contains(result.Snapshot.Materials, m => m.Section == "transcript");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureApprovalDecision_CapturesApproval()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryPackRunEvidenceStore();
|
||||
var service = new PackRunEvidenceSnapshotService(
|
||||
store,
|
||||
new PackRunRedactionGuard(),
|
||||
NullLogger<PackRunEvidenceSnapshotService>.Instance);
|
||||
|
||||
var approval = new PackRunApprovalEvidence(
|
||||
"approval-001",
|
||||
"approver@example.com",
|
||||
DateTimeOffset.UtcNow,
|
||||
"approved",
|
||||
new[] { "deploy:prod" },
|
||||
null,
|
||||
"LGTM");
|
||||
|
||||
// Act
|
||||
var result = await service.CaptureApprovalDecisionAsync(
|
||||
TestTenantId, TestRunId, TestPlanHash, approval,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(PackRunEvidenceSnapshotKind.ApprovalDecision, result.Snapshot!.Kind);
|
||||
Assert.Contains(result.Snapshot.Materials, m => m.Section == "approval");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapturePolicyEvaluation_CapturesEvaluation()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryPackRunEvidenceStore();
|
||||
var service = new PackRunEvidenceSnapshotService(
|
||||
store,
|
||||
new PackRunRedactionGuard(),
|
||||
NullLogger<PackRunEvidenceSnapshotService>.Instance);
|
||||
|
||||
var evaluation = new PackRunPolicyEvidence(
|
||||
"require-approval",
|
||||
"1.0.0",
|
||||
"pass",
|
||||
DateTimeOffset.UtcNow,
|
||||
5.5,
|
||||
new[] { "rule-1", "rule-2" },
|
||||
"sha256:policy123");
|
||||
|
||||
// Act
|
||||
var result = await service.CapturePolicyEvaluationAsync(
|
||||
TestTenantId, TestRunId, TestPlanHash, evaluation,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(PackRunEvidenceSnapshotKind.PolicyEvaluation, result.Snapshot!.Kind);
|
||||
Assert.Contains(result.Snapshot.Materials, m => m.Section == "policy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureRunCompletion_EmitsTimelineEvent()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryPackRunEvidenceStore();
|
||||
var sink = new InMemoryPackRunTimelineEventSink();
|
||||
var emitter = new PackRunTimelineEventEmitter(
|
||||
sink, TimeProvider.System, NullLogger<PackRunTimelineEventEmitter>.Instance);
|
||||
var service = new PackRunEvidenceSnapshotService(
|
||||
store,
|
||||
new PackRunRedactionGuard(),
|
||||
NullLogger<PackRunEvidenceSnapshotService>.Instance,
|
||||
emitter);
|
||||
|
||||
var state = CreateTestPackRunState();
|
||||
|
||||
// Act
|
||||
await service.CaptureRunCompletionAsync(
|
||||
TestTenantId, TestRunId, TestPlanHash, state,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
var events = sink.GetEvents();
|
||||
Assert.Single(events);
|
||||
Assert.Equal("pack.evidence.captured", events[0].EventType);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static PackRunState CreateTestPackRunState()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Sample);
|
||||
var planner = new TaskPackPlanner();
|
||||
var planResult = planner.Plan(manifest);
|
||||
var plan = planResult.Plan!;
|
||||
|
||||
var context = new PackRunExecutionContext(TestRunId, plan, DateTimeOffset.UtcNow);
|
||||
var graphBuilder = new PackRunExecutionGraphBuilder();
|
||||
var graph = graphBuilder.Build(plan);
|
||||
var simulationEngine = new PackRunSimulationEngine();
|
||||
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
return PackRunStateFactory.CreateInitialState(context, graph, simulationEngine, timestamp);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.TaskRunner.Core.Events;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for pack run timeline event domain model, emitter, and sink.
|
||||
/// Per TASKRUN-OBS-52-001.
|
||||
/// </summary>
|
||||
public sealed class PackRunTimelineEventTests
|
||||
{
|
||||
private const string TestTenantId = "test-tenant";
|
||||
private const string TestRunId = "run-12345";
|
||||
private const string TestPlanHash = "sha256:abc123";
|
||||
private const string TestStepId = "step-001";
|
||||
private const string TestProjectId = "project-xyz";
|
||||
|
||||
#region Domain Model Tests
|
||||
|
||||
[Fact]
|
||||
public void Create_WithRequiredFields_GeneratesValidEvent()
|
||||
{
|
||||
// Arrange
|
||||
var occurredAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// Act
|
||||
var evt = PackRunTimelineEvent.Create(
|
||||
tenantId: TestTenantId,
|
||||
eventType: PackRunEventTypes.PackStarted,
|
||||
source: "taskrunner-worker",
|
||||
occurredAt: occurredAt,
|
||||
runId: TestRunId,
|
||||
planHash: TestPlanHash);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(Guid.Empty, evt.EventId);
|
||||
Assert.Equal(TestTenantId, evt.TenantId);
|
||||
Assert.Equal(PackRunEventTypes.PackStarted, evt.EventType);
|
||||
Assert.Equal("taskrunner-worker", evt.Source);
|
||||
Assert.Equal(occurredAt, evt.OccurredAt);
|
||||
Assert.Equal(TestRunId, evt.RunId);
|
||||
Assert.Equal(TestPlanHash, evt.PlanHash);
|
||||
Assert.Null(evt.ReceivedAt);
|
||||
Assert.Null(evt.EventSeq);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithPayload_ComputesHashAndNormalizes()
|
||||
{
|
||||
// Arrange
|
||||
var payload = new { stepId = "step-001", attempt = 1 };
|
||||
|
||||
// Act
|
||||
var evt = PackRunTimelineEvent.Create(
|
||||
tenantId: TestTenantId,
|
||||
eventType: PackRunEventTypes.StepStarted,
|
||||
source: "taskrunner-worker",
|
||||
occurredAt: DateTimeOffset.UtcNow,
|
||||
runId: TestRunId,
|
||||
planHash: TestPlanHash,
|
||||
payload: payload);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(evt.RawPayloadJson);
|
||||
Assert.NotNull(evt.NormalizedPayloadJson);
|
||||
Assert.NotNull(evt.PayloadHash);
|
||||
Assert.StartsWith("sha256:", evt.PayloadHash);
|
||||
Assert.Equal(64 + 7, evt.PayloadHash.Length); // sha256: prefix + 64 hex chars
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithStepId_SetsStepId()
|
||||
{
|
||||
// Act
|
||||
var evt = PackRunTimelineEvent.Create(
|
||||
tenantId: TestTenantId,
|
||||
eventType: PackRunEventTypes.StepCompleted,
|
||||
source: "taskrunner-worker",
|
||||
occurredAt: DateTimeOffset.UtcNow,
|
||||
runId: TestRunId,
|
||||
planHash: TestPlanHash,
|
||||
stepId: TestStepId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TestStepId, evt.StepId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithEvidencePointer_SetsPointer()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = PackRunEvidencePointer.Bundle(Guid.NewGuid(), "sha256:def456");
|
||||
|
||||
// Act
|
||||
var evt = PackRunTimelineEvent.Create(
|
||||
tenantId: TestTenantId,
|
||||
eventType: PackRunEventTypes.PackCompleted,
|
||||
source: "taskrunner-worker",
|
||||
occurredAt: DateTimeOffset.UtcNow,
|
||||
runId: TestRunId,
|
||||
planHash: TestPlanHash,
|
||||
evidencePointer: evidence);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(evt.EvidencePointer);
|
||||
Assert.Equal(PackRunEvidencePointerType.Bundle, evt.EvidencePointer.Type);
|
||||
Assert.Equal("sha256:def456", evt.EvidencePointer.BundleDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithReceivedAt_CreatesCopyWithTimestamp()
|
||||
{
|
||||
// Arrange
|
||||
var evt = PackRunTimelineEvent.Create(
|
||||
tenantId: TestTenantId,
|
||||
eventType: PackRunEventTypes.PackStarted,
|
||||
source: "taskrunner-worker",
|
||||
occurredAt: DateTimeOffset.UtcNow,
|
||||
runId: TestRunId,
|
||||
planHash: TestPlanHash);
|
||||
|
||||
var receivedAt = DateTimeOffset.UtcNow.AddSeconds(1);
|
||||
|
||||
// Act
|
||||
var updated = evt.WithReceivedAt(receivedAt);
|
||||
|
||||
// Assert
|
||||
Assert.Null(evt.ReceivedAt);
|
||||
Assert.Equal(receivedAt, updated.ReceivedAt);
|
||||
Assert.Equal(evt.EventId, updated.EventId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithSequence_CreatesCopyWithSequence()
|
||||
{
|
||||
// Arrange
|
||||
var evt = PackRunTimelineEvent.Create(
|
||||
tenantId: TestTenantId,
|
||||
eventType: PackRunEventTypes.PackStarted,
|
||||
source: "taskrunner-worker",
|
||||
occurredAt: DateTimeOffset.UtcNow,
|
||||
runId: TestRunId,
|
||||
planHash: TestPlanHash);
|
||||
|
||||
// Act
|
||||
var updated = evt.WithSequence(42);
|
||||
|
||||
// Assert
|
||||
Assert.Null(evt.EventSeq);
|
||||
Assert.Equal(42, updated.EventSeq);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_SerializesEvent()
|
||||
{
|
||||
// Arrange
|
||||
var evt = PackRunTimelineEvent.Create(
|
||||
tenantId: TestTenantId,
|
||||
eventType: PackRunEventTypes.StepCompleted,
|
||||
source: "taskrunner-worker",
|
||||
occurredAt: DateTimeOffset.UtcNow,
|
||||
runId: TestRunId,
|
||||
planHash: TestPlanHash,
|
||||
stepId: TestStepId);
|
||||
|
||||
// Act
|
||||
var json = evt.ToJson();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("\"tenantId\"", json);
|
||||
Assert.Contains("\"eventType\"", json);
|
||||
Assert.Contains("pack.step.completed", json);
|
||||
Assert.Contains(TestStepId, json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_DeserializesEvent()
|
||||
{
|
||||
// Arrange
|
||||
var original = PackRunTimelineEvent.Create(
|
||||
tenantId: TestTenantId,
|
||||
eventType: PackRunEventTypes.StepCompleted,
|
||||
source: "taskrunner-worker",
|
||||
occurredAt: DateTimeOffset.UtcNow,
|
||||
runId: TestRunId,
|
||||
planHash: TestPlanHash,
|
||||
stepId: TestStepId);
|
||||
var json = original.ToJson();
|
||||
|
||||
// Act
|
||||
var deserialized = PackRunTimelineEvent.FromJson(json);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal(original.EventId, deserialized.EventId);
|
||||
Assert.Equal(original.TenantId, deserialized.TenantId);
|
||||
Assert.Equal(original.EventType, deserialized.EventType);
|
||||
Assert.Equal(original.RunId, deserialized.RunId);
|
||||
Assert.Equal(original.StepId, deserialized.StepId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateIdempotencyKey_ReturnsConsistentKey()
|
||||
{
|
||||
// Arrange
|
||||
var evt = PackRunTimelineEvent.Create(
|
||||
tenantId: TestTenantId,
|
||||
eventType: PackRunEventTypes.PackStarted,
|
||||
source: "taskrunner-worker",
|
||||
occurredAt: DateTimeOffset.UtcNow,
|
||||
runId: TestRunId,
|
||||
planHash: TestPlanHash);
|
||||
|
||||
// Act
|
||||
var key1 = evt.GenerateIdempotencyKey();
|
||||
var key2 = evt.GenerateIdempotencyKey();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(key1, key2);
|
||||
Assert.Contains(TestTenantId, key1);
|
||||
Assert.Contains(PackRunEventTypes.PackStarted, key1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Event Types Tests
|
||||
|
||||
[Fact]
|
||||
public void PackRunEventTypes_HasExpectedValues()
|
||||
{
|
||||
Assert.Equal("pack.started", PackRunEventTypes.PackStarted);
|
||||
Assert.Equal("pack.completed", PackRunEventTypes.PackCompleted);
|
||||
Assert.Equal("pack.failed", PackRunEventTypes.PackFailed);
|
||||
Assert.Equal("pack.step.started", PackRunEventTypes.StepStarted);
|
||||
Assert.Equal("pack.step.completed", PackRunEventTypes.StepCompleted);
|
||||
Assert.Equal("pack.step.failed", PackRunEventTypes.StepFailed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("pack.started", true)]
|
||||
[InlineData("pack.step.completed", true)]
|
||||
[InlineData("scan.completed", false)]
|
||||
[InlineData("job.started", false)]
|
||||
public void IsPackRunEvent_ReturnsCorrectly(string eventType, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, PackRunEventTypes.IsPackRunEvent(eventType));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Evidence Pointer Tests
|
||||
|
||||
[Fact]
|
||||
public void EvidencePointer_Bundle_CreatesCorrectType()
|
||||
{
|
||||
var bundleId = Guid.NewGuid();
|
||||
var pointer = PackRunEvidencePointer.Bundle(bundleId, "sha256:abc");
|
||||
|
||||
Assert.Equal(PackRunEvidencePointerType.Bundle, pointer.Type);
|
||||
Assert.Equal(bundleId, pointer.BundleId);
|
||||
Assert.Equal("sha256:abc", pointer.BundleDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvidencePointer_Attestation_CreatesCorrectType()
|
||||
{
|
||||
var pointer = PackRunEvidencePointer.Attestation("subject:uri", "sha256:abc");
|
||||
|
||||
Assert.Equal(PackRunEvidencePointerType.Attestation, pointer.Type);
|
||||
Assert.Equal("subject:uri", pointer.AttestationSubject);
|
||||
Assert.Equal("sha256:abc", pointer.AttestationDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvidencePointer_Manifest_CreatesCorrectType()
|
||||
{
|
||||
var pointer = PackRunEvidencePointer.Manifest("https://example.com/manifest", "/locker/path");
|
||||
|
||||
Assert.Equal(PackRunEvidencePointerType.Manifest, pointer.Type);
|
||||
Assert.Equal("https://example.com/manifest", pointer.ManifestUri);
|
||||
Assert.Equal("/locker/path", pointer.LockerPath);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region In-Memory Sink Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InMemorySink_WriteAsync_StoresEvent()
|
||||
{
|
||||
// Arrange
|
||||
var sink = new InMemoryPackRunTimelineEventSink();
|
||||
var evt = PackRunTimelineEvent.Create(
|
||||
tenantId: TestTenantId,
|
||||
eventType: PackRunEventTypes.PackStarted,
|
||||
source: "taskrunner-worker",
|
||||
occurredAt: DateTimeOffset.UtcNow,
|
||||
runId: TestRunId,
|
||||
planHash: TestPlanHash);
|
||||
|
||||
// Act
|
||||
var result = await sink.WriteAsync(evt, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Sequence);
|
||||
Assert.False(result.Deduplicated);
|
||||
Assert.Equal(1, sink.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemorySink_WriteAsync_Deduplicates()
|
||||
{
|
||||
// Arrange
|
||||
var sink = new InMemoryPackRunTimelineEventSink();
|
||||
var evt = PackRunTimelineEvent.Create(
|
||||
tenantId: TestTenantId,
|
||||
eventType: PackRunEventTypes.PackStarted,
|
||||
source: "taskrunner-worker",
|
||||
occurredAt: DateTimeOffset.UtcNow,
|
||||
runId: TestRunId,
|
||||
planHash: TestPlanHash);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Act
|
||||
await sink.WriteAsync(evt, ct);
|
||||
var result = await sink.WriteAsync(evt, ct);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.Deduplicated);
|
||||
Assert.Equal(1, sink.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemorySink_AssignsMonotonicSequence()
|
||||
{
|
||||
// Arrange
|
||||
var sink = new InMemoryPackRunTimelineEventSink();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Act
|
||||
var evt1 = PackRunTimelineEvent.Create(
|
||||
tenantId: TestTenantId,
|
||||
eventType: PackRunEventTypes.PackStarted,
|
||||
source: "test",
|
||||
occurredAt: DateTimeOffset.UtcNow,
|
||||
runId: "run-1",
|
||||
planHash: TestPlanHash);
|
||||
|
||||
var evt2 = PackRunTimelineEvent.Create(
|
||||
tenantId: TestTenantId,
|
||||
eventType: PackRunEventTypes.StepStarted,
|
||||
source: "test",
|
||||
occurredAt: DateTimeOffset.UtcNow,
|
||||
runId: "run-1",
|
||||
planHash: TestPlanHash);
|
||||
|
||||
var result1 = await sink.WriteAsync(evt1, ct);
|
||||
var result2 = await sink.WriteAsync(evt2, ct);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, result1.Sequence);
|
||||
Assert.Equal(2, result2.Sequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemorySink_WriteBatchAsync_StoresMultiple()
|
||||
{
|
||||
// Arrange
|
||||
var sink = new InMemoryPackRunTimelineEventSink();
|
||||
var events = Enumerable.Range(0, 3).Select(i =>
|
||||
PackRunTimelineEvent.Create(
|
||||
tenantId: TestTenantId,
|
||||
eventType: PackRunEventTypes.StepStarted,
|
||||
source: "test",
|
||||
occurredAt: DateTimeOffset.UtcNow,
|
||||
runId: TestRunId,
|
||||
planHash: TestPlanHash,
|
||||
stepId: $"step-{i}")).ToList();
|
||||
|
||||
// Act
|
||||
var result = await sink.WriteBatchAsync(events, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, result.Written);
|
||||
Assert.Equal(0, result.Deduplicated);
|
||||
Assert.Equal(3, sink.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemorySink_GetEventsForRun_FiltersCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var sink = new InMemoryPackRunTimelineEventSink();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await sink.WriteAsync(PackRunTimelineEvent.Create(
|
||||
tenantId: TestTenantId,
|
||||
eventType: PackRunEventTypes.PackStarted,
|
||||
source: "test",
|
||||
occurredAt: DateTimeOffset.UtcNow,
|
||||
runId: "run-1",
|
||||
planHash: TestPlanHash), ct);
|
||||
|
||||
await sink.WriteAsync(PackRunTimelineEvent.Create(
|
||||
tenantId: TestTenantId,
|
||||
eventType: PackRunEventTypes.PackStarted,
|
||||
source: "test",
|
||||
occurredAt: DateTimeOffset.UtcNow,
|
||||
runId: "run-2",
|
||||
planHash: TestPlanHash), ct);
|
||||
|
||||
// Act
|
||||
var run1Events = sink.GetEventsForRun("run-1");
|
||||
var run2Events = sink.GetEventsForRun("run-2");
|
||||
|
||||
// Assert
|
||||
Assert.Single(run1Events);
|
||||
Assert.Single(run2Events);
|
||||
Assert.Equal("run-1", run1Events[0].RunId);
|
||||
Assert.Equal("run-2", run2Events[0].RunId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemorySink_Clear_RemovesAll()
|
||||
{
|
||||
// Arrange
|
||||
var sink = new InMemoryPackRunTimelineEventSink();
|
||||
await sink.WriteAsync(PackRunTimelineEvent.Create(
|
||||
tenantId: TestTenantId,
|
||||
eventType: PackRunEventTypes.PackStarted,
|
||||
source: "test",
|
||||
occurredAt: DateTimeOffset.UtcNow,
|
||||
runId: TestRunId,
|
||||
planHash: TestPlanHash), TestContext.Current.CancellationToken);
|
||||
|
||||
// Act
|
||||
sink.Clear();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, sink.Count);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Emitter Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Emitter_EmitPackStartedAsync_CreatesEvent()
|
||||
{
|
||||
// Arrange
|
||||
var sink = new InMemoryPackRunTimelineEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
var emitter = new PackRunTimelineEventEmitter(
|
||||
sink,
|
||||
timeProvider,
|
||||
NullLogger<PackRunTimelineEventEmitter>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitPackStartedAsync(
|
||||
TestTenantId,
|
||||
TestRunId,
|
||||
TestPlanHash,
|
||||
projectId: TestProjectId,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.False(result.Deduplicated);
|
||||
Assert.Equal(PackRunEventTypes.PackStarted, result.Event.EventType);
|
||||
Assert.Equal(TestRunId, result.Event.RunId);
|
||||
Assert.Equal(1, sink.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Emitter_EmitPackCompletedAsync_CreatesEvent()
|
||||
{
|
||||
// Arrange
|
||||
var sink = new InMemoryPackRunTimelineEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
var emitter = new PackRunTimelineEventEmitter(
|
||||
sink,
|
||||
timeProvider,
|
||||
NullLogger<PackRunTimelineEventEmitter>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitPackCompletedAsync(
|
||||
TestTenantId,
|
||||
TestRunId,
|
||||
TestPlanHash,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(PackRunEventTypes.PackCompleted, result.Event.EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Emitter_EmitPackFailedAsync_CreatesEventWithError()
|
||||
{
|
||||
// Arrange
|
||||
var sink = new InMemoryPackRunTimelineEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
var emitter = new PackRunTimelineEventEmitter(
|
||||
sink,
|
||||
timeProvider,
|
||||
NullLogger<PackRunTimelineEventEmitter>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitPackFailedAsync(
|
||||
TestTenantId,
|
||||
TestRunId,
|
||||
TestPlanHash,
|
||||
failureReason: "Step step-001 failed",
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(PackRunEventTypes.PackFailed, result.Event.EventType);
|
||||
Assert.Equal(PackRunEventSeverity.Error, result.Event.Severity);
|
||||
Assert.Contains("failureReason", result.Event.Attributes!.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Emitter_EmitStepStartedAsync_IncludesAttempt()
|
||||
{
|
||||
// Arrange
|
||||
var sink = new InMemoryPackRunTimelineEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
var emitter = new PackRunTimelineEventEmitter(
|
||||
sink,
|
||||
timeProvider,
|
||||
NullLogger<PackRunTimelineEventEmitter>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitStepStartedAsync(
|
||||
TestTenantId,
|
||||
TestRunId,
|
||||
TestPlanHash,
|
||||
TestStepId,
|
||||
attempt: 2,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(PackRunEventTypes.StepStarted, result.Event.EventType);
|
||||
Assert.Equal(TestStepId, result.Event.StepId);
|
||||
Assert.Equal("2", result.Event.Attributes!["attempt"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Emitter_EmitStepCompletedAsync_IncludesDuration()
|
||||
{
|
||||
// Arrange
|
||||
var sink = new InMemoryPackRunTimelineEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
var emitter = new PackRunTimelineEventEmitter(
|
||||
sink,
|
||||
timeProvider,
|
||||
NullLogger<PackRunTimelineEventEmitter>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitStepCompletedAsync(
|
||||
TestTenantId,
|
||||
TestRunId,
|
||||
TestPlanHash,
|
||||
TestStepId,
|
||||
attempt: 1,
|
||||
durationMs: 123.45,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(PackRunEventTypes.StepCompleted, result.Event.EventType);
|
||||
Assert.Contains("durationMs", result.Event.Attributes!.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Emitter_EmitStepFailedAsync_IncludesError()
|
||||
{
|
||||
// Arrange
|
||||
var sink = new InMemoryPackRunTimelineEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
var emitter = new PackRunTimelineEventEmitter(
|
||||
sink,
|
||||
timeProvider,
|
||||
NullLogger<PackRunTimelineEventEmitter>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitStepFailedAsync(
|
||||
TestTenantId,
|
||||
TestRunId,
|
||||
TestPlanHash,
|
||||
TestStepId,
|
||||
attempt: 3,
|
||||
error: "Connection timeout",
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(PackRunEventTypes.StepFailed, result.Event.EventType);
|
||||
Assert.Equal(PackRunEventSeverity.Error, result.Event.Severity);
|
||||
Assert.Equal("Connection timeout", result.Event.Attributes!["error"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Emitter_EmitBatchAsync_OrdersEventsDeterministically()
|
||||
{
|
||||
// Arrange
|
||||
var sink = new InMemoryPackRunTimelineEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
var emitter = new PackRunTimelineEventEmitter(
|
||||
sink,
|
||||
timeProvider,
|
||||
NullLogger<PackRunTimelineEventEmitter>.Instance);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var events = new[]
|
||||
{
|
||||
PackRunTimelineEvent.Create(TestTenantId, PackRunEventTypes.StepStarted, "test", now.AddSeconds(2), TestRunId, TestPlanHash),
|
||||
PackRunTimelineEvent.Create(TestTenantId, PackRunEventTypes.PackStarted, "test", now, TestRunId, TestPlanHash),
|
||||
PackRunTimelineEvent.Create(TestTenantId, PackRunEventTypes.StepCompleted, "test", now.AddSeconds(1), TestRunId, TestPlanHash),
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitBatchAsync(events, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, result.Emitted);
|
||||
Assert.Equal(0, result.Deduplicated);
|
||||
|
||||
var stored = sink.GetEvents();
|
||||
Assert.Equal(PackRunEventTypes.PackStarted, stored[0].EventType);
|
||||
Assert.Equal(PackRunEventTypes.StepCompleted, stored[1].EventType);
|
||||
Assert.Equal(PackRunEventTypes.StepStarted, stored[2].EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Emitter_EmitBatchAsync_HandlesDuplicates()
|
||||
{
|
||||
// Arrange
|
||||
var sink = new InMemoryPackRunTimelineEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
var emitter = new PackRunTimelineEventEmitter(
|
||||
sink,
|
||||
timeProvider,
|
||||
NullLogger<PackRunTimelineEventEmitter>.Instance);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var evt = PackRunTimelineEvent.Create(
|
||||
TestTenantId,
|
||||
PackRunEventTypes.PackStarted,
|
||||
"test",
|
||||
DateTimeOffset.UtcNow,
|
||||
TestRunId,
|
||||
TestPlanHash);
|
||||
|
||||
// Emit once directly
|
||||
await sink.WriteAsync(evt, ct);
|
||||
|
||||
// Act - emit batch with same event
|
||||
var result = await emitter.EmitBatchAsync([evt], ct);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, result.Emitted);
|
||||
Assert.Equal(1, result.Deduplicated);
|
||||
Assert.Equal(1, sink.Count); // Only one event stored
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Null Sink Tests
|
||||
|
||||
[Fact]
|
||||
public async Task NullSink_WriteAsync_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var sink = NullPackRunTimelineEventSink.Instance;
|
||||
var evt = PackRunTimelineEvent.Create(
|
||||
TestTenantId,
|
||||
PackRunEventTypes.PackStarted,
|
||||
"test",
|
||||
DateTimeOffset.UtcNow,
|
||||
TestRunId,
|
||||
TestPlanHash);
|
||||
|
||||
// Act
|
||||
var result = await sink.WriteAsync(evt, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.False(result.Deduplicated);
|
||||
Assert.Null(result.Sequence);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake time provider for testing.
|
||||
/// </summary>
|
||||
internal sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset utcNow)
|
||||
{
|
||||
_utcNow = utcNow;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public void Advance(TimeSpan duration) => _utcNow = _utcNow.Add(duration);
|
||||
}
|
||||
Reference in New Issue
Block a user