stabilizaiton work - projects rework for maintenanceability and ui livening

This commit is contained in:
master
2026-02-03 23:40:04 +02:00
parent 074ce117ba
commit 557feefdc3
3305 changed files with 186813 additions and 107843 deletions

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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");
}
}

View File

@@ -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);
}
}

View File

@@ -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"
};
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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"
};
}

View File

@@ -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");
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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
};
}

View File

@@ -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");
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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");
}
}

View File

@@ -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}"
};

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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
};
}
}

View File

@@ -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");
}
}

View File

@@ -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();
}
}

View File

@@ -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>();
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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>();
}
}

View File

@@ -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). |

View File

@@ -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>

View File

@@ -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!;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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!);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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
}));
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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
}));
}
}

View File

@@ -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");
}
}

View File

@@ -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();
}
}

View File

@@ -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))
});
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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");
}
}

View File

@@ -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())
});
}
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.AuditPack.Tests;
public sealed partial class AuditPackImporterTests
{
private sealed record ArchivePayload(string Path, byte[] Content);
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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
}));
}
}

View File

@@ -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");
}
}

View File

@@ -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();
}
}

View File

@@ -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
};
}
}

View File

@@ -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"));
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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). |

View File

@@ -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
};
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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)));
}
}

View File

@@ -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)));
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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). |

View File

@@ -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