sprints work
This commit is contained in:
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user