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