Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/RichGraphAttestationServiceTests.cs

597 lines
18 KiB
C#

// -----------------------------------------------------------------------------
// 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.Determinism;
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;
/// <summary>
/// Unit tests for RichGraphAttestationService.
/// </summary>
public sealed class RichGraphAttestationServiceTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly RichGraphAttestationService _service;
private readonly IGuidProvider _guidProvider = new SequentialGuidProvider();
public RichGraphAttestationServiceTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 12, 19, 10, 0, 0, TimeSpan.Zero));
_service = new RichGraphAttestationService(
NullLogger<RichGraphAttestationService>.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<ArgumentNullException>(() =>
_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<ArgumentException>(() =>
_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<ArgumentException>(() =>
_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<ArgumentException>(() =>
_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(_guidProvider), "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(_guidProvider), 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(_guidProvider),
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
}
/// <summary>
/// Tests for RichGraphAttestationOptions configuration.
/// </summary>
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();
}
}
/// <summary>
/// Tests for RichGraphStatement model.
/// </summary>
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<RichGraphSubject>
{
new() { Name = "scan:test", Digest = new Dictionary<string, string> { ["sha256"] = "abc" } },
new() { Name = "graph:test", Digest = new Dictionary<string, string> { ["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
}
};
}
}
/// <summary>
/// Tests for RichGraphAttestationResult factory methods.
/// </summary>
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<RichGraphSubject>
{
new() { Name = "test", Digest = new Dictionary<string, string> { ["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
}
};
}
}