stabilizaiton work - projects rework for maintenanceability and ui livening
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
// <copyright file="AiAttestationServiceTests.ClaimAttestation.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
|
||||
public sealed partial class AiAttestationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateClaimAttestation_CreatesAndRetrievesClaimAsync()
|
||||
{
|
||||
var claimAttestation = CreateSampleClaimAttestation();
|
||||
|
||||
var result = await _service.CreateClaimAttestationAsync(claimAttestation);
|
||||
|
||||
result.AttestationId.Should().Be(claimAttestation.ClaimId);
|
||||
result.Digest.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetClaimAttestations_ReturnsClaimsForRunAsync()
|
||||
{
|
||||
var runId = "run-with-claims";
|
||||
var claim1 = CreateSampleClaimAttestation() with { ClaimId = "claim-1", RunId = runId };
|
||||
var claim2 = CreateSampleClaimAttestation() with { ClaimId = "claim-2", RunId = runId };
|
||||
var claim3 = CreateSampleClaimAttestation() with { ClaimId = "claim-3", RunId = "other-run" };
|
||||
|
||||
await _service.CreateClaimAttestationAsync(claim1);
|
||||
await _service.CreateClaimAttestationAsync(claim2);
|
||||
await _service.CreateClaimAttestationAsync(claim3);
|
||||
|
||||
var claims = await _service.GetClaimAttestationsAsync(runId);
|
||||
|
||||
claims.Should().HaveCount(2);
|
||||
claims.Should().AllSatisfy(c => c.RunId.Should().Be(runId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyClaimAttestation_ValidClaim_ReturnsValidAsync()
|
||||
{
|
||||
var claimAttestation = CreateSampleClaimAttestation();
|
||||
await _service.CreateClaimAttestationAsync(claimAttestation, sign: true);
|
||||
|
||||
var result = await _service.VerifyClaimAttestationAsync(claimAttestation.ClaimId);
|
||||
|
||||
result.Valid.Should().BeTrue();
|
||||
result.DigestValid.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// <copyright file="AiAttestationServiceTests.ListRecent.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
|
||||
public sealed partial class AiAttestationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ListRecentAttestations_FiltersByTenantAsync()
|
||||
{
|
||||
var tenant1Run = CreateSampleRunAttestation() with { RunId = "run-t1", TenantId = "tenant-1" };
|
||||
var tenant2Run = CreateSampleRunAttestation() with { RunId = "run-t2", TenantId = "tenant-2" };
|
||||
|
||||
await _service.CreateRunAttestationAsync(tenant1Run);
|
||||
await _service.CreateRunAttestationAsync(tenant2Run);
|
||||
|
||||
var tenant1Attestations = await _service.ListRecentAttestationsAsync("tenant-1");
|
||||
|
||||
tenant1Attestations.Should().HaveCount(1);
|
||||
tenant1Attestations[0].TenantId.Should().Be("tenant-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListRecentAttestations_RespectsLimitAsync()
|
||||
{
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var attestation = CreateSampleRunAttestation() with
|
||||
{
|
||||
RunId = $"run-{i}",
|
||||
TenantId = "tenant-test"
|
||||
};
|
||||
await _service.CreateRunAttestationAsync(attestation);
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
var recent = await _service.ListRecentAttestationsAsync("tenant-test", limit: 5);
|
||||
|
||||
recent.Should().HaveCount(5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// <copyright file="AiAttestationServiceTests.RunAttestation.Create.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
|
||||
public sealed partial class AiAttestationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateRunAttestation_WithSigning_ReturnsSignedResultAsync()
|
||||
{
|
||||
var attestation = CreateSampleRunAttestation();
|
||||
|
||||
var result = await _service.CreateRunAttestationAsync(attestation, sign: true);
|
||||
|
||||
result.AttestationId.Should().Be(attestation.RunId);
|
||||
result.Signed.Should().BeTrue();
|
||||
result.DsseEnvelope.Should().NotBeNullOrEmpty();
|
||||
result.Digest.Should().StartWith("sha256:");
|
||||
result.StorageUri.Should().Contain(attestation.RunId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRunAttestation_WithoutSigning_ReturnsUnsignedResultAsync()
|
||||
{
|
||||
var attestation = CreateSampleRunAttestation();
|
||||
|
||||
var result = await _service.CreateRunAttestationAsync(attestation, sign: false);
|
||||
|
||||
result.Signed.Should().BeFalse();
|
||||
result.DsseEnvelope.Should().BeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// <copyright file="AiAttestationServiceTests.RunAttestation.Read.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
|
||||
public sealed partial class AiAttestationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetRunAttestation_AfterCreation_ReturnsAttestationAsync()
|
||||
{
|
||||
var attestation = CreateSampleRunAttestation();
|
||||
await _service.CreateRunAttestationAsync(attestation);
|
||||
|
||||
var retrieved = await _service.GetRunAttestationAsync(attestation.RunId);
|
||||
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.RunId.Should().Be(attestation.RunId);
|
||||
retrieved.TenantId.Should().Be(attestation.TenantId);
|
||||
retrieved.Model.Provider.Should().Be(attestation.Model.Provider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRunAttestation_NotFound_ReturnsNullAsync()
|
||||
{
|
||||
var result = await _service.GetRunAttestationAsync("non-existent");
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// <copyright file="AiAttestationServiceTests.RunAttestation.Verify.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
|
||||
public sealed partial class AiAttestationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task VerifyRunAttestation_ValidAttestation_ReturnsValidAsync()
|
||||
{
|
||||
var attestation = CreateSampleRunAttestation();
|
||||
await _service.CreateRunAttestationAsync(attestation, sign: true);
|
||||
|
||||
var result = await _service.VerifyRunAttestationAsync(attestation.RunId);
|
||||
|
||||
result.Valid.Should().BeTrue();
|
||||
result.DigestValid.Should().BeTrue();
|
||||
result.SignatureValid.Should().BeTrue();
|
||||
result.SigningKeyId.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyRunAttestation_NotFound_ReturnsInvalidAsync()
|
||||
{
|
||||
var result = await _service.VerifyRunAttestationAsync("non-existent");
|
||||
|
||||
result.Valid.Should().BeFalse();
|
||||
result.FailureReason.Should().Contain("not found");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// <copyright file="AiAttestationServiceTests.TimeProvider.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
|
||||
public sealed partial class AiAttestationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreatedAt_UsesTimeProviderAsync()
|
||||
{
|
||||
var fixedTime = FixedUtcNow.AddHours(1);
|
||||
_timeProvider.SetUtcNow(fixedTime);
|
||||
|
||||
var attestation = CreateSampleRunAttestation();
|
||||
var result = await _service.CreateRunAttestationAsync(attestation);
|
||||
|
||||
result.CreatedAt.Should().Be(fixedTime);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
// <copyright file="AiAttestationServiceTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.AdvisoryAI.Attestation;
|
||||
using StellaOps.AdvisoryAI.Attestation.Models;
|
||||
using Xunit;
|
||||
|
||||
@@ -15,184 +13,29 @@ namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
/// Tests for <see cref="AiAttestationService"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class AiAttestationServiceTests
|
||||
public sealed partial class AiAttestationServiceTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedUtcNow = new(2026, 1, 9, 12, 0, 0, TimeSpan.Zero);
|
||||
private const string DefaultRunId = "run-001";
|
||||
private const string DefaultClaimId = "claim-001";
|
||||
private readonly FakeTimeProvider _timeProvider = new();
|
||||
private readonly AiAttestationService _service;
|
||||
|
||||
public AiAttestationServiceTests()
|
||||
{
|
||||
_service = new AiAttestationService(
|
||||
_timeProvider,
|
||||
NullLogger<AiAttestationService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRunAttestationAsync_WithSigning_ReturnsSignedResult()
|
||||
{
|
||||
var attestation = CreateSampleRunAttestation();
|
||||
|
||||
var result = await _service.CreateRunAttestationAsync(attestation, sign: true);
|
||||
|
||||
result.AttestationId.Should().Be(attestation.RunId);
|
||||
result.Signed.Should().BeTrue();
|
||||
result.DsseEnvelope.Should().NotBeNullOrEmpty();
|
||||
result.Digest.Should().StartWith("sha256:");
|
||||
result.StorageUri.Should().Contain(attestation.RunId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRunAttestationAsync_WithoutSigning_ReturnsUnsignedResult()
|
||||
{
|
||||
var attestation = CreateSampleRunAttestation();
|
||||
|
||||
var result = await _service.CreateRunAttestationAsync(attestation, sign: false);
|
||||
|
||||
result.Signed.Should().BeFalse();
|
||||
result.DsseEnvelope.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRunAttestationAsync_AfterCreation_ReturnsAttestation()
|
||||
{
|
||||
var attestation = CreateSampleRunAttestation();
|
||||
await _service.CreateRunAttestationAsync(attestation);
|
||||
|
||||
var retrieved = await _service.GetRunAttestationAsync(attestation.RunId);
|
||||
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.RunId.Should().Be(attestation.RunId);
|
||||
retrieved.TenantId.Should().Be(attestation.TenantId);
|
||||
retrieved.Model.Provider.Should().Be(attestation.Model.Provider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRunAttestationAsync_NotFound_ReturnsNull()
|
||||
{
|
||||
var result = await _service.GetRunAttestationAsync("non-existent");
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyRunAttestationAsync_ValidAttestation_ReturnsValid()
|
||||
{
|
||||
var attestation = CreateSampleRunAttestation();
|
||||
await _service.CreateRunAttestationAsync(attestation, sign: true);
|
||||
|
||||
var result = await _service.VerifyRunAttestationAsync(attestation.RunId);
|
||||
|
||||
result.Valid.Should().BeTrue();
|
||||
result.DigestValid.Should().BeTrue();
|
||||
result.SignatureValid.Should().BeTrue();
|
||||
result.SigningKeyId.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyRunAttestationAsync_NotFound_ReturnsInvalid()
|
||||
{
|
||||
var result = await _service.VerifyRunAttestationAsync("non-existent");
|
||||
|
||||
result.Valid.Should().BeFalse();
|
||||
result.FailureReason.Should().Contain("not found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateClaimAttestationAsync_CreatesAndRetrievesClaim()
|
||||
{
|
||||
var claimAttestation = CreateSampleClaimAttestation();
|
||||
|
||||
var result = await _service.CreateClaimAttestationAsync(claimAttestation);
|
||||
|
||||
result.AttestationId.Should().Be(claimAttestation.ClaimId);
|
||||
result.Digest.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetClaimAttestationsAsync_ReturnsClaimsForRun()
|
||||
{
|
||||
var runId = "run-with-claims";
|
||||
var claim1 = CreateSampleClaimAttestation() with { ClaimId = "claim-1", RunId = runId };
|
||||
var claim2 = CreateSampleClaimAttestation() with { ClaimId = "claim-2", RunId = runId };
|
||||
var claim3 = CreateSampleClaimAttestation() with { ClaimId = "claim-3", RunId = "other-run" };
|
||||
|
||||
await _service.CreateClaimAttestationAsync(claim1);
|
||||
await _service.CreateClaimAttestationAsync(claim2);
|
||||
await _service.CreateClaimAttestationAsync(claim3);
|
||||
|
||||
var claims = await _service.GetClaimAttestationsAsync(runId);
|
||||
|
||||
claims.Should().HaveCount(2);
|
||||
claims.Should().AllSatisfy(c => c.RunId.Should().Be(runId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyClaimAttestationAsync_ValidClaim_ReturnsValid()
|
||||
{
|
||||
var claimAttestation = CreateSampleClaimAttestation();
|
||||
await _service.CreateClaimAttestationAsync(claimAttestation, sign: true);
|
||||
|
||||
var result = await _service.VerifyClaimAttestationAsync(claimAttestation.ClaimId);
|
||||
|
||||
result.Valid.Should().BeTrue();
|
||||
result.DigestValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListRecentAttestationsAsync_FiltersByTenant()
|
||||
{
|
||||
var tenant1Run = CreateSampleRunAttestation() with { RunId = "run-t1", TenantId = "tenant-1" };
|
||||
var tenant2Run = CreateSampleRunAttestation() with { RunId = "run-t2", TenantId = "tenant-2" };
|
||||
|
||||
await _service.CreateRunAttestationAsync(tenant1Run);
|
||||
await _service.CreateRunAttestationAsync(tenant2Run);
|
||||
|
||||
var tenant1Attestations = await _service.ListRecentAttestationsAsync("tenant-1");
|
||||
|
||||
tenant1Attestations.Should().HaveCount(1);
|
||||
tenant1Attestations[0].TenantId.Should().Be("tenant-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListRecentAttestationsAsync_RespectsLimit()
|
||||
{
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var attestation = CreateSampleRunAttestation() with
|
||||
{
|
||||
RunId = $"run-{i}",
|
||||
TenantId = "tenant-test"
|
||||
};
|
||||
await _service.CreateRunAttestationAsync(attestation);
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
var recent = await _service.ListRecentAttestationsAsync("tenant-test", limit: 5);
|
||||
|
||||
recent.Should().HaveCount(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatedAt_UsesTimeProvider()
|
||||
{
|
||||
var fixedTime = new DateTimeOffset(2026, 1, 9, 12, 0, 0, TimeSpan.Zero);
|
||||
_timeProvider.SetUtcNow(fixedTime);
|
||||
|
||||
var attestation = CreateSampleRunAttestation();
|
||||
var result = await _service.CreateRunAttestationAsync(attestation);
|
||||
|
||||
result.CreatedAt.Should().Be(fixedTime);
|
||||
_timeProvider.SetUtcNow(FixedUtcNow);
|
||||
_service = new AiAttestationService(_timeProvider, NullLogger<AiAttestationService>.Instance);
|
||||
}
|
||||
|
||||
private static AiRunAttestation CreateSampleRunAttestation()
|
||||
{
|
||||
return new AiRunAttestation
|
||||
{
|
||||
RunId = $"run-{Guid.NewGuid():N}",
|
||||
RunId = DefaultRunId,
|
||||
TenantId = "tenant-test",
|
||||
UserId = "user:test@example.com",
|
||||
StartedAt = DateTimeOffset.Parse("2026-01-09T12:00:00Z"),
|
||||
CompletedAt = DateTimeOffset.Parse("2026-01-09T12:05:00Z"),
|
||||
StartedAt = FixedUtcNow,
|
||||
CompletedAt = FixedUtcNow.AddMinutes(5),
|
||||
Model = new AiModelInfo
|
||||
{
|
||||
Provider = "anthropic",
|
||||
@@ -207,14 +50,14 @@ public class AiAttestationServiceTests
|
||||
{
|
||||
return new AiClaimAttestation
|
||||
{
|
||||
ClaimId = $"claim-{Guid.NewGuid():N}",
|
||||
RunId = "run-xyz",
|
||||
ClaimId = DefaultClaimId,
|
||||
RunId = DefaultRunId,
|
||||
TurnId = "turn-001",
|
||||
TenantId = "tenant-test",
|
||||
ClaimText = "Test claim",
|
||||
ClaimDigest = "sha256:test",
|
||||
GroundingScore = 0.85,
|
||||
Timestamp = DateTimeOffset.Parse("2026-01-09T12:00:00Z"),
|
||||
Timestamp = FixedUtcNow,
|
||||
ContentDigest = "sha256:content-test"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// <copyright file="AiClaimAttestationTests.Digest.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
|
||||
public sealed partial class AiClaimAttestationTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeDigest_SameClaim_ReturnsSameDigest()
|
||||
{
|
||||
var attestation = CreateSampleClaimAttestation();
|
||||
|
||||
var digest1 = attestation.ComputeDigest();
|
||||
var digest2 = attestation.ComputeDigest();
|
||||
|
||||
digest1.Should().Be(digest2);
|
||||
digest1.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDigest_DifferentClaims_ReturnsDifferentDigests()
|
||||
{
|
||||
var attestation1 = CreateSampleClaimAttestation();
|
||||
var attestation2 = attestation1 with { ClaimText = "Different claim text" };
|
||||
|
||||
var digest1 = attestation1.ComputeDigest();
|
||||
var digest2 = attestation2.ComputeDigest();
|
||||
|
||||
digest1.Should().NotBe(digest2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// <copyright file="AiClaimAttestationTests.Evidence.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryAI.Attestation.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
|
||||
public sealed partial class AiClaimAttestationTests
|
||||
{
|
||||
[Fact]
|
||||
public void FromClaimEvidence_CreatesValidAttestation()
|
||||
{
|
||||
var evidence = new ClaimEvidence
|
||||
{
|
||||
Text = "This component is affected by the vulnerability",
|
||||
Position = 45,
|
||||
Length = 47,
|
||||
GroundedBy = ["stella://sbom/abc123", "stella://reach/api:func"],
|
||||
GroundingScore = 0.95,
|
||||
Verified = true,
|
||||
Category = ClaimCategory.Factual
|
||||
};
|
||||
|
||||
var attestation = AiClaimAttestation.FromClaimEvidence(
|
||||
evidence,
|
||||
runId: "run-123",
|
||||
turnId: "turn-456",
|
||||
tenantId: "tenant-xyz",
|
||||
timestamp: FixedUtcNow);
|
||||
|
||||
attestation.ClaimText.Should().Be(evidence.Text);
|
||||
attestation.RunId.Should().Be("run-123");
|
||||
attestation.TurnId.Should().Be("turn-456");
|
||||
attestation.TenantId.Should().Be("tenant-xyz");
|
||||
attestation.GroundedBy.Should().HaveCount(2);
|
||||
attestation.GroundingScore.Should().Be(0.95);
|
||||
attestation.Verified.Should().BeTrue();
|
||||
attestation.Category.Should().Be(ClaimCategory.Factual);
|
||||
attestation.ClaimDigest.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClaimDigest_IsDeterministic()
|
||||
{
|
||||
var evidence1 = new ClaimEvidence
|
||||
{
|
||||
Text = "Same text",
|
||||
Position = 0,
|
||||
Length = 9,
|
||||
GroundingScore = 0.9
|
||||
};
|
||||
|
||||
var evidence2 = new ClaimEvidence
|
||||
{
|
||||
Text = "Same text",
|
||||
Position = 100,
|
||||
Length = 9,
|
||||
GroundingScore = 0.5
|
||||
};
|
||||
|
||||
var attestation1 = AiClaimAttestation.FromClaimEvidence(
|
||||
evidence1,
|
||||
"run-1",
|
||||
"turn-1",
|
||||
"tenant-1",
|
||||
FixedUtcNow);
|
||||
var attestation2 = AiClaimAttestation.FromClaimEvidence(
|
||||
evidence2,
|
||||
"run-2",
|
||||
"turn-2",
|
||||
"tenant-2",
|
||||
FixedUtcNow);
|
||||
|
||||
attestation1.ClaimDigest.Should().Be(attestation2.ClaimDigest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// <copyright file="AiClaimAttestationTests.GroundedBy.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
|
||||
public sealed partial class AiClaimAttestationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Attestation_WithGrounding_PreservesEvidenceUris()
|
||||
{
|
||||
var groundedBy = ImmutableArray.Create(
|
||||
"stella://sbom/abc123",
|
||||
"stella://reach/api:vulnFunc",
|
||||
"stella://vex/CVE-2023-44487");
|
||||
|
||||
var attestation = CreateSampleClaimAttestation() with
|
||||
{
|
||||
GroundedBy = groundedBy
|
||||
};
|
||||
|
||||
attestation.GroundedBy.Should().HaveCount(3);
|
||||
attestation.GroundedBy.Should().Contain("stella://sbom/abc123");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// <copyright file="AiClaimAttestationTests.Predicate.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryAI.Attestation.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
|
||||
public sealed partial class AiClaimAttestationTests
|
||||
{
|
||||
[Fact]
|
||||
public void PredicateType_IsCorrect()
|
||||
{
|
||||
AiClaimAttestation.PredicateType.Should().Be("https://stellaops.org/attestation/ai-claim/v1");
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
// <copyright file="AiClaimAttestationTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryAI.Attestation.Models;
|
||||
using Xunit;
|
||||
|
||||
@@ -13,114 +10,9 @@ namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
/// Tests for <see cref="AiClaimAttestation"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class AiClaimAttestationTests
|
||||
public sealed partial class AiClaimAttestationTests
|
||||
{
|
||||
[Fact]
|
||||
public void PredicateType_IsCorrect()
|
||||
{
|
||||
AiClaimAttestation.PredicateType.Should().Be("https://stellaops.org/attestation/ai-claim/v1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDigest_SameClaim_ReturnsSameDigest()
|
||||
{
|
||||
var attestation = CreateSampleClaimAttestation();
|
||||
|
||||
var digest1 = attestation.ComputeDigest();
|
||||
var digest2 = attestation.ComputeDigest();
|
||||
|
||||
digest1.Should().Be(digest2);
|
||||
digest1.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDigest_DifferentClaims_ReturnsDifferentDigests()
|
||||
{
|
||||
var attestation1 = CreateSampleClaimAttestation();
|
||||
var attestation2 = attestation1 with { ClaimText = "Different claim text" };
|
||||
|
||||
var digest1 = attestation1.ComputeDigest();
|
||||
var digest2 = attestation2.ComputeDigest();
|
||||
|
||||
digest1.Should().NotBe(digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromClaimEvidence_CreatesValidAttestation()
|
||||
{
|
||||
var evidence = new ClaimEvidence
|
||||
{
|
||||
Text = "This component is affected by the vulnerability",
|
||||
Position = 45,
|
||||
Length = 47,
|
||||
GroundedBy = ["stella://sbom/abc123", "stella://reach/api:func"],
|
||||
GroundingScore = 0.95,
|
||||
Verified = true,
|
||||
Category = ClaimCategory.Factual
|
||||
};
|
||||
|
||||
var attestation = AiClaimAttestation.FromClaimEvidence(
|
||||
evidence,
|
||||
runId: "run-123",
|
||||
turnId: "turn-456",
|
||||
tenantId: "tenant-xyz",
|
||||
timestamp: DateTimeOffset.Parse("2026-01-09T12:00:00Z"));
|
||||
|
||||
attestation.ClaimText.Should().Be(evidence.Text);
|
||||
attestation.RunId.Should().Be("run-123");
|
||||
attestation.TurnId.Should().Be("turn-456");
|
||||
attestation.TenantId.Should().Be("tenant-xyz");
|
||||
attestation.GroundedBy.Should().HaveCount(2);
|
||||
attestation.GroundingScore.Should().Be(0.95);
|
||||
attestation.Verified.Should().BeTrue();
|
||||
attestation.Category.Should().Be(ClaimCategory.Factual);
|
||||
attestation.ClaimDigest.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClaimDigest_IsDeterministic()
|
||||
{
|
||||
var evidence1 = new ClaimEvidence
|
||||
{
|
||||
Text = "Same text",
|
||||
Position = 0,
|
||||
Length = 9,
|
||||
GroundingScore = 0.9
|
||||
};
|
||||
|
||||
var evidence2 = new ClaimEvidence
|
||||
{
|
||||
Text = "Same text",
|
||||
Position = 100, // Different position
|
||||
Length = 9,
|
||||
GroundingScore = 0.5 // Different score
|
||||
};
|
||||
|
||||
var attestation1 = AiClaimAttestation.FromClaimEvidence(
|
||||
evidence1, "run-1", "turn-1", "tenant-1", DateTimeOffset.UtcNow);
|
||||
var attestation2 = AiClaimAttestation.FromClaimEvidence(
|
||||
evidence2, "run-2", "turn-2", "tenant-2", DateTimeOffset.UtcNow);
|
||||
|
||||
// ClaimDigest should be same because it's based on text only
|
||||
attestation1.ClaimDigest.Should().Be(attestation2.ClaimDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attestation_WithGrounding_PreservesEvidenceUris()
|
||||
{
|
||||
var groundedBy = ImmutableArray.Create(
|
||||
"stella://sbom/abc123",
|
||||
"stella://reach/api:vulnFunc",
|
||||
"stella://vex/CVE-2023-44487");
|
||||
|
||||
var attestation = CreateSampleClaimAttestation() with
|
||||
{
|
||||
GroundedBy = groundedBy
|
||||
};
|
||||
|
||||
attestation.GroundedBy.Should().HaveCount(3);
|
||||
attestation.GroundedBy.Should().Contain("stella://sbom/abc123");
|
||||
}
|
||||
private static readonly DateTimeOffset FixedUtcNow = new(2026, 1, 9, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static AiClaimAttestation CreateSampleClaimAttestation()
|
||||
{
|
||||
@@ -136,7 +28,7 @@ public class AiClaimAttestationTests
|
||||
GroundedBy = ["stella://sbom/test"],
|
||||
GroundingScore = 0.85,
|
||||
Verified = true,
|
||||
Timestamp = DateTimeOffset.Parse("2026-01-09T12:00:00Z"),
|
||||
Timestamp = FixedUtcNow,
|
||||
ContentDigest = "sha256:content123"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// <copyright file="AiRunAttestationTests.Context.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryAI.Attestation.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
|
||||
public sealed partial class AiRunAttestationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Attestation_WithContext_PreservesContext()
|
||||
{
|
||||
var context = new AiRunContext
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
CveId = "CVE-2023-44487",
|
||||
Component = "pkg:npm/http2@1.0.0"
|
||||
};
|
||||
|
||||
var attestation = CreateSampleAttestation() with { Context = context };
|
||||
|
||||
attestation.Context.Should().NotBeNull();
|
||||
attestation.Context!.FindingId.Should().Be("finding-123");
|
||||
attestation.Context.CveId.Should().Be("CVE-2023-44487");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// <copyright file="AiRunAttestationTests.Digest.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
|
||||
public sealed partial class AiRunAttestationTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeDigest_SameAttestation_ReturnsSameDigest()
|
||||
{
|
||||
var attestation = CreateSampleAttestation();
|
||||
|
||||
var digest1 = attestation.ComputeDigest();
|
||||
var digest2 = attestation.ComputeDigest();
|
||||
|
||||
digest1.Should().Be(digest2);
|
||||
digest1.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDigest_DifferentAttestations_ReturnsDifferentDigests()
|
||||
{
|
||||
var attestation1 = CreateSampleAttestation();
|
||||
var attestation2 = attestation1 with { RunId = "run-different" };
|
||||
|
||||
var digest1 = attestation1.ComputeDigest();
|
||||
var digest2 = attestation2.ComputeDigest();
|
||||
|
||||
digest1.Should().NotBe(digest2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// <copyright file="AiRunAttestationTests.PredicateAndStatus.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryAI.Attestation.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
|
||||
public sealed partial class AiRunAttestationTests
|
||||
{
|
||||
[Fact]
|
||||
public void PredicateType_IsCorrect()
|
||||
{
|
||||
AiRunAttestation.PredicateType.Should().Be("https://stellaops.org/attestation/ai-run/v1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attestation_DefaultStatus_IsCompleted()
|
||||
{
|
||||
var attestation = CreateSampleAttestation();
|
||||
|
||||
attestation.Status.Should().Be(AiRunStatus.Completed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// <copyright file="AiRunAttestationTests.Turns.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryAI.Attestation.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
|
||||
public sealed partial class AiRunAttestationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Attestation_WithTurns_PreservesOrder()
|
||||
{
|
||||
var turns = new[]
|
||||
{
|
||||
CreateTurn("turn-1", TurnRole.User, FixedUtcNow),
|
||||
CreateTurn("turn-2", TurnRole.Assistant, FixedUtcNow.AddSeconds(5)),
|
||||
CreateTurn("turn-3", TurnRole.User, FixedUtcNow.AddSeconds(10))
|
||||
};
|
||||
|
||||
var attestation = CreateSampleAttestation() with
|
||||
{
|
||||
Turns = [.. turns]
|
||||
};
|
||||
|
||||
attestation.Turns.Should().HaveCount(3);
|
||||
attestation.Turns[0].TurnId.Should().Be("turn-1");
|
||||
attestation.Turns[1].TurnId.Should().Be("turn-2");
|
||||
attestation.Turns[2].TurnId.Should().Be("turn-3");
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
// <copyright file="AiRunAttestationTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryAI.Attestation.Models;
|
||||
using Xunit;
|
||||
|
||||
@@ -13,83 +10,9 @@ namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
/// Tests for <see cref="AiRunAttestation"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class AiRunAttestationTests
|
||||
public sealed partial class AiRunAttestationTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeDigest_SameAttestation_ReturnsSameDigest()
|
||||
{
|
||||
var attestation = CreateSampleAttestation();
|
||||
|
||||
var digest1 = attestation.ComputeDigest();
|
||||
var digest2 = attestation.ComputeDigest();
|
||||
|
||||
digest1.Should().Be(digest2);
|
||||
digest1.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDigest_DifferentAttestations_ReturnsDifferentDigests()
|
||||
{
|
||||
var attestation1 = CreateSampleAttestation();
|
||||
var attestation2 = attestation1 with { RunId = "run-different" };
|
||||
|
||||
var digest1 = attestation1.ComputeDigest();
|
||||
var digest2 = attestation2.ComputeDigest();
|
||||
|
||||
digest1.Should().NotBe(digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PredicateType_IsCorrect()
|
||||
{
|
||||
AiRunAttestation.PredicateType.Should().Be("https://stellaops.org/attestation/ai-run/v1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attestation_WithTurns_PreservesOrder()
|
||||
{
|
||||
var turns = new[]
|
||||
{
|
||||
CreateTurn("turn-1", TurnRole.User, "2026-01-09T12:00:00Z"),
|
||||
CreateTurn("turn-2", TurnRole.Assistant, "2026-01-09T12:00:05Z"),
|
||||
CreateTurn("turn-3", TurnRole.User, "2026-01-09T12:00:10Z")
|
||||
};
|
||||
|
||||
var attestation = CreateSampleAttestation() with
|
||||
{
|
||||
Turns = [.. turns]
|
||||
};
|
||||
|
||||
attestation.Turns.Should().HaveCount(3);
|
||||
attestation.Turns[0].TurnId.Should().Be("turn-1");
|
||||
attestation.Turns[1].TurnId.Should().Be("turn-2");
|
||||
attestation.Turns[2].TurnId.Should().Be("turn-3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attestation_WithContext_PreservesContext()
|
||||
{
|
||||
var context = new AiRunContext
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
CveId = "CVE-2023-44487",
|
||||
Component = "pkg:npm/http2@1.0.0"
|
||||
};
|
||||
|
||||
var attestation = CreateSampleAttestation() with { Context = context };
|
||||
|
||||
attestation.Context.Should().NotBeNull();
|
||||
attestation.Context!.FindingId.Should().Be("finding-123");
|
||||
attestation.Context.CveId.Should().Be("CVE-2023-44487");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attestation_DefaultStatus_IsCompleted()
|
||||
{
|
||||
var attestation = CreateSampleAttestation();
|
||||
|
||||
attestation.Status.Should().Be(AiRunStatus.Completed);
|
||||
}
|
||||
private static readonly DateTimeOffset FixedUtcNow = new(2026, 1, 9, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static AiRunAttestation CreateSampleAttestation()
|
||||
{
|
||||
@@ -98,8 +21,8 @@ public class AiRunAttestationTests
|
||||
RunId = "run-abc123",
|
||||
TenantId = "tenant-xyz",
|
||||
UserId = "user:alice@example.com",
|
||||
StartedAt = DateTimeOffset.Parse("2026-01-09T12:00:00Z"),
|
||||
CompletedAt = DateTimeOffset.Parse("2026-01-09T12:05:00Z"),
|
||||
StartedAt = FixedUtcNow,
|
||||
CompletedAt = FixedUtcNow.AddMinutes(5),
|
||||
Model = new AiModelInfo
|
||||
{
|
||||
Provider = "anthropic",
|
||||
@@ -110,14 +33,14 @@ public class AiRunAttestationTests
|
||||
};
|
||||
}
|
||||
|
||||
private static AiTurnSummary CreateTurn(string turnId, TurnRole role, string timestamp)
|
||||
private static AiTurnSummary CreateTurn(string turnId, TurnRole role, DateTimeOffset timestamp)
|
||||
{
|
||||
return new AiTurnSummary
|
||||
{
|
||||
TurnId = turnId,
|
||||
Role = role,
|
||||
ContentDigest = $"sha256:turn-{turnId}",
|
||||
Timestamp = DateTimeOffset.Parse(timestamp),
|
||||
Timestamp = timestamp,
|
||||
TokenCount = 100
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// <copyright file="InMemoryAiAttestationStoreTests.ClaimAttestations.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
|
||||
public sealed partial class InMemoryAiAttestationStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StoreClaimAttestation_ThenRetrieve_WorksAsync()
|
||||
{
|
||||
var claim = CreateClaimAttestation("run-1", "turn-1");
|
||||
|
||||
await _store.StoreClaimAttestationAsync(claim, NoCancellation);
|
||||
|
||||
var claims = await _store.GetClaimAttestationsAsync("run-1", NoCancellation);
|
||||
|
||||
claims.Should().HaveCount(1);
|
||||
claims[0].TurnId.Should().Be("turn-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetClaimAttestations_MultiplePerRun_ReturnsAllAsync()
|
||||
{
|
||||
await _store.StoreClaimAttestationAsync(CreateClaimAttestation("run-1", "turn-1"), NoCancellation);
|
||||
await _store.StoreClaimAttestationAsync(CreateClaimAttestation("run-1", "turn-2"), NoCancellation);
|
||||
await _store.StoreClaimAttestationAsync(CreateClaimAttestation("run-1", "turn-3"), NoCancellation);
|
||||
|
||||
var claims = await _store.GetClaimAttestationsAsync("run-1", NoCancellation);
|
||||
|
||||
claims.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetClaimAttestationsByTurn_FiltersCorrectlyAsync()
|
||||
{
|
||||
await _store.StoreClaimAttestationAsync(CreateClaimAttestation("run-1", "turn-1"), NoCancellation);
|
||||
await _store.StoreClaimAttestationAsync(CreateClaimAttestation("run-1", "turn-2"), NoCancellation);
|
||||
await _store.StoreClaimAttestationAsync(CreateClaimAttestation("run-1", "turn-1"), NoCancellation);
|
||||
|
||||
var turn1Claims = await _store.GetClaimAttestationsByTurnAsync("run-1", "turn-1", NoCancellation);
|
||||
|
||||
turn1Claims.Should().HaveCount(2);
|
||||
turn1Claims.Should().OnlyContain(c => c.TurnId == "turn-1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// <copyright file="InMemoryAiAttestationStoreTests.Clear.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
|
||||
public sealed partial class InMemoryAiAttestationStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Clear_RemovesAllDataAsync()
|
||||
{
|
||||
await _store.StoreRunAttestationAsync(CreateRunAttestation("run-1"), NoCancellation);
|
||||
await _store.StoreClaimAttestationAsync(CreateClaimAttestation("run-1", "turn-1"), NoCancellation);
|
||||
|
||||
_store.Clear();
|
||||
|
||||
_store.RunAttestationCount.Should().Be(0);
|
||||
_store.ClaimAttestationCount.Should().Be(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// <copyright file="InMemoryAiAttestationStoreTests.ContentDigest.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
|
||||
public sealed partial class InMemoryAiAttestationStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetByContentDigest_WorksAsync()
|
||||
{
|
||||
var claim = CreateClaimAttestation("run-1", "turn-1", "sha256:test123");
|
||||
await _store.StoreClaimAttestationAsync(claim, NoCancellation);
|
||||
|
||||
var retrieved = await _store.GetByContentDigestAsync("sha256:test123", NoCancellation);
|
||||
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.ContentDigest.Should().Be("sha256:test123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByContentDigest_NotFound_ReturnsNullAsync()
|
||||
{
|
||||
var retrieved = await _store.GetByContentDigestAsync("sha256:nonexistent", NoCancellation);
|
||||
|
||||
retrieved.Should().BeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// <copyright file="InMemoryAiAttestationStoreTests.Envelopes.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
|
||||
public sealed partial class InMemoryAiAttestationStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StoreSignedEnvelope_ThenRetrieve_WorksAsync()
|
||||
{
|
||||
var envelope = new { Type = "DSSE", Payload = "test" };
|
||||
|
||||
await _store.StoreSignedEnvelopeAsync("run-1", envelope, NoCancellation);
|
||||
|
||||
var retrieved = await _store.GetSignedEnvelopeAsync("run-1", NoCancellation);
|
||||
|
||||
retrieved.Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// <copyright file="InMemoryAiAttestationStoreTests.RunAttestations.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
|
||||
public sealed partial class InMemoryAiAttestationStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StoreRunAttestation_ThenRetrieve_WorksAsync()
|
||||
{
|
||||
var attestation = CreateRunAttestation("run-1");
|
||||
|
||||
await _store.StoreRunAttestationAsync(attestation, NoCancellation);
|
||||
|
||||
var retrieved = await _store.GetRunAttestationAsync("run-1", NoCancellation);
|
||||
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.RunId.Should().Be("run-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRunAttestation_NotFound_ReturnsNullAsync()
|
||||
{
|
||||
var retrieved = await _store.GetRunAttestationAsync("non-existent", NoCancellation);
|
||||
|
||||
retrieved.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exists_WhenStored_ReturnsTrueAsync()
|
||||
{
|
||||
await _store.StoreRunAttestationAsync(CreateRunAttestation("run-1"), NoCancellation);
|
||||
|
||||
var exists = await _store.ExistsAsync("run-1", NoCancellation);
|
||||
|
||||
exists.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exists_WhenNotStored_ReturnsFalseAsync()
|
||||
{
|
||||
var exists = await _store.ExistsAsync("non-existent", NoCancellation);
|
||||
|
||||
exists.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// <copyright file="InMemoryAiAttestationStoreTests.TenantQueries.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
|
||||
public sealed partial class InMemoryAiAttestationStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetByTenant_FiltersCorrectlyAsync()
|
||||
{
|
||||
var now = FixedUtcNow;
|
||||
var att1 = CreateRunAttestation("run-1", "tenant-a", now.AddMinutes(-30));
|
||||
var att2 = CreateRunAttestation("run-2", "tenant-a", now.AddMinutes(-10));
|
||||
var att3 = CreateRunAttestation("run-3", "tenant-b", now.AddMinutes(-20));
|
||||
|
||||
await _store.StoreRunAttestationAsync(att1, NoCancellation);
|
||||
await _store.StoreRunAttestationAsync(att2, NoCancellation);
|
||||
await _store.StoreRunAttestationAsync(att3, NoCancellation);
|
||||
|
||||
var tenantAResults = await _store.GetByTenantAsync(
|
||||
"tenant-a",
|
||||
now.AddHours(-1),
|
||||
now,
|
||||
NoCancellation);
|
||||
|
||||
tenantAResults.Should().HaveCount(2);
|
||||
tenantAResults.Should().OnlyContain(a => a.TenantId == "tenant-a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByTenant_FiltersTimeRangeCorrectlyAsync()
|
||||
{
|
||||
var now = FixedUtcNow;
|
||||
var att1 = CreateRunAttestation("run-1", "tenant-a", now.AddHours(-3));
|
||||
var att2 = CreateRunAttestation("run-2", "tenant-a", now.AddHours(-1));
|
||||
var att3 = CreateRunAttestation("run-3", "tenant-a", now.AddMinutes(-30));
|
||||
|
||||
await _store.StoreRunAttestationAsync(att1, NoCancellation);
|
||||
await _store.StoreRunAttestationAsync(att2, NoCancellation);
|
||||
await _store.StoreRunAttestationAsync(att3, NoCancellation);
|
||||
|
||||
var results = await _store.GetByTenantAsync(
|
||||
"tenant-a",
|
||||
now.AddHours(-2),
|
||||
now,
|
||||
NoCancellation);
|
||||
|
||||
results.Should().HaveCount(2);
|
||||
results.Should().NotContain(a => a.RunId == "run-1");
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
// <copyright file="InMemoryAiAttestationStoreTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Attestation.Models;
|
||||
using StellaOps.AdvisoryAI.Attestation.Storage;
|
||||
@@ -15,192 +14,33 @@ namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
/// Tests for <see cref="InMemoryAiAttestationStore"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class InMemoryAiAttestationStoreTests
|
||||
public sealed partial class InMemoryAiAttestationStoreTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedUtcNow = new(2026, 1, 9, 12, 0, 0, TimeSpan.Zero);
|
||||
private static readonly CancellationToken NoCancellation = CancellationToken.None;
|
||||
private const string DefaultTenantId = "test-tenant";
|
||||
private readonly InMemoryAiAttestationStore _store;
|
||||
private int _claimCounter;
|
||||
|
||||
public InMemoryAiAttestationStoreTests()
|
||||
{
|
||||
_store = new InMemoryAiAttestationStore(NullLogger<InMemoryAiAttestationStore>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreRunAttestation_ThenRetrieve_Works()
|
||||
{
|
||||
var attestation = CreateRunAttestation("run-1");
|
||||
|
||||
await _store.StoreRunAttestationAsync(attestation, CancellationToken.None);
|
||||
|
||||
var retrieved = await _store.GetRunAttestationAsync("run-1", CancellationToken.None);
|
||||
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.RunId.Should().Be("run-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRunAttestation_NotFound_ReturnsNull()
|
||||
{
|
||||
var retrieved = await _store.GetRunAttestationAsync("non-existent", CancellationToken.None);
|
||||
|
||||
retrieved.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreClaimAttestation_ThenRetrieve_Works()
|
||||
{
|
||||
var claim = CreateClaimAttestation("run-1", "turn-1");
|
||||
|
||||
await _store.StoreClaimAttestationAsync(claim, CancellationToken.None);
|
||||
|
||||
var claims = await _store.GetClaimAttestationsAsync("run-1", CancellationToken.None);
|
||||
|
||||
claims.Should().HaveCount(1);
|
||||
claims[0].TurnId.Should().Be("turn-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetClaimAttestations_MultiplePerRun_ReturnsAll()
|
||||
{
|
||||
await _store.StoreClaimAttestationAsync(CreateClaimAttestation("run-1", "turn-1"), CancellationToken.None);
|
||||
await _store.StoreClaimAttestationAsync(CreateClaimAttestation("run-1", "turn-2"), CancellationToken.None);
|
||||
await _store.StoreClaimAttestationAsync(CreateClaimAttestation("run-1", "turn-3"), CancellationToken.None);
|
||||
|
||||
var claims = await _store.GetClaimAttestationsAsync("run-1", CancellationToken.None);
|
||||
|
||||
claims.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetClaimAttestationsByTurn_FiltersCorrectly()
|
||||
{
|
||||
await _store.StoreClaimAttestationAsync(CreateClaimAttestation("run-1", "turn-1"), CancellationToken.None);
|
||||
await _store.StoreClaimAttestationAsync(CreateClaimAttestation("run-1", "turn-2"), CancellationToken.None);
|
||||
await _store.StoreClaimAttestationAsync(CreateClaimAttestation("run-1", "turn-1"), CancellationToken.None);
|
||||
|
||||
var turn1Claims = await _store.GetClaimAttestationsByTurnAsync("run-1", "turn-1", CancellationToken.None);
|
||||
|
||||
turn1Claims.Should().HaveCount(2);
|
||||
turn1Claims.Should().OnlyContain(c => c.TurnId == "turn-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exists_WhenStored_ReturnsTrue()
|
||||
{
|
||||
await _store.StoreRunAttestationAsync(CreateRunAttestation("run-1"), CancellationToken.None);
|
||||
|
||||
var exists = await _store.ExistsAsync("run-1", CancellationToken.None);
|
||||
|
||||
exists.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exists_WhenNotStored_ReturnsFalse()
|
||||
{
|
||||
var exists = await _store.ExistsAsync("non-existent", CancellationToken.None);
|
||||
|
||||
exists.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSignedEnvelope_ThenRetrieve_Works()
|
||||
{
|
||||
var envelope = new { Type = "DSSE", Payload = "test" };
|
||||
|
||||
await _store.StoreSignedEnvelopeAsync("run-1", envelope, CancellationToken.None);
|
||||
|
||||
var retrieved = await _store.GetSignedEnvelopeAsync("run-1", CancellationToken.None);
|
||||
|
||||
retrieved.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByTenant_FiltersCorrectly()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var att1 = CreateRunAttestation("run-1", "tenant-a", now.AddMinutes(-30));
|
||||
var att2 = CreateRunAttestation("run-2", "tenant-a", now.AddMinutes(-10));
|
||||
var att3 = CreateRunAttestation("run-3", "tenant-b", now.AddMinutes(-20));
|
||||
|
||||
await _store.StoreRunAttestationAsync(att1, CancellationToken.None);
|
||||
await _store.StoreRunAttestationAsync(att2, CancellationToken.None);
|
||||
await _store.StoreRunAttestationAsync(att3, CancellationToken.None);
|
||||
|
||||
var tenantAResults = await _store.GetByTenantAsync(
|
||||
"tenant-a",
|
||||
now.AddHours(-1),
|
||||
now,
|
||||
CancellationToken.None);
|
||||
|
||||
tenantAResults.Should().HaveCount(2);
|
||||
tenantAResults.Should().OnlyContain(a => a.TenantId == "tenant-a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByTenant_FiltersTimeRangeCorrectly()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var att1 = CreateRunAttestation("run-1", "tenant-a", now.AddHours(-3));
|
||||
var att2 = CreateRunAttestation("run-2", "tenant-a", now.AddHours(-1));
|
||||
var att3 = CreateRunAttestation("run-3", "tenant-a", now.AddMinutes(-30));
|
||||
|
||||
await _store.StoreRunAttestationAsync(att1, CancellationToken.None);
|
||||
await _store.StoreRunAttestationAsync(att2, CancellationToken.None);
|
||||
await _store.StoreRunAttestationAsync(att3, CancellationToken.None);
|
||||
|
||||
var results = await _store.GetByTenantAsync(
|
||||
"tenant-a",
|
||||
now.AddHours(-2),
|
||||
now,
|
||||
CancellationToken.None);
|
||||
|
||||
results.Should().HaveCount(2);
|
||||
results.Should().NotContain(a => a.RunId == "run-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByContentDigest_Works()
|
||||
{
|
||||
var claim = CreateClaimAttestation("run-1", "turn-1", "sha256:test123");
|
||||
await _store.StoreClaimAttestationAsync(claim, CancellationToken.None);
|
||||
|
||||
var retrieved = await _store.GetByContentDigestAsync("sha256:test123", CancellationToken.None);
|
||||
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.ContentDigest.Should().Be("sha256:test123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByContentDigest_NotFound_ReturnsNull()
|
||||
{
|
||||
var retrieved = await _store.GetByContentDigestAsync("sha256:nonexistent", CancellationToken.None);
|
||||
|
||||
retrieved.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clear_RemovesAllData()
|
||||
{
|
||||
await _store.StoreRunAttestationAsync(CreateRunAttestation("run-1"), CancellationToken.None);
|
||||
await _store.StoreClaimAttestationAsync(CreateClaimAttestation("run-1", "turn-1"), CancellationToken.None);
|
||||
|
||||
_store.Clear();
|
||||
|
||||
_store.RunAttestationCount.Should().Be(0);
|
||||
_store.ClaimAttestationCount.Should().Be(0);
|
||||
}
|
||||
|
||||
private static AiRunAttestation CreateRunAttestation(
|
||||
string runId,
|
||||
string tenantId = "test-tenant",
|
||||
string tenantId = DefaultTenantId,
|
||||
DateTimeOffset? startedAt = null)
|
||||
{
|
||||
var start = startedAt ?? FixedUtcNow;
|
||||
|
||||
return new AiRunAttestation
|
||||
{
|
||||
RunId = runId,
|
||||
TenantId = tenantId,
|
||||
UserId = "user-1",
|
||||
StartedAt = startedAt ?? DateTimeOffset.UtcNow,
|
||||
CompletedAt = DateTimeOffset.UtcNow,
|
||||
StartedAt = start,
|
||||
CompletedAt = start.AddMinutes(5),
|
||||
Model = new AiModelInfo
|
||||
{
|
||||
ModelId = "gpt-4",
|
||||
@@ -210,20 +50,23 @@ public class InMemoryAiAttestationStoreTests
|
||||
};
|
||||
}
|
||||
|
||||
private static AiClaimAttestation CreateClaimAttestation(
|
||||
private AiClaimAttestation CreateClaimAttestation(
|
||||
string runId,
|
||||
string turnId,
|
||||
string? contentDigest = null)
|
||||
{
|
||||
var suffix = Interlocked.Increment(ref _claimCounter);
|
||||
var claimId = $"claim-{runId}-{turnId}-{suffix:0000}";
|
||||
|
||||
return new AiClaimAttestation
|
||||
{
|
||||
ClaimId = $"claim-{Guid.NewGuid():N}",
|
||||
ClaimId = claimId,
|
||||
RunId = runId,
|
||||
TurnId = turnId,
|
||||
TenantId = "test-tenant",
|
||||
TenantId = DefaultTenantId,
|
||||
ClaimText = "Test claim text",
|
||||
ClaimDigest = "sha256:claimhash",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
ClaimDigest = $"sha256:{claimId}",
|
||||
Timestamp = FixedUtcNow,
|
||||
ClaimType = "vulnerability_assessment",
|
||||
ContentDigest = contentDigest ?? $"sha256:{runId}-{turnId}"
|
||||
};
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// <copyright file="AttestationServiceFixture.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.AdvisoryAI.Attestation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests.Integration;
|
||||
|
||||
public sealed class AttestationServiceFixture : IAsyncLifetime
|
||||
{
|
||||
private static readonly DateTimeOffset FixedUtcNow = new(2026, 1, 9, 12, 0, 0, TimeSpan.Zero);
|
||||
private ServiceProvider _serviceProvider = null!;
|
||||
|
||||
public IAiAttestationService AttestationService { get; private set; } = null!;
|
||||
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
var timeProvider = new FakeTimeProvider();
|
||||
timeProvider.SetUtcNow(FixedUtcNow);
|
||||
services.AddAiAttestationServices(timeProvider);
|
||||
services.AddInMemoryAiAttestationStore();
|
||||
|
||||
_serviceProvider = services.BuildServiceProvider();
|
||||
AttestationService = _serviceProvider.GetRequiredService<IAiAttestationService>();
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _serviceProvider.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// <copyright file="AttestationServiceIntegrationTests.ClaimAttestations.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests.Integration;
|
||||
|
||||
public sealed partial class AttestationServiceIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task FullClaimAttestationFlow_CreateSignVerify_SucceedsAsync()
|
||||
{
|
||||
var runAttestation = CreateSampleRunAttestation("run-integration-002");
|
||||
await _attestationService.CreateRunAttestationAsync(runAttestation);
|
||||
|
||||
var claimAttestation = CreateSampleClaimAttestation("claim-001", "run-integration-002", "turn-001");
|
||||
|
||||
var createResult = await _attestationService.CreateClaimAttestationAsync(claimAttestation, sign: true);
|
||||
|
||||
Assert.NotNull(createResult.Digest);
|
||||
|
||||
var claims = await _attestationService.GetClaimAttestationsAsync("run-integration-002");
|
||||
|
||||
Assert.Single(claims);
|
||||
Assert.Equal("claim-001", claims[0].ClaimId);
|
||||
|
||||
var verifyResult = await _attestationService.VerifyClaimAttestationAsync("claim-001");
|
||||
|
||||
Assert.True(verifyResult.Valid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StorageRoundTrip_MultipleClaimsPerRun_AllRetrievableAsync()
|
||||
{
|
||||
var runId = "run-multiclaim-001";
|
||||
var run = CreateSampleRunAttestation(runId);
|
||||
await _attestationService.CreateRunAttestationAsync(run);
|
||||
|
||||
var claims = Enumerable.Range(1, 3)
|
||||
.Select(i => CreateSampleClaimAttestation($"claim-mc-{i:D3}", runId, $"turn-{i:D3}"))
|
||||
.ToList();
|
||||
|
||||
foreach (var claim in claims)
|
||||
{
|
||||
var result = await _attestationService.CreateClaimAttestationAsync(claim);
|
||||
Assert.NotNull(result.Digest);
|
||||
}
|
||||
|
||||
var retrieved = await _attestationService.GetClaimAttestationsAsync(runId);
|
||||
Assert.Equal(3, retrieved.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// <copyright file="AttestationServiceIntegrationTests.Query.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests.Integration;
|
||||
|
||||
public sealed partial class AttestationServiceIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StorageRoundTrip_MultipleRuns_AllRetrievableAsync()
|
||||
{
|
||||
var runs = Enumerable.Range(1, 5)
|
||||
.Select(i => CreateSampleRunAttestation($"run-roundtrip-{i:D3}"))
|
||||
.ToList();
|
||||
|
||||
foreach (var run in runs)
|
||||
{
|
||||
var result = await _attestationService.CreateRunAttestationAsync(run);
|
||||
Assert.NotNull(result.Digest);
|
||||
}
|
||||
|
||||
foreach (var run in runs)
|
||||
{
|
||||
var retrieved = await _attestationService.GetRunAttestationAsync(run.RunId);
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(run.RunId, retrieved.RunId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryByTenant_ReturnsOnlyTenantRunsAsync()
|
||||
{
|
||||
var tenant1Run = CreateSampleRunAttestation("run-tenant1-001", tenantId: "tenant-1");
|
||||
var tenant2Run = CreateSampleRunAttestation("run-tenant2-001", tenantId: "tenant-2");
|
||||
|
||||
await _attestationService.CreateRunAttestationAsync(tenant1Run);
|
||||
await _attestationService.CreateRunAttestationAsync(tenant2Run);
|
||||
|
||||
var tenant1Runs = await _attestationService.ListRecentAttestationsAsync("tenant-1", limit: 10);
|
||||
var tenant2Runs = await _attestationService.ListRecentAttestationsAsync("tenant-2", limit: 10);
|
||||
|
||||
Assert.Single(tenant1Runs);
|
||||
Assert.Equal("run-tenant1-001", tenant1Runs[0].RunId);
|
||||
|
||||
Assert.Single(tenant2Runs);
|
||||
Assert.Equal("run-tenant2-001", tenant2Runs[0].RunId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// <copyright file="AttestationServiceIntegrationTests.RunAttestations.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests.Integration;
|
||||
|
||||
public sealed partial class AttestationServiceIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task FullRunAttestationFlow_CreateSignVerify_SucceedsAsync()
|
||||
{
|
||||
var attestation = CreateSampleRunAttestation("run-integration-001");
|
||||
|
||||
var createResult = await _attestationService.CreateRunAttestationAsync(attestation, sign: true);
|
||||
|
||||
Assert.NotNull(createResult.Digest);
|
||||
Assert.StartsWith("sha256:", createResult.Digest);
|
||||
|
||||
var retrieved = await _attestationService.GetRunAttestationAsync("run-integration-001");
|
||||
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(attestation.RunId, retrieved.RunId);
|
||||
Assert.Equal(attestation.TenantId, retrieved.TenantId);
|
||||
|
||||
var verifyResult = await _attestationService.VerifyRunAttestationAsync("run-integration-001");
|
||||
|
||||
Assert.True(verifyResult.Valid);
|
||||
Assert.True(verifyResult.DigestValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsignedAttestation_VerifiesDigestOnlyAsync()
|
||||
{
|
||||
var attestation = CreateSampleRunAttestation("run-unsigned-001");
|
||||
|
||||
var createResult = await _attestationService.CreateRunAttestationAsync(attestation, sign: false);
|
||||
Assert.NotNull(createResult.Digest);
|
||||
|
||||
var verifyResult = await _attestationService.VerifyRunAttestationAsync("run-unsigned-001");
|
||||
|
||||
Assert.True(verifyResult.Valid);
|
||||
Assert.True(verifyResult.DigestValid);
|
||||
Assert.Null(verifyResult.SignatureValid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// <copyright file="AttestationServiceIntegrationTests.Verification.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests.Integration;
|
||||
|
||||
public sealed partial class AttestationServiceIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task VerificationFailure_TamperedContent_ReturnsInvalidAsync()
|
||||
{
|
||||
var attestation = CreateSampleRunAttestation("run-tamper-001");
|
||||
var createResult = await _attestationService.CreateRunAttestationAsync(attestation, sign: true);
|
||||
Assert.NotNull(createResult.Digest);
|
||||
|
||||
var verifyResult = await _attestationService.VerifyRunAttestationAsync("run-tamper-001");
|
||||
|
||||
Assert.True(verifyResult.Valid, "Original attestation should verify");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerificationFailure_NonExistentRun_ReturnsInvalidAsync()
|
||||
{
|
||||
var verifyResult = await _attestationService.VerifyRunAttestationAsync("non-existent-run");
|
||||
|
||||
Assert.False(verifyResult.Valid);
|
||||
Assert.Contains("not found", verifyResult.FailureReason, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
// <copyright file="AttestationServiceIntegrationTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Attestation;
|
||||
using StellaOps.AdvisoryAI.Attestation.Models;
|
||||
using StellaOps.AdvisoryAI.Attestation.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests.Integration;
|
||||
@@ -15,215 +12,14 @@ namespace StellaOps.AdvisoryAI.Attestation.Tests.Integration;
|
||||
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-008
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class AttestationServiceIntegrationTests : IAsyncLifetime
|
||||
public sealed partial class AttestationServiceIntegrationTests : IClassFixture<AttestationServiceFixture>
|
||||
{
|
||||
private ServiceProvider _serviceProvider = null!;
|
||||
private IAiAttestationService _attestationService = null!;
|
||||
private IAiAttestationStore _store = null!;
|
||||
private TimeProvider _timeProvider = null!;
|
||||
private static readonly DateTimeOffset FixedUtcNow = new(2026, 1, 9, 12, 0, 0, TimeSpan.Zero);
|
||||
private readonly IAiAttestationService _attestationService;
|
||||
|
||||
public ValueTask InitializeAsync()
|
||||
public AttestationServiceIntegrationTests(AttestationServiceFixture fixture)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Add logging
|
||||
services.AddLogging();
|
||||
|
||||
// Register all attestation services
|
||||
services.AddAiAttestationServices();
|
||||
services.AddInMemoryAiAttestationStore();
|
||||
|
||||
_serviceProvider = services.BuildServiceProvider();
|
||||
_attestationService = _serviceProvider.GetRequiredService<IAiAttestationService>();
|
||||
_store = _serviceProvider.GetRequiredService<IAiAttestationStore>();
|
||||
_timeProvider = _serviceProvider.GetRequiredService<TimeProvider>();
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _serviceProvider.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullRunAttestationFlow_CreateSignVerify_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = CreateSampleRunAttestation("run-integration-001");
|
||||
|
||||
// Act - Create and sign
|
||||
var createResult = await _attestationService.CreateRunAttestationAsync(attestation, sign: true);
|
||||
|
||||
// Assert creation - result has Digest property
|
||||
Assert.NotNull(createResult.Digest);
|
||||
Assert.StartsWith("sha256:", createResult.Digest);
|
||||
|
||||
// Act - Retrieve
|
||||
var retrieved = await _attestationService.GetRunAttestationAsync("run-integration-001");
|
||||
|
||||
// Assert retrieval
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(attestation.RunId, retrieved.RunId);
|
||||
Assert.Equal(attestation.TenantId, retrieved.TenantId);
|
||||
|
||||
// Act - Verify
|
||||
var verifyResult = await _attestationService.VerifyRunAttestationAsync("run-integration-001");
|
||||
|
||||
// Assert verification
|
||||
Assert.True(verifyResult.Valid);
|
||||
Assert.True(verifyResult.DigestValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullClaimAttestationFlow_CreateSignVerify_Succeeds()
|
||||
{
|
||||
// Arrange - Create parent run first
|
||||
var runAttestation = CreateSampleRunAttestation("run-integration-002");
|
||||
await _attestationService.CreateRunAttestationAsync(runAttestation);
|
||||
|
||||
var claimAttestation = CreateSampleClaimAttestation("claim-001", "run-integration-002", "turn-001");
|
||||
|
||||
// Act - Create and sign
|
||||
var createResult = await _attestationService.CreateClaimAttestationAsync(claimAttestation, sign: true);
|
||||
|
||||
// Assert creation
|
||||
Assert.NotNull(createResult.Digest);
|
||||
|
||||
// Act - Retrieve claims for run
|
||||
var claims = await _attestationService.GetClaimAttestationsAsync("run-integration-002");
|
||||
|
||||
// Assert retrieval
|
||||
Assert.Single(claims);
|
||||
Assert.Equal("claim-001", claims[0].ClaimId);
|
||||
|
||||
// Act - Verify
|
||||
var verifyResult = await _attestationService.VerifyClaimAttestationAsync("claim-001");
|
||||
|
||||
// Assert verification
|
||||
Assert.True(verifyResult.Valid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StorageRoundTrip_MultipleRuns_AllRetrievable()
|
||||
{
|
||||
// Arrange - Create multiple runs
|
||||
var runs = Enumerable.Range(1, 5)
|
||||
.Select(i => CreateSampleRunAttestation($"run-roundtrip-{i:D3}"))
|
||||
.ToList();
|
||||
|
||||
// Act - Store all
|
||||
foreach (var run in runs)
|
||||
{
|
||||
var result = await _attestationService.CreateRunAttestationAsync(run);
|
||||
Assert.NotNull(result.Digest);
|
||||
}
|
||||
|
||||
// Assert - All retrievable
|
||||
foreach (var run in runs)
|
||||
{
|
||||
var retrieved = await _attestationService.GetRunAttestationAsync(run.RunId);
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(run.RunId, retrieved.RunId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StorageRoundTrip_MultipleClaimsPerRun_AllRetrievable()
|
||||
{
|
||||
// Arrange
|
||||
var runId = "run-multiclaim-001";
|
||||
var run = CreateSampleRunAttestation(runId);
|
||||
await _attestationService.CreateRunAttestationAsync(run);
|
||||
|
||||
var claims = Enumerable.Range(1, 3)
|
||||
.Select(i => CreateSampleClaimAttestation($"claim-mc-{i:D3}", runId, $"turn-{i:D3}"))
|
||||
.ToList();
|
||||
|
||||
// Act - Store all claims
|
||||
foreach (var claim in claims)
|
||||
{
|
||||
var result = await _attestationService.CreateClaimAttestationAsync(claim);
|
||||
Assert.NotNull(result.Digest);
|
||||
}
|
||||
|
||||
// Assert - All claims retrievable
|
||||
var retrieved = await _attestationService.GetClaimAttestationsAsync(runId);
|
||||
Assert.Equal(3, retrieved.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryByTenant_ReturnsOnlyTenantRuns()
|
||||
{
|
||||
// Arrange
|
||||
var tenant1Run = CreateSampleRunAttestation("run-tenant1-001", tenantId: "tenant-1");
|
||||
var tenant2Run = CreateSampleRunAttestation("run-tenant2-001", tenantId: "tenant-2");
|
||||
|
||||
await _attestationService.CreateRunAttestationAsync(tenant1Run);
|
||||
await _attestationService.CreateRunAttestationAsync(tenant2Run);
|
||||
|
||||
// Act
|
||||
var tenant1Runs = await _attestationService.ListRecentAttestationsAsync("tenant-1", limit: 10);
|
||||
var tenant2Runs = await _attestationService.ListRecentAttestationsAsync("tenant-2", limit: 10);
|
||||
|
||||
// Assert
|
||||
Assert.Single(tenant1Runs);
|
||||
Assert.Equal("run-tenant1-001", tenant1Runs[0].RunId);
|
||||
|
||||
Assert.Single(tenant2Runs);
|
||||
Assert.Equal("run-tenant2-001", tenant2Runs[0].RunId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerificationFailure_TamperedContent_ReturnsInvalid()
|
||||
{
|
||||
// This test validates tamper detection, which requires the service
|
||||
// to verify against stored digests. Currently the in-memory service
|
||||
// uses its own internal storage, so this scenario tests what's possible.
|
||||
|
||||
// Arrange
|
||||
var attestation = CreateSampleRunAttestation("run-tamper-001");
|
||||
var createResult = await _attestationService.CreateRunAttestationAsync(attestation, sign: true);
|
||||
Assert.NotNull(createResult.Digest);
|
||||
|
||||
// Act - Verify the original (should succeed)
|
||||
var verifyResult = await _attestationService.VerifyRunAttestationAsync("run-tamper-001");
|
||||
|
||||
// Assert - Original should verify
|
||||
Assert.True(verifyResult.Valid, "Original attestation should verify");
|
||||
|
||||
// Note: Full tamper detection (storing modified content and detecting mismatch)
|
||||
// requires AIAT-008 implementation. For now we just verify the happy path.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerificationFailure_NonExistentRun_ReturnsInvalid()
|
||||
{
|
||||
// Act
|
||||
var verifyResult = await _attestationService.VerifyRunAttestationAsync("non-existent-run");
|
||||
|
||||
// Assert
|
||||
Assert.False(verifyResult.Valid);
|
||||
Assert.Contains("not found", verifyResult.FailureReason, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsignedAttestation_VerifiesDigestOnly()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = CreateSampleRunAttestation("run-unsigned-001");
|
||||
|
||||
// Act - Create without signing
|
||||
var createResult = await _attestationService.CreateRunAttestationAsync(attestation, sign: false);
|
||||
Assert.NotNull(createResult.Digest);
|
||||
|
||||
// Act - Verify
|
||||
var verifyResult = await _attestationService.VerifyRunAttestationAsync("run-unsigned-001");
|
||||
|
||||
// Assert
|
||||
Assert.True(verifyResult.Valid);
|
||||
Assert.True(verifyResult.DigestValid);
|
||||
Assert.Null(verifyResult.SignatureValid); // No signature to verify
|
||||
_attestationService = fixture.AttestationService;
|
||||
}
|
||||
|
||||
private static AiRunAttestation CreateSampleRunAttestation(
|
||||
@@ -242,8 +38,8 @@ public sealed class AttestationServiceIntegrationTests : IAsyncLifetime
|
||||
Provider = "test-provider"
|
||||
},
|
||||
TotalTokens = 100,
|
||||
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
CompletedAt = DateTimeOffset.UtcNow
|
||||
StartedAt = FixedUtcNow.AddMinutes(-5),
|
||||
CompletedAt = FixedUtcNow
|
||||
};
|
||||
}
|
||||
|
||||
@@ -262,7 +58,7 @@ public sealed class AttestationServiceIntegrationTests : IAsyncLifetime
|
||||
ClaimText = "This is a test claim",
|
||||
ClaimDigest = $"sha256:{claimId}",
|
||||
ContentDigest = $"sha256:content-{claimId}",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = FixedUtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// <copyright file="PromptTemplateRegistryTests.GetAllTemplates.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
|
||||
public sealed partial class PromptTemplateRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetAllTemplates_ReturnsAllLatestVersions()
|
||||
{
|
||||
_registry.Register("template-a", "1.0.0", "Template A v1");
|
||||
_registry.Register("template-a", "1.1.0", "Template A v2");
|
||||
_registry.Register("template-b", "1.0.0", "Template B");
|
||||
_registry.Register("template-c", "2.0.0", "Template C");
|
||||
|
||||
var all = _registry.GetAllTemplates();
|
||||
|
||||
all.Should().HaveCount(3);
|
||||
all.Should().Contain(t => t.Name == "template-a" && t.Version == "1.1.0");
|
||||
all.Should().Contain(t => t.Name == "template-b");
|
||||
all.Should().Contain(t => t.Name == "template-c");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// <copyright file="PromptTemplateRegistryTests.GetTemplateInfo.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
|
||||
public sealed partial class PromptTemplateRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetTemplateInfo_ByVersion_ReturnsCorrectVersion()
|
||||
{
|
||||
_registry.Register("vuln-explanation", "1.0.0", "Template v1");
|
||||
_registry.Register("vuln-explanation", "1.1.0", "Template v2");
|
||||
|
||||
var v1 = _registry.GetTemplateInfo("vuln-explanation", "1.0.0");
|
||||
var v2 = _registry.GetTemplateInfo("vuln-explanation", "1.1.0");
|
||||
|
||||
v1!.Version.Should().Be("1.0.0");
|
||||
v2!.Version.Should().Be("1.1.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTemplateInfo_NotFound_ReturnsNull()
|
||||
{
|
||||
var info = _registry.GetTemplateInfo("non-existent");
|
||||
|
||||
info.Should().BeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// <copyright file="PromptTemplateRegistryTests.GuardClauses.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
|
||||
public sealed partial class PromptTemplateRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Register_NullName_Throws()
|
||||
{
|
||||
var act = () => _registry.Register(null!, "1.0.0", "content");
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_EmptyVersion_Throws()
|
||||
{
|
||||
var act = () => _registry.Register("name", string.Empty, "content");
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_EmptyTemplate_Throws()
|
||||
{
|
||||
var act = () => _registry.Register("name", "1.0.0", string.Empty);
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// <copyright file="PromptTemplateRegistryTests.Register.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
|
||||
public sealed partial class PromptTemplateRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Register_ValidTemplate_StoresWithDigest()
|
||||
{
|
||||
_registry.Register("vuln-explanation", "1.0.0", "Explain this vulnerability: {{cve}}");
|
||||
|
||||
var info = _registry.GetTemplateInfo("vuln-explanation");
|
||||
|
||||
info.Should().NotBeNull();
|
||||
info!.Name.Should().Be("vuln-explanation");
|
||||
info.Version.Should().Be("1.0.0");
|
||||
info.Digest.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_SameTemplateTwice_UpdatesVersion()
|
||||
{
|
||||
_registry.Register("vuln-explanation", "1.0.0", "Template v1");
|
||||
_registry.Register("vuln-explanation", "1.1.0", "Template v2");
|
||||
|
||||
var info = _registry.GetTemplateInfo("vuln-explanation");
|
||||
|
||||
info!.Version.Should().Be("1.1.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_DifferentContent_ProducesDifferentDigests()
|
||||
{
|
||||
_registry.Register("template-1", "1.0.0", "Content A");
|
||||
_registry.Register("template-2", "1.0.0", "Content B");
|
||||
|
||||
var info1 = _registry.GetTemplateInfo("template-1");
|
||||
var info2 = _registry.GetTemplateInfo("template-2");
|
||||
|
||||
info1!.Digest.Should().NotBe(info2!.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_SameContent_ProducesSameDigest()
|
||||
{
|
||||
const string content = "Same content";
|
||||
_registry.Register("template-1", "1.0.0", content);
|
||||
_registry.Register("template-2", "1.0.0", content);
|
||||
|
||||
var info1 = _registry.GetTemplateInfo("template-1");
|
||||
var info2 = _registry.GetTemplateInfo("template-2");
|
||||
|
||||
info1!.Digest.Should().Be(info2!.Digest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// <copyright file="PromptTemplateRegistryTests.VerifyHash.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
|
||||
public sealed partial class PromptTemplateRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void VerifyHash_MatchingHash_ReturnsTrue()
|
||||
{
|
||||
const string template = "Test template content";
|
||||
_registry.Register("test", "1.0.0", template);
|
||||
|
||||
var info = _registry.GetTemplateInfo("test");
|
||||
var result = _registry.VerifyHash("test", info!.Digest);
|
||||
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyHash_NonMatchingHash_ReturnsFalse()
|
||||
{
|
||||
_registry.Register("test", "1.0.0", "Test template content");
|
||||
|
||||
var result = _registry.VerifyHash("test", "sha256:wronghash");
|
||||
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyHash_NotFound_ReturnsFalse()
|
||||
{
|
||||
var result = _registry.VerifyHash("non-existent", "sha256:anyhash");
|
||||
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
// <copyright file="PromptTemplateRegistryTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.AdvisoryAI.Attestation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
@@ -13,155 +12,17 @@ namespace StellaOps.AdvisoryAI.Attestation.Tests;
|
||||
/// Tests for <see cref="PromptTemplateRegistry"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class PromptTemplateRegistryTests
|
||||
public sealed partial class PromptTemplateRegistryTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedUtcNow = new(2026, 1, 9, 12, 0, 0, TimeSpan.Zero);
|
||||
private readonly FakeTimeProvider _timeProvider = new();
|
||||
private readonly PromptTemplateRegistry _registry;
|
||||
|
||||
public PromptTemplateRegistryTests()
|
||||
{
|
||||
_timeProvider.SetUtcNow(FixedUtcNow);
|
||||
_registry = new PromptTemplateRegistry(
|
||||
_timeProvider,
|
||||
NullLogger<PromptTemplateRegistry>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_ValidTemplate_StoresWithDigest()
|
||||
{
|
||||
_registry.Register("vuln-explanation", "1.0.0", "Explain this vulnerability: {{cve}}");
|
||||
|
||||
var info = _registry.GetTemplateInfo("vuln-explanation");
|
||||
|
||||
info.Should().NotBeNull();
|
||||
info!.Name.Should().Be("vuln-explanation");
|
||||
info.Version.Should().Be("1.0.0");
|
||||
info.Digest.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_SameTemplateTwice_UpdatesVersion()
|
||||
{
|
||||
_registry.Register("vuln-explanation", "1.0.0", "Template v1");
|
||||
_registry.Register("vuln-explanation", "1.1.0", "Template v2");
|
||||
|
||||
var info = _registry.GetTemplateInfo("vuln-explanation");
|
||||
|
||||
info!.Version.Should().Be("1.1.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTemplateInfo_ByVersion_ReturnsCorrectVersion()
|
||||
{
|
||||
_registry.Register("vuln-explanation", "1.0.0", "Template v1");
|
||||
_registry.Register("vuln-explanation", "1.1.0", "Template v2");
|
||||
|
||||
var v1 = _registry.GetTemplateInfo("vuln-explanation", "1.0.0");
|
||||
var v2 = _registry.GetTemplateInfo("vuln-explanation", "1.1.0");
|
||||
|
||||
v1!.Version.Should().Be("1.0.0");
|
||||
v2!.Version.Should().Be("1.1.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTemplateInfo_NotFound_ReturnsNull()
|
||||
{
|
||||
var info = _registry.GetTemplateInfo("non-existent");
|
||||
|
||||
info.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyHash_MatchingHash_ReturnsTrue()
|
||||
{
|
||||
const string template = "Test template content";
|
||||
_registry.Register("test", "1.0.0", template);
|
||||
|
||||
var info = _registry.GetTemplateInfo("test");
|
||||
var result = _registry.VerifyHash("test", info!.Digest);
|
||||
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyHash_NonMatchingHash_ReturnsFalse()
|
||||
{
|
||||
_registry.Register("test", "1.0.0", "Test template content");
|
||||
|
||||
var result = _registry.VerifyHash("test", "sha256:wronghash");
|
||||
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyHash_NotFound_ReturnsFalse()
|
||||
{
|
||||
var result = _registry.VerifyHash("non-existent", "sha256:anyhash");
|
||||
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllTemplates_ReturnsAllLatestVersions()
|
||||
{
|
||||
_registry.Register("template-a", "1.0.0", "Template A v1");
|
||||
_registry.Register("template-a", "1.1.0", "Template A v2");
|
||||
_registry.Register("template-b", "1.0.0", "Template B");
|
||||
_registry.Register("template-c", "2.0.0", "Template C");
|
||||
|
||||
var all = _registry.GetAllTemplates();
|
||||
|
||||
all.Should().HaveCount(3);
|
||||
all.Should().Contain(t => t.Name == "template-a" && t.Version == "1.1.0");
|
||||
all.Should().Contain(t => t.Name == "template-b");
|
||||
all.Should().Contain(t => t.Name == "template-c");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_DifferentContent_ProducesDifferentDigests()
|
||||
{
|
||||
_registry.Register("template-1", "1.0.0", "Content A");
|
||||
_registry.Register("template-2", "1.0.0", "Content B");
|
||||
|
||||
var info1 = _registry.GetTemplateInfo("template-1");
|
||||
var info2 = _registry.GetTemplateInfo("template-2");
|
||||
|
||||
info1!.Digest.Should().NotBe(info2!.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_SameContent_ProducesSameDigest()
|
||||
{
|
||||
const string content = "Same content";
|
||||
_registry.Register("template-1", "1.0.0", content);
|
||||
_registry.Register("template-2", "1.0.0", content);
|
||||
|
||||
var info1 = _registry.GetTemplateInfo("template-1");
|
||||
var info2 = _registry.GetTemplateInfo("template-2");
|
||||
|
||||
info1!.Digest.Should().Be(info2!.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_NullName_Throws()
|
||||
{
|
||||
var act = () => _registry.Register(null!, "1.0.0", "content");
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_EmptyVersion_Throws()
|
||||
{
|
||||
var act = () => _registry.Register("name", "", "content");
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_EmptyTemplate_Throws()
|
||||
{
|
||||
var act = () => _registry.Register("name", "1.0.0", "");
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/__Tests/StellaOps.AdvisoryAI.Attestation.Tests/StellaOps.AdvisoryAI.Attestation.Tests.md. |
|
||||
| REMED-05 | DONE | Remediation checklist completed: file splits, async naming, deterministic fixtures, service locator removal. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-08 | DONE | Split tests <= 100 lines; deterministic fixtures/time/IDs; async naming; DI fixture for integration tests; ConfigureAwait(false) skipped per xUnit1030; dotnet test passed 2026-02-02 (58 tests). |
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.AspNet.Extensions.Tests</RootNamespace>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.AspNet.Extensions\StellaOps.AspNet.Extensions.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,218 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under BUSL-1.1. See LICENSE in the project root.
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Cors.Infrastructure;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Settings;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AspNet.Extensions.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class StellaOpsCorsExtensionsTests : IDisposable
|
||||
{
|
||||
private readonly List<string> _envVarsSet = [];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var key in _envVarsSet)
|
||||
Environment.SetEnvironmentVariable(key, null);
|
||||
}
|
||||
|
||||
private void SetEnv(string key, string? value)
|
||||
{
|
||||
Environment.SetEnvironmentVariable(key, value);
|
||||
_envVarsSet.Add(key);
|
||||
}
|
||||
|
||||
private static IConfiguration BuildConfig(Dictionary<string, string?>? values = null)
|
||||
{
|
||||
var builder = new ConfigurationBuilder();
|
||||
if (values is not null)
|
||||
builder.AddInMemoryCollection(values);
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static IHostEnvironment CreateEnvironment(bool isDevelopment)
|
||||
{
|
||||
return new TestHostEnvironment(isDevelopment ? Environments.Development : Environments.Production);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// AddStellaOpsCors registration
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AddStellaOpsCors_Dev_RegistersCorsWithSpecificOrigins()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var env = CreateEnvironment(isDevelopment: true);
|
||||
var config = BuildConfig();
|
||||
|
||||
services.AddStellaOpsCors(env, config);
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var corsOptions = provider.GetRequiredService<Microsoft.Extensions.Options.IOptions<CorsOptions>>();
|
||||
var policy = corsOptions.Value.GetPolicy(StellaOpsCorsExtensions.PolicyName);
|
||||
|
||||
policy.Should().NotBeNull();
|
||||
policy!.Origins.Should().BeEquivalentTo(StellaOpsCorsSettings.DefaultDevOrigins);
|
||||
policy.AllowAnyHeader.Should().BeTrue();
|
||||
policy.AllowAnyMethod.Should().BeTrue();
|
||||
policy.SupportsCredentials.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddStellaOpsCors_Prod_NoConfig_RegistersEmptyCors()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var env = CreateEnvironment(isDevelopment: false);
|
||||
var config = BuildConfig();
|
||||
|
||||
services.AddStellaOpsCors(env, config);
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var corsOptions = provider.GetRequiredService<Microsoft.Extensions.Options.IOptions<CorsOptions>>();
|
||||
var policy = corsOptions.Value.GetPolicy(StellaOpsCorsExtensions.PolicyName);
|
||||
|
||||
// When disabled, the named policy should not exist (only default empty AddCors).
|
||||
policy.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddStellaOpsCors_Prod_WithOrigins_RegistersSpecificPolicy()
|
||||
{
|
||||
SetEnv(StellaOpsCorsSettings.EnvEnabled, "true");
|
||||
SetEnv(StellaOpsCorsSettings.EnvOrigin, "https://prod.example.com,https://admin.example.com");
|
||||
|
||||
var services = new ServiceCollection();
|
||||
var env = CreateEnvironment(isDevelopment: false);
|
||||
var config = BuildConfig();
|
||||
|
||||
services.AddStellaOpsCors(env, config);
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var corsOptions = provider.GetRequiredService<Microsoft.Extensions.Options.IOptions<CorsOptions>>();
|
||||
var policy = corsOptions.Value.GetPolicy(StellaOpsCorsExtensions.PolicyName);
|
||||
|
||||
policy.Should().NotBeNull();
|
||||
policy!.Origins.Should().BeEquivalentTo(["https://prod.example.com", "https://admin.example.com"]);
|
||||
policy.SupportsCredentials.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddStellaOpsCors_EnabledButNoOrigins_RegistersEmptyCors()
|
||||
{
|
||||
SetEnv(StellaOpsCorsSettings.EnvEnabled, "true");
|
||||
// No origins set and not dev
|
||||
|
||||
var services = new ServiceCollection();
|
||||
var env = CreateEnvironment(isDevelopment: false);
|
||||
var config = BuildConfig();
|
||||
|
||||
services.AddStellaOpsCors(env, config);
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var corsOptions = provider.GetRequiredService<Microsoft.Extensions.Options.IOptions<CorsOptions>>();
|
||||
var policy = corsOptions.Value.GetPolicy(StellaOpsCorsExtensions.PolicyName);
|
||||
|
||||
// Enabled=true but Origins=[] → falls back to empty CORS
|
||||
policy.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddStellaOpsCors_Dev_NeverUsesAllowAnyOrigin()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var env = CreateEnvironment(isDevelopment: true);
|
||||
var config = BuildConfig();
|
||||
|
||||
services.AddStellaOpsCors(env, config);
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var corsOptions = provider.GetRequiredService<Microsoft.Extensions.Options.IOptions<CorsOptions>>();
|
||||
var policy = corsOptions.Value.GetPolicy(StellaOpsCorsExtensions.PolicyName);
|
||||
|
||||
policy.Should().NotBeNull();
|
||||
policy!.AllowAnyOrigin.Should().BeFalse("AllowAnyOrigin was replaced with specific dev origins");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// UseStellaOpsCors middleware
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void UseStellaOpsCors_NullApp_Throws()
|
||||
{
|
||||
var act = () => StellaOpsCorsExtensions.UseStellaOpsCors(null!);
|
||||
act.Should().Throw<ArgumentNullException>().WithParameterName("app");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Null guards
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AddStellaOpsCors_NullServices_Throws()
|
||||
{
|
||||
var act = () => StellaOpsCorsExtensions.AddStellaOpsCors(
|
||||
null!, CreateEnvironment(true), BuildConfig());
|
||||
act.Should().Throw<ArgumentNullException>().WithParameterName("services");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddStellaOpsCors_NullEnvironment_Throws()
|
||||
{
|
||||
var act = () => new ServiceCollection().AddStellaOpsCors(null!, BuildConfig());
|
||||
act.Should().Throw<ArgumentNullException>().WithParameterName("environment");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddStellaOpsCors_NullConfiguration_Throws()
|
||||
{
|
||||
var act = () => new ServiceCollection().AddStellaOpsCors(
|
||||
CreateEnvironment(true), null!);
|
||||
act.Should().Throw<ArgumentNullException>().WithParameterName("configuration");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Policy name constant
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void PolicyName_IsStable()
|
||||
{
|
||||
StellaOpsCorsExtensions.PolicyName.Should().Be("StellaOpsCors");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Namespace compatibility
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Class_IsInAuthServerIntegrationNamespace()
|
||||
{
|
||||
// The class must remain in StellaOps.Auth.ServerIntegration namespace
|
||||
// so existing 'using' statements in 44 Program.cs files continue to work.
|
||||
typeof(StellaOpsCorsExtensions).Namespace.Should().Be("StellaOps.Auth.ServerIntegration");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Helper
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
private sealed class TestHostEnvironment(string environmentName) : IHostEnvironment
|
||||
{
|
||||
public string EnvironmentName { get; set; } = environmentName;
|
||||
public string ApplicationName { get; set; } = "TestApp";
|
||||
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
|
||||
public IFileProvider ContentRootFileProvider { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AirGapTrustStoreIntegrationTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void LoadFromBundle_ParsesJsonBundle()
|
||||
{
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
var keyPem = GetPublicKeyPem();
|
||||
var bundle = new
|
||||
{
|
||||
roots = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
keyId = "bundle-key-001",
|
||||
publicKeyPem = keyPem,
|
||||
algorithm = "ES256",
|
||||
purpose = "signing"
|
||||
}
|
||||
}
|
||||
};
|
||||
var bundleBytes = Encoding.UTF8.GetBytes(
|
||||
JsonSerializer.Serialize(bundle, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }));
|
||||
|
||||
var result = integration.LoadFromBundle(bundleBytes);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(1, result.LoadedCount);
|
||||
Assert.Contains("bundle-key-001", result.KeyIds!);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void LoadFromBundle_FailsWithEmptyContent()
|
||||
{
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
|
||||
var result = integration.LoadFromBundle(Array.Empty<byte>());
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("empty", result.Error);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void LoadFromBundle_FailsWithInvalidJson()
|
||||
{
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
var invalidJson = Encoding.UTF8.GetBytes("not valid json");
|
||||
|
||||
var result = integration.LoadFromBundle(invalidJson);
|
||||
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AirGapTrustStoreIntegrationTests
|
||||
{
|
||||
private const string FixedPublicKeyPem =
|
||||
"-----BEGIN PUBLIC KEY-----\n" +
|
||||
"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEyi7gVscxgRXQzX5ErNuQFN3dPjVw\n" +
|
||||
"YzU0JE3PGhjSinBwpODxtweLfP6zw2N6f0H9z25t8HwTpFeuk1PWqTX7Gg==\n" +
|
||||
"-----END PUBLIC KEY-----\n";
|
||||
|
||||
private static string GetPublicKeyPem() => FixedPublicKeyPem;
|
||||
|
||||
private Task WriteManifestAsync(object manifest)
|
||||
{
|
||||
var path = Path.Combine(_tempDir, "trust-manifest.json");
|
||||
var json = JsonSerializer.Serialize(
|
||||
manifest,
|
||||
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
return File.WriteAllTextAsync(path, json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using StellaOps.AuditPack.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AirGapTrustStoreIntegrationTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetAvailableKeyIds_ReturnsAllKeysAsync()
|
||||
{
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "key1.pem"), GetPublicKeyPem());
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "key2.pem"), GetPublicKeyPem());
|
||||
await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
var keyIds = integration.GetAvailableKeyIds();
|
||||
|
||||
Assert.Equal(2, keyIds.Count);
|
||||
Assert.Contains("key1", keyIds);
|
||||
Assert.Contains("key2", keyIds);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Count_ReturnsCorrectValueAsync()
|
||||
{
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "key1.pem"), GetPublicKeyPem());
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "key2.pem"), GetPublicKeyPem());
|
||||
await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
Assert.Equal(2, integration.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using StellaOps.AuditPack.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AirGapTrustStoreIntegrationTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LoadFromDirectoryAsync_LoadsPemFilesAsync()
|
||||
{
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
var keyPem = GetPublicKeyPem();
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "test-key.pem"), keyPem);
|
||||
|
||||
var result = await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(1, result.LoadedCount);
|
||||
Assert.Contains("test-key", result.KeyIds!);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LoadFromDirectoryAsync_FailsWithNonExistentDirectoryAsync()
|
||||
{
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
var missingPath = Path.Combine(_tempDir, "missing");
|
||||
|
||||
var result = await integration.LoadFromDirectoryAsync(missingPath);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("not found", result.Error);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LoadFromDirectoryAsync_FailsWithEmptyPathAsync()
|
||||
{
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
|
||||
var result = await integration.LoadFromDirectoryAsync(string.Empty);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("required", result.Error);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LoadFromDirectoryAsync_LoadsFromManifestAsync()
|
||||
{
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
var keyPem = GetPublicKeyPem();
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "signing-key.pem"), keyPem);
|
||||
|
||||
var manifest = new
|
||||
{
|
||||
roots = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
keyId = "stella-signing-key-001",
|
||||
relativePath = "signing-key.pem",
|
||||
algorithm = "ES256",
|
||||
purpose = "signing"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await WriteManifestAsync(manifest);
|
||||
|
||||
var result = await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(1, result.LoadedCount);
|
||||
Assert.Contains("stella-signing-key-001", result.KeyIds!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using StellaOps.AuditPack.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AirGapTrustStoreIntegrationTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetPublicKey_ReturnsKeyAsync()
|
||||
{
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
var keyPem = GetPublicKeyPem();
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "test-key.pem"), keyPem);
|
||||
await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
var result = integration.GetPublicKey("test-key");
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.Equal("test-key", result.KeyId);
|
||||
Assert.NotNull(result.KeyBytes);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetPublicKey_ReturnsNotFoundAsync()
|
||||
{
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
var result = integration.GetPublicKey("nonexistent-key");
|
||||
|
||||
Assert.False(result.Found);
|
||||
Assert.Equal("nonexistent-key", result.KeyId);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetPublicKey_DetectsExpiredKeyAsync()
|
||||
{
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
var keyPem = GetPublicKeyPem();
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "expired-key.pem"), keyPem);
|
||||
|
||||
var manifest = new
|
||||
{
|
||||
roots = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
keyId = "expired-key",
|
||||
relativePath = "expired-key.pem",
|
||||
algorithm = "ES256",
|
||||
expiresAt = FixedUtcNow.AddDays(-1)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await WriteManifestAsync(manifest);
|
||||
await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
var result = integration.GetPublicKey("expired-key");
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.True(result.Expired);
|
||||
Assert.Contains("expired", result.Warning);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AirGapTrustStoreIntegrationTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateVerificationKey_ReturnsEcdsaKeyAsync()
|
||||
{
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
var keyPem = GetPublicKeyPem();
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "ecdsa-key.pem"), keyPem);
|
||||
|
||||
var manifest = new
|
||||
{
|
||||
roots = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
keyId = "ecdsa-key",
|
||||
relativePath = "ecdsa-key.pem",
|
||||
algorithm = "ES256",
|
||||
purpose = "signing"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await WriteManifestAsync(manifest);
|
||||
await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
var key = integration.CreateVerificationKey("ecdsa-key");
|
||||
|
||||
Assert.NotNull(key);
|
||||
Assert.IsAssignableFrom<ECDsa>(key);
|
||||
key.Dispose();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateVerificationKey_ReturnsNullForMissingKeyAsync()
|
||||
{
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
var key = integration.CreateVerificationKey("nonexistent");
|
||||
|
||||
Assert.Null(key);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,24 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// -----------------------------------------------------------------------------
|
||||
// AirGapTrustStoreIntegrationTests.cs
|
||||
// Sprint: SPRINT_4300_0001_0002 (One-Command Audit Replay CLI)
|
||||
// Description: Unit tests for AirGapTrustStoreIntegration.
|
||||
// -----------------------------------------------------------------------------
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.AuditPack.Services;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public class AirGapTrustStoreIntegrationTests : IDisposable
|
||||
public sealed partial class AirGapTrustStoreIntegrationTests : IDisposable
|
||||
{
|
||||
private static int _tempCounter;
|
||||
private static readonly DateTimeOffset FixedUtcNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private readonly string _tempDir;
|
||||
|
||||
public AirGapTrustStoreIntegrationTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"trust-test-{Guid.NewGuid():N}");
|
||||
var suffix = Interlocked.Increment(ref _tempCounter);
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"trust-test-{suffix:0000}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
@@ -30,313 +29,4 @@ public class AirGapTrustStoreIntegrationTests : IDisposable
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LoadFromDirectoryAsync_LoadsPemFiles()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
var keyPem = GenerateEcdsaPublicKeyPem();
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "test-key.pem"), keyPem);
|
||||
|
||||
// Act
|
||||
var result = await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(1, result.LoadedCount);
|
||||
Assert.Contains("test-key", result.KeyIds!);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LoadFromDirectoryAsync_FailsWithNonExistentDirectory()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
|
||||
// Act
|
||||
var result = await integration.LoadFromDirectoryAsync("/nonexistent/path");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("not found", result.Error);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LoadFromDirectoryAsync_FailsWithEmptyPath()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
|
||||
// Act
|
||||
var result = await integration.LoadFromDirectoryAsync("");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("required", result.Error);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LoadFromDirectoryAsync_LoadsFromManifest()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
var keyPem = GenerateEcdsaPublicKeyPem();
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "signing-key.pem"), keyPem);
|
||||
|
||||
var manifest = new
|
||||
{
|
||||
roots = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
keyId = "stella-signing-key-001",
|
||||
relativePath = "signing-key.pem",
|
||||
algorithm = "ES256",
|
||||
purpose = "signing"
|
||||
}
|
||||
}
|
||||
};
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_tempDir, "trust-manifest.json"),
|
||||
JsonSerializer.Serialize(manifest, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }));
|
||||
|
||||
// Act
|
||||
var result = await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(1, result.LoadedCount);
|
||||
Assert.Contains("stella-signing-key-001", result.KeyIds!);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void LoadFromBundle_ParsesJsonBundle()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
var keyPem = GenerateEcdsaPublicKeyPem();
|
||||
var bundle = new
|
||||
{
|
||||
roots = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
keyId = "bundle-key-001",
|
||||
publicKeyPem = keyPem,
|
||||
algorithm = "ES256",
|
||||
purpose = "signing"
|
||||
}
|
||||
}
|
||||
};
|
||||
var bundleBytes = Encoding.UTF8.GetBytes(
|
||||
JsonSerializer.Serialize(bundle, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }));
|
||||
|
||||
// Act
|
||||
var result = integration.LoadFromBundle(bundleBytes);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(1, result.LoadedCount);
|
||||
Assert.Contains("bundle-key-001", result.KeyIds!);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void LoadFromBundle_FailsWithEmptyContent()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
|
||||
// Act
|
||||
var result = integration.LoadFromBundle([]);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("empty", result.Error);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void LoadFromBundle_FailsWithInvalidJson()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
var invalidJson = Encoding.UTF8.GetBytes("not valid json");
|
||||
|
||||
// Act
|
||||
var result = integration.LoadFromBundle(invalidJson);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetPublicKey_ReturnsKey()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
var keyPem = GenerateEcdsaPublicKeyPem();
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "test-key.pem"), keyPem);
|
||||
await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = integration.GetPublicKey("test-key");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Found);
|
||||
Assert.Equal("test-key", result.KeyId);
|
||||
Assert.NotNull(result.KeyBytes);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetPublicKey_ReturnsNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = integration.GetPublicKey("nonexistent-key");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Found);
|
||||
Assert.Equal("nonexistent-key", result.KeyId);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetPublicKey_DetectsExpiredKey()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
var keyPem = GenerateEcdsaPublicKeyPem();
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "expired-key.pem"), keyPem);
|
||||
|
||||
var manifest = new
|
||||
{
|
||||
roots = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
keyId = "expired-key",
|
||||
relativePath = "expired-key.pem",
|
||||
algorithm = "ES256",
|
||||
expiresAt = DateTimeOffset.UtcNow.AddDays(-1)
|
||||
}
|
||||
}
|
||||
};
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_tempDir, "trust-manifest.json"),
|
||||
JsonSerializer.Serialize(manifest, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }));
|
||||
|
||||
await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = integration.GetPublicKey("expired-key");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Found);
|
||||
Assert.True(result.Expired);
|
||||
Assert.Contains("expired", result.Warning);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateVerificationKey_ReturnsEcdsaKey()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
var keyPem = GenerateEcdsaPublicKeyPem();
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "ecdsa-key.pem"), keyPem);
|
||||
|
||||
// Use manifest to explicitly set algorithm (SPKI format doesn't include algorithm in PEM header)
|
||||
var manifest = new
|
||||
{
|
||||
roots = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
keyId = "ecdsa-key",
|
||||
relativePath = "ecdsa-key.pem",
|
||||
algorithm = "ES256",
|
||||
purpose = "signing"
|
||||
}
|
||||
}
|
||||
};
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_tempDir, "trust-manifest.json"),
|
||||
JsonSerializer.Serialize(manifest, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }));
|
||||
|
||||
await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
// Act
|
||||
var key = integration.CreateVerificationKey("ecdsa-key");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(key);
|
||||
Assert.IsAssignableFrom<ECDsa>(key);
|
||||
key.Dispose();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateVerificationKey_ReturnsNullForMissingKey()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
// Act
|
||||
var key = integration.CreateVerificationKey("nonexistent");
|
||||
|
||||
// Assert
|
||||
Assert.Null(key);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetAvailableKeyIds_ReturnsAllKeys()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "key1.pem"), GenerateEcdsaPublicKeyPem());
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "key2.pem"), GenerateEcdsaPublicKeyPem());
|
||||
await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
// Act
|
||||
var keyIds = integration.GetAvailableKeyIds();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, keyIds.Count);
|
||||
Assert.Contains("key1", keyIds);
|
||||
Assert.Contains("key2", keyIds);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Count_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "key1.pem"), GenerateEcdsaPublicKeyPem());
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "key2.pem"), GenerateEcdsaPublicKeyPem());
|
||||
await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Equal(2, integration.Count);
|
||||
}
|
||||
|
||||
private static string GenerateEcdsaPublicKeyPem()
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
return ecdsa.ExportSubjectPublicKeyInfoPem();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using StellaOps.AuditPack.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AuditBundleWriterTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task WriteAsync_CreatesValidBundleAsync()
|
||||
{
|
||||
var writer = new AuditBundleWriter();
|
||||
var outputPath = Path.Combine(_tempDir, "test-bundle.tar.gz");
|
||||
var request = CreateValidRequest(outputPath);
|
||||
|
||||
var result = await writer.WriteAsync(request);
|
||||
|
||||
Assert.True(result.Success, result.Error);
|
||||
Assert.True(File.Exists(outputPath));
|
||||
Assert.NotNull(result.BundleId);
|
||||
Assert.NotNull(result.MerkleRoot);
|
||||
Assert.NotNull(result.BundleDigest);
|
||||
Assert.True(result.TotalSizeBytes > 0);
|
||||
Assert.True(result.FileCount > 0);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task WriteAsync_ComputesMerkleRootAsync()
|
||||
{
|
||||
var writer = new AuditBundleWriter();
|
||||
var outputPath = Path.Combine(_tempDir, "merkle-test.tar.gz");
|
||||
var request = CreateValidRequest(outputPath);
|
||||
|
||||
var result = await writer.WriteAsync(request);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.MerkleRoot);
|
||||
Assert.StartsWith("sha256:", result.MerkleRoot);
|
||||
Assert.Equal(71, result.MerkleRoot.Length);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task WriteAsync_FailsWithoutSbomAsync()
|
||||
{
|
||||
var writer = new AuditBundleWriter();
|
||||
var outputPath = Path.Combine(_tempDir, "no-sbom.tar.gz");
|
||||
var request = new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = outputPath,
|
||||
ScanId = "scan-001",
|
||||
ImageRef = "test:latest",
|
||||
ImageDigest = "sha256:abc123",
|
||||
Decision = "pass",
|
||||
Sbom = null!,
|
||||
FeedsSnapshot = CreateFeedsSnapshot(),
|
||||
PolicyBundle = CreatePolicyBundle(),
|
||||
Verdict = CreateVerdict()
|
||||
};
|
||||
|
||||
var result = await writer.WriteAsync(request);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("SBOM", result.Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using StellaOps.AuditPack.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AuditBundleWriterTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task WriteAsync_DeterministicMerkleRootAsync()
|
||||
{
|
||||
var writer = new AuditBundleWriter();
|
||||
var sbom = CreateSbom();
|
||||
var feeds = CreateFeedsSnapshot();
|
||||
var policy = CreatePolicyBundle();
|
||||
var verdict = CreateVerdict();
|
||||
var request1 = new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = Path.Combine(_tempDir, "det-1.tar.gz"),
|
||||
ScanId = "scan-001",
|
||||
ImageRef = "test:latest",
|
||||
ImageDigest = "sha256:abc123",
|
||||
Decision = "pass",
|
||||
Sbom = sbom,
|
||||
FeedsSnapshot = feeds,
|
||||
PolicyBundle = policy,
|
||||
Verdict = verdict,
|
||||
Sign = false
|
||||
};
|
||||
var request2 = request1 with { OutputPath = Path.Combine(_tempDir, "det-2.tar.gz") };
|
||||
|
||||
var result1 = await writer.WriteAsync(request1);
|
||||
var result2 = await writer.WriteAsync(request2);
|
||||
|
||||
Assert.True(result1.Success);
|
||||
Assert.True(result2.Success);
|
||||
Assert.Equal(result1.MerkleRoot, result2.MerkleRoot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.AuditPack.Services;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AuditBundleWriterTests
|
||||
{
|
||||
private AuditBundleWriteRequest CreateValidRequest(string outputPath)
|
||||
{
|
||||
return new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = outputPath,
|
||||
ScanId = "scan-001",
|
||||
ImageRef = "test:latest",
|
||||
ImageDigest = "sha256:abc123def456",
|
||||
Decision = "pass",
|
||||
Sbom = CreateSbom(),
|
||||
FeedsSnapshot = CreateFeedsSnapshot(),
|
||||
PolicyBundle = CreatePolicyBundle(),
|
||||
Verdict = CreateVerdict(),
|
||||
Sign = true
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] CreateSbom()
|
||||
{
|
||||
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new
|
||||
{
|
||||
bomFormat = "CycloneDX",
|
||||
specVersion = "1.6",
|
||||
version = 1,
|
||||
components = Array.Empty<object>()
|
||||
}));
|
||||
}
|
||||
|
||||
private static byte[] CreateFeedsSnapshot()
|
||||
{
|
||||
return Encoding.UTF8.GetBytes("{\"type\":\"feed-snapshot\"}\n");
|
||||
}
|
||||
|
||||
private static byte[] CreatePolicyBundle()
|
||||
{
|
||||
return new byte[] { 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
|
||||
}
|
||||
|
||||
private static byte[] CreateVerdict()
|
||||
{
|
||||
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new
|
||||
{
|
||||
decision = "pass",
|
||||
evaluatedAt = FixedUtcNow
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AuditBundleWriterTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task WriteAsync_IncludesOptionalVexAsync()
|
||||
{
|
||||
var writer = new AuditBundleWriter();
|
||||
var outputPath = Path.Combine(_tempDir, "with-vex.tar.gz");
|
||||
var vexContent = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "https://openvex.dev/ns/v0.2.0",
|
||||
statements = new[]
|
||||
{
|
||||
new { vulnerability = "CVE-2024-1234", status = "not_affected" }
|
||||
}
|
||||
}));
|
||||
var request = CreateValidRequest(outputPath) with { VexStatements = vexContent };
|
||||
|
||||
var result = await writer.WriteAsync(request);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.FileCount >= 5);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task WriteAsync_AddsTimeAnchorAsync()
|
||||
{
|
||||
var writer = new AuditBundleWriter();
|
||||
var outputPath = Path.Combine(_tempDir, "with-anchor.tar.gz");
|
||||
var request = CreateValidRequest(outputPath) with
|
||||
{
|
||||
TimeAnchor = new TimeAnchorInput
|
||||
{
|
||||
Timestamp = FixedUtcNow,
|
||||
Source = "local"
|
||||
}
|
||||
};
|
||||
|
||||
var result = await writer.WriteAsync(request);
|
||||
|
||||
Assert.True(result.Success);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using StellaOps.AuditPack.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AuditBundleWriterTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task WriteAsync_SignsManifest_WhenSignIsTrueAsync()
|
||||
{
|
||||
var writer = new AuditBundleWriter();
|
||||
var outputPath = Path.Combine(_tempDir, "signed-test.tar.gz");
|
||||
var request = CreateValidRequest(outputPath) with { Sign = true };
|
||||
|
||||
var result = await writer.WriteAsync(request);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.Signed);
|
||||
Assert.NotNull(result.SigningKeyId);
|
||||
Assert.NotNull(result.SigningAlgorithm);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task WriteAsync_DoesNotSign_WhenSignIsFalseAsync()
|
||||
{
|
||||
var writer = new AuditBundleWriter();
|
||||
var outputPath = Path.Combine(_tempDir, "unsigned-test.tar.gz");
|
||||
var request = CreateValidRequest(outputPath) with { Sign = false };
|
||||
|
||||
var result = await writer.WriteAsync(request);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.False(result.Signed);
|
||||
Assert.Null(result.SigningKeyId);
|
||||
}
|
||||
}
|
||||
@@ -3,21 +3,22 @@
|
||||
// Sprint: SPRINT_4300_0001_0002 (One-Command Audit Replay CLI)
|
||||
// Description: Unit tests for AuditBundleWriter.
|
||||
// -----------------------------------------------------------------------------
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.AuditPack.Services;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public class AuditBundleWriterTests : IDisposable
|
||||
public sealed partial class AuditBundleWriterTests : IDisposable
|
||||
{
|
||||
private static int _tempCounter;
|
||||
private static readonly DateTimeOffset FixedUtcNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private readonly string _tempDir;
|
||||
|
||||
public AuditBundleWriterTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"audit-test-{Guid.NewGuid():N}");
|
||||
var suffix = Interlocked.Increment(ref _tempCounter);
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"audit-test-{suffix:0000}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
@@ -28,258 +29,4 @@ public class AuditBundleWriterTests : IDisposable
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task WriteAsync_CreatesValidBundle()
|
||||
{
|
||||
// Arrange
|
||||
var writer = new AuditBundleWriter();
|
||||
var outputPath = Path.Combine(_tempDir, "test-bundle.tar.gz");
|
||||
|
||||
var request = CreateValidRequest(outputPath);
|
||||
|
||||
// Act
|
||||
var result = await writer.WriteAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success, result.Error);
|
||||
Assert.True(File.Exists(outputPath));
|
||||
Assert.NotNull(result.BundleId);
|
||||
Assert.NotNull(result.MerkleRoot);
|
||||
Assert.NotNull(result.BundleDigest);
|
||||
Assert.True(result.TotalSizeBytes > 0);
|
||||
Assert.True(result.FileCount > 0);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task WriteAsync_ComputesMerkleRoot()
|
||||
{
|
||||
// Arrange
|
||||
var writer = new AuditBundleWriter();
|
||||
var outputPath = Path.Combine(_tempDir, "merkle-test.tar.gz");
|
||||
|
||||
var request = CreateValidRequest(outputPath);
|
||||
|
||||
// Act
|
||||
var result = await writer.WriteAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.MerkleRoot);
|
||||
Assert.StartsWith("sha256:", result.MerkleRoot);
|
||||
Assert.Equal(71, result.MerkleRoot.Length); // sha256: + 64 hex chars
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task WriteAsync_SignsManifest_WhenSignIsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var writer = new AuditBundleWriter();
|
||||
var outputPath = Path.Combine(_tempDir, "signed-test.tar.gz");
|
||||
|
||||
var request = CreateValidRequest(outputPath) with { Sign = true };
|
||||
|
||||
// Act
|
||||
var result = await writer.WriteAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.Signed);
|
||||
Assert.NotNull(result.SigningKeyId);
|
||||
Assert.NotNull(result.SigningAlgorithm);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task WriteAsync_DoesNotSign_WhenSignIsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var writer = new AuditBundleWriter();
|
||||
var outputPath = Path.Combine(_tempDir, "unsigned-test.tar.gz");
|
||||
|
||||
var request = CreateValidRequest(outputPath) with { Sign = false };
|
||||
|
||||
// Act
|
||||
var result = await writer.WriteAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.False(result.Signed);
|
||||
Assert.Null(result.SigningKeyId);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task WriteAsync_FailsWithoutSbom()
|
||||
{
|
||||
// Arrange
|
||||
var writer = new AuditBundleWriter();
|
||||
var outputPath = Path.Combine(_tempDir, "no-sbom.tar.gz");
|
||||
|
||||
var request = new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = outputPath,
|
||||
ScanId = "scan-001",
|
||||
ImageRef = "test:latest",
|
||||
ImageDigest = "sha256:abc123",
|
||||
Decision = "pass",
|
||||
Sbom = null!,
|
||||
FeedsSnapshot = CreateFeedsSnapshot(),
|
||||
PolicyBundle = CreatePolicyBundle(),
|
||||
Verdict = CreateVerdict()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await writer.WriteAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("SBOM", result.Error);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task WriteAsync_IncludesOptionalVex()
|
||||
{
|
||||
// Arrange
|
||||
var writer = new AuditBundleWriter();
|
||||
var outputPath = Path.Combine(_tempDir, "with-vex.tar.gz");
|
||||
|
||||
var vexContent = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "https://openvex.dev/ns/v0.2.0",
|
||||
statements = new[]
|
||||
{
|
||||
new { vulnerability = "CVE-2024-1234", status = "not_affected" }
|
||||
}
|
||||
}));
|
||||
|
||||
var request = CreateValidRequest(outputPath) with
|
||||
{
|
||||
VexStatements = vexContent
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await writer.WriteAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.FileCount >= 5); // sbom, feeds, policy, verdict, vex
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task WriteAsync_AddsTimeAnchor()
|
||||
{
|
||||
// Arrange
|
||||
var writer = new AuditBundleWriter();
|
||||
var outputPath = Path.Combine(_tempDir, "with-anchor.tar.gz");
|
||||
|
||||
var request = CreateValidRequest(outputPath) with
|
||||
{
|
||||
TimeAnchor = new TimeAnchorInput
|
||||
{
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Source = "local"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await writer.WriteAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task WriteAsync_DeterministicMerkleRoot()
|
||||
{
|
||||
// Arrange
|
||||
var writer = new AuditBundleWriter();
|
||||
|
||||
var sbom = CreateSbom();
|
||||
var feeds = CreateFeedsSnapshot();
|
||||
var policy = CreatePolicyBundle();
|
||||
var verdict = CreateVerdict();
|
||||
|
||||
var request1 = new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = Path.Combine(_tempDir, "det-1.tar.gz"),
|
||||
ScanId = "scan-001",
|
||||
ImageRef = "test:latest",
|
||||
ImageDigest = "sha256:abc123",
|
||||
Decision = "pass",
|
||||
Sbom = sbom,
|
||||
FeedsSnapshot = feeds,
|
||||
PolicyBundle = policy,
|
||||
Verdict = verdict,
|
||||
Sign = false
|
||||
};
|
||||
|
||||
var request2 = request1 with
|
||||
{
|
||||
OutputPath = Path.Combine(_tempDir, "det-2.tar.gz")
|
||||
};
|
||||
|
||||
// Act
|
||||
var result1 = await writer.WriteAsync(request1);
|
||||
var result2 = await writer.WriteAsync(request2);
|
||||
|
||||
// Assert
|
||||
Assert.True(result1.Success);
|
||||
Assert.True(result2.Success);
|
||||
Assert.Equal(result1.MerkleRoot, result2.MerkleRoot);
|
||||
}
|
||||
|
||||
private AuditBundleWriteRequest CreateValidRequest(string outputPath)
|
||||
{
|
||||
return new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = outputPath,
|
||||
ScanId = "scan-001",
|
||||
ImageRef = "test:latest",
|
||||
ImageDigest = "sha256:abc123def456",
|
||||
Decision = "pass",
|
||||
Sbom = CreateSbom(),
|
||||
FeedsSnapshot = CreateFeedsSnapshot(),
|
||||
PolicyBundle = CreatePolicyBundle(),
|
||||
Verdict = CreateVerdict(),
|
||||
Sign = true
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] CreateSbom()
|
||||
{
|
||||
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new
|
||||
{
|
||||
bomFormat = "CycloneDX",
|
||||
specVersion = "1.6",
|
||||
version = 1,
|
||||
components = Array.Empty<object>()
|
||||
}));
|
||||
}
|
||||
|
||||
private static byte[] CreateFeedsSnapshot()
|
||||
{
|
||||
return Encoding.UTF8.GetBytes("{\"type\":\"feed-snapshot\"}\n");
|
||||
}
|
||||
|
||||
private static byte[] CreatePolicyBundle()
|
||||
{
|
||||
// Minimal gzip content
|
||||
return new byte[] { 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
|
||||
}
|
||||
|
||||
private static byte[] CreateVerdict()
|
||||
{
|
||||
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new
|
||||
{
|
||||
decision = "pass",
|
||||
evaluatedAt = DateTimeOffset.UtcNow
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AuditPackExportServiceIntegrationTests
|
||||
{
|
||||
[Fact(DisplayName = "DSSE export produces valid envelope")]
|
||||
public async Task ExportAsDsse_ProducesValidEnvelopeAsync()
|
||||
{
|
||||
var request = CreateTestRequest(ExportFormat.Dsse);
|
||||
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.ContentType.Should().Be("application/vnd.dsse+json");
|
||||
result.Filename.Should().EndWith(".dsse.json");
|
||||
result.Data.Should().NotBeNullOrEmpty();
|
||||
|
||||
var envelope = JsonSerializer.Deserialize<DsseExportEnvelope>(result.Data!, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
envelope.Should().NotBeNull();
|
||||
envelope!.PayloadType.Should().Be("application/vnd.stellaops.audit-pack+json");
|
||||
envelope.Payload.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "DSSE envelope payload is valid base64")]
|
||||
public async Task ExportAsDsse_PayloadIsValidBase64Async()
|
||||
{
|
||||
var request = CreateTestRequest(ExportFormat.Dsse);
|
||||
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
var envelope = JsonSerializer.Deserialize<DsseExportEnvelope>(result.Data!, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
var payloadBytes = Convert.FromBase64String(envelope!.Payload);
|
||||
payloadBytes.Should().NotBeEmpty();
|
||||
|
||||
var payloadDoc = JsonDocument.Parse(payloadBytes);
|
||||
payloadDoc.RootElement.GetProperty("scanId").GetString().Should().Be("scan-123");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AuditPackExportServiceIntegrationTests
|
||||
{
|
||||
[Fact(DisplayName = "Export returns error for unsupported format")]
|
||||
public async Task Export_ReturnsError_ForUnsupportedFormatAsync()
|
||||
{
|
||||
var request = new ExportRequest
|
||||
{
|
||||
ScanId = "scan-123",
|
||||
Format = (ExportFormat)999,
|
||||
Segments = [ExportSegment.Sbom],
|
||||
Filename = "test-export"
|
||||
};
|
||||
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("Unsupported");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Export handles empty segments list")]
|
||||
public async Task Export_HandlesEmptySegmentsAsync()
|
||||
{
|
||||
var request = new ExportRequest
|
||||
{
|
||||
ScanId = "scan-123",
|
||||
Format = ExportFormat.Zip,
|
||||
Segments = [],
|
||||
Filename = "test-export"
|
||||
};
|
||||
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Data.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Export includes finding IDs when specified")]
|
||||
public async Task Export_IncludesFindingIdsAsync()
|
||||
{
|
||||
var request = new ExportRequest
|
||||
{
|
||||
ScanId = "scan-123",
|
||||
FindingIds = ["CVE-2024-0001@pkg:npm/lodash@4.17.21", "CVE-2024-0002@pkg:npm/express@4.18.0"],
|
||||
Format = ExportFormat.Json,
|
||||
Segments = [ExportSegment.Sbom],
|
||||
Filename = "test-export"
|
||||
};
|
||||
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using StellaOps.AuditPack.Services;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AuditPackExportServiceIntegrationTests
|
||||
{
|
||||
private static ExportRequest CreateTestRequest(ExportFormat format, bool includeAllSegments = false)
|
||||
{
|
||||
var segments = includeAllSegments
|
||||
? new List<ExportSegment>
|
||||
{
|
||||
ExportSegment.Sbom,
|
||||
ExportSegment.Match,
|
||||
ExportSegment.Reachability,
|
||||
ExportSegment.Guards,
|
||||
ExportSegment.Runtime,
|
||||
ExportSegment.Policy
|
||||
}
|
||||
: new List<ExportSegment> { ExportSegment.Sbom, ExportSegment.Match };
|
||||
|
||||
return new ExportRequest
|
||||
{
|
||||
ScanId = "scan-123",
|
||||
Format = format,
|
||||
Segments = segments,
|
||||
IncludeAttestations = false,
|
||||
IncludeProofChain = false,
|
||||
Filename = "test-export"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class MockAuditBundleWriter : Services.IAuditBundleWriter
|
||||
{
|
||||
private readonly DateTimeOffset _createdAt;
|
||||
|
||||
public MockAuditBundleWriter(DateTimeOffset createdAt)
|
||||
{
|
||||
_createdAt = createdAt;
|
||||
}
|
||||
|
||||
public Task<Services.AuditBundleWriteResult> WriteAsync(
|
||||
Services.AuditBundleWriteRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(new Services.AuditBundleWriteResult
|
||||
{
|
||||
Success = true,
|
||||
BundleId = "test-bundle",
|
||||
MerkleRoot = "sha256:test",
|
||||
BundleDigest = "sha256:test",
|
||||
TotalSizeBytes = 0,
|
||||
FileCount = 0,
|
||||
CreatedAt = _createdAt
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeAuditPackRepository : IAuditPackRepository
|
||||
{
|
||||
public Task<byte[]?> GetSegmentDataAsync(string scanId, ExportSegment segment, CancellationToken ct)
|
||||
{
|
||||
var payload = new Dictionary<string, object>
|
||||
{
|
||||
["segment"] = segment.ToString(),
|
||||
["scanId"] = scanId
|
||||
};
|
||||
return Task.FromResult<byte[]?>(JsonSerializer.SerializeToUtf8Bytes(payload));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<object>> GetAttestationsAsync(string scanId, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<object>>(new[] { new { attestationId = "att-1", scanId } });
|
||||
|
||||
public Task<object?> GetProofChainAsync(string scanId, CancellationToken ct)
|
||||
=> Task.FromResult<object?>(new { proof = "chain", scanId });
|
||||
}
|
||||
|
||||
internal sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => now;
|
||||
}
|
||||
|
||||
internal sealed class FakeDsseSigner : IAuditPackExportSigner
|
||||
{
|
||||
public Task<DsseSignature> SignAsync(byte[] payload, CancellationToken ct)
|
||||
{
|
||||
var length = Math.Min(4, payload.Length);
|
||||
return Task.FromResult(new DsseSignature
|
||||
{
|
||||
KeyId = "test-key",
|
||||
Sig = Convert.ToBase64String(payload.AsSpan(0, length))
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AuditPackExportServiceIntegrationTests
|
||||
{
|
||||
[Fact(DisplayName = "JSON export produces valid document")]
|
||||
public async Task ExportAsJson_ProducesValidDocumentAsync()
|
||||
{
|
||||
var request = CreateTestRequest(ExportFormat.Json);
|
||||
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.ContentType.Should().Be("application/json");
|
||||
result.Filename.Should().EndWith(".json");
|
||||
result.Data.Should().NotBeNullOrEmpty();
|
||||
|
||||
var doc = JsonDocument.Parse(result.Data!);
|
||||
doc.RootElement.GetProperty("scanId").GetString().Should().Be("scan-123");
|
||||
doc.RootElement.GetProperty("format").GetString().Should().Be("json");
|
||||
doc.RootElement.GetProperty("version").GetString().Should().Be("1.0");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "JSON export includes all segments")]
|
||||
public async Task ExportAsJson_IncludesAllSegmentsAsync()
|
||||
{
|
||||
var request = CreateTestRequest(ExportFormat.Json, includeAllSegments: true);
|
||||
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
var doc = JsonDocument.Parse(result.Data!);
|
||||
var segments = doc.RootElement.GetProperty("segments");
|
||||
|
||||
segments.GetProperty("sbom").ValueKind.Should().NotBe(JsonValueKind.Undefined);
|
||||
segments.GetProperty("match").ValueKind.Should().NotBe(JsonValueKind.Undefined);
|
||||
segments.GetProperty("reachability").ValueKind.Should().NotBe(JsonValueKind.Undefined);
|
||||
segments.GetProperty("policy").ValueKind.Should().NotBe(JsonValueKind.Undefined);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "JSON export has correct export timestamp")]
|
||||
public async Task ExportAsJson_HasExportTimestampAsync()
|
||||
{
|
||||
var request = CreateTestRequest(ExportFormat.Json);
|
||||
var expected = FixedUtcNow;
|
||||
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
var doc = JsonDocument.Parse(result.Data!);
|
||||
var exportedAt = DateTimeOffset.Parse(doc.RootElement.GetProperty("exportedAt").GetString()!);
|
||||
|
||||
exportedAt.Should().Be(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AuditPackExportServiceIntegrationTests
|
||||
{
|
||||
[Fact(DisplayName = "Export reports correct size")]
|
||||
public async Task Export_ReportsCorrectSizeAsync()
|
||||
{
|
||||
var request = CreateTestRequest(ExportFormat.Json);
|
||||
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.SizeBytes.Should().Be(result.Data!.Length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.IO.Compression;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AuditPackExportServiceIntegrationTests
|
||||
{
|
||||
[Fact(DisplayName = "ZIP export produces valid archive with manifest")]
|
||||
public async Task ExportAsZip_ProducesValidArchiveAsync()
|
||||
{
|
||||
var request = CreateTestRequest(ExportFormat.Zip);
|
||||
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.ContentType.Should().Be("application/zip");
|
||||
result.Filename.Should().EndWith(".zip");
|
||||
result.Data.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ZIP export includes all requested segments")]
|
||||
public async Task ExportAsZip_IncludesRequestedSegmentsAsync()
|
||||
{
|
||||
var request = CreateTestRequest(ExportFormat.Zip, includeAllSegments: true);
|
||||
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
using var memoryStream = new MemoryStream(result.Data!);
|
||||
using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read);
|
||||
|
||||
archive.GetEntry("manifest.json").Should().NotBeNull();
|
||||
archive.GetEntry("sbom/sbom.json").Should().NotBeNull();
|
||||
archive.GetEntry("match/vulnerability-match.json").Should().NotBeNull();
|
||||
archive.GetEntry("reachability/reachability-analysis.json").Should().NotBeNull();
|
||||
archive.GetEntry("policy/policy-evaluation.json").Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ZIP export includes attestations when requested")]
|
||||
public async Task ExportAsZip_IncludesAttestationsAsync()
|
||||
{
|
||||
var request = new ExportRequest
|
||||
{
|
||||
ScanId = "scan-123",
|
||||
Format = ExportFormat.Zip,
|
||||
Segments = [ExportSegment.Sbom],
|
||||
IncludeAttestations = true,
|
||||
IncludeProofChain = false,
|
||||
Filename = "test-export"
|
||||
};
|
||||
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
using var memoryStream = new MemoryStream(result.Data!);
|
||||
using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read);
|
||||
|
||||
archive.GetEntry("attestations/attestations.json").Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ZIP export includes proof chain when requested")]
|
||||
public async Task ExportAsZip_IncludesProofChainAsync()
|
||||
{
|
||||
var request = new ExportRequest
|
||||
{
|
||||
ScanId = "scan-123",
|
||||
Format = ExportFormat.Zip,
|
||||
Segments = [ExportSegment.Sbom],
|
||||
IncludeAttestations = false,
|
||||
IncludeProofChain = true,
|
||||
Filename = "test-export"
|
||||
};
|
||||
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
using var memoryStream = new MemoryStream(result.Data!);
|
||||
using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read);
|
||||
archive.GetEntry("proof/proof-chain.json").Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AuditPackExportServiceIntegrationTests
|
||||
{
|
||||
[Fact(DisplayName = "ZIP manifest contains export metadata")]
|
||||
public async Task ExportAsZip_ManifestContainsMetadataAsync()
|
||||
{
|
||||
var request = CreateTestRequest(ExportFormat.Zip);
|
||||
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
using var memoryStream = new MemoryStream(result.Data!);
|
||||
using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read);
|
||||
|
||||
var manifestEntry = archive.GetEntry("manifest.json");
|
||||
manifestEntry.Should().NotBeNull();
|
||||
|
||||
using var reader = new StreamReader(manifestEntry!.Open());
|
||||
var manifestJson = await reader.ReadToEndAsync();
|
||||
var manifest = JsonSerializer.Deserialize<ExportManifest>(manifestJson, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
manifest.Should().NotBeNull();
|
||||
manifest!.ScanId.Should().Be("scan-123");
|
||||
manifest.Format.Should().Be("Zip");
|
||||
manifest.Version.Should().Be("1.0");
|
||||
}
|
||||
}
|
||||
@@ -1,452 +1,27 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AuditPackExportServiceIntegrationTests.cs
|
||||
// Sprint: SPRINT_1227_0005_0003_FE_copy_audit_export
|
||||
// Task: T11 — Integration tests for export flow
|
||||
// Task: T11 - Integration tests for export flow
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using System;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for AuditPackExportService.
|
||||
/// Tests full export flows including ZIP, JSON, and DSSE formats.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Category", "AuditPack")]
|
||||
public class AuditPackExportServiceIntegrationTests
|
||||
public sealed partial class AuditPackExportServiceIntegrationTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedUtcNow = new(2025, 6, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
private readonly AuditPackExportService _service;
|
||||
|
||||
public AuditPackExportServiceIntegrationTests()
|
||||
{
|
||||
var mockWriter = new MockAuditBundleWriter();
|
||||
var mockWriter = new MockAuditBundleWriter(FixedUtcNow);
|
||||
var repository = new FakeAuditPackRepository();
|
||||
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2025, 6, 1, 12, 0, 0, TimeSpan.Zero));
|
||||
var timeProvider = new FixedTimeProvider(FixedUtcNow);
|
||||
var dsseSigner = new FakeDsseSigner();
|
||||
_service = new AuditPackExportService(mockWriter, repository, timeProvider, dsseSigner);
|
||||
}
|
||||
|
||||
#region ZIP Export Tests
|
||||
|
||||
[Fact(DisplayName = "ZIP export produces valid archive with manifest")]
|
||||
public async Task ExportAsZip_ProducesValidArchive()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(ExportFormat.Zip);
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.ContentType.Should().Be("application/zip");
|
||||
result.Filename.Should().EndWith(".zip");
|
||||
result.Data.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ZIP export includes all requested segments")]
|
||||
public async Task ExportAsZip_IncludesRequestedSegments()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(ExportFormat.Zip, includeAllSegments: true);
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
using var memoryStream = new MemoryStream(result.Data!);
|
||||
using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read);
|
||||
|
||||
// Verify manifest exists
|
||||
archive.GetEntry("manifest.json").Should().NotBeNull();
|
||||
|
||||
// Verify segment entries exist
|
||||
archive.GetEntry("sbom/sbom.json").Should().NotBeNull();
|
||||
archive.GetEntry("match/vulnerability-match.json").Should().NotBeNull();
|
||||
archive.GetEntry("reachability/reachability-analysis.json").Should().NotBeNull();
|
||||
archive.GetEntry("policy/policy-evaluation.json").Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ZIP export includes attestations when requested")]
|
||||
public async Task ExportAsZip_IncludesAttestations()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ExportRequest
|
||||
{
|
||||
ScanId = "scan-123",
|
||||
Format = ExportFormat.Zip,
|
||||
Segments = [ExportSegment.Sbom],
|
||||
IncludeAttestations = true,
|
||||
IncludeProofChain = false,
|
||||
Filename = "test-export"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
using var memoryStream = new MemoryStream(result.Data!);
|
||||
using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read);
|
||||
|
||||
archive.GetEntry("attestations/attestations.json").Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ZIP export includes proof chain when requested")]
|
||||
public async Task ExportAsZip_IncludesProofChain()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ExportRequest
|
||||
{
|
||||
ScanId = "scan-123",
|
||||
Format = ExportFormat.Zip,
|
||||
Segments = [ExportSegment.Sbom],
|
||||
IncludeAttestations = false,
|
||||
IncludeProofChain = true,
|
||||
Filename = "test-export"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
using var memoryStream = new MemoryStream(result.Data!);
|
||||
using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read);
|
||||
archive.GetEntry("proof/proof-chain.json").Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ZIP manifest contains export metadata")]
|
||||
public async Task ExportAsZip_ManifestContainsMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(ExportFormat.Zip);
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
using var memoryStream = new MemoryStream(result.Data!);
|
||||
using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read);
|
||||
|
||||
var manifestEntry = archive.GetEntry("manifest.json");
|
||||
manifestEntry.Should().NotBeNull();
|
||||
|
||||
using var reader = new StreamReader(manifestEntry!.Open());
|
||||
var manifestJson = await reader.ReadToEndAsync();
|
||||
var manifest = JsonSerializer.Deserialize<ExportManifest>(manifestJson, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
manifest.Should().NotBeNull();
|
||||
manifest!.ScanId.Should().Be("scan-123");
|
||||
manifest.Format.Should().Be("Zip");
|
||||
manifest.Version.Should().Be("1.0");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON Export Tests
|
||||
|
||||
[Fact(DisplayName = "JSON export produces valid document")]
|
||||
public async Task ExportAsJson_ProducesValidDocument()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(ExportFormat.Json);
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.ContentType.Should().Be("application/json");
|
||||
result.Filename.Should().EndWith(".json");
|
||||
result.Data.Should().NotBeNullOrEmpty();
|
||||
|
||||
// Verify it's valid JSON
|
||||
var doc = JsonDocument.Parse(result.Data!);
|
||||
doc.RootElement.GetProperty("scanId").GetString().Should().Be("scan-123");
|
||||
doc.RootElement.GetProperty("format").GetString().Should().Be("json");
|
||||
doc.RootElement.GetProperty("version").GetString().Should().Be("1.0");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "JSON export includes all segments")]
|
||||
public async Task ExportAsJson_IncludesAllSegments()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(ExportFormat.Json, includeAllSegments: true);
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
var doc = JsonDocument.Parse(result.Data!);
|
||||
var segments = doc.RootElement.GetProperty("segments");
|
||||
|
||||
segments.GetProperty("sbom").ValueKind.Should().NotBe(JsonValueKind.Undefined);
|
||||
segments.GetProperty("match").ValueKind.Should().NotBe(JsonValueKind.Undefined);
|
||||
segments.GetProperty("reachability").ValueKind.Should().NotBe(JsonValueKind.Undefined);
|
||||
segments.GetProperty("policy").ValueKind.Should().NotBe(JsonValueKind.Undefined);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "JSON export has correct export timestamp")]
|
||||
public async Task ExportAsJson_HasExportTimestamp()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(ExportFormat.Json);
|
||||
var expected = new DateTimeOffset(2025, 6, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
var doc = JsonDocument.Parse(result.Data!);
|
||||
var exportedAt = DateTimeOffset.Parse(doc.RootElement.GetProperty("exportedAt").GetString()!);
|
||||
|
||||
exportedAt.Should().Be(expected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DSSE Export Tests
|
||||
|
||||
[Fact(DisplayName = "DSSE export produces valid envelope")]
|
||||
public async Task ExportAsDsse_ProducesValidEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(ExportFormat.Dsse);
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.ContentType.Should().Be("application/vnd.dsse+json");
|
||||
result.Filename.Should().EndWith(".dsse.json");
|
||||
result.Data.Should().NotBeNullOrEmpty();
|
||||
|
||||
// Verify envelope structure
|
||||
var envelope = JsonSerializer.Deserialize<DsseExportEnvelope>(result.Data!, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
envelope.Should().NotBeNull();
|
||||
envelope!.PayloadType.Should().Be("application/vnd.stellaops.audit-pack+json");
|
||||
envelope.Payload.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "DSSE envelope payload is valid base64")]
|
||||
public async Task ExportAsDsse_PayloadIsValidBase64()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(ExportFormat.Dsse);
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
var envelope = JsonSerializer.Deserialize<DsseExportEnvelope>(result.Data!, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
var payloadBytes = Convert.FromBase64String(envelope!.Payload);
|
||||
payloadBytes.Should().NotBeEmpty();
|
||||
|
||||
// Payload should be valid JSON
|
||||
var payloadDoc = JsonDocument.Parse(payloadBytes);
|
||||
payloadDoc.RootElement.GetProperty("scanId").GetString().Should().Be("scan-123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact(DisplayName = "Export returns error for unsupported format")]
|
||||
public async Task Export_ReturnsError_ForUnsupportedFormat()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ExportRequest
|
||||
{
|
||||
ScanId = "scan-123",
|
||||
Format = (ExportFormat)999, // Invalid format
|
||||
Segments = [ExportSegment.Sbom],
|
||||
Filename = "test-export"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("Unsupported");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Export handles empty segments list")]
|
||||
public async Task Export_HandlesEmptySegments()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ExportRequest
|
||||
{
|
||||
ScanId = "scan-123",
|
||||
Format = ExportFormat.Zip,
|
||||
Segments = [],
|
||||
Filename = "test-export"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Data.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Export includes finding IDs when specified")]
|
||||
public async Task Export_IncludesFindingIds()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ExportRequest
|
||||
{
|
||||
ScanId = "scan-123",
|
||||
FindingIds = ["CVE-2024-0001@pkg:npm/lodash@4.17.21", "CVE-2024-0002@pkg:npm/express@4.18.0"],
|
||||
Format = ExportFormat.Json,
|
||||
Segments = [ExportSegment.Sbom],
|
||||
Filename = "test-export"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Size Reporting Tests
|
||||
|
||||
[Fact(DisplayName = "Export reports correct size")]
|
||||
public async Task Export_ReportsCorrectSize()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(ExportFormat.Json);
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.SizeBytes.Should().Be(result.Data!.Length);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static ExportRequest CreateTestRequest(
|
||||
ExportFormat format,
|
||||
bool includeAllSegments = false)
|
||||
{
|
||||
var segments = includeAllSegments
|
||||
? new List<ExportSegment>
|
||||
{
|
||||
ExportSegment.Sbom,
|
||||
ExportSegment.Match,
|
||||
ExportSegment.Reachability,
|
||||
ExportSegment.Guards,
|
||||
ExportSegment.Runtime,
|
||||
ExportSegment.Policy
|
||||
}
|
||||
: new List<ExportSegment> { ExportSegment.Sbom, ExportSegment.Match };
|
||||
|
||||
return new ExportRequest
|
||||
{
|
||||
ScanId = "scan-123",
|
||||
Format = format,
|
||||
Segments = segments,
|
||||
IncludeAttestations = false,
|
||||
IncludeProofChain = false,
|
||||
Filename = "test-export"
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock implementation of IAuditBundleWriter for testing.
|
||||
/// </summary>
|
||||
internal class MockAuditBundleWriter : Services.IAuditBundleWriter
|
||||
{
|
||||
public Task<Services.AuditBundleWriteResult> WriteAsync(Services.AuditBundleWriteRequest request, CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(new Services.AuditBundleWriteResult
|
||||
{
|
||||
Success = true,
|
||||
BundleId = "test-bundle",
|
||||
MerkleRoot = "sha256:test",
|
||||
BundleDigest = "sha256:test",
|
||||
TotalSizeBytes = 0,
|
||||
FileCount = 0,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeAuditPackRepository : IAuditPackRepository
|
||||
{
|
||||
public Task<byte[]?> GetSegmentDataAsync(string scanId, ExportSegment segment, CancellationToken ct)
|
||||
{
|
||||
var payload = new Dictionary<string, object>
|
||||
{
|
||||
["segment"] = segment.ToString(),
|
||||
["scanId"] = scanId
|
||||
};
|
||||
return Task.FromResult<byte[]?>(JsonSerializer.SerializeToUtf8Bytes(payload));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<object>> GetAttestationsAsync(string scanId, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<object>>(new[] { new { attestationId = "att-1", scanId } });
|
||||
|
||||
public Task<object?> GetProofChainAsync(string scanId, CancellationToken ct)
|
||||
=> Task.FromResult<object?>(new { proof = "chain", scanId });
|
||||
}
|
||||
|
||||
internal sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => now;
|
||||
}
|
||||
|
||||
internal sealed class FakeDsseSigner : IAuditPackExportSigner
|
||||
{
|
||||
public Task<DsseSignature> SignAsync(byte[] payload, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(new DsseSignature
|
||||
{
|
||||
KeyId = "test-key",
|
||||
Sig = Convert.ToBase64String(payload.Take(4).ToArray())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AuditPackImporterTests
|
||||
{
|
||||
private sealed record ArchivePayload(string Path, byte[] Content);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AuditPackImporterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ImportAsync_DeletesTempDirectory_WhenKeepExtractedIsFalseAsync()
|
||||
{
|
||||
var archivePath = CreateArchiveWithManifest();
|
||||
var importer = new AuditPackImporter(new GuidAuditPackIdGenerator());
|
||||
|
||||
var result = await importer.ImportAsync(archivePath, new ImportOptions { KeepExtracted = false });
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.ExtractDirectory.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_FailsOnPathTraversalEntriesAsync()
|
||||
{
|
||||
var archivePath = CreateArchiveWithEntries(
|
||||
new ArchivePayload("manifest.json", CreateManifestBytes()),
|
||||
new ArchivePayload("../evil.txt", new byte[] { 1, 2, 3 }));
|
||||
|
||||
var importer = new AuditPackImporter(new GuidAuditPackIdGenerator());
|
||||
var result = await importer.ImportAsync(archivePath, new ImportOptions());
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Errors.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_FailsWhenSignaturePresentWithoutTrustRootsAsync()
|
||||
{
|
||||
var archivePath = CreateArchiveWithEntries(
|
||||
new ArchivePayload("manifest.json", CreateManifestBytes()),
|
||||
new ArchivePayload("manifest.sig", new byte[] { 1, 2, 3 }));
|
||||
|
||||
var importer = new AuditPackImporter(new GuidAuditPackIdGenerator());
|
||||
var result = await importer.ImportAsync(archivePath, new ImportOptions { VerifySignatures = true });
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("Signature verification failed", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using StellaOps.AuditPack.Models;
|
||||
using StellaOps.AuditPack.Services;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AuditPackImporterTests
|
||||
{
|
||||
private string CreateArchiveWithManifest()
|
||||
=> CreateArchiveWithEntries(new ArchivePayload("manifest.json", CreateManifestBytes()));
|
||||
|
||||
private string CreateArchiveWithEntries(params ArchivePayload[] payloads)
|
||||
{
|
||||
var suffix = Interlocked.Increment(ref _archiveCounter);
|
||||
var outputPath = Path.Combine(_tempDir, $"archive-{suffix:0000}.tar.gz");
|
||||
|
||||
using (var fileStream = File.Create(outputPath))
|
||||
using (var gzip = new GZipStream(fileStream, CompressionLevel.Optimal, leaveOpen: false))
|
||||
using (var tarWriter = new System.Formats.Tar.TarWriter(gzip, System.Formats.Tar.TarEntryFormat.Pax, leaveOpen: false))
|
||||
{
|
||||
foreach (var payload in payloads)
|
||||
{
|
||||
var entry = new System.Formats.Tar.PaxTarEntry(System.Formats.Tar.TarEntryType.RegularFile, payload.Path)
|
||||
{
|
||||
DataStream = new MemoryStream(payload.Content, writable: false)
|
||||
};
|
||||
tarWriter.WriteEntry(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
private static byte[] CreateManifestBytes()
|
||||
{
|
||||
var pack = new AuditPack.Models.AuditPack
|
||||
{
|
||||
PackId = "pack-1",
|
||||
Name = "pack",
|
||||
CreatedAt = DateTimeOffset.UnixEpoch,
|
||||
RunManifest = new RunManifest("scan-1", DateTimeOffset.UnixEpoch),
|
||||
EvidenceIndex = new EvidenceIndex(Array.Empty<string>().ToImmutableArray()),
|
||||
Verdict = new Verdict("verdict-1", "completed"),
|
||||
OfflineBundle = new BundleManifest("bundle-1", "1.0"),
|
||||
Contents = new PackContents
|
||||
{
|
||||
Files = Array.Empty<PackFile>().ToImmutableArray(),
|
||||
TotalSizeBytes = 0,
|
||||
FileCount = 0
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(pack);
|
||||
}
|
||||
}
|
||||
@@ -1,100 +1,29 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AuditPack.Models;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AuditPackImporterTests
|
||||
public sealed partial class AuditPackImporterTests : IDisposable
|
||||
{
|
||||
[Fact]
|
||||
public async Task ImportAsync_DeletesTempDirectory_WhenKeepExtractedIsFalse()
|
||||
private static int _tempCounter;
|
||||
private static int _archiveCounter;
|
||||
private readonly string _tempDir;
|
||||
|
||||
public AuditPackImporterTests()
|
||||
{
|
||||
var archivePath = CreateArchiveWithManifest();
|
||||
var importer = new AuditPackImporter(new GuidAuditPackIdGenerator());
|
||||
|
||||
var result = await importer.ImportAsync(archivePath, new ImportOptions { KeepExtracted = false });
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.ExtractDirectory.Should().BeNull();
|
||||
var suffix = Interlocked.Increment(ref _tempCounter);
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"audit-pack-test-{suffix:0000}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_FailsOnPathTraversalEntries()
|
||||
public void Dispose()
|
||||
{
|
||||
var archivePath = CreateArchiveWithEntries(
|
||||
new ArchivePayload("manifest.json", CreateManifestBytes()),
|
||||
new ArchivePayload("../evil.txt", new byte[] { 1, 2, 3 }));
|
||||
|
||||
var importer = new AuditPackImporter(new GuidAuditPackIdGenerator());
|
||||
var result = await importer.ImportAsync(archivePath, new ImportOptions());
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Errors.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_FailsWhenSignaturePresentWithoutTrustRoots()
|
||||
{
|
||||
var archivePath = CreateArchiveWithEntries(
|
||||
new ArchivePayload("manifest.json", CreateManifestBytes()),
|
||||
new ArchivePayload("manifest.sig", new byte[] { 1, 2, 3 }));
|
||||
|
||||
var importer = new AuditPackImporter(new GuidAuditPackIdGenerator());
|
||||
var result = await importer.ImportAsync(archivePath, new ImportOptions { VerifySignatures = true });
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("Signature verification failed", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static string CreateArchiveWithManifest()
|
||||
=> CreateArchiveWithEntries(new ArchivePayload("manifest.json", CreateManifestBytes()));
|
||||
|
||||
private static string CreateArchiveWithEntries(params ArchivePayload[] payloads)
|
||||
{
|
||||
var outputPath = Path.Combine(Path.GetTempPath(), $"audit-pack-test-{Guid.NewGuid():N}.tar.gz");
|
||||
|
||||
using (var fileStream = File.Create(outputPath))
|
||||
using (var gzip = new GZipStream(fileStream, CompressionLevel.Optimal, leaveOpen: false))
|
||||
using (var tarWriter = new System.Formats.Tar.TarWriter(gzip, System.Formats.Tar.TarEntryFormat.Pax, leaveOpen: false))
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
foreach (var payload in payloads)
|
||||
{
|
||||
var entry = new System.Formats.Tar.PaxTarEntry(System.Formats.Tar.TarEntryType.RegularFile, payload.Path)
|
||||
{
|
||||
DataStream = new MemoryStream(payload.Content, writable: false)
|
||||
};
|
||||
tarWriter.WriteEntry(entry);
|
||||
}
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
private static byte[] CreateManifestBytes()
|
||||
{
|
||||
var pack = new StellaOps.AuditPack.Models.AuditPack
|
||||
{
|
||||
PackId = "pack-1",
|
||||
Name = "pack",
|
||||
CreatedAt = DateTimeOffset.UnixEpoch,
|
||||
RunManifest = new RunManifest("scan-1", DateTimeOffset.UnixEpoch),
|
||||
EvidenceIndex = new EvidenceIndex(Array.Empty<string>().ToImmutableArray()),
|
||||
Verdict = new Verdict("verdict-1", "completed"),
|
||||
OfflineBundle = new BundleManifest("bundle-1", "1.0"),
|
||||
Contents = new PackContents
|
||||
{
|
||||
Files = Array.Empty<PackFile>().ToImmutableArray(),
|
||||
TotalSizeBytes = 0,
|
||||
FileCount = 0
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(pack);
|
||||
}
|
||||
|
||||
private sealed record ArchivePayload(string Path, byte[] Content);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using StellaOps.AuditPack.Models;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AuditReplayE2ETests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task E2E_DeterministicMerkleRoot_SameInputsAsync()
|
||||
{
|
||||
var sbom = CreateCycloneDxSbom("app:deterministic");
|
||||
var feeds = CreateFeedsSnapshot();
|
||||
var policy = CreatePolicyBundle();
|
||||
var verdict = CreateVerdict(FixedDecision, "scan-deterministic");
|
||||
|
||||
var writer = new AuditBundleWriter();
|
||||
var bundle1Path = Path.Combine(_exportDir, "deterministic-1.tar.gz");
|
||||
var result1 = await writer.WriteAsync(new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = bundle1Path,
|
||||
ScanId = "scan-deterministic",
|
||||
ImageRef = "app:deterministic",
|
||||
ImageDigest = "sha256:deterministic123",
|
||||
Decision = FixedDecision,
|
||||
Sbom = sbom,
|
||||
FeedsSnapshot = feeds,
|
||||
PolicyBundle = policy,
|
||||
Verdict = verdict,
|
||||
Sign = false
|
||||
});
|
||||
|
||||
var bundle2Path = Path.Combine(_exportDir, "deterministic-2.tar.gz");
|
||||
var result2 = await writer.WriteAsync(new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = bundle2Path,
|
||||
ScanId = "scan-deterministic",
|
||||
ImageRef = "app:deterministic",
|
||||
ImageDigest = "sha256:deterministic123",
|
||||
Decision = FixedDecision,
|
||||
Sbom = sbom,
|
||||
FeedsSnapshot = feeds,
|
||||
PolicyBundle = policy,
|
||||
Verdict = verdict,
|
||||
Sign = false
|
||||
});
|
||||
|
||||
Assert.True(result1.Success);
|
||||
Assert.True(result2.Success);
|
||||
Assert.Equal(result1.MerkleRoot, result2.MerkleRoot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using StellaOps.AuditPack.Models;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AuditReplayE2ETests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task E2E_ExportTransferReplayOffline_MatchingVerdictAsync()
|
||||
{
|
||||
var scanId = FixedScanId;
|
||||
var imageRef = FixedImageRef;
|
||||
var imageDigest = FixedImageDigest;
|
||||
var decision = FixedDecision;
|
||||
|
||||
var writer = new AuditBundleWriter();
|
||||
var bundlePath = Path.Combine(_exportDir, "audit-bundle.tar.gz");
|
||||
var writeRequest = CreateWriteRequest(bundlePath, scanId, imageRef, imageDigest, decision);
|
||||
|
||||
var writeResult = await writer.WriteAsync(writeRequest);
|
||||
|
||||
Assert.True(writeResult.Success, $"Export failed: {writeResult.Error}");
|
||||
Assert.True(File.Exists(bundlePath), "Bundle file not created");
|
||||
Assert.NotNull(writeResult.MerkleRoot);
|
||||
Assert.NotNull(writeResult.BundleDigest);
|
||||
|
||||
var transferredBundlePath = Path.Combine(_importDir, "transferred-bundle.tar.gz");
|
||||
File.Copy(bundlePath, transferredBundlePath);
|
||||
|
||||
var originalHash = await ComputeFileHashAsync(bundlePath);
|
||||
var transferredHash = await ComputeFileHashAsync(transferredBundlePath);
|
||||
Assert.Equal(originalHash, transferredHash);
|
||||
|
||||
var reader = new AuditBundleReader();
|
||||
var readRequest = CreateReadRequest(transferredBundlePath);
|
||||
var readResult = await reader.ReadAsync(readRequest);
|
||||
|
||||
Assert.True(readResult.Success, $"Read failed: {readResult.Error}");
|
||||
Assert.True(readResult.MerkleRootVerified ?? false, "Merkle root validation failed");
|
||||
Assert.True(readResult.InputDigestsVerified ?? false, "Input digests validation failed");
|
||||
|
||||
using var replayContext = new IsolatedReplayContext(new IsolatedReplayContextOptions
|
||||
{
|
||||
CleanupOnDispose = true,
|
||||
EnforceOffline = true
|
||||
});
|
||||
|
||||
var initResult = await replayContext.InitializeAsync(readResult);
|
||||
Assert.True(initResult.Success, $"Replay context init failed: {initResult.Error}");
|
||||
|
||||
var executor = new ReplayExecutor();
|
||||
var replayResult = await executor.ExecuteAsync(
|
||||
replayContext,
|
||||
readResult.Manifest!,
|
||||
new ReplayExecutionOptions
|
||||
{
|
||||
FailOnInputDrift = false,
|
||||
DetailedDriftDetection = true
|
||||
});
|
||||
|
||||
Assert.True(replayResult.Success, $"Replay failed: {replayResult.Error}");
|
||||
Assert.Equal(ReplayStatus.Match, replayResult.Status);
|
||||
Assert.True(replayResult.InputsVerified, "Inputs should be verified");
|
||||
Assert.True(replayResult.DecisionMatches, "Decision should match");
|
||||
Assert.Equal(decision, replayResult.OriginalDecision);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AuditReplayE2ETests
|
||||
{
|
||||
private static byte[] CreatePolicyBundle()
|
||||
{
|
||||
return new byte[]
|
||||
{
|
||||
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x03, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] CreateVerdict(string decision, string scanId)
|
||||
{
|
||||
var verdict = new
|
||||
{
|
||||
version = "1.0",
|
||||
scanId,
|
||||
decision,
|
||||
evaluatedAt = FixedUtcNow.ToString("o"),
|
||||
policyVersion = "2024.1",
|
||||
findings = new
|
||||
{
|
||||
critical = 0,
|
||||
high = 2,
|
||||
medium = 5,
|
||||
low = 10,
|
||||
unknown = 0
|
||||
},
|
||||
attestation = new
|
||||
{
|
||||
type = "https://stellaops.io/verdict/v1",
|
||||
predicateType = "https://stellaops.io/attestation/verdict/v1"
|
||||
}
|
||||
};
|
||||
|
||||
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(verdict, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AuditReplayE2ETests
|
||||
{
|
||||
private static byte[] CreateCycloneDxSbom(string imageRef, bool addMaliciousComponent = false)
|
||||
{
|
||||
var components = new List<object>
|
||||
{
|
||||
new { type = "library", name = "lodash", version = "4.17.21", purl = "pkg:npm/lodash@4.17.21" },
|
||||
new { type = "library", name = "express", version = "4.18.2", purl = "pkg:npm/express@4.18.2" }
|
||||
};
|
||||
|
||||
if (addMaliciousComponent)
|
||||
{
|
||||
components.Add(new
|
||||
{
|
||||
type = "library",
|
||||
name = "evil-package",
|
||||
version = "1.0.0",
|
||||
purl = "pkg:npm/evil-package@1.0.0"
|
||||
});
|
||||
}
|
||||
|
||||
var sbom = new
|
||||
{
|
||||
bomFormat = "CycloneDX",
|
||||
specVersion = "1.6",
|
||||
version = 1,
|
||||
serialNumber = FixedSbomSerial,
|
||||
metadata = new
|
||||
{
|
||||
timestamp = FixedUtcNow.ToString("o"),
|
||||
component = new { type = "container", name = imageRef }
|
||||
},
|
||||
components = components.ToArray()
|
||||
};
|
||||
|
||||
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(sbom, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
}));
|
||||
}
|
||||
|
||||
private static byte[] CreateFeedsSnapshot()
|
||||
{
|
||||
var snapshot = new
|
||||
{
|
||||
type = "feed-snapshot",
|
||||
version = "1.0",
|
||||
timestamp = FixedUtcNow.ToString("o"),
|
||||
sources = new[]
|
||||
{
|
||||
new { name = "nvd", lastSync = FixedUtcNow.AddHours(-1).ToString("o") },
|
||||
new { name = "ghsa", lastSync = FixedUtcNow.AddHours(-2).ToString("o") }
|
||||
},
|
||||
advisoryCount = 150000
|
||||
};
|
||||
|
||||
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(snapshot) + "\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AuditReplayE2ETests
|
||||
{
|
||||
private static byte[] CreateVexStatements()
|
||||
{
|
||||
var vex = new
|
||||
{
|
||||
type = "https://openvex.dev/ns/v0.2.0",
|
||||
id = FixedVexId,
|
||||
author = "security-team@example.com",
|
||||
timestamp = FixedUtcNow.ToString("o"),
|
||||
statements = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
vulnerability = new { id = "CVE-2024-1234" },
|
||||
status = "not_affected",
|
||||
justification = "vulnerable_code_not_present"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(vex, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
}));
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeFileHashAsync(string filePath)
|
||||
{
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hash = await SHA256.HashDataAsync(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using StellaOps.AuditPack.Models;
|
||||
using StellaOps.AuditPack.Services;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AuditReplayE2ETests
|
||||
{
|
||||
private static AuditBundleWriteRequest CreateWriteRequest(
|
||||
string bundlePath,
|
||||
string scanId,
|
||||
string imageRef,
|
||||
string imageDigest,
|
||||
string decision)
|
||||
{
|
||||
return new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = bundlePath,
|
||||
ScanId = scanId,
|
||||
ImageRef = imageRef,
|
||||
ImageDigest = imageDigest,
|
||||
Decision = decision,
|
||||
Sbom = CreateCycloneDxSbom(imageRef),
|
||||
FeedsSnapshot = CreateFeedsSnapshot(),
|
||||
PolicyBundle = CreatePolicyBundle(),
|
||||
Verdict = CreateVerdict(decision, scanId),
|
||||
VexStatements = CreateVexStatements(),
|
||||
Sign = false,
|
||||
TimeAnchor = new TimeAnchorInput
|
||||
{
|
||||
Timestamp = FixedUtcNow,
|
||||
Source = "local-test"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static AuditBundleReadRequest CreateReadRequest(string bundlePath)
|
||||
{
|
||||
return new AuditBundleReadRequest
|
||||
{
|
||||
BundlePath = bundlePath,
|
||||
VerifySignature = false,
|
||||
VerifyMerkleRoot = true,
|
||||
VerifyInputDigests = true,
|
||||
LoadReplayInputs = true
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Linq;
|
||||
using StellaOps.AuditPack.Models;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AuditReplayE2ETests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task E2E_BundleContainsAllRequiredFilesAsync()
|
||||
{
|
||||
var sbom = CreateCycloneDxSbom("app:v1");
|
||||
var feeds = CreateFeedsSnapshot();
|
||||
var policy = CreatePolicyBundle();
|
||||
var verdict = CreateVerdict(FixedDecision, "scan-files-test");
|
||||
var vex = CreateVexStatements();
|
||||
|
||||
var writer = new AuditBundleWriter();
|
||||
var bundlePath = Path.Combine(_exportDir, "files-test.tar.gz");
|
||||
var writeResult = await writer.WriteAsync(new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = bundlePath,
|
||||
ScanId = "scan-files-test",
|
||||
ImageRef = "app:v1",
|
||||
ImageDigest = "sha256:abc",
|
||||
Decision = FixedDecision,
|
||||
Sbom = sbom,
|
||||
FeedsSnapshot = feeds,
|
||||
PolicyBundle = policy,
|
||||
Verdict = verdict,
|
||||
VexStatements = vex,
|
||||
Sign = false
|
||||
});
|
||||
|
||||
Assert.True(writeResult.Success);
|
||||
Assert.True(writeResult.FileCount >= 5, $"Expected at least 5 files, got {writeResult.FileCount}");
|
||||
|
||||
var reader = new AuditBundleReader();
|
||||
var readResult = await reader.ReadAsync(new AuditBundleReadRequest
|
||||
{
|
||||
BundlePath = bundlePath,
|
||||
VerifySignature = false
|
||||
});
|
||||
|
||||
Assert.True(readResult.Success);
|
||||
Assert.NotNull(readResult.Manifest);
|
||||
Assert.NotEmpty(readResult.Manifest!.Files);
|
||||
|
||||
var filePaths = readResult.Manifest.Files.Select(f => f.RelativePath).ToList();
|
||||
Assert.Contains(filePaths, p => p.Contains("sbom"));
|
||||
Assert.Contains(filePaths, p => p.Contains("feeds"));
|
||||
Assert.Contains(filePaths, p => p.Contains("policy"));
|
||||
Assert.Contains(filePaths, p => p.Contains("verdict"));
|
||||
Assert.Contains(filePaths, p => p.Contains("vex"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using StellaOps.AuditPack.Models;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AuditReplayE2ETests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task E2E_ReplayDetectsTamperedSbomAsync()
|
||||
{
|
||||
var scanId = FixedScanId;
|
||||
var sbom = CreateCycloneDxSbom("app:v1");
|
||||
var feeds = CreateFeedsSnapshot();
|
||||
var policy = CreatePolicyBundle();
|
||||
var verdict = CreateVerdict(FixedDecision, scanId);
|
||||
|
||||
var writer = new AuditBundleWriter();
|
||||
var bundlePath = Path.Combine(_exportDir, "original.tar.gz");
|
||||
var writeResult = await writer.WriteAsync(new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = bundlePath,
|
||||
ScanId = scanId,
|
||||
ImageRef = "app:v1",
|
||||
ImageDigest = "sha256:abc",
|
||||
Decision = FixedDecision,
|
||||
Sbom = sbom,
|
||||
FeedsSnapshot = feeds,
|
||||
PolicyBundle = policy,
|
||||
Verdict = verdict,
|
||||
Sign = false
|
||||
});
|
||||
|
||||
Assert.True(writeResult.Success);
|
||||
|
||||
var tamperedSbom = CreateCycloneDxSbom("app:v1", addMaliciousComponent: true);
|
||||
var tamperedBundlePath = Path.Combine(_importDir, "tampered.tar.gz");
|
||||
var tamperedResult = await writer.WriteAsync(new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = tamperedBundlePath,
|
||||
ScanId = scanId,
|
||||
ImageRef = "app:v1",
|
||||
ImageDigest = "sha256:abc",
|
||||
Decision = FixedDecision,
|
||||
Sbom = tamperedSbom,
|
||||
FeedsSnapshot = feeds,
|
||||
PolicyBundle = policy,
|
||||
Verdict = verdict,
|
||||
Sign = false
|
||||
});
|
||||
|
||||
Assert.True(tamperedResult.Success);
|
||||
|
||||
var reader = new AuditBundleReader();
|
||||
var originalRead = await reader.ReadAsync(new AuditBundleReadRequest
|
||||
{
|
||||
BundlePath = bundlePath,
|
||||
VerifySignature = false,
|
||||
LoadReplayInputs = true
|
||||
});
|
||||
var tamperedRead = await reader.ReadAsync(new AuditBundleReadRequest
|
||||
{
|
||||
BundlePath = tamperedBundlePath,
|
||||
VerifySignature = false,
|
||||
LoadReplayInputs = true
|
||||
});
|
||||
|
||||
Assert.NotEqual(originalRead.Manifest?.MerkleRoot, tamperedRead.Manifest?.MerkleRoot);
|
||||
Assert.NotEqual(originalRead.Manifest?.Inputs.SbomDigest, tamperedRead.Manifest?.Inputs.SbomDigest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using StellaOps.AuditPack.Models;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class AuditReplayE2ETests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task E2E_FullCycleWithTimeAnchorAsync()
|
||||
{
|
||||
var timestamp = new DateTimeOffset(2024, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
var sbom = CreateCycloneDxSbom("app:time-test");
|
||||
var feeds = CreateFeedsSnapshot();
|
||||
var policy = CreatePolicyBundle();
|
||||
var verdict = CreateVerdict(FixedDecision, "scan-time-test");
|
||||
|
||||
var writer = new AuditBundleWriter();
|
||||
var bundlePath = Path.Combine(_exportDir, "time-anchor-test.tar.gz");
|
||||
var writeResult = await writer.WriteAsync(new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = bundlePath,
|
||||
ScanId = "scan-time-test",
|
||||
ImageRef = "app:time-test",
|
||||
ImageDigest = "sha256:abc",
|
||||
Decision = FixedDecision,
|
||||
Sbom = sbom,
|
||||
FeedsSnapshot = feeds,
|
||||
PolicyBundle = policy,
|
||||
Verdict = verdict,
|
||||
Sign = false,
|
||||
TimeAnchor = new TimeAnchorInput
|
||||
{
|
||||
Timestamp = timestamp,
|
||||
Source = "test-time-server"
|
||||
}
|
||||
});
|
||||
|
||||
Assert.True(writeResult.Success);
|
||||
|
||||
var reader = new AuditBundleReader();
|
||||
var readResult = await reader.ReadAsync(new AuditBundleReadRequest
|
||||
{
|
||||
BundlePath = bundlePath,
|
||||
VerifySignature = false,
|
||||
LoadReplayInputs = true
|
||||
});
|
||||
|
||||
Assert.True(readResult.Success);
|
||||
Assert.NotNull(readResult.Manifest?.TimeAnchor);
|
||||
Assert.Equal(timestamp, readResult.Manifest!.TimeAnchor.Timestamp);
|
||||
Assert.Equal("test-time-server", readResult.Manifest.TimeAnchor.Source);
|
||||
|
||||
using var context = new IsolatedReplayContext(new IsolatedReplayContextOptions
|
||||
{
|
||||
EvaluationTime = timestamp,
|
||||
CleanupOnDispose = true
|
||||
});
|
||||
|
||||
var initResult = await context.InitializeAsync(readResult);
|
||||
Assert.True(initResult.Success);
|
||||
Assert.Equal(timestamp, context.EvaluationTime);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,33 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// -----------------------------------------------------------------------------
|
||||
// AuditReplayE2ETests.cs
|
||||
// Sprint: SPRINT_4300_0001_0002 (One-Command Audit Replay CLI)
|
||||
// Task: REPLAY-028 - E2E test: export -> transfer -> replay offline
|
||||
// Description: End-to-end integration tests for audit bundle export and replay.
|
||||
// -----------------------------------------------------------------------------
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.AuditPack.Models;
|
||||
using StellaOps.AuditPack.Services;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end integration tests that verify the complete audit bundle workflow:
|
||||
/// export -> transfer -> replay offline.
|
||||
/// </summary>
|
||||
public class AuditReplayE2ETests : IDisposable
|
||||
public sealed partial class AuditReplayE2ETests : IDisposable
|
||||
{
|
||||
private static int _tempCounter;
|
||||
private static readonly DateTimeOffset FixedUtcNow = new(2026, 1, 2, 12, 0, 0, TimeSpan.Zero);
|
||||
private const string FixedScanId = "scan-001";
|
||||
private const string FixedImageRef = "registry.example.com/app:v1.2.3";
|
||||
private const string FixedImageDigest = "sha256:abc123def456789";
|
||||
private const string FixedDecision = "pass";
|
||||
private const string FixedSbomSerial = "urn:uuid:11111111-1111-1111-1111-111111111111";
|
||||
private const string FixedVexId = "https://stellaops.io/vex/00000000-0000-0000-0000-000000000000";
|
||||
private readonly string _tempDir;
|
||||
private readonly string _exportDir;
|
||||
private readonly string _importDir;
|
||||
|
||||
public AuditReplayE2ETests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"e2e-test-{Guid.NewGuid():N}");
|
||||
var suffix = Interlocked.Increment(ref _tempCounter);
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"e2e-test-{suffix:0000}");
|
||||
_exportDir = Path.Combine(_tempDir, "export");
|
||||
_importDir = Path.Combine(_tempDir, "import");
|
||||
|
||||
@@ -42,480 +42,4 @@ public class AuditReplayE2ETests : IDisposable
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task E2E_ExportTransferReplayOffline_MatchingVerdict()
|
||||
{
|
||||
// ===== PHASE 1: EXPORT =====
|
||||
// Create scan data
|
||||
var scanId = $"scan-{Guid.NewGuid():N}";
|
||||
var imageRef = "registry.example.com/app:v1.2.3";
|
||||
var imageDigest = "sha256:abc123def456789";
|
||||
var decision = "pass";
|
||||
|
||||
var sbom = CreateCycloneDxSbom(imageRef);
|
||||
var feeds = CreateFeedsSnapshot();
|
||||
var policy = CreatePolicyBundle();
|
||||
var verdict = CreateVerdict(decision, scanId);
|
||||
var vex = CreateVexStatements();
|
||||
|
||||
// Create audit bundle (unsigned for E2E test simplicity)
|
||||
var writer = new AuditBundleWriter();
|
||||
var bundlePath = Path.Combine(_exportDir, "audit-bundle.tar.gz");
|
||||
|
||||
var writeRequest = new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = bundlePath,
|
||||
ScanId = scanId,
|
||||
ImageRef = imageRef,
|
||||
ImageDigest = imageDigest,
|
||||
Decision = decision,
|
||||
Sbom = sbom,
|
||||
FeedsSnapshot = feeds,
|
||||
PolicyBundle = policy,
|
||||
Verdict = verdict,
|
||||
VexStatements = vex,
|
||||
Sign = false, // Skip signing for unit test
|
||||
TimeAnchor = new TimeAnchorInput
|
||||
{
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Source = "local-test"
|
||||
}
|
||||
};
|
||||
|
||||
var writeResult = await writer.WriteAsync(writeRequest);
|
||||
|
||||
// Assert export succeeded
|
||||
Assert.True(writeResult.Success, $"Export failed: {writeResult.Error}");
|
||||
Assert.True(File.Exists(bundlePath), "Bundle file not created");
|
||||
Assert.NotNull(writeResult.MerkleRoot);
|
||||
Assert.NotNull(writeResult.BundleDigest);
|
||||
|
||||
// ===== PHASE 2: TRANSFER (simulate by copying) =====
|
||||
var transferredBundlePath = Path.Combine(_importDir, "transferred-bundle.tar.gz");
|
||||
File.Copy(bundlePath, transferredBundlePath);
|
||||
|
||||
// Verify transfer integrity
|
||||
var originalHash = await ComputeFileHashAsync(bundlePath);
|
||||
var transferredHash = await ComputeFileHashAsync(transferredBundlePath);
|
||||
Assert.Equal(originalHash, transferredHash);
|
||||
|
||||
// ===== PHASE 3: REPLAY OFFLINE =====
|
||||
// Read the bundle
|
||||
var reader = new AuditBundleReader();
|
||||
var readRequest = new AuditBundleReadRequest
|
||||
{
|
||||
BundlePath = transferredBundlePath,
|
||||
VerifySignature = false, // No signature in this test
|
||||
VerifyMerkleRoot = true,
|
||||
VerifyInputDigests = true,
|
||||
LoadReplayInputs = true
|
||||
};
|
||||
|
||||
var readResult = await reader.ReadAsync(readRequest);
|
||||
|
||||
// Assert read succeeded
|
||||
Assert.True(readResult.Success, $"Read failed: {readResult.Error}");
|
||||
Assert.True(readResult.MerkleRootVerified ?? false, "Merkle root validation failed");
|
||||
Assert.True(readResult.InputDigestsVerified ?? false, "Input digests validation failed");
|
||||
|
||||
// Create isolated replay context
|
||||
using var replayContext = new IsolatedReplayContext(new IsolatedReplayContextOptions
|
||||
{
|
||||
CleanupOnDispose = true,
|
||||
EnforceOffline = true
|
||||
});
|
||||
|
||||
var initResult = await replayContext.InitializeAsync(readResult);
|
||||
Assert.True(initResult.Success, $"Replay context init failed: {initResult.Error}");
|
||||
|
||||
// Execute replay
|
||||
var executor = new ReplayExecutor();
|
||||
var replayResult = await executor.ExecuteAsync(
|
||||
replayContext,
|
||||
readResult.Manifest!,
|
||||
new ReplayExecutionOptions
|
||||
{
|
||||
FailOnInputDrift = false,
|
||||
DetailedDriftDetection = true
|
||||
});
|
||||
|
||||
// Assert replay succeeded with matching verdict
|
||||
Assert.True(replayResult.Success, $"Replay failed: {replayResult.Error}");
|
||||
Assert.Equal(ReplayStatus.Match, replayResult.Status);
|
||||
Assert.True(replayResult.InputsVerified, "Inputs should be verified");
|
||||
Assert.True(replayResult.DecisionMatches, "Decision should match");
|
||||
Assert.Equal(decision, replayResult.OriginalDecision);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task E2E_ReplayDetectsTamperedSbom()
|
||||
{
|
||||
// Setup
|
||||
var scanId = $"scan-{Guid.NewGuid():N}";
|
||||
var sbom = CreateCycloneDxSbom("app:v1");
|
||||
var feeds = CreateFeedsSnapshot();
|
||||
var policy = CreatePolicyBundle();
|
||||
var verdict = CreateVerdict("pass", scanId);
|
||||
|
||||
// Export original bundle
|
||||
var writer = new AuditBundleWriter();
|
||||
var bundlePath = Path.Combine(_exportDir, "original.tar.gz");
|
||||
|
||||
var writeResult = await writer.WriteAsync(new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = bundlePath,
|
||||
ScanId = scanId,
|
||||
ImageRef = "app:v1",
|
||||
ImageDigest = "sha256:abc",
|
||||
Decision = "pass",
|
||||
Sbom = sbom,
|
||||
FeedsSnapshot = feeds,
|
||||
PolicyBundle = policy,
|
||||
Verdict = verdict,
|
||||
Sign = false
|
||||
});
|
||||
|
||||
Assert.True(writeResult.Success);
|
||||
|
||||
// Export tampered bundle with modified SBOM
|
||||
var tamperedSbom = CreateCycloneDxSbom("app:v1", addMaliciousComponent: true);
|
||||
var tamperedBundlePath = Path.Combine(_importDir, "tampered.tar.gz");
|
||||
|
||||
var tamperedResult = await writer.WriteAsync(new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = tamperedBundlePath,
|
||||
ScanId = scanId,
|
||||
ImageRef = "app:v1",
|
||||
ImageDigest = "sha256:abc",
|
||||
Decision = "pass",
|
||||
Sbom = tamperedSbom, // Different SBOM
|
||||
FeedsSnapshot = feeds,
|
||||
PolicyBundle = policy,
|
||||
Verdict = verdict,
|
||||
Sign = false
|
||||
});
|
||||
|
||||
Assert.True(tamperedResult.Success);
|
||||
|
||||
// Read both bundles
|
||||
var reader = new AuditBundleReader();
|
||||
|
||||
var originalRead = await reader.ReadAsync(new AuditBundleReadRequest
|
||||
{
|
||||
BundlePath = bundlePath,
|
||||
VerifySignature = false,
|
||||
LoadReplayInputs = true
|
||||
});
|
||||
|
||||
var tamperedRead = await reader.ReadAsync(new AuditBundleReadRequest
|
||||
{
|
||||
BundlePath = tamperedBundlePath,
|
||||
VerifySignature = false,
|
||||
LoadReplayInputs = true
|
||||
});
|
||||
|
||||
// The merkle roots should differ
|
||||
Assert.NotEqual(originalRead.Manifest?.MerkleRoot, tamperedRead.Manifest?.MerkleRoot);
|
||||
|
||||
// Input digests should differ
|
||||
Assert.NotEqual(
|
||||
originalRead.Manifest?.Inputs.SbomDigest,
|
||||
tamperedRead.Manifest?.Inputs.SbomDigest);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task E2E_DeterministicMerkleRoot_SameInputs()
|
||||
{
|
||||
// Create identical inputs
|
||||
var sbom = CreateCycloneDxSbom("app:deterministic");
|
||||
var feeds = CreateFeedsSnapshot();
|
||||
var policy = CreatePolicyBundle();
|
||||
var verdict = CreateVerdict("pass", "scan-deterministic");
|
||||
|
||||
var writer = new AuditBundleWriter();
|
||||
|
||||
// Write bundle 1
|
||||
var bundle1Path = Path.Combine(_exportDir, "deterministic-1.tar.gz");
|
||||
var result1 = await writer.WriteAsync(new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = bundle1Path,
|
||||
ScanId = "scan-deterministic",
|
||||
ImageRef = "app:deterministic",
|
||||
ImageDigest = "sha256:deterministic123",
|
||||
Decision = "pass",
|
||||
Sbom = sbom,
|
||||
FeedsSnapshot = feeds,
|
||||
PolicyBundle = policy,
|
||||
Verdict = verdict,
|
||||
Sign = false
|
||||
});
|
||||
|
||||
// Write bundle 2 with same inputs
|
||||
var bundle2Path = Path.Combine(_exportDir, "deterministic-2.tar.gz");
|
||||
var result2 = await writer.WriteAsync(new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = bundle2Path,
|
||||
ScanId = "scan-deterministic",
|
||||
ImageRef = "app:deterministic",
|
||||
ImageDigest = "sha256:deterministic123",
|
||||
Decision = "pass",
|
||||
Sbom = sbom,
|
||||
FeedsSnapshot = feeds,
|
||||
PolicyBundle = policy,
|
||||
Verdict = verdict,
|
||||
Sign = false
|
||||
});
|
||||
|
||||
// Merkle roots must be identical
|
||||
Assert.True(result1.Success);
|
||||
Assert.True(result2.Success);
|
||||
Assert.Equal(result1.MerkleRoot, result2.MerkleRoot);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task E2E_BundleContainsAllRequiredFiles()
|
||||
{
|
||||
// Setup
|
||||
var sbom = CreateCycloneDxSbom("app:v1");
|
||||
var feeds = CreateFeedsSnapshot();
|
||||
var policy = CreatePolicyBundle();
|
||||
var verdict = CreateVerdict("pass", "scan-files-test");
|
||||
var vex = CreateVexStatements();
|
||||
|
||||
var writer = new AuditBundleWriter();
|
||||
var bundlePath = Path.Combine(_exportDir, "files-test.tar.gz");
|
||||
|
||||
var writeResult = await writer.WriteAsync(new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = bundlePath,
|
||||
ScanId = "scan-files-test",
|
||||
ImageRef = "app:v1",
|
||||
ImageDigest = "sha256:abc",
|
||||
Decision = "pass",
|
||||
Sbom = sbom,
|
||||
FeedsSnapshot = feeds,
|
||||
PolicyBundle = policy,
|
||||
Verdict = verdict,
|
||||
VexStatements = vex,
|
||||
Sign = false
|
||||
});
|
||||
|
||||
Assert.True(writeResult.Success);
|
||||
Assert.True(writeResult.FileCount >= 5, $"Expected at least 5 files, got {writeResult.FileCount}");
|
||||
|
||||
// Read and verify manifest contains all files
|
||||
var reader = new AuditBundleReader();
|
||||
var readResult = await reader.ReadAsync(new AuditBundleReadRequest
|
||||
{
|
||||
BundlePath = bundlePath,
|
||||
VerifySignature = false
|
||||
});
|
||||
|
||||
Assert.True(readResult.Success);
|
||||
Assert.NotNull(readResult.Manifest);
|
||||
Assert.NotEmpty(readResult.Manifest.Files);
|
||||
|
||||
// Verify essential files are present
|
||||
var filePaths = readResult.Manifest.Files.Select(f => f.RelativePath).ToList();
|
||||
Assert.Contains(filePaths, p => p.Contains("sbom"));
|
||||
Assert.Contains(filePaths, p => p.Contains("feeds"));
|
||||
Assert.Contains(filePaths, p => p.Contains("policy"));
|
||||
Assert.Contains(filePaths, p => p.Contains("verdict"));
|
||||
Assert.Contains(filePaths, p => p.Contains("vex"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task E2E_FullCycleWithTimeAnchor()
|
||||
{
|
||||
// Setup with explicit time anchor
|
||||
var timestamp = new DateTimeOffset(2024, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
var sbom = CreateCycloneDxSbom("app:time-test");
|
||||
var feeds = CreateFeedsSnapshot();
|
||||
var policy = CreatePolicyBundle();
|
||||
var verdict = CreateVerdict("pass", "scan-time-test");
|
||||
|
||||
var writer = new AuditBundleWriter();
|
||||
var bundlePath = Path.Combine(_exportDir, "time-anchor-test.tar.gz");
|
||||
|
||||
var writeResult = await writer.WriteAsync(new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = bundlePath,
|
||||
ScanId = "scan-time-test",
|
||||
ImageRef = "app:time-test",
|
||||
ImageDigest = "sha256:abc",
|
||||
Decision = "pass",
|
||||
Sbom = sbom,
|
||||
FeedsSnapshot = feeds,
|
||||
PolicyBundle = policy,
|
||||
Verdict = verdict,
|
||||
Sign = false,
|
||||
TimeAnchor = new TimeAnchorInput
|
||||
{
|
||||
Timestamp = timestamp,
|
||||
Source = "test-time-server"
|
||||
}
|
||||
});
|
||||
|
||||
Assert.True(writeResult.Success);
|
||||
|
||||
// Read and verify time anchor
|
||||
var reader = new AuditBundleReader();
|
||||
var readResult = await reader.ReadAsync(new AuditBundleReadRequest
|
||||
{
|
||||
BundlePath = bundlePath,
|
||||
VerifySignature = false,
|
||||
LoadReplayInputs = true
|
||||
});
|
||||
|
||||
Assert.True(readResult.Success);
|
||||
Assert.NotNull(readResult.Manifest?.TimeAnchor);
|
||||
Assert.Equal(timestamp, readResult.Manifest.TimeAnchor.Timestamp);
|
||||
Assert.Equal("test-time-server", readResult.Manifest.TimeAnchor.Source);
|
||||
|
||||
// Replay with time anchor context
|
||||
using var context = new IsolatedReplayContext(new IsolatedReplayContextOptions
|
||||
{
|
||||
EvaluationTime = timestamp,
|
||||
CleanupOnDispose = true
|
||||
});
|
||||
|
||||
var initResult = await context.InitializeAsync(readResult);
|
||||
Assert.True(initResult.Success);
|
||||
Assert.Equal(timestamp, context.EvaluationTime);
|
||||
}
|
||||
|
||||
#region Test Data Factories
|
||||
|
||||
private static byte[] CreateCycloneDxSbom(string imageRef, bool addMaliciousComponent = false)
|
||||
{
|
||||
var components = new List<object>
|
||||
{
|
||||
new { type = "library", name = "lodash", version = "4.17.21", purl = "pkg:npm/lodash@4.17.21" },
|
||||
new { type = "library", name = "express", version = "4.18.2", purl = "pkg:npm/express@4.18.2" }
|
||||
};
|
||||
|
||||
if (addMaliciousComponent)
|
||||
{
|
||||
components.Add(new { type = "library", name = "evil-package", version = "1.0.0", purl = "pkg:npm/evil-package@1.0.0" });
|
||||
}
|
||||
|
||||
var sbom = new
|
||||
{
|
||||
bomFormat = "CycloneDX",
|
||||
specVersion = "1.6",
|
||||
version = 1,
|
||||
serialNumber = $"urn:uuid:{Guid.NewGuid()}",
|
||||
metadata = new
|
||||
{
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("o"),
|
||||
component = new { type = "container", name = imageRef }
|
||||
},
|
||||
components = components.ToArray()
|
||||
};
|
||||
|
||||
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(sbom, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
}));
|
||||
}
|
||||
|
||||
private static byte[] CreateFeedsSnapshot()
|
||||
{
|
||||
var snapshot = new
|
||||
{
|
||||
type = "feed-snapshot",
|
||||
version = "1.0",
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("o"),
|
||||
sources = new[]
|
||||
{
|
||||
new { name = "nvd", lastSync = DateTimeOffset.UtcNow.AddHours(-1).ToString("o") },
|
||||
new { name = "ghsa", lastSync = DateTimeOffset.UtcNow.AddHours(-2).ToString("o") }
|
||||
},
|
||||
advisoryCount = 150000
|
||||
};
|
||||
|
||||
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(snapshot) + "\n");
|
||||
}
|
||||
|
||||
private static byte[] CreatePolicyBundle()
|
||||
{
|
||||
// Minimal valid gzip content (empty archive)
|
||||
return new byte[]
|
||||
{
|
||||
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x03, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] CreateVerdict(string decision, string scanId)
|
||||
{
|
||||
var verdict = new
|
||||
{
|
||||
version = "1.0",
|
||||
scanId = scanId,
|
||||
decision = decision,
|
||||
evaluatedAt = DateTimeOffset.UtcNow.ToString("o"),
|
||||
policyVersion = "2024.1",
|
||||
findings = new
|
||||
{
|
||||
critical = 0,
|
||||
high = 2,
|
||||
medium = 5,
|
||||
low = 10,
|
||||
unknown = 0
|
||||
},
|
||||
attestation = new
|
||||
{
|
||||
type = "https://stellaops.io/verdict/v1",
|
||||
predicateType = "https://stellaops.io/attestation/verdict/v1"
|
||||
}
|
||||
};
|
||||
|
||||
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(verdict, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
}));
|
||||
}
|
||||
|
||||
private static byte[] CreateVexStatements()
|
||||
{
|
||||
var vex = new
|
||||
{
|
||||
type = "https://openvex.dev/ns/v0.2.0",
|
||||
id = $"https://stellaops.io/vex/{Guid.NewGuid()}",
|
||||
author = "security-team@example.com",
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("o"),
|
||||
statements = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
vulnerability = new { id = "CVE-2024-1234" },
|
||||
status = "not_affected",
|
||||
justification = "vulnerable_code_not_present"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(vex, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
}));
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeFileHashAsync(string filePath)
|
||||
{
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hash = await SHA256.HashDataAsync(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using StellaOps.AuditPack.Models;
|
||||
using StellaOps.AuditPack.Services;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class ReplayAttestationServiceTests
|
||||
{
|
||||
private sealed class AcceptAllVerifier : IReplayAttestationSignatureVerifier
|
||||
{
|
||||
public Task<ReplayAttestationSignatureVerification> VerifyAsync(
|
||||
ReplayDsseEnvelope envelope,
|
||||
byte[] payload,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new ReplayAttestationSignatureVerification { Verified = true });
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using StellaOps.AuditPack.Models;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed partial class ReplayAttestationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task VerifyAsync_Fails_WhenEnvelopeHasNoSignaturesAsync()
|
||||
{
|
||||
var verifier = new AcceptAllVerifier();
|
||||
var service = new ReplayAttestationService(
|
||||
verifier: verifier,
|
||||
timeProvider: new FixedTimeProvider(DateTimeOffset.UnixEpoch));
|
||||
var attestation = await service.GenerateAsync(
|
||||
new AuditBundleManifest
|
||||
{
|
||||
BundleId = "bundle-1",
|
||||
Name = "bundle",
|
||||
CreatedAt = DateTimeOffset.UnixEpoch,
|
||||
ScanId = "scan-1",
|
||||
ImageRef = "image",
|
||||
ImageDigest = "sha256:abc",
|
||||
MerkleRoot = "sha256:root",
|
||||
Inputs = new InputDigests
|
||||
{
|
||||
SbomDigest = "sha256:sbom",
|
||||
FeedsDigest = "sha256:feeds",
|
||||
PolicyDigest = "sha256:policy"
|
||||
},
|
||||
VerdictDigest = "sha256:verdict",
|
||||
Decision = "pass",
|
||||
Files = [],
|
||||
TotalSizeBytes = 0
|
||||
},
|
||||
new ReplayExecutionResult
|
||||
{
|
||||
Success = true,
|
||||
Status = ReplayStatus.Match,
|
||||
InputsVerified = true,
|
||||
VerdictMatches = true,
|
||||
DecisionMatches = true,
|
||||
OriginalVerdictDigest = "sha256:verdict",
|
||||
ReplayedVerdictDigest = "sha256:verdict",
|
||||
OriginalDecision = "pass",
|
||||
ReplayedDecision = "pass",
|
||||
Drifts = [],
|
||||
Errors = [],
|
||||
DurationMs = 0,
|
||||
EvaluatedAt = DateTimeOffset.UnixEpoch
|
||||
});
|
||||
|
||||
var result = await service.VerifyAsync(attestation);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("signatures", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using StellaOps.AuditPack.Models;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class ReplayAttestationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task VerifyAsync_Succeeds_WithVerifierAndValidPayloadAsync()
|
||||
{
|
||||
var verifier = new AcceptAllVerifier();
|
||||
var service = new ReplayAttestationService(verifier: verifier);
|
||||
var payload = CanonicalJson.Serialize(new InTotoStatement
|
||||
{
|
||||
Type = "https://in-toto.io/Statement/v1",
|
||||
Subject =
|
||||
[
|
||||
new InTotoSubject
|
||||
{
|
||||
Name = "verdict:bundle-1",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = "abc" }
|
||||
}
|
||||
],
|
||||
PredicateType = "https://stellaops.io/attestation/verdict-replay/v1",
|
||||
Predicate = new VerdictReplayAttestation
|
||||
{
|
||||
ManifestId = "bundle-1",
|
||||
ScanId = "scan-1",
|
||||
ImageRef = "image",
|
||||
ImageDigest = "sha256:abc",
|
||||
InputsDigest = "sha256:inputs",
|
||||
OriginalVerdictDigest = "sha256:verdict",
|
||||
OriginalDecision = "pass",
|
||||
Match = true,
|
||||
Status = "Match",
|
||||
DriftCount = 0,
|
||||
EvaluatedAt = DateTimeOffset.UnixEpoch,
|
||||
ReplayedAt = DateTimeOffset.UnixEpoch,
|
||||
DurationMs = 0
|
||||
}
|
||||
});
|
||||
|
||||
var attestation = new ReplayAttestation
|
||||
{
|
||||
AttestationId = "att-1",
|
||||
ManifestId = "bundle-1",
|
||||
CreatedAt = DateTimeOffset.UnixEpoch,
|
||||
Statement = JsonSerializer.Deserialize<InTotoStatement>(payload)!,
|
||||
StatementDigest = "sha256:" + Convert.ToHexString(SHA256.HashData(payload)).ToLowerInvariant(),
|
||||
Envelope = new ReplayDsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = Convert.ToBase64String(payload),
|
||||
Signatures = [new ReplayDsseSignature { KeyId = "key", Sig = "sig" }]
|
||||
},
|
||||
Match = true,
|
||||
ReplayStatus = "Match"
|
||||
};
|
||||
|
||||
var result = await service.VerifyAsync(attestation);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.True(result.SignatureVerified);
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.AuditPack.Models;
|
||||
using StellaOps.AuditPack.Services;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ReplayAttestationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task VerifyAsync_Fails_WhenEnvelopeHasNoSignatures()
|
||||
{
|
||||
var verifier = new AcceptAllVerifier();
|
||||
var service = new ReplayAttestationService(verifier: verifier, timeProvider: new FixedTimeProvider(DateTimeOffset.UnixEpoch));
|
||||
|
||||
var attestation = await service.GenerateAsync(
|
||||
new AuditBundleManifest
|
||||
{
|
||||
BundleId = "bundle-1",
|
||||
Name = "bundle",
|
||||
CreatedAt = DateTimeOffset.UnixEpoch,
|
||||
ScanId = "scan-1",
|
||||
ImageRef = "image",
|
||||
ImageDigest = "sha256:abc",
|
||||
MerkleRoot = "sha256:root",
|
||||
Inputs = new InputDigests
|
||||
{
|
||||
SbomDigest = "sha256:sbom",
|
||||
FeedsDigest = "sha256:feeds",
|
||||
PolicyDigest = "sha256:policy"
|
||||
},
|
||||
VerdictDigest = "sha256:verdict",
|
||||
Decision = "pass",
|
||||
Files = [],
|
||||
TotalSizeBytes = 0
|
||||
},
|
||||
new ReplayExecutionResult
|
||||
{
|
||||
Success = true,
|
||||
Status = ReplayStatus.Match,
|
||||
InputsVerified = true,
|
||||
VerdictMatches = true,
|
||||
DecisionMatches = true,
|
||||
OriginalVerdictDigest = "sha256:verdict",
|
||||
ReplayedVerdictDigest = "sha256:verdict",
|
||||
OriginalDecision = "pass",
|
||||
ReplayedDecision = "pass",
|
||||
Drifts = [],
|
||||
Errors = [],
|
||||
DurationMs = 0,
|
||||
EvaluatedAt = DateTimeOffset.UnixEpoch
|
||||
});
|
||||
|
||||
var result = await service.VerifyAsync(attestation);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("signatures", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_Succeeds_WithVerifierAndValidPayload()
|
||||
{
|
||||
var verifier = new AcceptAllVerifier();
|
||||
var service = new ReplayAttestationService(verifier: verifier);
|
||||
|
||||
var payload = CanonicalJson.Serialize(new InTotoStatement
|
||||
{
|
||||
Type = "https://in-toto.io/Statement/v1",
|
||||
Subject = [new InTotoSubject
|
||||
{
|
||||
Name = "verdict:bundle-1",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = "abc" }
|
||||
}],
|
||||
PredicateType = "https://stellaops.io/attestation/verdict-replay/v1",
|
||||
Predicate = new VerdictReplayAttestation
|
||||
{
|
||||
ManifestId = "bundle-1",
|
||||
ScanId = "scan-1",
|
||||
ImageRef = "image",
|
||||
ImageDigest = "sha256:abc",
|
||||
InputsDigest = "sha256:inputs",
|
||||
OriginalVerdictDigest = "sha256:verdict",
|
||||
OriginalDecision = "pass",
|
||||
Match = true,
|
||||
Status = "Match",
|
||||
DriftCount = 0,
|
||||
EvaluatedAt = DateTimeOffset.UnixEpoch,
|
||||
ReplayedAt = DateTimeOffset.UnixEpoch,
|
||||
DurationMs = 0
|
||||
}
|
||||
});
|
||||
|
||||
var attestation = new ReplayAttestation
|
||||
{
|
||||
AttestationId = "att-1",
|
||||
ManifestId = "bundle-1",
|
||||
CreatedAt = DateTimeOffset.UnixEpoch,
|
||||
Statement = JsonSerializer.Deserialize<InTotoStatement>(payload)!,
|
||||
StatementDigest = "sha256:" + Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(payload)).ToLowerInvariant(),
|
||||
Envelope = new ReplayDsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = Convert.ToBase64String(payload),
|
||||
Signatures = [new ReplayDsseSignature { KeyId = "key", Sig = "sig" }]
|
||||
},
|
||||
Match = true,
|
||||
ReplayStatus = "Match"
|
||||
};
|
||||
|
||||
var result = await service.VerifyAsync(attestation);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.True(result.SignatureVerified);
|
||||
}
|
||||
|
||||
private sealed class AcceptAllVerifier : IReplayAttestationSignatureVerifier
|
||||
{
|
||||
public Task<ReplayAttestationSignatureVerification> VerifyAsync(
|
||||
ReplayDsseEnvelope envelope,
|
||||
byte[] payload,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new ReplayAttestationSignatureVerification { Verified = true });
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using StellaOps.AuditPack.Models;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class ReplayExecutorTests
|
||||
{
|
||||
private static AuditBundleManifest CreateManifest(string verdictDigest, string decision)
|
||||
{
|
||||
return new AuditBundleManifest
|
||||
{
|
||||
BundleId = "bundle-1",
|
||||
Name = "bundle",
|
||||
CreatedAt = DateTimeOffset.UnixEpoch,
|
||||
ScanId = "scan-1",
|
||||
ImageRef = "image",
|
||||
ImageDigest = "sha256:abc",
|
||||
MerkleRoot = "sha256:root",
|
||||
Inputs = new InputDigests
|
||||
{
|
||||
SbomDigest = "sha256:sbom",
|
||||
FeedsDigest = "sha256:feeds",
|
||||
PolicyDigest = "sha256:policy"
|
||||
},
|
||||
VerdictDigest = verdictDigest,
|
||||
Decision = decision,
|
||||
Files = [],
|
||||
TotalSizeBytes = 0
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FakeReplayContext : IIsolatedReplayContext
|
||||
{
|
||||
private readonly InputDigestVerification _verification;
|
||||
private readonly Dictionary<ReplayInputType, string> _paths;
|
||||
|
||||
public FakeReplayContext(string workingDirectory, InputDigestVerification verification)
|
||||
{
|
||||
WorkingDirectory = workingDirectory;
|
||||
_verification = verification;
|
||||
_paths = new Dictionary<ReplayInputType, string>
|
||||
{
|
||||
[ReplayInputType.Sbom] = Path.Combine(workingDirectory, "sbom.json"),
|
||||
[ReplayInputType.Feeds] = Path.Combine(workingDirectory, "feeds.json"),
|
||||
[ReplayInputType.Policy] = Path.Combine(workingDirectory, "policy.json"),
|
||||
[ReplayInputType.Vex] = Path.Combine(workingDirectory, "vex.json"),
|
||||
[ReplayInputType.Verdict] = Path.Combine(workingDirectory, "verdict.json")
|
||||
};
|
||||
}
|
||||
|
||||
public bool IsInitialized { get; init; } = true;
|
||||
public DateTimeOffset EvaluationTime { get; init; } = DateTimeOffset.UnixEpoch;
|
||||
public string WorkingDirectory { get; }
|
||||
public byte[]? Sbom { get; init; }
|
||||
public byte[]? FeedsSnapshot { get; init; }
|
||||
public byte[]? PolicyBundle { get; init; }
|
||||
public byte[]? VexStatements { get; init; }
|
||||
public string? SbomDigest { get; init; }
|
||||
public string? FeedsDigest { get; init; }
|
||||
public string? PolicyDigest { get; init; }
|
||||
|
||||
public Task<ReplayContextInitResult> InitializeAsync(
|
||||
AuditBundleReadResult bundleResult,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new ReplayContextInitResult
|
||||
{
|
||||
Success = true,
|
||||
EvaluationTime = EvaluationTime
|
||||
});
|
||||
}
|
||||
|
||||
public InputDigestVerification VerifyInputDigests(InputDigests expected) => _verification;
|
||||
public string GetInputPath(ReplayInputType inputType) => _paths[inputType];
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedPolicyEvaluator(PolicyEvaluationResult result) : IPolicyEvaluator
|
||||
{
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public Task<PolicyEvaluationResult> EvaluateAsync(
|
||||
PolicyEvaluationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
CallCount++;
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class ReplayExecutorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ReturnsInputDrift_WhenFailOnInputDriftAsync()
|
||||
{
|
||||
var executor = new ReplayExecutor();
|
||||
var verification = new InputDigestVerification
|
||||
{
|
||||
AllMatch = false,
|
||||
Mismatches = [new DigestMismatch("sbom", "expected", "actual")]
|
||||
};
|
||||
var context = new FakeReplayContext(_tempDir, verification);
|
||||
var manifest = CreateManifest("sha256:verdict", "pass");
|
||||
var options = new ReplayExecutionOptions
|
||||
{
|
||||
FailOnInputDrift = true,
|
||||
DetailedDriftDetection = false
|
||||
};
|
||||
|
||||
var result = await executor.ExecuteAsync(context, manifest, options);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Status.Should().Be(ReplayStatus.InputDrift);
|
||||
result.Drifts.Should().ContainSingle(d => d.Type == DriftType.InputDigest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class ReplayExecutorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_FlagsVerdictDigestDrift_WhenVerdictChangesAsync()
|
||||
{
|
||||
var evaluationResult = new PolicyEvaluationResult
|
||||
{
|
||||
Success = true,
|
||||
Verdict = "replayed"u8.ToArray(),
|
||||
Decision = "pass"
|
||||
};
|
||||
var evaluator = new FixedPolicyEvaluator(evaluationResult);
|
||||
var executor = new ReplayExecutor(evaluator);
|
||||
var verification = new InputDigestVerification { AllMatch = true };
|
||||
var context = new FakeReplayContext(_tempDir, verification);
|
||||
var manifest = CreateManifest("sha256:original", "pass");
|
||||
var options = new ReplayExecutionOptions { DetailedDriftDetection = false };
|
||||
|
||||
var result = await executor.ExecuteAsync(context, manifest, options);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Status.Should().Be(ReplayStatus.Drift);
|
||||
result.VerdictMatches.Should().BeFalse();
|
||||
result.DecisionMatches.Should().BeTrue();
|
||||
result.Drifts.Should().ContainSingle(d => d.Type == DriftType.VerdictDigest);
|
||||
evaluator.CallCount.Should().Be(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed partial class ReplayExecutorTests : IDisposable
|
||||
{
|
||||
private static int _tempCounter;
|
||||
private readonly string _tempDir;
|
||||
|
||||
public ReplayExecutorTests()
|
||||
{
|
||||
var suffix = Interlocked.Increment(ref _tempCounter);
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"replay-executor-{suffix:0000}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,3 +12,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0076-T | DONE | Revalidated 2026-01-06. |
|
||||
| AUDIT-0076-A | DONE | Waived (test project; revalidated 2026-01-06). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-08 | DONE | Split tests <= 100 lines; deterministic fixtures/time/IDs; async naming; replay/attestation coverage expanded; ConfigureAwait(false) skipped per xUnit1030; dotnet test passed 2026-02-02 (46 tests). |
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.AuditPack.Models;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed class VerdictReplayPredicateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Evaluate_ReturnsIneligible_WhenVerdictDigestMissing()
|
||||
{
|
||||
var predicate = new VerdictReplayPredicate();
|
||||
var manifest = CreateManifest("", "pass");
|
||||
|
||||
var result = predicate.Evaluate(manifest);
|
||||
|
||||
result.IsEligible.Should().BeFalse();
|
||||
result.Reasons.Should().Contain(r => r.Contains("verdict digest", StringComparison.OrdinalIgnoreCase));
|
||||
result.Warnings.Should().Contain(r => r.Contains("time anchor", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PredictOutcome_ReportsDrift_WhenFeedsChange()
|
||||
{
|
||||
var predicate = new VerdictReplayPredicate();
|
||||
var manifest = CreateManifest("sha256:verdict", "pass");
|
||||
var currentState = new ReplayInputState { FeedsDigest = "sha256:feeds2" };
|
||||
|
||||
var prediction = predicate.PredictOutcome(manifest, currentState);
|
||||
|
||||
prediction.ExpectedStatus.Should().Be(ReplayStatus.Drift);
|
||||
prediction.ExpectedDriftTypes.Should().Contain(DriftType.VerdictField);
|
||||
prediction.ExpectedDriftTypes.Should().Contain(DriftType.Decision);
|
||||
}
|
||||
|
||||
private static AuditBundleManifest CreateManifest(string verdictDigest, string decision)
|
||||
{
|
||||
return new AuditBundleManifest
|
||||
{
|
||||
BundleId = "bundle-1",
|
||||
Name = "bundle",
|
||||
CreatedAt = DateTimeOffset.UnixEpoch,
|
||||
ScanId = "scan-1",
|
||||
ImageRef = "image",
|
||||
ImageDigest = "sha256:abc",
|
||||
MerkleRoot = "sha256:root",
|
||||
Inputs = new InputDigests
|
||||
{
|
||||
SbomDigest = "sha256:sbom",
|
||||
FeedsDigest = "sha256:feeds",
|
||||
PolicyDigest = "sha256:policy"
|
||||
},
|
||||
VerdictDigest = verdictDigest,
|
||||
Decision = decision,
|
||||
Files = [],
|
||||
TotalSizeBytes = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Security.Tests;
|
||||
|
||||
public sealed partial class DpopNonceStoreTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task InMemoryNonceStore_IssuesAndConsumesNonceAsync()
|
||||
{
|
||||
var timeProvider = CreateTimeProvider();
|
||||
var store = new InMemoryDpopNonceStore(timeProvider);
|
||||
|
||||
var issue = await store.IssueAsync(
|
||||
"audience",
|
||||
"client",
|
||||
"thumb",
|
||||
TimeSpan.FromMinutes(5),
|
||||
maxIssuancePerMinute: 5);
|
||||
|
||||
Assert.Equal(DpopNonceIssueStatus.Success, issue.Status);
|
||||
Assert.False(string.IsNullOrWhiteSpace(issue.Nonce));
|
||||
|
||||
var consume = await store.TryConsumeAsync(issue.Nonce!, "audience", "client", "thumb");
|
||||
|
||||
Assert.Equal(DpopNonceConsumeStatus.Success, consume.Status);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task InMemoryNonceStore_ReturnsExpiredAfterTtlAsync()
|
||||
{
|
||||
var timeProvider = CreateTimeProvider();
|
||||
var store = new InMemoryDpopNonceStore(timeProvider);
|
||||
|
||||
var issue = await store.IssueAsync(
|
||||
"audience",
|
||||
"client",
|
||||
"thumb",
|
||||
TimeSpan.FromMinutes(1),
|
||||
maxIssuancePerMinute: 5);
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(2));
|
||||
var consume = await store.TryConsumeAsync(issue.Nonce!, "audience", "client", "thumb");
|
||||
|
||||
Assert.Equal(DpopNonceConsumeStatus.Expired, consume.Status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
|
||||
namespace StellaOps.Auth.Security.Tests;
|
||||
|
||||
public sealed partial class DpopNonceStoreTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedUtcNow = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static FakeTimeProvider CreateTimeProvider() => new(FixedUtcNow);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
using System;
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
namespace StellaOps.Auth.Security.Tests;
|
||||
|
||||
public class DpopNonceUtilitiesTests
|
||||
@@ -11,7 +11,18 @@ public class DpopNonceUtilitiesTests
|
||||
public void ComputeStorageKey_NormalizesToLowerInvariant()
|
||||
{
|
||||
var key = DpopNonceUtilities.ComputeStorageKey("API", "Client-Id", "ThumbPrint");
|
||||
|
||||
Assert.Equal("dpop-nonce:api:client-id:thumbprint", key);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GenerateNonce_ReturnsUrlSafeValue()
|
||||
{
|
||||
var nonce = DpopNonceUtilities.GenerateNonce();
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(nonce));
|
||||
Assert.DoesNotContain("+", nonce, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("/", nonce, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("=", nonce, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Security.Tests;
|
||||
|
||||
public sealed partial class DpopProofValidatorTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Validate_ReturnsFailure_ForNonStringTypAsync()
|
||||
{
|
||||
var proof = BuildUnsignedToken(
|
||||
new { typ = 123, alg = "ES256" },
|
||||
new { htm = "GET", htu = DefaultUri.ToString(), iat = 0, jti = "1" });
|
||||
|
||||
var validator = CreateValidator(FixedUtcNow);
|
||||
var result = await validator.ValidateAsync(proof, "GET", DefaultUri);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_header", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Validate_ReturnsFailure_ForNonStringAlgAsync()
|
||||
{
|
||||
var proof = BuildUnsignedToken(
|
||||
new { typ = "dpop+jwt", alg = 55 },
|
||||
new { htm = "GET", htu = DefaultUri.ToString(), iat = 0, jti = "1" });
|
||||
|
||||
var validator = CreateValidator(FixedUtcNow);
|
||||
var result = await validator.ValidateAsync(proof, "GET", DefaultUri);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_header", result.ErrorCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Security.Tests;
|
||||
|
||||
public sealed partial class DpopProofValidatorTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Validate_UsesSnapshotOfOptionsAsync()
|
||||
{
|
||||
var (proof, _) = CreateSignedProof(FixedUtcNow);
|
||||
|
||||
var options = new DpopValidationOptions();
|
||||
var timeProvider = new FakeTimeProvider(FixedUtcNow);
|
||||
var validator = new DpopProofValidator(Options.Create(options), new InMemoryDpopReplayCache(timeProvider), timeProvider);
|
||||
|
||||
options.AllowedAlgorithms.Clear();
|
||||
options.AllowedAlgorithms.Add("ES512");
|
||||
|
||||
var result = await validator.ValidateAsync(proof, "GET", DefaultUri);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Security.Tests;
|
||||
|
||||
public sealed partial class DpopProofValidatorTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Validate_ReturnsFailure_ForNonStringHtmAsync()
|
||||
{
|
||||
var (proof, _) = CreateSignedProof(FixedUtcNow, payloadMutator: payload => payload["htm"] = 123);
|
||||
|
||||
var validator = CreateValidator(FixedUtcNow);
|
||||
var result = await validator.ValidateAsync(proof, "GET", DefaultUri);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_payload", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Validate_ReturnsFailure_ForNonStringHtuAsync()
|
||||
{
|
||||
var (proof, _) = CreateSignedProof(FixedUtcNow, payloadMutator: payload => payload["htu"] = 123);
|
||||
|
||||
var validator = CreateValidator(FixedUtcNow);
|
||||
var result = await validator.ValidateAsync(proof, "GET", DefaultUri);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_payload", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Validate_ReturnsFailure_ForNonStringNonceAsync()
|
||||
{
|
||||
var (proof, _) = CreateSignedProof(FixedUtcNow, payloadMutator: payload => payload["nonce"] = 999);
|
||||
|
||||
var validator = CreateValidator(FixedUtcNow);
|
||||
var result = await validator.ValidateAsync(proof, "GET", DefaultUri, nonce: "nonce-1");
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_token", result.ErrorCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Security.Tests;
|
||||
|
||||
public sealed partial class DpopProofValidatorTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Validate_RejectsReplayTokensAsync()
|
||||
{
|
||||
var jwtId = "jwt-1";
|
||||
var (proof, _) = CreateSignedProof(FixedUtcNow, jti: jwtId);
|
||||
|
||||
var timeProvider = new FakeTimeProvider(FixedUtcNow);
|
||||
var replayCache = new InMemoryDpopReplayCache(timeProvider);
|
||||
var validator = CreateValidator(timeProvider, replayCache);
|
||||
|
||||
var first = await validator.ValidateAsync(proof, "GET", DefaultUri);
|
||||
var second = await validator.ValidateAsync(proof, "GET", DefaultUri);
|
||||
|
||||
Assert.True(first.IsValid);
|
||||
Assert.False(second.IsValid);
|
||||
Assert.Equal("replay", second.ErrorCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Security.Tests;
|
||||
|
||||
public sealed partial class DpopProofValidatorTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Validate_RejectsProofIssuedInFutureAsync()
|
||||
{
|
||||
var issuedAt = FixedUtcNow.AddMinutes(2);
|
||||
var (proof, _) = CreateSignedProof(issuedAt);
|
||||
|
||||
var validator = CreateValidator(FixedUtcNow, options => options.AllowedClockSkew = TimeSpan.FromSeconds(5));
|
||||
var result = await validator.ValidateAsync(proof, "GET", DefaultUri);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_token", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Validate_RejectsExpiredProofsAsync()
|
||||
{
|
||||
var issuedAt = FixedUtcNow.AddMinutes(-10);
|
||||
var (proof, _) = CreateSignedProof(issuedAt);
|
||||
|
||||
var validator = CreateValidator(FixedUtcNow, options =>
|
||||
{
|
||||
options.ProofLifetime = TimeSpan.FromMinutes(1);
|
||||
options.AllowedClockSkew = TimeSpan.FromSeconds(5);
|
||||
});
|
||||
|
||||
var result = await validator.ValidateAsync(proof, "GET", DefaultUri);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_token", result.ErrorCode);
|
||||
}
|
||||
}
|
||||
@@ -8,160 +8,15 @@ using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Auth.Security.Tests;
|
||||
|
||||
public class DpopProofValidatorTests
|
||||
public sealed partial class DpopProofValidatorTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReturnsFailure_ForNonStringTyp()
|
||||
{
|
||||
var proof = BuildUnsignedToken(
|
||||
new { typ = 123, alg = "ES256" },
|
||||
new { htm = "GET", htu = "https://api.test/resource", iat = 0, jti = "1" });
|
||||
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var validator = CreateValidator(now);
|
||||
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_header", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReturnsFailure_ForNonStringAlg()
|
||||
{
|
||||
var proof = BuildUnsignedToken(
|
||||
new { typ = "dpop+jwt", alg = 55 },
|
||||
new { htm = "GET", htu = "https://api.test/resource", iat = 0, jti = "1" });
|
||||
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var validator = CreateValidator(now);
|
||||
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_header", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReturnsFailure_ForNonStringHtm()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var (proof, _) = CreateSignedProof(now, payloadMutator: payload => payload["htm"] = 123);
|
||||
|
||||
var validator = CreateValidator(now);
|
||||
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_payload", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReturnsFailure_ForNonStringHtu()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var (proof, _) = CreateSignedProof(now, payloadMutator: payload => payload["htu"] = 123);
|
||||
|
||||
var validator = CreateValidator(now);
|
||||
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_payload", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReturnsFailure_ForNonStringNonce()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var (proof, _) = CreateSignedProof(now, payloadMutator: payload => payload["nonce"] = 999);
|
||||
|
||||
var validator = CreateValidator(now);
|
||||
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"), nonce: "nonce-1");
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_token", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_RejectsProofIssuedInFuture()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var issuedAt = now.AddMinutes(2);
|
||||
var (proof, _) = CreateSignedProof(issuedAt);
|
||||
|
||||
var validator = CreateValidator(now, options => options.AllowedClockSkew = TimeSpan.FromSeconds(5));
|
||||
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_token", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_RejectsExpiredProofs()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var issuedAt = now.AddMinutes(-10);
|
||||
var (proof, _) = CreateSignedProof(issuedAt);
|
||||
|
||||
var validator = CreateValidator(now, options =>
|
||||
{
|
||||
options.ProofLifetime = TimeSpan.FromMinutes(1);
|
||||
options.AllowedClockSkew = TimeSpan.FromSeconds(5);
|
||||
});
|
||||
|
||||
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_token", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_RejectsReplayTokens()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var jwtId = "jwt-1";
|
||||
var (proof, _) = CreateSignedProof(now, jti: jwtId);
|
||||
|
||||
var timeProvider = new FakeTimeProvider(now);
|
||||
var replayCache = new InMemoryDpopReplayCache(timeProvider);
|
||||
var validator = CreateValidator(timeProvider, replayCache);
|
||||
|
||||
var first = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
var second = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
|
||||
Assert.True(first.IsValid);
|
||||
Assert.False(second.IsValid);
|
||||
Assert.Equal("replay", second.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_UsesSnapshotOfOptions()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var (proof, _) = CreateSignedProof(now);
|
||||
|
||||
var options = new DpopValidationOptions();
|
||||
var timeProvider = new FakeTimeProvider(now);
|
||||
var validator = new DpopProofValidator(Options.Create(options), new InMemoryDpopReplayCache(timeProvider), timeProvider);
|
||||
|
||||
options.AllowedAlgorithms.Clear();
|
||||
options.AllowedAlgorithms.Add("ES512");
|
||||
|
||||
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
private static readonly DateTimeOffset FixedUtcNow = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private static readonly Uri DefaultUri = new("https://api.test/resource");
|
||||
private const string DefaultKeyId = "test-key-001";
|
||||
private const string DefaultJti = "jwt-0001";
|
||||
|
||||
private static DpopProofValidator CreateValidator(DateTimeOffset now, Action<DpopValidationOptions>? configure = null)
|
||||
{
|
||||
@@ -169,7 +24,10 @@ public class DpopProofValidatorTests
|
||||
return CreateValidator(timeProvider, null, configure);
|
||||
}
|
||||
|
||||
private static DpopProofValidator CreateValidator(TimeProvider timeProvider, IDpopReplayCache? replayCache = null, Action<DpopValidationOptions>? configure = null)
|
||||
private static DpopProofValidator CreateValidator(
|
||||
TimeProvider timeProvider,
|
||||
IDpopReplayCache? replayCache = null,
|
||||
Action<DpopValidationOptions>? configure = null)
|
||||
{
|
||||
var options = new DpopValidationOptions();
|
||||
configure?.Invoke(options);
|
||||
@@ -185,10 +43,10 @@ public class DpopProofValidatorTests
|
||||
Action<JwtHeader>? headerMutator = null,
|
||||
Action<JwtPayload>? payloadMutator = null)
|
||||
{
|
||||
httpUri ??= new Uri("https://api.test/resource");
|
||||
httpUri ??= DefaultUri;
|
||||
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var securityKey = new ECDsaSecurityKey(ecdsa) { KeyId = Guid.NewGuid().ToString("N") };
|
||||
var securityKey = new ECDsaSecurityKey(ecdsa) { KeyId = DefaultKeyId };
|
||||
var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(securityKey);
|
||||
|
||||
var jwkHeader = new Dictionary<string, object>
|
||||
@@ -215,7 +73,7 @@ public class DpopProofValidatorTests
|
||||
{ "htm", method ?? "GET" },
|
||||
{ "htu", httpUri.ToString() },
|
||||
{ "iat", issuedAt.ToUnixTimeSeconds() },
|
||||
{ "jti", jti ?? Guid.NewGuid().ToString("N") }
|
||||
{ "jti", jti ?? DefaultJti }
|
||||
};
|
||||
|
||||
if (nonce is not null)
|
||||
@@ -230,7 +88,6 @@ public class DpopProofValidatorTests
|
||||
return (handler.WriteToken(token), jwk);
|
||||
}
|
||||
|
||||
|
||||
private static string BuildUnsignedToken(object header, object payload)
|
||||
{
|
||||
var headerJson = JsonSerializer.Serialize(header);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Security.Tests;
|
||||
|
||||
public sealed partial class DpopReplayCacheTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task InMemoryReplayCache_RejectsDuplicatesUntilExpiryAsync()
|
||||
{
|
||||
var timeProvider = CreateTimeProvider();
|
||||
var cache = new InMemoryDpopReplayCache(timeProvider);
|
||||
var expiresAt = timeProvider.GetUtcNow().AddMinutes(1);
|
||||
|
||||
Assert.True(await cache.TryStoreAsync("jti-1", expiresAt));
|
||||
Assert.False(await cache.TryStoreAsync("jti-1", expiresAt));
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(2));
|
||||
Assert.True(await cache.TryStoreAsync("jti-1", timeProvider.GetUtcNow().AddMinutes(1)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Security.Tests;
|
||||
|
||||
public sealed partial class DpopReplayCacheTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task MessagingReplayCache_RejectsDuplicatesUntilExpiryAsync()
|
||||
{
|
||||
var timeProvider = CreateTimeProvider();
|
||||
var factory = new FakeIdempotencyStoreFactory(timeProvider);
|
||||
var cache = new MessagingDpopReplayCache(factory, timeProvider);
|
||||
var expiresAt = timeProvider.GetUtcNow().AddMinutes(1);
|
||||
|
||||
Assert.True(await cache.TryStoreAsync("jti-1", expiresAt));
|
||||
Assert.False(await cache.TryStoreAsync("jti-1", expiresAt));
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(2));
|
||||
Assert.True(await cache.TryStoreAsync("jti-1", timeProvider.GetUtcNow().AddMinutes(1)));
|
||||
}
|
||||
}
|
||||
@@ -1,110 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Auth.Security.Tests;
|
||||
|
||||
public class DpopReplayCacheTests
|
||||
public sealed partial class DpopReplayCacheTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task InMemoryReplayCache_RejectsDuplicatesUntilExpiry()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
|
||||
var cache = new InMemoryDpopReplayCache(timeProvider);
|
||||
private static readonly DateTimeOffset FixedUtcNow = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
var expiresAt = timeProvider.GetUtcNow().AddMinutes(1);
|
||||
|
||||
Assert.True(await cache.TryStoreAsync("jti-1", expiresAt));
|
||||
Assert.False(await cache.TryStoreAsync("jti-1", expiresAt));
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(2));
|
||||
Assert.True(await cache.TryStoreAsync("jti-1", timeProvider.GetUtcNow().AddMinutes(1)));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task MessagingReplayCache_RejectsDuplicatesUntilExpiry()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
|
||||
var factory = new FakeIdempotencyStoreFactory(timeProvider);
|
||||
var cache = new MessagingDpopReplayCache(factory, timeProvider);
|
||||
|
||||
var expiresAt = timeProvider.GetUtcNow().AddMinutes(1);
|
||||
|
||||
Assert.True(await cache.TryStoreAsync("jti-1", expiresAt));
|
||||
Assert.False(await cache.TryStoreAsync("jti-1", expiresAt));
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(2));
|
||||
Assert.True(await cache.TryStoreAsync("jti-1", timeProvider.GetUtcNow().AddMinutes(1)));
|
||||
}
|
||||
|
||||
private sealed class FakeIdempotencyStoreFactory : IIdempotencyStoreFactory
|
||||
{
|
||||
private readonly FakeIdempotencyStore store;
|
||||
|
||||
public FakeIdempotencyStoreFactory(TimeProvider timeProvider)
|
||||
{
|
||||
store = new FakeIdempotencyStore(timeProvider);
|
||||
}
|
||||
|
||||
public string ProviderName => "fake";
|
||||
|
||||
public IIdempotencyStore Create(string name) => store;
|
||||
}
|
||||
|
||||
private sealed class FakeIdempotencyStore : IIdempotencyStore
|
||||
{
|
||||
private readonly Dictionary<string, Entry> entries = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
public FakeIdempotencyStore(TimeProvider timeProvider)
|
||||
{
|
||||
this.timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public string ProviderName => "fake";
|
||||
|
||||
public ValueTask<IdempotencyResult> TryClaimAsync(string key, string value, TimeSpan window, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
if (entries.TryGetValue(key, out var entry) && entry.ExpiresAt > now)
|
||||
{
|
||||
return ValueTask.FromResult(IdempotencyResult.Duplicate(entry.Value));
|
||||
}
|
||||
|
||||
entries[key] = new Entry(value, now.Add(window));
|
||||
return ValueTask.FromResult(IdempotencyResult.Claimed());
|
||||
}
|
||||
|
||||
public ValueTask<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult(entries.TryGetValue(key, out var entry) && entry.ExpiresAt > timeProvider.GetUtcNow());
|
||||
|
||||
public ValueTask<string?> GetAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult(entries.TryGetValue(key, out var entry) && entry.ExpiresAt > timeProvider.GetUtcNow() ? entry.Value : null);
|
||||
|
||||
public ValueTask<bool> ReleaseAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult(entries.Remove(key));
|
||||
|
||||
public ValueTask<bool> ExtendAsync(string key, TimeSpan extension, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!entries.TryGetValue(key, out var entry))
|
||||
{
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
entries[key] = entry with { ExpiresAt = entry.ExpiresAt.Add(extension) };
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
private readonly record struct Entry(string Value, DateTimeOffset ExpiresAt);
|
||||
}
|
||||
private static FakeTimeProvider CreateTimeProvider() => new(FixedUtcNow);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Auth.Security.Tests;
|
||||
|
||||
internal sealed class FakeIdempotencyStore : IIdempotencyStore
|
||||
{
|
||||
private readonly Dictionary<string, Entry> _entries = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public FakeIdempotencyStore(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public string ProviderName => "fake";
|
||||
|
||||
public ValueTask<IdempotencyResult> TryClaimAsync(
|
||||
string key,
|
||||
string value,
|
||||
TimeSpan window,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
if (_entries.TryGetValue(key, out var entry) && entry.ExpiresAt > now)
|
||||
{
|
||||
return ValueTask.FromResult(IdempotencyResult.Duplicate(entry.Value));
|
||||
}
|
||||
|
||||
_entries[key] = new Entry(value, now.Add(window));
|
||||
return ValueTask.FromResult(IdempotencyResult.Claimed());
|
||||
}
|
||||
|
||||
public ValueTask<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult(_entries.TryGetValue(key, out var entry) && entry.ExpiresAt > _timeProvider.GetUtcNow());
|
||||
|
||||
public ValueTask<string?> GetAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult(_entries.TryGetValue(key, out var entry) && entry.ExpiresAt > _timeProvider.GetUtcNow() ? entry.Value : null);
|
||||
|
||||
public ValueTask<bool> ReleaseAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult(_entries.Remove(key));
|
||||
|
||||
public ValueTask<bool> ExtendAsync(string key, TimeSpan extension, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_entries.TryGetValue(key, out var entry))
|
||||
{
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
_entries[key] = entry with { ExpiresAt = entry.ExpiresAt.Add(extension) };
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
private readonly record struct Entry(string Value, DateTimeOffset ExpiresAt);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Auth.Security.Tests;
|
||||
|
||||
internal sealed class FakeIdempotencyStoreFactory : IIdempotencyStoreFactory
|
||||
{
|
||||
private readonly FakeIdempotencyStore _store;
|
||||
|
||||
public FakeIdempotencyStoreFactory(TimeProvider timeProvider)
|
||||
{
|
||||
_store = new FakeIdempotencyStore(timeProvider);
|
||||
}
|
||||
|
||||
public string ProviderName => "fake";
|
||||
|
||||
public IIdempotencyStore Create(string name) => _store;
|
||||
}
|
||||
@@ -12,3 +12,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0785-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0785-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-08 | DONE | Split tests <= 100 lines; deterministic time/IDs; async naming; helper types separated; ConfigureAwait(false) skipped per xUnit1030; dotnet test passed 2026-02-02 (12 tests). |
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Collections;
|
||||
|
||||
namespace StellaOps.Canonicalization.Tests;
|
||||
|
||||
internal sealed class TimeWrapper
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class NullKeyDictionary : IDictionary<string, int>
|
||||
{
|
||||
private readonly List<KeyValuePair<string, int>> _items =
|
||||
[
|
||||
new(null!, 1),
|
||||
new("b", 2)
|
||||
];
|
||||
|
||||
public IEnumerator<KeyValuePair<string, int>> GetEnumerator() => _items.GetEnumerator();
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
public void Add(KeyValuePair<string, int> item) => throw new NotSupportedException();
|
||||
public void Clear() => throw new NotSupportedException();
|
||||
public bool Contains(KeyValuePair<string, int> item) => false;
|
||||
public void CopyTo(KeyValuePair<string, int>[] array, int arrayIndex) => throw new NotSupportedException();
|
||||
public bool Remove(KeyValuePair<string, int> item) => throw new NotSupportedException();
|
||||
public int Count => _items.Count;
|
||||
public bool IsReadOnly => true;
|
||||
public void Add(string key, int value) => throw new NotSupportedException();
|
||||
public bool ContainsKey(string key) => _items.Any(i => i.Key == key);
|
||||
public bool Remove(string key) => throw new NotSupportedException();
|
||||
public bool TryGetValue(string key, out int value)
|
||||
{
|
||||
var found = _items.FirstOrDefault(i => i.Key == key);
|
||||
value = found.Value;
|
||||
return found.Key is not null;
|
||||
}
|
||||
public int this[string key]
|
||||
{
|
||||
get => _items.First(i => i.Key == key).Value;
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
public ICollection<string> Keys => _items.Select(i => i.Key!).ToList();
|
||||
public ICollection<int> Values => _items.Select(i => i.Value).ToList();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user