save development progress

This commit is contained in:
StellaOps Bot
2025-12-25 23:09:58 +02:00
parent d71853ad7e
commit aa70af062e
351 changed files with 37683 additions and 150156 deletions

View File

@@ -0,0 +1,361 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2025 StellaOps Contributors
using FluentAssertions;
using StellaOps.Provcache.Api;
using System.Text.Json;
using Xunit;
namespace StellaOps.Provcache.Tests;
/// <summary>
/// Contract tests verifying the structure and serialization of new API response fields.
/// These tests ensure the API contracts remain stable across versions.
/// </summary>
public sealed class ApiContractTests
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
};
#region CacheSource Contract Tests
[Theory]
[InlineData("none")]
[InlineData("inMemory")]
[InlineData("redis")]
public void CacheSource_AllValues_SerializeCorrectly(string expectedValue)
{
// CacheSource enum values should serialize to their lowercase/camelCase string equivalents
// This ensures compatibility with the OpenAPI spec and frontend models
var response = new
{
cacheSource = expectedValue,
cacheHit = expectedValue != "none"
};
var json = JsonSerializer.Serialize(response, JsonOptions);
json.Should().Contain($"\"cacheSource\":\"{expectedValue}\"");
}
#endregion
#region TrustScoreBreakdown Contract Tests
[Fact]
public void TrustScoreBreakdown_DefaultWeights_SumToOne()
{
// Verify the standard weights sum to 1.0 (100%)
var breakdown = TrustScoreBreakdown.CreateDefault();
var totalWeight = breakdown.Reachability.Weight +
breakdown.SbomCompleteness.Weight +
breakdown.VexCoverage.Weight +
breakdown.PolicyFreshness.Weight +
breakdown.SignerTrust.Weight;
totalWeight.Should().Be(1.00m, "standard weights must sum to 100%");
}
[Fact]
public void TrustScoreBreakdown_StandardWeights_MatchDocumentation()
{
// Verify weights match the documented percentages
// Reachability: 25%, SBOM: 20%, VEX: 20%, Policy: 15%, Signer: 20%
var breakdown = TrustScoreBreakdown.CreateDefault();
breakdown.Reachability.Weight.Should().Be(0.25m, "Reachability weight should be 25%");
breakdown.SbomCompleteness.Weight.Should().Be(0.20m, "SBOM completeness weight should be 20%");
breakdown.VexCoverage.Weight.Should().Be(0.20m, "VEX coverage weight should be 20%");
breakdown.PolicyFreshness.Weight.Should().Be(0.15m, "Policy freshness weight should be 15%");
breakdown.SignerTrust.Weight.Should().Be(0.20m, "Signer trust weight should be 20%");
}
[Fact]
public void TrustScoreBreakdown_ComputeTotal_ReturnsCorrectWeightedSum()
{
// Given all scores at 100, total should be 100
var breakdown = TrustScoreBreakdown.CreateDefault(
reachabilityScore: 100,
sbomScore: 100,
vexScore: 100,
policyScore: 100,
signerScore: 100);
breakdown.ComputeTotal().Should().Be(100);
}
[Fact]
public void TrustScoreBreakdown_ComputeTotal_WithZeroScores_ReturnsZero()
{
var breakdown = TrustScoreBreakdown.CreateDefault();
breakdown.ComputeTotal().Should().Be(0);
}
[Fact]
public void TrustScoreBreakdown_ComputeTotal_WithMixedScores_ComputesCorrectly()
{
// Specific test case:
// Reachability: 80 * 0.25 = 20
// SBOM: 90 * 0.20 = 18
// VEX: 70 * 0.20 = 14
// Policy: 100 * 0.15 = 15
// Signer: 60 * 0.20 = 12
// Total: 79
var breakdown = TrustScoreBreakdown.CreateDefault(
reachabilityScore: 80,
sbomScore: 90,
vexScore: 70,
policyScore: 100,
signerScore: 60);
breakdown.ComputeTotal().Should().Be(79);
}
[Fact]
public void TrustScoreBreakdown_Serialization_IncludesAllComponents()
{
var breakdown = TrustScoreBreakdown.CreateDefault(50, 60, 70, 80, 90);
var json = JsonSerializer.Serialize(breakdown, JsonOptions);
json.Should().Contain("\"reachability\":");
json.Should().Contain("\"sbomCompleteness\":");
json.Should().Contain("\"vexCoverage\":");
json.Should().Contain("\"policyFreshness\":");
json.Should().Contain("\"signerTrust\":");
}
[Fact]
public void TrustScoreComponent_Contribution_CalculatesCorrectly()
{
var component = new TrustScoreComponent { Score = 80, Weight = 0.25m };
component.Contribution.Should().Be(20m, "80 * 0.25 = 20");
}
#endregion
#region DecisionDigest Contract Tests
[Fact]
public void DecisionDigest_TrustScoreBreakdown_IsOptional()
{
// DecisionDigest should serialize correctly without TrustScoreBreakdown
// The field is nullable and can be omitted when not applicable
var digest = new DecisionDigest
{
DigestVersion = "v1",
VeriKey = "sha256:abc",
VerdictHash = "sha256:def",
ProofRoot = "sha256:ghi",
ReplaySeed = new ReplaySeed { FeedIds = [], RuleIds = [] },
CreatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
TrustScore = 85,
TrustScoreBreakdown = null
};
var json = JsonSerializer.Serialize(digest, JsonOptions);
// The JSON should serialize successfully, proving the field is optional
json.Should().Contain("\"trustScore\":85");
// trustScoreBreakdown being null is a valid state
digest.TrustScoreBreakdown.Should().BeNull("TrustScoreBreakdown should be optional");
}
[Fact]
public void DecisionDigest_WithBreakdown_SerializesCorrectly()
{
var digest = new DecisionDigest
{
DigestVersion = "v1",
VeriKey = "sha256:abc",
VerdictHash = "sha256:def",
ProofRoot = "sha256:ghi",
ReplaySeed = new ReplaySeed { FeedIds = [], RuleIds = [] },
CreatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
TrustScore = 79,
TrustScoreBreakdown = TrustScoreBreakdown.CreateDefault(80, 90, 70, 100, 60)
};
var json = JsonSerializer.Serialize(digest, JsonOptions);
json.Should().Contain("\"trustScoreBreakdown\":");
json.Should().Contain("\"reachability\":");
}
#endregion
#region InputManifest Contract Tests
[Fact]
public void InputManifestResponse_RequiredFields_NotNull()
{
var manifest = new InputManifestResponse
{
VeriKey = "sha256:test",
SourceArtifact = new SourceArtifactInfo { Digest = "sha256:image" },
Sbom = new SbomInfoDto { Hash = "sha256:sbom" },
Vex = new VexInfoDto { HashSetHash = "sha256:vex" },
Policy = new PolicyInfoDto { Hash = "sha256:policy" },
Signers = new SignerInfoDto { SetHash = "sha256:signers" },
TimeWindow = new TimeWindowInfoDto { Bucket = "2024-12-24" },
GeneratedAt = DateTimeOffset.UtcNow,
};
manifest.VeriKey.Should().NotBeNull();
manifest.SourceArtifact.Should().NotBeNull();
manifest.Sbom.Should().NotBeNull();
manifest.Vex.Should().NotBeNull();
manifest.Policy.Should().NotBeNull();
manifest.Signers.Should().NotBeNull();
manifest.TimeWindow.Should().NotBeNull();
}
[Fact]
public void InputManifestResponse_Serialization_IncludesAllComponents()
{
var manifest = new InputManifestResponse
{
VeriKey = "sha256:test",
SourceArtifact = new SourceArtifactInfo { Digest = "sha256:image" },
Sbom = new SbomInfoDto { Hash = "sha256:sbom" },
Vex = new VexInfoDto { HashSetHash = "sha256:vex" },
Policy = new PolicyInfoDto { Hash = "sha256:policy" },
Signers = new SignerInfoDto { SetHash = "sha256:signers" },
TimeWindow = new TimeWindowInfoDto { Bucket = "2024-12-24" },
GeneratedAt = DateTimeOffset.UtcNow,
};
var json = JsonSerializer.Serialize(manifest, JsonOptions);
json.Should().Contain("\"veriKey\":");
json.Should().Contain("\"sourceArtifact\":");
json.Should().Contain("\"sbom\":");
json.Should().Contain("\"vex\":");
json.Should().Contain("\"policy\":");
json.Should().Contain("\"signers\":");
json.Should().Contain("\"timeWindow\":");
json.Should().Contain("\"generatedAt\":");
}
[Fact]
public void SbomInfoDto_OptionalFields_CanBeNull()
{
var sbom = new SbomInfoDto
{
Hash = "sha256:required",
Format = null,
PackageCount = null,
CompletenessScore = null
};
var json = JsonSerializer.Serialize(sbom, JsonOptions);
json.Should().Contain("\"hash\":");
// Optional fields should not be serialized as null (default JsonSerializer behavior with ignore defaults)
}
[Fact]
public void VexInfoDto_Sources_CanBeEmpty()
{
var vex = new VexInfoDto
{
HashSetHash = "sha256:test",
StatementCount = 0,
Sources = []
};
vex.Sources.Should().BeEmpty();
vex.StatementCount.Should().Be(0);
}
[Fact]
public void PolicyInfoDto_OptionalFields_PreserveValues()
{
var policy = new PolicyInfoDto
{
Hash = "sha256:policy",
PackId = "org-policy-v2",
Version = 5,
Name = "Organization Security Policy"
};
policy.Hash.Should().Be("sha256:policy");
policy.PackId.Should().Be("org-policy-v2");
policy.Version.Should().Be(5);
policy.Name.Should().Be("Organization Security Policy");
}
[Fact]
public void SignerInfoDto_Certificates_CanBeNull()
{
var signers = new SignerInfoDto
{
SetHash = "sha256:signers",
SignerCount = 0,
Certificates = null
};
signers.Certificates.Should().BeNull();
}
[Fact]
public void SignerCertificateDto_AllFields_AreOptional()
{
var cert = new SignerCertificateDto
{
Subject = null,
Issuer = null,
ExpiresAt = null
};
// Should not throw - all fields are optional
var json = JsonSerializer.Serialize(cert, JsonOptions);
json.Should().NotBeNullOrEmpty();
}
[Fact]
public void TimeWindowInfoDto_Bucket_IsRequired()
{
var timeWindow = new TimeWindowInfoDto
{
Bucket = "2024-W52",
StartsAt = DateTimeOffset.Parse("2024-12-23T00:00:00Z"),
EndsAt = DateTimeOffset.Parse("2024-12-30T00:00:00Z")
};
timeWindow.Bucket.Should().NotBeNullOrEmpty();
}
#endregion
#region API Response Backwards Compatibility
[Fact]
public void ProvcacheGetResponse_Status_ValidValues()
{
// Verify status field uses expected values
var statuses = new[] { "hit", "miss", "bypassed", "expired" };
foreach (var status in statuses)
{
var response = new ProvcacheGetResponse
{
VeriKey = "sha256:test",
Status = status,
ElapsedMs = 1.0
};
response.Status.Should().Be(status);
}
}
#endregion
}

