// ----------------------------------------------------------------------------- // RichGraphAttestationServiceTests.cs // Sprint: SPRINT_3801_0001_0002_richgraph_attestation (GRAPH-005) // Description: Unit tests for RichGraphAttestationService. // ----------------------------------------------------------------------------- using System; using System.Collections.Generic; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using StellaOps.Scanner.WebService.Contracts; using StellaOps.Scanner.WebService.Domain; using StellaOps.Scanner.WebService.Services; using Xunit; using MsOptions = Microsoft.Extensions.Options; using StellaOps.TestKit; namespace StellaOps.Scanner.WebService.Tests; /// /// Unit tests for RichGraphAttestationService. /// public sealed class RichGraphAttestationServiceTests { private readonly FakeTimeProvider _timeProvider; private readonly RichGraphAttestationService _service; public RichGraphAttestationServiceTests() { _timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 12, 19, 10, 0, 0, TimeSpan.Zero)); _service = new RichGraphAttestationService( NullLogger.Instance, MsOptions.Options.Create(new RichGraphAttestationOptions { DefaultGraphTtlDays = 7 }), _timeProvider); } #region CreateAttestationAsync Tests [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAttestationAsync_ValidInput_ReturnsSuccessResult() { // Arrange var input = CreateValidInput(); // Act var result = await _service.CreateAttestationAsync(input); // Assert result.Success.Should().BeTrue(); result.Statement.Should().NotBeNull(); result.AttestationId.Should().NotBeNullOrWhiteSpace(); result.AttestationId.Should().StartWith("sha256:"); result.Error.Should().BeNull(); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAttestationAsync_ValidInput_CreatesInTotoStatement() { // Arrange var input = CreateValidInput(); // Act var result = await _service.CreateAttestationAsync(input); // Assert result.Statement.Should().NotBeNull(); result.Statement!.Type.Should().Be("https://in-toto.io/Statement/v1"); result.Statement.PredicateType.Should().Be("stella.ops/richgraph@v1"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAttestationAsync_ValidInput_IncludesSubjects() { // Arrange var input = CreateValidInput(); // Act var result = await _service.CreateAttestationAsync(input); // Assert result.Statement!.Subject.Should().HaveCount(2); result.Statement.Subject[0].Name.Should().StartWith("scan:"); result.Statement.Subject[0].Digest.Should().ContainKey("sha256"); result.Statement.Subject[1].Name.Should().StartWith("graph:"); result.Statement.Subject[1].Digest.Should().ContainKey("sha256"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAttestationAsync_ValidInput_IncludesPredicateWithGraphMetrics() { // Arrange var input = CreateValidInput(); // Act var result = await _service.CreateAttestationAsync(input); // Assert var predicate = result.Statement!.Predicate; predicate.GraphId.Should().Be(input.GraphId); predicate.GraphDigest.Should().Be(input.GraphDigest); predicate.NodeCount.Should().Be(input.NodeCount); predicate.EdgeCount.Should().Be(input.EdgeCount); predicate.RootCount.Should().Be(input.RootCount); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAttestationAsync_ValidInput_IncludesAnalyzerInfo() { // Arrange var input = CreateValidInput(); // Act var result = await _service.CreateAttestationAsync(input); // Assert var analyzer = result.Statement!.Predicate.Analyzer; analyzer.Name.Should().Be(input.AnalyzerName); analyzer.Version.Should().Be(input.AnalyzerVersion); analyzer.ConfigHash.Should().Be(input.AnalyzerConfigHash); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAttestationAsync_ValidInput_SetsComputedAtToCurrentTime() { // Arrange var input = CreateValidInput(); var expectedTime = _timeProvider.GetUtcNow(); // Act var result = await _service.CreateAttestationAsync(input); // Assert result.Statement!.Predicate.ComputedAt.Should().Be(expectedTime); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAttestationAsync_WithDefaultTtl_SetsExpiresAtTo7Days() { // Arrange var input = CreateValidInput(); var expectedExpiry = _timeProvider.GetUtcNow().AddDays(7); // Act var result = await _service.CreateAttestationAsync(input); // Assert result.Statement!.Predicate.ExpiresAt.Should().Be(expectedExpiry); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAttestationAsync_WithCustomTtl_SetsExpiresAtToCustomValue() { // Arrange var input = CreateValidInput() with { GraphTtl = TimeSpan.FromDays(14) }; var expectedExpiry = _timeProvider.GetUtcNow().AddDays(14); // Act var result = await _service.CreateAttestationAsync(input); // Assert result.Statement!.Predicate.ExpiresAt.Should().Be(expectedExpiry); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAttestationAsync_IncludesOptionalRefs() { // Arrange var input = CreateValidInput() with { SbomRef = "sha256:sbom123", CallgraphRef = "sha256:callgraph456", Language = "java" }; // Act var result = await _service.CreateAttestationAsync(input); // Assert result.Statement!.Predicate.SbomRef.Should().Be("sha256:sbom123"); result.Statement.Predicate.CallgraphRef.Should().Be("sha256:callgraph456"); result.Statement.Predicate.Language.Should().Be("java"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAttestationAsync_GeneratesDeterministicAttestationId() { // Arrange var input = CreateValidInput(); // Act var result1 = await _service.CreateAttestationAsync(input); var result2 = await _service.CreateAttestationAsync(input); // Assert result1.AttestationId.Should().Be(result2.AttestationId); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAttestationAsync_DifferentInputs_GenerateDifferentAttestationIds() { // Arrange var input1 = CreateValidInput(); var input2 = CreateValidInput() with { GraphId = "different-graph-id" }; // Act var result1 = await _service.CreateAttestationAsync(input1); var result2 = await _service.CreateAttestationAsync(input2); // Assert result1.AttestationId.Should().NotBe(result2.AttestationId); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAttestationAsync_NullInput_ThrowsArgumentNullException() { // Act & Assert await Assert.ThrowsAsync(() => _service.CreateAttestationAsync(null!)); } [Trait("Category", TestCategories.Unit)] [Theory] [InlineData("")] [InlineData(" ")] public async Task CreateAttestationAsync_EmptyGraphId_ThrowsArgumentException(string graphId) { // Arrange var input = CreateValidInput() with { GraphId = graphId }; // Act & Assert await Assert.ThrowsAsync(() => _service.CreateAttestationAsync(input)); } [Trait("Category", TestCategories.Unit)] [Theory] [InlineData("")] [InlineData(" ")] public async Task CreateAttestationAsync_EmptyGraphDigest_ThrowsArgumentException(string graphDigest) { // Arrange var input = CreateValidInput() with { GraphDigest = graphDigest }; // Act & Assert await Assert.ThrowsAsync(() => _service.CreateAttestationAsync(input)); } [Trait("Category", TestCategories.Unit)] [Theory] [InlineData("")] [InlineData(" ")] public async Task CreateAttestationAsync_EmptyAnalyzerName_ThrowsArgumentException(string analyzerName) { // Arrange var input = CreateValidInput() with { AnalyzerName = analyzerName }; // Act & Assert await Assert.ThrowsAsync(() => _service.CreateAttestationAsync(input)); } #endregion #region GetAttestationAsync Tests [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetAttestationAsync_ExistingAttestation_ReturnsAttestation() { // Arrange var input = CreateValidInput(); await _service.CreateAttestationAsync(input); // Act var result = await _service.GetAttestationAsync(input.ScanId, input.GraphId); // Assert result.Should().NotBeNull(); result!.Success.Should().BeTrue(); result.Statement!.Predicate.GraphId.Should().Be(input.GraphId); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetAttestationAsync_NonExistentAttestation_ReturnsNull() { // Act var result = await _service.GetAttestationAsync(ScanId.New(), "nonexistent-graph"); // Assert result.Should().BeNull(); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetAttestationAsync_WrongScanId_ReturnsNull() { // Arrange var input = CreateValidInput(); await _service.CreateAttestationAsync(input); // Act var result = await _service.GetAttestationAsync(ScanId.New(), input.GraphId); // Assert result.Should().BeNull(); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetAttestationAsync_WrongGraphId_ReturnsNull() { // Arrange var input = CreateValidInput(); await _service.CreateAttestationAsync(input); // Act var result = await _service.GetAttestationAsync(input.ScanId, "wrong-graph-id"); // Assert result.Should().BeNull(); } #endregion #region Serialization Tests [Trait("Category", TestCategories.Unit)] [Fact] public async Task Statement_SerializesToValidJson() { // Arrange var input = CreateValidInput(); var result = await _service.CreateAttestationAsync(input); // Act var json = JsonSerializer.Serialize(result.Statement); // Assert json.Should().Contain("\"_type\":"); json.Should().Contain("\"predicateType\":"); json.Should().Contain("\"subject\":"); json.Should().Contain("\"predicate\":"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Statement_PredicateType_IsCorrectUri() { // Arrange var input = CreateValidInput(); // Act var result = await _service.CreateAttestationAsync(input); // Assert result.Statement!.PredicateType.Should().Be("stella.ops/richgraph@v1"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Statement_Schema_IsRichGraphV1() { // Arrange var input = CreateValidInput(); // Act var result = await _service.CreateAttestationAsync(input); // Assert result.Statement!.Predicate.Schema.Should().Be("richgraph-v1"); } #endregion #region Helper Methods private RichGraphAttestationInput CreateValidInput() { return new RichGraphAttestationInput { ScanId = ScanId.New(), GraphId = $"richgraph-{Guid.NewGuid():N}", GraphDigest = "sha256:abc123def456789", NodeCount = 1234, EdgeCount = 5678, RootCount = 12, AnalyzerName = "stellaops-reachability", AnalyzerVersion = "1.0.0", AnalyzerConfigHash = "sha256:config123", SbomRef = null, CallgraphRef = null, Language = "java" }; } #endregion #region FakeTimeProvider private sealed class FakeTimeProvider : TimeProvider { private readonly DateTimeOffset _fixedTime; public FakeTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime; public override DateTimeOffset GetUtcNow() => _fixedTime; } #endregion } /// /// Tests for RichGraphAttestationOptions configuration. /// public sealed class RichGraphAttestationOptionsTests { [Trait("Category", TestCategories.Unit)] [Fact] public void DefaultGraphTtlDays_DefaultsToSevenDays() { var options = new RichGraphAttestationOptions(); options.DefaultGraphTtlDays.Should().Be(7); } [Trait("Category", TestCategories.Unit)] [Fact] public void EnableSigning_DefaultsToTrue() { var options = new RichGraphAttestationOptions(); options.EnableSigning.Should().BeTrue(); } [Trait("Category", TestCategories.Unit)] [Fact] public void Options_CanBeConfigured() { var options = new RichGraphAttestationOptions { DefaultGraphTtlDays = 14, EnableSigning = false }; options.DefaultGraphTtlDays.Should().Be(14); options.EnableSigning.Should().BeFalse(); } } /// /// Tests for RichGraphStatement model. /// public sealed class RichGraphStatementTests { [Trait("Category", TestCategories.Unit)] [Fact] public void Type_AlwaysReturnsInTotoStatementV1() { var statement = CreateValidStatement(); statement.Type.Should().Be("https://in-toto.io/Statement/v1"); } [Trait("Category", TestCategories.Unit)] [Fact] public void PredicateType_AlwaysReturnsCorrectUri() { var statement = CreateValidStatement(); statement.PredicateType.Should().Be("stella.ops/richgraph@v1"); } [Trait("Category", TestCategories.Unit)] [Fact] public void Subject_CanContainMultipleEntries() { var statement = CreateValidStatement(); statement.Subject.Should().HaveCount(2); } private static RichGraphStatement CreateValidStatement() { return new RichGraphStatement { Subject = new List { new() { Name = "scan:test", Digest = new Dictionary { ["sha256"] = "abc" } }, new() { Name = "graph:test", Digest = new Dictionary { ["sha256"] = "def" } } }, Predicate = new RichGraphPredicate { GraphId = "richgraph-test", GraphDigest = "sha256:test123", NodeCount = 100, EdgeCount = 200, RootCount = 5, Analyzer = new RichGraphAnalyzerInfo { Name = "test-analyzer", Version = "1.0.0" }, ComputedAt = DateTimeOffset.UtcNow } }; } } /// /// Tests for RichGraphAttestationResult factory methods. /// public sealed class RichGraphAttestationResultTests { [Trait("Category", TestCategories.Unit)] [Fact] public void Succeeded_CreatesSuccessResult() { var statement = CreateValidStatement(); var result = RichGraphAttestationResult.Succeeded(statement, "sha256:test123"); result.Success.Should().BeTrue(); result.Statement.Should().Be(statement); result.AttestationId.Should().Be("sha256:test123"); result.Error.Should().BeNull(); } [Trait("Category", TestCategories.Unit)] [Fact] public void Succeeded_WithDsseEnvelope_IncludesEnvelope() { var statement = CreateValidStatement(); var result = RichGraphAttestationResult.Succeeded( statement, "sha256:test123", dsseEnvelope: "eyJ0eXBlIjoiYXBwbGljYXRpb24vdm5kLmRzc2UranNvbiJ9..."); result.DsseEnvelope.Should().NotBeNullOrEmpty(); } [Trait("Category", TestCategories.Unit)] [Fact] public void Failed_CreatesFailedResult() { var result = RichGraphAttestationResult.Failed("Test error message"); result.Success.Should().BeFalse(); result.Statement.Should().BeNull(); result.AttestationId.Should().BeNull(); result.Error.Should().Be("Test error message"); } private static RichGraphStatement CreateValidStatement() { return new RichGraphStatement { Subject = new List { new() { Name = "test", Digest = new Dictionary { ["sha256"] = "abc" } } }, Predicate = new RichGraphPredicate { GraphId = "richgraph-test", GraphDigest = "sha256:test123", NodeCount = 100, EdgeCount = 200, RootCount = 5, Analyzer = new RichGraphAnalyzerInfo { Name = "test-analyzer", Version = "1.0.0" }, ComputedAt = DateTimeOffset.UtcNow } }; } }