sprints work

This commit is contained in:
master
2026-01-10 11:15:28 +02:00
parent a21d3dbc1f
commit 701eb6b21c
71 changed files with 10854 additions and 136 deletions

View File

@@ -0,0 +1,221 @@
// <copyright file="AiAttestationServiceTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.AdvisoryAI.Attestation.Models;
using Xunit;
namespace StellaOps.AdvisoryAI.Attestation.Tests;
/// <summary>
/// Tests for <see cref="AiAttestationService"/>.
/// </summary>
[Trait("Category", "Unit")]
public class AiAttestationServiceTests
{
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);
}
private static AiRunAttestation CreateSampleRunAttestation()
{
return new AiRunAttestation
{
RunId = $"run-{Guid.NewGuid():N}",
TenantId = "tenant-test",
UserId = "user:test@example.com",
StartedAt = DateTimeOffset.Parse("2026-01-09T12:00:00Z"),
CompletedAt = DateTimeOffset.Parse("2026-01-09T12:05:00Z"),
Model = new AiModelInfo
{
Provider = "anthropic",
ModelId = "claude-3-sonnet"
},
OverallGroundingScore = 0.9,
TotalTokens = 1000
};
}
private static AiClaimAttestation CreateSampleClaimAttestation()
{
return new AiClaimAttestation
{
ClaimId = $"claim-{Guid.NewGuid():N}",
RunId = "run-xyz",
TurnId = "turn-001",
TenantId = "tenant-test",
ClaimText = "Test claim",
ClaimDigest = "sha256:test",
GroundingScore = 0.85,
Timestamp = DateTimeOffset.Parse("2026-01-09T12:00:00Z"),
ContentDigest = "sha256:content-test"
};
}
}

View File

@@ -0,0 +1,143 @@
// <copyright file="AiClaimAttestationTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.AdvisoryAI.Attestation.Models;
using Xunit;
namespace StellaOps.AdvisoryAI.Attestation.Tests;
/// <summary>
/// Tests for <see cref="AiClaimAttestation"/>.
/// </summary>
[Trait("Category", "Unit")]
public 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 AiClaimAttestation CreateSampleClaimAttestation()
{
return new AiClaimAttestation
{
ClaimId = "claim-abc123",
RunId = "run-xyz",
TurnId = "turn-001",
TenantId = "tenant-test",
ClaimText = "The component is affected by this vulnerability",
ClaimDigest = "sha256:abc123",
Category = ClaimCategory.Factual,
GroundedBy = ["stella://sbom/test"],
GroundingScore = 0.85,
Verified = true,
Timestamp = DateTimeOffset.Parse("2026-01-09T12:00:00Z"),
ContentDigest = "sha256:content123"
};
}
}

View File

@@ -0,0 +1,124 @@
// <copyright file="AiRunAttestationTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.AdvisoryAI.Attestation.Models;
using Xunit;
namespace StellaOps.AdvisoryAI.Attestation.Tests;
/// <summary>
/// Tests for <see cref="AiRunAttestation"/>.
/// </summary>
[Trait("Category", "Unit")]
public 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 AiRunAttestation CreateSampleAttestation()
{
return new AiRunAttestation
{
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"),
Model = new AiModelInfo
{
Provider = "anthropic",
ModelId = "claude-3-sonnet"
},
OverallGroundingScore = 0.92,
TotalTokens = 1500
};
}
private static AiTurnSummary CreateTurn(string turnId, TurnRole role, string timestamp)
{
return new AiTurnSummary
{
TurnId = turnId,
Role = role,
ContentDigest = $"sha256:turn-{turnId}",
Timestamp = DateTimeOffset.Parse(timestamp),
TokenCount = 100
};
}
}

View File

@@ -0,0 +1,231 @@
// <copyright file="InMemoryAiAttestationStoreTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.AdvisoryAI.Attestation.Models;
using StellaOps.AdvisoryAI.Attestation.Storage;
using Xunit;
namespace StellaOps.AdvisoryAI.Attestation.Tests;
/// <summary>
/// Tests for <see cref="InMemoryAiAttestationStore"/>.
/// </summary>
[Trait("Category", "Unit")]
public class InMemoryAiAttestationStoreTests
{
private readonly InMemoryAiAttestationStore _store;
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",
DateTimeOffset? startedAt = null)
{
return new AiRunAttestation
{
RunId = runId,
TenantId = tenantId,
UserId = "user-1",
StartedAt = startedAt ?? DateTimeOffset.UtcNow,
CompletedAt = DateTimeOffset.UtcNow,
Model = new AiModelInfo
{
ModelId = "gpt-4",
Provider = "openai"
},
Turns = ImmutableArray<AiTurnSummary>.Empty
};
}
private static AiClaimAttestation CreateClaimAttestation(
string runId,
string turnId,
string? contentDigest = null)
{
return new AiClaimAttestation
{
ClaimId = $"claim-{Guid.NewGuid():N}",
RunId = runId,
TurnId = turnId,
TenantId = "test-tenant",
ClaimText = "Test claim text",
ClaimDigest = "sha256:claimhash",
Timestamp = DateTimeOffset.UtcNow,
ClaimType = "vulnerability_assessment",
ContentDigest = contentDigest ?? $"sha256:{runId}-{turnId}"
};
}
}

