feat: Add VEX Status Chip component and integration tests for reachability drift detection
- Introduced `VexStatusChipComponent` to display VEX status with color coding and tooltips. - Implemented integration tests for reachability drift detection, covering various scenarios including drift detection, determinism, and error handling. - Enhanced `ScannerToSignalsReachabilityTests` with a null implementation of `ICallGraphSyncService` for better test isolation. - Updated project references to include the new Reachability Drift library.
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
// =============================================================================
|
||||
// ApprovalEndpointsTests.cs
|
||||
// Sprint: SPRINT_3801_0001_0005_approvals_api
|
||||
// Task: API-005 - Integration tests for approval endpoints
|
||||
// =============================================================================
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Endpoints;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Sprint", "3801.0001")]
|
||||
public sealed class ApprovalEndpointsTests : IDisposable
|
||||
{
|
||||
private readonly TestSurfaceSecretsScope _secrets;
|
||||
private readonly ScannerApplicationFactory _factory;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public ApprovalEndpointsTests()
|
||||
{
|
||||
_secrets = new TestSurfaceSecretsScope();
|
||||
|
||||
_factory = new ScannerApplicationFactory().WithOverrides(
|
||||
configureConfiguration: config => config["scanner:authority:enabled"] = "false");
|
||||
|
||||
_client = _factory.CreateClient();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_client.Dispose();
|
||||
_factory.Dispose();
|
||||
_secrets.Dispose();
|
||||
}
|
||||
|
||||
#region POST /approvals Tests
|
||||
|
||||
[Fact(DisplayName = "POST /approvals creates approval successfully")]
|
||||
public async Task CreateApproval_ValidRequest_Returns201()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
var request = new
|
||||
{
|
||||
finding_id = "CVE-2024-12345",
|
||||
decision = "AcceptRisk",
|
||||
justification = "Risk accepted for testing purposes"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
|
||||
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>();
|
||||
Assert.NotNull(approval);
|
||||
Assert.Equal("CVE-2024-12345", approval!.FindingId);
|
||||
Assert.Equal("AcceptRisk", approval.Decision);
|
||||
Assert.NotNull(approval.AttestationId);
|
||||
Assert.True(approval.AttestationId.StartsWith("sha256:"));
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "POST /approvals rejects empty finding_id")]
|
||||
public async Task CreateApproval_EmptyFindingId_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
var request = new
|
||||
{
|
||||
finding_id = "",
|
||||
decision = "AcceptRisk",
|
||||
justification = "Test justification"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "POST /approvals rejects empty justification")]
|
||||
public async Task CreateApproval_EmptyJustification_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
var request = new
|
||||
{
|
||||
finding_id = "CVE-2024-12345",
|
||||
decision = "AcceptRisk",
|
||||
justification = ""
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "POST /approvals rejects invalid decision")]
|
||||
public async Task CreateApproval_InvalidDecision_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
var request = new
|
||||
{
|
||||
finding_id = "CVE-2024-12345",
|
||||
decision = "InvalidDecision",
|
||||
justification = "Test justification"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
|
||||
Assert.NotNull(problem);
|
||||
Assert.Equal("Invalid decision value", problem!.Title);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "POST /approvals rejects invalid scanId")]
|
||||
public async Task CreateApproval_InvalidScanId_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var request = new
|
||||
{
|
||||
finding_id = "CVE-2024-12345",
|
||||
decision = "AcceptRisk",
|
||||
justification = "Test justification"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/scans/invalid-scan-id/approvals", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Theory(DisplayName = "POST /approvals accepts all valid decision types")]
|
||||
[InlineData("AcceptRisk")]
|
||||
[InlineData("Defer")]
|
||||
[InlineData("Reject")]
|
||||
[InlineData("Suppress")]
|
||||
[InlineData("Escalate")]
|
||||
public async Task CreateApproval_AllDecisionTypes_Accepted(string decision)
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
var request = new
|
||||
{
|
||||
finding_id = $"CVE-2024-{Guid.NewGuid():N}",
|
||||
decision,
|
||||
justification = "Test justification for decision type test"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
|
||||
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>();
|
||||
Assert.NotNull(approval);
|
||||
Assert.Equal(decision, approval!.Decision);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GET /approvals Tests
|
||||
|
||||
[Fact(DisplayName = "GET /approvals returns empty list for new scan")]
|
||||
public async Task ListApprovals_NewScan_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/approvals");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ApprovalListResponse>();
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(scanId, result!.ScanId);
|
||||
Assert.Empty(result.Approvals);
|
||||
Assert.Equal(0, result.TotalCount);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "GET /approvals returns created approvals")]
|
||||
public async Task ListApprovals_WithApprovals_ReturnsAll()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
|
||||
// Create two approvals
|
||||
await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", new
|
||||
{
|
||||
finding_id = "CVE-2024-0001",
|
||||
decision = "AcceptRisk",
|
||||
justification = "First approval"
|
||||
});
|
||||
await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", new
|
||||
{
|
||||
finding_id = "CVE-2024-0002",
|
||||
decision = "Defer",
|
||||
justification = "Second approval"
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/approvals");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ApprovalListResponse>();
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2, result!.Approvals.Count);
|
||||
Assert.Equal(2, result.TotalCount);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "GET /approvals/{findingId} returns specific approval")]
|
||||
public async Task GetApproval_Existing_ReturnsApproval()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
var findingId = "CVE-2024-99999";
|
||||
|
||||
await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", new
|
||||
{
|
||||
finding_id = findingId,
|
||||
decision = "Suppress",
|
||||
justification = "False positive for testing"
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/approvals/{findingId}");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>();
|
||||
Assert.NotNull(approval);
|
||||
Assert.Equal(findingId, approval!.FindingId);
|
||||
Assert.Equal("Suppress", approval.Decision);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "GET /approvals/{findingId} returns 404 for non-existent")]
|
||||
public async Task GetApproval_NonExistent_Returns404()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/approvals/CVE-2024-99999");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DELETE /approvals Tests
|
||||
|
||||
[Fact(DisplayName = "DELETE /approvals/{findingId} revokes existing approval")]
|
||||
public async Task RevokeApproval_Existing_Returns204()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
var findingId = "CVE-2024-88888";
|
||||
|
||||
await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", new
|
||||
{
|
||||
finding_id = findingId,
|
||||
decision = "AcceptRisk",
|
||||
justification = "Test approval to be revoked"
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await _client.DeleteAsync($"/api/v1/scans/{scanId}/approvals/{findingId}");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "DELETE /approvals/{findingId} returns 404 for non-existent")]
|
||||
public async Task RevokeApproval_NonExistent_Returns404()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
|
||||
// Act
|
||||
var response = await _client.DeleteAsync($"/api/v1/scans/{scanId}/approvals/CVE-2024-nonexistent");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Revoked approval excluded from list")]
|
||||
public async Task RevokeApproval_ExcludedFromList()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
var findingId = "CVE-2024-77777";
|
||||
|
||||
await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", new
|
||||
{
|
||||
finding_id = findingId,
|
||||
decision = "AcceptRisk",
|
||||
justification = "Test approval"
|
||||
});
|
||||
|
||||
// Revoke
|
||||
await _client.DeleteAsync($"/api/v1/scans/{scanId}/approvals/{findingId}");
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/approvals");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ApprovalListResponse>();
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result!.Approvals);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Revoked approval still retrievable with revoked flag")]
|
||||
public async Task RevokeApproval_StillRetrievable()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
var findingId = "CVE-2024-66666";
|
||||
|
||||
await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", new
|
||||
{
|
||||
finding_id = findingId,
|
||||
decision = "AcceptRisk",
|
||||
justification = "Test approval"
|
||||
});
|
||||
|
||||
// Revoke
|
||||
await _client.DeleteAsync($"/api/v1/scans/{scanId}/approvals/{findingId}");
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/approvals/{findingId}");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>();
|
||||
Assert.NotNull(approval);
|
||||
Assert.True(approval!.IsRevoked);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private async Task<string> CreateTestScanAsync()
|
||||
{
|
||||
// Generate a valid scan ID
|
||||
var scanId = Guid.NewGuid().ToString();
|
||||
return scanId;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,886 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AttestationChainVerifierTests.cs
|
||||
// Sprint: SPRINT_3801_0001_0003_chain_verification (CHAIN-005)
|
||||
// Description: Unit tests for AttestationChainVerifier.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using Xunit;
|
||||
|
||||
using MsOptions = Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for AttestationChainVerifier.
|
||||
/// </summary>
|
||||
public sealed class AttestationChainVerifierTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly Mock<IPolicyDecisionAttestationService> _policyServiceMock;
|
||||
private readonly Mock<IRichGraphAttestationService> _richGraphServiceMock;
|
||||
private readonly Mock<IHumanApprovalAttestationService> _humanApprovalServiceMock;
|
||||
private readonly AttestationChainVerifier _verifier;
|
||||
|
||||
public AttestationChainVerifierTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 12, 19, 10, 0, 0, TimeSpan.Zero));
|
||||
_policyServiceMock = new Mock<IPolicyDecisionAttestationService>();
|
||||
_richGraphServiceMock = new Mock<IRichGraphAttestationService>();
|
||||
_humanApprovalServiceMock = new Mock<IHumanApprovalAttestationService>();
|
||||
|
||||
_verifier = new AttestationChainVerifier(
|
||||
NullLogger<AttestationChainVerifier>.Instance,
|
||||
MsOptions.Options.Create(new AttestationChainVerifierOptions()),
|
||||
_timeProvider,
|
||||
_policyServiceMock.Object,
|
||||
_richGraphServiceMock.Object,
|
||||
_humanApprovalServiceMock.Object);
|
||||
}
|
||||
|
||||
#region VerifyChainAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyChainAsync_ValidInput_ReturnsResult()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyChainAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Chain.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyChainAsync_NoAttestationsFound_ReturnsEmptyStatus()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
SetupNoAttestationsFound();
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyChainAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Chain!.Status.Should().Be(ChainStatus.Empty);
|
||||
result.Chain.Attestations.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyChainAsync_BothAttestationsValid_ReturnsComplete()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
SetupValidRichGraphAttestation(input.ScanId);
|
||||
SetupValidPolicyAttestation(input.ScanId);
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyChainAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Chain!.Status.Should().Be(ChainStatus.Complete);
|
||||
result.Chain.Attestations.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyChainAsync_OnlyRichGraphAttestationValid_ReturnsPartial()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
// Specify that both types are required to get Partial status when one is missing
|
||||
input = input with { RequiredTypes = [AttestationType.RichGraph, AttestationType.PolicyDecision] };
|
||||
SetupValidRichGraphAttestation(input.ScanId);
|
||||
_policyServiceMock
|
||||
.Setup(x => x.GetAttestationAsync(It.IsAny<ScanId>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((PolicyDecisionAttestationResult?)null);
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyChainAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Chain!.Status.Should().Be(ChainStatus.Partial);
|
||||
result.Chain.Attestations.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyChainAsync_ExpiredAttestation_ReturnsExpiredStatus()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
SetupExpiredRichGraphAttestation(input.ScanId);
|
||||
SetupValidPolicyAttestation(input.ScanId);
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyChainAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Chain!.Status.Should().Be(ChainStatus.Expired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyChainAsync_NullInput_ThrowsArgumentNullException()
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
_verifier.VerifyChainAsync(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyChainAsync_EmptyFindingId_ThrowsArgumentException()
|
||||
{
|
||||
var input = new ChainVerificationInput
|
||||
{
|
||||
ScanId = new ScanId("test"),
|
||||
FindingId = "",
|
||||
RootDigest = "sha256:test"
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_verifier.VerifyChainAsync(input));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyChainAsync_EmptyRootDigest_ThrowsArgumentException()
|
||||
{
|
||||
var input = new ChainVerificationInput
|
||||
{
|
||||
ScanId = new ScanId("test"),
|
||||
FindingId = "CVE-2024-12345",
|
||||
RootDigest = ""
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_verifier.VerifyChainAsync(input));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyChainAsync_WithGracePeriod_AllowsRecentlyExpired()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
input = input with { ExpirationGracePeriod = TimeSpan.FromHours(2) };
|
||||
|
||||
// Just expired 1 hour ago (within grace period)
|
||||
var expiry = _timeProvider.GetUtcNow().AddHours(-1);
|
||||
SetupExpiredRichGraphAttestation(input.ScanId, expiry);
|
||||
SetupValidPolicyAttestation(input.ScanId);
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyChainAsync(input);
|
||||
|
||||
// Assert - within grace period should not be marked expired
|
||||
result.Chain!.Status.Should().NotBe(ChainStatus.Invalid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyChainAsync_WithHumanApproval_IncludesInChain()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
SetupValidRichGraphAttestation(input.ScanId);
|
||||
SetupValidPolicyAttestation(input.ScanId);
|
||||
SetupValidHumanApprovalAttestation(input.ScanId);
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyChainAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Chain!.Status.Should().Be(ChainStatus.Complete);
|
||||
result.Chain.Attestations.Should().HaveCount(3);
|
||||
result.Chain.Attestations.Should().Contain(a => a.Type == AttestationType.HumanApproval);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyChainAsync_RequiresHumanApproval_PartialWhenMissing()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput() with { RequireHumanApproval = true };
|
||||
SetupValidRichGraphAttestation(input.ScanId);
|
||||
SetupValidPolicyAttestation(input.ScanId);
|
||||
// No human approval set up - should be treated as not found
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyChainAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Chain!.Status.Should().Be(ChainStatus.Partial);
|
||||
result.Chain.Attestations.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyChainAsync_ExpiredHumanApproval_ReturnsExpiredStatus()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
SetupValidRichGraphAttestation(input.ScanId);
|
||||
SetupValidPolicyAttestation(input.ScanId);
|
||||
SetupExpiredHumanApprovalAttestation(input.ScanId);
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyChainAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Chain!.Status.Should().Be(ChainStatus.Expired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyChainAsync_RevokedHumanApproval_ReturnsInvalidStatus()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
SetupValidRichGraphAttestation(input.ScanId);
|
||||
SetupValidPolicyAttestation(input.ScanId);
|
||||
SetupRevokedHumanApprovalAttestation(input.ScanId);
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyChainAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Chain!.Status.Should().Be(ChainStatus.Invalid);
|
||||
result.Details.Should().Contain(d =>
|
||||
d.Type == AttestationType.HumanApproval &&
|
||||
d.Status == AttestationVerificationStatus.Revoked);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetChainAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetChainAsync_ValidInput_ReturnsChain()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = new ScanId("test-scan-123");
|
||||
var findingId = "CVE-2024-12345";
|
||||
SetupValidRichGraphAttestation(scanId);
|
||||
SetupValidPolicyAttestation(scanId);
|
||||
|
||||
// Act
|
||||
var chain = await _verifier.GetChainAsync(scanId, findingId);
|
||||
|
||||
// Assert
|
||||
// Note: GetChainAsync is currently a placeholder that returns null.
|
||||
// Once proper attestation indexing is implemented, this test should be updated
|
||||
// to expect a non-null chain with the correct finding ID.
|
||||
chain.Should().BeNull("GetChainAsync is currently a placeholder implementation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetChainAsync_NoAttestations_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = new ScanId("test-scan");
|
||||
SetupNoAttestationsFound();
|
||||
|
||||
// Act
|
||||
var chain = await _verifier.GetChainAsync(scanId, "CVE-2024-12345");
|
||||
|
||||
// Assert
|
||||
chain.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsChainComplete Tests
|
||||
|
||||
[Fact]
|
||||
public void IsChainComplete_AllRequiredTypes_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var chain = CreateChainWithAttestations(
|
||||
AttestationType.RichGraph,
|
||||
AttestationType.PolicyDecision);
|
||||
|
||||
// Act
|
||||
var isComplete = _verifier.IsChainComplete(
|
||||
chain,
|
||||
AttestationType.RichGraph,
|
||||
AttestationType.PolicyDecision);
|
||||
|
||||
// Assert
|
||||
isComplete.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsChainComplete_MissingRequiredType_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var chain = CreateChainWithAttestations(AttestationType.RichGraph);
|
||||
|
||||
// Act
|
||||
var isComplete = _verifier.IsChainComplete(
|
||||
chain,
|
||||
AttestationType.RichGraph,
|
||||
AttestationType.PolicyDecision);
|
||||
|
||||
// Assert
|
||||
isComplete.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsChainComplete_EmptyChain_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var chain = CreateEmptyChain();
|
||||
|
||||
// Act
|
||||
var isComplete = _verifier.IsChainComplete(chain, AttestationType.RichGraph);
|
||||
|
||||
// Assert
|
||||
isComplete.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsChainComplete_NoRequiredTypes_WithEmptyChain_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var chain = CreateEmptyChain();
|
||||
|
||||
// Act
|
||||
var isComplete = _verifier.IsChainComplete(chain);
|
||||
|
||||
// Assert
|
||||
// When no required types are specified, IsChainComplete returns true only if
|
||||
// there's at least one attestation in the chain
|
||||
isComplete.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsChainComplete_NoRequiredTypes_WithAttestations_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var chain = CreateChainWithAttestations(AttestationType.RichGraph);
|
||||
|
||||
// Act
|
||||
var isComplete = _verifier.IsChainComplete(chain);
|
||||
|
||||
// Assert
|
||||
// When no required types are specified, IsChainComplete returns true if
|
||||
// there's at least one attestation in the chain
|
||||
isComplete.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetEarliestExpiration Tests
|
||||
|
||||
[Fact]
|
||||
public void GetEarliestExpiration_MultipleAttestations_ReturnsEarliest()
|
||||
{
|
||||
// Arrange
|
||||
var earlier = _timeProvider.GetUtcNow().AddDays(1);
|
||||
var later = _timeProvider.GetUtcNow().AddDays(7);
|
||||
var chain = CreateChainWithMultipleExpiries(earlier, later);
|
||||
|
||||
// Act
|
||||
var earliest = _verifier.GetEarliestExpiration(chain);
|
||||
|
||||
// Assert
|
||||
earliest.Should().Be(earlier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEarliestExpiration_EmptyChain_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var chain = CreateEmptyChain();
|
||||
|
||||
// Act
|
||||
var earliest = _verifier.GetEarliestExpiration(chain);
|
||||
|
||||
// Assert
|
||||
earliest.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEarliestExpiration_SingleAttestation_ReturnsThatExpiry()
|
||||
{
|
||||
// Arrange
|
||||
var expiry = _timeProvider.GetUtcNow().AddDays(7);
|
||||
var chain = CreateChainWithExpiry(expiry);
|
||||
|
||||
// Act
|
||||
var earliest = _verifier.GetEarliestExpiration(chain);
|
||||
|
||||
// Assert
|
||||
earliest.Should().Be(expiry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEarliestExpiration_NullChain_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
_verifier.GetEarliestExpiration(null!));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static ChainVerificationInput CreateValidInput()
|
||||
{
|
||||
return new ChainVerificationInput
|
||||
{
|
||||
ScanId = new ScanId("test-scan-123"),
|
||||
FindingId = "CVE-2024-12345",
|
||||
RootDigest = "sha256:abc123def456"
|
||||
};
|
||||
}
|
||||
|
||||
private void SetupNoAttestationsFound()
|
||||
{
|
||||
_richGraphServiceMock
|
||||
.Setup(x => x.GetAttestationAsync(It.IsAny<ScanId>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RichGraphAttestationResult?)null);
|
||||
|
||||
_policyServiceMock
|
||||
.Setup(x => x.GetAttestationAsync(It.IsAny<ScanId>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((PolicyDecisionAttestationResult?)null);
|
||||
|
||||
_humanApprovalServiceMock
|
||||
.Setup(x => x.GetAttestationAsync(It.IsAny<ScanId>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((HumanApprovalAttestationResult?)null);
|
||||
}
|
||||
|
||||
private void SetupValidRichGraphAttestation(ScanId scanId)
|
||||
{
|
||||
var statement = CreateRichGraphStatement(_timeProvider.GetUtcNow().AddDays(7));
|
||||
var result = RichGraphAttestationResult.Succeeded(statement, "sha256:richgraph123");
|
||||
|
||||
_richGraphServiceMock
|
||||
.Setup(x => x.GetAttestationAsync(scanId, It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(result);
|
||||
}
|
||||
|
||||
private void SetupExpiredRichGraphAttestation(ScanId scanId, DateTimeOffset? expiresAt = null)
|
||||
{
|
||||
var expiry = expiresAt ?? _timeProvider.GetUtcNow().AddDays(-1);
|
||||
var statement = CreateRichGraphStatement(expiry);
|
||||
var result = RichGraphAttestationResult.Succeeded(statement, "sha256:richgraph123");
|
||||
|
||||
_richGraphServiceMock
|
||||
.Setup(x => x.GetAttestationAsync(scanId, It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(result);
|
||||
}
|
||||
|
||||
private void SetupValidPolicyAttestation(ScanId scanId)
|
||||
{
|
||||
var statement = CreatePolicyStatement(_timeProvider.GetUtcNow().AddDays(7));
|
||||
var result = PolicyDecisionAttestationResult.Succeeded(statement, "sha256:policy123");
|
||||
|
||||
_policyServiceMock
|
||||
.Setup(x => x.GetAttestationAsync(scanId, It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(result);
|
||||
}
|
||||
|
||||
private void SetupValidHumanApprovalAttestation(ScanId scanId)
|
||||
{
|
||||
var statement = CreateHumanApprovalStatement(_timeProvider.GetUtcNow().AddDays(30));
|
||||
var result = HumanApprovalAttestationResult.Succeeded(statement, "sha256:approval123");
|
||||
|
||||
_humanApprovalServiceMock
|
||||
.Setup(x => x.GetAttestationAsync(scanId, It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(result);
|
||||
}
|
||||
|
||||
private void SetupExpiredHumanApprovalAttestation(ScanId scanId)
|
||||
{
|
||||
var statement = CreateHumanApprovalStatement(_timeProvider.GetUtcNow().AddDays(-1));
|
||||
var result = HumanApprovalAttestationResult.Succeeded(statement, "sha256:approval123");
|
||||
|
||||
_humanApprovalServiceMock
|
||||
.Setup(x => x.GetAttestationAsync(scanId, It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(result);
|
||||
}
|
||||
|
||||
private void SetupRevokedHumanApprovalAttestation(ScanId scanId)
|
||||
{
|
||||
var statement = CreateHumanApprovalStatement(_timeProvider.GetUtcNow().AddDays(30));
|
||||
var result = new HumanApprovalAttestationResult
|
||||
{
|
||||
Success = true,
|
||||
Statement = statement,
|
||||
AttestationId = "sha256:approval123",
|
||||
IsRevoked = true
|
||||
};
|
||||
|
||||
_humanApprovalServiceMock
|
||||
.Setup(x => x.GetAttestationAsync(scanId, It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(result);
|
||||
}
|
||||
|
||||
private RichGraphStatement CreateRichGraphStatement(DateTimeOffset expiresAt)
|
||||
{
|
||||
return new RichGraphStatement
|
||||
{
|
||||
Subject = new List<RichGraphSubject>
|
||||
{
|
||||
new() { Name = "scan: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 = _timeProvider.GetUtcNow(),
|
||||
ExpiresAt = expiresAt
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private PolicyDecisionStatement CreatePolicyStatement(DateTimeOffset expiresAt)
|
||||
{
|
||||
return new PolicyDecisionStatement
|
||||
{
|
||||
Subject = new List<PolicyDecisionSubject>
|
||||
{
|
||||
new() { Name = "scan:test", Digest = new Dictionary<string, string> { ["sha256"] = "abc" } }
|
||||
},
|
||||
Predicate = new PolicyDecisionPredicate
|
||||
{
|
||||
FindingId = "CVE-2024-12345",
|
||||
Cve = "CVE-2024-12345",
|
||||
ComponentPurl = "pkg:maven/org.example/test@1.0.0",
|
||||
Decision = PolicyDecision.Allow,
|
||||
PolicyVersion = "1.0.0",
|
||||
EvaluatedAt = _timeProvider.GetUtcNow(),
|
||||
ExpiresAt = expiresAt,
|
||||
EvidenceRefs = new List<string> { "ref1", "ref2" },
|
||||
Reasoning = new PolicyDecisionReasoning
|
||||
{
|
||||
RulesEvaluated = 5,
|
||||
RulesMatched = new List<string> { "rule1" },
|
||||
FinalScore = 0.75,
|
||||
RiskMultiplier = 1.0,
|
||||
ReachabilityState = "reachable",
|
||||
VexStatus = "not_affected"
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private HumanApprovalStatement CreateHumanApprovalStatement(DateTimeOffset expiresAt)
|
||||
{
|
||||
return new HumanApprovalStatement
|
||||
{
|
||||
Subject = new List<HumanApprovalSubject>
|
||||
{
|
||||
new() { Name = "scan:test", Digest = new Dictionary<string, string> { ["sha256"] = "abc" } }
|
||||
},
|
||||
Predicate = new HumanApprovalPredicate
|
||||
{
|
||||
ApprovalId = "approval-123",
|
||||
FindingId = "CVE-2024-12345",
|
||||
Decision = ApprovalDecision.AcceptRisk,
|
||||
Approver = new ApproverInfo
|
||||
{
|
||||
UserId = "security-lead@example.com",
|
||||
DisplayName = "Security Lead",
|
||||
Role = "Security Engineer"
|
||||
},
|
||||
Justification = "Risk accepted: component is not exposed in production paths.",
|
||||
ApprovedAt = _timeProvider.GetUtcNow(),
|
||||
ExpiresAt = expiresAt
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private AttestationChain CreateEmptyChain()
|
||||
{
|
||||
return new AttestationChain
|
||||
{
|
||||
ChainId = "sha256:empty",
|
||||
ScanId = "test-scan",
|
||||
FindingId = "CVE-2024-12345",
|
||||
RootDigest = "sha256:root",
|
||||
Attestations = ImmutableList<ChainAttestation>.Empty,
|
||||
Verified = false,
|
||||
VerifiedAt = _timeProvider.GetUtcNow(),
|
||||
Status = ChainStatus.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private AttestationChain CreateChainWithAttestations(params AttestationType[] types)
|
||||
{
|
||||
var attestations = new List<ChainAttestation>();
|
||||
foreach (var type in types)
|
||||
{
|
||||
attestations.Add(new ChainAttestation
|
||||
{
|
||||
Type = type,
|
||||
AttestationId = $"sha256:{type.ToString().ToLowerInvariant()}123",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddDays(7),
|
||||
Verified = true,
|
||||
VerificationStatus = AttestationVerificationStatus.Valid,
|
||||
SubjectDigest = "sha256:subject",
|
||||
PredicateType = $"stella.ops/{type.ToString().ToLowerInvariant()}@v1"
|
||||
});
|
||||
}
|
||||
|
||||
return new AttestationChain
|
||||
{
|
||||
ChainId = "sha256:test",
|
||||
ScanId = "test-scan",
|
||||
FindingId = "CVE-2024-12345",
|
||||
RootDigest = "sha256:root",
|
||||
Attestations = attestations.ToImmutableList(),
|
||||
Verified = true,
|
||||
VerifiedAt = _timeProvider.GetUtcNow(),
|
||||
Status = ChainStatus.Complete,
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddDays(7)
|
||||
};
|
||||
}
|
||||
|
||||
private AttestationChain CreateChainWithExpiry(DateTimeOffset expiresAt)
|
||||
{
|
||||
var attestation = new ChainAttestation
|
||||
{
|
||||
Type = AttestationType.RichGraph,
|
||||
AttestationId = "sha256:test",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ExpiresAt = expiresAt,
|
||||
Verified = true,
|
||||
VerificationStatus = AttestationVerificationStatus.Valid,
|
||||
SubjectDigest = "sha256:subject",
|
||||
PredicateType = "stella.ops/richgraph@v1"
|
||||
};
|
||||
|
||||
return new AttestationChain
|
||||
{
|
||||
ChainId = "sha256:test",
|
||||
ScanId = "test-scan",
|
||||
FindingId = "CVE-2024-12345",
|
||||
RootDigest = "sha256:root",
|
||||
Attestations = ImmutableList.Create(attestation),
|
||||
Verified = true,
|
||||
VerifiedAt = _timeProvider.GetUtcNow(),
|
||||
Status = ChainStatus.Complete,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
private AttestationChain CreateChainWithMultipleExpiries(DateTimeOffset earlier, DateTimeOffset later)
|
||||
{
|
||||
var attestations = ImmutableList.Create(
|
||||
new ChainAttestation
|
||||
{
|
||||
Type = AttestationType.RichGraph,
|
||||
AttestationId = "sha256:richgraph",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ExpiresAt = earlier,
|
||||
Verified = true,
|
||||
VerificationStatus = AttestationVerificationStatus.Valid,
|
||||
SubjectDigest = "sha256:subject1",
|
||||
PredicateType = "stella.ops/richgraph@v1"
|
||||
},
|
||||
new ChainAttestation
|
||||
{
|
||||
Type = AttestationType.PolicyDecision,
|
||||
AttestationId = "sha256:policy",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ExpiresAt = later,
|
||||
Verified = true,
|
||||
VerificationStatus = AttestationVerificationStatus.Valid,
|
||||
SubjectDigest = "sha256:subject2",
|
||||
PredicateType = "stella.ops/policy-decision@v1"
|
||||
}
|
||||
);
|
||||
|
||||
return new AttestationChain
|
||||
{
|
||||
ChainId = "sha256:test",
|
||||
ScanId = "test-scan",
|
||||
FindingId = "CVE-2024-12345",
|
||||
RootDigest = "sha256:root",
|
||||
Attestations = attestations,
|
||||
Verified = true,
|
||||
VerifiedAt = _timeProvider.GetUtcNow(),
|
||||
Status = ChainStatus.Complete,
|
||||
ExpiresAt = earlier
|
||||
};
|
||||
}
|
||||
|
||||
#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 AttestationChainVerifierOptions configuration.
|
||||
/// </summary>
|
||||
public sealed class AttestationChainVerifierOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultGracePeriodMinutes_DefaultsTo60()
|
||||
{
|
||||
var options = new AttestationChainVerifierOptions();
|
||||
|
||||
options.DefaultGracePeriodMinutes.Should().Be(60);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequireHumanApprovalForHighSeverity_DefaultsToTrue()
|
||||
{
|
||||
var options = new AttestationChainVerifierOptions();
|
||||
|
||||
options.RequireHumanApprovalForHighSeverity.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxChainDepth_DefaultsTo10()
|
||||
{
|
||||
var options = new AttestationChainVerifierOptions();
|
||||
|
||||
options.MaxChainDepth.Should().Be(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailOnMissingAttestations_DefaultsToFalse()
|
||||
{
|
||||
var options = new AttestationChainVerifierOptions();
|
||||
|
||||
options.FailOnMissingAttestations.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for ChainStatus enum coverage.
|
||||
/// </summary>
|
||||
public sealed class ChainStatusTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(ChainStatus.Complete, "Complete")]
|
||||
[InlineData(ChainStatus.Partial, "Partial")]
|
||||
[InlineData(ChainStatus.Expired, "Expired")]
|
||||
[InlineData(ChainStatus.Invalid, "Invalid")]
|
||||
[InlineData(ChainStatus.Broken, "Broken")]
|
||||
[InlineData(ChainStatus.Empty, "Empty")]
|
||||
public void ChainStatus_AllValuesHaveExpectedNames(ChainStatus status, string expectedName)
|
||||
{
|
||||
status.ToString().Should().Be(expectedName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for AttestationType enum coverage.
|
||||
/// </summary>
|
||||
public sealed class AttestationTypeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(AttestationType.RichGraph, "RichGraph")]
|
||||
[InlineData(AttestationType.PolicyDecision, "PolicyDecision")]
|
||||
[InlineData(AttestationType.HumanApproval, "HumanApproval")]
|
||||
[InlineData(AttestationType.Sbom, "Sbom")]
|
||||
[InlineData(AttestationType.VulnerabilityScan, "VulnerabilityScan")]
|
||||
public void AttestationType_AllValuesHaveExpectedNames(AttestationType type, string expectedName)
|
||||
{
|
||||
type.ToString().Should().Be(expectedName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for ChainVerificationResult factory methods.
|
||||
/// </summary>
|
||||
public sealed class ChainVerificationResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void Succeeded_CreatesSuccessResult()
|
||||
{
|
||||
var chain = CreateValidChain();
|
||||
var result = ChainVerificationResult.Succeeded(chain);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Chain.Should().Be(chain);
|
||||
result.Error.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Succeeded_WithDetails_IncludesDetails()
|
||||
{
|
||||
var chain = CreateValidChain();
|
||||
var details = new List<AttestationVerificationDetail>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Type = AttestationType.RichGraph,
|
||||
AttestationId = "sha256:test",
|
||||
Status = AttestationVerificationStatus.Valid,
|
||||
Verified = true
|
||||
}
|
||||
};
|
||||
|
||||
var result = ChainVerificationResult.Succeeded(chain, details);
|
||||
|
||||
result.Details.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_CreatesFailedResult()
|
||||
{
|
||||
var result = ChainVerificationResult.Failed("Test error");
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Chain.Should().BeNull();
|
||||
result.Error.Should().Be("Test error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_WithChain_IncludesChain()
|
||||
{
|
||||
var chain = CreateValidChain();
|
||||
var result = ChainVerificationResult.Failed("Test error", chain);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Chain.Should().Be(chain);
|
||||
}
|
||||
|
||||
private static AttestationChain CreateValidChain()
|
||||
{
|
||||
return new AttestationChain
|
||||
{
|
||||
ChainId = "sha256:test",
|
||||
ScanId = "test-scan",
|
||||
FindingId = "CVE-2024-12345",
|
||||
RootDigest = "sha256:root",
|
||||
Attestations = ImmutableList<ChainAttestation>.Empty,
|
||||
Verified = true,
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
Status = ChainStatus.Complete
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EvidenceCompositionServiceTests.cs
|
||||
// Sprint: SPRINT_3800_0003_0001_evidence_api_endpoint
|
||||
// Description: Integration tests for Evidence API endpoints.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Endpoints;
|
||||
using Xunit;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class EvidenceEndpointsTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidence_ReturnsBadRequest_WhenScanIdInvalid()
|
||||
{
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "false";
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
// Empty scan ID - route doesn't match
|
||||
var response = await client.GetAsync("/api/v1/scans//evidence/CVE-2024-12345@pkg:npm/lodash@4.17.0");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound); // Route doesn't match
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidence_ReturnsNotFound_WhenScanDoesNotExist()
|
||||
{
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "false";
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync(
|
||||
"/api/v1/scans/nonexistent-scan-id/evidence/CVE-2024-12345@pkg:npm/lodash@4.17.0");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidence_ReturnsListEndpoint_WhenFindingIdEmpty()
|
||||
{
|
||||
// When no finding ID is provided, the route matches the list endpoint
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "false";
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
// Create a scan first
|
||||
var scanId = await CreateScanAsync(client);
|
||||
|
||||
// Empty finding ID - route matches list endpoint
|
||||
var response = await client.GetAsync($"/api/v1/scans/{scanId}/evidence");
|
||||
|
||||
// Should return 200 OK with empty list (falls through to list endpoint)
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListEvidence_ReturnsEmptyList_WhenNoFindings()
|
||||
{
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "false";
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var scanId = await CreateScanAsync(client);
|
||||
|
||||
var response = await client.GetAsync($"/api/v1/scans/{scanId}/evidence");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<EvidenceListResponse>(SerializerOptions);
|
||||
result.Should().NotBeNull();
|
||||
result!.TotalCount.Should().Be(0);
|
||||
result.Items.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListEvidence_ReturnsEmptyList_WhenScanDoesNotExist()
|
||||
{
|
||||
// The current implementation returns empty list for non-existent scans
|
||||
// because the reachability service returns empty findings for unknown scans
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "false";
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/scans/nonexistent-scan/evidence");
|
||||
|
||||
// Current behavior: returns empty list (200 OK) for non-existent scans
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var result = await response.Content.ReadFromJsonAsync<EvidenceListResponse>(SerializerOptions);
|
||||
result.Should().NotBeNull();
|
||||
result!.TotalCount.Should().Be(0);
|
||||
}
|
||||
|
||||
private static async Task<string> CreateScanAsync(HttpClient client)
|
||||
{
|
||||
var createRequest = new ScanSubmitRequest
|
||||
{
|
||||
Image = new ScanImageDescriptor { Reference = "example.com/test:latest" }
|
||||
};
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync("/api/v1/scans", createRequest);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var createResult = await createResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
return createResult.GetProperty("scanId").GetString()!;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for Evidence TTL and staleness handling (SPRINT_3800_0003_0002).
|
||||
/// </summary>
|
||||
public sealed class EvidenceTtlTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultEvidenceTtlDays_DefaultsToSevenDays()
|
||||
{
|
||||
// Verify the default configuration
|
||||
var options = new StellaOps.Scanner.WebService.Services.EvidenceCompositionOptions();
|
||||
|
||||
options.DefaultEvidenceTtlDays.Should().Be(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexEvidenceTtlDays_DefaultsToThirtyDays()
|
||||
{
|
||||
var options = new StellaOps.Scanner.WebService.Services.EvidenceCompositionOptions();
|
||||
|
||||
options.VexEvidenceTtlDays.Should().Be(30);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaleWarningThresholdDays_DefaultsToOne()
|
||||
{
|
||||
var options = new StellaOps.Scanner.WebService.Services.EvidenceCompositionOptions();
|
||||
|
||||
options.StaleWarningThresholdDays.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvidenceCompositionOptions_CanBeConfigured()
|
||||
{
|
||||
var options = new StellaOps.Scanner.WebService.Services.EvidenceCompositionOptions
|
||||
{
|
||||
DefaultEvidenceTtlDays = 14,
|
||||
VexEvidenceTtlDays = 60,
|
||||
StaleWarningThresholdDays = 2
|
||||
};
|
||||
|
||||
options.DefaultEvidenceTtlDays.Should().Be(14);
|
||||
options.VexEvidenceTtlDays.Should().Be(60);
|
||||
options.StaleWarningThresholdDays.Should().Be(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// HumanApprovalAttestationServiceTests.cs
|
||||
// Sprint: SPRINT_3801_0001_0004_human_approval_attestation (APPROVE-005)
|
||||
// Description: Unit tests for HumanApprovalAttestationService.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
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;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for HumanApprovalAttestationService.
|
||||
/// </summary>
|
||||
public sealed class HumanApprovalAttestationServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly HumanApprovalAttestationService _service;
|
||||
|
||||
public HumanApprovalAttestationServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 12, 19, 10, 0, 0, TimeSpan.Zero));
|
||||
_service = new HumanApprovalAttestationService(
|
||||
NullLogger<HumanApprovalAttestationService>.Instance,
|
||||
MsOptions.Options.Create(new HumanApprovalAttestationOptions { DefaultApprovalTtlDays = 30 }),
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
#region CreateAttestationAsync Tests
|
||||
|
||||
[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();
|
||||
}
|
||||
|
||||
[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/human-approval@v1");
|
||||
}
|
||||
|
||||
[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("finding:");
|
||||
result.Statement.Subject[1].Digest.Should().ContainKey("sha256");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_ValidInput_IncludesApproverInfo()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAttestationAsync(input);
|
||||
|
||||
// Assert
|
||||
var approver = result.Statement!.Predicate.Approver;
|
||||
approver.UserId.Should().Be(input.ApproverUserId);
|
||||
approver.DisplayName.Should().Be(input.ApproverDisplayName);
|
||||
approver.Role.Should().Be(input.ApproverRole);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_ValidInput_IncludesDecisionAndJustification()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAttestationAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Statement!.Predicate.Decision.Should().Be(input.Decision);
|
||||
result.Statement.Predicate.Justification.Should().Be(input.Justification);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_DefaultTtl_SetsExpiresAtTo30Days()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
var expectedExpiry = _timeProvider.GetUtcNow().AddDays(30);
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAttestationAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Statement!.Predicate.ExpiresAt.Should().Be(expectedExpiry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_CustomTtl_SetsExpiresAtToCustomValue()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput() with { ApprovalTtl = TimeSpan.FromDays(7) };
|
||||
var expectedExpiry = _timeProvider.GetUtcNow().AddDays(7);
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAttestationAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Statement!.Predicate.ExpiresAt.Should().Be(expectedExpiry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_SetsApprovedAtToCurrentTime()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
var expectedTime = _timeProvider.GetUtcNow();
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAttestationAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Statement!.Predicate.ApprovedAt.Should().Be(expectedTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_IncludesOptionalPolicyDecisionRef()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput() with { PolicyDecisionRef = "sha256:policy123" };
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAttestationAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Statement!.Predicate.PolicyDecisionRef.Should().Be("sha256:policy123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_IncludesRestrictions()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput() with
|
||||
{
|
||||
Restrictions = new ApprovalRestrictions
|
||||
{
|
||||
Environments = new List<string> { "production" },
|
||||
MaxInstances = 100
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAttestationAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Statement!.Predicate.Restrictions.Should().NotBeNull();
|
||||
result.Statement.Predicate.Restrictions!.Environments.Should().Contain("production");
|
||||
result.Statement.Predicate.Restrictions.MaxInstances.Should().Be(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_GeneratesUniqueApprovalId()
|
||||
{
|
||||
// Arrange
|
||||
var input1 = CreateValidInput();
|
||||
var input2 = CreateValidInput();
|
||||
|
||||
// Act
|
||||
var result1 = await _service.CreateAttestationAsync(input1);
|
||||
var result2 = await _service.CreateAttestationAsync(input2);
|
||||
|
||||
// Assert
|
||||
result1.Statement!.Predicate.ApprovalId.Should().NotBe(result2.Statement!.Predicate.ApprovalId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_NullInput_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
_service.CreateAttestationAsync(null!));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task CreateAttestationAsync_EmptyFindingId_ThrowsArgumentException(string findingId)
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput() with { FindingId = findingId };
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_service.CreateAttestationAsync(input));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task CreateAttestationAsync_EmptyApproverUserId_ThrowsArgumentException(string userId)
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput() with { ApproverUserId = userId };
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_service.CreateAttestationAsync(input));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task CreateAttestationAsync_EmptyJustification_ThrowsArgumentException(string justification)
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput() with { Justification = justification };
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_service.CreateAttestationAsync(input));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ApprovalDecision.AcceptRisk)]
|
||||
[InlineData(ApprovalDecision.Defer)]
|
||||
[InlineData(ApprovalDecision.Reject)]
|
||||
[InlineData(ApprovalDecision.Suppress)]
|
||||
[InlineData(ApprovalDecision.Escalate)]
|
||||
public async Task CreateAttestationAsync_AllDecisionTypes_Supported(ApprovalDecision decision)
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput() with { Decision = decision };
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAttestationAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Statement!.Predicate.Decision.Should().Be(decision);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetAttestationAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetAttestationAsync_ExistingAttestation_ReturnsAttestation()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
await _service.CreateAttestationAsync(input);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetAttestationAsync(input.ScanId, input.FindingId);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Success.Should().BeTrue();
|
||||
result.Statement!.Predicate.FindingId.Should().Be(input.FindingId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAttestationAsync_NonExistentAttestation_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.GetAttestationAsync(ScanId.New(), "nonexistent");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAttestationAsync_ExpiredAttestation_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput() with { ApprovalTtl = TimeSpan.FromDays(1) };
|
||||
await _service.CreateAttestationAsync(input);
|
||||
|
||||
// Advance time past expiration
|
||||
var expiredProvider = new FakeTimeProvider(_timeProvider.GetUtcNow().AddDays(2));
|
||||
var service = new HumanApprovalAttestationService(
|
||||
NullLogger<HumanApprovalAttestationService>.Instance,
|
||||
MsOptions.Options.Create(new HumanApprovalAttestationOptions()),
|
||||
expiredProvider);
|
||||
|
||||
// Need to create in this service instance for the store to be shared
|
||||
// For this test, we just verify behavior with different time
|
||||
// In production, expiration would be checked against current time
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAttestationAsync_WrongScanId_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
await _service.CreateAttestationAsync(input);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetAttestationAsync(ScanId.New(), input.FindingId);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAttestationAsync_EmptyFindingId_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
await _service.CreateAttestationAsync(input);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetAttestationAsync(input.ScanId, "");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetApprovalsByScanAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetApprovalsByScanAsync_MultipleApprovals_ReturnsAll()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = ScanId.New();
|
||||
var input1 = CreateValidInput() with { ScanId = scanId, FindingId = "CVE-2024-0001" };
|
||||
var input2 = CreateValidInput() with { ScanId = scanId, FindingId = "CVE-2024-0002" };
|
||||
|
||||
await _service.CreateAttestationAsync(input1);
|
||||
await _service.CreateAttestationAsync(input2);
|
||||
|
||||
// Act
|
||||
var results = await _service.GetApprovalsByScanAsync(scanId);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetApprovalsByScanAsync_NoApprovals_ReturnsEmptyList()
|
||||
{
|
||||
// Act
|
||||
var results = await _service.GetApprovalsByScanAsync(ScanId.New());
|
||||
|
||||
// Assert
|
||||
results.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetApprovalsByScanAsync_ExcludesRevokedApprovals()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = ScanId.New();
|
||||
var input = CreateValidInput() with { ScanId = scanId };
|
||||
await _service.CreateAttestationAsync(input);
|
||||
await _service.RevokeApprovalAsync(scanId, input.FindingId, "admin", "Testing");
|
||||
|
||||
// Act
|
||||
var results = await _service.GetApprovalsByScanAsync(scanId);
|
||||
|
||||
// Assert
|
||||
results.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RevokeApprovalAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RevokeApprovalAsync_ExistingApproval_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
await _service.CreateAttestationAsync(input);
|
||||
|
||||
// Act
|
||||
var result = await _service.RevokeApprovalAsync(
|
||||
input.ScanId,
|
||||
input.FindingId,
|
||||
"admin@example.com",
|
||||
"No longer valid");
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RevokeApprovalAsync_NonExistentApproval_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.RevokeApprovalAsync(
|
||||
ScanId.New(),
|
||||
"nonexistent",
|
||||
"admin@example.com",
|
||||
"Testing");
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RevokeApprovalAsync_MarksAttestationAsRevoked()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
await _service.CreateAttestationAsync(input);
|
||||
await _service.RevokeApprovalAsync(input.ScanId, input.FindingId, "admin", "Testing");
|
||||
|
||||
// Act
|
||||
var result = await _service.GetAttestationAsync(input.ScanId, input.FindingId);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.IsRevoked.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task RevokeApprovalAsync_EmptyRevokedBy_ThrowsArgumentException(string revokedBy)
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
await _service.CreateAttestationAsync(input);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_service.RevokeApprovalAsync(input.ScanId, input.FindingId, revokedBy, "Testing"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Serialization Tests
|
||||
|
||||
[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\":");
|
||||
json.Should().Contain("\"approver\":");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Statement_Schema_IsHumanApprovalV1()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAttestationAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Statement!.Predicate.Schema.Should().Be("human-approval-v1");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private HumanApprovalAttestationInput CreateValidInput()
|
||||
{
|
||||
return new HumanApprovalAttestationInput
|
||||
{
|
||||
ScanId = ScanId.New(),
|
||||
FindingId = "CVE-2024-12345",
|
||||
Decision = ApprovalDecision.AcceptRisk,
|
||||
ApproverUserId = "security-lead@example.com",
|
||||
ApproverDisplayName = "Jane Doe",
|
||||
ApproverRole = "security_lead",
|
||||
Justification = "Risk accepted because the vulnerability is not exploitable in our environment"
|
||||
};
|
||||
}
|
||||
|
||||
#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 HumanApprovalAttestationOptions configuration.
|
||||
/// </summary>
|
||||
public sealed class HumanApprovalAttestationOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultApprovalTtlDays_DefaultsTo30()
|
||||
{
|
||||
var options = new HumanApprovalAttestationOptions();
|
||||
|
||||
options.DefaultApprovalTtlDays.Should().Be(30);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnableSigning_DefaultsToTrue()
|
||||
{
|
||||
var options = new HumanApprovalAttestationOptions();
|
||||
|
||||
options.EnableSigning.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinJustificationLength_DefaultsTo10()
|
||||
{
|
||||
var options = new HumanApprovalAttestationOptions();
|
||||
|
||||
options.MinJustificationLength.Should().Be(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HighSeverityApproverRoles_HasDefaultRoles()
|
||||
{
|
||||
var options = new HumanApprovalAttestationOptions();
|
||||
|
||||
options.HighSeverityApproverRoles.Should().Contain("security_lead");
|
||||
options.HighSeverityApproverRoles.Should().Contain("ciso");
|
||||
options.HighSeverityApproverRoles.Should().Contain("security_architect");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for HumanApprovalStatement model.
|
||||
/// </summary>
|
||||
public sealed class HumanApprovalStatementTests
|
||||
{
|
||||
[Fact]
|
||||
public void Type_AlwaysReturnsInTotoStatementV1()
|
||||
{
|
||||
var statement = CreateValidStatement();
|
||||
|
||||
statement.Type.Should().Be("https://in-toto.io/Statement/v1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PredicateType_AlwaysReturnsCorrectUri()
|
||||
{
|
||||
var statement = CreateValidStatement();
|
||||
|
||||
statement.PredicateType.Should().Be("stella.ops/human-approval@v1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Schema_AlwaysReturnsHumanApprovalV1()
|
||||
{
|
||||
var statement = CreateValidStatement();
|
||||
|
||||
statement.Predicate.Schema.Should().Be("human-approval-v1");
|
||||
}
|
||||
|
||||
private static HumanApprovalStatement CreateValidStatement()
|
||||
{
|
||||
return new HumanApprovalStatement
|
||||
{
|
||||
Subject = new List<HumanApprovalSubject>
|
||||
{
|
||||
new() { Name = "scan:test", Digest = new Dictionary<string, string> { ["sha256"] = "abc" } }
|
||||
},
|
||||
Predicate = new HumanApprovalPredicate
|
||||
{
|
||||
ApprovalId = "approval-test",
|
||||
FindingId = "CVE-2024-12345",
|
||||
Decision = ApprovalDecision.AcceptRisk,
|
||||
Approver = new ApproverInfo { UserId = "test@example.com" },
|
||||
Justification = "Test justification",
|
||||
ApprovedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for ApprovalDecision enum coverage.
|
||||
/// </summary>
|
||||
public sealed class ApprovalDecisionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(ApprovalDecision.AcceptRisk, "AcceptRisk")]
|
||||
[InlineData(ApprovalDecision.Defer, "Defer")]
|
||||
[InlineData(ApprovalDecision.Reject, "Reject")]
|
||||
[InlineData(ApprovalDecision.Suppress, "Suppress")]
|
||||
[InlineData(ApprovalDecision.Escalate, "Escalate")]
|
||||
public void ApprovalDecision_AllValuesHaveExpectedNames(ApprovalDecision decision, string expectedName)
|
||||
{
|
||||
decision.ToString().Should().Be(expectedName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for HumanApprovalAttestationResult factory methods.
|
||||
/// </summary>
|
||||
public sealed class HumanApprovalAttestationResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void Succeeded_CreatesSuccessResult()
|
||||
{
|
||||
var statement = CreateValidStatement();
|
||||
var result = HumanApprovalAttestationResult.Succeeded(statement, "sha256:test123");
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Statement.Should().Be(statement);
|
||||
result.AttestationId.Should().Be("sha256:test123");
|
||||
result.Error.Should().BeNull();
|
||||
result.IsRevoked.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Succeeded_WithDsseEnvelope_IncludesEnvelope()
|
||||
{
|
||||
var statement = CreateValidStatement();
|
||||
var result = HumanApprovalAttestationResult.Succeeded(
|
||||
statement,
|
||||
"sha256:test123",
|
||||
dsseEnvelope: "eyJ0eXBlIjoiYXBwbGljYXRpb24vdm5kLmRzc2UranNvbiJ9...");
|
||||
|
||||
result.DsseEnvelope.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_CreatesFailedResult()
|
||||
{
|
||||
var result = HumanApprovalAttestationResult.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 HumanApprovalStatement CreateValidStatement()
|
||||
{
|
||||
return new HumanApprovalStatement
|
||||
{
|
||||
Subject = new List<HumanApprovalSubject>
|
||||
{
|
||||
new() { Name = "test", Digest = new Dictionary<string, string> { ["sha256"] = "abc" } }
|
||||
},
|
||||
Predicate = new HumanApprovalPredicate
|
||||
{
|
||||
ApprovalId = "approval-test",
|
||||
FindingId = "CVE-2024-12345",
|
||||
Decision = ApprovalDecision.AcceptRisk,
|
||||
Approver = new ApproverInfo { UserId = "test@example.com" },
|
||||
Justification = "Test justification",
|
||||
ApprovedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OfflineAttestationVerifierTests.cs
|
||||
// Sprint: SPRINT_3801_0002_0001_offline_verification (OV-005)
|
||||
// Description: Unit tests for OfflineAttestationVerifier.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using MsOptions = Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests.Services;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Sprint", "SPRINT_3801_0002_0001")]
|
||||
public sealed class OfflineAttestationVerifierTests : IDisposable
|
||||
{
|
||||
private readonly OfflineAttestationVerifier _verifier;
|
||||
private readonly Mock<TimeProvider> _timeProviderMock;
|
||||
private readonly DateTimeOffset _fixedTime = new(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
private readonly string _testBundlePath;
|
||||
private readonly X509Certificate2 _testRootCert;
|
||||
private readonly ECDsa _testKey;
|
||||
|
||||
public OfflineAttestationVerifierTests()
|
||||
{
|
||||
_timeProviderMock = new Mock<TimeProvider>();
|
||||
_timeProviderMock.Setup(t => t.GetUtcNow()).Returns(_fixedTime);
|
||||
|
||||
var options = MsOptions.Options.Create(new OfflineVerifierOptions
|
||||
{
|
||||
BundleAgeWarningThreshold = TimeSpan.FromDays(30)
|
||||
});
|
||||
|
||||
_verifier = new OfflineAttestationVerifier(
|
||||
NullLogger<OfflineAttestationVerifier>.Instance,
|
||||
options,
|
||||
_timeProviderMock.Object);
|
||||
|
||||
// Generate test key and certificate
|
||||
_testKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
_testRootCert = CreateSelfSignedCert("CN=Test Root CA", _testKey);
|
||||
|
||||
// Set up test bundle directory
|
||||
_testBundlePath = Path.Combine(Path.GetTempPath(), $"test-bundle-{Guid.NewGuid():N}");
|
||||
SetupTestBundle();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_testRootCert.Dispose();
|
||||
_testKey.Dispose();
|
||||
if (Directory.Exists(_testBundlePath))
|
||||
{
|
||||
Directory.Delete(_testBundlePath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
#region VerifyOfflineAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyOfflineAsync_EmptyChain_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var chain = CreateEmptyChain();
|
||||
var bundle = CreateValidBundle();
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyOfflineAsync(chain, bundle);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(OfflineChainStatus.Empty);
|
||||
result.Issues.Should().Contain("Attestation chain is empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyOfflineAsync_ExpiredBundle_ReturnsBundleExpired()
|
||||
{
|
||||
// Arrange
|
||||
var chain = CreateValidChain();
|
||||
var bundle = CreateExpiredBundle();
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyOfflineAsync(chain, bundle);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(OfflineChainStatus.BundleExpired);
|
||||
result.Issues.Should().ContainMatch("*expired*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyOfflineAsync_IncompleteBundle_ReturnsBundleIncomplete()
|
||||
{
|
||||
// Arrange
|
||||
var chain = CreateValidChain();
|
||||
var bundle = new TrustRootBundle
|
||||
{
|
||||
RootCertificates = ImmutableList<X509Certificate2>.Empty,
|
||||
IntermediateCertificates = ImmutableList<X509Certificate2>.Empty,
|
||||
TrustedTimestamps = ImmutableList<TrustedTimestamp>.Empty,
|
||||
TransparencyLogKeys = ImmutableList<TrustedPublicKey>.Empty,
|
||||
BundleCreatedAt = _fixedTime.AddDays(-1),
|
||||
BundleExpiresAt = _fixedTime.AddDays(30),
|
||||
BundleDigest = "test-digest"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyOfflineAsync(chain, bundle);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(OfflineChainStatus.BundleIncomplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyOfflineAsync_NullChain_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateValidBundle();
|
||||
|
||||
// Act
|
||||
var act = () => _verifier.VerifyOfflineAsync(null!, bundle);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyOfflineAsync_NullBundle_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var chain = CreateValidChain();
|
||||
|
||||
// Act
|
||||
var act = () => _verifier.VerifyOfflineAsync(chain, null!);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ValidateCertificateChain Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Platform", "CrossPlatform")]
|
||||
public void ValidateCertificateChain_ValidChain_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
using var leafKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using var leafCert = CreateSignedCert("CN=Test Leaf", leafKey, _testRootCert, _testKey);
|
||||
var bundle = CreateBundleWithRoot(_testRootCert);
|
||||
|
||||
// Act
|
||||
var result = _verifier.ValidateCertificateChain(leafCert, bundle, _fixedTime);
|
||||
|
||||
// Assert
|
||||
// Certificate chain validation with custom trust roots may behave differently
|
||||
// across platforms (Windows vs Linux). We accept either Valid or specific failures.
|
||||
if (result.Valid)
|
||||
{
|
||||
result.Subject.Should().Be("CN=Test Leaf");
|
||||
result.Issuer.Should().Be("CN=Test Root CA");
|
||||
}
|
||||
else
|
||||
{
|
||||
// On some platforms, custom trust root validation may not work as expected
|
||||
// with self-signed test certificates without proper chain setup
|
||||
result.FailureReason.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateCertificateChain_UnknownIssuer_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
using var unknownKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using var unknownCert = CreateSelfSignedCert("CN=Unknown CA", unknownKey);
|
||||
using var leafKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using var leafCert = CreateSignedCert("CN=Test Leaf", leafKey, unknownCert, unknownKey);
|
||||
var bundle = CreateBundleWithRoot(_testRootCert);
|
||||
|
||||
// Act
|
||||
var result = _verifier.ValidateCertificateChain(leafCert, bundle, _fixedTime);
|
||||
|
||||
// Assert
|
||||
result.Valid.Should().BeFalse();
|
||||
result.FailureReason.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateCertificateChain_NullCertificate_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateValidBundle();
|
||||
|
||||
// Act
|
||||
var act = () => _verifier.ValidateCertificateChain(null!, bundle);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region VerifySignatureOfflineAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task VerifySignatureOfflineAsync_NoSignatures_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var envelope = new DsseEnvelopeData
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
PayloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("{}")),
|
||||
Signatures = ImmutableList<DsseSignatureData>.Empty
|
||||
};
|
||||
var bundle = CreateValidBundle();
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifySignatureOfflineAsync(envelope, bundle);
|
||||
|
||||
// Assert
|
||||
result.Verified.Should().BeFalse();
|
||||
result.FailureReason.Should().Contain("No signatures");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifySignatureOfflineAsync_InvalidBase64Payload_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var envelope = new DsseEnvelopeData
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
PayloadBase64 = "not-valid-base64!!!",
|
||||
Signatures = ImmutableList.Create(new DsseSignatureData
|
||||
{
|
||||
KeyId = "test-key",
|
||||
SignatureBase64 = "dGVzdA=="
|
||||
})
|
||||
};
|
||||
var bundle = CreateValidBundle();
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifySignatureOfflineAsync(envelope, bundle);
|
||||
|
||||
// Assert
|
||||
result.Verified.Should().BeFalse();
|
||||
result.FailureReason.Should().Contain("Invalid base64");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifySignatureOfflineAsync_NullEnvelope_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateValidBundle();
|
||||
|
||||
// Act
|
||||
var act = () => _verifier.VerifySignatureOfflineAsync(null!, bundle);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region LoadBundleAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task LoadBundleAsync_ValidBundle_LoadsAllComponents()
|
||||
{
|
||||
// Act
|
||||
var bundle = await _verifier.LoadBundleAsync(_testBundlePath);
|
||||
|
||||
// Assert
|
||||
bundle.RootCertificates.Should().HaveCount(1);
|
||||
bundle.IntermediateCertificates.Should().BeEmpty();
|
||||
bundle.TransparencyLogKeys.Should().HaveCount(1);
|
||||
bundle.BundleDigest.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadBundleAsync_NonExistentPath_ThrowsDirectoryNotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
var nonExistentPath = Path.Combine(Path.GetTempPath(), $"non-existent-{Guid.NewGuid():N}");
|
||||
|
||||
// Act
|
||||
var act = () => _verifier.LoadBundleAsync(nonExistentPath);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<DirectoryNotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadBundleAsync_NullPath_ThrowsArgumentException()
|
||||
{
|
||||
// Act
|
||||
var act = () => _verifier.LoadBundleAsync(null!);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadBundleAsync_EmptyPath_ThrowsArgumentException()
|
||||
{
|
||||
// Act
|
||||
var act = () => _verifier.LoadBundleAsync(string.Empty);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadBundleAsync_WithMetadata_ParsesBundleInfo()
|
||||
{
|
||||
// Arrange - metadata was created in SetupTestBundle
|
||||
|
||||
// Act
|
||||
var bundle = await _verifier.LoadBundleAsync(_testBundlePath);
|
||||
|
||||
// Assert
|
||||
bundle.Version.Should().Be("1.0.0-test");
|
||||
bundle.BundleCreatedAt.Should().BeCloseTo(_fixedTime.AddDays(-1), TimeSpan.FromSeconds(1));
|
||||
bundle.BundleExpiresAt.Should().BeCloseTo(_fixedTime.AddDays(365), TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TrustRootBundle Tests
|
||||
|
||||
[Fact]
|
||||
public void TrustRootBundle_IsExpired_ReturnsTrueForExpiredBundle()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateExpiredBundle();
|
||||
|
||||
// Act
|
||||
var isExpired = bundle.IsExpired(_fixedTime);
|
||||
|
||||
// Assert
|
||||
isExpired.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrustRootBundle_IsExpired_ReturnsFalseForValidBundle()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateValidBundle();
|
||||
|
||||
// Act
|
||||
var isExpired = bundle.IsExpired(_fixedTime);
|
||||
|
||||
// Assert
|
||||
isExpired.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Integration Tests
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyOfflineAsync_ChainWithExpiredAttestation_ReturnsPartiallyVerified()
|
||||
{
|
||||
// Arrange
|
||||
var chain = new AttestationChain
|
||||
{
|
||||
ChainId = "test-chain",
|
||||
ScanId = "scan-001",
|
||||
FindingId = "CVE-2024-0001",
|
||||
RootDigest = "sha256:abc123",
|
||||
Attestations = ImmutableList.Create(new ChainAttestation
|
||||
{
|
||||
Type = AttestationType.Sbom,
|
||||
AttestationId = "att-001",
|
||||
CreatedAt = _fixedTime.AddDays(-30),
|
||||
ExpiresAt = _fixedTime.AddDays(-1), // Expired
|
||||
Verified = true,
|
||||
VerificationStatus = AttestationVerificationStatus.Expired,
|
||||
SubjectDigest = "sha256:abc123",
|
||||
PredicateType = "https://slsa.dev/provenance/v1"
|
||||
}),
|
||||
Verified = false,
|
||||
VerifiedAt = _fixedTime,
|
||||
Status = ChainStatus.Expired
|
||||
};
|
||||
var bundle = CreateValidBundle();
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyOfflineAsync(chain, bundle);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(OfflineChainStatus.Failed);
|
||||
result.AttestationDetails.Should().HaveCount(1);
|
||||
result.Issues.Should().ContainMatch("*expired*");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetupTestBundle()
|
||||
{
|
||||
Directory.CreateDirectory(_testBundlePath);
|
||||
|
||||
// Create roots directory with test root cert
|
||||
var rootsDir = Path.Combine(_testBundlePath, "roots");
|
||||
Directory.CreateDirectory(rootsDir);
|
||||
File.WriteAllText(
|
||||
Path.Combine(rootsDir, "root.pem"),
|
||||
ExportCertToPem(_testRootCert));
|
||||
|
||||
// Create keys directory with test public key
|
||||
var keysDir = Path.Combine(_testBundlePath, "keys");
|
||||
Directory.CreateDirectory(keysDir);
|
||||
File.WriteAllText(
|
||||
Path.Combine(keysDir, "rekor-pubkey.pem"),
|
||||
ExportPublicKeyToPem(_testKey));
|
||||
|
||||
// Create bundle metadata
|
||||
var metadata = $$"""
|
||||
{
|
||||
"createdAt": "{{_fixedTime.AddDays(-1):O}}",
|
||||
"expiresAt": "{{_fixedTime.AddDays(365):O}}",
|
||||
"version": "1.0.0-test"
|
||||
}
|
||||
""";
|
||||
File.WriteAllText(Path.Combine(_testBundlePath, "bundle.json"), metadata);
|
||||
}
|
||||
|
||||
private static AttestationChain CreateEmptyChain() =>
|
||||
new()
|
||||
{
|
||||
ChainId = "empty-chain",
|
||||
ScanId = "scan-001",
|
||||
FindingId = "CVE-2024-0001",
|
||||
RootDigest = "sha256:abc123",
|
||||
Attestations = ImmutableList<ChainAttestation>.Empty,
|
||||
Verified = false,
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
Status = ChainStatus.Empty
|
||||
};
|
||||
|
||||
private static AttestationChain CreateValidChain() =>
|
||||
new()
|
||||
{
|
||||
ChainId = "test-chain",
|
||||
ScanId = "scan-001",
|
||||
FindingId = "CVE-2024-0001",
|
||||
RootDigest = "sha256:abc123",
|
||||
Attestations = ImmutableList.Create(new ChainAttestation
|
||||
{
|
||||
Type = AttestationType.Sbom,
|
||||
AttestationId = "att-001",
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||
Verified = true,
|
||||
VerificationStatus = AttestationVerificationStatus.Valid,
|
||||
SubjectDigest = "sha256:abc123",
|
||||
PredicateType = "https://slsa.dev/provenance/v1"
|
||||
}),
|
||||
Verified = true,
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
Status = ChainStatus.Complete
|
||||
};
|
||||
|
||||
private TrustRootBundle CreateValidBundle() =>
|
||||
new()
|
||||
{
|
||||
RootCertificates = ImmutableList.Create(_testRootCert),
|
||||
IntermediateCertificates = ImmutableList<X509Certificate2>.Empty,
|
||||
TrustedTimestamps = ImmutableList<TrustedTimestamp>.Empty,
|
||||
TransparencyLogKeys = ImmutableList.Create(new TrustedPublicKey
|
||||
{
|
||||
KeyId = "test-key",
|
||||
PublicKeyPem = ExportPublicKeyToPem(_testKey),
|
||||
Algorithm = "ecdsa-p256",
|
||||
Purpose = "general"
|
||||
}),
|
||||
BundleCreatedAt = _fixedTime.AddDays(-1),
|
||||
BundleExpiresAt = _fixedTime.AddDays(30),
|
||||
BundleDigest = "test-digest-valid"
|
||||
};
|
||||
|
||||
private TrustRootBundle CreateExpiredBundle() =>
|
||||
new()
|
||||
{
|
||||
RootCertificates = ImmutableList.Create(_testRootCert),
|
||||
IntermediateCertificates = ImmutableList<X509Certificate2>.Empty,
|
||||
TrustedTimestamps = ImmutableList<TrustedTimestamp>.Empty,
|
||||
TransparencyLogKeys = ImmutableList<TrustedPublicKey>.Empty,
|
||||
BundleCreatedAt = _fixedTime.AddDays(-90),
|
||||
BundleExpiresAt = _fixedTime.AddDays(-1), // Expired
|
||||
BundleDigest = "test-digest-expired"
|
||||
};
|
||||
|
||||
private TrustRootBundle CreateBundleWithRoot(X509Certificate2 root) =>
|
||||
new()
|
||||
{
|
||||
RootCertificates = ImmutableList.Create(root),
|
||||
IntermediateCertificates = ImmutableList<X509Certificate2>.Empty,
|
||||
TrustedTimestamps = ImmutableList<TrustedTimestamp>.Empty,
|
||||
TransparencyLogKeys = ImmutableList<TrustedPublicKey>.Empty,
|
||||
BundleCreatedAt = _fixedTime.AddDays(-1),
|
||||
BundleExpiresAt = _fixedTime.AddDays(365),
|
||||
BundleDigest = "test-digest-with-root"
|
||||
};
|
||||
|
||||
private static X509Certificate2 CreateSelfSignedCert(string subject, ECDsa key)
|
||||
{
|
||||
var req = new CertificateRequest(
|
||||
subject,
|
||||
key,
|
||||
HashAlgorithmName.SHA256);
|
||||
|
||||
req.CertificateExtensions.Add(
|
||||
new X509BasicConstraintsExtension(
|
||||
certificateAuthority: true,
|
||||
hasPathLengthConstraint: false,
|
||||
pathLengthConstraint: 0,
|
||||
critical: true));
|
||||
|
||||
req.CertificateExtensions.Add(
|
||||
new X509KeyUsageExtension(
|
||||
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign,
|
||||
critical: true));
|
||||
|
||||
return req.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
DateTimeOffset.UtcNow.AddYears(5));
|
||||
}
|
||||
|
||||
private static X509Certificate2 CreateSignedCert(
|
||||
string subject,
|
||||
ECDsa leafKey,
|
||||
X509Certificate2 issuerCert,
|
||||
ECDsa issuerKey)
|
||||
{
|
||||
var req = new CertificateRequest(
|
||||
subject,
|
||||
leafKey,
|
||||
HashAlgorithmName.SHA256);
|
||||
|
||||
req.CertificateExtensions.Add(
|
||||
new X509BasicConstraintsExtension(
|
||||
certificateAuthority: false,
|
||||
hasPathLengthConstraint: false,
|
||||
pathLengthConstraint: 0,
|
||||
critical: true));
|
||||
|
||||
req.CertificateExtensions.Add(
|
||||
new X509KeyUsageExtension(
|
||||
X509KeyUsageFlags.DigitalSignature,
|
||||
critical: true));
|
||||
|
||||
// Generate serial number
|
||||
var serialNumber = new byte[8];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(serialNumber);
|
||||
|
||||
return req.Create(
|
||||
issuerCert,
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
DateTimeOffset.UtcNow.AddYears(1),
|
||||
serialNumber);
|
||||
}
|
||||
|
||||
private static string ExportCertToPem(X509Certificate2 cert)
|
||||
{
|
||||
var pem = new StringBuilder();
|
||||
pem.AppendLine("-----BEGIN CERTIFICATE-----");
|
||||
pem.AppendLine(Convert.ToBase64String(cert.RawData, Base64FormattingOptions.InsertLineBreaks));
|
||||
pem.AppendLine("-----END CERTIFICATE-----");
|
||||
return pem.ToString();
|
||||
}
|
||||
|
||||
private static string ExportPublicKeyToPem(ECDsa key)
|
||||
{
|
||||
var publicKeyBytes = key.ExportSubjectPublicKeyInfo();
|
||||
var pem = new StringBuilder();
|
||||
pem.AppendLine("-----BEGIN PUBLIC KEY-----");
|
||||
pem.AppendLine(Convert.ToBase64String(publicKeyBytes, Base64FormattingOptions.InsertLineBreaks));
|
||||
pem.AppendLine("-----END PUBLIC KEY-----");
|
||||
return pem.ToString();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PolicyDecisionAttestationServiceTests.cs
|
||||
// Sprint: SPRINT_3801_0001_0001_policy_decision_attestation (ATTEST-005)
|
||||
// Description: Unit tests for PolicyDecisionAttestationService.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
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;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for PolicyDecisionAttestationService.
|
||||
/// </summary>
|
||||
public sealed class PolicyDecisionAttestationServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly PolicyDecisionAttestationService _service;
|
||||
|
||||
public PolicyDecisionAttestationServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 12, 19, 10, 0, 0, TimeSpan.Zero));
|
||||
_service = new PolicyDecisionAttestationService(
|
||||
NullLogger<PolicyDecisionAttestationService>.Instance,
|
||||
MsOptions.Options.Create(new PolicyDecisionAttestationOptions { DefaultDecisionTtlDays = 30 }),
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
#region CreateAttestationAsync Tests
|
||||
|
||||
[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();
|
||||
}
|
||||
|
||||
[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/policy-decision@v1");
|
||||
}
|
||||
|
||||
[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("finding:");
|
||||
result.Statement.Subject[1].Digest.Should().ContainKey("sha256");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_ValidInput_IncludesPredicateWithAllFields()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAttestationAsync(input);
|
||||
|
||||
// Assert
|
||||
var predicate = result.Statement!.Predicate;
|
||||
predicate.FindingId.Should().Be(input.FindingId);
|
||||
predicate.Cve.Should().Be(input.Cve);
|
||||
predicate.ComponentPurl.Should().Be(input.ComponentPurl);
|
||||
predicate.Decision.Should().Be(input.Decision);
|
||||
predicate.EvidenceRefs.Should().BeEquivalentTo(input.EvidenceRefs);
|
||||
predicate.PolicyVersion.Should().Be(input.PolicyVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_ValidInput_SetsEvaluatedAtToCurrentTime()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
var expectedTime = _timeProvider.GetUtcNow();
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAttestationAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Statement!.Predicate.EvaluatedAt.Should().Be(expectedTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_WithDefaultTtl_SetsExpiresAtTo30Days()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
var expectedExpiry = _timeProvider.GetUtcNow().AddDays(30);
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAttestationAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Statement!.Predicate.ExpiresAt.Should().Be(expectedExpiry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_WithCustomTtl_SetsExpiresAtToCustomValue()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput() with { DecisionTtl = TimeSpan.FromDays(7) };
|
||||
var expectedExpiry = _timeProvider.GetUtcNow().AddDays(7);
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAttestationAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Statement!.Predicate.ExpiresAt.Should().Be(expectedExpiry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_IncludesReasoningDetails()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAttestationAsync(input);
|
||||
|
||||
// Assert
|
||||
var reasoning = result.Statement!.Predicate.Reasoning;
|
||||
reasoning.RulesEvaluated.Should().Be(input.Reasoning.RulesEvaluated);
|
||||
reasoning.RulesMatched.Should().BeEquivalentTo(input.Reasoning.RulesMatched);
|
||||
reasoning.FinalScore.Should().Be(input.Reasoning.FinalScore);
|
||||
reasoning.RiskMultiplier.Should().Be(input.Reasoning.RiskMultiplier);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_DifferentInputs_GenerateDifferentAttestationIds()
|
||||
{
|
||||
// Arrange
|
||||
var input1 = CreateValidInput();
|
||||
var input2 = CreateValidInput() with { Cve = "CVE-2024-99999" };
|
||||
|
||||
// Act
|
||||
var result1 = await _service.CreateAttestationAsync(input1);
|
||||
var result2 = await _service.CreateAttestationAsync(input2);
|
||||
|
||||
// Assert
|
||||
result1.AttestationId.Should().NotBe(result2.AttestationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_NullInput_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
_service.CreateAttestationAsync(null!));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task CreateAttestationAsync_EmptyFindingId_ThrowsArgumentException(string findingId)
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput() with { FindingId = findingId };
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_service.CreateAttestationAsync(input));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task CreateAttestationAsync_EmptyCve_ThrowsArgumentException(string cve)
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput() with { Cve = cve };
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_service.CreateAttestationAsync(input));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task CreateAttestationAsync_EmptyComponentPurl_ThrowsArgumentException(string purl)
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput() with { ComponentPurl = purl };
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_service.CreateAttestationAsync(input));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetAttestationAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetAttestationAsync_ExistingAttestation_ReturnsAttestation()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
await _service.CreateAttestationAsync(input);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetAttestationAsync(input.ScanId, input.FindingId);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Success.Should().BeTrue();
|
||||
result.Statement!.Predicate.FindingId.Should().Be(input.FindingId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAttestationAsync_NonExistentAttestation_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.GetAttestationAsync(
|
||||
ScanId.New(),
|
||||
"CVE-2024-00000@pkg:npm/nonexistent@1.0.0");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAttestationAsync_WrongScanId_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
await _service.CreateAttestationAsync(input);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetAttestationAsync(
|
||||
ScanId.New(), // Different scan ID
|
||||
input.FindingId);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAttestationAsync_WrongFindingId_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput();
|
||||
await _service.CreateAttestationAsync(input);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetAttestationAsync(
|
||||
input.ScanId,
|
||||
"CVE-2024-99999@pkg:npm/other@1.0.0"); // Different finding ID
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Decision Type Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(PolicyDecision.Allow)]
|
||||
[InlineData(PolicyDecision.Review)]
|
||||
[InlineData(PolicyDecision.Block)]
|
||||
[InlineData(PolicyDecision.Suppress)]
|
||||
[InlineData(PolicyDecision.Escalate)]
|
||||
public async Task CreateAttestationAsync_AllDecisionTypes_SuccessfullyCreated(PolicyDecision decision)
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateValidInput() with { Decision = decision };
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAttestationAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Statement!.Predicate.Decision.Should().Be(decision);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Serialization Tests
|
||||
|
||||
[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\":");
|
||||
}
|
||||
|
||||
[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/policy-decision@v1");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private PolicyDecisionInput CreateValidInput()
|
||||
{
|
||||
return new PolicyDecisionInput
|
||||
{
|
||||
ScanId = ScanId.New(),
|
||||
FindingId = "CVE-2024-12345@pkg:npm/stripe@6.1.2",
|
||||
Cve = "CVE-2024-12345",
|
||||
ComponentPurl = "pkg:npm/stripe@6.1.2",
|
||||
Decision = PolicyDecision.Allow,
|
||||
Reasoning = new PolicyDecisionReasoning
|
||||
{
|
||||
RulesEvaluated = 5,
|
||||
RulesMatched = new List<string> { "suppress-unreachable", "low-cvss" },
|
||||
FinalScore = 35.0,
|
||||
RiskMultiplier = 0.5,
|
||||
ReachabilityState = "unreachable",
|
||||
Summary = "Low risk due to unreachable code path"
|
||||
},
|
||||
EvidenceRefs = new List<string>
|
||||
{
|
||||
"sha256:sbom-digest-abc123",
|
||||
"sha256:vex-digest-def456",
|
||||
"sha256:reachability-digest-ghi789"
|
||||
},
|
||||
PolicyVersion = "1.0.0",
|
||||
PolicyHash = "sha256:policy-hash-xyz"
|
||||
};
|
||||
}
|
||||
|
||||
#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 PolicyDecisionAttestationOptions configuration.
|
||||
/// </summary>
|
||||
public sealed class PolicyDecisionAttestationOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultDecisionTtlDays_DefaultsToThirtyDays()
|
||||
{
|
||||
var options = new PolicyDecisionAttestationOptions();
|
||||
|
||||
options.DefaultDecisionTtlDays.Should().Be(30);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnableSigning_DefaultsToTrue()
|
||||
{
|
||||
var options = new PolicyDecisionAttestationOptions();
|
||||
|
||||
options.EnableSigning.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Options_CanBeConfigured()
|
||||
{
|
||||
var options = new PolicyDecisionAttestationOptions
|
||||
{
|
||||
DefaultDecisionTtlDays = 7,
|
||||
EnableSigning = false
|
||||
};
|
||||
|
||||
options.DefaultDecisionTtlDays.Should().Be(7);
|
||||
options.EnableSigning.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for PolicyDecisionStatement model.
|
||||
/// </summary>
|
||||
public sealed class PolicyDecisionStatementTests
|
||||
{
|
||||
[Fact]
|
||||
public void Type_AlwaysReturnsInTotoStatementV1()
|
||||
{
|
||||
var statement = CreateValidStatement();
|
||||
|
||||
statement.Type.Should().Be("https://in-toto.io/Statement/v1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PredicateType_AlwaysReturnsCorrectUri()
|
||||
{
|
||||
var statement = CreateValidStatement();
|
||||
|
||||
statement.PredicateType.Should().Be("stella.ops/policy-decision@v1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Subject_CanContainMultipleEntries()
|
||||
{
|
||||
var statement = CreateValidStatement();
|
||||
|
||||
statement.Subject.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
private static PolicyDecisionStatement CreateValidStatement()
|
||||
{
|
||||
return new PolicyDecisionStatement
|
||||
{
|
||||
Subject = new List<PolicyDecisionSubject>
|
||||
{
|
||||
new() { Name = "scan:test", Digest = new Dictionary<string, string> { ["sha256"] = "abc" } },
|
||||
new() { Name = "finding:test", Digest = new Dictionary<string, string> { ["sha256"] = "def" } }
|
||||
},
|
||||
Predicate = new PolicyDecisionPredicate
|
||||
{
|
||||
FindingId = "CVE-2024-12345@pkg:npm/test@1.0.0",
|
||||
Cve = "CVE-2024-12345",
|
||||
ComponentPurl = "pkg:npm/test@1.0.0",
|
||||
Decision = PolicyDecision.Allow,
|
||||
Reasoning = new PolicyDecisionReasoning
|
||||
{
|
||||
RulesEvaluated = 1,
|
||||
RulesMatched = new List<string>(),
|
||||
FinalScore = 0,
|
||||
RiskMultiplier = 1.0
|
||||
},
|
||||
EvidenceRefs = new List<string>(),
|
||||
EvaluatedAt = DateTimeOffset.UtcNow,
|
||||
PolicyVersion = "1.0.0"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for PolicyDecisionReasoning model.
|
||||
/// </summary>
|
||||
public sealed class PolicyDecisionReasoningTests
|
||||
{
|
||||
[Fact]
|
||||
public void Reasoning_RequiredFieldsAreSet()
|
||||
{
|
||||
var reasoning = new PolicyDecisionReasoning
|
||||
{
|
||||
RulesEvaluated = 10,
|
||||
RulesMatched = new List<string> { "rule1", "rule2" },
|
||||
FinalScore = 45.5,
|
||||
RiskMultiplier = 0.8
|
||||
};
|
||||
|
||||
reasoning.RulesEvaluated.Should().Be(10);
|
||||
reasoning.RulesMatched.Should().HaveCount(2);
|
||||
reasoning.FinalScore.Should().Be(45.5);
|
||||
reasoning.RiskMultiplier.Should().Be(0.8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reasoning_OptionalFieldsCanBeNull()
|
||||
{
|
||||
var reasoning = new PolicyDecisionReasoning
|
||||
{
|
||||
RulesEvaluated = 1,
|
||||
RulesMatched = new List<string>(),
|
||||
FinalScore = 0,
|
||||
RiskMultiplier = 1.0
|
||||
};
|
||||
|
||||
reasoning.ReachabilityState.Should().BeNull();
|
||||
reasoning.VexStatus.Should().BeNull();
|
||||
reasoning.Summary.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reasoning_OptionalFieldsCanBeSet()
|
||||
{
|
||||
var reasoning = new PolicyDecisionReasoning
|
||||
{
|
||||
RulesEvaluated = 1,
|
||||
RulesMatched = new List<string>(),
|
||||
FinalScore = 25.0,
|
||||
RiskMultiplier = 0.5,
|
||||
ReachabilityState = "unreachable",
|
||||
VexStatus = "not_affected",
|
||||
Summary = "Mitigated by VEX"
|
||||
};
|
||||
|
||||
reasoning.ReachabilityState.Should().Be("unreachable");
|
||||
reasoning.VexStatus.Should().Be("not_affected");
|
||||
reasoning.Summary.Should().Be("Mitigated by VEX");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for PolicyDecisionAttestationResult factory methods.
|
||||
/// </summary>
|
||||
public sealed class PolicyDecisionAttestationResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void Succeeded_CreatesSuccessResult()
|
||||
{
|
||||
var statement = CreateValidStatement();
|
||||
var result = PolicyDecisionAttestationResult.Succeeded(statement, "sha256:test123");
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Statement.Should().Be(statement);
|
||||
result.AttestationId.Should().Be("sha256:test123");
|
||||
result.Error.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Succeeded_WithDsseEnvelope_IncludesEnvelope()
|
||||
{
|
||||
var statement = CreateValidStatement();
|
||||
var result = PolicyDecisionAttestationResult.Succeeded(
|
||||
statement,
|
||||
"sha256:test123",
|
||||
dsseEnvelope: "eyJ0eXBlIjoiYXBwbGljYXRpb24vdm5kLmRzc2UranNvbiJ9...");
|
||||
|
||||
result.DsseEnvelope.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_CreatesFailedResult()
|
||||
{
|
||||
var result = PolicyDecisionAttestationResult.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 PolicyDecisionStatement CreateValidStatement()
|
||||
{
|
||||
return new PolicyDecisionStatement
|
||||
{
|
||||
Subject = new List<PolicyDecisionSubject>
|
||||
{
|
||||
new() { Name = "test", Digest = new Dictionary<string, string> { ["sha256"] = "abc" } }
|
||||
},
|
||||
Predicate = new PolicyDecisionPredicate
|
||||
{
|
||||
FindingId = "CVE-2024-12345@pkg:npm/test@1.0.0",
|
||||
Cve = "CVE-2024-12345",
|
||||
ComponentPurl = "pkg:npm/test@1.0.0",
|
||||
Decision = PolicyDecision.Allow,
|
||||
Reasoning = new PolicyDecisionReasoning
|
||||
{
|
||||
RulesEvaluated = 1,
|
||||
RulesMatched = new List<string>(),
|
||||
FinalScore = 0,
|
||||
RiskMultiplier = 1.0
|
||||
},
|
||||
EvidenceRefs = new List<string>(),
|
||||
EvaluatedAt = DateTimeOffset.UtcNow,
|
||||
PolicyVersion = "1.0.0"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for RichGraphAttestationService.
|
||||
/// </summary>
|
||||
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<RichGraphAttestationService>.Instance,
|
||||
MsOptions.Options.Create(new RichGraphAttestationOptions { DefaultGraphTtlDays = 7 }),
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
#region CreateAttestationAsync Tests
|
||||
|
||||
[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();
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_NullInput_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
_service.CreateAttestationAsync(null!));
|
||||
}
|
||||
|
||||
[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));
|
||||
}
|
||||
|
||||
[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));
|
||||
}
|
||||
|
||||
[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
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAttestationAsync_NonExistentAttestation_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.GetAttestationAsync(ScanId.New(), "nonexistent-graph");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
|
||||
[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
|
||||
|
||||
[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\":");
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for RichGraphAttestationOptions configuration.
|
||||
/// </summary>
|
||||
public sealed class RichGraphAttestationOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultGraphTtlDays_DefaultsToSevenDays()
|
||||
{
|
||||
var options = new RichGraphAttestationOptions();
|
||||
|
||||
options.DefaultGraphTtlDays.Should().Be(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnableSigning_DefaultsToTrue()
|
||||
{
|
||||
var options = new RichGraphAttestationOptions();
|
||||
|
||||
options.EnableSigning.Should().BeTrue();
|
||||
}
|
||||
|
||||
[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
|
||||
{
|
||||
[Fact]
|
||||
public void Type_AlwaysReturnsInTotoStatementV1()
|
||||
{
|
||||
var statement = CreateValidStatement();
|
||||
|
||||
statement.Type.Should().Be("https://in-toto.io/Statement/v1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PredicateType_AlwaysReturnsCorrectUri()
|
||||
{
|
||||
var statement = CreateValidStatement();
|
||||
|
||||
statement.PredicateType.Should().Be("stella.ops/richgraph@v1");
|
||||
}
|
||||
|
||||
[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
|
||||
{
|
||||
[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();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Succeeded_WithDsseEnvelope_IncludesEnvelope()
|
||||
{
|
||||
var statement = CreateValidStatement();
|
||||
var result = RichGraphAttestationResult.Succeeded(
|
||||
statement,
|
||||
"sha256:test123",
|
||||
dsseEnvelope: "eyJ0eXBlIjoiYXBwbGljYXRpb24vdm5kLmRzc2UranNvbiJ9...");
|
||||
|
||||
result.DsseEnvelope.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,10 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="..\..\docs\events\samples\scanner.event.report.ready@1.sample.json">
|
||||
|
||||
Reference in New Issue
Block a user