597 lines
18 KiB
C#
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
|
|
}
|
|
};
|
|
}
|
|
}
|