View File

@@ -40,6 +40,8 @@ public sealed class EvidenceApiTests : IAsyncLifetime
services.AddSingleton(_mockChunker.Object);
// Add mock IProvcacheService to satisfy the main endpoints
services.AddSingleton(Mock.Of<IProvcacheService>());
// Add TimeProvider for InputManifest endpoint
services.AddSingleton(TimeProvider.System);
})
.Configure(app =>
{

View File

@@ -0,0 +1,574 @@
// ----------------------------------------------------------------------------
// Copyright (c) 2025 StellaOps contributors. All rights reserved.
// SPDX-License-Identifier: AGPL-3.0-or-later
// ----------------------------------------------------------------------------
using System.Text.Json;
using FluentAssertions;
using StellaOps.Provcache.Oci;
using Xunit;
namespace StellaOps.Provcache.Tests.Oci;
/// <summary>
/// Tests for <see cref="ProvcacheOciAttestationBuilder"/>.
/// Includes cosign verify-attestation compatibility tests.
/// </summary>
public sealed class ProvcacheOciAttestationBuilderTests
{
private readonly ProvcacheOciAttestationBuilder _sut = new();
private static DecisionDigest CreateTestDigest() => new()
{
DigestVersion = "v1",
VeriKey = "sha256:abc123def456789",
VerdictHash = "sha256:verdict123",
ProofRoot = "sha256:proof456",
TrustScore = 85,
ReplaySeed = new ReplaySeed
{
FeedIds = ["cve-2024", "ghsa-2024"],
RuleIds = ["default-policy-v2"],
FrozenEpoch = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)
},
CreatedAt = new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero),
ExpiresAt = new DateTimeOffset(2025, 1, 2, 12, 0, 0, TimeSpan.Zero),
TrustScoreBreakdown = TrustScoreBreakdown.CreateDefault(80, 90, 85, 75, 95)
};
private static InputManifest CreateTestManifest() => new()
{
VeriKey = "sha256:abc123def456789",
SourceArtifact = new SourceArtifactInfo
{
Digest = "sha256:source123",
ArtifactType = "container-image",
OciReference = "ghcr.io/stellaops/test:latest",
SizeBytes = 1024 * 1024
},
Sbom = new SbomInfo
{
Hash = "sha256:sbom123",
Format = "cyclonedx",
Version = "1.6",
PackageCount = 42,
CompletenessScore = 95
},
Vex = new VexInfo
{
SetHash = "sha256:vex123",
StatementCount = 5,
Sources = ["vendor", "osv"]
},
Policy = new PolicyInfo
{
Hash = "sha256:policy123",
Name = "default-policy",
PackId = "stellaops-base",
Version = "v2.0"
},
Signers = new SignerInfo
{
SetHash = "sha256:signers123",
SignerCount = 2,
Certificates = []
},
TimeWindow = new TimeWindowInfo
{
Bucket = "2025-01-01",
StartTime = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
EndTime = new DateTimeOffset(2025, 1, 2, 0, 0, 0, TimeSpan.Zero)
}
};
[Fact]
public void Build_ValidRequest_ReturnsValidResult()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest(),
InputManifest = CreateTestManifest()
};
// Act
var result = _sut.Build(request);
// Assert
result.Should().NotBeNull();
result.Statement.Should().NotBeNull();
result.StatementJson.Should().NotBeNullOrWhiteSpace();
result.StatementBytes.Should().NotBeEmpty();
result.MediaType.Should().Be(ProvcachePredicateTypes.MediaType);
result.Annotations.Should().NotBeEmpty();
}
[Fact]
public void Build_ValidRequest_ProducesValidInTotoStatement()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest()
};
// Act
var result = _sut.Build(request);
// Assert - In-toto statement format
result.Statement.Type.Should().Be("https://in-toto.io/Statement/v1");
result.Statement.PredicateType.Should().Be(ProvcachePredicateTypes.ProvcacheV1);
result.Statement.Subject.Should().ContainSingle();
}
[Fact]
public void Build_ValidRequest_ProducesValidSubject()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest()
};
// Act
var result = _sut.Build(request);
// Assert
var subject = result.Statement.Subject.Single();
subject.Name.Should().Be("ghcr.io/stellaops/scanner");
subject.Digest.Should().ContainKey("sha256");
subject.Digest["sha256"].Should().Be("abc123def456");
}
[Fact]
public void Build_ArtifactWithDigestSuffix_ExtractsNameCorrectly()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner@sha256:abc123def456",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest()
};
// Act
var result = _sut.Build(request);
// Assert
var subject = result.Statement.Subject.Single();
subject.Name.Should().Be("ghcr.io/stellaops/scanner");
}
[Fact]
public void Build_ValidRequest_IncludesVeriKeyInPredicate()
{
// Arrange
var digest = CreateTestDigest();
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = digest
};
// Act
var result = _sut.Build(request);
// Assert
result.Statement.Predicate.VeriKey.Should().Be(digest.VeriKey);
result.Statement.Predicate.VerdictHash.Should().Be(digest.VerdictHash);
result.Statement.Predicate.ProofRoot.Should().Be(digest.ProofRoot);
result.Statement.Predicate.TrustScore.Should().Be(digest.TrustScore);
}
[Fact]
public void Build_WithTrustScoreBreakdown_IncludesBreakdown()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest()
};
// Act
var result = _sut.Build(request);
// Assert
result.Statement.Predicate.TrustScoreBreakdown.Should().NotBeNull();
result.Statement.Predicate.TrustScoreBreakdown!.Reachability.Score.Should().Be(80);
result.Statement.Predicate.TrustScoreBreakdown.SbomCompleteness.Score.Should().Be(90);
}
[Fact]
public void Build_WithInputManifest_IncludesManifestSummary()
{
// Arrange
var manifest = CreateTestManifest();
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest(),
InputManifest = manifest
};
// Act
var result = _sut.Build(request);
// Assert
var inputSummary = result.Statement.Predicate.InputManifest;
inputSummary.SourceHash.Should().Be(manifest.SourceArtifact.Digest);
inputSummary.SbomHash.Should().Be(manifest.Sbom.Hash);
inputSummary.VexSetHash.Should().Be(manifest.Vex.SetHash);
inputSummary.PolicyHash.Should().Be(manifest.Policy.Hash);
inputSummary.SignerSetHash.Should().Be(manifest.Signers.SetHash);
}
[Fact]
public void Build_WithVerdictSummary_IncludesSummary()
{
// Arrange
var summary = new ProvcacheVerdictSummary
{
TotalFindings = 100,
Affected = 10,
NotAffected = 70,
Mitigated = 15,
UnderInvestigation = 3,
Fixed = 2
};
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest(),
VerdictSummary = summary
};
// Act
var result = _sut.Build(request);
// Assert
result.Statement.Predicate.VerdictSummary.Should().NotBeNull();
result.Statement.Predicate.VerdictSummary!.TotalFindings.Should().Be(100);
result.Statement.Predicate.VerdictSummary.Affected.Should().Be(10);
}
[Fact]
public void Build_WithTenantAndScope_IncludesInAnnotations()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest(),
TenantId = "acme-corp",
Scope = "production"
};
// Act
var result = _sut.Build(request);
// Assert
result.Annotations.Should().ContainKey("stellaops.tenant");
result.Annotations["stellaops.tenant"].Should().Be("acme-corp");
result.Annotations.Should().ContainKey("stellaops.scope");
result.Annotations["stellaops.scope"].Should().Be("production");
}
[Fact]
public void Build_AlwaysIncludesRequiredOciAnnotations()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest()
};
// Act
var result = _sut.Build(request);
// Assert
result.Annotations.Should().ContainKey("org.opencontainers.image.title");
result.Annotations.Should().ContainKey("org.opencontainers.image.description");
result.Annotations.Should().ContainKey("org.opencontainers.image.created");
result.Annotations.Should().ContainKey("stellaops.provcache.verikey");
result.Annotations.Should().ContainKey("stellaops.provcache.trust-score");
}
[Fact]
public void Build_ProducesDeterministicJson()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest(),
InputManifest = CreateTestManifest()
};
// Act
var result1 = _sut.Build(request);
var result2 = _sut.Build(request);
// Assert - Same input should produce same output (deterministic)
result1.StatementJson.Should().Be(result2.StatementJson);
result1.StatementBytes.Should().BeEquivalentTo(result2.StatementBytes);
}
[Fact]
public void Build_ProducesValidJson()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest()
};
// Act
var result = _sut.Build(request);
// Assert - Should be valid JSON
var parseAction = () => JsonDocument.Parse(result.StatementJson);
parseAction.Should().NotThrow();
}
// ==================== Cosign Compatibility Tests ====================
/// <summary>
/// Verifies the attestation format is compatible with cosign verify-attestation.
/// cosign expects the "_type" field to be "https://in-toto.io/Statement/v1".
/// </summary>
[Fact]
public void Build_CosignCompatible_HasCorrectType()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest()
};
// Act
var result = _sut.Build(request);
using var doc = JsonDocument.Parse(result.StatementJson);
// Assert - cosign requires _type field
doc.RootElement.TryGetProperty("_type", out var typeElement).Should().BeTrue();
typeElement.GetString().Should().Be("https://in-toto.io/Statement/v1");
}
/// <summary>
/// Verifies the subject array is valid for cosign.
/// cosign expects at least one subject with name and digest.
/// </summary>
[Fact]
public void Build_CosignCompatible_HasValidSubject()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest()
};
// Act
var result = _sut.Build(request);
using var doc = JsonDocument.Parse(result.StatementJson);
// Assert - cosign requires subject array with name and digest
doc.RootElement.TryGetProperty("subject", out var subjectElement).Should().BeTrue();
subjectElement.GetArrayLength().Should().BeGreaterThan(0);
var firstSubject = subjectElement.EnumerateArray().First();
firstSubject.TryGetProperty("name", out var nameElement).Should().BeTrue();
firstSubject.TryGetProperty("digest", out var digestElement).Should().BeTrue();
nameElement.GetString().Should().NotBeNullOrEmpty();
digestElement.TryGetProperty("sha256", out var sha256Element).Should().BeTrue();
sha256Element.GetString().Should().NotBeNullOrEmpty();
}
/// <summary>
/// Verifies predicateType is present for cosign filtering.
/// cosign verify-attestation --type allows filtering by predicateType.
/// </summary>
[Fact]
public void Build_CosignCompatible_HasPredicateType()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest()
};
// Act
var result = _sut.Build(request);
using var doc = JsonDocument.Parse(result.StatementJson);
// Assert - cosign uses predicateType for filtering
doc.RootElement.TryGetProperty("predicateType", out var predicateTypeElement).Should().BeTrue();
predicateTypeElement.GetString().Should().Be(ProvcachePredicateTypes.ProvcacheV1);
}
/// <summary>
/// Verifies predicate object is present.
/// cosign verify-attestation expects a predicate object.
/// </summary>
[Fact]
public void Build_CosignCompatible_HasPredicate()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest()
};
// Act
var result = _sut.Build(request);
using var doc = JsonDocument.Parse(result.StatementJson);
// Assert - cosign expects predicate object
doc.RootElement.TryGetProperty("predicate", out var predicateElement).Should().BeTrue();
predicateElement.ValueKind.Should().Be(JsonValueKind.Object);
// Verify key fields are present in predicate
predicateElement.TryGetProperty("veriKey", out _).Should().BeTrue();
predicateElement.TryGetProperty("verdictHash", out _).Should().BeTrue();
predicateElement.TryGetProperty("proofRoot", out _).Should().BeTrue();
predicateElement.TryGetProperty("trustScore", out _).Should().BeTrue();
}
/// <summary>
/// Verifies the media type is appropriate for OCI referrers.
/// </summary>
[Fact]
public void Build_CosignCompatible_HasCorrectMediaType()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest()
};
// Act
var result = _sut.Build(request);
// Assert
result.MediaType.Should().Be("application/vnd.stellaops.provcache.decision+json");
}
// ==================== Validation Tests ====================
[Fact]
public void Build_NullRequest_ThrowsArgumentNullException()
{
// Act & Assert
var action = () => _sut.Build(null!);
action.Should().Throw<ArgumentNullException>();
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void Build_EmptyArtifactReference_ThrowsArgumentException(string? artifactRef)
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = artifactRef!,
ArtifactDigest = "sha256:abc123",
DecisionDigest = CreateTestDigest()
};
// Act & Assert
var action = () => _sut.Build(request);
action.Should().Throw<ArgumentException>()
.WithParameterName("request");
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void Build_EmptyArtifactDigest_ThrowsArgumentException(string? digest)
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/test/image:v1",
ArtifactDigest = digest!,
DecisionDigest = CreateTestDigest()
};
// Act & Assert
var action = () => _sut.Build(request);
action.Should().Throw<ArgumentException>()
.WithParameterName("request");
}
[Fact]
public void Build_NullDecisionDigest_ThrowsArgumentException()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/test/image:v1",
ArtifactDigest = "sha256:abc123",
DecisionDigest = null!
};
// Act & Assert
var action = () => _sut.Build(request);
action.Should().Throw<ArgumentException>()
.WithParameterName("request");
}
// ==================== CreateAttachment Tests ====================
[Fact]
public void CreateAttachment_ValidRequest_ReturnsValidAttachment()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest()
};
// Act
var attachment = _sut.CreateAttachment(request);
// Assert
attachment.Should().NotBeNull();
attachment.ArtifactReference.Should().Be(request.ArtifactReference);
attachment.MediaType.Should().Be(ProvcachePredicateTypes.MediaType);
attachment.Payload.Should().NotBeNullOrEmpty();
attachment.PayloadBytes.Should().NotBeEmpty();
attachment.Annotations.Should().NotBeEmpty();
}
}