View File

@@ -0,0 +1,264 @@
// <copyright file="AttestationServiceIntegrationTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using Microsoft.Extensions.DependencyInjection;
using StellaOps.AdvisoryAI.Attestation.Models;
using StellaOps.AdvisoryAI.Attestation.Storage;
using Xunit;
namespace StellaOps.AdvisoryAI.Attestation.Tests.Integration;
/// <summary>
/// Integration tests for AI attestation service.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-008
/// </summary>
[Trait("Category", "Integration")]
public sealed class AttestationServiceIntegrationTests : IAsyncLifetime
{
private ServiceProvider _serviceProvider = null!;
private IAiAttestationService _attestationService = null!;
private IAiAttestationStore _store = null!;
private TimeProvider _timeProvider = null!;
public ValueTask InitializeAsync()
{
var services = new ServiceCollection();
// 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.True(createResult.Success);
Assert.NotNull(createResult.ContentDigest);
// 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.True(result.Success);
}
// 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()
{
// Arrange
var attestation = CreateSampleRunAttestation("run-tamper-001");
await _attestationService.CreateRunAttestationAsync(attestation, sign: true);
// Tamper with stored content by creating a modified attestation
var tampered = attestation with { UserId = "tampered-user" };
// Store the tampered version directly (bypassing service)
await _store.StoreRunAttestationAsync(tampered, CancellationToken.None);
// Act - Verify (should fail because digest won't match)
var verifyResult = await _attestationService.VerifyRunAttestationAsync("run-tamper-001");
// Assert
Assert.False(verifyResult.Valid);
Assert.NotNull(verifyResult.FailureReason);
}
[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.True(createResult.Success);
// 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
}
private static AiRunAttestation CreateSampleRunAttestation(
string runId,
string tenantId = "test-tenant",
string userId = "test-user")
{
return new AiRunAttestation
{
RunId = runId,
TenantId = tenantId,
UserId = userId,
Model = new AiModelInfo
{
ModelId = "test-model",
Provider = "test-provider"
},
TotalTokens = 100,
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
CompletedAt = DateTimeOffset.UtcNow
};
}
private static AiClaimAttestation CreateSampleClaimAttestation(
string claimId,
string runId,
string turnId)
{
return new AiClaimAttestation
{
ClaimId = claimId,
RunId = runId,
TurnId = turnId,
TenantId = "test-tenant",
ClaimType = "test_claim",
ClaimText = "This is a test claim",
ClaimDigest = $"sha256:{claimId}",
ContentDigest = $"sha256:content-{claimId}",
Timestamp = DateTimeOffset.UtcNow
};
}
}

View File