View File

@@ -37,6 +37,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
.ConfigureServices(services =>
{
services.AddSingleton(_mockService.Object);
services.AddSingleton(TimeProvider.System);
services.AddRouting();
services.AddLogging(b => b.SetMinimumLevel(LogLevel.Warning));
})

View File

@@ -0,0 +1,307 @@
using System.Diagnostics;
using System.Diagnostics.Metrics;
using FluentAssertions;
using StellaOps.Provcache;
using Xunit;
namespace StellaOps.Provcache.Tests.Telemetry;
/// <summary>
/// Tests for <see cref="ProvcacheTelemetry"/> metrics and activity tracing.
/// </summary>
public sealed class ProvcacheTelemetryTests
{
[Fact]
public void StartGetActivity_CreatesActivityWithVeriKeyTag()
{
// Arrange
using var listener = CreateActivityListener();
const string veriKey = "sha256:abc123def456...";
// Act
using var activity = ProvcacheTelemetry.StartGetActivity(veriKey);
// Assert
activity.Should().NotBeNull();
activity!.DisplayName.Should().Be("provcache.get");
activity.GetTagItem("provcache.verikey").Should().NotBeNull();
}
[Fact]
public void StartSetActivity_CreatesActivityWithVeriKeyAndTrustScore()
{
// Arrange
using var listener = CreateActivityListener();
const string veriKey = "sha256:abc123def456...";
const int trustScore = 85;
// Act
using var activity = ProvcacheTelemetry.StartSetActivity(veriKey, trustScore);
// Assert
activity.Should().NotBeNull();
activity!.DisplayName.Should().Be("provcache.set");
activity.GetTagItem("provcache.verikey").Should().NotBeNull();
activity.GetTagItem("provcache.trust_score").Should().Be(trustScore);
}
[Fact]
public void StartInvalidateActivity_CreatesActivityWithTypeAndTarget()
{
// Arrange
using var listener = CreateActivityListener();
const string invalidationType = "policy_hash";
const string targetValue = "sha256:policy:abc123";
// Act
using var activity = ProvcacheTelemetry.StartInvalidateActivity(invalidationType, targetValue);
// Assert
activity.Should().NotBeNull();
activity!.DisplayName.Should().Be("provcache.invalidate");
activity.GetTagItem("provcache.invalidation_type").Should().Be(invalidationType);
activity.GetTagItem("provcache.target").Should().NotBeNull();
}
[Fact]
public void MarkCacheHit_SetsResultAndSource()
{
// Arrange
using var listener = CreateActivityListener();
using var activity = ProvcacheTelemetry.StartGetActivity("sha256:test");
// Act
ProvcacheTelemetry.MarkCacheHit(activity, "valkey");
// Assert
activity.Should().NotBeNull();
activity!.GetTagItem("provcache.result").Should().Be("hit");
activity.GetTagItem("provcache.source").Should().Be("valkey");
}
[Fact]
public void MarkCacheMiss_SetsResult()
{
// Arrange
using var listener = CreateActivityListener();
using var activity = ProvcacheTelemetry.StartGetActivity("sha256:test");
// Act
ProvcacheTelemetry.MarkCacheMiss(activity);
// Assert
activity.Should().NotBeNull();
activity!.GetTagItem("provcache.result").Should().Be("miss");
}
[Fact]
public void MarkError_SetsErrorStatusAndResult()
{
// Arrange
using var listener = CreateActivityListener();
using var activity = ProvcacheTelemetry.StartGetActivity("sha256:test");
const string errorMessage = "Test error";
// Act
ProvcacheTelemetry.MarkError(activity, errorMessage);
// Assert
activity.Should().NotBeNull();
activity!.Status.Should().Be(ActivityStatusCode.Error);
activity.GetTagItem("provcache.result").Should().Be("error");
}
[Fact]
public void RecordRequest_IncrementsRequestCounter()
{
// Arrange
var measurements = new List<KeyValuePair<string, object?>>();
using var meterListener = CreateMeterListener(measurements);
// Act
ProvcacheTelemetry.RecordRequest("get", "hit");
// Assert - counter should have been incremented
measurements.Should().ContainSingle(m => m.Key == "provcache_requests_total");
}
[Fact]
public void RecordHit_IncrementsHitCounter()
{
// Arrange
var measurements = new List<KeyValuePair<string, object?>>();
using var meterListener = CreateMeterListener(measurements);
// Act
ProvcacheTelemetry.RecordHit("valkey");
// Assert
measurements.Should().ContainSingle(m => m.Key == "provcache_hits_total");
}
[Fact]
public void RecordMiss_IncrementsMissCounter()
{
// Arrange
var measurements = new List<KeyValuePair<string, object?>>();
using var meterListener = CreateMeterListener(measurements);
// Act
ProvcacheTelemetry.RecordMiss();
// Assert
measurements.Should().ContainSingle(m => m.Key == "provcache_misses_total");
}
[Fact]
public void RecordInvalidation_IncrementsInvalidationCounter()
{
// Arrange
var measurements = new List<KeyValuePair<string, object?>>();
using var meterListener = CreateMeterListener(measurements);
// Act
ProvcacheTelemetry.RecordInvalidation("policy", 5);
// Assert
measurements.Should().ContainSingle(m => m.Key == "provcache_invalidations_total");
}
[Fact]
public void RecordLatency_RecordsToHistogram()
{
// Arrange
var measurements = new List<KeyValuePair<string, object?>>();
using var meterListener = CreateMeterListener(measurements);
// Act
ProvcacheTelemetry.RecordLatency("get", TimeSpan.FromMilliseconds(15.5));
// Assert
measurements.Should().ContainSingle(m => m.Key == "provcache_latency_seconds");
}
[Fact]
public void SetWriteBehindQueueSize_UpdatesGauge()
{
// Arrange & Act
ProvcacheTelemetry.SetWriteBehindQueueSize(42);
// Assert - verify through the observable gauge
// The gauge will report 42 when observed
// We can't directly verify this without full OTel integration,
// but we ensure the method doesn't throw
var size = ProvcacheTelemetry.WriteBehindQueueGauge;
size.Should().NotBeNull();
}
[Fact]
public void SetItemsCount_UpdatesGauge()
{
// Arrange & Act
ProvcacheTelemetry.SetItemsCount(1000);
// Assert - verify through the observable gauge
var gauge = ProvcacheTelemetry.ItemsCountGauge;
gauge.Should().NotBeNull();
}
[Fact]
public void ActivitySource_HasCorrectName()
{
// The activity source should be named correctly for OTel integration
using var listener = CreateActivityListener();
using var activity = ProvcacheTelemetry.StartGetActivity("test");
activity.Should().NotBeNull();
activity!.Source.Name.Should().Be("StellaOps.Provcache");
}
[Fact]
public void StartWriteBehindFlushActivity_CreatesBatchActivity()
{
// Arrange
using var listener = CreateActivityListener();
const int batchSize = 10;
// Act
using var activity = ProvcacheTelemetry.StartWriteBehindFlushActivity(batchSize);
// Assert
activity.Should().NotBeNull();
activity!.DisplayName.Should().Be("provcache.writebehind.flush");
activity.GetTagItem("provcache.batch_size").Should().Be(batchSize);
}
[Fact]
public void StartVeriKeyBuildActivity_CreatesActivity()
{
// Arrange
using var listener = CreateActivityListener();
// Act
using var activity = ProvcacheTelemetry.StartVeriKeyBuildActivity();
// Assert
activity.Should().NotBeNull();
activity!.DisplayName.Should().Be("provcache.verikey.build");
}
[Fact]
public void StartDecisionDigestBuildActivity_CreatesActivity()
{
// Arrange
using var listener = CreateActivityListener();
// Act
using var activity = ProvcacheTelemetry.StartDecisionDigestBuildActivity();
// Assert
activity.Should().NotBeNull();
activity!.DisplayName.Should().Be("provcache.digest.build");
}
#region Helpers
private static ActivityListener CreateActivityListener()
{
var listener = new ActivityListener
{
ShouldListenTo = source => source.Name == "StellaOps.Provcache",
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
SampleUsingParentId = (ref ActivityCreationOptions<string> _) => ActivitySamplingResult.AllDataAndRecorded
};
ActivitySource.AddActivityListener(listener);
return listener;
}
private static MeterListener CreateMeterListener(List<KeyValuePair<string, object?>> measurements)
{
var listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == "StellaOps.Provcache")
{
listener.EnableMeasurementEvents(instrument);
}
}
};
listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
{
measurements.Add(new KeyValuePair<string, object?>(instrument.Name, measurement));
});
listener.SetMeasurementEventCallback<double>((instrument, measurement, tags, state) =>
{
measurements.Add(new KeyValuePair<string, object?>(instrument.Name, measurement));
});
listener.Start();
return listener;
}
#endregion
}