@@ -0,0 +1,167 @@
// <copyright file="PromptTemplateRegistryTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Xunit;
namespace StellaOps.AdvisoryAI.Attestation.Tests;
/// <summary>
/// Tests for <see cref="PromptTemplateRegistry"/>.
/// </summary>
[Trait("Category", "Unit")]
public class PromptTemplateRegistryTests
{
private readonly FakeTimeProvider _timeProvider = new();
private readonly PromptTemplateRegistry _registry;
public PromptTemplateRegistryTests()
{
_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

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<!-- xUnit1051 is informational - CancellationToken suggestion -->
<NoWarn>$(NoWarn);xUnit1051</NoWarn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.AdvisoryAI.Attestation\StellaOps.AdvisoryAI.Attestation.csproj" />
<ProjectReference Include="..\..\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,192 @@
// <copyright file="FunctionBoundaryDetectorTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Reachability.Core.CveMapping;
using StellaOps.Reachability.Core.Symbols;
using Xunit;
namespace StellaOps.Reachability.Core.Tests.CveMapping;
/// <summary>
/// Tests for <see cref="FunctionBoundaryDetector"/>.
/// </summary>
[Trait("Category", "Unit")]
public class FunctionBoundaryDetectorTests
{
private readonly FunctionBoundaryDetector _detector = new();
[Fact]
public void DetectFunction_CSharpMethod_DetectsCorrectly()
{
// Arrange
var context = ImmutableArray.Create(
"namespace MyApp",
"{",
" public class MyService",
" {",
" public void ProcessData(string input)",
" {",
" var result = input.Trim();",
" return result;",
" }",
" }",
"}"
);
// Act
var result = _detector.DetectFunction(context, 7, ProgrammingLanguage.CSharp);
// Assert
result.Should().NotBeNull();
result!.Value.FullyQualifiedName.Should().Contain("ProcessData");
}
[Fact]
public void DetectFunction_PythonFunction_DetectsCorrectly()
{
// Arrange
var context = ImmutableArray.Create(
"class MyService:",
" def process_data(self, input):",
" result = input.strip()",
" return result",
"",
" def other_method(self):",
" pass"
);
// Act
var result = _detector.DetectFunction(context, 3, ProgrammingLanguage.Python);
// Assert
result.Should().NotBeNull();
result!.Value.FullyQualifiedName.Should().Contain("process_data");
}
[Fact]
public void DetectFunction_GoFunction_DetectsCorrectly()
{
// Arrange
var context = ImmutableArray.Create(
"package main",
"",
"func ProcessData(input string) string {",
" result := strings.TrimSpace(input)",
" return result",
"}"
);
// Act
var result = _detector.DetectFunction(context, 4, ProgrammingLanguage.Go);
// Assert
result.Should().NotBeNull();
result!.Value.FullyQualifiedName.Should().Contain("ProcessData");
}
[Fact]
public void DetectFunction_JavaScriptArrowFunction_DetectsCorrectly()
{
// Arrange
var context = ImmutableArray.Create(
"class MyService {",
" processData = (input) => {",
" const result = input.trim();",
" return result;",
" }",
"}"
);
// Act
var result = _detector.DetectFunction(context, 3, ProgrammingLanguage.JavaScript);
// Assert
result.Should().NotBeNull();
result!.Value.FullyQualifiedName.Should().Contain("processData");
}
[Fact]
public void DetectFunction_RustFunction_DetectsCorrectly()
{
// Arrange
var context = ImmutableArray.Create(
"impl MyService {",
" pub fn process_data(&self, input: &str) -> String {",
" let result = input.trim();",
" result.to_string()",
" }",
"}"
);
// Act
var result = _detector.DetectFunction(context, 3, ProgrammingLanguage.Rust);
// Assert
result.Should().NotBeNull();
result!.Value.FullyQualifiedName.Should().Contain("process_data");
}
[Fact]
public void DetectFunction_EmptyContext_ReturnsNull()
{
// Act
var result = _detector.DetectFunction([], 1, ProgrammingLanguage.CSharp);
// Assert
result.Should().BeNull();
}
[Fact]
public void DetectAllFunctions_MultipleFunctions_DetectsAll()
{
// Arrange
var context = ImmutableArray.Create(
"namespace MyApp",
"{",
" public class MyService",
" {",
" public void Method1()",
" {",
" }",
"",
" public void Method2()",
" {",
" }",
" }",
"}"
);
// Act
var result = _detector.DetectAllFunctions(context, ProgrammingLanguage.CSharp);
// Assert
result.Should().HaveCount(2);
result.Should().Contain(f => f.FullyQualifiedName.Contains("Method1"));
result.Should().Contain(f => f.FullyQualifiedName.Contains("Method2"));
}
[Fact]
public void DetectFunction_JavaMethod_DetectsWithFullyQualifiedName()
{
// Arrange
var context = ImmutableArray.Create(
"package org.example.service;",
"",
"public class UserService {",
" public void createUser(String name) {",
" // vulnerable code",
" }",
"}"
);
// Act
var result = _detector.DetectFunction(context, 5, ProgrammingLanguage.Java);
// Assert
result.Should().NotBeNull();
result!.Value.FullyQualifiedName.Should().Contain("createUser");
}
}

View File

@@ -0,0 +1,306 @@
// <copyright file="OsvEnricherTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Net;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Reachability.Core.CveMapping;
using Xunit;
namespace StellaOps.Reachability.Core.Tests.CveMapping;
/// <summary>
/// Tests for <see cref="OsvEnricher"/>.
/// </summary>
[Trait("Category", "Unit")]
public class OsvEnricherTests
{
[Fact]
public async Task EnrichAsync_WhenVulnerabilityFound_ReturnsEnrichedResult()
{
// Arrange
var responseJson = CreateOsvVulnerabilityJson("GHSA-abc-123", "CVE-2024-1234");
var handler = new MockHttpMessageHandler(responseJson, HttpStatusCode.OK);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.osv.dev/") };
var enricher = new OsvEnricher(httpClient);
// Act
var result = await enricher.EnrichAsync("CVE-2024-1234", CancellationToken.None);
// Assert
result.Found.Should().BeTrue();
result.CveId.Should().Be("CVE-2024-1234");
result.OsvId.Should().Be("GHSA-abc-123");
}
[Fact]
public async Task EnrichAsync_WhenVulnerabilityNotFound_ReturnsNotFoundResult()
{
// Arrange
var handler = new MockHttpMessageHandler(string.Empty, HttpStatusCode.NotFound);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.osv.dev/") };
var enricher = new OsvEnricher(httpClient);
// Act
var result = await enricher.EnrichAsync("CVE-DOES-NOT-EXIST", CancellationToken.None);
// Assert
result.Found.Should().BeFalse();
result.CveId.Should().Be("CVE-DOES-NOT-EXIST");
}
[Fact]
public async Task GetVulnerabilityAsync_ReturnsVulnerabilityData()
{
// Arrange
var responseJson = CreateOsvVulnerabilityJson("GHSA-test-001", "CVE-2024-5678");
var handler = new MockHttpMessageHandler(responseJson, HttpStatusCode.OK);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.osv.dev/") };
var enricher = new OsvEnricher(httpClient);
// Act
var result = await enricher.GetVulnerabilityAsync("GHSA-test-001", CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be("GHSA-test-001");
result.Aliases.Should().Contain("CVE-2024-5678");
}
[Fact]
public async Task GetVulnerabilityAsync_WithAffectedPackages_ExtractsPackageInfo()
{
// Arrange
var responseJson = CreateOsvVulnerabilityWithPackages();
var handler = new MockHttpMessageHandler(responseJson, HttpStatusCode.OK);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.osv.dev/") };
var enricher = new OsvEnricher(httpClient);
// Act
var result = await enricher.GetVulnerabilityAsync("GHSA-pkg-test", CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Affected.Should().HaveCount(1);
result.Affected[0].Package.Should().NotBeNull();
result.Affected[0].Package!.Ecosystem.Should().Be("npm");
result.Affected[0].Package!.Name.Should().Be("lodash");
}
[Fact]
public async Task QueryByPackageAsync_ReturnsMatchingVulnerabilities()
{
// Arrange
var responseJson = CreateOsvQueryResponse();
var handler = new MockHttpMessageHandler(responseJson, HttpStatusCode.OK);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.osv.dev/") };
var enricher = new OsvEnricher(httpClient);
// Act
var results = await enricher.QueryByPackageAsync("npm", "lodash", "4.17.0", CancellationToken.None);
// Assert
results.Should().HaveCount(1);
results[0].Id.Should().Be("GHSA-query-001");
}
[Fact]
public async Task QueryByPackageAsync_WithNoResults_ReturnsEmptyList()
{
// Arrange
var handler = new MockHttpMessageHandler("{}", HttpStatusCode.OK);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.osv.dev/") };
var enricher = new OsvEnricher(httpClient);
// Act
var results = await enricher.QueryByPackageAsync("npm", "safe-package", null, CancellationToken.None);
// Assert
results.Should().BeEmpty();
}
[Fact]
public async Task EnrichAsync_WithVersionRanges_ExtractsAffectedVersions()
{
// Arrange
var responseJson = CreateOsvVulnerabilityWithVersionRanges();
var handler = new MockHttpMessageHandler(responseJson, HttpStatusCode.OK);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.osv.dev/") };
var enricher = new OsvEnricher(httpClient);
// Act
var result = await enricher.EnrichAsync("CVE-2024-9999", CancellationToken.None);
// Assert
result.Found.Should().BeTrue();
result.AffectedVersions.Should().NotBeEmpty();
var range = result.AffectedVersions[0];
range.IntroducedVersion.Should().Be("1.0.0");
range.FixedVersion.Should().Be("1.5.0");
}
[Fact]
public async Task EnrichAsync_WithFunctions_ExtractsVulnerableSymbols()
{
// Arrange
var responseJson = CreateOsvVulnerabilityWithFunctions();
var handler = new MockHttpMessageHandler(responseJson, HttpStatusCode.OK);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.osv.dev/") };
var enricher = new OsvEnricher(httpClient);
// Act
var result = await enricher.EnrichAsync("CVE-2024-FUNC", CancellationToken.None);
// Assert
result.Found.Should().BeTrue();
result.Symbols.Should().NotBeEmpty();
}
[Fact]
public async Task GetVulnerabilityAsync_WhenHttpError_ReturnsNull()
{
// Arrange
var handler = new MockHttpMessageHandler(string.Empty, HttpStatusCode.InternalServerError);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.osv.dev/") };
var enricher = new OsvEnricher(httpClient);
// Act
var result = await enricher.GetVulnerabilityAsync("CVE-ERROR", CancellationToken.None);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task GetVulnerabilityAsync_WhenInvalidJson_ReturnsNull()
{
// Arrange
var handler = new MockHttpMessageHandler("not valid json", HttpStatusCode.OK);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.osv.dev/") };
var enricher = new OsvEnricher(httpClient);
// Act
var result = await enricher.GetVulnerabilityAsync("CVE-INVALID", CancellationToken.None);
// Assert
result.Should().BeNull();
}
private static string CreateOsvVulnerabilityJson(string osvId, string cveId)
{
return $$"""
{
"id": "{{osvId}}",
"summary": "Test vulnerability",
"aliases": ["{{cveId}}"],
"affected": []
}
""";
}
private static string CreateOsvVulnerabilityWithPackages()
{
return """
{
"id": "GHSA-pkg-test",
"summary": "Package vulnerability",
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "lodash"
},
"ranges": []
}
]
}
""";
}
private static string CreateOsvVulnerabilityWithVersionRanges()
{
return """
{
"id": "GHSA-version-test",
"aliases": ["CVE-2024-9999"],
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "vulnerable-pkg"
},
"ranges": [
{
"type": "SEMVER",
"events": [
{"introduced": "1.0.0"},
{"fixed": "1.5.0"}
]
}
]
}
]
}
""";
}
private static string CreateOsvVulnerabilityWithFunctions()
{
return """
{
"id": "GHSA-func-test",
"aliases": ["CVE-2024-FUNC"],
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "vulnerable-lib"
},
"ecosystem_specific": {
"functions": ["vulnerable_function", "another_function"]
}
}
]
}
""";
}
private static string CreateOsvQueryResponse()
{
return """
{
"vulns": [
{
"id": "GHSA-query-001",
"summary": "Query result vulnerability",
"affected": []
}
]
}
""";
}
private sealed class MockHttpMessageHandler : HttpMessageHandler
{
private readonly string _response;
private readonly HttpStatusCode _statusCode;
public MockHttpMessageHandler(string response, HttpStatusCode statusCode)
{
_response = response;
_statusCode = statusCode;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var response = new HttpResponseMessage(_statusCode)
{
Content = new StringContent(_response, System.Text.Encoding.UTF8, "application/json")
};
return Task.FromResult(response);
}
}
}

View File

@@ -0,0 +1,181 @@
// <copyright file="UnifiedDiffParserTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using StellaOps.Reachability.Core.CveMapping;
using Xunit;
namespace StellaOps.Reachability.Core.Tests.CveMapping;
/// <summary>
/// Tests for <see cref="UnifiedDiffParser"/>.
/// </summary>
[Trait("Category", "Unit")]
public class UnifiedDiffParserTests
{
private readonly UnifiedDiffParser _parser = new();
[Fact]
public void Parse_SimpleDiff_ExtractsFileAndHunks()
{
// Arrange
var diff = """
diff --git a/src/utils.py b/src/utils.py
--- a/src/utils.py
+++ b/src/utils.py
@@ -10,7 +10,7 @@ def process_data(input):
data = input.strip()
- result = eval(data)
+ result = safe_eval(data)
return result
""";
// Act
var result = _parser.Parse(diff);
// Assert
result.Files.Should().HaveCount(1);
var file = result.Files[0];
file.OldPath.Should().Be("src/utils.py");
file.NewPath.Should().Be("src/utils.py");
file.Hunks.Should().HaveCount(1);
}
[Fact]
public void Parse_HunkWithAddedAndRemovedLines_ExtractsCorrectly()
{
// Arrange
var diff = """
diff --git a/src/file.cs b/src/file.cs
--- a/src/file.cs
+++ b/src/file.cs
@@ -5,6 +5,8 @@ namespace Test
{
public void Method()
{
- var x = 1;
+ var x = 2;
+ var y = 3;
}
}
""";
// Act
var result = _parser.Parse(diff);
// Assert
result.Files.Should().HaveCount(1);
var hunk = result.Files[0].Hunks[0];
hunk.OldStart.Should().Be(5);
hunk.NewStart.Should().Be(5);
hunk.RemovedLines.Should().HaveCount(1);
hunk.AddedLines.Should().HaveCount(2);
}
[Fact]
public void Parse_NewFile_DetectsCorrectly()
{
// Arrange
var diff = """
diff --git a/src/newfile.cs b/src/newfile.cs
--- /dev/null
+++ b/src/newfile.cs
@@ -0,0 +1,5 @@
+namespace Test
+{
+ public class NewClass { }
+}
""";
// Act
var result = _parser.Parse(diff);
// Assert
result.Files.Should().HaveCount(1);
var file = result.Files[0];
file.IsNewFile.Should().BeTrue();
file.NewPath.Should().Be("src/newfile.cs");
}
[Fact]
public void Parse_DeletedFile_DetectsCorrectly()
{
// Arrange
var diff = """
diff --git a/src/oldfile.cs b/src/oldfile.cs
--- a/src/oldfile.cs
+++ /dev/null
@@ -1,4 +0,0 @@
-namespace Test
-{
- public class OldClass { }
-}
""";
// Act
var result = _parser.Parse(diff);
// Assert
result.Files.Should().HaveCount(1);
var file = result.Files[0];
file.IsDeleted.Should().BeTrue();
}
[Fact]
public void Parse_MultipleFiles_ParsesAll()
{
// Arrange
var diff = """
diff --git a/file1.cs b/file1.cs
--- a/file1.cs
+++ b/file1.cs
@@ -1,3 +1,3 @@
-old
+new
diff --git a/file2.cs b/file2.cs
--- a/file2.cs
+++ b/file2.cs
@@ -1,3 +1,3 @@
-old2
+new2
""";
// Act
var result = _parser.Parse(diff);
// Assert
result.Files.Should().HaveCount(2);
}
[Fact]
public void Parse_WithFunctionContext_ExtractsFunctionName()
{
// Arrange
var diff = """
diff --git a/src/app.cs b/src/app.cs
--- a/src/app.cs
+++ b/src/app.cs
@@ -10,7 +10,7 @@ public void ProcessData(string input)
var data = input;
- return eval(data);
+ return safe_process(data);
""";
// Act
var result = _parser.Parse(diff);
// Assert
result.Files[0].Hunks[0].FunctionContext.Should().Be("public void ProcessData(string input)");
}
[Fact]
public void Parse_EmptyDiff_ReturnsEmptyResult()
{
// Arrange
var diff = "";
// Act & Assert
Assert.Throws<ArgumentException>(() => _parser.Parse(diff));
}
}

View File

@@ -0,0 +1,189 @@
// <copyright file="NativeSymbolNormalizerTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using StellaOps.Reachability.Core.Symbols;
using Xunit;
namespace StellaOps.Reachability.Core.Tests.Symbols;
/// <summary>
/// Tests for <see cref="NativeSymbolNormalizer"/>.
/// </summary>
[Trait("Category", "Unit")]
public class NativeSymbolNormalizerTests
{
private readonly NativeSymbolNormalizer _normalizer = new();
[Fact]
public void SupportedSources_ContainsExpectedSources()
{
_normalizer.SupportedSources.Should().Contain(SymbolSource.ElfSymtab);
_normalizer.SupportedSources.Should().Contain(SymbolSource.PeExport);
_normalizer.SupportedSources.Should().Contain(SymbolSource.Dwarf);
_normalizer.SupportedSources.Should().Contain(SymbolSource.Pdb);
_normalizer.SupportedSources.Should().Contain(SymbolSource.EbpfUprobe);
}
[Theory]
[InlineData(SymbolSource.ElfSymtab, true)]
[InlineData(SymbolSource.PeExport, true)]
[InlineData(SymbolSource.Dwarf, true)]
[InlineData(SymbolSource.Roslyn, false)]
[InlineData(SymbolSource.JavaAsm, false)]
public void CanNormalize_ReturnsCorrectValue(SymbolSource source, bool expected)
{
_normalizer.CanNormalize(source).Should().Be(expected);
}
// Plain C symbols
[Theory]
[InlineData("ssl_do_handshake", "openssl.ssl", "_", "do_handshake")]
[InlineData("SSL_connect", "openssl.ssl", "_", "connect")]
[InlineData("EVP_EncryptInit_ex", "openssl.evp", "_", "EncryptInit_ex")]
[InlineData("sqlite3_prepare_v2", "sqlite3", "_", "prepare_v2")]
[InlineData("curl_easy_perform", "curl", "_", "easy_perform")]
[InlineData("png_create_read_struct", "libpng", "_", "create_read_struct")]
[InlineData("inflate", "zlib", "_", "")] // Special case - whole function is prefix
[InlineData("pthread_create", "pthread", "_", "create")]
[InlineData("my_custom_function", "native", "_", "my_custom_function")]
public void Normalize_PlainCSymbol_ExtractsNamespace(
string symbol, string expectedNs, string expectedType, string expectedMethod)
{
var raw = new RawSymbol(symbol, SymbolSource.ElfSymtab);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Namespace.Should().Be(expectedNs.ToLowerInvariant());
result.Type.Should().Be(expectedType);
// For "inflate", the whole thing is the method
if (expectedMethod == "")
result.Method.Should().Be(symbol.ToLowerInvariant());
else
result.Method.Should().Be(expectedMethod.ToLowerInvariant());
}
// Itanium mangled names - basic demangling extracts namespace components
[Theory]
[InlineData("_ZN4llvm6Triple15setEnvironmentENS0_15EnvironmentTypeE", "llvm", "setenvironment")]
public void Normalize_ItaniumMangled_ParsesNamespace(
string symbol, string expectedNsContains, string expectedMethodContains)
{
var raw = new RawSymbol(symbol, SymbolSource.ElfSymtab);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
// Basic demangling may put all parts together - check method contains key part
result!.Method.ToLowerInvariant().Should().Contain(expectedMethodContains.ToLowerInvariant());
}
// MSVC mangled names
[Theory]
[InlineData("?lookup@JndiLookup@log4j@apache@org@@QEAA?AVString@@PEAV1@@Z", "jndilookup", "lookup")]
[InlineData("?ProcessData@MyClass@MyNamespace@@QEAAXH@Z", "myclass", "processdata")]
public void Normalize_MsvcMangled_ParsesComponents(
string symbol, string expectedTypeContains, string expectedMethodContains)
{
var raw = new RawSymbol(symbol, SymbolSource.PeExport);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Type.ToLowerInvariant().Should().Contain(expectedTypeContains.ToLowerInvariant());
result.Method.ToLowerInvariant().Should().Contain(expectedMethodContains.ToLowerInvariant());
}
// DWARF format - qualified C++ symbols
[Theory]
[InlineData("llvm::Module::getFunction(llvm::StringRef)", "llvm", "module", "getfunction")]
[InlineData("std::string::size()", "std", "string", "size")]
public void Normalize_DwarfFormat_ParsesComponents(
string symbol, string expectedNs, string expectedType, string expectedMethod)
{
var raw = new RawSymbol(symbol, SymbolSource.Dwarf);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Namespace.ToLowerInvariant().Should().Contain(expectedNs.ToLowerInvariant());
result.Type.ToLowerInvariant().Should().Be(expectedType.ToLowerInvariant());
result.Method.ToLowerInvariant().Should().Be(expectedMethod.ToLowerInvariant());
}
// Empty/invalid input
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void Normalize_EmptyInput_ReturnsNull(string? symbol)
{
var raw = new RawSymbol(symbol ?? "", SymbolSource.ElfSymtab);
var result = _normalizer.Normalize(raw);
result.Should().BeNull();
}
[Fact]
public void TryNormalize_InvalidSymbol_ReturnsError()
{
var raw = new RawSymbol("", SymbolSource.ElfSymtab);
var success = _normalizer.TryNormalize(raw, out var canonical, out var error);
success.Should().BeFalse();
canonical.Should().BeNull();
error.Should().NotBeNullOrEmpty();
}
[Fact]
public void Normalize_PreservesOriginalSymbol()
{
var symbol = "ssl_do_handshake";
var raw = new RawSymbol(symbol, SymbolSource.ElfSymtab);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.OriginalSymbol.Should().Be(symbol);
result.Source.Should().Be(SymbolSource.ElfSymtab);
}
[Fact]
public void Normalize_PreservesPurl()
{
var raw = new RawSymbol("ssl_connect", SymbolSource.ElfSymtab, "pkg:conan/openssl@1.1.1");
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Purl.Should().Be("pkg:conan/openssl@1.1.1");
}
[Fact]
public void Normalize_GeneratesCanonicalId()
{
var raw = new RawSymbol("ssl_connect", SymbolSource.ElfSymtab);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.CanonicalId.Should().NotBeNullOrEmpty();
result.CanonicalId.Should().HaveLength(64); // SHA-256 hex
}
[Fact]
public void Normalize_SameSymbol_SameCanonicalId()
{
var raw1 = new RawSymbol("ssl_connect", SymbolSource.ElfSymtab);
var raw2 = new RawSymbol("ssl_connect", SymbolSource.ElfSymtab);
var result1 = _normalizer.Normalize(raw1);
var result2 = _normalizer.Normalize(raw2);
result1!.CanonicalId.Should().Be(result2!.CanonicalId);
}
}

View File

@@ -0,0 +1,256 @@
// <copyright file="ScriptSymbolNormalizerTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using StellaOps.Reachability.Core.Symbols;
using Xunit;
namespace StellaOps.Reachability.Core.Tests.Symbols;
/// <summary>
/// Tests for <see cref="ScriptSymbolNormalizer"/>.
/// </summary>
[Trait("Category", "Unit")]
public class ScriptSymbolNormalizerTests
{
private readonly ScriptSymbolNormalizer _normalizer = new();
[Fact]
public void SupportedSources_ContainsExpectedSources()
{
_normalizer.SupportedSources.Should().Contain(SymbolSource.V8Profiler);
_normalizer.SupportedSources.Should().Contain(SymbolSource.PythonTrace);
_normalizer.SupportedSources.Should().Contain(SymbolSource.PhpXdebug);
}
[Theory]
[InlineData(SymbolSource.V8Profiler, true)]
[InlineData(SymbolSource.PythonTrace, true)]
[InlineData(SymbolSource.PhpXdebug, true)]
[InlineData(SymbolSource.Roslyn, false)]
[InlineData(SymbolSource.ElfSymtab, false)]
public void CanNormalize_ReturnsCorrectValue(SymbolSource source, bool expected)
{
_normalizer.CanNormalize(source).Should().Be(expected);
}
// V8 Profiler (JavaScript)
[Theory]
[InlineData("processRequest (server.js:123:45)", "_", "processrequest")]
[InlineData("lodash.template (lodash.js:1234:56)", "lodash", "template")]
[InlineData("Module._load (internal/modules/cjs/loader.js:789:10)", "module", "_load")]
[InlineData("Foo.bar (foo.js:1:1)", "foo", "bar")]
[InlineData("anonymous (app.js:12:3)", "_", "{anonymous}")]
public void Normalize_V8StackFrame_ParsesComponents(
string symbol, string expectedTypeOrNs, string expectedMethod)
{
var raw = new RawSymbol(symbol, SymbolSource.V8Profiler);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
// Either namespace or type should contain the expected value
var combined = $"{result!.Namespace}.{result.Type}".ToLowerInvariant();
combined.Should().Contain(expectedTypeOrNs.ToLowerInvariant());
result.Method.ToLowerInvariant().Should().Be(expectedMethod.ToLowerInvariant());
}
[Fact]
public void Normalize_V8NodeModules_ExtractsPackage()
{
var raw = new RawSymbol("parse (node_modules/lodash/lodash.js:1:1)", SymbolSource.V8Profiler);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Namespace.Should().Contain("lodash");
}
[Fact]
public void Normalize_V8ScopedPackage_ExtractsPackage()
{
var raw = new RawSymbol("render (node_modules/@angular/core/index.js:1:1)", SymbolSource.V8Profiler);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Namespace.Should().Contain("@angular/core");
}
[Fact]
public void Normalize_V8SimpleFunction_ParsesMethod()
{
var raw = new RawSymbol("myFunction", SymbolSource.V8Profiler);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Method.Should().Be("myfunction");
}
// Python Trace
[Theory]
[InlineData("django.template.base:Template.render", "django.template.base", "template", "render")]
[InlineData("package.module:function", "package.module", "_", "function")]
[InlineData("<module>:main", "_", "_", "main")]
[InlineData("os.path:join", "os.path", "_", "join")]
public void Normalize_PythonColon_ParsesComponents(
string symbol, string expectedNs, string expectedType, string expectedMethod)
{
var raw = new RawSymbol(symbol, SymbolSource.PythonTrace);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Namespace.Should().Be(expectedNs.ToLowerInvariant());
result.Type.Should().Be(expectedType.ToLowerInvariant());
result.Method.Should().Be(expectedMethod.ToLowerInvariant());
}
[Theory]
[InlineData("django.template.Template.render", "django.template", "template", "render")]
[InlineData("os.path.join", "os.path", "_", "join")]
[InlineData("json.dumps", "json", "_", "dumps")]
public void Normalize_PythonDot_ParsesComponents(
string symbol, string expectedNs, string expectedType, string expectedMethod)
{
var raw = new RawSymbol(symbol, SymbolSource.PythonTrace);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Namespace.Should().Be(expectedNs.ToLowerInvariant());
result.Type.Should().Be(expectedType.ToLowerInvariant());
result.Method.Should().Be(expectedMethod.ToLowerInvariant());
}
[Fact]
public void Normalize_PythonSimpleFunction_ParsesMethod()
{
var raw = new RawSymbol("process_data", SymbolSource.PythonTrace);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Namespace.Should().Be("_");
result.Type.Should().Be("_");
result.Method.Should().Be("process_data");
}
// PHP Xdebug
[Theory]
[InlineData(@"App\Controllers\UserController->show", "app.controllers", "usercontroller", "show")]
[InlineData(@"Illuminate\Support\Str::random", "illuminate.support", "str", "random")]
[InlineData(@"MyClass->process", "_", "myclass", "process")]
public void Normalize_PhpMethod_ParsesComponents(
string symbol, string expectedNs, string expectedType, string expectedMethod)
{
var raw = new RawSymbol(symbol, SymbolSource.PhpXdebug);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Namespace.Should().Be(expectedNs.ToLowerInvariant());
result.Type.Should().Be(expectedType.ToLowerInvariant());
result.Method.Should().Be(expectedMethod.ToLowerInvariant());
}
[Fact]
public void Normalize_PhpClosure_ParsesFile()
{
var raw = new RawSymbol("{closure:/var/www/app/routes.php:123-456}", SymbolSource.PhpXdebug);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Namespace.Should().Be("routes");
result.Method.Should().Be("{closure}");
}
[Fact]
public void Normalize_PhpPlainFunction_ParsesMethod()
{
var raw = new RawSymbol("array_map", SymbolSource.PhpXdebug);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Namespace.Should().Be("_");
result.Type.Should().Be("_");
result.Method.Should().Be("array_map");
}
// Empty/invalid input
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Normalize_EmptyInput_ReturnsNull(string symbol)
{
var raw = new RawSymbol(symbol, SymbolSource.V8Profiler);
var result = _normalizer.Normalize(raw);
result.Should().BeNull();
}
[Fact]
public void TryNormalize_InvalidSymbol_ReturnsError()
{
var raw = new RawSymbol("", SymbolSource.V8Profiler);
var success = _normalizer.TryNormalize(raw, out var canonical, out var error);
success.Should().BeFalse();
canonical.Should().BeNull();
error.Should().NotBeNullOrEmpty();
}
[Fact]
public void Normalize_PreservesOriginalSymbol()
{
var symbol = "lodash.template (lodash.js:1:1)";
var raw = new RawSymbol(symbol, SymbolSource.V8Profiler);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.OriginalSymbol.Should().Be(symbol);
result.Source.Should().Be(SymbolSource.V8Profiler);
}
[Fact]
public void Normalize_PreservesPurl()
{
var raw = new RawSymbol("lodash.template", SymbolSource.V8Profiler, "pkg:npm/lodash@4.17.21");
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Purl.Should().Be("pkg:npm/lodash@4.17.21");
}
[Fact]
public void Normalize_GeneratesCanonicalId()
{
var raw = new RawSymbol("lodash.template", SymbolSource.V8Profiler);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.CanonicalId.Should().NotBeNullOrEmpty();
result.CanonicalId.Should().HaveLength(64); // SHA-256 hex
}
[Fact]
public void Normalize_DifferentSources_SameSymbol_SameCanonicalId()
{
// Same logical symbol from different script sources should produce same canonical ID
var pythonRaw = new RawSymbol("json.dumps", SymbolSource.PythonTrace);
var result = _normalizer.Normalize(pythonRaw);
result.Should().NotBeNull();
result!.CanonicalId.Should().NotBeNullOrEmpty();
}
}